From 94709435dff9af11e726d9fa830354336f0c27a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Apr 2026 01:12:11 +0000 Subject: [PATCH 01/15] Add Convex media storage sync pipeline Co-authored-by: Dara Adedeji --- convex/images.ts | 294 +++++++-- convex/ops.ts | 2 + convex/pages.ts | 9 +- convex/schema.ts | 11 +- convex/strategies.ts | 9 +- lib/collab/cloud_media_models.dart | 149 +++++ lib/collab/collab_models.dart | 50 ++ lib/collab/convex_strategy_repository.dart | 64 ++ lib/const/hive_boxes.dart | 1 + lib/hive/hive_adapters.dart | 4 + lib/hive/hive_adapters.g.dart | 154 +++++ lib/hive/hive_adapters.g.yaml | 54 +- lib/hive/hive_registrar.g.dart | 6 + lib/main.dart | 6 + .../active_page_live_sync_provider.dart | 7 +- .../collab/cloud_media_cache_provider.dart | 218 +++++++ .../cloud_media_upload_queue_provider.dart | 496 +++++++++++++++ .../remote_strategy_snapshot_provider.dart | 14 + lib/providers/image_provider.dart | 92 ++- lib/providers/strategy_provider.dart | 30 +- .../strategy_save_state_provider.dart | 50 +- lib/services/unsaved_strategy_guard.dart | 13 +- lib/strategy/strategy_cloud_migration.dart | 5 +- lib/strategy/strategy_import_export.dart | 53 ++ lib/strategy/strategy_page_source.dart | 25 +- lib/widgets/dialogs/create_lineup_dialog.dart | 40 ++ .../dialogs/strategy/line_up_media_page.dart | 30 +- lib/widgets/image_drop_target.dart | 5 +- lib/widgets/line_up_media_carousel.dart | 24 +- lib/widgets/strategy_save_icon_button.dart | 27 +- package-lock.json | 597 ++++++++++++++++++ test/strategy_page_session_provider_test.dart | 1 + 32 files changed, 2411 insertions(+), 129 deletions(-) create mode 100644 lib/collab/cloud_media_models.dart create mode 100644 lib/providers/collab/cloud_media_cache_provider.dart create mode 100644 lib/providers/collab/cloud_media_upload_queue_provider.dart create mode 100644 package-lock.json diff --git a/convex/images.ts b/convex/images.ts index 88b5be31..89aad317 100644 --- a/convex/images.ts +++ b/convex/images.ts @@ -1,20 +1,211 @@ -import { mutation, query } from "./_generated/server"; -import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; import { assertStrategyRole, requireCurrentUser } from "./lib/auth"; import { getElementByPublicId, + getLineupByPublicId, getPageByPublicId, getStrategyByPublicId, } from "./lib/entities"; -export const registerAssetRef = mutation({ +type AnyCtx = MutationCtx | QueryCtx; +type ImageAssetOwnerType = "element" | "lineup"; + +function normalizeOwnerType( + asset: Pick, "ownerType">, +): ImageAssetOwnerType { + return asset.ownerType ?? "element"; +} + +function ownerNotFoundError( + ownerType: ImageAssetOwnerType, + ownerPublicId: string, +): ConvexError<{ + code: "OWNER_NOT_FOUND"; + message: "owner_not_found"; + ownerType: ImageAssetOwnerType; + ownerPublicId: string; +}> { + return new ConvexError({ + code: "OWNER_NOT_FOUND", + message: "owner_not_found", + ownerType, + ownerPublicId, + }); +} + +async function getImageAssetByPublicId( + ctx: AnyCtx, + assetPublicId: string, +): Promise | null> { + return await ctx.db + .query("imageAssets") + .withIndex("by_publicId", (q) => q.eq("publicId", assetPublicId)) + .unique(); +} + +function inferFileExtension( + asset: Pick, "fileExtension" | "storagePath">, +): string { + if (asset.fileExtension !== undefined && asset.fileExtension.length > 0) { + return asset.fileExtension; + } + + const legacyPath = asset.storagePath ?? ""; + const match = legacyPath.match(/(\.[A-Za-z0-9]+)(?:$|[?#])/); + return match?.[1]?.toLowerCase() ?? ""; +} + +async function resolveOwnerAttachment( + ctx: MutationCtx, + args: { + strategyId: Id<"strategies">; + pageId: Id<"pages">; + ownerType: ImageAssetOwnerType; + ownerPublicId: string; + }, +): Promise<{ + elementId: Id<"elements"> | undefined; + lineupId: Id<"lineups"> | undefined; +}> { + if (args.ownerType === "element") { + try { + const element = await getElementByPublicId(ctx, args.ownerPublicId); + if (element.strategyId !== args.strategyId || element.pageId !== args.pageId) { + throw new Error("Element context mismatch"); + } + return { + elementId: element._id, + lineupId: undefined, + }; + } catch (error) { + if (error instanceof Error && error.message.startsWith("Element not found:")) { + throw ownerNotFoundError(args.ownerType, args.ownerPublicId); + } + throw error; + } + } + + try { + const lineup = await getLineupByPublicId(ctx, args.ownerPublicId); + if (lineup.strategyId !== args.strategyId || lineup.pageId !== args.pageId) { + throw new Error("Lineup context mismatch"); + } + return { + elementId: undefined, + lineupId: lineup._id, + }; + } catch (error) { + if (error instanceof Error && error.message.startsWith("Lineup not found:")) { + throw ownerNotFoundError(args.ownerType, args.ownerPublicId); + } + throw error; + } +} + +async function serializeAssetForViewer( + ctx: QueryCtx, + strategyPublicId: string, + asset: Doc<"imageAssets">, +): Promise<{ + publicId: string; + strategyPublicId: string; + pagePublicId: string; + ownerType: ImageAssetOwnerType; + ownerPublicId: string; + fileExtension: string; + mimeType: string; + width: number | null; + height: number | null; + url: string | null; + legacyStoragePath: string | null; +} | null> { + const page = await ctx.db.get(asset.pageId); + if (page === null) { + return null; + } + + const ownerType = normalizeOwnerType(asset); + const ownerDoc = ownerType === "lineup" + ? asset.lineupId === undefined + ? null + : await ctx.db.get(asset.lineupId) + : asset.elementId === undefined + ? null + : await ctx.db.get(asset.elementId); + + if (ownerDoc === null) { + return null; + } + + const url = asset.storageId === undefined + ? null + : await ctx.storage.getUrl(asset.storageId); + + return { + publicId: asset.publicId, + strategyPublicId, + pagePublicId: page.publicId, + ownerType, + ownerPublicId: ownerDoc.publicId, + fileExtension: inferFileExtension(asset), + mimeType: asset.mimeType, + width: asset.width ?? null, + height: asset.height ?? null, + url, + legacyStoragePath: asset.storagePath ?? null, + }; +} + +export async function deleteImageAsset( + ctx: MutationCtx, + asset: Doc<"imageAssets">, +): Promise { + if (asset.storageId !== undefined) { + await ctx.storage.delete(asset.storageId); + } + await ctx.db.delete(asset._id); +} + +export async function deleteImageAssetsForPage( + ctx: MutationCtx, + pageId: Id<"pages">, +): Promise { + const assets = await ctx.db + .query("imageAssets") + .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .collect(); + + for (const asset of assets) { + await deleteImageAsset(ctx, asset); + } +} + +export const generateUploadUrl = mutation({ + args: { + strategyPublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + return { + uploadUrl: await ctx.storage.generateUploadUrl(), + }; + }, +}); + +export const completeUpload = mutation({ args: { strategyPublicId: v.string(), pagePublicId: v.string(), assetPublicId: v.string(), - elementPublicId: v.optional(v.string()), - storagePath: v.string(), + ownerType: v.union(v.literal("element"), v.literal("lineup")), + ownerPublicId: v.string(), + storageId: v.id("_storage"), mimeType: v.string(), + fileExtension: v.string(), width: v.optional(v.number()), height: v.optional(v.number()), }, @@ -27,44 +218,49 @@ export const registerAssetRef = mutation({ throw new Error("Page strategy mismatch"); } - let elementId; - if (args.elementPublicId !== undefined) { - const element = await getElementByPublicId(ctx, args.elementPublicId); - if (element.strategyId !== strategy._id || element.pageId !== page._id) { - throw new Error("Element context mismatch"); - } - elementId = element._id; - } - - const existing = await ctx.db - .query("imageAssets") - .withIndex("by_publicId", (q) => q.eq("publicId", args.assetPublicId)) - .first(); + const { elementId, lineupId } = await resolveOwnerAttachment(ctx, { + strategyId: strategy._id, + pageId: page._id, + ownerType: args.ownerType, + ownerPublicId: args.ownerPublicId, + }); + const existing = await getImageAssetByPublicId(ctx, args.assetPublicId); + const now = Date.now(); if (existing === null) { await ctx.db.insert("imageAssets", { publicId: args.assetPublicId, strategyId: strategy._id, pageId: page._id, + ownerType: args.ownerType, elementId, - storagePath: args.storagePath, + lineupId, + storageId: args.storageId, + fileExtension: args.fileExtension, mimeType: args.mimeType, width: args.width, height: args.height, createdByUserId: user._id, - createdAt: Date.now(), - updatedAt: Date.now(), + createdAt: now, + updatedAt: now, }); } else { + if (existing.strategyId !== strategy._id) { + throw new Error("Asset strategy mismatch"); + } + await ctx.db.patch(existing._id, { strategyId: strategy._id, pageId: page._id, + ownerType: args.ownerType, elementId, - storagePath: args.storagePath, + lineupId, + storageId: args.storageId, + fileExtension: args.fileExtension, mimeType: args.mimeType, width: args.width, height: args.height, - updatedAt: Date.now(), + updatedAt: now, }); } @@ -85,17 +281,32 @@ export const listForStrategy = query({ .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) .collect(); - return assets.map((asset) => ({ - publicId: asset.publicId, - storagePath: asset.storagePath, - mimeType: asset.mimeType, - width: asset.width ?? null, - height: asset.height ?? null, - pageId: asset.pageId, - elementId: asset.elementId ?? null, - createdAt: asset.createdAt, - updatedAt: asset.updatedAt, - })); + const serialized = await Promise.all( + assets.map((asset) => serializeAssetForViewer(ctx, strategy.publicId, asset)), + ); + + return serialized.filter((asset) => asset !== null); + }, +}); + +export const getAssetUrl = query({ + args: { + strategyPublicId: v.string(), + assetPublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + + const asset = await getImageAssetByPublicId(ctx, args.assetPublicId); + if (asset === null || asset.strategyId !== strategy._id) { + throw new Error("Asset not found"); + } + + return { + url: + asset.storageId === undefined ? null : await ctx.storage.getUrl(asset.storageId), + }; }, }); @@ -108,16 +319,13 @@ export const deleteAssetRef = mutation({ const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - const asset = await ctx.db - .query("imageAssets") - .withIndex("by_publicId", (q) => q.eq("publicId", args.assetPublicId)) - .first(); + const asset = await getImageAssetByPublicId(ctx, args.assetPublicId); if (asset === null || asset.strategyId !== strategy._id) { throw new Error("Asset not found"); } - await ctx.db.delete(asset._id); + await deleteImageAsset(ctx, asset); return { ok: true }; }, }); @@ -136,6 +344,12 @@ export const listPotentiallyStale = query({ .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) .collect(); - return assets.filter((asset) => asset.createdByUserId === user._id); + const serialized = await Promise.all( + assets + .filter((asset) => asset.createdByUserId === user._id) + .map((asset) => serializeAssetForViewer(ctx, strategy.publicId, asset)), + ); + + return serialized.filter((asset) => asset !== null); }, }); diff --git a/convex/ops.ts b/convex/ops.ts index 27216d23..9f739522 100644 --- a/convex/ops.ts +++ b/convex/ops.ts @@ -1,5 +1,6 @@ import { mutation } from "./_generated/server"; import { v } from "convex/values"; +import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole } from "./lib/auth"; import { getElementByPublicId, @@ -225,6 +226,7 @@ export const applyBatch = mutation({ await ctx.db.delete(lineup._id); } + await deleteImageAssetsForPage(ctx, page._id); await ctx.db.delete(page._id); appliedRevision = page.revision + 1; strategy = await incrementSequence(ctx, strategy); diff --git a/convex/pages.ts b/convex/pages.ts index daa465e6..907cc667 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -1,5 +1,6 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole } from "./lib/auth"; import { getPageByPublicId, @@ -175,13 +176,7 @@ export const deletePage = mutation({ await ctx.db.delete(lineup._id); } - const assets = await ctx.db - .query("imageAssets") - .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) - .collect(); - for (const asset of assets) { - await ctx.db.delete(asset._id); - } + await deleteImageAssetsForPage(ctx, page._id); await ctx.db.delete(page._id); diff --git a/convex/schema.ts b/convex/schema.ts index 4ee76e00..47b11dc8 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -111,8 +111,14 @@ export default defineSchema({ publicId: v.string(), strategyId: v.id("strategies"), pageId: v.id("pages"), + // Legacy rows predate ownerType and are treated as element-owned. + ownerType: v.optional(v.union(v.literal("element"), v.literal("lineup"))), elementId: v.optional(v.id("elements")), - storagePath: v.string(), + lineupId: v.optional(v.id("lineups")), + storageId: v.optional(v.id("_storage")), + // Legacy rows predate fileExtension and may need best-effort inference. + fileExtension: v.optional(v.string()), + storagePath: v.optional(v.string()), mimeType: v.string(), width: v.optional(v.number()), height: v.optional(v.number()), @@ -122,7 +128,8 @@ export default defineSchema({ }) .index("by_publicId", ["publicId"]) .index("by_strategyId", ["strategyId"]) - .index("by_pageId", ["pageId"]), + .index("by_pageId", ["pageId"]) + .index("by_lineupId", ["lineupId"]), operationEvents: defineTable({ strategyId: v.id("strategies"), pageId: v.optional(v.id("pages")), diff --git a/convex/strategies.ts b/convex/strategies.ts index 5444e57e..f2889b02 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -1,5 +1,6 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole, requireCurrentUser } from "./lib/auth"; import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; @@ -274,13 +275,7 @@ export const deleteStrategy = mutation({ await ctx.db.delete(lineup._id); } - const assets = await ctx.db - .query("imageAssets") - .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) - .collect(); - for (const asset of assets) { - await ctx.db.delete(asset._id); - } + await deleteImageAssetsForPage(ctx, page._id); await ctx.db.delete(page._id); } diff --git a/lib/collab/cloud_media_models.dart b/lib/collab/cloud_media_models.dart new file mode 100644 index 00000000..ccb8e32e --- /dev/null +++ b/lib/collab/cloud_media_models.dart @@ -0,0 +1,149 @@ +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; + +enum CloudMediaOwnerType { element, lineup } + +enum CloudMediaJobState { pendingUpload, pendingAttach, failed } + +String normalizeImageExtension(String extension) { + if (extension.isEmpty) { + return extension; + } + return extension.startsWith('.') ? extension.toLowerCase() : '.${extension.toLowerCase()}'; +} + +String mimeTypeForImageExtension(String extension) { + switch (normalizeImageExtension(extension)) { + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.bmp': + return 'image/bmp'; + default: + return 'application/octet-stream'; + } +} + +class CloudMediaUploadJob extends HiveObject { + CloudMediaUploadJob({ + required this.jobId, + required this.strategyPublicId, + required this.pagePublicId, + required this.ownerType, + required this.ownerPublicId, + required this.assetPublicId, + required this.fileExtension, + required this.mimeType, + required this.state, + required this.attempts, + required this.updatedAt, + this.width, + this.height, + this.storageId, + this.lastError, + }); + + final String jobId; + final String strategyPublicId; + final String pagePublicId; + final CloudMediaOwnerType ownerType; + final String ownerPublicId; + final String assetPublicId; + final String fileExtension; + final String mimeType; + final int? width; + final int? height; + final String? storageId; + final CloudMediaJobState state; + final int attempts; + final String? lastError; + final DateTime updatedAt; + + bool get isFailed => state == CloudMediaJobState.failed; + + CloudMediaUploadJob copyWith({ + String? jobId, + String? strategyPublicId, + String? pagePublicId, + CloudMediaOwnerType? ownerType, + String? ownerPublicId, + String? assetPublicId, + String? fileExtension, + String? mimeType, + int? width, + int? height, + Object? storageId = _noChange, + CloudMediaJobState? state, + int? attempts, + Object? lastError = _noChange, + DateTime? updatedAt, + }) { + return CloudMediaUploadJob( + jobId: jobId ?? this.jobId, + strategyPublicId: strategyPublicId ?? this.strategyPublicId, + pagePublicId: pagePublicId ?? this.pagePublicId, + ownerType: ownerType ?? this.ownerType, + ownerPublicId: ownerPublicId ?? this.ownerPublicId, + assetPublicId: assetPublicId ?? this.assetPublicId, + fileExtension: fileExtension ?? this.fileExtension, + mimeType: mimeType ?? this.mimeType, + width: width ?? this.width, + height: height ?? this.height, + storageId: + identical(storageId, _noChange) ? this.storageId : storageId as String?, + state: state ?? this.state, + attempts: attempts ?? this.attempts, + lastError: + identical(lastError, _noChange) ? this.lastError : lastError as String?, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +Map cloudImagePayloadFromPlacedImage(PlacedImage image) { + final payload = Map.from(image.toJson()); + payload['link'] = ''; + return payload; +} + +Map cloudLineupPayload(LineUp lineup) { + return { + ...lineup.toJson(), + 'images': [ + for (final image in lineup.images) image.toJson(), + ], + }; +} + +Set collectStrategyImageAssetIds(StrategyDataLike strategy) { + final assetIds = {}; + for (final page in strategy.pages) { + for (final image in page.imageData) { + assetIds.add(image.id); + } + for (final lineup in page.lineUps) { + for (final image in lineup.images) { + assetIds.add(image.id); + } + } + } + return assetIds; +} + +abstract class StrategyDataLike { + Iterable get pages; +} + +abstract class StrategyPageLike { + Iterable get imageData; + Iterable get lineUps; +} + +const _noChange = Object(); diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index 1090468e..46d24fca 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:icarus/collab/cloud_media_models.dart'; + enum StrategyOpKind { add, move, patch, delete, reorder } enum StrategyOpEntityType { strategy, page, element, lineup } @@ -290,18 +292,66 @@ class RemoteLineup { } } +class RemoteImageAsset { + const RemoteImageAsset({ + required this.publicId, + required this.strategyPublicId, + required this.pagePublicId, + required this.ownerType, + required this.ownerPublicId, + required this.fileExtension, + required this.mimeType, + required this.width, + required this.height, + required this.url, + required this.legacyStoragePath, + }); + + final String publicId; + final String strategyPublicId; + final String pagePublicId; + final CloudMediaOwnerType ownerType; + final String ownerPublicId; + final String fileExtension; + final String mimeType; + final int? width; + final int? height; + final String? url; + final String? legacyStoragePath; + + factory RemoteImageAsset.fromJson(Map json) { + return RemoteImageAsset( + publicId: json['publicId'] as String, + strategyPublicId: json['strategyPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + ownerType: (json['ownerType'] as String?) == 'lineup' + ? CloudMediaOwnerType.lineup + : CloudMediaOwnerType.element, + ownerPublicId: json['ownerPublicId'] as String, + fileExtension: json['fileExtension'] as String? ?? '', + mimeType: json['mimeType'] as String, + width: (json['width'] as num?)?.toInt(), + height: (json['height'] as num?)?.toInt(), + url: json['url'] as String?, + legacyStoragePath: json['legacyStoragePath'] as String?, + ); + } +} + class RemoteStrategySnapshot { const RemoteStrategySnapshot({ 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; + final Map assetsById; } class CloudStrategySummary { diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index c2ab6e28..368a160b 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:convex_flutter/convex_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; final convexStrategyRepositoryProvider = Provider( (ref) => ConvexStrategyRepository(ConvexClient.instance), @@ -262,6 +263,15 @@ class ConvexStrategyRepository { final elementsByPage = >{}; final lineupsByPage = >{}; + final assetsRaw = await _client.query('images:listForStrategy', { + 'strategyPublicId': strategyPublicId, + }); + final assets = _decodeObjectList(assetsRaw) + .map(RemoteImageAsset.fromJson) + .toList(growable: false); + final assetsById = { + for (final asset in assets) asset.publicId: asset, + }; for (final page in pages) { final elementsRaw = await _client.query('elements:listForPage', { @@ -290,7 +300,61 @@ class ConvexStrategyRepository { pages: pages, elementsByPage: elementsByPage, lineupsByPage: lineupsByPage, + assetsById: assetsById, + ); + } + + Future generateImageUploadUrl(String strategyPublicId) async { + final response = await _client.mutation( + name: 'images:generateUploadUrl', + args: { + 'strategyPublicId': strategyPublicId, + }, + ); + return (_decodeObject(response)['uploadUrl'] as String?) ?? ''; + } + + Future completeImageUpload({ + required String strategyPublicId, + required String pagePublicId, + required String assetPublicId, + required CloudMediaOwnerType ownerType, + required String ownerPublicId, + required String storageId, + required String mimeType, + required String fileExtension, + int? width, + int? height, + }) async { + await _client.mutation( + name: 'images:completeUpload', + args: { + 'strategyPublicId': strategyPublicId, + 'pagePublicId': pagePublicId, + 'assetPublicId': assetPublicId, + 'ownerType': ownerType.name, + 'ownerPublicId': ownerPublicId, + 'storageId': storageId, + 'mimeType': mimeType, + 'fileExtension': fileExtension, + if (width != null) 'width': width, + if (height != null) 'height': height, + }, + ); + } + + Future getImageAssetUrl({ + required String strategyPublicId, + required String assetPublicId, + }) async { + final response = await _client.query( + 'images:getAssetUrl', + { + 'strategyPublicId': strategyPublicId, + 'assetPublicId': assetPublicId, + }, ); + return _decodeObject(response)['url'] as String?; } Future> applyBatch({ diff --git a/lib/const/hive_boxes.dart b/lib/const/hive_boxes.dart index b1460824..53e6fc1a 100644 --- a/lib/const/hive_boxes.dart +++ b/lib/const/hive_boxes.dart @@ -1,6 +1,7 @@ class HiveBoxNames { static const strategiesBox = "strategy_box"; static const foldersBox = "folder_box"; + static const mediaUploadJobsBox = "media_upload_jobs_box"; static const mapThemeProfilesBox = "map_theme_profiles_box"; static const appPreferencesBox = "app_preferences_box"; static const favoriteAgentsBox = "favorite_agents_box"; diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 8d0d0c02..5522cc7a 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -5,6 +5,7 @@ import 'dart:ui' show Offset; import 'package:flutter/material.dart'; import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/bounding_box.dart'; import 'package:icarus/const/drawing_element.dart'; @@ -42,6 +43,9 @@ import 'package:icarus/strategy/strategy_models.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), AdapterSpec(), AdapterSpec(), AdapterSpec(), diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index 28e96733..b3a4f709 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1708,3 +1708,157 @@ class AbilityVisualStateAdapter extends TypeAdapter { runtimeType == other.runtimeType && typeId == other.typeId; } + +class CloudMediaOwnerTypeAdapter extends TypeAdapter { + @override + final typeId = 33; + + @override + CloudMediaOwnerType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return CloudMediaOwnerType.element; + case 1: + return CloudMediaOwnerType.lineup; + default: + return CloudMediaOwnerType.element; + } + } + + @override + void write(BinaryWriter writer, CloudMediaOwnerType obj) { + switch (obj) { + case CloudMediaOwnerType.element: + writer.writeByte(0); + case CloudMediaOwnerType.lineup: + writer.writeByte(1); + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CloudMediaOwnerTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class CloudMediaJobStateAdapter extends TypeAdapter { + @override + final typeId = 34; + + @override + CloudMediaJobState read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return CloudMediaJobState.pendingUpload; + case 1: + return CloudMediaJobState.pendingAttach; + case 2: + return CloudMediaJobState.failed; + default: + return CloudMediaJobState.pendingUpload; + } + } + + @override + void write(BinaryWriter writer, CloudMediaJobState obj) { + switch (obj) { + case CloudMediaJobState.pendingUpload: + writer.writeByte(0); + case CloudMediaJobState.pendingAttach: + writer.writeByte(1); + case CloudMediaJobState.failed: + writer.writeByte(2); + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CloudMediaJobStateAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class CloudMediaUploadJobAdapter extends TypeAdapter { + @override + final typeId = 35; + + @override + CloudMediaUploadJob read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CloudMediaUploadJob( + jobId: fields[0] as String, + strategyPublicId: fields[1] as String, + pagePublicId: fields[2] as String, + ownerType: fields[3] as CloudMediaOwnerType, + ownerPublicId: fields[4] as String, + assetPublicId: fields[5] as String, + fileExtension: fields[6] as String, + mimeType: fields[7] as String, + state: fields[11] as CloudMediaJobState, + attempts: (fields[12] as num).toInt(), + updatedAt: fields[14] as DateTime, + width: (fields[8] as num?)?.toInt(), + height: (fields[9] as num?)?.toInt(), + storageId: fields[10] as String?, + lastError: fields[13] as String?, + ); + } + + @override + void write(BinaryWriter writer, CloudMediaUploadJob obj) { + writer + ..writeByte(15) + ..writeByte(0) + ..write(obj.jobId) + ..writeByte(1) + ..write(obj.strategyPublicId) + ..writeByte(2) + ..write(obj.pagePublicId) + ..writeByte(3) + ..write(obj.ownerType) + ..writeByte(4) + ..write(obj.ownerPublicId) + ..writeByte(5) + ..write(obj.assetPublicId) + ..writeByte(6) + ..write(obj.fileExtension) + ..writeByte(7) + ..write(obj.mimeType) + ..writeByte(8) + ..write(obj.width) + ..writeByte(9) + ..write(obj.height) + ..writeByte(10) + ..write(obj.storageId) + ..writeByte(11) + ..write(obj.state) + ..writeByte(12) + ..write(obj.attempts) + ..writeByte(13) + ..write(obj.lastError) + ..writeByte(14) + ..write(obj.updatedAt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CloudMediaUploadJobAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index cc067d37..0d0e40a5 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -1,7 +1,7 @@ # Generated by Hive CE # Manual modifications may be necessary for certain migrations # Check in to version control -nextTypeId: 33 +nextTypeId: 36 types: StrategyData: typeId: 0 @@ -585,3 +585,55 @@ types: index: 4 showInnerFill: index: 5 + CloudMediaOwnerType: + typeId: 33 + nextIndex: 2 + fields: + element: + index: 0 + lineup: + index: 1 + CloudMediaJobState: + typeId: 34 + nextIndex: 3 + fields: + pendingUpload: + index: 0 + pendingAttach: + index: 1 + failed: + index: 2 + CloudMediaUploadJob: + typeId: 35 + nextIndex: 15 + fields: + jobId: + index: 0 + strategyPublicId: + index: 1 + pagePublicId: + index: 2 + ownerType: + index: 3 + ownerPublicId: + index: 4 + assetPublicId: + index: 5 + fileExtension: + index: 6 + mimeType: + index: 7 + width: + index: 8 + height: + index: 9 + storageId: + index: 10 + state: + index: 11 + attempts: + index: 12 + lastError: + index: 13 + updatedAt: + index: 14 diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index 6ad54fa5..71a7e173 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -14,6 +14,9 @@ extension HiveRegistrar on HiveInterface { registerAdapter(AgentTypeAdapter()); registerAdapter(AppPreferencesAdapter()); registerAdapter(BoundingBoxAdapter()); + registerAdapter(CloudMediaJobStateAdapter()); + registerAdapter(CloudMediaOwnerTypeAdapter()); + registerAdapter(CloudMediaUploadJobAdapter()); registerAdapter(EllipseDrawingAdapter()); registerAdapter(FolderColorAdapter()); registerAdapter(FreeDrawingAdapter()); @@ -50,6 +53,9 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(AgentTypeAdapter()); registerAdapter(AppPreferencesAdapter()); registerAdapter(BoundingBoxAdapter()); + registerAdapter(CloudMediaJobStateAdapter()); + registerAdapter(CloudMediaOwnerTypeAdapter()); + registerAdapter(CloudMediaUploadJobAdapter()); registerAdapter(EllipseDrawingAdapter()); registerAdapter(FolderColorAdapter()); registerAdapter(FreeDrawingAdapter()); diff --git a/lib/main.dart b/lib/main.dart index 5af6387f..af9e4119 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/services/deep_link_registrar.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -24,6 +25,8 @@ import 'package:icarus/const/second_instance_args.dart'; import 'package:icarus/const/settings.dart' show Settings; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; @@ -134,6 +137,7 @@ Future main(List args) async { await Hive.openBox(HiveBoxNames.strategiesBox); await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.mediaUploadJobsBox); await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); await Hive.openBox(HiveBoxNames.appPreferencesBox); await Hive.openBox(HiveBoxNames.favoriteAgentsBox); @@ -358,6 +362,8 @@ class _MyAppState extends ConsumerState { void initState() { super.initState(); ref.read(authProvider); + ref.read(cloudMediaUploadQueueProvider); + ref.read(cloudMediaCacheProvider); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(warmUpWebViewEnvironment()); diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index 0fd46539..1cdc7687 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; @@ -439,7 +440,7 @@ class ActivePageLiveSyncNotifier extends Notifier { entities[key] = _NormalizedEntity( key: key, overlayEntityType: ActivePageOverlayEntityType.lineup, - payload: jsonEncode(lineup.toJson()), + payload: jsonEncode(cloudLineupPayload(lineup)), sortIndex: index, revision: 0, deleted: false, @@ -499,7 +500,9 @@ class ActivePageLiveSyncNotifier extends Notifier { } for (final image in ref.read(placedImageProvider).images) { - final payload = Map.from(image.toJson()) + final payload = Map.from( + cloudImagePayloadFromPlacedImage(image), + ) ..putIfAbsent('elementType', () => 'image'); envelopes.add( _CollabElementEnvelope( diff --git a/lib/providers/collab/cloud_media_cache_provider.dart b/lib/providers/collab/cloud_media_cache_provider.dart new file mode 100644 index 00000000..1526dec9 --- /dev/null +++ b/lib/providers/collab/cloud_media_cache_provider.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/image_provider.dart'; + +class CloudMediaCacheState { + const CloudMediaCacheState({ + this.strategyPublicId, + this.cachedAssetIds = const {}, + this.inFlightAssetIds = const {}, + this.lastErrorByAssetId = const {}, + }); + + final String? strategyPublicId; + final Set cachedAssetIds; + final Set inFlightAssetIds; + final Map lastErrorByAssetId; + + CloudMediaCacheState copyWith({ + String? strategyPublicId, + bool clearStrategyPublicId = false, + Set? cachedAssetIds, + Set? inFlightAssetIds, + Map? lastErrorByAssetId, + }) { + return CloudMediaCacheState( + strategyPublicId: + clearStrategyPublicId ? null : (strategyPublicId ?? this.strategyPublicId), + cachedAssetIds: cachedAssetIds ?? this.cachedAssetIds, + inFlightAssetIds: inFlightAssetIds ?? this.inFlightAssetIds, + lastErrorByAssetId: lastErrorByAssetId ?? this.lastErrorByAssetId, + ); + } +} + +final cloudMediaCacheProvider = + NotifierProvider( + CloudMediaCacheNotifier.new, +); + +class CloudMediaCacheNotifier extends Notifier { + @override + CloudMediaCacheState build() { + return const CloudMediaCacheState(); + } + + Future localAssetPath({ + required String strategyId, + required String assetId, + required String fileExtension, + }) async { + final imageFolder = await PlacedImageProvider.getImageFolder(strategyId); + return PlacedImageProvider.buildImageFilePath( + imageFolder.path, + assetId, + fileExtension, + ); + } + + Future localFileForAsset({ + required String strategyId, + required RemoteImageAsset asset, + }) async { + final file = File( + await localAssetPath( + strategyId: strategyId, + assetId: asset.publicId, + fileExtension: asset.fileExtension, + ), + ); + if (await file.exists()) { + _markCached(asset.publicId); + return file; + } + return null; + } + + Future ensureAssetsCached({ + required String strategyId, + required String strategyPublicId, + required Iterable assets, + }) async { + final uniqueAssets = { + for (final asset in assets) asset.publicId: asset, + }.values.toList(growable: false); + for (final asset in uniqueAssets) { + await ensureAssetCached( + strategyId: strategyId, + strategyPublicId: strategyPublicId, + asset: asset, + ); + } + } + + Future ensureAssetCached({ + required String strategyId, + required String strategyPublicId, + required RemoteImageAsset asset, + }) async { + final existing = await localFileForAsset(strategyId: strategyId, asset: asset); + if (existing != null) { + return existing; + } + + if (asset.url == null || asset.url!.isEmpty) { + _recordError(asset.publicId, 'Missing remote asset URL.'); + return null; + } + + if (state.inFlightAssetIds.contains(asset.publicId)) { + return null; + } + + _markInFlight(asset.publicId, strategyPublicId); + try { + var response = await http.get(Uri.parse(asset.url!)); + if (_shouldRefreshSignedUrl(response.statusCode)) { + final refreshed = await ref + .read(convexStrategyRepositoryProvider) + .getImageAssetUrl( + strategyPublicId: strategyPublicId, + assetPublicId: asset.publicId, + ); + if (refreshed != null && refreshed.isNotEmpty) { + response = await http.get(Uri.parse(refreshed)); + } + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + _recordError( + asset.publicId, + 'Failed to cache asset (${response.statusCode}).', + ); + return null; + } + + final output = File( + await localAssetPath( + strategyId: strategyId, + assetId: asset.publicId, + fileExtension: asset.fileExtension, + ), + ); + await output.parent.create(recursive: true); + await output.writeAsBytes(response.bodyBytes, flush: true); + _markCached(asset.publicId); + return output; + } catch (error) { + _recordError(asset.publicId, '$error'); + return null; + } finally { + _clearInFlight(asset.publicId); + } + } + + Future ensureAssetIdsCached({ + required String strategyId, + required String strategyPublicId, + required Map assetsById, + required Iterable assetIds, + }) async { + for (final assetId in assetIds.toSet()) { + final asset = assetsById[assetId]; + if (asset == null) { + return false; + } + final file = await ensureAssetCached( + strategyId: strategyId, + strategyPublicId: strategyPublicId, + asset: asset, + ); + if (file == null || !await file.exists()) { + return false; + } + } + return true; + } + + bool _shouldRefreshSignedUrl(int statusCode) { + return statusCode == 401 || statusCode == 403 || statusCode == 404; + } + + void resetStrategy(String? strategyPublicId) { + state = CloudMediaCacheState(strategyPublicId: strategyPublicId); + } + + void _markCached(String assetId) { + final cached = {...state.cachedAssetIds, assetId}; + final errors = Map.from(state.lastErrorByAssetId) + ..remove(assetId); + state = state.copyWith( + cachedAssetIds: cached, + lastErrorByAssetId: errors, + ); + } + + void _markInFlight(String assetId, String strategyPublicId) { + state = state.copyWith( + strategyPublicId: strategyPublicId, + inFlightAssetIds: {...state.inFlightAssetIds, assetId}, + ); + } + + void _clearInFlight(String assetId) { + final next = {...state.inFlightAssetIds}..remove(assetId); + state = state.copyWith(inFlightAssetIds: next); + } + + void _recordError(String assetId, String error) { + final errors = Map.from(state.lastErrorByAssetId) + ..[assetId] = error; + state = state.copyWith(lastErrorByAssetId: errors); + } +} diff --git a/lib/providers/collab/cloud_media_upload_queue_provider.dart b/lib/providers/collab/cloud_media_upload_queue_provider.dart new file mode 100644 index 00000000..9a056086 --- /dev/null +++ b/lib/providers/collab/cloud_media_upload_queue_provider.dart @@ -0,0 +1,496 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:convex_flutter/convex_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:http/http.dart' as http; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class CloudMediaUploadQueueState { + const CloudMediaUploadQueueState({ + required this.jobs, + required this.isProcessing, + }); + + final List jobs; + final bool isProcessing; + + List jobsForStrategy(String? strategyPublicId) { + if (strategyPublicId == null) { + return const []; + } + return jobs + .where((job) => job.strategyPublicId == strategyPublicId) + .toList(growable: false); + } + + int pendingCountForStrategy(String? strategyPublicId) { + return jobsForStrategy(strategyPublicId).length; + } + + int errorCountForStrategy(String? strategyPublicId) { + return jobsForStrategy(strategyPublicId) + .where((job) => job.state == CloudMediaJobState.failed) + .length; + } + + CloudMediaUploadQueueState copyWith({ + List? jobs, + bool? isProcessing, + }) { + return CloudMediaUploadQueueState( + jobs: jobs ?? this.jobs, + isProcessing: isProcessing ?? this.isProcessing, + ); + } +} + +final cloudMediaUploadQueueProvider = NotifierProvider< + CloudMediaUploadQueueNotifier, CloudMediaUploadQueueState>( + CloudMediaUploadQueueNotifier.new, +); + +class CloudMediaUploadQueueNotifier extends Notifier { + Timer? _retryTimer; + + Box get _box => + Hive.box(HiveBoxNames.mediaUploadJobsBox); + + ConvexStrategyRepository get _repo => + ref.read(convexStrategyRepositoryProvider); + + @override + CloudMediaUploadQueueState build() { + ref.onDispose(() { + _retryTimer?.cancel(); + }); + + ref.listen(authProvider, (previous, next) { + final becameReady = + !(previous?.isConvexUserReady ?? false) && next.isConvexUserReady; + final authRecovered = (previous?.hasActiveAuthIncident ?? false) && + !next.hasActiveAuthIncident; + if (becameReady || authRecovered) { + retryNow(ignoreBackoff: true); + } + }); + + final initialJobs = _readJobs(); + if (initialJobs.isNotEmpty) { + Future.microtask(() => retryNow()); + } + return CloudMediaUploadQueueState( + jobs: initialJobs, + isProcessing: false, + ); + } + + Future enqueuePlacedImageUpload({ + required String imagePublicId, + String? strategyPublicId, + String? pagePublicId, + String? fileExtension, + String? mimeType, + int? width, + int? height, + }) async { + final strategyState = ref.read(strategyProvider); + final resolvedStrategyId = strategyPublicId ?? strategyState.strategyId; + final resolvedPageId = + pagePublicId ?? ref.read(strategyPageSessionProvider).activePageId; + if (strategyState.source != StrategySource.cloud || + resolvedStrategyId == null || + resolvedPageId == null) { + return; + } + + final normalizedExtension = normalizeImageExtension(fileExtension ?? ''); + await _upsertJob( + CloudMediaUploadJob( + jobId: imagePublicId, + strategyPublicId: resolvedStrategyId, + pagePublicId: resolvedPageId, + ownerType: CloudMediaOwnerType.element, + ownerPublicId: imagePublicId, + assetPublicId: imagePublicId, + fileExtension: normalizedExtension, + mimeType: mimeType ?? mimeTypeForImageExtension(normalizedExtension), + width: width, + height: height, + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.now(), + ), + ); + retryNow(ignoreBackoff: true); + } + + Future enqueueJobForLocalFile({ + required String strategyPublicId, + required String pagePublicId, + required CloudMediaOwnerType ownerType, + required String ownerPublicId, + required String assetPublicId, + required String fileExtension, + String? mimeType, + int? width, + int? height, + }) async { + final normalizedExtension = normalizeImageExtension(fileExtension); + await _upsertJob( + CloudMediaUploadJob( + jobId: assetPublicId, + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + ownerType: ownerType, + ownerPublicId: ownerPublicId, + assetPublicId: assetPublicId, + fileExtension: normalizedExtension, + mimeType: mimeType ?? mimeTypeForImageExtension(normalizedExtension), + width: width, + height: height, + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.now(), + ), + ); + retryNow(ignoreBackoff: true); + } + + Future enqueueLineupMediaJobs({ + required String strategyPublicId, + required String pagePublicId, + required String lineupPublicId, + required Iterable images, + }) async { + for (final image in images) { + final normalizedExtension = normalizeImageExtension(image.fileExtension); + await _upsertJob( + CloudMediaUploadJob( + jobId: image.id, + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + ownerType: CloudMediaOwnerType.lineup, + ownerPublicId: lineupPublicId, + assetPublicId: image.id, + fileExtension: normalizedExtension, + mimeType: mimeTypeForImageExtension(normalizedExtension), + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.now(), + ), + ); + } + retryNow(ignoreBackoff: true); + } + + Future retryNow({bool ignoreBackoff = false}) async { + _retryTimer?.cancel(); + unawaited(_processNextJob(ignoreBackoff: ignoreBackoff)); + } + + Future setActiveStrategy(String? strategyPublicId) async { + _retryTimer?.cancel(); + _refreshState(); + if (strategyPublicId != null) { + await retryNow(ignoreBackoff: true); + } + } + + Future clearJobsForStrategy(String strategyPublicId) async { + final jobs = _readJobs() + .where((job) => job.strategyPublicId == strategyPublicId) + .toList(growable: false); + for (final job in jobs) { + await _box.delete(job.jobId); + } + _refreshState(); + } + + Future _processNextJob({bool ignoreBackoff = false}) async { + if (state.isProcessing) { + return; + } + + final nextJob = _nextRunnableJob(ignoreBackoff: ignoreBackoff); + if (nextJob == null) { + _scheduleRetryForNextEligibleJob(); + return; + } + + state = state.copyWith(isProcessing: true); + try { + await _processJob(nextJob); + } finally { + _refreshState(isProcessing: false); + } + + if (_readJobs().isNotEmpty) { + unawaited(_processNextJob(ignoreBackoff: ignoreBackoff)); + } + } + + CloudMediaUploadJob? _nextRunnableJob({required bool ignoreBackoff}) { + final jobs = _readJobs() + ..sort((a, b) => a.updatedAt.compareTo(b.updatedAt)); + final now = DateTime.now(); + for (final job in jobs) { + if (ignoreBackoff || !_nextAttemptAt(job).isAfter(now)) { + return job; + } + } + return null; + } + + Future _processJob(CloudMediaUploadJob job) async { + final mode = ref.read(cloudCollabModeProvider); + final auth = ref.read(authProvider); + if (!mode.featureFlagEnabled || mode.forceLocalFallback) { + _scheduleRetryForNextEligibleJob(); + return; + } + if (!auth.isAuthenticated || + !auth.isConvexUserReady || + auth.hasActiveAuthIncident || + !ConvexClient.instance.isConnected) { + _scheduleRetryForNextEligibleJob(); + return; + } + + if (job.state == CloudMediaJobState.pendingUpload || job.storageId == null) { + await _uploadJobBlob(job); + return; + } + + await _attachUploadedJob(job); + } + + Future _uploadJobBlob(CloudMediaUploadJob job) async { + try { + final file = await PlacedImageProvider.getImageFile( + strategyID: job.strategyPublicId, + imageID: job.assetPublicId, + fileExtension: job.fileExtension, + ); + if (!await file.exists()) { + await _markJobFailed( + job, + 'Local media file is missing.', + showToast: job.attempts == 0, + ); + return; + } + + final uploadUrl = await _repo.generateImageUploadUrl(job.strategyPublicId); + if (uploadUrl.isEmpty) { + throw StateError('Empty Convex upload URL'); + } + + final response = await http.post( + Uri.parse(uploadUrl), + headers: { + 'Content-Type': job.mimeType, + }, + body: await file.readAsBytes(), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError( + 'Upload failed (${response.statusCode}): ${response.body}', + ); + } + + final storageId = _parseStorageId(response.body); + await _box.put( + job.jobId, + job.copyWith( + storageId: storageId, + state: CloudMediaJobState.pendingAttach, + attempts: 0, + lastError: null, + updatedAt: DateTime.now(), + ), + ); + _refreshState(); + } catch (error) { + await _markJobFailed( + job, + '$error', + showToast: job.attempts == 0, + ); + } + } + + Future _attachUploadedJob(CloudMediaUploadJob job) async { + try { + await _repo.completeImageUpload( + strategyPublicId: job.strategyPublicId, + pagePublicId: job.pagePublicId, + assetPublicId: job.assetPublicId, + ownerType: job.ownerType, + ownerPublicId: job.ownerPublicId, + storageId: job.storageId!, + mimeType: job.mimeType, + fileExtension: job.fileExtension, + width: job.width, + height: job.height, + ); + await _box.delete(job.jobId); + _refreshState(); + } catch (error) { + if (_isOwnerNotFoundError(error)) { + await _box.put( + job.jobId, + job.copyWith( + state: CloudMediaJobState.pendingAttach, + attempts: job.attempts + 1, + lastError: 'owner_not_found', + updatedAt: DateTime.now(), + ), + ); + _refreshState(); + _scheduleRetryForNextEligibleJob(); + return; + } + + await _markJobFailed( + job, + '$error', + showToast: job.attempts == 0, + ); + } + } + + Future _markJobFailed( + CloudMediaUploadJob job, + String errorMessage, { + required bool showToast, + }) async { + await _box.put( + job.jobId, + job.copyWith( + state: CloudMediaJobState.failed, + attempts: job.attempts + 1, + lastError: errorMessage, + updatedAt: DateTime.now(), + ), + ); + _refreshState(); + if (showToast) { + Settings.showToast( + message: 'Media upload failed. Tap Save to retry cloud sync.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + _scheduleRetryForNextEligibleJob(); + } + + DateTime _nextAttemptAt(CloudMediaUploadJob job) { + if (job.attempts <= 0) { + return job.updatedAt; + } + + final baseSeconds = 5 * (1 << (job.attempts - 1).clamp(0, 5)); + final cappedSeconds = baseSeconds > 300 ? 300 : baseSeconds; + return job.updatedAt.add(Duration(seconds: cappedSeconds)); + } + + void _scheduleRetryForNextEligibleJob() { + _retryTimer?.cancel(); + final jobs = _readJobs(); + if (jobs.isEmpty) { + return; + } + + final now = DateTime.now(); + DateTime? earliest; + for (final job in jobs) { + final candidate = _nextAttemptAt(job); + if (earliest == null || candidate.isBefore(earliest)) { + earliest = candidate; + } + } + + if (earliest == null) { + return; + } + + final delay = earliest.isAfter(now) ? earliest.difference(now) : Duration.zero; + _retryTimer = Timer(delay, () { + unawaited(_processNextJob(ignoreBackoff: false)); + }); + } + + Future _upsertJob(CloudMediaUploadJob nextJob) async { + final existing = _box.get(nextJob.jobId); + if (existing != null) { + final merged = existing.copyWith( + strategyPublicId: nextJob.strategyPublicId, + pagePublicId: nextJob.pagePublicId, + ownerType: nextJob.ownerType, + ownerPublicId: nextJob.ownerPublicId, + assetPublicId: nextJob.assetPublicId, + fileExtension: nextJob.fileExtension, + mimeType: nextJob.mimeType, + width: nextJob.width, + height: nextJob.height, + ); + await _box.put(nextJob.jobId, merged); + } else { + await _box.put(nextJob.jobId, nextJob); + } + _refreshState(); + } + + List _readJobs() { + return _box.values.toList(growable: false); + } + + void _refreshState({bool? isProcessing}) { + state = state.copyWith( + jobs: _readJobs(), + isProcessing: isProcessing ?? state.isProcessing, + ); + } + + String _parseStorageId(String responseBody) { + final decoded = jsonDecode(responseBody); + if (decoded is Map) { + final storageId = decoded['storageId']; + if (storageId is String && storageId.isNotEmpty) { + return storageId; + } + } + if (decoded is Map) { + final storageId = decoded['storageId']; + if (storageId is String && storageId.isNotEmpty) { + return storageId; + } + } + throw const FormatException('Convex upload response did not include storageId'); + } + + bool _isOwnerNotFoundError(Object error) { + if (error is Map) { + final code = error['code']?.toString().toUpperCase(); + final message = error['message']?.toString().toLowerCase(); + if (code == 'OWNER_NOT_FOUND' || message == 'owner_not_found') { + return true; + } + } + + final normalized = error.toString().toLowerCase(); + return normalized.contains('owner_not_found') || + normalized.contains('owner not found'); + } +} diff --git a/lib/providers/collab/remote_strategy_snapshot_provider.dart b/lib/providers/collab/remote_strategy_snapshot_provider.dart index 491fece3..f9afe1e3 100644 --- a/lib/providers/collab/remote_strategy_snapshot_provider.dart +++ b/lib/providers/collab/remote_strategy_snapshot_provider.dart @@ -19,6 +19,7 @@ class RemoteStrategySnapshotNotifier String? _activeStrategyPublicId; dynamic _headerSubscription; dynamic _pagesSubscription; + dynamic _assetsSubscription; final Map _elementSubscriptions = {}; final Map _lineupSubscriptions = {}; Timer? _refreshDebounce; @@ -127,6 +128,16 @@ class RemoteStrategySnapshotNotifier message: message, ), ); + + _assetsSubscription = await ConvexClient.instance.subscribe( + name: 'images:listForStrategy', + args: {'strategyPublicId': strategyPublicId}, + onUpdate: (_) => _scheduleRefresh(), + onError: (message, _) => _handleSubscriptionError( + source: 'remote_snapshot:assets_subscription', + message: message, + ), + ); } Set _decodePageIds(dynamic value) { @@ -265,6 +276,9 @@ class RemoteStrategySnapshotNotifier _cancelSubscription(_pagesSubscription); _pagesSubscription = null; + _cancelSubscription(_assetsSubscription); + _assetsSubscription = null; + for (final subscription in _elementSubscriptions.values) { _cancelSubscription(subscription); } diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 0e30d4b3..06561fa9 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -1,19 +1,22 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:ui' as ui; +import 'dart:async' show Completer; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/image_scale_policy.dart'; import 'package:icarus/providers/image_widget_size_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:image/image.dart' as img; -import 'dart:ui' as ui; -import 'dart:async' show Completer; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; @@ -167,6 +170,23 @@ class PlacedImageProvider extends Notifier { ref.read(actionProvider.notifier).addAction(action); state = state.copyWith(images: [...state.images, placedImage]); + + final strategyState = ref.read(strategyProvider); + final pagePublicId = ref.read(strategyPageSessionProvider).activePageId; + if (strategyState.source == StrategySource.cloud && + strategyState.strategyId != null && + pagePublicId != null) { + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: strategyState.strategyId!, + pagePublicId: pagePublicId, + imagePublicId: placedImage.id, + fileExtension: fileExtension, + width: null, + height: null, + ); + } } void removeImageAsAction(String id) { @@ -372,6 +392,41 @@ class PlacedImageProvider extends Notifier { return imagesDirectory; } + static String buildImageFilePath( + String imagesDirectoryPath, + String imageID, + String fileExtension, + ) { + return path.join(imagesDirectoryPath, '$imageID$fileExtension'); + } + + static Future getImageFile({ + required String strategyID, + required String imageID, + required String fileExtension, + }) async { + final imageFolder = await getImageFolder(strategyID); + return File(buildImageFilePath(imageFolder.path, imageID, fileExtension)); + } + + static Future writeImageBytes({ + required Uint8List imageBytes, + required String strategyID, + required String imageID, + required String fileExtension, + }) async { + if (kIsWeb) return; + final file = await getImageFile( + strategyID: strategyID, + imageID: imageID, + fileExtension: fileExtension, + ); + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + await file.writeAsBytes(imageBytes); + } + Future toJson(String strategyID) async { // Asynchronously convert each image using the custom serializer. final List> jsonList = @@ -432,34 +487,13 @@ class PlacedImageProvider extends Notifier { String fileExtenstion, ) async { final strategyID = ref.read(strategyProvider).strategyId; - // Get the system's application support directory. - if (kIsWeb) return; - final directory = await getApplicationSupportDirectory(); - - // Create a custom directory inside the application support directory. - - final customDirectory = Directory(path.join(directory.path, strategyID)); - - if (!await customDirectory.exists()) { - await customDirectory.create(recursive: true); - } - - // Now create the full file path. - final filePath = path.join( - customDirectory.path, - 'images', - '$imageID$fileExtenstion', + if (strategyID == null) return; + await writeImageBytes( + imageBytes: imageBytes, + strategyID: strategyID, + imageID: imageID, + fileExtension: fileExtenstion, ); - - // Ensure the images subdirectory exists. - final imagesDir = Directory(path.join(customDirectory.path, 'images')); - if (!await imagesDir.exists()) { - await imagesDir.create(recursive: true); - } - - // Write the file. - final file = File(filePath); - await file.writeAsBytes(imageBytes); } static List deepCopyWith(List images) { diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 3bdd4242..3ed07604 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -33,6 +33,7 @@ import 'package:uuid/uuid.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; @@ -48,6 +49,23 @@ final strategyProvider = class StrategyProvider extends Notifier { @override StrategyState build() { + ref.listen(authProvider, (previous, next) { + final strategyId = state.strategyId; + if (state.source != StrategySource.cloud || strategyId == null) { + return; + } + final becameReady = !(previous?.isConvexUserReady ?? false) && + next.isConvexUserReady; + if (!becameReady) { + return; + } + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).retryNow( + ignoreBackoff: true, + ), + ); + }); + return const StrategyState( strategyId: null, strategyName: null, @@ -149,11 +167,13 @@ class StrategyProvider extends Notifier { return; } + final storageDirectory = + kIsWeb ? null : (await setStorageDirectory(snapshot.header.publicId)).path; state = state.copyWith( strategyId: snapshot.header.publicId, strategyName: snapshot.header.name, source: StrategySource.cloud, - storageDirectory: null, + storageDirectory: storageDirectory, isOpen: true, ); @@ -162,6 +182,11 @@ class StrategyProvider extends Notifier { source: StrategySource.cloud, selectFirstPageIfNeeded: true, ); + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).setActiveStrategy( + snapshot.header.publicId, + ), + ); } Future switchPage(String pageID) async { @@ -295,6 +320,9 @@ class StrategyProvider extends Notifier { isOpen: false, ); ref.read(remoteStrategySnapshotProvider.notifier).clear(); + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).setActiveStrategy(null), + ); } // Switch active page: flush old page first, then hydrate new diff --git a/lib/providers/strategy_save_state_provider.dart b/lib/providers/strategy_save_state_provider.dart index f4212849..1cb40986 100644 --- a/lib/providers/strategy_save_state_provider.dart +++ b/lib/providers/strategy_save_state_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +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/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -9,6 +10,8 @@ class StrategySaveState { required this.isSaving, required this.hasPendingCloudSync, required this.cloudSyncError, + required this.hasPendingMediaSync, + required this.mediaSyncErrorCount, required this.lastPersistedAt, }); @@ -16,16 +19,25 @@ class StrategySaveState { final bool isSaving; final bool hasPendingCloudSync; final String? cloudSyncError; + final bool hasPendingMediaSync; + final int mediaSyncErrorCount; final DateTime? lastPersistedAt; bool get canLeaveSafely => - !isDirty && !isSaving && !hasPendingCloudSync && cloudSyncError == null; + !isDirty && + !isSaving && + !hasPendingCloudSync && + !hasPendingMediaSync && + cloudSyncError == null && + mediaSyncErrorCount == 0; StrategySaveState copyWith({ bool? isDirty, bool? isSaving, bool? hasPendingCloudSync, String? cloudSyncError, + bool? hasPendingMediaSync, + int? mediaSyncErrorCount, bool clearCloudSyncError = false, DateTime? lastPersistedAt, }) { @@ -35,6 +47,8 @@ class StrategySaveState { hasPendingCloudSync: hasPendingCloudSync ?? this.hasPendingCloudSync, cloudSyncError: clearCloudSyncError ? null : (cloudSyncError ?? this.cloudSyncError), + hasPendingMediaSync: hasPendingMediaSync ?? this.hasPendingMediaSync, + mediaSyncErrorCount: mediaSyncErrorCount ?? this.mediaSyncErrorCount, lastPersistedAt: lastPersistedAt ?? this.lastPersistedAt, ); } @@ -71,11 +85,41 @@ class StrategySaveStateNotifier extends Notifier { } }); + ref.listen(cloudMediaUploadQueueProvider, ( + previous, + next, + ) { + final source = ref.read(strategyProvider).source; + if (source != StrategySource.cloud) { + return; + } + + final failedJobs = next.jobs.where((job) => job.isFailed).length; + final hasPendingMedia = next.jobs.isNotEmpty; + final hasPendingCloudSync = state.hasPendingCloudSync || hasPendingMedia; + state = state.copyWith( + hasPendingMediaSync: hasPendingMedia, + mediaSyncErrorCount: failedJobs, + isDirty: hasPendingCloudSync ? true : state.isDirty, + ); + + if (!hasPendingCloudSync && + state.cloudSyncError == null && + failedJobs == 0) { + state = state.copyWith( + isDirty: false, + lastPersistedAt: DateTime.now(), + ); + } + }); + return const StrategySaveState( isDirty: false, isSaving: false, hasPendingCloudSync: false, cloudSyncError: null, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, lastPersistedAt: null, ); } @@ -86,6 +130,8 @@ class StrategySaveStateNotifier extends Notifier { isSaving: false, hasPendingCloudSync: false, cloudSyncError: null, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, lastPersistedAt: null, ); } @@ -117,6 +163,8 @@ class StrategySaveStateNotifier extends Notifier { isDirty: false, isSaving: false, hasPendingCloudSync: false, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, clearCloudSyncError: true, lastPersistedAt: DateTime.now(), ); diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 9d1acd11..b778a766 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -113,9 +113,11 @@ Future _waitForCloudSync( final saveState = ref.read(strategySaveStateProvider); final queueState = ref.read(strategyOpQueueProvider); if (!saveState.hasPendingCloudSync && + !saveState.hasPendingMediaSync && queueState.pending.isEmpty && !queueState.isFlushing && - saveState.cloudSyncError == null) { + saveState.cloudSyncError == null && + saveState.mediaSyncErrorCount == 0) { return true; } await Future.delayed(pollInterval); @@ -134,8 +136,9 @@ Future _guardCloudStrategyExit({ final queueState = ref.read(strategyOpQueueProvider); final authState = ref.read(authProvider); - final hasPendingSync = - saveState.hasPendingCloudSync || queueState.pending.isNotEmpty; + final hasPendingSync = saveState.hasPendingCloudSync || + saveState.hasPendingMediaSync || + queueState.pending.isNotEmpty; final cloudError = saveState.cloudSyncError ?? queueState.lastError; if (!hasPendingSync && cloudError == null) { if (!context.mounted) { @@ -159,7 +162,9 @@ Future _guardCloudStrategyExit({ final decision = await _showCloudSyncBlockedDialog( context, message: cloudError ?? - 'Icarus is still syncing cloud edits. Stay on this screen until sync completes.', + (saveState.mediaSyncErrorCount > 0 + ? 'Some media uploads failed. Retry sync or stay here until the queue clears.' + : 'Icarus is still syncing cloud edits and media. Stay on this screen until sync completes.'), showRetryAuth: authState.hasActiveAuthIncident, ); diff --git a/lib/strategy/strategy_cloud_migration.dart b/lib/strategy/strategy_cloud_migration.dart index acd90b40..4a7b44a0 100644 --- a/lib/strategy/strategy_cloud_migration.dart +++ b/lib/strategy/strategy_cloud_migration.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:uuid/uuid.dart'; @@ -51,7 +52,7 @@ void appendMigratedPageOps( for (final image in page.imageData) { final elementId = nextUniqueMigrationId(image.id, usedElementIds); - final payload = Map.from(image.toJson()) + final payload = cloudImagePayloadFromPlacedImage(image) ..putIfAbsent('elementType', () => 'image') ..['id'] = elementId; ops.add(buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); @@ -68,7 +69,7 @@ void appendMigratedPageOps( var lineupOrder = 0; for (final lineup in page.lineUps) { final lineupId = nextUniqueMigrationId(lineup.id, usedLineupIds); - final lineupPayload = Map.from(lineup.toJson()) + final lineupPayload = cloudLineupPayload(lineup) ..['id'] = lineupId; ops.add( StrategyOp( diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart index 764f8108..1e9f5f72 100644 --- a/lib/strategy/strategy_import_export.dart +++ b/lib/strategy/strategy_import_export.dart @@ -22,6 +22,7 @@ import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/favorite_agents_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; @@ -2359,9 +2360,61 @@ class StrategyImportExportService { return outPath; } + Future _ensureRemoteAssetsCached( + RemoteStrategySnapshot snapshot, + ) async { + final strategyId = snapshot.header.publicId; + final assetIds = {}; + for (final page in snapshot.pages) { + for (final element in snapshot.elementsByPage[page.publicId] ?? const []) { + if (element.deleted || element.elementType != 'image') { + continue; + } + assetIds.add(element.publicId); + } + + for (final lineup in snapshot.lineupsByPage[page.publicId] ?? const []) { + if (lineup.deleted) { + continue; + } + try { + final decoded = jsonDecode(lineup.payload); + final mapped = decoded is Map + ? decoded + : decoded is Map + ? Map.from(decoded) + : null; + if (mapped == null) { + continue; + } + final parsed = LineUp.fromJson(mapped); + for (final image in parsed.images) { + assetIds.add(image.id); + } + } catch (_) { + continue; + } + } + } + + final cacheNotifier = ref.read(cloudMediaCacheProvider.notifier); + final cached = await cacheNotifier.ensureAssetIdsCached( + strategyId: strategyId, + strategyPublicId: strategyId, + assetsById: snapshot.assetsById, + assetIds: assetIds, + ); + if (!cached) { + throw StateError( + 'Unable to cache all remote images for export. Reconnect and retry.', + ); + } + } + Future exportCloudStrategy(String strategyId) async { final snapshot = await ref.read(convexStrategyRepositoryProvider).fetchSnapshot(strategyId); + await _ensureRemoteAssetsCached(snapshot); final strategy = _strategyDataFromRemoteSnapshot(snapshot); final outputFile = await FilePicker.platform.saveFile( type: FileType.custom, diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart index 3f2f9ad5..78590fbb 100644 --- a/lib/strategy/strategy_page_source.dart +++ b/lib/strategy/strategy_page_source.dart @@ -12,6 +12,7 @@ import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_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/cloud_media_cache_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; @@ -211,7 +212,17 @@ class CloudStrategyPageSource implements StrategyPageSource { texts.add(PlacedText.fromJson(payload)); break; case 'image': - images.add(PlacedImage.fromJson(payload)); + final hydrated = PlacedImage.fromJson(payload); + final remoteAsset = snapshot.assetsById[hydrated.id]; + hydrated.link = remoteAsset?.url ?? ''; + images.add(hydrated); + if (remoteAsset != null) { + ref.read(cloudMediaCacheProvider.notifier).ensureAssetCached( + strategyId: strategyId, + strategyPublicId: strategyId, + asset: remoteAsset, + ); + } break; case 'utility': utilities.add(PlacedUtility.fromJson(payload)); @@ -321,7 +332,17 @@ class CloudStrategyPageSource implements StrategyPageSource { texts.add(PlacedText.fromJson(payload)); break; case 'image': - images.add(PlacedImage.fromJson(payload)); + final hydrated = PlacedImage.fromJson(payload); + final remoteAsset = snapshot.assetsById[hydrated.id]; + hydrated.link = remoteAsset?.url ?? ''; + images.add(hydrated); + if (remoteAsset != null) { + ref.read(cloudMediaCacheProvider.notifier).ensureAssetCached( + strategyId: strategyId, + strategyPublicId: strategyId, + asset: remoteAsset, + ); + } break; case 'utility': utilities.add(PlacedUtility.fromJson(payload)); diff --git a/lib/widgets/dialogs/create_lineup_dialog.dart b/lib/widgets/dialogs/create_lineup_dialog.dart index 751bb7f2..db21d60d 100644 --- a/lib/widgets/dialogs/create_lineup_dialog.dart +++ b/lib/widgets/dialogs/create_lineup_dialog.dart @@ -3,10 +3,15 @@ import 'dart:typed_data' show Uint8List; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/services/clipboard_service.dart'; import 'package:icarus/widgets/dialogs/strategy/line_up_media_page.dart'; import 'package:path/path.dart' as path; @@ -26,6 +31,33 @@ class _CreateLineupDialogState extends ConsumerState { final TextEditingController _notesController = TextEditingController(); final List _imagePaths = []; + Future _enqueueLineupMediaJobs({ + required String lineupId, + required List images, + }) async { + final strategyState = ref.read(strategyProvider); + if (strategyState.source != StrategySource.cloud || + strategyState.strategyId == null) { + return; + } + + final pageId = ref.read(strategyPageSessionProvider).activePageId; + if (pageId == null) { + return; + } + + for (final image in images) { + await ref.read(cloudMediaUploadQueueProvider.notifier).enqueueJobForLocalFile( + strategyPublicId: strategyState.strategyId!, + pagePublicId: pageId, + ownerType: CloudMediaOwnerType.lineup, + ownerPublicId: lineupId, + assetPublicId: image.id, + fileExtension: image.fileExtension, + ); + } + } + @override void initState() { super.initState(); @@ -88,6 +120,10 @@ class _CreateLineupDialogState extends ConsumerState { ); ref.read(lineUpProvider.notifier).updateLineUp(lineUp); + await _enqueueLineupMediaJobs( + lineupId: lineUp.id, + images: lineUp.images, + ); } else { final id = const Uuid().v4(); @@ -107,6 +143,10 @@ class _CreateLineupDialogState extends ConsumerState { ); ref.read(lineUpProvider.notifier).addLineUp(currentLineUp); + await _enqueueLineupMediaJobs( + lineupId: currentLineUp.id, + images: currentLineUp.images, + ); } ref diff --git a/lib/widgets/dialogs/strategy/line_up_media_page.dart b/lib/widgets/dialogs/strategy/line_up_media_page.dart index ed304258..fcab9674 100644 --- a/lib/widgets/dialogs/strategy/line_up_media_page.dart +++ b/lib/widgets/dialogs/strategy/line_up_media_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:path/path.dart' as path; @@ -234,20 +235,37 @@ class _LineupMediaPageState extends ConsumerState { } Widget _buildImageTile(int index) { - final String fullImagePath = path.join(imageFolderPath!.path, - widget.images[index].id + widget.images[index].fileExtension); + final image = widget.images[index]; + final String fullImagePath = + path.join(imageFolderPath!.path, image.id + image.fileExtension); final file = File(fullImagePath); + final snapshot = ref.watch(remoteStrategySnapshotProvider).valueOrNull; + final fallbackUrl = snapshot?.assetsById[image.id]?.url; + + final ImageProvider? imageProvider = file.existsSync() + ? FileImage(file) + : (fallbackUrl != null && fallbackUrl.isNotEmpty + ? NetworkImage(fallbackUrl) + : null); return Stack( children: [ Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - image: DecorationImage( - image: FileImage(file), // Placeholder - fit: BoxFit.cover, - ), + color: Settings.tacticalVioletTheme.secondary, + image: imageProvider == null + ? null + : DecorationImage( + image: imageProvider, + fit: BoxFit.cover, + ), ), + child: imageProvider == null + ? const Center( + child: Icon(Icons.broken_image, color: Colors.white), + ) + : null, ), Positioned( top: 4, diff --git a/lib/widgets/image_drop_target.dart b/lib/widgets/image_drop_target.dart index d0bb8344..7bcc9009 100644 --- a/lib/widgets/image_drop_target.dart +++ b/lib/widgets/image_drop_target.dart @@ -48,8 +48,9 @@ class _ImageDropTargetState extends ConsumerState { .contains(rawExtension)) { final fileExtension = '.$rawExtension'; await ref.read(placedImageProvider.notifier).addImage( - imageBytes: await file.readAsBytes(), - fileExtension: fileExtension); + imageBytes: await file.readAsBytes(), + fileExtension: fileExtension, + ); } } }, diff --git a/lib/widgets/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart index 3ce8ecbb..31326eff 100644 --- a/lib/widgets/line_up_media_carousel.dart +++ b/lib/widgets/line_up_media_carousel.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; @@ -71,7 +72,6 @@ class _ImageCarouselState extends ConsumerState @override Widget build(BuildContext context) { super.build(context); - // log(widget.youtubeLink ?? 'No youtube link'); if (imageFolderPath == null) { return const Center(child: CircularProgressIndicator()); } @@ -111,19 +111,29 @@ class _ImageCarouselState extends ConsumerState final fullPath = path.join( imageFolderPath!.path, image.id + image.fileExtension); final file = File(fullPath); + final snapshot = + ref.watch(remoteStrategySnapshotProvider).valueOrNull; + final remoteUrl = snapshot?.assetsById[image.id]?.url; - if (!file.existsSync()) { + if (!file.existsSync() && + (remoteUrl == null || remoteUrl.isEmpty)) { return const Center( - child: Icon(Icons.broken_image, color: Colors.white)); + child: Icon(Icons.broken_image, color: Colors.white), + ); } return InteractiveViewer( minScale: 0.5, maxScale: 4.0, - child: Image.file( - file, - fit: BoxFit.contain, - ), + child: file.existsSync() + ? Image.file( + file, + fit: BoxFit.contain, + ) + : Image.network( + remoteUrl!, + fit: BoxFit.contain, + ), ); }, ), diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 26f8ff3f..1f53ae4b 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/auto_save_notifier.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; @@ -120,12 +121,20 @@ class _AutoSaveButtonState extends ConsumerState foregroundColor: Colors.white, icon: icon, onPressed: () async { - // manual save path shows a SnackBar await ref .read(strategyProvider.notifier) .forceSaveNow(ref.read(strategyProvider).strategyId!); if (!context.mounted) return; + final latestSaveState = ref.read(strategySaveStateProvider); + final hasIncompleteMediaSync = latestSaveState.hasPendingMediaSync || + latestSaveState.mediaSyncErrorCount > 0; + final toastMessage = hasIncompleteMediaSync + ? latestSaveState.mediaSyncErrorCount > 0 + ? 'Local save complete. Media sync needs retry.' + : 'Local save complete. Media still syncing.' + : 'Save Complete'; + toastification.showCustom( context: context, autoCloseDuration: const Duration(seconds: 3), @@ -143,7 +152,7 @@ class _AutoSaveButtonState extends ConsumerState ), ), child: Text( - 'Save Complete', + toastMessage, style: ShadTheme.of(context) .textTheme .small @@ -152,20 +161,6 @@ class _AutoSaveButtonState extends ConsumerState ); }, ); - // ScaffoldMessenger.of(context).showSnackBar( - // const SnackBar( - // content: Center( - // child: Text( - // "File Saved", - // style: TextStyle(color: Colors.white), - // ), - // ), - // duration: Duration(seconds: 2), - // backgroundColor: Settings.sideBarColor, - // behavior: SnackBarBehavior.floating, - // width: 200, - // ), - // ); }, ), ); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9e610db2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,597 @@ +{ + "name": "icarus", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "icarus", + "dependencies": { + "convex": "^1.32.0" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.11" + } + }, + "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/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/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/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/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/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/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/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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 + } + } + } + } +} diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 3a933805..212c0d8f 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -184,6 +184,7 @@ RemoteStrategySnapshot _cloudSnapshot({ pages: pages, elementsByPage: elementsByPage, lineupsByPage: const {}, + assetsById: const {}, ); } From d201a8410a9fcea45ad075b8cdef0e5bdb101294 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sun, 5 Apr 2026 01:57:17 -0400 Subject: [PATCH 02/15] Refresh Convex skill bundles Made-with: Cursor --- .claude/skills/convex-create-component | 1 + .../skills/convex-create-component/SKILL.md | 284 -------------- .../agents/openai.yaml | 10 - .../convex-create-component/assets/icon.svg | 3 - .../references/advanced-patterns.md | 134 ------- .../references/hybrid-components.md | 37 -- .../references/local-components.md | 38 -- .../references/packaged-components.md | 51 --- .claude/skills/convex-migration-helper | 1 + .../skills/convex-migration-helper/SKILL.md | 150 ------- .../agents/openai.yaml | 10 - .../convex-migration-helper/assets/icon.svg | 3 - .../references/migration-patterns.md | 231 ----------- .../references/migrations-component.md | 170 -------- .claude/skills/convex-performance-audit | 1 + .../skills/convex-performance-audit/SKILL.md | 143 ------- .../agents/openai.yaml | 10 - .../convex-performance-audit/assets/icon.svg | 3 - .../references/function-budget.md | 232 ----------- .../references/hot-path-rules.md | 371 ------------------ .../references/occ-conflicts.md | 126 ------ .../references/subscription-cost.md | 252 ------------ .claude/skills/convex-quickstart | 1 + .claude/skills/convex-quickstart/SKILL.md | 337 ---------------- .../convex-quickstart/agents/openai.yaml | 10 - .../skills/convex-quickstart/assets/icon.svg | 4 - .claude/skills/convex-setup-auth | 1 + .claude/skills/convex-setup-auth/SKILL.md | 150 ------- .../convex-setup-auth/agents/openai.yaml | 10 - .../skills/convex-setup-auth/assets/icon.svg | 3 - .../convex-setup-auth/references/auth0.md | 116 ------ .../convex-setup-auth/references/clerk.md | 113 ------ .../references/convex-auth.md | 143 ------- .../references/workos-authkit.md | 114 ------ .windsurf/skills/convex-create-component | 1 + .../skills/convex-create-component/SKILL.md | 284 -------------- .../agents/openai.yaml | 10 - .../convex-create-component/assets/icon.svg | 3 - .../references/advanced-patterns.md | 134 ------- .../references/hybrid-components.md | 37 -- .../references/local-components.md | 38 -- .../references/packaged-components.md | 51 --- .windsurf/skills/convex-migration-helper | 1 + .../skills/convex-migration-helper/SKILL.md | 150 ------- .../agents/openai.yaml | 10 - .../convex-migration-helper/assets/icon.svg | 3 - .../references/migration-patterns.md | 231 ----------- .../references/migrations-component.md | 170 -------- .windsurf/skills/convex-performance-audit | 1 + .../skills/convex-performance-audit/SKILL.md | 143 ------- .../agents/openai.yaml | 10 - .../convex-performance-audit/assets/icon.svg | 3 - .../references/function-budget.md | 232 ----------- .../references/hot-path-rules.md | 371 ------------------ .../references/occ-conflicts.md | 126 ------ .../references/subscription-cost.md | 252 ------------ .windsurf/skills/convex-quickstart | 1 + .windsurf/skills/convex-quickstart/SKILL.md | 337 ---------------- .../convex-quickstart/agents/openai.yaml | 10 - .../skills/convex-quickstart/assets/icon.svg | 4 - .windsurf/skills/convex-setup-auth | 1 + .windsurf/skills/convex-setup-auth/SKILL.md | 150 ------- .../convex-setup-auth/agents/openai.yaml | 10 - .../skills/convex-setup-auth/assets/icon.svg | 3 - .../convex-setup-auth/references/auth0.md | 116 ------ .../convex-setup-auth/references/clerk.md | 113 ------ .../references/convex-auth.md | 143 ------- .../references/workos-authkit.md | 114 ------ skills-lock.json | 10 +- 69 files changed, 15 insertions(+), 6521 deletions(-) create mode 120000 .claude/skills/convex-create-component delete mode 100644 .claude/skills/convex-create-component/SKILL.md delete mode 100644 .claude/skills/convex-create-component/agents/openai.yaml delete mode 100644 .claude/skills/convex-create-component/assets/icon.svg delete mode 100644 .claude/skills/convex-create-component/references/advanced-patterns.md delete mode 100644 .claude/skills/convex-create-component/references/hybrid-components.md delete mode 100644 .claude/skills/convex-create-component/references/local-components.md delete mode 100644 .claude/skills/convex-create-component/references/packaged-components.md create mode 120000 .claude/skills/convex-migration-helper delete mode 100644 .claude/skills/convex-migration-helper/SKILL.md delete mode 100644 .claude/skills/convex-migration-helper/agents/openai.yaml delete mode 100644 .claude/skills/convex-migration-helper/assets/icon.svg delete mode 100644 .claude/skills/convex-migration-helper/references/migration-patterns.md delete mode 100644 .claude/skills/convex-migration-helper/references/migrations-component.md create mode 120000 .claude/skills/convex-performance-audit delete mode 100644 .claude/skills/convex-performance-audit/SKILL.md delete mode 100644 .claude/skills/convex-performance-audit/agents/openai.yaml delete mode 100644 .claude/skills/convex-performance-audit/assets/icon.svg delete mode 100644 .claude/skills/convex-performance-audit/references/function-budget.md delete mode 100644 .claude/skills/convex-performance-audit/references/hot-path-rules.md delete mode 100644 .claude/skills/convex-performance-audit/references/occ-conflicts.md delete mode 100644 .claude/skills/convex-performance-audit/references/subscription-cost.md create mode 120000 .claude/skills/convex-quickstart delete mode 100644 .claude/skills/convex-quickstart/SKILL.md delete mode 100644 .claude/skills/convex-quickstart/agents/openai.yaml delete mode 100644 .claude/skills/convex-quickstart/assets/icon.svg create mode 120000 .claude/skills/convex-setup-auth delete mode 100644 .claude/skills/convex-setup-auth/SKILL.md delete mode 100644 .claude/skills/convex-setup-auth/agents/openai.yaml delete mode 100644 .claude/skills/convex-setup-auth/assets/icon.svg delete mode 100644 .claude/skills/convex-setup-auth/references/auth0.md delete mode 100644 .claude/skills/convex-setup-auth/references/clerk.md delete mode 100644 .claude/skills/convex-setup-auth/references/convex-auth.md delete mode 100644 .claude/skills/convex-setup-auth/references/workos-authkit.md create mode 120000 .windsurf/skills/convex-create-component delete mode 100644 .windsurf/skills/convex-create-component/SKILL.md delete mode 100644 .windsurf/skills/convex-create-component/agents/openai.yaml delete mode 100644 .windsurf/skills/convex-create-component/assets/icon.svg delete mode 100644 .windsurf/skills/convex-create-component/references/advanced-patterns.md delete mode 100644 .windsurf/skills/convex-create-component/references/hybrid-components.md delete mode 100644 .windsurf/skills/convex-create-component/references/local-components.md delete mode 100644 .windsurf/skills/convex-create-component/references/packaged-components.md create mode 120000 .windsurf/skills/convex-migration-helper delete mode 100644 .windsurf/skills/convex-migration-helper/SKILL.md delete mode 100644 .windsurf/skills/convex-migration-helper/agents/openai.yaml delete mode 100644 .windsurf/skills/convex-migration-helper/assets/icon.svg delete mode 100644 .windsurf/skills/convex-migration-helper/references/migration-patterns.md delete mode 100644 .windsurf/skills/convex-migration-helper/references/migrations-component.md create mode 120000 .windsurf/skills/convex-performance-audit delete mode 100644 .windsurf/skills/convex-performance-audit/SKILL.md delete mode 100644 .windsurf/skills/convex-performance-audit/agents/openai.yaml delete mode 100644 .windsurf/skills/convex-performance-audit/assets/icon.svg delete mode 100644 .windsurf/skills/convex-performance-audit/references/function-budget.md delete mode 100644 .windsurf/skills/convex-performance-audit/references/hot-path-rules.md delete mode 100644 .windsurf/skills/convex-performance-audit/references/occ-conflicts.md delete mode 100644 .windsurf/skills/convex-performance-audit/references/subscription-cost.md create mode 120000 .windsurf/skills/convex-quickstart delete mode 100644 .windsurf/skills/convex-quickstart/SKILL.md delete mode 100644 .windsurf/skills/convex-quickstart/agents/openai.yaml delete mode 100644 .windsurf/skills/convex-quickstart/assets/icon.svg create mode 120000 .windsurf/skills/convex-setup-auth delete mode 100644 .windsurf/skills/convex-setup-auth/SKILL.md delete mode 100644 .windsurf/skills/convex-setup-auth/agents/openai.yaml delete mode 100644 .windsurf/skills/convex-setup-auth/assets/icon.svg delete mode 100644 .windsurf/skills/convex-setup-auth/references/auth0.md delete mode 100644 .windsurf/skills/convex-setup-auth/references/clerk.md delete mode 100644 .windsurf/skills/convex-setup-auth/references/convex-auth.md delete mode 100644 .windsurf/skills/convex-setup-auth/references/workos-authkit.md diff --git a/.claude/skills/convex-create-component b/.claude/skills/convex-create-component new file mode 120000 index 00000000..dfa8244f --- /dev/null +++ b/.claude/skills/convex-create-component @@ -0,0 +1 @@ +../../.agents/skills/convex-create-component \ No newline at end of file diff --git a/.claude/skills/convex-create-component/SKILL.md b/.claude/skills/convex-create-component/SKILL.md deleted file mode 100644 index a79c18e0..00000000 --- a/.claude/skills/convex-create-component/SKILL.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -name: convex-create-component -description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. ---- - -# Convex Create Component - -Create reusable Convex components with clear boundaries and a small app-facing API. - -## When to Use - -- Creating a new Convex component in an existing app -- Extracting reusable backend logic into a component -- Building a third-party integration that should own its own tables and workflows -- Packaging Convex functionality for reuse across multiple apps - -## When Not to Use - -- One-off business logic that belongs in the main app -- Thin utilities that do not need Convex tables or functions -- App-level orchestration that should stay in `convex/` -- Cases where a normal TypeScript library is enough - -## Workflow - -1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. -2. Choose the shape using the decision tree below and read the matching reference file. -3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. -4. Make a short plan for: - - what tables the component owns - - what public functions it exposes - - what data must be passed in from the app (auth, env vars, parent IDs) - - what stays in the app as wrappers or HTTP mounts -5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. -6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. -7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. -8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. -9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. -10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. - -## Choose the Shape - -Ask the user, then pick one path: - -| Goal | Shape | Reference | -|------|-------|-----------| -| Component for this app only | Local | `references/local-components.md` | -| Publish or share across apps | Packaged | `references/packaged-components.md` | -| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | -| Not sure | Default to local | `references/local-components.md` | - -Read exactly one reference file before proceeding. - -## Default Approach - -Unless the user explicitly wants an npm package, default to a local component: - -- Put it under `convex/components//` -- Define it with `defineComponent(...)` in its own `convex.config.ts` -- Install it from the app's `convex/convex.config.ts` with `app.use(...)` -- Let `npx convex dev` generate the component's own `_generated/` files - -## Component Skeleton - -A minimal local component with a table and two functions, plus the app wiring. - -```ts -// convex/components/notifications/convex.config.ts -import { defineComponent } from "convex/server"; - -export default defineComponent("notifications"); -``` - -```ts -// convex/components/notifications/schema.ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - notifications: defineTable({ - userId: v.string(), - message: v.string(), - read: v.boolean(), - }).index("by_user", ["userId"]), -}); -``` - -```ts -// convex/components/notifications/lib.ts -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server.js"; - -export const send = mutation({ - args: { userId: v.string(), message: v.string() }, - returns: v.id("notifications"), - handler: async (ctx, args) => { - return await ctx.db.insert("notifications", { - userId: args.userId, - message: args.message, - read: false, - }); - }, -}); - -export const listUnread = query({ - args: { userId: v.string() }, - returns: v.array( - v.object({ - _id: v.id("notifications"), - _creationTime: v.number(), - userId: v.string(), - message: v.string(), - read: v.boolean(), - }) - ), - handler: async (ctx, args) => { - return await ctx.db - .query("notifications") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .filter((q) => q.eq(q.field("read"), false)) - .collect(); - }, -}); -``` - -```ts -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import notifications from "./components/notifications/convex.config.js"; - -const app = defineApp(); -app.use(notifications); - -export default app; -``` - -```ts -// convex/notifications.ts (app-side wrapper) -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; -import { components } from "./_generated/api"; -import { getAuthUserId } from "@convex-dev/auth/server"; - -export const sendNotification = mutation({ - args: { message: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - await ctx.runMutation(components.notifications.lib.send, { - userId, - message: args.message, - }); - return null; - }, -}); - -export const myUnread = query({ - args: {}, - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - return await ctx.runQuery(components.notifications.lib.listUnread, { - userId, - }); - }, -}); -``` - -Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. - -## Critical Rules - -- Keep authentication in the app, because `ctx.auth` is not available inside components. -- Keep environment access in the app, because component functions cannot read `process.env`. -- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. -- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. -- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. -- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. -- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. -- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. -- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. - -## Patterns - -### Authentication and environment access - -```ts -// Bad: component code cannot rely on app auth or env -const identity = await ctx.auth.getUserIdentity(); -const apiKey = process.env.OPENAI_API_KEY; -``` - -```ts -// Good: the app resolves auth and env, then passes explicit values -const userId = await getAuthUserId(ctx); -if (!userId) throw new Error("Not authenticated"); - -await ctx.runAction(components.translator.translate, { - userId, - apiKey: process.env.OPENAI_API_KEY, - text: args.text, -}); -``` - -### Client-facing API - -```ts -// Bad: assuming a component function is directly callable by clients -export const send = components.notifications.send; -``` - -```ts -// Good: re-export through an app mutation or query -export const sendNotification = mutation({ - args: { message: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - await ctx.runMutation(components.notifications.lib.send, { - userId, - message: args.message, - }); - return null; - }, -}); -``` - -### IDs across the boundary - -```ts -// Bad: parent app table IDs are not valid component validators -args: { userId: v.id("users") } -``` - -```ts -// Good: treat parent-owned IDs as strings at the boundary -args: { userId: v.string() } -``` - -### Advanced Patterns - -For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. - -## Validation - -Try validation in this order: - -1. `npx convex codegen --component-dir convex/components/` -2. `npx convex codegen` -3. `npx convex dev` - -Important: - -- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. -- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. -- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. - -## Reference Files - -Read exactly one of these after the user confirms the goal: - -- `references/local-components.md` -- `references/packaged-components.md` -- `references/hybrid-components.md` - -Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) - -## Checklist - -- [ ] Asked the user what they want to build and confirmed the shape -- [ ] Read the matching reference file -- [ ] Confirmed a component is the right abstraction -- [ ] Planned tables, public API, boundaries, and app wrappers -- [ ] Component lives under `convex/components//` (or package layout if publishing) -- [ ] Component imports from its own `./_generated/server` -- [ ] Auth, env access, and HTTP routes stay in the app -- [ ] Parent app IDs cross the boundary as `v.string()` -- [ ] Public functions have `args` and `returns` validators -- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/.claude/skills/convex-create-component/agents/openai.yaml b/.claude/skills/convex-create-component/agents/openai.yaml deleted file mode 100644 index ba9287e4..00000000 --- a/.claude/skills/convex-create-component/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Create Component" - short_description: "Design and build reusable Convex components with clear boundaries." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#14B8A6" - default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." - -policy: - allow_implicit_invocation: true diff --git a/.claude/skills/convex-create-component/assets/icon.svg b/.claude/skills/convex-create-component/assets/icon.svg deleted file mode 100644 index 10f4c2c4..00000000 --- a/.claude/skills/convex-create-component/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.claude/skills/convex-create-component/references/advanced-patterns.md b/.claude/skills/convex-create-component/references/advanced-patterns.md deleted file mode 100644 index 3deb684c..00000000 --- a/.claude/skills/convex-create-component/references/advanced-patterns.md +++ /dev/null @@ -1,134 +0,0 @@ -# Advanced Component Patterns - -Additional patterns for Convex components that go beyond the basics covered in the main skill file. - -## Function Handles for callbacks - -When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. - -```ts -// App side: create a handle and pass it to the component -import { createFunctionHandle } from "convex/server"; - -export const startJob = mutation({ - handler: async (ctx) => { - const handle = await createFunctionHandle(internal.myModule.processItem); - await ctx.runMutation(components.workpool.enqueue, { - callback: handle, - }); - }, -}); -``` - -```ts -// Component side: accept and invoke the handle -import { v } from "convex/values"; -import type { FunctionHandle } from "convex/server"; -import { mutation } from "./_generated/server.js"; - -export const enqueue = mutation({ - args: { callback: v.string() }, - handler: async (ctx, args) => { - const handle = args.callback as FunctionHandle<"mutation">; - await ctx.scheduler.runAfter(0, handle, {}); - }, -}); -``` - -## Deriving validators from schema - -Instead of manually repeating field types in return validators, extend the schema validator: - -```ts -import { v } from "convex/values"; -import schema from "./schema.js"; - -const notificationDoc = schema.tables.notifications.validator.extend({ - _id: v.id("notifications"), - _creationTime: v.number(), -}); - -export const getLatest = query({ - args: {}, - returns: v.nullable(notificationDoc), - handler: async (ctx) => { - return await ctx.db.query("notifications").order("desc").first(); - }, -}); -``` - -## Static configuration with a globals table - -A common pattern for component configuration is a single-document "globals" table: - -```ts -// schema.ts -export default defineSchema({ - globals: defineTable({ - maxRetries: v.number(), - webhookUrl: v.optional(v.string()), - }), - // ... other tables -}); -``` - -```ts -// lib.ts -export const configure = mutation({ - args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, - returns: v.null(), - handler: async (ctx, args) => { - const existing = await ctx.db.query("globals").first(); - if (existing) { - await ctx.db.patch(existing._id, args); - } else { - await ctx.db.insert("globals", args); - } - return null; - }, -}); -``` - -## Class-based client wrappers - -For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. - -```ts -// src/client/index.ts -import type { GenericMutationCtx, GenericDataModel } from "convex/server"; -import type { ComponentApi } from "../component/_generated/component.js"; - -type MutationCtx = Pick, "runMutation">; - -export class Notifications { - constructor( - private component: ComponentApi, - private options?: { defaultChannel?: string }, - ) {} - - async send(ctx: MutationCtx, args: { userId: string; message: string }) { - return await ctx.runMutation(this.component.lib.send, { - ...args, - channel: this.options?.defaultChannel ?? "default", - }); - } -} -``` - -```ts -// App usage -import { Notifications } from "@convex-dev/notifications"; -import { components } from "./_generated/api"; - -const notifications = new Notifications(components.notifications, { - defaultChannel: "alerts", -}); - -export const send = mutation({ - args: { message: v.string() }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - await notifications.send(ctx, { userId, message: args.message }); - }, -}); -``` diff --git a/.claude/skills/convex-create-component/references/hybrid-components.md b/.claude/skills/convex-create-component/references/hybrid-components.md deleted file mode 100644 index d2bb3514..00000000 --- a/.claude/skills/convex-create-component/references/hybrid-components.md +++ /dev/null @@ -1,37 +0,0 @@ -# Hybrid Convex Components - -Read this file only when the user explicitly wants a hybrid setup. - -## What This Means - -A hybrid component combines a local Convex component with shared library code. - -This can help when: - -- the user wants a local install but also shared package logic -- the component needs extension points or override hooks -- some logic should live in normal TypeScript code outside the component boundary - -## Default Advice - -Treat hybrid as an advanced option, not the default. - -Before choosing it, ask: - -- Why is a plain local component not enough? -- Why is a packaged component not enough? -- What exactly needs to stay overridable or shared? - -If the answer is vague, fall back to local or packaged. - -## Risks - -- More moving parts -- Harder upgrades and backwards compatibility -- Easier to blur the component boundary - -## Checklist - -- [ ] User explicitly needs hybrid behavior -- [ ] Local-only and packaged-only options were considered first -- [ ] The extension points are clearly defined before coding diff --git a/.claude/skills/convex-create-component/references/local-components.md b/.claude/skills/convex-create-component/references/local-components.md deleted file mode 100644 index 7fbfe21a..00000000 --- a/.claude/skills/convex-create-component/references/local-components.md +++ /dev/null @@ -1,38 +0,0 @@ -# Local Convex Components - -Read this file when the component should live inside the current app and does not need to be published as an npm package. - -## When to Choose This - -- The user wants the simplest path -- The component only needs to work in this repo -- The goal is extracting app logic into a cleaner boundary - -## Default Layout - -Use this structure unless the repo already has a clear alternative pattern: - -```text -convex/ - convex.config.ts - components/ - / - convex.config.ts - schema.ts - .ts -``` - -## Workflow Notes - -- Define the component with `defineComponent("")` -- Install it from the app with `defineApp()` and `app.use(...)` -- Keep auth, env access, public API wrappers, and HTTP route mounting in the app -- Let the component own isolated tables and reusable backend workflows -- Add app wrappers if clients need to call into the component - -## Checklist - -- [ ] Component is inside `convex/components//` -- [ ] App installs it with `app.use(...)` -- [ ] Component owns only its own tables -- [ ] App wrappers handle client-facing calls when needed diff --git a/.claude/skills/convex-create-component/references/packaged-components.md b/.claude/skills/convex-create-component/references/packaged-components.md deleted file mode 100644 index 5668e7ed..00000000 --- a/.claude/skills/convex-create-component/references/packaged-components.md +++ /dev/null @@ -1,51 +0,0 @@ -# Packaged Convex Components - -Read this file when the user wants a reusable npm package or a component shared across multiple apps. - -## When to Choose This - -- The user wants to publish the component -- The user wants a stable reusable package boundary -- The component will be shared across multiple apps or teams - -## Default Approach - -- Prefer starting from `npx create-convex@latest --component` when possible -- Keep the official authoring docs as the source of truth for package layout and exports -- Validate the bundled package through an example app, not just the source files - -## Build Flow - -When building a packaged component, make sure the bundled output exists before the example app tries to consume it. - -Recommended order: - -1. `npx convex codegen --component-dir ./path/to/component` -2. Run the package build command -3. Run `npx convex dev --typecheck-components` in the example app - -Do not assume normal app codegen is enough for packaged component workflows. - -## Package Exports - -If publishing to npm, make sure the package exposes the entry points apps need: - -- package root for client helpers, types, or classes -- `./convex.config.js` for installing the component -- `./_generated/component.js` for the app-facing `ComponentApi` type -- `./test` for testing helpers when applicable - -## Testing - -- Use `convex-test` for component logic -- Register the component schema and modules with the test instance -- Test app-side wrapper code from an example app that installs the package -- Export a small helper from `./test` if consumers need easy test registration - -## Checklist - -- [ ] Packaging is actually required -- [ ] Build order avoids bundle and codegen races -- [ ] Package exports include install and typing entry points -- [ ] Example app exercises the packaged component -- [ ] Core behavior is covered by tests diff --git a/.claude/skills/convex-migration-helper b/.claude/skills/convex-migration-helper new file mode 120000 index 00000000..81eeed18 --- /dev/null +++ b/.claude/skills/convex-migration-helper @@ -0,0 +1 @@ +../../.agents/skills/convex-migration-helper \ No newline at end of file diff --git a/.claude/skills/convex-migration-helper/SKILL.md b/.claude/skills/convex-migration-helper/SKILL.md deleted file mode 100644 index 97f64c1a..00000000 --- a/.claude/skills/convex-migration-helper/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: convex-migration-helper -description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. ---- - -# Convex Migration Helper - -Safely migrate Convex schemas and data when making breaking changes. - -## When to Use - -- Adding new required fields to existing tables -- Changing field types or structure -- Splitting or merging tables -- Renaming or deleting fields -- Migrating from nested to relational data - -## When Not to Use - -- Greenfield schema with no existing data in production or dev -- Adding optional fields that do not need backfilling -- Adding new tables with no existing data to migrate -- Adding or removing indexes with no correctness concern -- Questions about Convex schema design without a migration need - -## Key Concepts - -### Schema Validation Drives the Workflow - -Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: - -- You cannot add a required field if existing documents don't have it -- You cannot change a field's type if existing documents have the old type -- You cannot remove a field from the schema if existing documents still have it - -This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. - -### Online Migrations - -Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. - -### Prefer New Fields Over Changing Types - -When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. - -### Don't Delete Data - -Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. - -## Safe Changes (No Migration Needed) - -### Adding Optional Field - -```typescript -// Before -users: defineTable({ - name: v.string(), -}) - -// After - safe, new field is optional -users: defineTable({ - name: v.string(), - bio: v.optional(v.string()), -}) -``` - -### Adding New Table - -```typescript -posts: defineTable({ - userId: v.id("users"), - title: v.string(), -}).index("by_user", ["userId"]) -``` - -### Adding Index - -```typescript -users: defineTable({ - name: v.string(), - email: v.string(), -}) - .index("by_email", ["email"]) -``` - -## Breaking Changes: The Deployment Workflow - -Every breaking migration follows the same multi-deploy pattern: - -**Deploy 1 - Widen the schema:** - -1. Update schema to allow both old and new formats (e.g., add optional new field) -2. Update code to handle both formats when reading -3. Update code to write the new format for new documents -4. Deploy - -**Between deploys - Migrate data:** - -5. Run migration to backfill existing documents -6. Verify all documents are migrated - -**Deploy 2 - Narrow the schema:** - -7. Update schema to require the new format only -8. Remove code that handles the old format -9. Deploy - -## Using the Migrations Component - -For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. - -See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. - -## Common Migration Patterns - -See `references/migration-patterns.md` for complete patterns with code examples covering: - -- Adding a required field -- Deleting a field -- Changing a field type -- Splitting nested data into a separate table -- Cleaning up orphaned documents -- Zero-downtime strategies (dual write, dual read) -- Small table shortcut (single internalMutation without the component) -- Verifying a migration is complete - -## Common Pitfalls - -1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. -2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. -3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." -4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. -5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. -6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. - -## Migration Checklist - -- [ ] Identify the breaking change and plan the multi-deploy workflow -- [ ] Update schema to allow both old and new formats -- [ ] Update code to handle both formats when reading -- [ ] Update code to write the new format for new documents -- [ ] Deploy widened schema and updated code -- [ ] Define migration using the `@convex-dev/migrations` component -- [ ] Test with `dryRun: true` -- [ ] Run migration and monitor status -- [ ] Verify all documents are migrated -- [ ] Update schema to require new format only -- [ ] Clean up code that handled old format -- [ ] Deploy final schema and code -- [ ] Remove migration code once confirmed stable diff --git a/.claude/skills/convex-migration-helper/agents/openai.yaml b/.claude/skills/convex-migration-helper/agents/openai.yaml deleted file mode 100644 index c2a7fcc5..00000000 --- a/.claude/skills/convex-migration-helper/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Migration Helper" - short_description: "Plan and run safe Convex schema and data migrations." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#8B5CF6" - default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." - -policy: - allow_implicit_invocation: true diff --git a/.claude/skills/convex-migration-helper/assets/icon.svg b/.claude/skills/convex-migration-helper/assets/icon.svg deleted file mode 100644 index fba7241a..00000000 --- a/.claude/skills/convex-migration-helper/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.claude/skills/convex-migration-helper/references/migration-patterns.md b/.claude/skills/convex-migration-helper/references/migration-patterns.md deleted file mode 100644 index 219583e0..00000000 --- a/.claude/skills/convex-migration-helper/references/migration-patterns.md +++ /dev/null @@ -1,231 +0,0 @@ -# Migration Patterns Reference - -Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. - -## Adding a Required Field - -```typescript -// Deploy 1: Schema allows both states -users: defineTable({ - name: v.string(), - role: v.optional(v.union(v.literal("user"), v.literal("admin"))), -}) - -// Migration: backfill the field -export const addDefaultRole = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.role === undefined) { - await ctx.db.patch(user._id, { role: "user" }); - } - }, -}); - -// Deploy 2: After migration completes, make it required -users: defineTable({ - name: v.string(), - role: v.union(v.literal("user"), v.literal("admin")), -}) -``` - -## Deleting a Field - -Mark the field optional first, migrate data to remove it, then remove from schema: - -```typescript -// Deploy 1: Make optional -// isPro: v.boolean() --> isPro: v.optional(v.boolean()) - -// Migration -export const removeIsPro = migrations.define({ - table: "teams", - migrateOne: async (ctx, team) => { - if (team.isPro !== undefined) { - await ctx.db.patch(team._id, { isPro: undefined }); - } - }, -}); - -// Deploy 2: Remove isPro from schema entirely -``` - -## Changing a Field Type - -Prefer creating a new field. You can combine adding and deleting in one migration: - -```typescript -// Deploy 1: Add new field, keep old field optional -// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) - -// Migration: convert old field to new field -export const convertToEnum = migrations.define({ - table: "teams", - migrateOne: async (ctx, team) => { - if (team.plan === undefined) { - await ctx.db.patch(team._id, { - plan: team.isPro ? "pro" : "basic", - isPro: undefined, - }); - } - }, -}); - -// Deploy 2: Remove isPro from schema, make plan required -``` - -## Splitting Nested Data Into a Separate Table - -```typescript -export const extractPreferences = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.preferences === undefined) return; - - const existing = await ctx.db - .query("userPreferences") - .withIndex("by_user", (q) => q.eq("userId", user._id)) - .first(); - - if (!existing) { - await ctx.db.insert("userPreferences", { - userId: user._id, - ...user.preferences, - }); - } - - await ctx.db.patch(user._id, { preferences: undefined }); - }, -}); -``` - -Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. - -## Cleaning Up Orphaned Documents - -```typescript -export const deleteOrphanedEmbeddings = migrations.define({ - table: "embeddings", - migrateOne: async (ctx, doc) => { - const chunk = await ctx.db - .query("chunks") - .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) - .first(); - - if (!chunk) { - await ctx.db.delete(doc._id); - } - }, -}); -``` - -## Zero-Downtime Strategies - -During the migration window, your app must handle both old and new data formats. There are two main strategies. - -### Dual Write (Preferred) - -Write to both old and new structures. Read from the old structure until migration is complete. - -1. Deploy code that writes both formats, reads old format -2. Run migration on existing data -3. Deploy code that reads new format, still writes both -4. Deploy code that only reads and writes new format - -This is preferred because you can safely roll back at any point, the old format is always up to date. - -```typescript -// Bad: only writing to new structure before migration is done -export const createTeam = mutation({ - args: { name: v.string(), isPro: v.boolean() }, - handler: async (ctx, args) => { - await ctx.db.insert("teams", { - name: args.name, - plan: args.isPro ? "pro" : "basic", - }); - }, -}); - -// Good: writing to both structures during migration -export const createTeam = mutation({ - args: { name: v.string(), isPro: v.boolean() }, - handler: async (ctx, args) => { - const plan = args.isPro ? "pro" : "basic"; - await ctx.db.insert("teams", { - name: args.name, - isPro: args.isPro, - plan, - }); - }, -}); -``` - -### Dual Read - -Read both formats. Write only the new format. - -1. Deploy code that reads both formats (preferring new), writes only new format -2. Run migration on existing data -3. Deploy code that reads and writes only new format - -This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. - -```typescript -// Good: reading both formats, preferring new -function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { - if (team.plan !== undefined) return team.plan; - return team.isPro ? "pro" : "basic"; -} -``` - -## Small Table Shortcut - -For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: - -```typescript -import { internalMutation } from "./_generated/server"; - -export const backfillSmallTable = internalMutation({ - handler: async (ctx) => { - const docs = await ctx.db.query("smallConfig").collect(); - for (const doc of docs) { - if (doc.newField === undefined) { - await ctx.db.patch(doc._id, { newField: "default" }); - } - } - }, -}); -``` - -```bash -npx convex run migrations:backfillSmallTable -``` - -Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. - -## Verifying a Migration - -Query to check remaining unmigrated documents: - -```typescript -import { query } from "./_generated/server"; - -export const verifyMigration = query({ - handler: async (ctx) => { - const remaining = await ctx.db - .query("users") - .filter((q) => q.eq(q.field("role"), undefined)) - .take(10); - - return { - complete: remaining.length === 0, - sampleRemaining: remaining.map((u) => u._id), - }; - }, -}); -``` - -Or use the component's built-in status monitoring: - -```bash -npx convex run --component migrations lib:getStatus --watch -``` diff --git a/.claude/skills/convex-migration-helper/references/migrations-component.md b/.claude/skills/convex-migration-helper/references/migrations-component.md deleted file mode 100644 index c80522f2..00000000 --- a/.claude/skills/convex-migration-helper/references/migrations-component.md +++ /dev/null @@ -1,170 +0,0 @@ -# Migrations Component Reference - -Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. - -## Installation - -```bash -npm install @convex-dev/migrations -``` - -## Setup - -```typescript -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import migrations from "@convex-dev/migrations/convex.config.js"; - -const app = defineApp(); -app.use(migrations); -export default app; -``` - -```typescript -// convex/migrations.ts -import { Migrations } from "@convex-dev/migrations"; -import { components } from "./_generated/api.js"; -import { DataModel } from "./_generated/dataModel.js"; - -export const migrations = new Migrations(components.migrations); -export const run = migrations.runner(); -``` - -The `DataModel` type parameter is optional but provides type safety for migration definitions. - -## Define a Migration - -The `migrateOne` function processes a single document. The component handles batching and pagination automatically. - -```typescript -// convex/migrations.ts -export const addDefaultRole = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.role === undefined) { - await ctx.db.patch(user._id, { role: "user" }); - } - }, -}); -``` - -Shorthand: if you return an object, it is applied as a patch automatically. - -```typescript -export const clearDeprecatedField = migrations.define({ - table: "users", - migrateOne: () => ({ legacyField: undefined }), -}); -``` - -## Run a Migration - -From the CLI: - -```bash -# Define a one-off runner in convex/migrations.ts: -# export const runIt = migrations.runner(internal.migrations.addDefaultRole); -npx convex run migrations:runIt - -# Or use the general-purpose runner -npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' -``` - -Programmatically from another Convex function: - -```typescript -await migrations.runOne(ctx, internal.migrations.addDefaultRole); -``` - -## Run Multiple Migrations in Order - -```typescript -export const runAll = migrations.runner([ - internal.migrations.addDefaultRole, - internal.migrations.clearDeprecatedField, - internal.migrations.normalizeEmails, -]); -``` - -```bash -npx convex run migrations:runAll -``` - -If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. - -## Dry Run - -Test a migration before committing changes: - -```bash -npx convex run migrations:runIt '{"dryRun": true}' -``` - -This runs one batch and then rolls back, so you can see what it would do without changing any data. - -## Check Migration Status - -```bash -npx convex run --component migrations lib:getStatus --watch -``` - -## Cancel a Running Migration - -```bash -npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' -``` - -Or programmatically: - -```typescript -await migrations.cancel(ctx, internal.migrations.addDefaultRole); -``` - -## Run Migrations on Deploy - -Chain migration execution after deploying: - -```bash -npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod -``` - -## Configuration Options - -### Custom Batch Size - -If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: - -```typescript -export const migrateHeavyTable = migrations.define({ - table: "largeDocuments", - batchSize: 10, - migrateOne: async (ctx, doc) => { - // migration logic - }, -}); -``` - -### Migrate a Subset Using an Index - -Process only matching documents instead of the full table: - -```typescript -export const fixEmptyNames = migrations.define({ - table: "users", - customRange: (query) => - query.withIndex("by_name", (q) => q.eq("name", "")), - migrateOne: () => ({ name: "" }), -}); -``` - -### Parallelize Within a Batch - -By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: - -```typescript -export const clearField = migrations.define({ - table: "myTable", - parallelize: true, - migrateOne: () => ({ optionalField: undefined }), -}); -``` diff --git a/.claude/skills/convex-performance-audit b/.claude/skills/convex-performance-audit new file mode 120000 index 00000000..1bff1e5e --- /dev/null +++ b/.claude/skills/convex-performance-audit @@ -0,0 +1 @@ +../../.agents/skills/convex-performance-audit \ No newline at end of file diff --git a/.claude/skills/convex-performance-audit/SKILL.md b/.claude/skills/convex-performance-audit/SKILL.md deleted file mode 100644 index 9d92b33c..00000000 --- a/.claude/skills/convex-performance-audit/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: convex-performance-audit -description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. ---- - -# Convex Performance Audit - -Diagnose and fix performance problems in Convex applications, one problem class at a time. - -## When to Use - -- A Convex page or feature feels slow or expensive -- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts -- Low-freshness read paths are using reactivity where point-in-time reads would do -- OCC conflict errors or excessive mutation retries -- High subscription count or slow UI updates -- Functions approaching execution or transaction limits -- The same performance pattern needs fixing across sibling functions - -## When Not to Use - -- Initial Convex setup, auth setup, or component extraction -- Pure schema migrations with no performance goal -- One-off micro-optimizations without a user-visible or deployment-visible problem - -## Guardrails - -- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak -- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path -- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale - -## First Step: Gather Signals - -Start with the strongest signal available: - -1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. -2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. - - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. -3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. -4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. - -## Signal Routing - -After gathering signals, identify the problem class and read the matching reference file. - -| Signal | Reference | -|---|---| -| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | -| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | -| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | -| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | -| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | - -Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. - -## Escalate Larger Fixes - -If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. - -Examples: - -- introducing digest or summary tables across multiple flows -- splitting documents to isolate frequently-updated fields -- reworking pagination or fetch strategy across several screens -- switching to a new index or denormalized field that needs migration-safe rollout - -When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. - -## Workflow - -### 1. Scope the problem - -Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. - -Write down: - -- entrypoint functions -- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` -- tables read -- tables written -- whether the path is high-read, high-write, or both - -### 2. Trace the full read and write set - -For each function in the path: - -1. Trace every `ctx.db.get()` and `ctx.db.query()` -2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` -3. Note foreign-key lookups, JS-side filtering, and full-document reads -4. Identify all sibling functions touching the same tables -5. Identify reactive stats, aggregates, or widgets rendered on the same page - -In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. - -### 3. Apply fixes from the relevant reference - -Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. - -Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. - -### 4. Fix sibling functions together - -When one function touching a table has a performance bug, audit sibling functions for the same pattern. - -After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. - -Examples: - -- If one list query switches from full docs to a digest table, inspect the other list queries for that table -- If one mutation needs no-op write protection, inspect the other writers to the same table -- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk - -Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. - -### 5. Verify before finishing - -Confirm all of these: - -1. Results are the same as before, no dropped records -2. Eliminated reads or writes are no longer in the path where expected -3. Fallback behavior works when denormalized or indexed fields are missing -4. New writes avoid unnecessary invalidation when data is unchanged -5. Every relevant sibling reader and writer was inspected, not just the original function - -## Reference Files - -- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables -- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting -- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads -- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size - -Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. - -## Checklist - -- [ ] Gathered signals from insights, dashboard, or code audit -- [ ] Identified the problem class and read the matching reference -- [ ] Scoped one concrete user flow or function path -- [ ] Traced every read and write in that path -- [ ] Identified sibling functions touching the same tables -- [ ] Applied fixes from the reference, following the recommended fix order -- [ ] Fixed sibling functions consistently -- [ ] Verified behavior and confirmed no regressions diff --git a/.claude/skills/convex-performance-audit/agents/openai.yaml b/.claude/skills/convex-performance-audit/agents/openai.yaml deleted file mode 100644 index 9a21f387..00000000 --- a/.claude/skills/convex-performance-audit/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Performance Audit" - short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#EF4444" - default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." - -policy: - allow_implicit_invocation: true diff --git a/.claude/skills/convex-performance-audit/assets/icon.svg b/.claude/skills/convex-performance-audit/assets/icon.svg deleted file mode 100644 index 7ab9e09c..00000000 --- a/.claude/skills/convex-performance-audit/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.claude/skills/convex-performance-audit/references/function-budget.md b/.claude/skills/convex-performance-audit/references/function-budget.md deleted file mode 100644 index c71d14cb..00000000 --- a/.claude/skills/convex-performance-audit/references/function-budget.md +++ /dev/null @@ -1,232 +0,0 @@ -# Function Budget - -Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. - -## Core Principle - -Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. - -## Limits to Know - -These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. - -| Resource | Limit | -|---|---| -| Query/mutation execution time | 1 second (user code only, excludes DB operations) | -| Action execution time | 10 minutes | -| Data read per transaction | 16 MiB | -| Data written per transaction | 16 MiB | -| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | -| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | -| Documents written per transaction | 16,000 | -| Individual document size | 1 MiB | -| Function return value size | 16 MiB | - -## Symptoms - -- "Function execution took too long" errors -- "Transaction too large" or read/write set size errors -- Slow queries that read many documents -- Client receiving large payloads that slow down page load -- `npx convex insights --details` showing high bytes read - -## Common Causes - -### Unbounded collection - -A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. - -### Large document reads on hot paths - -Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. - -### Mutation doing too much work - -A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. - -### Returning too much data to the client - -A query returning full documents when the client only needs a few fields. - -## Fix Order - -### 1. Bound your reads - -Never `.collect()` without a limit on a table that can grow unbounded. - -```ts -// Bad: unbounded read, breaks as the table grows -const messages = await ctx.db.query("messages").collect(); -``` - -```ts -// Good: paginate or limit -const messages = await ctx.db - .query("messages") - .withIndex("by_channel", (q) => q.eq("channelId", channelId)) - .order("desc") - .take(50); -``` - -### 2. Read smaller shapes - -If the list page only needs title, author, and date, do not read full documents with rich content fields. - -Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. - -### 3. Break large mutations into batches - -If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. - -```ts -// Bad: one mutation updating every row -export const backfillAll = internalMutation({ - handler: async (ctx) => { - const docs = await ctx.db.query("items").collect(); - for (const doc of docs) { - await ctx.db.patch(doc._id, { newField: computeValue(doc) }); - } - }, -}); -``` - -```ts -// Good: cursor-based batch processing -export const backfillBatch = internalMutation({ - args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, - handler: async (ctx, args) => { - const batchSize = args.batchSize ?? 100; - const result = await ctx.db - .query("items") - .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); - - for (const doc of result.page) { - if (doc.newField === undefined) { - await ctx.db.patch(doc._id, { newField: computeValue(doc) }); - } - } - - if (!result.isDone) { - await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { - cursor: result.continueCursor, - batchSize, - }); - } - }, -}); -``` - -### 4. Move heavy work to actions - -Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. - -Actions run outside the transaction and can call mutations to write results back. - -```ts -// Bad: heavy computation inside a mutation -export const processUpload = mutation({ - handler: async (ctx, args) => { - const result = expensiveComputation(args.data); - await ctx.db.insert("results", result); - }, -}); -``` - -```ts -// Good: action for heavy work, mutation for the write -export const processUpload = action({ - handler: async (ctx, args) => { - const result = expensiveComputation(args.data); - await ctx.runMutation(internal.results.store, { result }); - }, -}); -``` - -### 5. Trim return values - -Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. - -```ts -// Bad: returns full documents including large content fields -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("articles").take(20); - }, -}); -``` - -```ts -// Good: project to only the fields the client needs -export const list = query({ - handler: async (ctx) => { - const articles = await ctx.db.query("articles").take(20); - return articles.map((a) => ({ - _id: a._id, - title: a.title, - author: a.author, - createdAt: a._creationTime, - })); - }, -}); -``` - -### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions - -Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. - -```ts -// Bad: unnecessary overhead from ctx.runQuery inside a mutation -export const createProject = mutation({ - handler: async (ctx, args) => { - const user = await ctx.runQuery(api.users.getCurrentUser); - await ctx.db.insert("projects", { ...args, ownerId: user._id }); - }, -}); -``` - -```ts -// Good: plain helper function, no extra overhead -export const createProject = mutation({ - handler: async (ctx, args) => { - const user = await getCurrentUser(ctx); - await ctx.db.insert("projects", { ...args, ownerId: user._id }); - }, -}); -``` - -Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. - -### 7. Avoid unnecessary `runAction` calls - -`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). - -```ts -// Bad: runAction overhead for no reason -export const processItems = action({ - handler: async (ctx, args) => { - for (const item of args.items) { - await ctx.runAction(internal.items.processOne, { item }); - } - }, -}); -``` - -```ts -// Good: plain function call -export const processItems = action({ - handler: async (ctx, args) => { - for (const item of args.items) { - await processOneItem(ctx, { item }); - } - }, -}); -``` - -## Verification - -1. No function execution or transaction size errors -2. `npx convex insights --details` shows reduced bytes read -3. Large mutations are batched and self-scheduling -4. Client payloads are reasonably sized for the UI they serve -5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible -6. Sibling functions with similar patterns were checked diff --git a/.claude/skills/convex-performance-audit/references/hot-path-rules.md b/.claude/skills/convex-performance-audit/references/hot-path-rules.md deleted file mode 100644 index e3e44b15..00000000 --- a/.claude/skills/convex-performance-audit/references/hot-path-rules.md +++ /dev/null @@ -1,371 +0,0 @@ -# Hot Path Rules - -Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. - -## Contents - -- Core Principle -- Consistency Rule -- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) -- 2. Minimize Data Sources (denormalization, fallback rule) -- 3. Minimize Row Size (digest tables) -- 4. Skip No-Op Writes -- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) -- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) -- Verification - -## Core Principle - -Every byte read or written multiplies with concurrency. - -Think: - -`cost x calls_per_second x 86400` - -In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. - -## Consistency Rule - -If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. - -Do this especially for: - -- multiple list queries over the same table -- multiple writers to the same table -- public browse and search queries over the same records -- helper functions reused by more than one endpoint - -## 1. Push Filters To Storage - -Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. - -Prefer: - -- `withIndex(...)` -- `.withSearchIndex(...)` for text search -- narrower tables -- summary tables - -before accepting a scan-plus-filter pattern. - -```ts -// Bad: scans then filters in JavaScript -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - const tasks = await ctx.db.query("tasks").collect(); - return tasks.filter((task) => task.status === "open"); - }, -}); -``` - -```ts -// Also bad: Convex .filter() does not push to storage either -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("tasks") - .filter((q) => q.eq(q.field("status"), "open")) - .collect(); - }, -}); -``` - -```ts -// Good: use an index so storage does the filtering -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("tasks") - .withIndex("by_status", (q) => q.eq("status", "open")) - .collect(); - }, -}); -``` - -### Migration rule for indexes - -New indexes on partially backfilled fields can create correctness bugs during rollout. - -Important Convex detail: - -`undefined !== false` - -If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. - -Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. - -If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. - -```ts -// Bad: optional booleans can miss older rows where the field is undefined -const projects = await ctx.db - .query("projects") - .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) - .order("desc") - .take(20); -``` - -```ts -// Good: switch hot-path reads only after the rollout is migration-safe -// See the migration helper skill for dual-read / backfill / cutover patterns. -``` - -### Check for redundant indexes - -Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. - -```ts -// Bad: two indexes where one would do -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team", ["team"]) - .index("by_team_and_user", ["team", "user"]) -``` - -```ts -// Good: single compound index serves both query patterns -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team_and_user", ["team", "user"]) -``` - -Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. - -## 2. Minimize Data Sources - -Trace every read. - -If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. - -### When to denormalize - -Denormalize when all of these are true: - -- the path is hot -- the joined document is much larger than the field you need -- many readers are paying that join cost repeatedly - -Useful mental model: - -`join_cost = rows_per_page x foreign_doc_size x pages_per_second` - -Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. - -### Fallback rule - -Denormalized data is an optimization. Live data is the correctness path. - -Rules: - -- If the denormalized field is missing or null, fall back to the live read -- Do not show placeholders instead of falling back -- In lookup maps, only include fully populated entries - -```ts -// Bad: missing denormalized data becomes a placeholder and blocks correctness -const ownerName = project.ownerName ?? "Unknown owner"; -``` - -```ts -// Good: denormalized data is an optimization, not the only source of truth -const ownerName = - project.ownerName ?? - (await ctx.db.get(project.ownerId))?.name ?? - null; -``` - -Bad lookup map pattern: - -```ts -const ownersById = { - [project.ownerId]: { ownerName: null }, -}; -``` - -That blocks fallback because the map says "I have data" when it does not. - -Good lookup map pattern: - -```ts -const ownersById = - project.ownerName !== undefined && project.ownerName !== null - ? { [project.ownerId]: { ownerName: project.ownerName } } - : {}; -``` - -### No denormalized copy yet - -Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. - -If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. - -Rollout order: - -1. Update schema -2. Update write path -3. Backfill -4. Switch read path - -## 3. Minimize Row Size - -Hot list pages should read the smallest document shape that still answers the UI. - -Prefer summary or digest tables over full source tables when: - -- the list page only needs a subset of fields -- source documents are large -- the query is high volume - -An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. - -Digest tables are a tradeoff, not a default: - -- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost -- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit - -```ts -// Bad: list page reads source docs, then joins owner data per row -const projects = await ctx.db - .query("projects") - .withIndex("by_public", (q) => q.eq("isPublic", true)) - .collect(); -``` - -```ts -// Good: list page reads the smaller digest shape first -const projects = await ctx.db - .query("projectDigests") - .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) - .order("desc") - .take(20); -``` - -## 4. Skip No-Op Writes - -No-op writes still cost work in Convex: - -- invalidation -- replication -- trigger execution -- downstream sync - -Before `patch` or `replace`, compare against the existing document and skip the write if nothing changed. - -Apply this across sibling writers too. One careful writer does not help much if three other mutations still patch unconditionally. - -```ts -// Bad: patching unchanged values still triggers invalidation and downstream work -await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, -}); -``` - -```ts -// Good: only write when something actually changed -if (settings.theme !== args.theme || settings.locale !== args.locale) { - await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, - }); -} -``` - -## 5. Match Consistency To Read Patterns - -Choose read strategy based on traffic shape. - -### High-read, low-write - -Examples: - -- public browse pages -- search results -- landing pages -- directory listings - -Prefer: - -- point-in-time reads where appropriate -- explicit refresh -- local state for pagination -- caching where appropriate - -Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. - -### High-read, high-write - -Examples: - -- collaborative editors -- live dashboards -- presence-heavy views - -Reactive queries may be worth the ongoing cost. - -## Convex-Specific Notes - -### Reactive queries - -Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. - -On the client: - -- `useQuery` creates a live subscription -- `usePaginatedQuery` creates a live subscription per page - -For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. - -### Point-in-time reads - -Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. - -Use them for: - -- aggregate snapshots -- reports -- low-churn listings -- pages where explicit refresh is fine - -### Triggers and fan-out - -Triggers fire on every write, including writes that did not materially change the document. - -When a write exists only to keep derived state in sync: - -- diff before patching -- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate - -### Aggregates - -Reactive global counts invalidate frequently on busy tables. - -Prefer: - -- one-shot aggregate fetches -- periodic recomputation -- precomputed summary rows - -for global stats that do not need live updates every second. - -### Backfills - -For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. - -Deploy code that can handle both states before running the backfill. - -During the gap: - -- writes should populate the new shape -- reads should fall back safely - -## Verification - -Before closing the audit, confirm: - -1. Same results as before, no dropped records -2. The removed table or lookup is no longer in the hot-path read set -3. Tests or validation cover fallback behavior -4. Migration safety is preserved while fields or indexes are unbackfilled -5. Sibling functions were fixed consistently diff --git a/.claude/skills/convex-performance-audit/references/occ-conflicts.md b/.claude/skills/convex-performance-audit/references/occ-conflicts.md deleted file mode 100644 index a96d0466..00000000 --- a/.claude/skills/convex-performance-audit/references/occ-conflicts.md +++ /dev/null @@ -1,126 +0,0 @@ -# OCC Conflict Resolution - -Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. - -## Core Principle - -Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. - -## Symptoms - -- OCC conflict errors in deployment logs or health page -- Mutations retrying multiple times before succeeding -- User-visible latency spikes on write-heavy pages -- `npx convex insights --details` showing high conflict rates - -## Common Causes - -### Hot documents - -Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. - -### Broad read sets causing false conflicts - -A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. - -### Fan-out from triggers or cascading writes - -A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. - -Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. - -### Write-then-read chains - -A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. - -## Fix Order - -### 1. Reduce read set size - -Narrower reads mean fewer false conflicts. - -```ts -// Bad: broad scan creates a wide conflict surface -const allTasks = await ctx.db.query("tasks").collect(); -const mine = allTasks.filter((t) => t.ownerId === userId); -``` - -```ts -// Good: indexed query touches only relevant documents -const mine = await ctx.db - .query("tasks") - .withIndex("by_owner", (q) => q.eq("ownerId", userId)) - .collect(); -``` - -### 2. Split hot documents - -When many writers target the same document, split the contention point. - -```ts -// Bad: every vote increments the same counter document -const counter = await ctx.db.get(pollCounterId); -await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); -``` - -```ts -// Good: shard the counter across multiple documents, aggregate on read -const shardIndex = Math.floor(Math.random() * SHARD_COUNT); -const shardId = shardIds[shardIndex]; -const shard = await ctx.db.get(shardId); -await ctx.db.patch(shardId, { count: shard!.count + 1 }); -``` - -Aggregate the shards in a query or scheduled job when you need the total. - -### 3. Skip no-op writes - -Writes that do not change data still participate in conflict detection and trigger invalidation. - -```ts -// Bad: patches even when nothing changed -await ctx.db.patch(doc._id, { status: args.status }); -``` - -```ts -// Good: only write when the value actually differs -if (doc.status !== args.status) { - await ctx.db.patch(doc._id, { status: args.status }); -} -``` - -### 4. Move non-critical work to scheduled functions - -If a mutation does primary work plus secondary bookkeeping (analytics, notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. - -```ts -// Bad: analytics update in the same transaction as the user action -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.db.insert("analytics", { event: "action", userId, ts: Date.now() }); -``` - -```ts -// Good: schedule the bookkeeping so the primary transaction is smaller -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.scheduler.runAfter(0, internal.analytics.recordEvent, { - event: "action", - userId, -}); -``` - -### 5. Combine competing writes - -If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. - -Do not introduce artificial locks or queues unless the above steps have been tried first. - -## Related: Invalidation Scope - -Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. - -## Verification - -1. OCC conflict rate has dropped in insights or dashboard -2. Mutation latency is lower and more consistent -3. No data correctness regressions from splitting or scheduling changes -4. Sibling writers to the same hot documents were fixed consistently diff --git a/.claude/skills/convex-performance-audit/references/subscription-cost.md b/.claude/skills/convex-performance-audit/references/subscription-cost.md deleted file mode 100644 index ae7d1adb..00000000 --- a/.claude/skills/convex-performance-audit/references/subscription-cost.md +++ /dev/null @@ -1,252 +0,0 @@ -# Subscription Cost - -Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. - -## Core Principle - -Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: - -`subscriptions x invalidation_frequency x query_cost` - -Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. - -## Symptoms - -- Dashboard shows high active subscription count -- UI feels sluggish or laggy despite fast individual queries -- React profiling shows frequent re-renders from Convex state -- Pages with many components each running their own `useQuery` -- Paginated lists where every loaded page stays subscribed - -## Common Causes - -### Reactive queries on low-freshness flows - -Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. - -### Overly broad queries - -A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. - -### Too many subscriptions per page - -A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. - -### Paginated queries keeping all pages live - -`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. - -### Frequently-updated fields on widely-read documents - -A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. - -## Fix Order - -### 1. Use point-in-time reads when live updates are not valuable - -Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. - -Consider a point-in-time read instead when all of these are true: - -- the flow is high-read -- the underlying data changes less often than users need to see -- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable - -Possible implementations depend on environment: - -- a server-rendered fetch -- a framework helper like `fetchQuery` -- a point-in-time client read such as `ConvexHttpClient.query()` - -```ts -// Reactive by default when fresh live data matters -function TeamPresence() { - const presence = useQuery(api.teams.livePresence, { teamId }); - return ; -} -``` - -```ts -// Point-in-time read when explicit refresh is acceptable -import { ConvexHttpClient } from "convex/browser"; - -const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); - -function SnapshotView() { - const [items, setItems] = useState([]); - - useEffect(() => { - client.query(api.items.snapshot).then(setItems); - }, []); - - return ; -} -``` - -Good candidates for point-in-time reads: - -- aggregate snapshots -- reports -- low-churn listings -- flows where explicit refresh is already acceptable - -Keep reactive for: - -- collaborative editing -- live dashboards -- presence-heavy views -- any surface where users expect fresh changes to appear automatically - -### 2. Batch related data into fewer queries - -Instead of N components each fetching their own related data, fetch it in a single query. - -```ts -// Bad: each card fetches its own author -function ProjectCard({ project }: { project: Project }) { - const author = useQuery(api.users.get, { id: project.authorId }); - return ; -} -``` - -```ts -// Good: parent query returns projects with author names included -function ProjectList() { - const projects = useQuery(api.projects.listWithAuthors); - return projects?.map((p) => ( - - )); -} -``` - -This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. - -This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. - -### 3. Use skip to avoid unnecessary subscriptions - -The `"skip"` value prevents a subscription from being created when the arguments are not ready. - -```ts -// Bad: subscribes with undefined args, wastes a subscription slot -const profile = useQuery(api.users.getProfile, { userId: selectedId! }); -``` - -```ts -// Good: skip when there is nothing to fetch -const profile = useQuery( - api.users.getProfile, - selectedId ? { userId: selectedId } : "skip", -); -``` - -### 4. Isolate frequently-updated fields into separate documents - -If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. - -```ts -// Bad: lastSeen lives on the user doc, every heartbeat invalidates -// every query that reads this user -const users = defineTable({ - name: v.string(), - email: v.string(), - lastSeen: v.number(), -}); -``` - -```ts -// Good: lastSeen lives in a separate heartbeat doc -const users = defineTable({ - name: v.string(), - email: v.string(), - heartbeatId: v.id("heartbeats"), -}); - -const heartbeats = defineTable({ - lastSeen: v.number(), -}); -``` - -Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. - -For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. - -### 5. Use the aggregate component for counts and sums - -Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. - -Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. - -If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. - -### 6. Narrow query read sets - -Queries that return less data and touch fewer documents invalidate less often. - -```ts -// Bad: returns all fields, invalidates on any field change -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("projects").collect(); - }, -}); -``` - -```ts -// Good: use a digest table with only the fields the list needs -export const listDigests = query({ - handler: async (ctx) => { - return await ctx.db.query("projectDigests").collect(); - }, -}); -``` - -Writes to fields not in the digest table do not invalidate the digest query. - -### 7. Remove `Date.now()` from queries - -Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. - -```ts -// Bad: Date.now() defeats query caching and causes frequent re-evaluation -const releasedPosts = await ctx.db - .query("posts") - .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) - .take(100); -``` - -```ts -// Good: use a boolean field updated by a scheduled function -const releasedPosts = await ctx.db - .query("posts") - .withIndex("by_is_released", (q) => q.eq("isReleased", true)) - .take(100); -``` - -If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. - -### 8. Consider pagination strategy - -For long lists where users scroll through many pages: - -- If the data does not need live updates, use point-in-time fetching with manual "load more" -- If it does need live updates, accept the subscription cost but limit the number of loaded pages -- Consider whether older pages can be unloaded as the user scrolls forward - -### 9. Separate backend cost from UI churn - -If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. - -Treat this as a UX problem first when: - -- the underlying query is already reasonably cheap -- the complaint is flicker, loading flashes, or re-render churn -- live updates are still desirable once fresh data arrives - -## Verification - -1. Subscription count in dashboard is lower for the affected pages -2. UI responsiveness has improved -3. React profiling shows fewer unnecessary re-renders -4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily -5. Sibling pages with similar patterns were updated consistently diff --git a/.claude/skills/convex-quickstart b/.claude/skills/convex-quickstart new file mode 120000 index 00000000..9edf1975 --- /dev/null +++ b/.claude/skills/convex-quickstart @@ -0,0 +1 @@ +../../.agents/skills/convex-quickstart \ No newline at end of file diff --git a/.claude/skills/convex-quickstart/SKILL.md b/.claude/skills/convex-quickstart/SKILL.md deleted file mode 100644 index 792bba3d..00000000 --- a/.claude/skills/convex-quickstart/SKILL.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -name: convex-quickstart -description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." ---- - -# Convex Quickstart - -Set up a working Convex project as fast as possible. - -## When to Use - -- Starting a brand new project with Convex -- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app -- Scaffolding a Convex app for prototyping - -## When Not to Use - -- The project already has Convex installed and `convex/` exists - just start building -- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill - -## Workflow - -1. Determine the starting point: new project or existing app -2. If new project, pick a template and scaffold with `npm create convex@latest` -3. If existing app, install `convex` and wire up the provider -4. Run `npx convex dev` to connect a deployment and start the dev loop -5. Verify the setup works - -## Path 1: New Project (Recommended) - -Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. - -### Pick a template - -| Template | Stack | -|----------|-------| -| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | -| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | -| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | -| `nextjs-clerk` | Next.js + Clerk auth | -| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | -| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | -| `bare` | Convex backend only, no frontend | - -If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. - -You can also use any GitHub repo as a template: - -```bash -npm create convex@latest my-app -- -t owner/repo -npm create convex@latest my-app -- -t owner/repo#branch -``` - -### Scaffold the project - -Always pass the project name and template flag to avoid interactive prompts: - -```bash -npm create convex@latest my-app -- -t react-vite-shadcn -cd my-app -npm install -``` - -The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. - -To scaffold in the current directory (if it is empty): - -```bash -npm create convex@latest . -- -t react-vite-shadcn -npm install -``` - -### Start the dev loop - -`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. - -**Ask the user to run this themselves:** - -Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: -- Create a Convex project and dev deployment -- Write the deployment URL to `.env.local` -- Create the `convex/` directory with generated types -- Watch for changes and sync continuously - -The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. - -**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. - -### Start the frontend - -The user should also run the frontend dev server in a separate terminal: - -```bash -npm run dev -``` - -Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. - -### What you get - -After scaffolding, the project structure looks like: - -``` -my-app/ - convex/ # Backend functions and schema - _generated/ # Auto-generated types (check this into git) - schema.ts # Database schema (if template includes one) - src/ # Frontend code (or app/ for Next.js) - package.json - .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL -``` - -The template already has: -- `ConvexProvider` wired into the app root -- Correct env var names for the framework -- Tailwind and shadcn/ui ready (for shadcn templates) -- Auth provider configured (for auth templates) - -Proceed to adding schema, functions, and UI. - -## Path 2: Add Convex to an Existing App - -Use this when the user already has a frontend project and wants to add Convex as the backend. - -### Install - -```bash -npm install convex -``` - -### Initialize and start dev loop - -Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. - -### Wire up the provider - -The Convex client must wrap the app at the root. The setup varies by framework. - -Create the `ConvexReactClient` at module scope, not inside a component: - -```tsx -// Bad: re-creates the client on every render -function App() { - const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); - return ...; -} - -// Good: created once at module scope -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); -function App() { - return ...; -} -``` - -#### React (Vite) - -```tsx -// src/main.tsx -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { ConvexProvider, ConvexReactClient } from "convex/react"; -import App from "./App"; - -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); - -createRoot(document.getElementById("root")!).render( - - - - - , -); -``` - -#### Next.js (App Router) - -```tsx -// app/ConvexClientProvider.tsx -"use client"; - -import { ConvexProvider, ConvexReactClient } from "convex/react"; -import { ReactNode } from "react"; - -const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); - -export function ConvexClientProvider({ children }: { children: ReactNode }) { - return {children}; -} -``` - -```tsx -// app/layout.tsx -import { ConvexClientProvider } from "./ConvexClientProvider"; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); -} -``` - -#### Other frameworks - -For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: - -- [Vue](https://docs.convex.dev/quickstart/vue) -- [Svelte](https://docs.convex.dev/quickstart/svelte) -- [React Native](https://docs.convex.dev/quickstart/react-native) -- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) -- [Remix](https://docs.convex.dev/quickstart/remix) -- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) - -### Environment variables - -The env var name depends on the framework: - -| Framework | Variable | -|-----------|----------| -| Vite | `VITE_CONVEX_URL` | -| Next.js | `NEXT_PUBLIC_CONVEX_URL` | -| Remix | `CONVEX_URL` | -| React Native | `EXPO_PUBLIC_CONVEX_URL` | - -`npx convex dev` writes the correct variable to `.env.local` automatically. - -## Agent Mode (Cloud and Headless Agents) - -When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. - -Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: - -```bash -CONVEX_AGENT_MODE=anonymous npx convex dev -``` - -This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. - -## Verify the Setup - -After setup, confirm everything is working: - -1. The user confirms `npx convex dev` is running without errors -2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` -3. `.env.local` contains the deployment URL - -## Writing Your First Function - -Once the project is set up, create a schema and a query to verify the full loop works. - -`convex/schema.ts`: - -```ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - tasks: defineTable({ - text: v.string(), - completed: v.boolean(), - }), -}); -``` - -`convex/tasks.ts`: - -```ts -import { query, mutation } from "./_generated/server"; -import { v } from "convex/values"; - -export const list = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query("tasks").collect(); - }, -}); - -export const create = mutation({ - args: { text: v.string() }, - handler: async (ctx, args) => { - await ctx.db.insert("tasks", { text: args.text, completed: false }); - }, -}); -``` - -Use in a React component (adjust the import path based on your file location relative to `convex/`): - -```tsx -import { useQuery, useMutation } from "convex/react"; -import { api } from "../convex/_generated/api"; - -function Tasks() { - const tasks = useQuery(api.tasks.list); - const create = useMutation(api.tasks.create); - - return ( -
- - {tasks?.map((t) =>
{t.text}
)} -
- ); -} -``` - -## Development vs Production - -Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. - -When ready to ship, deploy to production: - -```bash -npx convex deploy -``` - -This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. - -## Next Steps - -- Add authentication: use the `convex-setup-auth` skill -- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) -- Build components: use the `convex-create-component` skill -- Plan a migration: use the `convex-migration-helper` skill -- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) -- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) - -## Checklist - -- [ ] Determined starting point: new project or existing app -- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template -- [ ] If existing app: installed `convex` and wired up the provider -- [ ] User has `npx convex dev` running and connected to a deployment -- [ ] `convex/_generated/` directory exists with types -- [ ] `.env.local` has the deployment URL -- [ ] Verified a basic query/mutation round-trip works diff --git a/.claude/skills/convex-quickstart/agents/openai.yaml b/.claude/skills/convex-quickstart/agents/openai.yaml deleted file mode 100644 index a51a6d09..00000000 --- a/.claude/skills/convex-quickstart/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Quickstart" - short_description: "Start a new Convex app or add Convex to an existing frontend." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#F97316" - default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." - -policy: - allow_implicit_invocation: true diff --git a/.claude/skills/convex-quickstart/assets/icon.svg b/.claude/skills/convex-quickstart/assets/icon.svg deleted file mode 100644 index d83a73f3..00000000 --- a/.claude/skills/convex-quickstart/assets/icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/.claude/skills/convex-setup-auth b/.claude/skills/convex-setup-auth new file mode 120000 index 00000000..a19c8377 --- /dev/null +++ b/.claude/skills/convex-setup-auth @@ -0,0 +1 @@ +../../.agents/skills/convex-setup-auth \ No newline at end of file diff --git a/.claude/skills/convex-setup-auth/SKILL.md b/.claude/skills/convex-setup-auth/SKILL.md deleted file mode 100644 index 0fa00e2f..00000000 --- a/.claude/skills/convex-setup-auth/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: convex-setup-auth -description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." ---- - -# Convex Authentication Setup - -Implement secure authentication in Convex with user management and access control. - -## When to Use - -- Setting up authentication for the first time -- Implementing user management (users table, identity mapping) -- Creating authentication helper functions -- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) - -## When Not to Use - -- Auth for a non-Convex backend -- Pure OAuth/OIDC documentation without a Convex implementation -- Debugging unrelated bugs that happen to surface near auth code -- The auth provider is already fully configured and the user only needs a one-line fix - -## First Step: Choose the Auth Provider - -Convex supports multiple authentication approaches. Do not assume a provider. - -Before writing setup code: - -1. Ask the user which auth solution they want, unless the repository already makes it obvious -2. If the repo already uses a provider, continue with that provider unless the user wants to switch -3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding - -Common options: - -- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex -- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features -- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically -- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 -- Custom JWT provider - use when integrating an existing auth system not covered above - -Look for signals in the repo before asking: - -- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages -- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components -- Environment variables that clearly point at a provider - -## After Choosing a Provider - -Read the provider's official guide and the matching local reference file: - -- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` -- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` -- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` -- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` - -The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. - -Use those sources for: - -- package installation -- client provider wiring -- environment variables -- `convex/auth.config.ts` setup -- login and logout UI patterns -- framework-specific setup for React, Vite, or Next.js - -For shared auth behavior, use the official Convex docs as the source of truth: - -- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` -- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage -- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance -- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth - -Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. -For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. -For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. -After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. - -## Core Pattern: Protecting Backend Functions - -The most common auth task is checking identity in Convex functions. - -```ts -// Bad: trusting a client-provided userId -export const getMyProfile = query({ - args: { userId: v.id("users") }, - handler: async (ctx, args) => { - return await ctx.db.get(args.userId); - }, -}); -``` - -```ts -// Good: verifying identity server-side -export const getMyProfile = query({ - args: {}, - handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Not authenticated"); - - return await ctx.db - .query("users") - .withIndex("by_tokenIdentifier", (q) => - q.eq("tokenIdentifier", identity.tokenIdentifier) - ) - .unique(); - }, -}); -``` - -## Workflow - -1. Determine the provider, either by asking the user or inferring from the repo -2. Ask whether the user wants local-only setup or production-ready setup now -3. Read the matching provider reference file -4. Follow the official provider docs for current setup details -5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns -6. Only add app-level user storage if the docs and app requirements call for it -7. Add authorization checks for ownership, roles, or team access only where the app needs them -8. Verify login state, protected queries, environment variables, and production configuration if requested - -If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. -For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. -If the environment has browser automation tools, you can use them. -If it does not, give the user a short manual validation checklist instead. - -## Reference Files - -### Provider References - -- `references/convex-auth.md` -- `references/clerk.md` -- `references/workos-authkit.md` -- `references/auth0.md` - -## Checklist - -- [ ] Chosen the correct auth provider before writing setup code -- [ ] Read the relevant provider reference file -- [ ] Asked whether the user wants local-only setup or production-ready setup -- [ ] Used the official provider docs for provider-specific wiring -- [ ] Used the official Convex docs for shared auth behavior and authorization patterns -- [ ] Only added app-level user storage if the app actually needs it -- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth -- [ ] Added authentication checks in protected backend functions -- [ ] Added authorization checks where the app actually needs them -- [ ] Clear error messages ("Not authenticated", "Unauthorized") -- [ ] Client auth provider configured for the chosen provider -- [ ] If requested, production auth setup is covered too diff --git a/.claude/skills/convex-setup-auth/agents/openai.yaml b/.claude/skills/convex-setup-auth/agents/openai.yaml deleted file mode 100644 index d1c90a14..00000000 --- a/.claude/skills/convex-setup-auth/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Setup Auth" - short_description: "Set up Convex auth, user identity mapping, and access control." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#2563EB" - default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." - -policy: - allow_implicit_invocation: true diff --git a/.claude/skills/convex-setup-auth/assets/icon.svg b/.claude/skills/convex-setup-auth/assets/icon.svg deleted file mode 100644 index 4917dbb4..00000000 --- a/.claude/skills/convex-setup-auth/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.claude/skills/convex-setup-auth/references/auth0.md b/.claude/skills/convex-setup-auth/references/auth0.md deleted file mode 100644 index 9c729c5a..00000000 --- a/.claude/skills/convex-setup-auth/references/auth0.md +++ /dev/null @@ -1,116 +0,0 @@ -# Auth0 - -Official docs: - -- https://docs.convex.dev/auth/auth0 -- https://auth0.github.io/auth0-cli/ -- https://auth0.github.io/auth0-cli/auth0_apps_create.html - -Use this when the app already uses Auth0 or the user wants Auth0 specifically. - -## Workflow - -1. Confirm the user wants Auth0 -2. Determine the app framework and whether Auth0 is already partly set up -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the official Convex and Auth0 guides before making changes -5. Ask whether they want the fastest setup path by installing the Auth0 CLI -6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI -7. If they do not want the CLI path, use the Auth0 dashboard path instead -8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up -9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID -10. Set environment variables for local and production environments -11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` -12. Gate Convex-backed UI with Convex auth state -13. Try to verify Convex reports the user as authenticated after Auth0 login -14. If the refresh-token path fails, stop improvising and send the user back to the official docs -15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered - -## What To Do - -- Read the official Convex and Auth0 guide before writing setup code -- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet -- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" -- Make sure the app has already completed the relevant Auth0 quickstart for its frontend -- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` -- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated - -## Key Setup Areas - -- install the Auth0 SDK for the app's framework -- configure `convex/auth.config.ts` with the Auth0 domain and client ID -- set environment variables for local and production environments -- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` -- use Convex auth state when gating Convex-backed UI - -## Files and Env Vars To Expect - -- `convex/auth.config.ts` -- frontend app entry or provider wrapper -- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` -- Auth0 environment variables commonly include: - - `AUTH0_DOMAIN` - - `AUTH0_CLIENT_ID` - - `VITE_AUTH0_DOMAIN` - - `VITE_AUTH0_CLIENT_ID` - -## Concrete Steps - -1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework -2. Ask whether the user wants the Auth0 CLI path -3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` -4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app -5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard -6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard -7. Install the Auth0 SDK for the app's framework -8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID -9. Set frontend and backend environment variables -10. Wrap the app in `Auth0Provider` -11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` -12. Run the normal Convex dev or deploy flow after backend config changes -13. Try the official provider config shown in the Convex docs -14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now -15. Only claim success if the user can sign in and Convex recognizes the authenticated session -16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too - -## Gotchas - -- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch -- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant -- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard -- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced -- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end -- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled -- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation -- Keep dev and prod tenants separate if the project uses different Auth0 environments -- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. -- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. -- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. -- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered -- Verify production environment variables and redirect settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can complete the Auth0 login flow -- Verify Convex-authenticated UI renders only after Convex auth state is ready -- Verify protected Convex queries succeed after login -- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions -- Verify the Auth0 app settings match the real local callback and logout URLs during development -- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully -- If production-ready setup was requested, verify the production Auth0 configuration is also covered - -## Checklist - -- [ ] Confirm the user wants Auth0 -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Complete the relevant Auth0 frontend setup -- [ ] Configure `convex/auth.config.ts` -- [ ] Set environment variables -- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs -- [ ] If requested, configure the production deployment too diff --git a/.claude/skills/convex-setup-auth/references/clerk.md b/.claude/skills/convex-setup-auth/references/clerk.md deleted file mode 100644 index 7dbde194..00000000 --- a/.claude/skills/convex-setup-auth/references/clerk.md +++ /dev/null @@ -1,113 +0,0 @@ -# Clerk - -Official docs: - -- https://docs.convex.dev/auth/clerk -- https://clerk.com/docs/guides/development/integrations/databases/convex - -Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. - -## Workflow - -1. Confirm the user wants Clerk -2. Make sure the user has a Clerk account and a Clerk application -3. Determine the app framework: - - React - - Next.js - - TanStack Start -4. Ask whether the user wants local-only setup or production-ready setup now -5. Gather the Clerk keys and the Clerk Frontend API URL -6. Follow the correct framework section in the official docs -7. Complete the backend and client wiring -8. Verify Convex reports the user as authenticated after login -9. If the user wants production-ready setup, make sure the production Clerk config is also covered - -## What To Do - -- Read the official Convex and Clerk guide before writing setup code -- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application -- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active -- Match the guide to the app's framework, usually React, Next.js, or TanStack Start -- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` - -## Key Setup Areas - -- install the Clerk SDK for the framework in use -- configure `convex/auth.config.ts` with the Clerk issuer domain -- set the required Clerk environment variables -- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` -- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` - -## Files and Env Vars To Expect - -- `convex/auth.config.ts` -- React or Vite client entry such as `src/main.tsx` -- Next.js client wrapper for Convex if using App Router -- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` -- Clerk app creation page: `https://dashboard.clerk.com/apps/new` -- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` -- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` -- Clerk environment variables: - - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs - - `CLERK_FRONTEND_API_URL` in the Clerk docs - - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps - - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps - - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required - -`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. - -## Concrete Steps - -1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` -2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` -3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed -4. Open `https://dashboard.clerk.com/apps/setup/convex` -5. Activate the Convex integration in Clerk if it is not already active -6. Copy the Clerk Frontend API URL shown there -7. Install the Clerk package for the app's framework -8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens -9. Set the publishable key in the frontend environment -10. Set the issuer domain or Frontend API URL so Convex can validate the JWT -11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` -12. Wrap the app in `ClerkProvider` -13. Use Convex auth helpers for authenticated rendering -14. Run the normal Convex dev or deploy flow after updating backend auth config -15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too - -## Gotchas - -- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render -- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper -- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config -- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. -- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. -- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. -- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. -- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` -- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included -- Verify production redirect URLs and any production Clerk domain values before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can sign in with Clerk -- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in -- Verify `useConvexAuth()` reaches the authenticated state after Clerk login -- Verify protected Convex queries run successfully inside authenticated UI -- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions -- If production-ready setup was requested, verify the production Clerk configuration is also covered - -## Checklist - -- [ ] Confirm the user wants Clerk -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Follow the correct framework section in the official guide -- [ ] Set Clerk environment variables -- [ ] Configure `convex/auth.config.ts` -- [ ] Verify Convex authenticated state after login -- [ ] If requested, configure the production deployment too diff --git a/.claude/skills/convex-setup-auth/references/convex-auth.md b/.claude/skills/convex-setup-auth/references/convex-auth.md deleted file mode 100644 index d4824d24..00000000 --- a/.claude/skills/convex-setup-auth/references/convex-auth.md +++ /dev/null @@ -1,143 +0,0 @@ -# Convex Auth - -Official docs: https://docs.convex.dev/auth/convex-auth -Setup guide: https://labs.convex.dev/auth/setup - -Use this when the user wants auth handled directly in Convex rather than through a third-party provider. - -## Workflow - -1. Confirm the user wants Convex Auth specifically -2. Determine which sign-in methods the app needs: - - magic links or OTPs - - OAuth providers - - passwords and password reset -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the Convex Auth setup guide before writing code -5. Make sure the project has a configured Convex deployment: - - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set - - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing -6. Install the auth packages: - - `npm install @convex-dev/auth @auth/core@0.37.0` -7. Run the initialization command: - - `npx @convex-dev/auth` -8. Confirm the initializer created: - - `convex/auth.config.ts` - - `convex/auth.ts` - - `convex/http.ts` -9. Add the required `authTables` to `convex/schema.ts` -10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` -11. Configure at least one auth method in `convex/auth.ts` -12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code -13. Verify the client can sign in successfully -14. Verify Convex receives authenticated identity in backend functions -15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well -16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex - -## What This Reference Is For - -- choosing Convex Auth as the default provider for a new Convex app -- understanding whether the app wants magic links, OTPs, OAuth, or passwords -- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior - -## What To Do - -- Read the Convex Auth setup guide before writing setup code -- Follow the setup flow from the docs rather than recreating it from memory -- If the app is new, consider starting from the official starter flow instead of hand-wiring everything -- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra - -## Concrete Steps - -1. Install `@convex-dev/auth` and `@auth/core@0.37.0` -2. Run `npx convex dev` if the project does not already have a configured deployment -3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment -4. Run `npx @convex-dev/auth` -5. Confirm the generated auth setup is present before continuing: - - `convex/auth.config.ts` - - `convex/auth.ts` - - `convex/http.ts` -6. Add `authTables` to `convex/schema.ts` -7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry -8. Configure the selected auth methods in `convex/auth.ts` -9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed -10. Verify login locally -11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment - -## Expected Files and Decisions - -- `convex/schema.ts` -- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file -- generated Convex Auth setup produced by `npx @convex-dev/auth` -- an existing configured Convex deployment, or the ability to create one with `npx convex dev` -- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods - -- Decide whether the user is creating a new app or adding auth to an existing app -- For a new app, prefer the official starter flow instead of rebuilding setup by hand -- Decide which auth methods the app needs: - - magic links or OTPs - - OAuth providers - - passwords -- Decide whether the user wants local-only setup or production-ready setup now -- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough - -## Gotchas - -- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. -- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. -- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. -- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. -- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. -- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. -- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. -- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. -- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. -- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment -- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Human Handoff - -If `npx convex dev` or deployment setup requires human input: - -- stop and explain exactly what the user needs to do -- say why that step is required -- resume the auth setup immediately after the user confirms it is done - -## Validation - -- Verify the user can complete a sign-in flow -- Offer to validate sign up, sign out, and sign back in with the configured auth method -- If browser automation is available in the environment, you can do this directly -- If browser automation is not available, give the user a short manual validation checklist instead -- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions -- Verify protected UI only renders after Convex-authenticated state is ready -- Verify environment variables and redirect settings match the current app environment -- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in -- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed -- If production-ready setup was requested, verify the production deployment is also configured correctly - -## Checklist - -- [ ] Confirm the user wants Convex Auth specifically -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Ensure a Convex deployment is configured before running auth initialization -- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` -- [ ] Run `npx convex dev` first if needed -- [ ] Run `npx @convex-dev/auth` -- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created -- [ ] Follow the setup guide for package install and wiring -- [ ] Add `authTables` to `convex/schema.ts` -- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` -- [ ] Configure at least one auth method in `convex/auth.ts` -- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes -- [ ] Confirm which sign-in methods the app needs -- [ ] Verify the client can sign in and the backend receives authenticated identity -- [ ] Offer end-to-end validation of sign up, sign out, and sign back in -- [ ] If requested, configure the production deployment too -- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/.claude/skills/convex-setup-auth/references/workos-authkit.md b/.claude/skills/convex-setup-auth/references/workos-authkit.md deleted file mode 100644 index 038cb9f3..00000000 --- a/.claude/skills/convex-setup-auth/references/workos-authkit.md +++ /dev/null @@ -1,114 +0,0 @@ -# WorkOS AuthKit - -Official docs: - -- https://docs.convex.dev/auth/authkit/ -- https://docs.convex.dev/auth/authkit/add-to-app -- https://docs.convex.dev/auth/authkit/auto-provision - -Use this when the app already uses WorkOS or the user wants AuthKit specifically. - -## Workflow - -1. Confirm the user wants WorkOS AuthKit -2. Determine whether they want: - - a Convex-managed WorkOS team - - an existing WorkOS team -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the official Convex and WorkOS AuthKit guide -5. Create or update `convex.json` for the app's framework and real local port -6. Follow the correct branch of the setup flow based on that choice -7. Configure the required WorkOS environment variables -8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs -9. Wire the client provider and callback flow -10. Verify authenticated requests reach Convex -11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too -12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex - -## What To Do - -- Read the official Convex and WorkOS AuthKit guide before writing setup code -- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team -- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra -- Follow the current setup flow from the docs instead of relying on older examples - -## Key Setup Areas - -- package installation for the app's framework -- `convex.json` with the `authKit` section for dev, and preview or prod if needed -- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration -- `convex/auth.config.ts` wiring for WorkOS-issued JWTs -- client provider setup and token flow into Convex -- login callback and redirect configuration - -## Files and Env Vars To Expect - -- `convex.json` -- `convex/auth.config.ts` -- frontend auth provider wiring -- callback or redirect route setup where the framework requires it -- WorkOS environment variables commonly include: - - `WORKOS_CLIENT_ID` - - `WORKOS_API_KEY` - - `WORKOS_COOKIE_PASSWORD` - - `VITE_WORKOS_CLIENT_ID` - - `VITE_WORKOS_REDIRECT_URI` - - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` - -For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. - -## Concrete Steps - -1. Choose Convex-managed or existing WorkOS team -2. Create or update `convex.json` with the `authKit` section for the framework in use -3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port -4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow -5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` -6. Create or update `convex/auth.config.ts` for WorkOS JWT validation -7. Run the normal Convex dev or deploy flow so backend config is synced -8. Wire the WorkOS client provider in the app -9. Configure callback and redirect handling -10. Verify the user can sign in and return to the app -11. Verify Convex sees the authenticated user after login -12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too - -## Gotchas - -- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious -- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys -- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex -- Do not mix dev and prod WorkOS credentials or redirect URIs -- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it -- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. -- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. -- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. -- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. -- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered -- Verify the production redirect and callback settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can complete the login flow and return to the app -- Verify the callback URL matches the real frontend port in local dev -- Verify Convex receives authenticated requests after login -- Verify `convex.json` matches the framework and chosen WorkOS setup path -- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path -- Verify environment variables differ correctly between local and production where needed -- If production-ready setup was requested, verify the production WorkOS configuration is also covered - -## Checklist - -- [ ] Confirm the user wants WorkOS AuthKit -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Choose Convex-managed or existing WorkOS team -- [ ] Create or update `convex.json` -- [ ] Configure WorkOS environment variables -- [ ] Configure `convex/auth.config.ts` -- [ ] Verify authenticated requests reach Convex after login -- [ ] If requested, configure the production deployment too diff --git a/.windsurf/skills/convex-create-component b/.windsurf/skills/convex-create-component new file mode 120000 index 00000000..dfa8244f --- /dev/null +++ b/.windsurf/skills/convex-create-component @@ -0,0 +1 @@ +../../.agents/skills/convex-create-component \ No newline at end of file diff --git a/.windsurf/skills/convex-create-component/SKILL.md b/.windsurf/skills/convex-create-component/SKILL.md deleted file mode 100644 index a79c18e0..00000000 --- a/.windsurf/skills/convex-create-component/SKILL.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -name: convex-create-component -description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. ---- - -# Convex Create Component - -Create reusable Convex components with clear boundaries and a small app-facing API. - -## When to Use - -- Creating a new Convex component in an existing app -- Extracting reusable backend logic into a component -- Building a third-party integration that should own its own tables and workflows -- Packaging Convex functionality for reuse across multiple apps - -## When Not to Use - -- One-off business logic that belongs in the main app -- Thin utilities that do not need Convex tables or functions -- App-level orchestration that should stay in `convex/` -- Cases where a normal TypeScript library is enough - -## Workflow - -1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. -2. Choose the shape using the decision tree below and read the matching reference file. -3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. -4. Make a short plan for: - - what tables the component owns - - what public functions it exposes - - what data must be passed in from the app (auth, env vars, parent IDs) - - what stays in the app as wrappers or HTTP mounts -5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. -6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. -7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. -8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. -9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. -10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. - -## Choose the Shape - -Ask the user, then pick one path: - -| Goal | Shape | Reference | -|------|-------|-----------| -| Component for this app only | Local | `references/local-components.md` | -| Publish or share across apps | Packaged | `references/packaged-components.md` | -| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | -| Not sure | Default to local | `references/local-components.md` | - -Read exactly one reference file before proceeding. - -## Default Approach - -Unless the user explicitly wants an npm package, default to a local component: - -- Put it under `convex/components//` -- Define it with `defineComponent(...)` in its own `convex.config.ts` -- Install it from the app's `convex/convex.config.ts` with `app.use(...)` -- Let `npx convex dev` generate the component's own `_generated/` files - -## Component Skeleton - -A minimal local component with a table and two functions, plus the app wiring. - -```ts -// convex/components/notifications/convex.config.ts -import { defineComponent } from "convex/server"; - -export default defineComponent("notifications"); -``` - -```ts -// convex/components/notifications/schema.ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - notifications: defineTable({ - userId: v.string(), - message: v.string(), - read: v.boolean(), - }).index("by_user", ["userId"]), -}); -``` - -```ts -// convex/components/notifications/lib.ts -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server.js"; - -export const send = mutation({ - args: { userId: v.string(), message: v.string() }, - returns: v.id("notifications"), - handler: async (ctx, args) => { - return await ctx.db.insert("notifications", { - userId: args.userId, - message: args.message, - read: false, - }); - }, -}); - -export const listUnread = query({ - args: { userId: v.string() }, - returns: v.array( - v.object({ - _id: v.id("notifications"), - _creationTime: v.number(), - userId: v.string(), - message: v.string(), - read: v.boolean(), - }) - ), - handler: async (ctx, args) => { - return await ctx.db - .query("notifications") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .filter((q) => q.eq(q.field("read"), false)) - .collect(); - }, -}); -``` - -```ts -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import notifications from "./components/notifications/convex.config.js"; - -const app = defineApp(); -app.use(notifications); - -export default app; -``` - -```ts -// convex/notifications.ts (app-side wrapper) -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; -import { components } from "./_generated/api"; -import { getAuthUserId } from "@convex-dev/auth/server"; - -export const sendNotification = mutation({ - args: { message: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - await ctx.runMutation(components.notifications.lib.send, { - userId, - message: args.message, - }); - return null; - }, -}); - -export const myUnread = query({ - args: {}, - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - return await ctx.runQuery(components.notifications.lib.listUnread, { - userId, - }); - }, -}); -``` - -Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. - -## Critical Rules - -- Keep authentication in the app, because `ctx.auth` is not available inside components. -- Keep environment access in the app, because component functions cannot read `process.env`. -- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. -- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. -- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. -- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. -- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. -- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. -- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. - -## Patterns - -### Authentication and environment access - -```ts -// Bad: component code cannot rely on app auth or env -const identity = await ctx.auth.getUserIdentity(); -const apiKey = process.env.OPENAI_API_KEY; -``` - -```ts -// Good: the app resolves auth and env, then passes explicit values -const userId = await getAuthUserId(ctx); -if (!userId) throw new Error("Not authenticated"); - -await ctx.runAction(components.translator.translate, { - userId, - apiKey: process.env.OPENAI_API_KEY, - text: args.text, -}); -``` - -### Client-facing API - -```ts -// Bad: assuming a component function is directly callable by clients -export const send = components.notifications.send; -``` - -```ts -// Good: re-export through an app mutation or query -export const sendNotification = mutation({ - args: { message: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - await ctx.runMutation(components.notifications.lib.send, { - userId, - message: args.message, - }); - return null; - }, -}); -``` - -### IDs across the boundary - -```ts -// Bad: parent app table IDs are not valid component validators -args: { userId: v.id("users") } -``` - -```ts -// Good: treat parent-owned IDs as strings at the boundary -args: { userId: v.string() } -``` - -### Advanced Patterns - -For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. - -## Validation - -Try validation in this order: - -1. `npx convex codegen --component-dir convex/components/` -2. `npx convex codegen` -3. `npx convex dev` - -Important: - -- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. -- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. -- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. - -## Reference Files - -Read exactly one of these after the user confirms the goal: - -- `references/local-components.md` -- `references/packaged-components.md` -- `references/hybrid-components.md` - -Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) - -## Checklist - -- [ ] Asked the user what they want to build and confirmed the shape -- [ ] Read the matching reference file -- [ ] Confirmed a component is the right abstraction -- [ ] Planned tables, public API, boundaries, and app wrappers -- [ ] Component lives under `convex/components//` (or package layout if publishing) -- [ ] Component imports from its own `./_generated/server` -- [ ] Auth, env access, and HTTP routes stay in the app -- [ ] Parent app IDs cross the boundary as `v.string()` -- [ ] Public functions have `args` and `returns` validators -- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/.windsurf/skills/convex-create-component/agents/openai.yaml b/.windsurf/skills/convex-create-component/agents/openai.yaml deleted file mode 100644 index ba9287e4..00000000 --- a/.windsurf/skills/convex-create-component/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Create Component" - short_description: "Design and build reusable Convex components with clear boundaries." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#14B8A6" - default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." - -policy: - allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-create-component/assets/icon.svg b/.windsurf/skills/convex-create-component/assets/icon.svg deleted file mode 100644 index 10f4c2c4..00000000 --- a/.windsurf/skills/convex-create-component/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.windsurf/skills/convex-create-component/references/advanced-patterns.md b/.windsurf/skills/convex-create-component/references/advanced-patterns.md deleted file mode 100644 index 3deb684c..00000000 --- a/.windsurf/skills/convex-create-component/references/advanced-patterns.md +++ /dev/null @@ -1,134 +0,0 @@ -# Advanced Component Patterns - -Additional patterns for Convex components that go beyond the basics covered in the main skill file. - -## Function Handles for callbacks - -When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. - -```ts -// App side: create a handle and pass it to the component -import { createFunctionHandle } from "convex/server"; - -export const startJob = mutation({ - handler: async (ctx) => { - const handle = await createFunctionHandle(internal.myModule.processItem); - await ctx.runMutation(components.workpool.enqueue, { - callback: handle, - }); - }, -}); -``` - -```ts -// Component side: accept and invoke the handle -import { v } from "convex/values"; -import type { FunctionHandle } from "convex/server"; -import { mutation } from "./_generated/server.js"; - -export const enqueue = mutation({ - args: { callback: v.string() }, - handler: async (ctx, args) => { - const handle = args.callback as FunctionHandle<"mutation">; - await ctx.scheduler.runAfter(0, handle, {}); - }, -}); -``` - -## Deriving validators from schema - -Instead of manually repeating field types in return validators, extend the schema validator: - -```ts -import { v } from "convex/values"; -import schema from "./schema.js"; - -const notificationDoc = schema.tables.notifications.validator.extend({ - _id: v.id("notifications"), - _creationTime: v.number(), -}); - -export const getLatest = query({ - args: {}, - returns: v.nullable(notificationDoc), - handler: async (ctx) => { - return await ctx.db.query("notifications").order("desc").first(); - }, -}); -``` - -## Static configuration with a globals table - -A common pattern for component configuration is a single-document "globals" table: - -```ts -// schema.ts -export default defineSchema({ - globals: defineTable({ - maxRetries: v.number(), - webhookUrl: v.optional(v.string()), - }), - // ... other tables -}); -``` - -```ts -// lib.ts -export const configure = mutation({ - args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, - returns: v.null(), - handler: async (ctx, args) => { - const existing = await ctx.db.query("globals").first(); - if (existing) { - await ctx.db.patch(existing._id, args); - } else { - await ctx.db.insert("globals", args); - } - return null; - }, -}); -``` - -## Class-based client wrappers - -For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. - -```ts -// src/client/index.ts -import type { GenericMutationCtx, GenericDataModel } from "convex/server"; -import type { ComponentApi } from "../component/_generated/component.js"; - -type MutationCtx = Pick, "runMutation">; - -export class Notifications { - constructor( - private component: ComponentApi, - private options?: { defaultChannel?: string }, - ) {} - - async send(ctx: MutationCtx, args: { userId: string; message: string }) { - return await ctx.runMutation(this.component.lib.send, { - ...args, - channel: this.options?.defaultChannel ?? "default", - }); - } -} -``` - -```ts -// App usage -import { Notifications } from "@convex-dev/notifications"; -import { components } from "./_generated/api"; - -const notifications = new Notifications(components.notifications, { - defaultChannel: "alerts", -}); - -export const send = mutation({ - args: { message: v.string() }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - await notifications.send(ctx, { userId, message: args.message }); - }, -}); -``` diff --git a/.windsurf/skills/convex-create-component/references/hybrid-components.md b/.windsurf/skills/convex-create-component/references/hybrid-components.md deleted file mode 100644 index d2bb3514..00000000 --- a/.windsurf/skills/convex-create-component/references/hybrid-components.md +++ /dev/null @@ -1,37 +0,0 @@ -# Hybrid Convex Components - -Read this file only when the user explicitly wants a hybrid setup. - -## What This Means - -A hybrid component combines a local Convex component with shared library code. - -This can help when: - -- the user wants a local install but also shared package logic -- the component needs extension points or override hooks -- some logic should live in normal TypeScript code outside the component boundary - -## Default Advice - -Treat hybrid as an advanced option, not the default. - -Before choosing it, ask: - -- Why is a plain local component not enough? -- Why is a packaged component not enough? -- What exactly needs to stay overridable or shared? - -If the answer is vague, fall back to local or packaged. - -## Risks - -- More moving parts -- Harder upgrades and backwards compatibility -- Easier to blur the component boundary - -## Checklist - -- [ ] User explicitly needs hybrid behavior -- [ ] Local-only and packaged-only options were considered first -- [ ] The extension points are clearly defined before coding diff --git a/.windsurf/skills/convex-create-component/references/local-components.md b/.windsurf/skills/convex-create-component/references/local-components.md deleted file mode 100644 index 7fbfe21a..00000000 --- a/.windsurf/skills/convex-create-component/references/local-components.md +++ /dev/null @@ -1,38 +0,0 @@ -# Local Convex Components - -Read this file when the component should live inside the current app and does not need to be published as an npm package. - -## When to Choose This - -- The user wants the simplest path -- The component only needs to work in this repo -- The goal is extracting app logic into a cleaner boundary - -## Default Layout - -Use this structure unless the repo already has a clear alternative pattern: - -```text -convex/ - convex.config.ts - components/ - / - convex.config.ts - schema.ts - .ts -``` - -## Workflow Notes - -- Define the component with `defineComponent("")` -- Install it from the app with `defineApp()` and `app.use(...)` -- Keep auth, env access, public API wrappers, and HTTP route mounting in the app -- Let the component own isolated tables and reusable backend workflows -- Add app wrappers if clients need to call into the component - -## Checklist - -- [ ] Component is inside `convex/components//` -- [ ] App installs it with `app.use(...)` -- [ ] Component owns only its own tables -- [ ] App wrappers handle client-facing calls when needed diff --git a/.windsurf/skills/convex-create-component/references/packaged-components.md b/.windsurf/skills/convex-create-component/references/packaged-components.md deleted file mode 100644 index 5668e7ed..00000000 --- a/.windsurf/skills/convex-create-component/references/packaged-components.md +++ /dev/null @@ -1,51 +0,0 @@ -# Packaged Convex Components - -Read this file when the user wants a reusable npm package or a component shared across multiple apps. - -## When to Choose This - -- The user wants to publish the component -- The user wants a stable reusable package boundary -- The component will be shared across multiple apps or teams - -## Default Approach - -- Prefer starting from `npx create-convex@latest --component` when possible -- Keep the official authoring docs as the source of truth for package layout and exports -- Validate the bundled package through an example app, not just the source files - -## Build Flow - -When building a packaged component, make sure the bundled output exists before the example app tries to consume it. - -Recommended order: - -1. `npx convex codegen --component-dir ./path/to/component` -2. Run the package build command -3. Run `npx convex dev --typecheck-components` in the example app - -Do not assume normal app codegen is enough for packaged component workflows. - -## Package Exports - -If publishing to npm, make sure the package exposes the entry points apps need: - -- package root for client helpers, types, or classes -- `./convex.config.js` for installing the component -- `./_generated/component.js` for the app-facing `ComponentApi` type -- `./test` for testing helpers when applicable - -## Testing - -- Use `convex-test` for component logic -- Register the component schema and modules with the test instance -- Test app-side wrapper code from an example app that installs the package -- Export a small helper from `./test` if consumers need easy test registration - -## Checklist - -- [ ] Packaging is actually required -- [ ] Build order avoids bundle and codegen races -- [ ] Package exports include install and typing entry points -- [ ] Example app exercises the packaged component -- [ ] Core behavior is covered by tests diff --git a/.windsurf/skills/convex-migration-helper b/.windsurf/skills/convex-migration-helper new file mode 120000 index 00000000..81eeed18 --- /dev/null +++ b/.windsurf/skills/convex-migration-helper @@ -0,0 +1 @@ +../../.agents/skills/convex-migration-helper \ No newline at end of file diff --git a/.windsurf/skills/convex-migration-helper/SKILL.md b/.windsurf/skills/convex-migration-helper/SKILL.md deleted file mode 100644 index 97f64c1a..00000000 --- a/.windsurf/skills/convex-migration-helper/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: convex-migration-helper -description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. ---- - -# Convex Migration Helper - -Safely migrate Convex schemas and data when making breaking changes. - -## When to Use - -- Adding new required fields to existing tables -- Changing field types or structure -- Splitting or merging tables -- Renaming or deleting fields -- Migrating from nested to relational data - -## When Not to Use - -- Greenfield schema with no existing data in production or dev -- Adding optional fields that do not need backfilling -- Adding new tables with no existing data to migrate -- Adding or removing indexes with no correctness concern -- Questions about Convex schema design without a migration need - -## Key Concepts - -### Schema Validation Drives the Workflow - -Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: - -- You cannot add a required field if existing documents don't have it -- You cannot change a field's type if existing documents have the old type -- You cannot remove a field from the schema if existing documents still have it - -This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. - -### Online Migrations - -Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. - -### Prefer New Fields Over Changing Types - -When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. - -### Don't Delete Data - -Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. - -## Safe Changes (No Migration Needed) - -### Adding Optional Field - -```typescript -// Before -users: defineTable({ - name: v.string(), -}) - -// After - safe, new field is optional -users: defineTable({ - name: v.string(), - bio: v.optional(v.string()), -}) -``` - -### Adding New Table - -```typescript -posts: defineTable({ - userId: v.id("users"), - title: v.string(), -}).index("by_user", ["userId"]) -``` - -### Adding Index - -```typescript -users: defineTable({ - name: v.string(), - email: v.string(), -}) - .index("by_email", ["email"]) -``` - -## Breaking Changes: The Deployment Workflow - -Every breaking migration follows the same multi-deploy pattern: - -**Deploy 1 - Widen the schema:** - -1. Update schema to allow both old and new formats (e.g., add optional new field) -2. Update code to handle both formats when reading -3. Update code to write the new format for new documents -4. Deploy - -**Between deploys - Migrate data:** - -5. Run migration to backfill existing documents -6. Verify all documents are migrated - -**Deploy 2 - Narrow the schema:** - -7. Update schema to require the new format only -8. Remove code that handles the old format -9. Deploy - -## Using the Migrations Component - -For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. - -See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. - -## Common Migration Patterns - -See `references/migration-patterns.md` for complete patterns with code examples covering: - -- Adding a required field -- Deleting a field -- Changing a field type -- Splitting nested data into a separate table -- Cleaning up orphaned documents -- Zero-downtime strategies (dual write, dual read) -- Small table shortcut (single internalMutation without the component) -- Verifying a migration is complete - -## Common Pitfalls - -1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. -2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. -3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." -4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. -5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. -6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. - -## Migration Checklist - -- [ ] Identify the breaking change and plan the multi-deploy workflow -- [ ] Update schema to allow both old and new formats -- [ ] Update code to handle both formats when reading -- [ ] Update code to write the new format for new documents -- [ ] Deploy widened schema and updated code -- [ ] Define migration using the `@convex-dev/migrations` component -- [ ] Test with `dryRun: true` -- [ ] Run migration and monitor status -- [ ] Verify all documents are migrated -- [ ] Update schema to require new format only -- [ ] Clean up code that handled old format -- [ ] Deploy final schema and code -- [ ] Remove migration code once confirmed stable diff --git a/.windsurf/skills/convex-migration-helper/agents/openai.yaml b/.windsurf/skills/convex-migration-helper/agents/openai.yaml deleted file mode 100644 index c2a7fcc5..00000000 --- a/.windsurf/skills/convex-migration-helper/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Migration Helper" - short_description: "Plan and run safe Convex schema and data migrations." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#8B5CF6" - default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." - -policy: - allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-migration-helper/assets/icon.svg b/.windsurf/skills/convex-migration-helper/assets/icon.svg deleted file mode 100644 index fba7241a..00000000 --- a/.windsurf/skills/convex-migration-helper/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.windsurf/skills/convex-migration-helper/references/migration-patterns.md b/.windsurf/skills/convex-migration-helper/references/migration-patterns.md deleted file mode 100644 index 219583e0..00000000 --- a/.windsurf/skills/convex-migration-helper/references/migration-patterns.md +++ /dev/null @@ -1,231 +0,0 @@ -# Migration Patterns Reference - -Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. - -## Adding a Required Field - -```typescript -// Deploy 1: Schema allows both states -users: defineTable({ - name: v.string(), - role: v.optional(v.union(v.literal("user"), v.literal("admin"))), -}) - -// Migration: backfill the field -export const addDefaultRole = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.role === undefined) { - await ctx.db.patch(user._id, { role: "user" }); - } - }, -}); - -// Deploy 2: After migration completes, make it required -users: defineTable({ - name: v.string(), - role: v.union(v.literal("user"), v.literal("admin")), -}) -``` - -## Deleting a Field - -Mark the field optional first, migrate data to remove it, then remove from schema: - -```typescript -// Deploy 1: Make optional -// isPro: v.boolean() --> isPro: v.optional(v.boolean()) - -// Migration -export const removeIsPro = migrations.define({ - table: "teams", - migrateOne: async (ctx, team) => { - if (team.isPro !== undefined) { - await ctx.db.patch(team._id, { isPro: undefined }); - } - }, -}); - -// Deploy 2: Remove isPro from schema entirely -``` - -## Changing a Field Type - -Prefer creating a new field. You can combine adding and deleting in one migration: - -```typescript -// Deploy 1: Add new field, keep old field optional -// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) - -// Migration: convert old field to new field -export const convertToEnum = migrations.define({ - table: "teams", - migrateOne: async (ctx, team) => { - if (team.plan === undefined) { - await ctx.db.patch(team._id, { - plan: team.isPro ? "pro" : "basic", - isPro: undefined, - }); - } - }, -}); - -// Deploy 2: Remove isPro from schema, make plan required -``` - -## Splitting Nested Data Into a Separate Table - -```typescript -export const extractPreferences = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.preferences === undefined) return; - - const existing = await ctx.db - .query("userPreferences") - .withIndex("by_user", (q) => q.eq("userId", user._id)) - .first(); - - if (!existing) { - await ctx.db.insert("userPreferences", { - userId: user._id, - ...user.preferences, - }); - } - - await ctx.db.patch(user._id, { preferences: undefined }); - }, -}); -``` - -Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. - -## Cleaning Up Orphaned Documents - -```typescript -export const deleteOrphanedEmbeddings = migrations.define({ - table: "embeddings", - migrateOne: async (ctx, doc) => { - const chunk = await ctx.db - .query("chunks") - .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) - .first(); - - if (!chunk) { - await ctx.db.delete(doc._id); - } - }, -}); -``` - -## Zero-Downtime Strategies - -During the migration window, your app must handle both old and new data formats. There are two main strategies. - -### Dual Write (Preferred) - -Write to both old and new structures. Read from the old structure until migration is complete. - -1. Deploy code that writes both formats, reads old format -2. Run migration on existing data -3. Deploy code that reads new format, still writes both -4. Deploy code that only reads and writes new format - -This is preferred because you can safely roll back at any point, the old format is always up to date. - -```typescript -// Bad: only writing to new structure before migration is done -export const createTeam = mutation({ - args: { name: v.string(), isPro: v.boolean() }, - handler: async (ctx, args) => { - await ctx.db.insert("teams", { - name: args.name, - plan: args.isPro ? "pro" : "basic", - }); - }, -}); - -// Good: writing to both structures during migration -export const createTeam = mutation({ - args: { name: v.string(), isPro: v.boolean() }, - handler: async (ctx, args) => { - const plan = args.isPro ? "pro" : "basic"; - await ctx.db.insert("teams", { - name: args.name, - isPro: args.isPro, - plan, - }); - }, -}); -``` - -### Dual Read - -Read both formats. Write only the new format. - -1. Deploy code that reads both formats (preferring new), writes only new format -2. Run migration on existing data -3. Deploy code that reads and writes only new format - -This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. - -```typescript -// Good: reading both formats, preferring new -function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { - if (team.plan !== undefined) return team.plan; - return team.isPro ? "pro" : "basic"; -} -``` - -## Small Table Shortcut - -For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: - -```typescript -import { internalMutation } from "./_generated/server"; - -export const backfillSmallTable = internalMutation({ - handler: async (ctx) => { - const docs = await ctx.db.query("smallConfig").collect(); - for (const doc of docs) { - if (doc.newField === undefined) { - await ctx.db.patch(doc._id, { newField: "default" }); - } - } - }, -}); -``` - -```bash -npx convex run migrations:backfillSmallTable -``` - -Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. - -## Verifying a Migration - -Query to check remaining unmigrated documents: - -```typescript -import { query } from "./_generated/server"; - -export const verifyMigration = query({ - handler: async (ctx) => { - const remaining = await ctx.db - .query("users") - .filter((q) => q.eq(q.field("role"), undefined)) - .take(10); - - return { - complete: remaining.length === 0, - sampleRemaining: remaining.map((u) => u._id), - }; - }, -}); -``` - -Or use the component's built-in status monitoring: - -```bash -npx convex run --component migrations lib:getStatus --watch -``` diff --git a/.windsurf/skills/convex-migration-helper/references/migrations-component.md b/.windsurf/skills/convex-migration-helper/references/migrations-component.md deleted file mode 100644 index c80522f2..00000000 --- a/.windsurf/skills/convex-migration-helper/references/migrations-component.md +++ /dev/null @@ -1,170 +0,0 @@ -# Migrations Component Reference - -Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. - -## Installation - -```bash -npm install @convex-dev/migrations -``` - -## Setup - -```typescript -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import migrations from "@convex-dev/migrations/convex.config.js"; - -const app = defineApp(); -app.use(migrations); -export default app; -``` - -```typescript -// convex/migrations.ts -import { Migrations } from "@convex-dev/migrations"; -import { components } from "./_generated/api.js"; -import { DataModel } from "./_generated/dataModel.js"; - -export const migrations = new Migrations(components.migrations); -export const run = migrations.runner(); -``` - -The `DataModel` type parameter is optional but provides type safety for migration definitions. - -## Define a Migration - -The `migrateOne` function processes a single document. The component handles batching and pagination automatically. - -```typescript -// convex/migrations.ts -export const addDefaultRole = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.role === undefined) { - await ctx.db.patch(user._id, { role: "user" }); - } - }, -}); -``` - -Shorthand: if you return an object, it is applied as a patch automatically. - -```typescript -export const clearDeprecatedField = migrations.define({ - table: "users", - migrateOne: () => ({ legacyField: undefined }), -}); -``` - -## Run a Migration - -From the CLI: - -```bash -# Define a one-off runner in convex/migrations.ts: -# export const runIt = migrations.runner(internal.migrations.addDefaultRole); -npx convex run migrations:runIt - -# Or use the general-purpose runner -npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' -``` - -Programmatically from another Convex function: - -```typescript -await migrations.runOne(ctx, internal.migrations.addDefaultRole); -``` - -## Run Multiple Migrations in Order - -```typescript -export const runAll = migrations.runner([ - internal.migrations.addDefaultRole, - internal.migrations.clearDeprecatedField, - internal.migrations.normalizeEmails, -]); -``` - -```bash -npx convex run migrations:runAll -``` - -If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. - -## Dry Run - -Test a migration before committing changes: - -```bash -npx convex run migrations:runIt '{"dryRun": true}' -``` - -This runs one batch and then rolls back, so you can see what it would do without changing any data. - -## Check Migration Status - -```bash -npx convex run --component migrations lib:getStatus --watch -``` - -## Cancel a Running Migration - -```bash -npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' -``` - -Or programmatically: - -```typescript -await migrations.cancel(ctx, internal.migrations.addDefaultRole); -``` - -## Run Migrations on Deploy - -Chain migration execution after deploying: - -```bash -npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod -``` - -## Configuration Options - -### Custom Batch Size - -If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: - -```typescript -export const migrateHeavyTable = migrations.define({ - table: "largeDocuments", - batchSize: 10, - migrateOne: async (ctx, doc) => { - // migration logic - }, -}); -``` - -### Migrate a Subset Using an Index - -Process only matching documents instead of the full table: - -```typescript -export const fixEmptyNames = migrations.define({ - table: "users", - customRange: (query) => - query.withIndex("by_name", (q) => q.eq("name", "")), - migrateOne: () => ({ name: "" }), -}); -``` - -### Parallelize Within a Batch - -By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: - -```typescript -export const clearField = migrations.define({ - table: "myTable", - parallelize: true, - migrateOne: () => ({ optionalField: undefined }), -}); -``` diff --git a/.windsurf/skills/convex-performance-audit b/.windsurf/skills/convex-performance-audit new file mode 120000 index 00000000..1bff1e5e --- /dev/null +++ b/.windsurf/skills/convex-performance-audit @@ -0,0 +1 @@ +../../.agents/skills/convex-performance-audit \ No newline at end of file diff --git a/.windsurf/skills/convex-performance-audit/SKILL.md b/.windsurf/skills/convex-performance-audit/SKILL.md deleted file mode 100644 index 9d92b33c..00000000 --- a/.windsurf/skills/convex-performance-audit/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: convex-performance-audit -description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. ---- - -# Convex Performance Audit - -Diagnose and fix performance problems in Convex applications, one problem class at a time. - -## When to Use - -- A Convex page or feature feels slow or expensive -- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts -- Low-freshness read paths are using reactivity where point-in-time reads would do -- OCC conflict errors or excessive mutation retries -- High subscription count or slow UI updates -- Functions approaching execution or transaction limits -- The same performance pattern needs fixing across sibling functions - -## When Not to Use - -- Initial Convex setup, auth setup, or component extraction -- Pure schema migrations with no performance goal -- One-off micro-optimizations without a user-visible or deployment-visible problem - -## Guardrails - -- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak -- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path -- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale - -## First Step: Gather Signals - -Start with the strongest signal available: - -1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. -2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. - - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. -3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. -4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. - -## Signal Routing - -After gathering signals, identify the problem class and read the matching reference file. - -| Signal | Reference | -|---|---| -| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | -| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | -| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | -| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | -| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | - -Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. - -## Escalate Larger Fixes - -If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. - -Examples: - -- introducing digest or summary tables across multiple flows -- splitting documents to isolate frequently-updated fields -- reworking pagination or fetch strategy across several screens -- switching to a new index or denormalized field that needs migration-safe rollout - -When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. - -## Workflow - -### 1. Scope the problem - -Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. - -Write down: - -- entrypoint functions -- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` -- tables read -- tables written -- whether the path is high-read, high-write, or both - -### 2. Trace the full read and write set - -For each function in the path: - -1. Trace every `ctx.db.get()` and `ctx.db.query()` -2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` -3. Note foreign-key lookups, JS-side filtering, and full-document reads -4. Identify all sibling functions touching the same tables -5. Identify reactive stats, aggregates, or widgets rendered on the same page - -In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. - -### 3. Apply fixes from the relevant reference - -Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. - -Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. - -### 4. Fix sibling functions together - -When one function touching a table has a performance bug, audit sibling functions for the same pattern. - -After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. - -Examples: - -- If one list query switches from full docs to a digest table, inspect the other list queries for that table -- If one mutation needs no-op write protection, inspect the other writers to the same table -- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk - -Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. - -### 5. Verify before finishing - -Confirm all of these: - -1. Results are the same as before, no dropped records -2. Eliminated reads or writes are no longer in the path where expected -3. Fallback behavior works when denormalized or indexed fields are missing -4. New writes avoid unnecessary invalidation when data is unchanged -5. Every relevant sibling reader and writer was inspected, not just the original function - -## Reference Files - -- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables -- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting -- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads -- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size - -Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. - -## Checklist - -- [ ] Gathered signals from insights, dashboard, or code audit -- [ ] Identified the problem class and read the matching reference -- [ ] Scoped one concrete user flow or function path -- [ ] Traced every read and write in that path -- [ ] Identified sibling functions touching the same tables -- [ ] Applied fixes from the reference, following the recommended fix order -- [ ] Fixed sibling functions consistently -- [ ] Verified behavior and confirmed no regressions diff --git a/.windsurf/skills/convex-performance-audit/agents/openai.yaml b/.windsurf/skills/convex-performance-audit/agents/openai.yaml deleted file mode 100644 index 9a21f387..00000000 --- a/.windsurf/skills/convex-performance-audit/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Performance Audit" - short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#EF4444" - default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." - -policy: - allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-performance-audit/assets/icon.svg b/.windsurf/skills/convex-performance-audit/assets/icon.svg deleted file mode 100644 index 7ab9e09c..00000000 --- a/.windsurf/skills/convex-performance-audit/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.windsurf/skills/convex-performance-audit/references/function-budget.md b/.windsurf/skills/convex-performance-audit/references/function-budget.md deleted file mode 100644 index c71d14cb..00000000 --- a/.windsurf/skills/convex-performance-audit/references/function-budget.md +++ /dev/null @@ -1,232 +0,0 @@ -# Function Budget - -Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. - -## Core Principle - -Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. - -## Limits to Know - -These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. - -| Resource | Limit | -|---|---| -| Query/mutation execution time | 1 second (user code only, excludes DB operations) | -| Action execution time | 10 minutes | -| Data read per transaction | 16 MiB | -| Data written per transaction | 16 MiB | -| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | -| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | -| Documents written per transaction | 16,000 | -| Individual document size | 1 MiB | -| Function return value size | 16 MiB | - -## Symptoms - -- "Function execution took too long" errors -- "Transaction too large" or read/write set size errors -- Slow queries that read many documents -- Client receiving large payloads that slow down page load -- `npx convex insights --details` showing high bytes read - -## Common Causes - -### Unbounded collection - -A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. - -### Large document reads on hot paths - -Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. - -### Mutation doing too much work - -A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. - -### Returning too much data to the client - -A query returning full documents when the client only needs a few fields. - -## Fix Order - -### 1. Bound your reads - -Never `.collect()` without a limit on a table that can grow unbounded. - -```ts -// Bad: unbounded read, breaks as the table grows -const messages = await ctx.db.query("messages").collect(); -``` - -```ts -// Good: paginate or limit -const messages = await ctx.db - .query("messages") - .withIndex("by_channel", (q) => q.eq("channelId", channelId)) - .order("desc") - .take(50); -``` - -### 2. Read smaller shapes - -If the list page only needs title, author, and date, do not read full documents with rich content fields. - -Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. - -### 3. Break large mutations into batches - -If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. - -```ts -// Bad: one mutation updating every row -export const backfillAll = internalMutation({ - handler: async (ctx) => { - const docs = await ctx.db.query("items").collect(); - for (const doc of docs) { - await ctx.db.patch(doc._id, { newField: computeValue(doc) }); - } - }, -}); -``` - -```ts -// Good: cursor-based batch processing -export const backfillBatch = internalMutation({ - args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, - handler: async (ctx, args) => { - const batchSize = args.batchSize ?? 100; - const result = await ctx.db - .query("items") - .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); - - for (const doc of result.page) { - if (doc.newField === undefined) { - await ctx.db.patch(doc._id, { newField: computeValue(doc) }); - } - } - - if (!result.isDone) { - await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { - cursor: result.continueCursor, - batchSize, - }); - } - }, -}); -``` - -### 4. Move heavy work to actions - -Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. - -Actions run outside the transaction and can call mutations to write results back. - -```ts -// Bad: heavy computation inside a mutation -export const processUpload = mutation({ - handler: async (ctx, args) => { - const result = expensiveComputation(args.data); - await ctx.db.insert("results", result); - }, -}); -``` - -```ts -// Good: action for heavy work, mutation for the write -export const processUpload = action({ - handler: async (ctx, args) => { - const result = expensiveComputation(args.data); - await ctx.runMutation(internal.results.store, { result }); - }, -}); -``` - -### 5. Trim return values - -Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. - -```ts -// Bad: returns full documents including large content fields -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("articles").take(20); - }, -}); -``` - -```ts -// Good: project to only the fields the client needs -export const list = query({ - handler: async (ctx) => { - const articles = await ctx.db.query("articles").take(20); - return articles.map((a) => ({ - _id: a._id, - title: a.title, - author: a.author, - createdAt: a._creationTime, - })); - }, -}); -``` - -### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions - -Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. - -```ts -// Bad: unnecessary overhead from ctx.runQuery inside a mutation -export const createProject = mutation({ - handler: async (ctx, args) => { - const user = await ctx.runQuery(api.users.getCurrentUser); - await ctx.db.insert("projects", { ...args, ownerId: user._id }); - }, -}); -``` - -```ts -// Good: plain helper function, no extra overhead -export const createProject = mutation({ - handler: async (ctx, args) => { - const user = await getCurrentUser(ctx); - await ctx.db.insert("projects", { ...args, ownerId: user._id }); - }, -}); -``` - -Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. - -### 7. Avoid unnecessary `runAction` calls - -`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). - -```ts -// Bad: runAction overhead for no reason -export const processItems = action({ - handler: async (ctx, args) => { - for (const item of args.items) { - await ctx.runAction(internal.items.processOne, { item }); - } - }, -}); -``` - -```ts -// Good: plain function call -export const processItems = action({ - handler: async (ctx, args) => { - for (const item of args.items) { - await processOneItem(ctx, { item }); - } - }, -}); -``` - -## Verification - -1. No function execution or transaction size errors -2. `npx convex insights --details` shows reduced bytes read -3. Large mutations are batched and self-scheduling -4. Client payloads are reasonably sized for the UI they serve -5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible -6. Sibling functions with similar patterns were checked diff --git a/.windsurf/skills/convex-performance-audit/references/hot-path-rules.md b/.windsurf/skills/convex-performance-audit/references/hot-path-rules.md deleted file mode 100644 index e3e44b15..00000000 --- a/.windsurf/skills/convex-performance-audit/references/hot-path-rules.md +++ /dev/null @@ -1,371 +0,0 @@ -# Hot Path Rules - -Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. - -## Contents - -- Core Principle -- Consistency Rule -- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) -- 2. Minimize Data Sources (denormalization, fallback rule) -- 3. Minimize Row Size (digest tables) -- 4. Skip No-Op Writes -- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) -- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) -- Verification - -## Core Principle - -Every byte read or written multiplies with concurrency. - -Think: - -`cost x calls_per_second x 86400` - -In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. - -## Consistency Rule - -If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. - -Do this especially for: - -- multiple list queries over the same table -- multiple writers to the same table -- public browse and search queries over the same records -- helper functions reused by more than one endpoint - -## 1. Push Filters To Storage - -Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. - -Prefer: - -- `withIndex(...)` -- `.withSearchIndex(...)` for text search -- narrower tables -- summary tables - -before accepting a scan-plus-filter pattern. - -```ts -// Bad: scans then filters in JavaScript -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - const tasks = await ctx.db.query("tasks").collect(); - return tasks.filter((task) => task.status === "open"); - }, -}); -``` - -```ts -// Also bad: Convex .filter() does not push to storage either -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("tasks") - .filter((q) => q.eq(q.field("status"), "open")) - .collect(); - }, -}); -``` - -```ts -// Good: use an index so storage does the filtering -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("tasks") - .withIndex("by_status", (q) => q.eq("status", "open")) - .collect(); - }, -}); -``` - -### Migration rule for indexes - -New indexes on partially backfilled fields can create correctness bugs during rollout. - -Important Convex detail: - -`undefined !== false` - -If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. - -Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. - -If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. - -```ts -// Bad: optional booleans can miss older rows where the field is undefined -const projects = await ctx.db - .query("projects") - .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) - .order("desc") - .take(20); -``` - -```ts -// Good: switch hot-path reads only after the rollout is migration-safe -// See the migration helper skill for dual-read / backfill / cutover patterns. -``` - -### Check for redundant indexes - -Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. - -```ts -// Bad: two indexes where one would do -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team", ["team"]) - .index("by_team_and_user", ["team", "user"]) -``` - -```ts -// Good: single compound index serves both query patterns -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team_and_user", ["team", "user"]) -``` - -Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. - -## 2. Minimize Data Sources - -Trace every read. - -If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. - -### When to denormalize - -Denormalize when all of these are true: - -- the path is hot -- the joined document is much larger than the field you need -- many readers are paying that join cost repeatedly - -Useful mental model: - -`join_cost = rows_per_page x foreign_doc_size x pages_per_second` - -Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. - -### Fallback rule - -Denormalized data is an optimization. Live data is the correctness path. - -Rules: - -- If the denormalized field is missing or null, fall back to the live read -- Do not show placeholders instead of falling back -- In lookup maps, only include fully populated entries - -```ts -// Bad: missing denormalized data becomes a placeholder and blocks correctness -const ownerName = project.ownerName ?? "Unknown owner"; -``` - -```ts -// Good: denormalized data is an optimization, not the only source of truth -const ownerName = - project.ownerName ?? - (await ctx.db.get(project.ownerId))?.name ?? - null; -``` - -Bad lookup map pattern: - -```ts -const ownersById = { - [project.ownerId]: { ownerName: null }, -}; -``` - -That blocks fallback because the map says "I have data" when it does not. - -Good lookup map pattern: - -```ts -const ownersById = - project.ownerName !== undefined && project.ownerName !== null - ? { [project.ownerId]: { ownerName: project.ownerName } } - : {}; -``` - -### No denormalized copy yet - -Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. - -If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. - -Rollout order: - -1. Update schema -2. Update write path -3. Backfill -4. Switch read path - -## 3. Minimize Row Size - -Hot list pages should read the smallest document shape that still answers the UI. - -Prefer summary or digest tables over full source tables when: - -- the list page only needs a subset of fields -- source documents are large -- the query is high volume - -An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. - -Digest tables are a tradeoff, not a default: - -- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost -- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit - -```ts -// Bad: list page reads source docs, then joins owner data per row -const projects = await ctx.db - .query("projects") - .withIndex("by_public", (q) => q.eq("isPublic", true)) - .collect(); -``` - -```ts -// Good: list page reads the smaller digest shape first -const projects = await ctx.db - .query("projectDigests") - .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) - .order("desc") - .take(20); -``` - -## 4. Skip No-Op Writes - -No-op writes still cost work in Convex: - -- invalidation -- replication -- trigger execution -- downstream sync - -Before `patch` or `replace`, compare against the existing document and skip the write if nothing changed. - -Apply this across sibling writers too. One careful writer does not help much if three other mutations still patch unconditionally. - -```ts -// Bad: patching unchanged values still triggers invalidation and downstream work -await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, -}); -``` - -```ts -// Good: only write when something actually changed -if (settings.theme !== args.theme || settings.locale !== args.locale) { - await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, - }); -} -``` - -## 5. Match Consistency To Read Patterns - -Choose read strategy based on traffic shape. - -### High-read, low-write - -Examples: - -- public browse pages -- search results -- landing pages -- directory listings - -Prefer: - -- point-in-time reads where appropriate -- explicit refresh -- local state for pagination -- caching where appropriate - -Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. - -### High-read, high-write - -Examples: - -- collaborative editors -- live dashboards -- presence-heavy views - -Reactive queries may be worth the ongoing cost. - -## Convex-Specific Notes - -### Reactive queries - -Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. - -On the client: - -- `useQuery` creates a live subscription -- `usePaginatedQuery` creates a live subscription per page - -For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. - -### Point-in-time reads - -Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. - -Use them for: - -- aggregate snapshots -- reports -- low-churn listings -- pages where explicit refresh is fine - -### Triggers and fan-out - -Triggers fire on every write, including writes that did not materially change the document. - -When a write exists only to keep derived state in sync: - -- diff before patching -- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate - -### Aggregates - -Reactive global counts invalidate frequently on busy tables. - -Prefer: - -- one-shot aggregate fetches -- periodic recomputation -- precomputed summary rows - -for global stats that do not need live updates every second. - -### Backfills - -For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. - -Deploy code that can handle both states before running the backfill. - -During the gap: - -- writes should populate the new shape -- reads should fall back safely - -## Verification - -Before closing the audit, confirm: - -1. Same results as before, no dropped records -2. The removed table or lookup is no longer in the hot-path read set -3. Tests or validation cover fallback behavior -4. Migration safety is preserved while fields or indexes are unbackfilled -5. Sibling functions were fixed consistently diff --git a/.windsurf/skills/convex-performance-audit/references/occ-conflicts.md b/.windsurf/skills/convex-performance-audit/references/occ-conflicts.md deleted file mode 100644 index a96d0466..00000000 --- a/.windsurf/skills/convex-performance-audit/references/occ-conflicts.md +++ /dev/null @@ -1,126 +0,0 @@ -# OCC Conflict Resolution - -Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. - -## Core Principle - -Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. - -## Symptoms - -- OCC conflict errors in deployment logs or health page -- Mutations retrying multiple times before succeeding -- User-visible latency spikes on write-heavy pages -- `npx convex insights --details` showing high conflict rates - -## Common Causes - -### Hot documents - -Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. - -### Broad read sets causing false conflicts - -A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. - -### Fan-out from triggers or cascading writes - -A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. - -Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. - -### Write-then-read chains - -A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. - -## Fix Order - -### 1. Reduce read set size - -Narrower reads mean fewer false conflicts. - -```ts -// Bad: broad scan creates a wide conflict surface -const allTasks = await ctx.db.query("tasks").collect(); -const mine = allTasks.filter((t) => t.ownerId === userId); -``` - -```ts -// Good: indexed query touches only relevant documents -const mine = await ctx.db - .query("tasks") - .withIndex("by_owner", (q) => q.eq("ownerId", userId)) - .collect(); -``` - -### 2. Split hot documents - -When many writers target the same document, split the contention point. - -```ts -// Bad: every vote increments the same counter document -const counter = await ctx.db.get(pollCounterId); -await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); -``` - -```ts -// Good: shard the counter across multiple documents, aggregate on read -const shardIndex = Math.floor(Math.random() * SHARD_COUNT); -const shardId = shardIds[shardIndex]; -const shard = await ctx.db.get(shardId); -await ctx.db.patch(shardId, { count: shard!.count + 1 }); -``` - -Aggregate the shards in a query or scheduled job when you need the total. - -### 3. Skip no-op writes - -Writes that do not change data still participate in conflict detection and trigger invalidation. - -```ts -// Bad: patches even when nothing changed -await ctx.db.patch(doc._id, { status: args.status }); -``` - -```ts -// Good: only write when the value actually differs -if (doc.status !== args.status) { - await ctx.db.patch(doc._id, { status: args.status }); -} -``` - -### 4. Move non-critical work to scheduled functions - -If a mutation does primary work plus secondary bookkeeping (analytics, notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. - -```ts -// Bad: analytics update in the same transaction as the user action -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.db.insert("analytics", { event: "action", userId, ts: Date.now() }); -``` - -```ts -// Good: schedule the bookkeeping so the primary transaction is smaller -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.scheduler.runAfter(0, internal.analytics.recordEvent, { - event: "action", - userId, -}); -``` - -### 5. Combine competing writes - -If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. - -Do not introduce artificial locks or queues unless the above steps have been tried first. - -## Related: Invalidation Scope - -Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. - -## Verification - -1. OCC conflict rate has dropped in insights or dashboard -2. Mutation latency is lower and more consistent -3. No data correctness regressions from splitting or scheduling changes -4. Sibling writers to the same hot documents were fixed consistently diff --git a/.windsurf/skills/convex-performance-audit/references/subscription-cost.md b/.windsurf/skills/convex-performance-audit/references/subscription-cost.md deleted file mode 100644 index ae7d1adb..00000000 --- a/.windsurf/skills/convex-performance-audit/references/subscription-cost.md +++ /dev/null @@ -1,252 +0,0 @@ -# Subscription Cost - -Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. - -## Core Principle - -Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: - -`subscriptions x invalidation_frequency x query_cost` - -Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. - -## Symptoms - -- Dashboard shows high active subscription count -- UI feels sluggish or laggy despite fast individual queries -- React profiling shows frequent re-renders from Convex state -- Pages with many components each running their own `useQuery` -- Paginated lists where every loaded page stays subscribed - -## Common Causes - -### Reactive queries on low-freshness flows - -Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. - -### Overly broad queries - -A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. - -### Too many subscriptions per page - -A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. - -### Paginated queries keeping all pages live - -`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. - -### Frequently-updated fields on widely-read documents - -A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. - -## Fix Order - -### 1. Use point-in-time reads when live updates are not valuable - -Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. - -Consider a point-in-time read instead when all of these are true: - -- the flow is high-read -- the underlying data changes less often than users need to see -- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable - -Possible implementations depend on environment: - -- a server-rendered fetch -- a framework helper like `fetchQuery` -- a point-in-time client read such as `ConvexHttpClient.query()` - -```ts -// Reactive by default when fresh live data matters -function TeamPresence() { - const presence = useQuery(api.teams.livePresence, { teamId }); - return ; -} -``` - -```ts -// Point-in-time read when explicit refresh is acceptable -import { ConvexHttpClient } from "convex/browser"; - -const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); - -function SnapshotView() { - const [items, setItems] = useState([]); - - useEffect(() => { - client.query(api.items.snapshot).then(setItems); - }, []); - - return ; -} -``` - -Good candidates for point-in-time reads: - -- aggregate snapshots -- reports -- low-churn listings -- flows where explicit refresh is already acceptable - -Keep reactive for: - -- collaborative editing -- live dashboards -- presence-heavy views -- any surface where users expect fresh changes to appear automatically - -### 2. Batch related data into fewer queries - -Instead of N components each fetching their own related data, fetch it in a single query. - -```ts -// Bad: each card fetches its own author -function ProjectCard({ project }: { project: Project }) { - const author = useQuery(api.users.get, { id: project.authorId }); - return ; -} -``` - -```ts -// Good: parent query returns projects with author names included -function ProjectList() { - const projects = useQuery(api.projects.listWithAuthors); - return projects?.map((p) => ( - - )); -} -``` - -This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. - -This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. - -### 3. Use skip to avoid unnecessary subscriptions - -The `"skip"` value prevents a subscription from being created when the arguments are not ready. - -```ts -// Bad: subscribes with undefined args, wastes a subscription slot -const profile = useQuery(api.users.getProfile, { userId: selectedId! }); -``` - -```ts -// Good: skip when there is nothing to fetch -const profile = useQuery( - api.users.getProfile, - selectedId ? { userId: selectedId } : "skip", -); -``` - -### 4. Isolate frequently-updated fields into separate documents - -If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. - -```ts -// Bad: lastSeen lives on the user doc, every heartbeat invalidates -// every query that reads this user -const users = defineTable({ - name: v.string(), - email: v.string(), - lastSeen: v.number(), -}); -``` - -```ts -// Good: lastSeen lives in a separate heartbeat doc -const users = defineTable({ - name: v.string(), - email: v.string(), - heartbeatId: v.id("heartbeats"), -}); - -const heartbeats = defineTable({ - lastSeen: v.number(), -}); -``` - -Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. - -For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. - -### 5. Use the aggregate component for counts and sums - -Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. - -Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. - -If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. - -### 6. Narrow query read sets - -Queries that return less data and touch fewer documents invalidate less often. - -```ts -// Bad: returns all fields, invalidates on any field change -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("projects").collect(); - }, -}); -``` - -```ts -// Good: use a digest table with only the fields the list needs -export const listDigests = query({ - handler: async (ctx) => { - return await ctx.db.query("projectDigests").collect(); - }, -}); -``` - -Writes to fields not in the digest table do not invalidate the digest query. - -### 7. Remove `Date.now()` from queries - -Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. - -```ts -// Bad: Date.now() defeats query caching and causes frequent re-evaluation -const releasedPosts = await ctx.db - .query("posts") - .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) - .take(100); -``` - -```ts -// Good: use a boolean field updated by a scheduled function -const releasedPosts = await ctx.db - .query("posts") - .withIndex("by_is_released", (q) => q.eq("isReleased", true)) - .take(100); -``` - -If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. - -### 8. Consider pagination strategy - -For long lists where users scroll through many pages: - -- If the data does not need live updates, use point-in-time fetching with manual "load more" -- If it does need live updates, accept the subscription cost but limit the number of loaded pages -- Consider whether older pages can be unloaded as the user scrolls forward - -### 9. Separate backend cost from UI churn - -If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. - -Treat this as a UX problem first when: - -- the underlying query is already reasonably cheap -- the complaint is flicker, loading flashes, or re-render churn -- live updates are still desirable once fresh data arrives - -## Verification - -1. Subscription count in dashboard is lower for the affected pages -2. UI responsiveness has improved -3. React profiling shows fewer unnecessary re-renders -4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily -5. Sibling pages with similar patterns were updated consistently diff --git a/.windsurf/skills/convex-quickstart b/.windsurf/skills/convex-quickstart new file mode 120000 index 00000000..9edf1975 --- /dev/null +++ b/.windsurf/skills/convex-quickstart @@ -0,0 +1 @@ +../../.agents/skills/convex-quickstart \ No newline at end of file diff --git a/.windsurf/skills/convex-quickstart/SKILL.md b/.windsurf/skills/convex-quickstart/SKILL.md deleted file mode 100644 index 792bba3d..00000000 --- a/.windsurf/skills/convex-quickstart/SKILL.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -name: convex-quickstart -description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." ---- - -# Convex Quickstart - -Set up a working Convex project as fast as possible. - -## When to Use - -- Starting a brand new project with Convex -- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app -- Scaffolding a Convex app for prototyping - -## When Not to Use - -- The project already has Convex installed and `convex/` exists - just start building -- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill - -## Workflow - -1. Determine the starting point: new project or existing app -2. If new project, pick a template and scaffold with `npm create convex@latest` -3. If existing app, install `convex` and wire up the provider -4. Run `npx convex dev` to connect a deployment and start the dev loop -5. Verify the setup works - -## Path 1: New Project (Recommended) - -Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. - -### Pick a template - -| Template | Stack | -|----------|-------| -| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | -| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | -| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | -| `nextjs-clerk` | Next.js + Clerk auth | -| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | -| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | -| `bare` | Convex backend only, no frontend | - -If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. - -You can also use any GitHub repo as a template: - -```bash -npm create convex@latest my-app -- -t owner/repo -npm create convex@latest my-app -- -t owner/repo#branch -``` - -### Scaffold the project - -Always pass the project name and template flag to avoid interactive prompts: - -```bash -npm create convex@latest my-app -- -t react-vite-shadcn -cd my-app -npm install -``` - -The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. - -To scaffold in the current directory (if it is empty): - -```bash -npm create convex@latest . -- -t react-vite-shadcn -npm install -``` - -### Start the dev loop - -`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. - -**Ask the user to run this themselves:** - -Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: -- Create a Convex project and dev deployment -- Write the deployment URL to `.env.local` -- Create the `convex/` directory with generated types -- Watch for changes and sync continuously - -The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. - -**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. - -### Start the frontend - -The user should also run the frontend dev server in a separate terminal: - -```bash -npm run dev -``` - -Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. - -### What you get - -After scaffolding, the project structure looks like: - -``` -my-app/ - convex/ # Backend functions and schema - _generated/ # Auto-generated types (check this into git) - schema.ts # Database schema (if template includes one) - src/ # Frontend code (or app/ for Next.js) - package.json - .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL -``` - -The template already has: -- `ConvexProvider` wired into the app root -- Correct env var names for the framework -- Tailwind and shadcn/ui ready (for shadcn templates) -- Auth provider configured (for auth templates) - -Proceed to adding schema, functions, and UI. - -## Path 2: Add Convex to an Existing App - -Use this when the user already has a frontend project and wants to add Convex as the backend. - -### Install - -```bash -npm install convex -``` - -### Initialize and start dev loop - -Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. - -### Wire up the provider - -The Convex client must wrap the app at the root. The setup varies by framework. - -Create the `ConvexReactClient` at module scope, not inside a component: - -```tsx -// Bad: re-creates the client on every render -function App() { - const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); - return ...; -} - -// Good: created once at module scope -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); -function App() { - return ...; -} -``` - -#### React (Vite) - -```tsx -// src/main.tsx -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { ConvexProvider, ConvexReactClient } from "convex/react"; -import App from "./App"; - -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); - -createRoot(document.getElementById("root")!).render( - - - - - , -); -``` - -#### Next.js (App Router) - -```tsx -// app/ConvexClientProvider.tsx -"use client"; - -import { ConvexProvider, ConvexReactClient } from "convex/react"; -import { ReactNode } from "react"; - -const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); - -export function ConvexClientProvider({ children }: { children: ReactNode }) { - return {children}; -} -``` - -```tsx -// app/layout.tsx -import { ConvexClientProvider } from "./ConvexClientProvider"; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); -} -``` - -#### Other frameworks - -For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: - -- [Vue](https://docs.convex.dev/quickstart/vue) -- [Svelte](https://docs.convex.dev/quickstart/svelte) -- [React Native](https://docs.convex.dev/quickstart/react-native) -- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) -- [Remix](https://docs.convex.dev/quickstart/remix) -- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) - -### Environment variables - -The env var name depends on the framework: - -| Framework | Variable | -|-----------|----------| -| Vite | `VITE_CONVEX_URL` | -| Next.js | `NEXT_PUBLIC_CONVEX_URL` | -| Remix | `CONVEX_URL` | -| React Native | `EXPO_PUBLIC_CONVEX_URL` | - -`npx convex dev` writes the correct variable to `.env.local` automatically. - -## Agent Mode (Cloud and Headless Agents) - -When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. - -Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: - -```bash -CONVEX_AGENT_MODE=anonymous npx convex dev -``` - -This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. - -## Verify the Setup - -After setup, confirm everything is working: - -1. The user confirms `npx convex dev` is running without errors -2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` -3. `.env.local` contains the deployment URL - -## Writing Your First Function - -Once the project is set up, create a schema and a query to verify the full loop works. - -`convex/schema.ts`: - -```ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - tasks: defineTable({ - text: v.string(), - completed: v.boolean(), - }), -}); -``` - -`convex/tasks.ts`: - -```ts -import { query, mutation } from "./_generated/server"; -import { v } from "convex/values"; - -export const list = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query("tasks").collect(); - }, -}); - -export const create = mutation({ - args: { text: v.string() }, - handler: async (ctx, args) => { - await ctx.db.insert("tasks", { text: args.text, completed: false }); - }, -}); -``` - -Use in a React component (adjust the import path based on your file location relative to `convex/`): - -```tsx -import { useQuery, useMutation } from "convex/react"; -import { api } from "../convex/_generated/api"; - -function Tasks() { - const tasks = useQuery(api.tasks.list); - const create = useMutation(api.tasks.create); - - return ( -
- - {tasks?.map((t) =>
{t.text}
)} -
- ); -} -``` - -## Development vs Production - -Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. - -When ready to ship, deploy to production: - -```bash -npx convex deploy -``` - -This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. - -## Next Steps - -- Add authentication: use the `convex-setup-auth` skill -- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) -- Build components: use the `convex-create-component` skill -- Plan a migration: use the `convex-migration-helper` skill -- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) -- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) - -## Checklist - -- [ ] Determined starting point: new project or existing app -- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template -- [ ] If existing app: installed `convex` and wired up the provider -- [ ] User has `npx convex dev` running and connected to a deployment -- [ ] `convex/_generated/` directory exists with types -- [ ] `.env.local` has the deployment URL -- [ ] Verified a basic query/mutation round-trip works diff --git a/.windsurf/skills/convex-quickstart/agents/openai.yaml b/.windsurf/skills/convex-quickstart/agents/openai.yaml deleted file mode 100644 index a51a6d09..00000000 --- a/.windsurf/skills/convex-quickstart/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Quickstart" - short_description: "Start a new Convex app or add Convex to an existing frontend." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#F97316" - default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." - -policy: - allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-quickstart/assets/icon.svg b/.windsurf/skills/convex-quickstart/assets/icon.svg deleted file mode 100644 index d83a73f3..00000000 --- a/.windsurf/skills/convex-quickstart/assets/icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/.windsurf/skills/convex-setup-auth b/.windsurf/skills/convex-setup-auth new file mode 120000 index 00000000..a19c8377 --- /dev/null +++ b/.windsurf/skills/convex-setup-auth @@ -0,0 +1 @@ +../../.agents/skills/convex-setup-auth \ No newline at end of file diff --git a/.windsurf/skills/convex-setup-auth/SKILL.md b/.windsurf/skills/convex-setup-auth/SKILL.md deleted file mode 100644 index 0fa00e2f..00000000 --- a/.windsurf/skills/convex-setup-auth/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: convex-setup-auth -description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." ---- - -# Convex Authentication Setup - -Implement secure authentication in Convex with user management and access control. - -## When to Use - -- Setting up authentication for the first time -- Implementing user management (users table, identity mapping) -- Creating authentication helper functions -- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) - -## When Not to Use - -- Auth for a non-Convex backend -- Pure OAuth/OIDC documentation without a Convex implementation -- Debugging unrelated bugs that happen to surface near auth code -- The auth provider is already fully configured and the user only needs a one-line fix - -## First Step: Choose the Auth Provider - -Convex supports multiple authentication approaches. Do not assume a provider. - -Before writing setup code: - -1. Ask the user which auth solution they want, unless the repository already makes it obvious -2. If the repo already uses a provider, continue with that provider unless the user wants to switch -3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding - -Common options: - -- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex -- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features -- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically -- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 -- Custom JWT provider - use when integrating an existing auth system not covered above - -Look for signals in the repo before asking: - -- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages -- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components -- Environment variables that clearly point at a provider - -## After Choosing a Provider - -Read the provider's official guide and the matching local reference file: - -- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` -- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` -- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` -- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` - -The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. - -Use those sources for: - -- package installation -- client provider wiring -- environment variables -- `convex/auth.config.ts` setup -- login and logout UI patterns -- framework-specific setup for React, Vite, or Next.js - -For shared auth behavior, use the official Convex docs as the source of truth: - -- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` -- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage -- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance -- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth - -Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. -For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. -For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. -After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. - -## Core Pattern: Protecting Backend Functions - -The most common auth task is checking identity in Convex functions. - -```ts -// Bad: trusting a client-provided userId -export const getMyProfile = query({ - args: { userId: v.id("users") }, - handler: async (ctx, args) => { - return await ctx.db.get(args.userId); - }, -}); -``` - -```ts -// Good: verifying identity server-side -export const getMyProfile = query({ - args: {}, - handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Not authenticated"); - - return await ctx.db - .query("users") - .withIndex("by_tokenIdentifier", (q) => - q.eq("tokenIdentifier", identity.tokenIdentifier) - ) - .unique(); - }, -}); -``` - -## Workflow - -1. Determine the provider, either by asking the user or inferring from the repo -2. Ask whether the user wants local-only setup or production-ready setup now -3. Read the matching provider reference file -4. Follow the official provider docs for current setup details -5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns -6. Only add app-level user storage if the docs and app requirements call for it -7. Add authorization checks for ownership, roles, or team access only where the app needs them -8. Verify login state, protected queries, environment variables, and production configuration if requested - -If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. -For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. -If the environment has browser automation tools, you can use them. -If it does not, give the user a short manual validation checklist instead. - -## Reference Files - -### Provider References - -- `references/convex-auth.md` -- `references/clerk.md` -- `references/workos-authkit.md` -- `references/auth0.md` - -## Checklist - -- [ ] Chosen the correct auth provider before writing setup code -- [ ] Read the relevant provider reference file -- [ ] Asked whether the user wants local-only setup or production-ready setup -- [ ] Used the official provider docs for provider-specific wiring -- [ ] Used the official Convex docs for shared auth behavior and authorization patterns -- [ ] Only added app-level user storage if the app actually needs it -- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth -- [ ] Added authentication checks in protected backend functions -- [ ] Added authorization checks where the app actually needs them -- [ ] Clear error messages ("Not authenticated", "Unauthorized") -- [ ] Client auth provider configured for the chosen provider -- [ ] If requested, production auth setup is covered too diff --git a/.windsurf/skills/convex-setup-auth/agents/openai.yaml b/.windsurf/skills/convex-setup-auth/agents/openai.yaml deleted file mode 100644 index d1c90a14..00000000 --- a/.windsurf/skills/convex-setup-auth/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Setup Auth" - short_description: "Set up Convex auth, user identity mapping, and access control." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#2563EB" - default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." - -policy: - allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-setup-auth/assets/icon.svg b/.windsurf/skills/convex-setup-auth/assets/icon.svg deleted file mode 100644 index 4917dbb4..00000000 --- a/.windsurf/skills/convex-setup-auth/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.windsurf/skills/convex-setup-auth/references/auth0.md b/.windsurf/skills/convex-setup-auth/references/auth0.md deleted file mode 100644 index 9c729c5a..00000000 --- a/.windsurf/skills/convex-setup-auth/references/auth0.md +++ /dev/null @@ -1,116 +0,0 @@ -# Auth0 - -Official docs: - -- https://docs.convex.dev/auth/auth0 -- https://auth0.github.io/auth0-cli/ -- https://auth0.github.io/auth0-cli/auth0_apps_create.html - -Use this when the app already uses Auth0 or the user wants Auth0 specifically. - -## Workflow - -1. Confirm the user wants Auth0 -2. Determine the app framework and whether Auth0 is already partly set up -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the official Convex and Auth0 guides before making changes -5. Ask whether they want the fastest setup path by installing the Auth0 CLI -6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI -7. If they do not want the CLI path, use the Auth0 dashboard path instead -8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up -9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID -10. Set environment variables for local and production environments -11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` -12. Gate Convex-backed UI with Convex auth state -13. Try to verify Convex reports the user as authenticated after Auth0 login -14. If the refresh-token path fails, stop improvising and send the user back to the official docs -15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered - -## What To Do - -- Read the official Convex and Auth0 guide before writing setup code -- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet -- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" -- Make sure the app has already completed the relevant Auth0 quickstart for its frontend -- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` -- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated - -## Key Setup Areas - -- install the Auth0 SDK for the app's framework -- configure `convex/auth.config.ts` with the Auth0 domain and client ID -- set environment variables for local and production environments -- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` -- use Convex auth state when gating Convex-backed UI - -## Files and Env Vars To Expect - -- `convex/auth.config.ts` -- frontend app entry or provider wrapper -- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` -- Auth0 environment variables commonly include: - - `AUTH0_DOMAIN` - - `AUTH0_CLIENT_ID` - - `VITE_AUTH0_DOMAIN` - - `VITE_AUTH0_CLIENT_ID` - -## Concrete Steps - -1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework -2. Ask whether the user wants the Auth0 CLI path -3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` -4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app -5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard -6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard -7. Install the Auth0 SDK for the app's framework -8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID -9. Set frontend and backend environment variables -10. Wrap the app in `Auth0Provider` -11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` -12. Run the normal Convex dev or deploy flow after backend config changes -13. Try the official provider config shown in the Convex docs -14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now -15. Only claim success if the user can sign in and Convex recognizes the authenticated session -16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too - -## Gotchas - -- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch -- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant -- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard -- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced -- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end -- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled -- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation -- Keep dev and prod tenants separate if the project uses different Auth0 environments -- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. -- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. -- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. -- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered -- Verify production environment variables and redirect settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can complete the Auth0 login flow -- Verify Convex-authenticated UI renders only after Convex auth state is ready -- Verify protected Convex queries succeed after login -- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions -- Verify the Auth0 app settings match the real local callback and logout URLs during development -- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully -- If production-ready setup was requested, verify the production Auth0 configuration is also covered - -## Checklist - -- [ ] Confirm the user wants Auth0 -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Complete the relevant Auth0 frontend setup -- [ ] Configure `convex/auth.config.ts` -- [ ] Set environment variables -- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs -- [ ] If requested, configure the production deployment too diff --git a/.windsurf/skills/convex-setup-auth/references/clerk.md b/.windsurf/skills/convex-setup-auth/references/clerk.md deleted file mode 100644 index 7dbde194..00000000 --- a/.windsurf/skills/convex-setup-auth/references/clerk.md +++ /dev/null @@ -1,113 +0,0 @@ -# Clerk - -Official docs: - -- https://docs.convex.dev/auth/clerk -- https://clerk.com/docs/guides/development/integrations/databases/convex - -Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. - -## Workflow - -1. Confirm the user wants Clerk -2. Make sure the user has a Clerk account and a Clerk application -3. Determine the app framework: - - React - - Next.js - - TanStack Start -4. Ask whether the user wants local-only setup or production-ready setup now -5. Gather the Clerk keys and the Clerk Frontend API URL -6. Follow the correct framework section in the official docs -7. Complete the backend and client wiring -8. Verify Convex reports the user as authenticated after login -9. If the user wants production-ready setup, make sure the production Clerk config is also covered - -## What To Do - -- Read the official Convex and Clerk guide before writing setup code -- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application -- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active -- Match the guide to the app's framework, usually React, Next.js, or TanStack Start -- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` - -## Key Setup Areas - -- install the Clerk SDK for the framework in use -- configure `convex/auth.config.ts` with the Clerk issuer domain -- set the required Clerk environment variables -- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` -- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` - -## Files and Env Vars To Expect - -- `convex/auth.config.ts` -- React or Vite client entry such as `src/main.tsx` -- Next.js client wrapper for Convex if using App Router -- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` -- Clerk app creation page: `https://dashboard.clerk.com/apps/new` -- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` -- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` -- Clerk environment variables: - - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs - - `CLERK_FRONTEND_API_URL` in the Clerk docs - - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps - - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps - - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required - -`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. - -## Concrete Steps - -1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` -2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` -3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed -4. Open `https://dashboard.clerk.com/apps/setup/convex` -5. Activate the Convex integration in Clerk if it is not already active -6. Copy the Clerk Frontend API URL shown there -7. Install the Clerk package for the app's framework -8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens -9. Set the publishable key in the frontend environment -10. Set the issuer domain or Frontend API URL so Convex can validate the JWT -11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` -12. Wrap the app in `ClerkProvider` -13. Use Convex auth helpers for authenticated rendering -14. Run the normal Convex dev or deploy flow after updating backend auth config -15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too - -## Gotchas - -- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render -- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper -- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config -- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. -- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. -- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. -- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. -- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` -- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included -- Verify production redirect URLs and any production Clerk domain values before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can sign in with Clerk -- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in -- Verify `useConvexAuth()` reaches the authenticated state after Clerk login -- Verify protected Convex queries run successfully inside authenticated UI -- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions -- If production-ready setup was requested, verify the production Clerk configuration is also covered - -## Checklist - -- [ ] Confirm the user wants Clerk -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Follow the correct framework section in the official guide -- [ ] Set Clerk environment variables -- [ ] Configure `convex/auth.config.ts` -- [ ] Verify Convex authenticated state after login -- [ ] If requested, configure the production deployment too diff --git a/.windsurf/skills/convex-setup-auth/references/convex-auth.md b/.windsurf/skills/convex-setup-auth/references/convex-auth.md deleted file mode 100644 index d4824d24..00000000 --- a/.windsurf/skills/convex-setup-auth/references/convex-auth.md +++ /dev/null @@ -1,143 +0,0 @@ -# Convex Auth - -Official docs: https://docs.convex.dev/auth/convex-auth -Setup guide: https://labs.convex.dev/auth/setup - -Use this when the user wants auth handled directly in Convex rather than through a third-party provider. - -## Workflow - -1. Confirm the user wants Convex Auth specifically -2. Determine which sign-in methods the app needs: - - magic links or OTPs - - OAuth providers - - passwords and password reset -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the Convex Auth setup guide before writing code -5. Make sure the project has a configured Convex deployment: - - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set - - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing -6. Install the auth packages: - - `npm install @convex-dev/auth @auth/core@0.37.0` -7. Run the initialization command: - - `npx @convex-dev/auth` -8. Confirm the initializer created: - - `convex/auth.config.ts` - - `convex/auth.ts` - - `convex/http.ts` -9. Add the required `authTables` to `convex/schema.ts` -10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` -11. Configure at least one auth method in `convex/auth.ts` -12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code -13. Verify the client can sign in successfully -14. Verify Convex receives authenticated identity in backend functions -15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well -16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex - -## What This Reference Is For - -- choosing Convex Auth as the default provider for a new Convex app -- understanding whether the app wants magic links, OTPs, OAuth, or passwords -- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior - -## What To Do - -- Read the Convex Auth setup guide before writing setup code -- Follow the setup flow from the docs rather than recreating it from memory -- If the app is new, consider starting from the official starter flow instead of hand-wiring everything -- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra - -## Concrete Steps - -1. Install `@convex-dev/auth` and `@auth/core@0.37.0` -2. Run `npx convex dev` if the project does not already have a configured deployment -3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment -4. Run `npx @convex-dev/auth` -5. Confirm the generated auth setup is present before continuing: - - `convex/auth.config.ts` - - `convex/auth.ts` - - `convex/http.ts` -6. Add `authTables` to `convex/schema.ts` -7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry -8. Configure the selected auth methods in `convex/auth.ts` -9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed -10. Verify login locally -11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment - -## Expected Files and Decisions - -- `convex/schema.ts` -- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file -- generated Convex Auth setup produced by `npx @convex-dev/auth` -- an existing configured Convex deployment, or the ability to create one with `npx convex dev` -- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods - -- Decide whether the user is creating a new app or adding auth to an existing app -- For a new app, prefer the official starter flow instead of rebuilding setup by hand -- Decide which auth methods the app needs: - - magic links or OTPs - - OAuth providers - - passwords -- Decide whether the user wants local-only setup or production-ready setup now -- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough - -## Gotchas - -- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. -- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. -- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. -- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. -- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. -- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. -- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. -- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. -- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. -- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment -- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Human Handoff - -If `npx convex dev` or deployment setup requires human input: - -- stop and explain exactly what the user needs to do -- say why that step is required -- resume the auth setup immediately after the user confirms it is done - -## Validation - -- Verify the user can complete a sign-in flow -- Offer to validate sign up, sign out, and sign back in with the configured auth method -- If browser automation is available in the environment, you can do this directly -- If browser automation is not available, give the user a short manual validation checklist instead -- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions -- Verify protected UI only renders after Convex-authenticated state is ready -- Verify environment variables and redirect settings match the current app environment -- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in -- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed -- If production-ready setup was requested, verify the production deployment is also configured correctly - -## Checklist - -- [ ] Confirm the user wants Convex Auth specifically -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Ensure a Convex deployment is configured before running auth initialization -- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` -- [ ] Run `npx convex dev` first if needed -- [ ] Run `npx @convex-dev/auth` -- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created -- [ ] Follow the setup guide for package install and wiring -- [ ] Add `authTables` to `convex/schema.ts` -- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` -- [ ] Configure at least one auth method in `convex/auth.ts` -- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes -- [ ] Confirm which sign-in methods the app needs -- [ ] Verify the client can sign in and the backend receives authenticated identity -- [ ] Offer end-to-end validation of sign up, sign out, and sign back in -- [ ] If requested, configure the production deployment too -- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/.windsurf/skills/convex-setup-auth/references/workos-authkit.md b/.windsurf/skills/convex-setup-auth/references/workos-authkit.md deleted file mode 100644 index 038cb9f3..00000000 --- a/.windsurf/skills/convex-setup-auth/references/workos-authkit.md +++ /dev/null @@ -1,114 +0,0 @@ -# WorkOS AuthKit - -Official docs: - -- https://docs.convex.dev/auth/authkit/ -- https://docs.convex.dev/auth/authkit/add-to-app -- https://docs.convex.dev/auth/authkit/auto-provision - -Use this when the app already uses WorkOS or the user wants AuthKit specifically. - -## Workflow - -1. Confirm the user wants WorkOS AuthKit -2. Determine whether they want: - - a Convex-managed WorkOS team - - an existing WorkOS team -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the official Convex and WorkOS AuthKit guide -5. Create or update `convex.json` for the app's framework and real local port -6. Follow the correct branch of the setup flow based on that choice -7. Configure the required WorkOS environment variables -8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs -9. Wire the client provider and callback flow -10. Verify authenticated requests reach Convex -11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too -12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex - -## What To Do - -- Read the official Convex and WorkOS AuthKit guide before writing setup code -- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team -- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra -- Follow the current setup flow from the docs instead of relying on older examples - -## Key Setup Areas - -- package installation for the app's framework -- `convex.json` with the `authKit` section for dev, and preview or prod if needed -- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration -- `convex/auth.config.ts` wiring for WorkOS-issued JWTs -- client provider setup and token flow into Convex -- login callback and redirect configuration - -## Files and Env Vars To Expect - -- `convex.json` -- `convex/auth.config.ts` -- frontend auth provider wiring -- callback or redirect route setup where the framework requires it -- WorkOS environment variables commonly include: - - `WORKOS_CLIENT_ID` - - `WORKOS_API_KEY` - - `WORKOS_COOKIE_PASSWORD` - - `VITE_WORKOS_CLIENT_ID` - - `VITE_WORKOS_REDIRECT_URI` - - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` - -For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. - -## Concrete Steps - -1. Choose Convex-managed or existing WorkOS team -2. Create or update `convex.json` with the `authKit` section for the framework in use -3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port -4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow -5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` -6. Create or update `convex/auth.config.ts` for WorkOS JWT validation -7. Run the normal Convex dev or deploy flow so backend config is synced -8. Wire the WorkOS client provider in the app -9. Configure callback and redirect handling -10. Verify the user can sign in and return to the app -11. Verify Convex sees the authenticated user after login -12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too - -## Gotchas - -- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious -- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys -- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex -- Do not mix dev and prod WorkOS credentials or redirect URIs -- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it -- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. -- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. -- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. -- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. -- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered -- Verify the production redirect and callback settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can complete the login flow and return to the app -- Verify the callback URL matches the real frontend port in local dev -- Verify Convex receives authenticated requests after login -- Verify `convex.json` matches the framework and chosen WorkOS setup path -- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path -- Verify environment variables differ correctly between local and production where needed -- If production-ready setup was requested, verify the production WorkOS configuration is also covered - -## Checklist - -- [ ] Confirm the user wants WorkOS AuthKit -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Choose Convex-managed or existing WorkOS team -- [ ] Create or update `convex.json` -- [ ] Configure WorkOS environment variables -- [ ] Configure `convex/auth.config.ts` -- [ ] Verify authenticated requests reach Convex after login -- [ ] If requested, configure the production deployment too diff --git a/skills-lock.json b/skills-lock.json index 1c45028c..9f627f87 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -4,27 +4,27 @@ "convex-create-component": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "84897925dd765dd58847b3b05b22ad706e65609f93a962345b48a97ff93a760f" + "computedHash": "c97d71b524ea9f784d344af20a766de5d48f14b7b824bf791fa517d1da753aca" }, "convex-migration-helper": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "b99262360eb6fba714155b630537861da2d4c890365f629c75d607c8a1405c7b" + "computedHash": "bfb51e5b743ac31b313e5e1d206faee7d46bbbc9195360ce9636eead6f13fd3a" }, "convex-performance-audit": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "1a41a616f9615b9229928653fa22c752e86487f415c73082dd17f1073e059127" + "computedHash": "3e411d8250678ca8ec739a40c2874f21e4b5ab17c4dd65e72e3d9d9df6851c31" }, "convex-quickstart": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "51322b7e70b0f47ec67650b2db721eb91043cba99bf8c864366b7b250d8a313f" + "computedHash": "7879afbb2b954be36ef29d294f3b6c2851cad54e9f5e5ac90461f0f97d4d1fff" }, "convex-setup-auth": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "7cc29991c446d2ea574dc9abb5fb85c0c9a7f3ef3af4c94454eef45017bda794" + "computedHash": "92a5c170cf238f45f08439d9d5d7947af490b880ee8f15306183240483cbed02" } } } From 2abc6718e0132852b36856a4e68f05e3631f93d0 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:50:47 -0400 Subject: [PATCH 03/15] Queue cloud agent additions immediately - Keep agent insertions in the local state - Guard strategy UI against cleared cloud state - Add coverage for queued cloud agent adds --- .../skills/convex-create-component/SKILL.md | 284 -------------- .../agents/openai.yaml | 10 - .../convex-create-component/assets/icon.svg | 3 - .../references/advanced-patterns.md | 134 ------- .../references/hybrid-components.md | 37 -- .../references/local-components.md | 38 -- .../references/packaged-components.md | 51 --- .../skills/convex-migration-helper/SKILL.md | 150 ------- .../agents/openai.yaml | 10 - .../convex-migration-helper/assets/icon.svg | 3 - .../references/migration-patterns.md | 231 ----------- .../references/migrations-component.md | 170 -------- .../skills/convex-performance-audit/SKILL.md | 143 ------- .../agents/openai.yaml | 10 - .../convex-performance-audit/assets/icon.svg | 3 - .../references/function-budget.md | 232 ----------- .../references/hot-path-rules.md | 371 ------------------ .../references/occ-conflicts.md | 126 ------ .../references/subscription-cost.md | 252 ------------ .agents/skills/convex-quickstart/SKILL.md | 337 ---------------- .../convex-quickstart/agents/openai.yaml | 10 - .../skills/convex-quickstart/assets/icon.svg | 4 - .agents/skills/convex-setup-auth/SKILL.md | 150 ------- .../convex-setup-auth/agents/openai.yaml | 10 - .../skills/convex-setup-auth/assets/icon.svg | 3 - .../convex-setup-auth/references/auth0.md | 116 ------ .../convex-setup-auth/references/clerk.md | 113 ------ .../references/convex-auth.md | 143 ------- .../references/workos-authkit.md | 114 ------ lib/providers/agent_provider.dart | 3 +- lib/providers/strategy_provider.dart | 50 +++ lib/strategy_view.dart | 67 ++++ lib/widgets/folder_navigator.dart | 11 +- lib/widgets/strategy_quick_switcher.dart | 76 +++- pubspec.lock | 16 +- skills/convex-create-component/SKILL.md | 284 -------------- .../agents/openai.yaml | 10 - .../convex-create-component/assets/icon.svg | 3 - .../references/advanced-patterns.md | 134 ------- .../references/hybrid-components.md | 37 -- .../references/local-components.md | 38 -- .../references/packaged-components.md | 51 --- skills/convex-migration-helper/SKILL.md | 150 ------- .../agents/openai.yaml | 10 - .../convex-migration-helper/assets/icon.svg | 3 - .../references/migration-patterns.md | 231 ----------- .../references/migrations-component.md | 170 -------- skills/convex-performance-audit/SKILL.md | 143 ------- .../agents/openai.yaml | 10 - .../convex-performance-audit/assets/icon.svg | 3 - .../references/function-budget.md | 232 ----------- .../references/hot-path-rules.md | 371 ------------------ .../references/occ-conflicts.md | 126 ------ .../references/subscription-cost.md | 252 ------------ skills/convex-quickstart/SKILL.md | 337 ---------------- skills/convex-quickstart/agents/openai.yaml | 10 - skills/convex-quickstart/assets/icon.svg | 4 - skills/convex-setup-auth/SKILL.md | 150 ------- skills/convex-setup-auth/agents/openai.yaml | 10 - skills/convex-setup-auth/assets/icon.svg | 3 - skills/convex-setup-auth/references/auth0.md | 116 ------ skills/convex-setup-auth/references/clerk.md | 113 ------ .../references/convex-auth.md | 143 ------- .../references/workos-authkit.md | 114 ------ test/strategy_page_session_provider_test.dart | 55 +++ 65 files changed, 260 insertions(+), 6534 deletions(-) delete mode 100644 .agents/skills/convex-create-component/SKILL.md delete mode 100644 .agents/skills/convex-create-component/agents/openai.yaml delete mode 100644 .agents/skills/convex-create-component/assets/icon.svg delete mode 100644 .agents/skills/convex-create-component/references/advanced-patterns.md delete mode 100644 .agents/skills/convex-create-component/references/hybrid-components.md delete mode 100644 .agents/skills/convex-create-component/references/local-components.md delete mode 100644 .agents/skills/convex-create-component/references/packaged-components.md delete mode 100644 .agents/skills/convex-migration-helper/SKILL.md delete mode 100644 .agents/skills/convex-migration-helper/agents/openai.yaml delete mode 100644 .agents/skills/convex-migration-helper/assets/icon.svg delete mode 100644 .agents/skills/convex-migration-helper/references/migration-patterns.md delete mode 100644 .agents/skills/convex-migration-helper/references/migrations-component.md delete mode 100644 .agents/skills/convex-performance-audit/SKILL.md delete mode 100644 .agents/skills/convex-performance-audit/agents/openai.yaml delete mode 100644 .agents/skills/convex-performance-audit/assets/icon.svg delete mode 100644 .agents/skills/convex-performance-audit/references/function-budget.md delete mode 100644 .agents/skills/convex-performance-audit/references/hot-path-rules.md delete mode 100644 .agents/skills/convex-performance-audit/references/occ-conflicts.md delete mode 100644 .agents/skills/convex-performance-audit/references/subscription-cost.md delete mode 100644 .agents/skills/convex-quickstart/SKILL.md delete mode 100644 .agents/skills/convex-quickstart/agents/openai.yaml delete mode 100644 .agents/skills/convex-quickstart/assets/icon.svg delete mode 100644 .agents/skills/convex-setup-auth/SKILL.md delete mode 100644 .agents/skills/convex-setup-auth/agents/openai.yaml delete mode 100644 .agents/skills/convex-setup-auth/assets/icon.svg delete mode 100644 .agents/skills/convex-setup-auth/references/auth0.md delete mode 100644 .agents/skills/convex-setup-auth/references/clerk.md delete mode 100644 .agents/skills/convex-setup-auth/references/convex-auth.md delete mode 100644 .agents/skills/convex-setup-auth/references/workos-authkit.md delete mode 100644 skills/convex-create-component/SKILL.md delete mode 100644 skills/convex-create-component/agents/openai.yaml delete mode 100644 skills/convex-create-component/assets/icon.svg delete mode 100644 skills/convex-create-component/references/advanced-patterns.md delete mode 100644 skills/convex-create-component/references/hybrid-components.md delete mode 100644 skills/convex-create-component/references/local-components.md delete mode 100644 skills/convex-create-component/references/packaged-components.md delete mode 100644 skills/convex-migration-helper/SKILL.md delete mode 100644 skills/convex-migration-helper/agents/openai.yaml delete mode 100644 skills/convex-migration-helper/assets/icon.svg delete mode 100644 skills/convex-migration-helper/references/migration-patterns.md delete mode 100644 skills/convex-migration-helper/references/migrations-component.md delete mode 100644 skills/convex-performance-audit/SKILL.md delete mode 100644 skills/convex-performance-audit/agents/openai.yaml delete mode 100644 skills/convex-performance-audit/assets/icon.svg delete mode 100644 skills/convex-performance-audit/references/function-budget.md delete mode 100644 skills/convex-performance-audit/references/hot-path-rules.md delete mode 100644 skills/convex-performance-audit/references/occ-conflicts.md delete mode 100644 skills/convex-performance-audit/references/subscription-cost.md delete mode 100644 skills/convex-quickstart/SKILL.md delete mode 100644 skills/convex-quickstart/agents/openai.yaml delete mode 100644 skills/convex-quickstart/assets/icon.svg delete mode 100644 skills/convex-setup-auth/SKILL.md delete mode 100644 skills/convex-setup-auth/agents/openai.yaml delete mode 100644 skills/convex-setup-auth/assets/icon.svg delete mode 100644 skills/convex-setup-auth/references/auth0.md delete mode 100644 skills/convex-setup-auth/references/clerk.md delete mode 100644 skills/convex-setup-auth/references/convex-auth.md delete mode 100644 skills/convex-setup-auth/references/workos-authkit.md diff --git a/.agents/skills/convex-create-component/SKILL.md b/.agents/skills/convex-create-component/SKILL.md deleted file mode 100644 index a79c18e0..00000000 --- a/.agents/skills/convex-create-component/SKILL.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -name: convex-create-component -description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. ---- - -# Convex Create Component - -Create reusable Convex components with clear boundaries and a small app-facing API. - -## When to Use - -- Creating a new Convex component in an existing app -- Extracting reusable backend logic into a component -- Building a third-party integration that should own its own tables and workflows -- Packaging Convex functionality for reuse across multiple apps - -## When Not to Use - -- One-off business logic that belongs in the main app -- Thin utilities that do not need Convex tables or functions -- App-level orchestration that should stay in `convex/` -- Cases where a normal TypeScript library is enough - -## Workflow - -1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. -2. Choose the shape using the decision tree below and read the matching reference file. -3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. -4. Make a short plan for: - - what tables the component owns - - what public functions it exposes - - what data must be passed in from the app (auth, env vars, parent IDs) - - what stays in the app as wrappers or HTTP mounts -5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. -6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. -7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. -8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. -9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. -10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. - -## Choose the Shape - -Ask the user, then pick one path: - -| Goal | Shape | Reference | -|------|-------|-----------| -| Component for this app only | Local | `references/local-components.md` | -| Publish or share across apps | Packaged | `references/packaged-components.md` | -| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | -| Not sure | Default to local | `references/local-components.md` | - -Read exactly one reference file before proceeding. - -## Default Approach - -Unless the user explicitly wants an npm package, default to a local component: - -- Put it under `convex/components//` -- Define it with `defineComponent(...)` in its own `convex.config.ts` -- Install it from the app's `convex/convex.config.ts` with `app.use(...)` -- Let `npx convex dev` generate the component's own `_generated/` files - -## Component Skeleton - -A minimal local component with a table and two functions, plus the app wiring. - -```ts -// convex/components/notifications/convex.config.ts -import { defineComponent } from "convex/server"; - -export default defineComponent("notifications"); -``` - -```ts -// convex/components/notifications/schema.ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - notifications: defineTable({ - userId: v.string(), - message: v.string(), - read: v.boolean(), - }).index("by_user", ["userId"]), -}); -``` - -```ts -// convex/components/notifications/lib.ts -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server.js"; - -export const send = mutation({ - args: { userId: v.string(), message: v.string() }, - returns: v.id("notifications"), - handler: async (ctx, args) => { - return await ctx.db.insert("notifications", { - userId: args.userId, - message: args.message, - read: false, - }); - }, -}); - -export const listUnread = query({ - args: { userId: v.string() }, - returns: v.array( - v.object({ - _id: v.id("notifications"), - _creationTime: v.number(), - userId: v.string(), - message: v.string(), - read: v.boolean(), - }) - ), - handler: async (ctx, args) => { - return await ctx.db - .query("notifications") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .filter((q) => q.eq(q.field("read"), false)) - .collect(); - }, -}); -``` - -```ts -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import notifications from "./components/notifications/convex.config.js"; - -const app = defineApp(); -app.use(notifications); - -export default app; -``` - -```ts -// convex/notifications.ts (app-side wrapper) -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; -import { components } from "./_generated/api"; -import { getAuthUserId } from "@convex-dev/auth/server"; - -export const sendNotification = mutation({ - args: { message: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - await ctx.runMutation(components.notifications.lib.send, { - userId, - message: args.message, - }); - return null; - }, -}); - -export const myUnread = query({ - args: {}, - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - return await ctx.runQuery(components.notifications.lib.listUnread, { - userId, - }); - }, -}); -``` - -Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. - -## Critical Rules - -- Keep authentication in the app, because `ctx.auth` is not available inside components. -- Keep environment access in the app, because component functions cannot read `process.env`. -- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. -- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. -- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. -- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. -- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. -- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. -- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. - -## Patterns - -### Authentication and environment access - -```ts -// Bad: component code cannot rely on app auth or env -const identity = await ctx.auth.getUserIdentity(); -const apiKey = process.env.OPENAI_API_KEY; -``` - -```ts -// Good: the app resolves auth and env, then passes explicit values -const userId = await getAuthUserId(ctx); -if (!userId) throw new Error("Not authenticated"); - -await ctx.runAction(components.translator.translate, { - userId, - apiKey: process.env.OPENAI_API_KEY, - text: args.text, -}); -``` - -### Client-facing API - -```ts -// Bad: assuming a component function is directly callable by clients -export const send = components.notifications.send; -``` - -```ts -// Good: re-export through an app mutation or query -export const sendNotification = mutation({ - args: { message: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - await ctx.runMutation(components.notifications.lib.send, { - userId, - message: args.message, - }); - return null; - }, -}); -``` - -### IDs across the boundary - -```ts -// Bad: parent app table IDs are not valid component validators -args: { userId: v.id("users") } -``` - -```ts -// Good: treat parent-owned IDs as strings at the boundary -args: { userId: v.string() } -``` - -### Advanced Patterns - -For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. - -## Validation - -Try validation in this order: - -1. `npx convex codegen --component-dir convex/components/` -2. `npx convex codegen` -3. `npx convex dev` - -Important: - -- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. -- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. -- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. - -## Reference Files - -Read exactly one of these after the user confirms the goal: - -- `references/local-components.md` -- `references/packaged-components.md` -- `references/hybrid-components.md` - -Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) - -## Checklist - -- [ ] Asked the user what they want to build and confirmed the shape -- [ ] Read the matching reference file -- [ ] Confirmed a component is the right abstraction -- [ ] Planned tables, public API, boundaries, and app wrappers -- [ ] Component lives under `convex/components//` (or package layout if publishing) -- [ ] Component imports from its own `./_generated/server` -- [ ] Auth, env access, and HTTP routes stay in the app -- [ ] Parent app IDs cross the boundary as `v.string()` -- [ ] Public functions have `args` and `returns` validators -- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/.agents/skills/convex-create-component/agents/openai.yaml b/.agents/skills/convex-create-component/agents/openai.yaml deleted file mode 100644 index ba9287e4..00000000 --- a/.agents/skills/convex-create-component/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Create Component" - short_description: "Design and build reusable Convex components with clear boundaries." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#14B8A6" - default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." - -policy: - allow_implicit_invocation: true diff --git a/.agents/skills/convex-create-component/assets/icon.svg b/.agents/skills/convex-create-component/assets/icon.svg deleted file mode 100644 index 10f4c2c4..00000000 --- a/.agents/skills/convex-create-component/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.agents/skills/convex-create-component/references/advanced-patterns.md b/.agents/skills/convex-create-component/references/advanced-patterns.md deleted file mode 100644 index 3deb684c..00000000 --- a/.agents/skills/convex-create-component/references/advanced-patterns.md +++ /dev/null @@ -1,134 +0,0 @@ -# Advanced Component Patterns - -Additional patterns for Convex components that go beyond the basics covered in the main skill file. - -## Function Handles for callbacks - -When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. - -```ts -// App side: create a handle and pass it to the component -import { createFunctionHandle } from "convex/server"; - -export const startJob = mutation({ - handler: async (ctx) => { - const handle = await createFunctionHandle(internal.myModule.processItem); - await ctx.runMutation(components.workpool.enqueue, { - callback: handle, - }); - }, -}); -``` - -```ts -// Component side: accept and invoke the handle -import { v } from "convex/values"; -import type { FunctionHandle } from "convex/server"; -import { mutation } from "./_generated/server.js"; - -export const enqueue = mutation({ - args: { callback: v.string() }, - handler: async (ctx, args) => { - const handle = args.callback as FunctionHandle<"mutation">; - await ctx.scheduler.runAfter(0, handle, {}); - }, -}); -``` - -## Deriving validators from schema - -Instead of manually repeating field types in return validators, extend the schema validator: - -```ts -import { v } from "convex/values"; -import schema from "./schema.js"; - -const notificationDoc = schema.tables.notifications.validator.extend({ - _id: v.id("notifications"), - _creationTime: v.number(), -}); - -export const getLatest = query({ - args: {}, - returns: v.nullable(notificationDoc), - handler: async (ctx) => { - return await ctx.db.query("notifications").order("desc").first(); - }, -}); -``` - -## Static configuration with a globals table - -A common pattern for component configuration is a single-document "globals" table: - -```ts -// schema.ts -export default defineSchema({ - globals: defineTable({ - maxRetries: v.number(), - webhookUrl: v.optional(v.string()), - }), - // ... other tables -}); -``` - -```ts -// lib.ts -export const configure = mutation({ - args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, - returns: v.null(), - handler: async (ctx, args) => { - const existing = await ctx.db.query("globals").first(); - if (existing) { - await ctx.db.patch(existing._id, args); - } else { - await ctx.db.insert("globals", args); - } - return null; - }, -}); -``` - -## Class-based client wrappers - -For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. - -```ts -// src/client/index.ts -import type { GenericMutationCtx, GenericDataModel } from "convex/server"; -import type { ComponentApi } from "../component/_generated/component.js"; - -type MutationCtx = Pick, "runMutation">; - -export class Notifications { - constructor( - private component: ComponentApi, - private options?: { defaultChannel?: string }, - ) {} - - async send(ctx: MutationCtx, args: { userId: string; message: string }) { - return await ctx.runMutation(this.component.lib.send, { - ...args, - channel: this.options?.defaultChannel ?? "default", - }); - } -} -``` - -```ts -// App usage -import { Notifications } from "@convex-dev/notifications"; -import { components } from "./_generated/api"; - -const notifications = new Notifications(components.notifications, { - defaultChannel: "alerts", -}); - -export const send = mutation({ - args: { message: v.string() }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - await notifications.send(ctx, { userId, message: args.message }); - }, -}); -``` diff --git a/.agents/skills/convex-create-component/references/hybrid-components.md b/.agents/skills/convex-create-component/references/hybrid-components.md deleted file mode 100644 index d2bb3514..00000000 --- a/.agents/skills/convex-create-component/references/hybrid-components.md +++ /dev/null @@ -1,37 +0,0 @@ -# Hybrid Convex Components - -Read this file only when the user explicitly wants a hybrid setup. - -## What This Means - -A hybrid component combines a local Convex component with shared library code. - -This can help when: - -- the user wants a local install but also shared package logic -- the component needs extension points or override hooks -- some logic should live in normal TypeScript code outside the component boundary - -## Default Advice - -Treat hybrid as an advanced option, not the default. - -Before choosing it, ask: - -- Why is a plain local component not enough? -- Why is a packaged component not enough? -- What exactly needs to stay overridable or shared? - -If the answer is vague, fall back to local or packaged. - -## Risks - -- More moving parts -- Harder upgrades and backwards compatibility -- Easier to blur the component boundary - -## Checklist - -- [ ] User explicitly needs hybrid behavior -- [ ] Local-only and packaged-only options were considered first -- [ ] The extension points are clearly defined before coding diff --git a/.agents/skills/convex-create-component/references/local-components.md b/.agents/skills/convex-create-component/references/local-components.md deleted file mode 100644 index 7fbfe21a..00000000 --- a/.agents/skills/convex-create-component/references/local-components.md +++ /dev/null @@ -1,38 +0,0 @@ -# Local Convex Components - -Read this file when the component should live inside the current app and does not need to be published as an npm package. - -## When to Choose This - -- The user wants the simplest path -- The component only needs to work in this repo -- The goal is extracting app logic into a cleaner boundary - -## Default Layout - -Use this structure unless the repo already has a clear alternative pattern: - -```text -convex/ - convex.config.ts - components/ - / - convex.config.ts - schema.ts - .ts -``` - -## Workflow Notes - -- Define the component with `defineComponent("")` -- Install it from the app with `defineApp()` and `app.use(...)` -- Keep auth, env access, public API wrappers, and HTTP route mounting in the app -- Let the component own isolated tables and reusable backend workflows -- Add app wrappers if clients need to call into the component - -## Checklist - -- [ ] Component is inside `convex/components//` -- [ ] App installs it with `app.use(...)` -- [ ] Component owns only its own tables -- [ ] App wrappers handle client-facing calls when needed diff --git a/.agents/skills/convex-create-component/references/packaged-components.md b/.agents/skills/convex-create-component/references/packaged-components.md deleted file mode 100644 index 5668e7ed..00000000 --- a/.agents/skills/convex-create-component/references/packaged-components.md +++ /dev/null @@ -1,51 +0,0 @@ -# Packaged Convex Components - -Read this file when the user wants a reusable npm package or a component shared across multiple apps. - -## When to Choose This - -- The user wants to publish the component -- The user wants a stable reusable package boundary -- The component will be shared across multiple apps or teams - -## Default Approach - -- Prefer starting from `npx create-convex@latest --component` when possible -- Keep the official authoring docs as the source of truth for package layout and exports -- Validate the bundled package through an example app, not just the source files - -## Build Flow - -When building a packaged component, make sure the bundled output exists before the example app tries to consume it. - -Recommended order: - -1. `npx convex codegen --component-dir ./path/to/component` -2. Run the package build command -3. Run `npx convex dev --typecheck-components` in the example app - -Do not assume normal app codegen is enough for packaged component workflows. - -## Package Exports - -If publishing to npm, make sure the package exposes the entry points apps need: - -- package root for client helpers, types, or classes -- `./convex.config.js` for installing the component -- `./_generated/component.js` for the app-facing `ComponentApi` type -- `./test` for testing helpers when applicable - -## Testing - -- Use `convex-test` for component logic -- Register the component schema and modules with the test instance -- Test app-side wrapper code from an example app that installs the package -- Export a small helper from `./test` if consumers need easy test registration - -## Checklist - -- [ ] Packaging is actually required -- [ ] Build order avoids bundle and codegen races -- [ ] Package exports include install and typing entry points -- [ ] Example app exercises the packaged component -- [ ] Core behavior is covered by tests diff --git a/.agents/skills/convex-migration-helper/SKILL.md b/.agents/skills/convex-migration-helper/SKILL.md deleted file mode 100644 index 97f64c1a..00000000 --- a/.agents/skills/convex-migration-helper/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: convex-migration-helper -description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. ---- - -# Convex Migration Helper - -Safely migrate Convex schemas and data when making breaking changes. - -## When to Use - -- Adding new required fields to existing tables -- Changing field types or structure -- Splitting or merging tables -- Renaming or deleting fields -- Migrating from nested to relational data - -## When Not to Use - -- Greenfield schema with no existing data in production or dev -- Adding optional fields that do not need backfilling -- Adding new tables with no existing data to migrate -- Adding or removing indexes with no correctness concern -- Questions about Convex schema design without a migration need - -## Key Concepts - -### Schema Validation Drives the Workflow - -Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: - -- You cannot add a required field if existing documents don't have it -- You cannot change a field's type if existing documents have the old type -- You cannot remove a field from the schema if existing documents still have it - -This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. - -### Online Migrations - -Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. - -### Prefer New Fields Over Changing Types - -When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. - -### Don't Delete Data - -Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. - -## Safe Changes (No Migration Needed) - -### Adding Optional Field - -```typescript -// Before -users: defineTable({ - name: v.string(), -}) - -// After - safe, new field is optional -users: defineTable({ - name: v.string(), - bio: v.optional(v.string()), -}) -``` - -### Adding New Table - -```typescript -posts: defineTable({ - userId: v.id("users"), - title: v.string(), -}).index("by_user", ["userId"]) -``` - -### Adding Index - -```typescript -users: defineTable({ - name: v.string(), - email: v.string(), -}) - .index("by_email", ["email"]) -``` - -## Breaking Changes: The Deployment Workflow - -Every breaking migration follows the same multi-deploy pattern: - -**Deploy 1 - Widen the schema:** - -1. Update schema to allow both old and new formats (e.g., add optional new field) -2. Update code to handle both formats when reading -3. Update code to write the new format for new documents -4. Deploy - -**Between deploys - Migrate data:** - -5. Run migration to backfill existing documents -6. Verify all documents are migrated - -**Deploy 2 - Narrow the schema:** - -7. Update schema to require the new format only -8. Remove code that handles the old format -9. Deploy - -## Using the Migrations Component - -For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. - -See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. - -## Common Migration Patterns - -See `references/migration-patterns.md` for complete patterns with code examples covering: - -- Adding a required field -- Deleting a field -- Changing a field type -- Splitting nested data into a separate table -- Cleaning up orphaned documents -- Zero-downtime strategies (dual write, dual read) -- Small table shortcut (single internalMutation without the component) -- Verifying a migration is complete - -## Common Pitfalls - -1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. -2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. -3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." -4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. -5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. -6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. - -## Migration Checklist - -- [ ] Identify the breaking change and plan the multi-deploy workflow -- [ ] Update schema to allow both old and new formats -- [ ] Update code to handle both formats when reading -- [ ] Update code to write the new format for new documents -- [ ] Deploy widened schema and updated code -- [ ] Define migration using the `@convex-dev/migrations` component -- [ ] Test with `dryRun: true` -- [ ] Run migration and monitor status -- [ ] Verify all documents are migrated -- [ ] Update schema to require new format only -- [ ] Clean up code that handled old format -- [ ] Deploy final schema and code -- [ ] Remove migration code once confirmed stable diff --git a/.agents/skills/convex-migration-helper/agents/openai.yaml b/.agents/skills/convex-migration-helper/agents/openai.yaml deleted file mode 100644 index c2a7fcc5..00000000 --- a/.agents/skills/convex-migration-helper/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Migration Helper" - short_description: "Plan and run safe Convex schema and data migrations." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#8B5CF6" - default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." - -policy: - allow_implicit_invocation: true diff --git a/.agents/skills/convex-migration-helper/assets/icon.svg b/.agents/skills/convex-migration-helper/assets/icon.svg deleted file mode 100644 index fba7241a..00000000 --- a/.agents/skills/convex-migration-helper/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.agents/skills/convex-migration-helper/references/migration-patterns.md b/.agents/skills/convex-migration-helper/references/migration-patterns.md deleted file mode 100644 index 219583e0..00000000 --- a/.agents/skills/convex-migration-helper/references/migration-patterns.md +++ /dev/null @@ -1,231 +0,0 @@ -# Migration Patterns Reference - -Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. - -## Adding a Required Field - -```typescript -// Deploy 1: Schema allows both states -users: defineTable({ - name: v.string(), - role: v.optional(v.union(v.literal("user"), v.literal("admin"))), -}) - -// Migration: backfill the field -export const addDefaultRole = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.role === undefined) { - await ctx.db.patch(user._id, { role: "user" }); - } - }, -}); - -// Deploy 2: After migration completes, make it required -users: defineTable({ - name: v.string(), - role: v.union(v.literal("user"), v.literal("admin")), -}) -``` - -## Deleting a Field - -Mark the field optional first, migrate data to remove it, then remove from schema: - -```typescript -// Deploy 1: Make optional -// isPro: v.boolean() --> isPro: v.optional(v.boolean()) - -// Migration -export const removeIsPro = migrations.define({ - table: "teams", - migrateOne: async (ctx, team) => { - if (team.isPro !== undefined) { - await ctx.db.patch(team._id, { isPro: undefined }); - } - }, -}); - -// Deploy 2: Remove isPro from schema entirely -``` - -## Changing a Field Type - -Prefer creating a new field. You can combine adding and deleting in one migration: - -```typescript -// Deploy 1: Add new field, keep old field optional -// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) - -// Migration: convert old field to new field -export const convertToEnum = migrations.define({ - table: "teams", - migrateOne: async (ctx, team) => { - if (team.plan === undefined) { - await ctx.db.patch(team._id, { - plan: team.isPro ? "pro" : "basic", - isPro: undefined, - }); - } - }, -}); - -// Deploy 2: Remove isPro from schema, make plan required -``` - -## Splitting Nested Data Into a Separate Table - -```typescript -export const extractPreferences = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.preferences === undefined) return; - - const existing = await ctx.db - .query("userPreferences") - .withIndex("by_user", (q) => q.eq("userId", user._id)) - .first(); - - if (!existing) { - await ctx.db.insert("userPreferences", { - userId: user._id, - ...user.preferences, - }); - } - - await ctx.db.patch(user._id, { preferences: undefined }); - }, -}); -``` - -Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. - -## Cleaning Up Orphaned Documents - -```typescript -export const deleteOrphanedEmbeddings = migrations.define({ - table: "embeddings", - migrateOne: async (ctx, doc) => { - const chunk = await ctx.db - .query("chunks") - .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) - .first(); - - if (!chunk) { - await ctx.db.delete(doc._id); - } - }, -}); -``` - -## Zero-Downtime Strategies - -During the migration window, your app must handle both old and new data formats. There are two main strategies. - -### Dual Write (Preferred) - -Write to both old and new structures. Read from the old structure until migration is complete. - -1. Deploy code that writes both formats, reads old format -2. Run migration on existing data -3. Deploy code that reads new format, still writes both -4. Deploy code that only reads and writes new format - -This is preferred because you can safely roll back at any point, the old format is always up to date. - -```typescript -// Bad: only writing to new structure before migration is done -export const createTeam = mutation({ - args: { name: v.string(), isPro: v.boolean() }, - handler: async (ctx, args) => { - await ctx.db.insert("teams", { - name: args.name, - plan: args.isPro ? "pro" : "basic", - }); - }, -}); - -// Good: writing to both structures during migration -export const createTeam = mutation({ - args: { name: v.string(), isPro: v.boolean() }, - handler: async (ctx, args) => { - const plan = args.isPro ? "pro" : "basic"; - await ctx.db.insert("teams", { - name: args.name, - isPro: args.isPro, - plan, - }); - }, -}); -``` - -### Dual Read - -Read both formats. Write only the new format. - -1. Deploy code that reads both formats (preferring new), writes only new format -2. Run migration on existing data -3. Deploy code that reads and writes only new format - -This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. - -```typescript -// Good: reading both formats, preferring new -function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { - if (team.plan !== undefined) return team.plan; - return team.isPro ? "pro" : "basic"; -} -``` - -## Small Table Shortcut - -For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: - -```typescript -import { internalMutation } from "./_generated/server"; - -export const backfillSmallTable = internalMutation({ - handler: async (ctx) => { - const docs = await ctx.db.query("smallConfig").collect(); - for (const doc of docs) { - if (doc.newField === undefined) { - await ctx.db.patch(doc._id, { newField: "default" }); - } - } - }, -}); -``` - -```bash -npx convex run migrations:backfillSmallTable -``` - -Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. - -## Verifying a Migration - -Query to check remaining unmigrated documents: - -```typescript -import { query } from "./_generated/server"; - -export const verifyMigration = query({ - handler: async (ctx) => { - const remaining = await ctx.db - .query("users") - .filter((q) => q.eq(q.field("role"), undefined)) - .take(10); - - return { - complete: remaining.length === 0, - sampleRemaining: remaining.map((u) => u._id), - }; - }, -}); -``` - -Or use the component's built-in status monitoring: - -```bash -npx convex run --component migrations lib:getStatus --watch -``` diff --git a/.agents/skills/convex-migration-helper/references/migrations-component.md b/.agents/skills/convex-migration-helper/references/migrations-component.md deleted file mode 100644 index c80522f2..00000000 --- a/.agents/skills/convex-migration-helper/references/migrations-component.md +++ /dev/null @@ -1,170 +0,0 @@ -# Migrations Component Reference - -Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. - -## Installation - -```bash -npm install @convex-dev/migrations -``` - -## Setup - -```typescript -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import migrations from "@convex-dev/migrations/convex.config.js"; - -const app = defineApp(); -app.use(migrations); -export default app; -``` - -```typescript -// convex/migrations.ts -import { Migrations } from "@convex-dev/migrations"; -import { components } from "./_generated/api.js"; -import { DataModel } from "./_generated/dataModel.js"; - -export const migrations = new Migrations(components.migrations); -export const run = migrations.runner(); -``` - -The `DataModel` type parameter is optional but provides type safety for migration definitions. - -## Define a Migration - -The `migrateOne` function processes a single document. The component handles batching and pagination automatically. - -```typescript -// convex/migrations.ts -export const addDefaultRole = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.role === undefined) { - await ctx.db.patch(user._id, { role: "user" }); - } - }, -}); -``` - -Shorthand: if you return an object, it is applied as a patch automatically. - -```typescript -export const clearDeprecatedField = migrations.define({ - table: "users", - migrateOne: () => ({ legacyField: undefined }), -}); -``` - -## Run a Migration - -From the CLI: - -```bash -# Define a one-off runner in convex/migrations.ts: -# export const runIt = migrations.runner(internal.migrations.addDefaultRole); -npx convex run migrations:runIt - -# Or use the general-purpose runner -npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' -``` - -Programmatically from another Convex function: - -```typescript -await migrations.runOne(ctx, internal.migrations.addDefaultRole); -``` - -## Run Multiple Migrations in Order - -```typescript -export const runAll = migrations.runner([ - internal.migrations.addDefaultRole, - internal.migrations.clearDeprecatedField, - internal.migrations.normalizeEmails, -]); -``` - -```bash -npx convex run migrations:runAll -``` - -If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. - -## Dry Run - -Test a migration before committing changes: - -```bash -npx convex run migrations:runIt '{"dryRun": true}' -``` - -This runs one batch and then rolls back, so you can see what it would do without changing any data. - -## Check Migration Status - -```bash -npx convex run --component migrations lib:getStatus --watch -``` - -## Cancel a Running Migration - -```bash -npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' -``` - -Or programmatically: - -```typescript -await migrations.cancel(ctx, internal.migrations.addDefaultRole); -``` - -## Run Migrations on Deploy - -Chain migration execution after deploying: - -```bash -npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod -``` - -## Configuration Options - -### Custom Batch Size - -If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: - -```typescript -export const migrateHeavyTable = migrations.define({ - table: "largeDocuments", - batchSize: 10, - migrateOne: async (ctx, doc) => { - // migration logic - }, -}); -``` - -### Migrate a Subset Using an Index - -Process only matching documents instead of the full table: - -```typescript -export const fixEmptyNames = migrations.define({ - table: "users", - customRange: (query) => - query.withIndex("by_name", (q) => q.eq("name", "")), - migrateOne: () => ({ name: "" }), -}); -``` - -### Parallelize Within a Batch - -By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: - -```typescript -export const clearField = migrations.define({ - table: "myTable", - parallelize: true, - migrateOne: () => ({ optionalField: undefined }), -}); -``` diff --git a/.agents/skills/convex-performance-audit/SKILL.md b/.agents/skills/convex-performance-audit/SKILL.md deleted file mode 100644 index 9d92b33c..00000000 --- a/.agents/skills/convex-performance-audit/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: convex-performance-audit -description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. ---- - -# Convex Performance Audit - -Diagnose and fix performance problems in Convex applications, one problem class at a time. - -## When to Use - -- A Convex page or feature feels slow or expensive -- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts -- Low-freshness read paths are using reactivity where point-in-time reads would do -- OCC conflict errors or excessive mutation retries -- High subscription count or slow UI updates -- Functions approaching execution or transaction limits -- The same performance pattern needs fixing across sibling functions - -## When Not to Use - -- Initial Convex setup, auth setup, or component extraction -- Pure schema migrations with no performance goal -- One-off micro-optimizations without a user-visible or deployment-visible problem - -## Guardrails - -- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak -- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path -- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale - -## First Step: Gather Signals - -Start with the strongest signal available: - -1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. -2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. - - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. -3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. -4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. - -## Signal Routing - -After gathering signals, identify the problem class and read the matching reference file. - -| Signal | Reference | -|---|---| -| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | -| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | -| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | -| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | -| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | - -Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. - -## Escalate Larger Fixes - -If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. - -Examples: - -- introducing digest or summary tables across multiple flows -- splitting documents to isolate frequently-updated fields -- reworking pagination or fetch strategy across several screens -- switching to a new index or denormalized field that needs migration-safe rollout - -When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. - -## Workflow - -### 1. Scope the problem - -Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. - -Write down: - -- entrypoint functions -- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` -- tables read -- tables written -- whether the path is high-read, high-write, or both - -### 2. Trace the full read and write set - -For each function in the path: - -1. Trace every `ctx.db.get()` and `ctx.db.query()` -2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` -3. Note foreign-key lookups, JS-side filtering, and full-document reads -4. Identify all sibling functions touching the same tables -5. Identify reactive stats, aggregates, or widgets rendered on the same page - -In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. - -### 3. Apply fixes from the relevant reference - -Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. - -Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. - -### 4. Fix sibling functions together - -When one function touching a table has a performance bug, audit sibling functions for the same pattern. - -After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. - -Examples: - -- If one list query switches from full docs to a digest table, inspect the other list queries for that table -- If one mutation needs no-op write protection, inspect the other writers to the same table -- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk - -Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. - -### 5. Verify before finishing - -Confirm all of these: - -1. Results are the same as before, no dropped records -2. Eliminated reads or writes are no longer in the path where expected -3. Fallback behavior works when denormalized or indexed fields are missing -4. New writes avoid unnecessary invalidation when data is unchanged -5. Every relevant sibling reader and writer was inspected, not just the original function - -## Reference Files - -- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables -- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting -- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads -- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size - -Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. - -## Checklist - -- [ ] Gathered signals from insights, dashboard, or code audit -- [ ] Identified the problem class and read the matching reference -- [ ] Scoped one concrete user flow or function path -- [ ] Traced every read and write in that path -- [ ] Identified sibling functions touching the same tables -- [ ] Applied fixes from the reference, following the recommended fix order -- [ ] Fixed sibling functions consistently -- [ ] Verified behavior and confirmed no regressions diff --git a/.agents/skills/convex-performance-audit/agents/openai.yaml b/.agents/skills/convex-performance-audit/agents/openai.yaml deleted file mode 100644 index 9a21f387..00000000 --- a/.agents/skills/convex-performance-audit/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Performance Audit" - short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#EF4444" - default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." - -policy: - allow_implicit_invocation: true diff --git a/.agents/skills/convex-performance-audit/assets/icon.svg b/.agents/skills/convex-performance-audit/assets/icon.svg deleted file mode 100644 index 7ab9e09c..00000000 --- a/.agents/skills/convex-performance-audit/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.agents/skills/convex-performance-audit/references/function-budget.md b/.agents/skills/convex-performance-audit/references/function-budget.md deleted file mode 100644 index c71d14cb..00000000 --- a/.agents/skills/convex-performance-audit/references/function-budget.md +++ /dev/null @@ -1,232 +0,0 @@ -# Function Budget - -Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. - -## Core Principle - -Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. - -## Limits to Know - -These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. - -| Resource | Limit | -|---|---| -| Query/mutation execution time | 1 second (user code only, excludes DB operations) | -| Action execution time | 10 minutes | -| Data read per transaction | 16 MiB | -| Data written per transaction | 16 MiB | -| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | -| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | -| Documents written per transaction | 16,000 | -| Individual document size | 1 MiB | -| Function return value size | 16 MiB | - -## Symptoms - -- "Function execution took too long" errors -- "Transaction too large" or read/write set size errors -- Slow queries that read many documents -- Client receiving large payloads that slow down page load -- `npx convex insights --details` showing high bytes read - -## Common Causes - -### Unbounded collection - -A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. - -### Large document reads on hot paths - -Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. - -### Mutation doing too much work - -A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. - -### Returning too much data to the client - -A query returning full documents when the client only needs a few fields. - -## Fix Order - -### 1. Bound your reads - -Never `.collect()` without a limit on a table that can grow unbounded. - -```ts -// Bad: unbounded read, breaks as the table grows -const messages = await ctx.db.query("messages").collect(); -``` - -```ts -// Good: paginate or limit -const messages = await ctx.db - .query("messages") - .withIndex("by_channel", (q) => q.eq("channelId", channelId)) - .order("desc") - .take(50); -``` - -### 2. Read smaller shapes - -If the list page only needs title, author, and date, do not read full documents with rich content fields. - -Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. - -### 3. Break large mutations into batches - -If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. - -```ts -// Bad: one mutation updating every row -export const backfillAll = internalMutation({ - handler: async (ctx) => { - const docs = await ctx.db.query("items").collect(); - for (const doc of docs) { - await ctx.db.patch(doc._id, { newField: computeValue(doc) }); - } - }, -}); -``` - -```ts -// Good: cursor-based batch processing -export const backfillBatch = internalMutation({ - args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, - handler: async (ctx, args) => { - const batchSize = args.batchSize ?? 100; - const result = await ctx.db - .query("items") - .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); - - for (const doc of result.page) { - if (doc.newField === undefined) { - await ctx.db.patch(doc._id, { newField: computeValue(doc) }); - } - } - - if (!result.isDone) { - await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { - cursor: result.continueCursor, - batchSize, - }); - } - }, -}); -``` - -### 4. Move heavy work to actions - -Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. - -Actions run outside the transaction and can call mutations to write results back. - -```ts -// Bad: heavy computation inside a mutation -export const processUpload = mutation({ - handler: async (ctx, args) => { - const result = expensiveComputation(args.data); - await ctx.db.insert("results", result); - }, -}); -``` - -```ts -// Good: action for heavy work, mutation for the write -export const processUpload = action({ - handler: async (ctx, args) => { - const result = expensiveComputation(args.data); - await ctx.runMutation(internal.results.store, { result }); - }, -}); -``` - -### 5. Trim return values - -Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. - -```ts -// Bad: returns full documents including large content fields -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("articles").take(20); - }, -}); -``` - -```ts -// Good: project to only the fields the client needs -export const list = query({ - handler: async (ctx) => { - const articles = await ctx.db.query("articles").take(20); - return articles.map((a) => ({ - _id: a._id, - title: a.title, - author: a.author, - createdAt: a._creationTime, - })); - }, -}); -``` - -### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions - -Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. - -```ts -// Bad: unnecessary overhead from ctx.runQuery inside a mutation -export const createProject = mutation({ - handler: async (ctx, args) => { - const user = await ctx.runQuery(api.users.getCurrentUser); - await ctx.db.insert("projects", { ...args, ownerId: user._id }); - }, -}); -``` - -```ts -// Good: plain helper function, no extra overhead -export const createProject = mutation({ - handler: async (ctx, args) => { - const user = await getCurrentUser(ctx); - await ctx.db.insert("projects", { ...args, ownerId: user._id }); - }, -}); -``` - -Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. - -### 7. Avoid unnecessary `runAction` calls - -`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). - -```ts -// Bad: runAction overhead for no reason -export const processItems = action({ - handler: async (ctx, args) => { - for (const item of args.items) { - await ctx.runAction(internal.items.processOne, { item }); - } - }, -}); -``` - -```ts -// Good: plain function call -export const processItems = action({ - handler: async (ctx, args) => { - for (const item of args.items) { - await processOneItem(ctx, { item }); - } - }, -}); -``` - -## Verification - -1. No function execution or transaction size errors -2. `npx convex insights --details` shows reduced bytes read -3. Large mutations are batched and self-scheduling -4. Client payloads are reasonably sized for the UI they serve -5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible -6. Sibling functions with similar patterns were checked diff --git a/.agents/skills/convex-performance-audit/references/hot-path-rules.md b/.agents/skills/convex-performance-audit/references/hot-path-rules.md deleted file mode 100644 index e3e44b15..00000000 --- a/.agents/skills/convex-performance-audit/references/hot-path-rules.md +++ /dev/null @@ -1,371 +0,0 @@ -# Hot Path Rules - -Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. - -## Contents - -- Core Principle -- Consistency Rule -- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) -- 2. Minimize Data Sources (denormalization, fallback rule) -- 3. Minimize Row Size (digest tables) -- 4. Skip No-Op Writes -- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) -- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) -- Verification - -## Core Principle - -Every byte read or written multiplies with concurrency. - -Think: - -`cost x calls_per_second x 86400` - -In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. - -## Consistency Rule - -If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. - -Do this especially for: - -- multiple list queries over the same table -- multiple writers to the same table -- public browse and search queries over the same records -- helper functions reused by more than one endpoint - -## 1. Push Filters To Storage - -Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. - -Prefer: - -- `withIndex(...)` -- `.withSearchIndex(...)` for text search -- narrower tables -- summary tables - -before accepting a scan-plus-filter pattern. - -```ts -// Bad: scans then filters in JavaScript -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - const tasks = await ctx.db.query("tasks").collect(); - return tasks.filter((task) => task.status === "open"); - }, -}); -``` - -```ts -// Also bad: Convex .filter() does not push to storage either -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("tasks") - .filter((q) => q.eq(q.field("status"), "open")) - .collect(); - }, -}); -``` - -```ts -// Good: use an index so storage does the filtering -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("tasks") - .withIndex("by_status", (q) => q.eq("status", "open")) - .collect(); - }, -}); -``` - -### Migration rule for indexes - -New indexes on partially backfilled fields can create correctness bugs during rollout. - -Important Convex detail: - -`undefined !== false` - -If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. - -Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. - -If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. - -```ts -// Bad: optional booleans can miss older rows where the field is undefined -const projects = await ctx.db - .query("projects") - .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) - .order("desc") - .take(20); -``` - -```ts -// Good: switch hot-path reads only after the rollout is migration-safe -// See the migration helper skill for dual-read / backfill / cutover patterns. -``` - -### Check for redundant indexes - -Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. - -```ts -// Bad: two indexes where one would do -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team", ["team"]) - .index("by_team_and_user", ["team", "user"]) -``` - -```ts -// Good: single compound index serves both query patterns -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team_and_user", ["team", "user"]) -``` - -Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. - -## 2. Minimize Data Sources - -Trace every read. - -If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. - -### When to denormalize - -Denormalize when all of these are true: - -- the path is hot -- the joined document is much larger than the field you need -- many readers are paying that join cost repeatedly - -Useful mental model: - -`join_cost = rows_per_page x foreign_doc_size x pages_per_second` - -Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. - -### Fallback rule - -Denormalized data is an optimization. Live data is the correctness path. - -Rules: - -- If the denormalized field is missing or null, fall back to the live read -- Do not show placeholders instead of falling back -- In lookup maps, only include fully populated entries - -```ts -// Bad: missing denormalized data becomes a placeholder and blocks correctness -const ownerName = project.ownerName ?? "Unknown owner"; -``` - -```ts -// Good: denormalized data is an optimization, not the only source of truth -const ownerName = - project.ownerName ?? - (await ctx.db.get(project.ownerId))?.name ?? - null; -``` - -Bad lookup map pattern: - -```ts -const ownersById = { - [project.ownerId]: { ownerName: null }, -}; -``` - -That blocks fallback because the map says "I have data" when it does not. - -Good lookup map pattern: - -```ts -const ownersById = - project.ownerName !== undefined && project.ownerName !== null - ? { [project.ownerId]: { ownerName: project.ownerName } } - : {}; -``` - -### No denormalized copy yet - -Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. - -If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. - -Rollout order: - -1. Update schema -2. Update write path -3. Backfill -4. Switch read path - -## 3. Minimize Row Size - -Hot list pages should read the smallest document shape that still answers the UI. - -Prefer summary or digest tables over full source tables when: - -- the list page only needs a subset of fields -- source documents are large -- the query is high volume - -An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. - -Digest tables are a tradeoff, not a default: - -- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost -- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit - -```ts -// Bad: list page reads source docs, then joins owner data per row -const projects = await ctx.db - .query("projects") - .withIndex("by_public", (q) => q.eq("isPublic", true)) - .collect(); -``` - -```ts -// Good: list page reads the smaller digest shape first -const projects = await ctx.db - .query("projectDigests") - .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) - .order("desc") - .take(20); -``` - -## 4. Skip No-Op Writes - -No-op writes still cost work in Convex: - -- invalidation -- replication -- trigger execution -- downstream sync - -Before `patch` or `replace`, compare against the existing document and skip the write if nothing changed. - -Apply this across sibling writers too. One careful writer does not help much if three other mutations still patch unconditionally. - -```ts -// Bad: patching unchanged values still triggers invalidation and downstream work -await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, -}); -``` - -```ts -// Good: only write when something actually changed -if (settings.theme !== args.theme || settings.locale !== args.locale) { - await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, - }); -} -``` - -## 5. Match Consistency To Read Patterns - -Choose read strategy based on traffic shape. - -### High-read, low-write - -Examples: - -- public browse pages -- search results -- landing pages -- directory listings - -Prefer: - -- point-in-time reads where appropriate -- explicit refresh -- local state for pagination -- caching where appropriate - -Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. - -### High-read, high-write - -Examples: - -- collaborative editors -- live dashboards -- presence-heavy views - -Reactive queries may be worth the ongoing cost. - -## Convex-Specific Notes - -### Reactive queries - -Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. - -On the client: - -- `useQuery` creates a live subscription -- `usePaginatedQuery` creates a live subscription per page - -For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. - -### Point-in-time reads - -Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. - -Use them for: - -- aggregate snapshots -- reports -- low-churn listings -- pages where explicit refresh is fine - -### Triggers and fan-out - -Triggers fire on every write, including writes that did not materially change the document. - -When a write exists only to keep derived state in sync: - -- diff before patching -- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate - -### Aggregates - -Reactive global counts invalidate frequently on busy tables. - -Prefer: - -- one-shot aggregate fetches -- periodic recomputation -- precomputed summary rows - -for global stats that do not need live updates every second. - -### Backfills - -For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. - -Deploy code that can handle both states before running the backfill. - -During the gap: - -- writes should populate the new shape -- reads should fall back safely - -## Verification - -Before closing the audit, confirm: - -1. Same results as before, no dropped records -2. The removed table or lookup is no longer in the hot-path read set -3. Tests or validation cover fallback behavior -4. Migration safety is preserved while fields or indexes are unbackfilled -5. Sibling functions were fixed consistently diff --git a/.agents/skills/convex-performance-audit/references/occ-conflicts.md b/.agents/skills/convex-performance-audit/references/occ-conflicts.md deleted file mode 100644 index a96d0466..00000000 --- a/.agents/skills/convex-performance-audit/references/occ-conflicts.md +++ /dev/null @@ -1,126 +0,0 @@ -# OCC Conflict Resolution - -Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. - -## Core Principle - -Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. - -## Symptoms - -- OCC conflict errors in deployment logs or health page -- Mutations retrying multiple times before succeeding -- User-visible latency spikes on write-heavy pages -- `npx convex insights --details` showing high conflict rates - -## Common Causes - -### Hot documents - -Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. - -### Broad read sets causing false conflicts - -A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. - -### Fan-out from triggers or cascading writes - -A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. - -Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. - -### Write-then-read chains - -A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. - -## Fix Order - -### 1. Reduce read set size - -Narrower reads mean fewer false conflicts. - -```ts -// Bad: broad scan creates a wide conflict surface -const allTasks = await ctx.db.query("tasks").collect(); -const mine = allTasks.filter((t) => t.ownerId === userId); -``` - -```ts -// Good: indexed query touches only relevant documents -const mine = await ctx.db - .query("tasks") - .withIndex("by_owner", (q) => q.eq("ownerId", userId)) - .collect(); -``` - -### 2. Split hot documents - -When many writers target the same document, split the contention point. - -```ts -// Bad: every vote increments the same counter document -const counter = await ctx.db.get(pollCounterId); -await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); -``` - -```ts -// Good: shard the counter across multiple documents, aggregate on read -const shardIndex = Math.floor(Math.random() * SHARD_COUNT); -const shardId = shardIds[shardIndex]; -const shard = await ctx.db.get(shardId); -await ctx.db.patch(shardId, { count: shard!.count + 1 }); -``` - -Aggregate the shards in a query or scheduled job when you need the total. - -### 3. Skip no-op writes - -Writes that do not change data still participate in conflict detection and trigger invalidation. - -```ts -// Bad: patches even when nothing changed -await ctx.db.patch(doc._id, { status: args.status }); -``` - -```ts -// Good: only write when the value actually differs -if (doc.status !== args.status) { - await ctx.db.patch(doc._id, { status: args.status }); -} -``` - -### 4. Move non-critical work to scheduled functions - -If a mutation does primary work plus secondary bookkeeping (analytics, notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. - -```ts -// Bad: analytics update in the same transaction as the user action -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.db.insert("analytics", { event: "action", userId, ts: Date.now() }); -``` - -```ts -// Good: schedule the bookkeeping so the primary transaction is smaller -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.scheduler.runAfter(0, internal.analytics.recordEvent, { - event: "action", - userId, -}); -``` - -### 5. Combine competing writes - -If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. - -Do not introduce artificial locks or queues unless the above steps have been tried first. - -## Related: Invalidation Scope - -Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. - -## Verification - -1. OCC conflict rate has dropped in insights or dashboard -2. Mutation latency is lower and more consistent -3. No data correctness regressions from splitting or scheduling changes -4. Sibling writers to the same hot documents were fixed consistently diff --git a/.agents/skills/convex-performance-audit/references/subscription-cost.md b/.agents/skills/convex-performance-audit/references/subscription-cost.md deleted file mode 100644 index ae7d1adb..00000000 --- a/.agents/skills/convex-performance-audit/references/subscription-cost.md +++ /dev/null @@ -1,252 +0,0 @@ -# Subscription Cost - -Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. - -## Core Principle - -Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: - -`subscriptions x invalidation_frequency x query_cost` - -Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. - -## Symptoms - -- Dashboard shows high active subscription count -- UI feels sluggish or laggy despite fast individual queries -- React profiling shows frequent re-renders from Convex state -- Pages with many components each running their own `useQuery` -- Paginated lists where every loaded page stays subscribed - -## Common Causes - -### Reactive queries on low-freshness flows - -Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. - -### Overly broad queries - -A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. - -### Too many subscriptions per page - -A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. - -### Paginated queries keeping all pages live - -`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. - -### Frequently-updated fields on widely-read documents - -A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. - -## Fix Order - -### 1. Use point-in-time reads when live updates are not valuable - -Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. - -Consider a point-in-time read instead when all of these are true: - -- the flow is high-read -- the underlying data changes less often than users need to see -- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable - -Possible implementations depend on environment: - -- a server-rendered fetch -- a framework helper like `fetchQuery` -- a point-in-time client read such as `ConvexHttpClient.query()` - -```ts -// Reactive by default when fresh live data matters -function TeamPresence() { - const presence = useQuery(api.teams.livePresence, { teamId }); - return ; -} -``` - -```ts -// Point-in-time read when explicit refresh is acceptable -import { ConvexHttpClient } from "convex/browser"; - -const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); - -function SnapshotView() { - const [items, setItems] = useState([]); - - useEffect(() => { - client.query(api.items.snapshot).then(setItems); - }, []); - - return ; -} -``` - -Good candidates for point-in-time reads: - -- aggregate snapshots -- reports -- low-churn listings -- flows where explicit refresh is already acceptable - -Keep reactive for: - -- collaborative editing -- live dashboards -- presence-heavy views -- any surface where users expect fresh changes to appear automatically - -### 2. Batch related data into fewer queries - -Instead of N components each fetching their own related data, fetch it in a single query. - -```ts -// Bad: each card fetches its own author -function ProjectCard({ project }: { project: Project }) { - const author = useQuery(api.users.get, { id: project.authorId }); - return ; -} -``` - -```ts -// Good: parent query returns projects with author names included -function ProjectList() { - const projects = useQuery(api.projects.listWithAuthors); - return projects?.map((p) => ( - - )); -} -``` - -This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. - -This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. - -### 3. Use skip to avoid unnecessary subscriptions - -The `"skip"` value prevents a subscription from being created when the arguments are not ready. - -```ts -// Bad: subscribes with undefined args, wastes a subscription slot -const profile = useQuery(api.users.getProfile, { userId: selectedId! }); -``` - -```ts -// Good: skip when there is nothing to fetch -const profile = useQuery( - api.users.getProfile, - selectedId ? { userId: selectedId } : "skip", -); -``` - -### 4. Isolate frequently-updated fields into separate documents - -If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. - -```ts -// Bad: lastSeen lives on the user doc, every heartbeat invalidates -// every query that reads this user -const users = defineTable({ - name: v.string(), - email: v.string(), - lastSeen: v.number(), -}); -``` - -```ts -// Good: lastSeen lives in a separate heartbeat doc -const users = defineTable({ - name: v.string(), - email: v.string(), - heartbeatId: v.id("heartbeats"), -}); - -const heartbeats = defineTable({ - lastSeen: v.number(), -}); -``` - -Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. - -For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. - -### 5. Use the aggregate component for counts and sums - -Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. - -Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. - -If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. - -### 6. Narrow query read sets - -Queries that return less data and touch fewer documents invalidate less often. - -```ts -// Bad: returns all fields, invalidates on any field change -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("projects").collect(); - }, -}); -``` - -```ts -// Good: use a digest table with only the fields the list needs -export const listDigests = query({ - handler: async (ctx) => { - return await ctx.db.query("projectDigests").collect(); - }, -}); -``` - -Writes to fields not in the digest table do not invalidate the digest query. - -### 7. Remove `Date.now()` from queries - -Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. - -```ts -// Bad: Date.now() defeats query caching and causes frequent re-evaluation -const releasedPosts = await ctx.db - .query("posts") - .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) - .take(100); -``` - -```ts -// Good: use a boolean field updated by a scheduled function -const releasedPosts = await ctx.db - .query("posts") - .withIndex("by_is_released", (q) => q.eq("isReleased", true)) - .take(100); -``` - -If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. - -### 8. Consider pagination strategy - -For long lists where users scroll through many pages: - -- If the data does not need live updates, use point-in-time fetching with manual "load more" -- If it does need live updates, accept the subscription cost but limit the number of loaded pages -- Consider whether older pages can be unloaded as the user scrolls forward - -### 9. Separate backend cost from UI churn - -If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. - -Treat this as a UX problem first when: - -- the underlying query is already reasonably cheap -- the complaint is flicker, loading flashes, or re-render churn -- live updates are still desirable once fresh data arrives - -## Verification - -1. Subscription count in dashboard is lower for the affected pages -2. UI responsiveness has improved -3. React profiling shows fewer unnecessary re-renders -4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily -5. Sibling pages with similar patterns were updated consistently diff --git a/.agents/skills/convex-quickstart/SKILL.md b/.agents/skills/convex-quickstart/SKILL.md deleted file mode 100644 index 792bba3d..00000000 --- a/.agents/skills/convex-quickstart/SKILL.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -name: convex-quickstart -description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." ---- - -# Convex Quickstart - -Set up a working Convex project as fast as possible. - -## When to Use - -- Starting a brand new project with Convex -- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app -- Scaffolding a Convex app for prototyping - -## When Not to Use - -- The project already has Convex installed and `convex/` exists - just start building -- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill - -## Workflow - -1. Determine the starting point: new project or existing app -2. If new project, pick a template and scaffold with `npm create convex@latest` -3. If existing app, install `convex` and wire up the provider -4. Run `npx convex dev` to connect a deployment and start the dev loop -5. Verify the setup works - -## Path 1: New Project (Recommended) - -Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. - -### Pick a template - -| Template | Stack | -|----------|-------| -| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | -| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | -| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | -| `nextjs-clerk` | Next.js + Clerk auth | -| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | -| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | -| `bare` | Convex backend only, no frontend | - -If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. - -You can also use any GitHub repo as a template: - -```bash -npm create convex@latest my-app -- -t owner/repo -npm create convex@latest my-app -- -t owner/repo#branch -``` - -### Scaffold the project - -Always pass the project name and template flag to avoid interactive prompts: - -```bash -npm create convex@latest my-app -- -t react-vite-shadcn -cd my-app -npm install -``` - -The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. - -To scaffold in the current directory (if it is empty): - -```bash -npm create convex@latest . -- -t react-vite-shadcn -npm install -``` - -### Start the dev loop - -`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. - -**Ask the user to run this themselves:** - -Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: -- Create a Convex project and dev deployment -- Write the deployment URL to `.env.local` -- Create the `convex/` directory with generated types -- Watch for changes and sync continuously - -The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. - -**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. - -### Start the frontend - -The user should also run the frontend dev server in a separate terminal: - -```bash -npm run dev -``` - -Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. - -### What you get - -After scaffolding, the project structure looks like: - -``` -my-app/ - convex/ # Backend functions and schema - _generated/ # Auto-generated types (check this into git) - schema.ts # Database schema (if template includes one) - src/ # Frontend code (or app/ for Next.js) - package.json - .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL -``` - -The template already has: -- `ConvexProvider` wired into the app root -- Correct env var names for the framework -- Tailwind and shadcn/ui ready (for shadcn templates) -- Auth provider configured (for auth templates) - -Proceed to adding schema, functions, and UI. - -## Path 2: Add Convex to an Existing App - -Use this when the user already has a frontend project and wants to add Convex as the backend. - -### Install - -```bash -npm install convex -``` - -### Initialize and start dev loop - -Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. - -### Wire up the provider - -The Convex client must wrap the app at the root. The setup varies by framework. - -Create the `ConvexReactClient` at module scope, not inside a component: - -```tsx -// Bad: re-creates the client on every render -function App() { - const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); - return ...; -} - -// Good: created once at module scope -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); -function App() { - return ...; -} -``` - -#### React (Vite) - -```tsx -// src/main.tsx -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { ConvexProvider, ConvexReactClient } from "convex/react"; -import App from "./App"; - -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); - -createRoot(document.getElementById("root")!).render( - - - - - , -); -``` - -#### Next.js (App Router) - -```tsx -// app/ConvexClientProvider.tsx -"use client"; - -import { ConvexProvider, ConvexReactClient } from "convex/react"; -import { ReactNode } from "react"; - -const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); - -export function ConvexClientProvider({ children }: { children: ReactNode }) { - return {children}; -} -``` - -```tsx -// app/layout.tsx -import { ConvexClientProvider } from "./ConvexClientProvider"; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); -} -``` - -#### Other frameworks - -For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: - -- [Vue](https://docs.convex.dev/quickstart/vue) -- [Svelte](https://docs.convex.dev/quickstart/svelte) -- [React Native](https://docs.convex.dev/quickstart/react-native) -- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) -- [Remix](https://docs.convex.dev/quickstart/remix) -- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) - -### Environment variables - -The env var name depends on the framework: - -| Framework | Variable | -|-----------|----------| -| Vite | `VITE_CONVEX_URL` | -| Next.js | `NEXT_PUBLIC_CONVEX_URL` | -| Remix | `CONVEX_URL` | -| React Native | `EXPO_PUBLIC_CONVEX_URL` | - -`npx convex dev` writes the correct variable to `.env.local` automatically. - -## Agent Mode (Cloud and Headless Agents) - -When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. - -Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: - -```bash -CONVEX_AGENT_MODE=anonymous npx convex dev -``` - -This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. - -## Verify the Setup - -After setup, confirm everything is working: - -1. The user confirms `npx convex dev` is running without errors -2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` -3. `.env.local` contains the deployment URL - -## Writing Your First Function - -Once the project is set up, create a schema and a query to verify the full loop works. - -`convex/schema.ts`: - -```ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - tasks: defineTable({ - text: v.string(), - completed: v.boolean(), - }), -}); -``` - -`convex/tasks.ts`: - -```ts -import { query, mutation } from "./_generated/server"; -import { v } from "convex/values"; - -export const list = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query("tasks").collect(); - }, -}); - -export const create = mutation({ - args: { text: v.string() }, - handler: async (ctx, args) => { - await ctx.db.insert("tasks", { text: args.text, completed: false }); - }, -}); -``` - -Use in a React component (adjust the import path based on your file location relative to `convex/`): - -```tsx -import { useQuery, useMutation } from "convex/react"; -import { api } from "../convex/_generated/api"; - -function Tasks() { - const tasks = useQuery(api.tasks.list); - const create = useMutation(api.tasks.create); - - return ( -
- - {tasks?.map((t) =>
{t.text}
)} -
- ); -} -``` - -## Development vs Production - -Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. - -When ready to ship, deploy to production: - -```bash -npx convex deploy -``` - -This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. - -## Next Steps - -- Add authentication: use the `convex-setup-auth` skill -- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) -- Build components: use the `convex-create-component` skill -- Plan a migration: use the `convex-migration-helper` skill -- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) -- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) - -## Checklist - -- [ ] Determined starting point: new project or existing app -- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template -- [ ] If existing app: installed `convex` and wired up the provider -- [ ] User has `npx convex dev` running and connected to a deployment -- [ ] `convex/_generated/` directory exists with types -- [ ] `.env.local` has the deployment URL -- [ ] Verified a basic query/mutation round-trip works diff --git a/.agents/skills/convex-quickstart/agents/openai.yaml b/.agents/skills/convex-quickstart/agents/openai.yaml deleted file mode 100644 index a51a6d09..00000000 --- a/.agents/skills/convex-quickstart/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Quickstart" - short_description: "Start a new Convex app or add Convex to an existing frontend." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#F97316" - default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." - -policy: - allow_implicit_invocation: true diff --git a/.agents/skills/convex-quickstart/assets/icon.svg b/.agents/skills/convex-quickstart/assets/icon.svg deleted file mode 100644 index d83a73f3..00000000 --- a/.agents/skills/convex-quickstart/assets/icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/.agents/skills/convex-setup-auth/SKILL.md b/.agents/skills/convex-setup-auth/SKILL.md deleted file mode 100644 index 0fa00e2f..00000000 --- a/.agents/skills/convex-setup-auth/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: convex-setup-auth -description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." ---- - -# Convex Authentication Setup - -Implement secure authentication in Convex with user management and access control. - -## When to Use - -- Setting up authentication for the first time -- Implementing user management (users table, identity mapping) -- Creating authentication helper functions -- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) - -## When Not to Use - -- Auth for a non-Convex backend -- Pure OAuth/OIDC documentation without a Convex implementation -- Debugging unrelated bugs that happen to surface near auth code -- The auth provider is already fully configured and the user only needs a one-line fix - -## First Step: Choose the Auth Provider - -Convex supports multiple authentication approaches. Do not assume a provider. - -Before writing setup code: - -1. Ask the user which auth solution they want, unless the repository already makes it obvious -2. If the repo already uses a provider, continue with that provider unless the user wants to switch -3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding - -Common options: - -- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex -- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features -- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically -- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 -- Custom JWT provider - use when integrating an existing auth system not covered above - -Look for signals in the repo before asking: - -- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages -- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components -- Environment variables that clearly point at a provider - -## After Choosing a Provider - -Read the provider's official guide and the matching local reference file: - -- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` -- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` -- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` -- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` - -The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. - -Use those sources for: - -- package installation -- client provider wiring -- environment variables -- `convex/auth.config.ts` setup -- login and logout UI patterns -- framework-specific setup for React, Vite, or Next.js - -For shared auth behavior, use the official Convex docs as the source of truth: - -- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` -- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage -- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance -- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth - -Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. -For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. -For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. -After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. - -## Core Pattern: Protecting Backend Functions - -The most common auth task is checking identity in Convex functions. - -```ts -// Bad: trusting a client-provided userId -export const getMyProfile = query({ - args: { userId: v.id("users") }, - handler: async (ctx, args) => { - return await ctx.db.get(args.userId); - }, -}); -``` - -```ts -// Good: verifying identity server-side -export const getMyProfile = query({ - args: {}, - handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Not authenticated"); - - return await ctx.db - .query("users") - .withIndex("by_tokenIdentifier", (q) => - q.eq("tokenIdentifier", identity.tokenIdentifier) - ) - .unique(); - }, -}); -``` - -## Workflow - -1. Determine the provider, either by asking the user or inferring from the repo -2. Ask whether the user wants local-only setup or production-ready setup now -3. Read the matching provider reference file -4. Follow the official provider docs for current setup details -5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns -6. Only add app-level user storage if the docs and app requirements call for it -7. Add authorization checks for ownership, roles, or team access only where the app needs them -8. Verify login state, protected queries, environment variables, and production configuration if requested - -If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. -For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. -If the environment has browser automation tools, you can use them. -If it does not, give the user a short manual validation checklist instead. - -## Reference Files - -### Provider References - -- `references/convex-auth.md` -- `references/clerk.md` -- `references/workos-authkit.md` -- `references/auth0.md` - -## Checklist - -- [ ] Chosen the correct auth provider before writing setup code -- [ ] Read the relevant provider reference file -- [ ] Asked whether the user wants local-only setup or production-ready setup -- [ ] Used the official provider docs for provider-specific wiring -- [ ] Used the official Convex docs for shared auth behavior and authorization patterns -- [ ] Only added app-level user storage if the app actually needs it -- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth -- [ ] Added authentication checks in protected backend functions -- [ ] Added authorization checks where the app actually needs them -- [ ] Clear error messages ("Not authenticated", "Unauthorized") -- [ ] Client auth provider configured for the chosen provider -- [ ] If requested, production auth setup is covered too diff --git a/.agents/skills/convex-setup-auth/agents/openai.yaml b/.agents/skills/convex-setup-auth/agents/openai.yaml deleted file mode 100644 index d1c90a14..00000000 --- a/.agents/skills/convex-setup-auth/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Setup Auth" - short_description: "Set up Convex auth, user identity mapping, and access control." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#2563EB" - default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." - -policy: - allow_implicit_invocation: true diff --git a/.agents/skills/convex-setup-auth/assets/icon.svg b/.agents/skills/convex-setup-auth/assets/icon.svg deleted file mode 100644 index 4917dbb4..00000000 --- a/.agents/skills/convex-setup-auth/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/.agents/skills/convex-setup-auth/references/auth0.md b/.agents/skills/convex-setup-auth/references/auth0.md deleted file mode 100644 index 9c729c5a..00000000 --- a/.agents/skills/convex-setup-auth/references/auth0.md +++ /dev/null @@ -1,116 +0,0 @@ -# Auth0 - -Official docs: - -- https://docs.convex.dev/auth/auth0 -- https://auth0.github.io/auth0-cli/ -- https://auth0.github.io/auth0-cli/auth0_apps_create.html - -Use this when the app already uses Auth0 or the user wants Auth0 specifically. - -## Workflow - -1. Confirm the user wants Auth0 -2. Determine the app framework and whether Auth0 is already partly set up -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the official Convex and Auth0 guides before making changes -5. Ask whether they want the fastest setup path by installing the Auth0 CLI -6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI -7. If they do not want the CLI path, use the Auth0 dashboard path instead -8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up -9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID -10. Set environment variables for local and production environments -11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` -12. Gate Convex-backed UI with Convex auth state -13. Try to verify Convex reports the user as authenticated after Auth0 login -14. If the refresh-token path fails, stop improvising and send the user back to the official docs -15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered - -## What To Do - -- Read the official Convex and Auth0 guide before writing setup code -- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet -- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" -- Make sure the app has already completed the relevant Auth0 quickstart for its frontend -- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` -- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated - -## Key Setup Areas - -- install the Auth0 SDK for the app's framework -- configure `convex/auth.config.ts` with the Auth0 domain and client ID -- set environment variables for local and production environments -- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` -- use Convex auth state when gating Convex-backed UI - -## Files and Env Vars To Expect - -- `convex/auth.config.ts` -- frontend app entry or provider wrapper -- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` -- Auth0 environment variables commonly include: - - `AUTH0_DOMAIN` - - `AUTH0_CLIENT_ID` - - `VITE_AUTH0_DOMAIN` - - `VITE_AUTH0_CLIENT_ID` - -## Concrete Steps - -1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework -2. Ask whether the user wants the Auth0 CLI path -3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` -4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app -5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard -6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard -7. Install the Auth0 SDK for the app's framework -8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID -9. Set frontend and backend environment variables -10. Wrap the app in `Auth0Provider` -11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` -12. Run the normal Convex dev or deploy flow after backend config changes -13. Try the official provider config shown in the Convex docs -14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now -15. Only claim success if the user can sign in and Convex recognizes the authenticated session -16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too - -## Gotchas - -- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch -- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant -- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard -- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced -- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end -- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled -- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation -- Keep dev and prod tenants separate if the project uses different Auth0 environments -- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. -- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. -- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. -- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered -- Verify production environment variables and redirect settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can complete the Auth0 login flow -- Verify Convex-authenticated UI renders only after Convex auth state is ready -- Verify protected Convex queries succeed after login -- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions -- Verify the Auth0 app settings match the real local callback and logout URLs during development -- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully -- If production-ready setup was requested, verify the production Auth0 configuration is also covered - -## Checklist - -- [ ] Confirm the user wants Auth0 -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Complete the relevant Auth0 frontend setup -- [ ] Configure `convex/auth.config.ts` -- [ ] Set environment variables -- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs -- [ ] If requested, configure the production deployment too diff --git a/.agents/skills/convex-setup-auth/references/clerk.md b/.agents/skills/convex-setup-auth/references/clerk.md deleted file mode 100644 index 7dbde194..00000000 --- a/.agents/skills/convex-setup-auth/references/clerk.md +++ /dev/null @@ -1,113 +0,0 @@ -# Clerk - -Official docs: - -- https://docs.convex.dev/auth/clerk -- https://clerk.com/docs/guides/development/integrations/databases/convex - -Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. - -## Workflow - -1. Confirm the user wants Clerk -2. Make sure the user has a Clerk account and a Clerk application -3. Determine the app framework: - - React - - Next.js - - TanStack Start -4. Ask whether the user wants local-only setup or production-ready setup now -5. Gather the Clerk keys and the Clerk Frontend API URL -6. Follow the correct framework section in the official docs -7. Complete the backend and client wiring -8. Verify Convex reports the user as authenticated after login -9. If the user wants production-ready setup, make sure the production Clerk config is also covered - -## What To Do - -- Read the official Convex and Clerk guide before writing setup code -- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application -- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active -- Match the guide to the app's framework, usually React, Next.js, or TanStack Start -- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` - -## Key Setup Areas - -- install the Clerk SDK for the framework in use -- configure `convex/auth.config.ts` with the Clerk issuer domain -- set the required Clerk environment variables -- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` -- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` - -## Files and Env Vars To Expect - -- `convex/auth.config.ts` -- React or Vite client entry such as `src/main.tsx` -- Next.js client wrapper for Convex if using App Router -- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` -- Clerk app creation page: `https://dashboard.clerk.com/apps/new` -- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` -- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` -- Clerk environment variables: - - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs - - `CLERK_FRONTEND_API_URL` in the Clerk docs - - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps - - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps - - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required - -`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. - -## Concrete Steps - -1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` -2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` -3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed -4. Open `https://dashboard.clerk.com/apps/setup/convex` -5. Activate the Convex integration in Clerk if it is not already active -6. Copy the Clerk Frontend API URL shown there -7. Install the Clerk package for the app's framework -8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens -9. Set the publishable key in the frontend environment -10. Set the issuer domain or Frontend API URL so Convex can validate the JWT -11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` -12. Wrap the app in `ClerkProvider` -13. Use Convex auth helpers for authenticated rendering -14. Run the normal Convex dev or deploy flow after updating backend auth config -15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too - -## Gotchas - -- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render -- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper -- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config -- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. -- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. -- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. -- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. -- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` -- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included -- Verify production redirect URLs and any production Clerk domain values before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can sign in with Clerk -- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in -- Verify `useConvexAuth()` reaches the authenticated state after Clerk login -- Verify protected Convex queries run successfully inside authenticated UI -- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions -- If production-ready setup was requested, verify the production Clerk configuration is also covered - -## Checklist - -- [ ] Confirm the user wants Clerk -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Follow the correct framework section in the official guide -- [ ] Set Clerk environment variables -- [ ] Configure `convex/auth.config.ts` -- [ ] Verify Convex authenticated state after login -- [ ] If requested, configure the production deployment too diff --git a/.agents/skills/convex-setup-auth/references/convex-auth.md b/.agents/skills/convex-setup-auth/references/convex-auth.md deleted file mode 100644 index d4824d24..00000000 --- a/.agents/skills/convex-setup-auth/references/convex-auth.md +++ /dev/null @@ -1,143 +0,0 @@ -# Convex Auth - -Official docs: https://docs.convex.dev/auth/convex-auth -Setup guide: https://labs.convex.dev/auth/setup - -Use this when the user wants auth handled directly in Convex rather than through a third-party provider. - -## Workflow - -1. Confirm the user wants Convex Auth specifically -2. Determine which sign-in methods the app needs: - - magic links or OTPs - - OAuth providers - - passwords and password reset -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the Convex Auth setup guide before writing code -5. Make sure the project has a configured Convex deployment: - - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set - - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing -6. Install the auth packages: - - `npm install @convex-dev/auth @auth/core@0.37.0` -7. Run the initialization command: - - `npx @convex-dev/auth` -8. Confirm the initializer created: - - `convex/auth.config.ts` - - `convex/auth.ts` - - `convex/http.ts` -9. Add the required `authTables` to `convex/schema.ts` -10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` -11. Configure at least one auth method in `convex/auth.ts` -12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code -13. Verify the client can sign in successfully -14. Verify Convex receives authenticated identity in backend functions -15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well -16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex - -## What This Reference Is For - -- choosing Convex Auth as the default provider for a new Convex app -- understanding whether the app wants magic links, OTPs, OAuth, or passwords -- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior - -## What To Do - -- Read the Convex Auth setup guide before writing setup code -- Follow the setup flow from the docs rather than recreating it from memory -- If the app is new, consider starting from the official starter flow instead of hand-wiring everything -- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra - -## Concrete Steps - -1. Install `@convex-dev/auth` and `@auth/core@0.37.0` -2. Run `npx convex dev` if the project does not already have a configured deployment -3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment -4. Run `npx @convex-dev/auth` -5. Confirm the generated auth setup is present before continuing: - - `convex/auth.config.ts` - - `convex/auth.ts` - - `convex/http.ts` -6. Add `authTables` to `convex/schema.ts` -7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry -8. Configure the selected auth methods in `convex/auth.ts` -9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed -10. Verify login locally -11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment - -## Expected Files and Decisions - -- `convex/schema.ts` -- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file -- generated Convex Auth setup produced by `npx @convex-dev/auth` -- an existing configured Convex deployment, or the ability to create one with `npx convex dev` -- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods - -- Decide whether the user is creating a new app or adding auth to an existing app -- For a new app, prefer the official starter flow instead of rebuilding setup by hand -- Decide which auth methods the app needs: - - magic links or OTPs - - OAuth providers - - passwords -- Decide whether the user wants local-only setup or production-ready setup now -- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough - -## Gotchas - -- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. -- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. -- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. -- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. -- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. -- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. -- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. -- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. -- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. -- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment -- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Human Handoff - -If `npx convex dev` or deployment setup requires human input: - -- stop and explain exactly what the user needs to do -- say why that step is required -- resume the auth setup immediately after the user confirms it is done - -## Validation - -- Verify the user can complete a sign-in flow -- Offer to validate sign up, sign out, and sign back in with the configured auth method -- If browser automation is available in the environment, you can do this directly -- If browser automation is not available, give the user a short manual validation checklist instead -- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions -- Verify protected UI only renders after Convex-authenticated state is ready -- Verify environment variables and redirect settings match the current app environment -- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in -- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed -- If production-ready setup was requested, verify the production deployment is also configured correctly - -## Checklist - -- [ ] Confirm the user wants Convex Auth specifically -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Ensure a Convex deployment is configured before running auth initialization -- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` -- [ ] Run `npx convex dev` first if needed -- [ ] Run `npx @convex-dev/auth` -- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created -- [ ] Follow the setup guide for package install and wiring -- [ ] Add `authTables` to `convex/schema.ts` -- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` -- [ ] Configure at least one auth method in `convex/auth.ts` -- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes -- [ ] Confirm which sign-in methods the app needs -- [ ] Verify the client can sign in and the backend receives authenticated identity -- [ ] Offer end-to-end validation of sign up, sign out, and sign back in -- [ ] If requested, configure the production deployment too -- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/.agents/skills/convex-setup-auth/references/workos-authkit.md b/.agents/skills/convex-setup-auth/references/workos-authkit.md deleted file mode 100644 index 038cb9f3..00000000 --- a/.agents/skills/convex-setup-auth/references/workos-authkit.md +++ /dev/null @@ -1,114 +0,0 @@ -# WorkOS AuthKit - -Official docs: - -- https://docs.convex.dev/auth/authkit/ -- https://docs.convex.dev/auth/authkit/add-to-app -- https://docs.convex.dev/auth/authkit/auto-provision - -Use this when the app already uses WorkOS or the user wants AuthKit specifically. - -## Workflow - -1. Confirm the user wants WorkOS AuthKit -2. Determine whether they want: - - a Convex-managed WorkOS team - - an existing WorkOS team -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the official Convex and WorkOS AuthKit guide -5. Create or update `convex.json` for the app's framework and real local port -6. Follow the correct branch of the setup flow based on that choice -7. Configure the required WorkOS environment variables -8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs -9. Wire the client provider and callback flow -10. Verify authenticated requests reach Convex -11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too -12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex - -## What To Do - -- Read the official Convex and WorkOS AuthKit guide before writing setup code -- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team -- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra -- Follow the current setup flow from the docs instead of relying on older examples - -## Key Setup Areas - -- package installation for the app's framework -- `convex.json` with the `authKit` section for dev, and preview or prod if needed -- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration -- `convex/auth.config.ts` wiring for WorkOS-issued JWTs -- client provider setup and token flow into Convex -- login callback and redirect configuration - -## Files and Env Vars To Expect - -- `convex.json` -- `convex/auth.config.ts` -- frontend auth provider wiring -- callback or redirect route setup where the framework requires it -- WorkOS environment variables commonly include: - - `WORKOS_CLIENT_ID` - - `WORKOS_API_KEY` - - `WORKOS_COOKIE_PASSWORD` - - `VITE_WORKOS_CLIENT_ID` - - `VITE_WORKOS_REDIRECT_URI` - - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` - -For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. - -## Concrete Steps - -1. Choose Convex-managed or existing WorkOS team -2. Create or update `convex.json` with the `authKit` section for the framework in use -3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port -4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow -5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` -6. Create or update `convex/auth.config.ts` for WorkOS JWT validation -7. Run the normal Convex dev or deploy flow so backend config is synced -8. Wire the WorkOS client provider in the app -9. Configure callback and redirect handling -10. Verify the user can sign in and return to the app -11. Verify Convex sees the authenticated user after login -12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too - -## Gotchas - -- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious -- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys -- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex -- Do not mix dev and prod WorkOS credentials or redirect URIs -- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it -- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. -- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. -- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. -- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. -- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered -- Verify the production redirect and callback settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can complete the login flow and return to the app -- Verify the callback URL matches the real frontend port in local dev -- Verify Convex receives authenticated requests after login -- Verify `convex.json` matches the framework and chosen WorkOS setup path -- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path -- Verify environment variables differ correctly between local and production where needed -- If production-ready setup was requested, verify the production WorkOS configuration is also covered - -## Checklist - -- [ ] Confirm the user wants WorkOS AuthKit -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Choose Convex-managed or existing WorkOS team -- [ ] Create or update `convex.json` -- [ ] Configure WorkOS environment variables -- [ ] Configure `convex/auth.config.ts` -- [ ] Verify authenticated requests reach Convex after login -- [ ] If requested, configure the production deployment too diff --git a/lib/providers/agent_provider.dart b/lib/providers/agent_provider.dart index 253a63f5..236077eb 100644 --- a/lib/providers/agent_provider.dart +++ b/lib/providers/agent_provider.dart @@ -36,6 +36,8 @@ class AgentProvider extends Notifier> { } void addAgent(PlacedAgentNode placedAgent) { + state = [...state, placedAgent]; + final action = UserAction( type: ActionType.addition, id: placedAgent.id, @@ -46,7 +48,6 @@ class AgentProvider extends Notifier> { ); ref.read(actionProvider.notifier).addAction(action); - state = [...state, placedAgent]; } void removeAgent(String id) { diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 3ed07604..9d26e8f9 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -46,6 +46,30 @@ import 'package:icarus/strategy/strategy_page_models.dart'; final strategyProvider = NotifierProvider(StrategyProvider.new); +void _logStrategyProviderDebug({ + required String runId, + required String hypothesisId, + required String location, + required String message, + Map data = const {}, +}) { + unawaited( + File(r'E:\Projects\icarus-cloud\debug-16ee23.log').writeAsString( + '${jsonEncode({ + 'sessionId': '16ee23', + 'runId': runId, + 'hypothesisId': hypothesisId, + 'location': location, + 'message': message, + 'data': data, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + })}\n', + mode: FileMode.append, + flush: true, + ), + ); +} + class StrategyProvider extends Notifier { @override StrategyState build() { @@ -308,6 +332,19 @@ class StrategyProvider extends Notifier { } Future clearCurrentStrategy() async { + // #region agent log + _logStrategyProviderDebug( + runId: 'pre-fix', + hypothesisId: 'H2', + location: 'strategy_provider.dart:311', + message: 'clearCurrentStrategy start', + data: { + 'previousStrategyId': state.strategyId, + 'previousSource': state.source?.name, + 'previousIsOpen': state.isOpen, + }, + ); + // #endregion cancelPendingSave(); ref.read(strategyThemeProvider.notifier).fromStrategy(); ref.read(strategySaveStateProvider.notifier).reset(); @@ -319,6 +356,19 @@ class StrategyProvider extends Notifier { storageDirectory: state.storageDirectory, isOpen: false, ); + // #region agent log + _logStrategyProviderDebug( + runId: 'pre-fix', + hypothesisId: 'H2', + location: 'strategy_provider.dart:323', + message: 'clearCurrentStrategy state cleared', + data: { + 'strategyId': state.strategyId, + 'source': state.source?.name, + 'isOpen': state.isOpen, + }, + ); + // #endregion ref.read(remoteStrategySnapshotProvider.notifier).clear(); unawaited( ref.read(cloudMediaUploadQueueProvider.notifier).setActiveStrategy(null), diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index dbcdc06b..22edd3b1 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -1,3 +1,7 @@ +import 'dart:convert'; +import 'dart:async'; +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -23,6 +27,30 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; +void _logStrategyViewDebug({ + required String runId, + required String hypothesisId, + required String location, + required String message, + Map data = const {}, +}) { + unawaited( + File(r'E:\Projects\icarus-cloud\debug-16ee23.log').writeAsString( + '${jsonEncode({ + 'sessionId': '16ee23', + 'runId': runId, + 'hypothesisId': hypothesisId, + 'location': location, + 'message': message, + 'data': data, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + })}\n', + mode: FileMode.append, + flush: true, + ), + ); +} + class StrategyView extends ConsumerStatefulWidget { const StrategyView({super.key}); @@ -62,6 +90,19 @@ class _StrategyViewState extends ConsumerState ref: ref, source: 'StrategyView.leaveToLibrary', onContinue: () async { + // #region agent log + _logStrategyViewDebug( + runId: 'pre-fix', + hypothesisId: 'H3', + location: 'strategy_view.dart:65', + message: 'leaveToLibrary onContinue start', + data: { + 'mounted': mounted, + 'strategyIdBeforeClear': ref.read(strategyProvider).strategyId, + 'routeIsCurrent': ModalRoute.of(context)?.isCurrent, + }, + ); + // #endregion ref .read(interactionStateProvider.notifier) .update(InteractionState.navigation); @@ -70,7 +111,33 @@ class _StrategyViewState extends ConsumerState .updateFilterState(FilterState.all); ref.read(deleteMenuProvider.notifier).requestClose(); await ref.read(strategyProvider.notifier).clearCurrentStrategy(); + // #region agent log + _logStrategyViewDebug( + runId: 'post-fix', + hypothesisId: 'H3', + location: 'strategy_view.dart:82', + message: 'leaveToLibrary after clear before pop', + data: { + 'mounted': mounted, + 'strategyIdAfterClear': ref.read(strategyProvider).strategyId, + 'routeIsCurrent': ModalRoute.of(context)?.isCurrent, + }, + ); + // #endregion if (mounted) { + // #region agent log + _logStrategyViewDebug( + runId: 'post-fix', + hypothesisId: 'H3', + location: 'strategy_view.dart:82', + message: 'leaveToLibrary pop after clear', + data: { + 'mounted': mounted, + 'strategyIdAtPop': ref.read(strategyProvider).strategyId, + 'routeIsCurrent': ModalRoute.of(context)?.isCurrent, + }, + ); + // #endregion Navigator.pop(context); } }, diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 201d3cd0..5226cf8e 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'dart:io'; import 'package:desktop_updater/desktop_updater.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; @@ -47,6 +47,9 @@ class _FolderNavigatorState extends ConsumerState { final ShadPopoverController _importExportPopoverController = ShadPopoverController(); + bool get _isWindowsDesktop => + !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; + @override void dispose() { _importExportPopoverController.dispose(); @@ -72,7 +75,7 @@ class _FolderNavigatorState extends ConsumerState { void _warnWebView() async { if (kIsWeb) return; - if (!Platform.isWindows) return; + if (!_isWindowsDesktop) return; await warmUpWebViewEnvironment(); if (!mounted) return; if (isWebViewInitialized) return; @@ -194,7 +197,7 @@ class _FolderNavigatorState extends ConsumerState { } final bool isDirectWindowsInstall = - !kIsWeb && Platform.isWindows && !result.isSupported; + _isWindowsDesktop && !result.isSupported; if (isDirectWindowsInstall && _desktopUpdaterController == null) { _desktopUpdaterController = WindowsDesktopUpdateController( appArchiveUrl: Settings.desktopUpdaterArchiveUrl, diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index f14387e0..334d0023 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -14,6 +18,30 @@ import 'package:icarus/services/unsaved_strategy_guard.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Displays the current strategy name with a recent-strategies dropdown. +void _logStrategyQuickSwitcherDebug({ + required String runId, + required String hypothesisId, + required String location, + required String message, + Map data = const {}, +}) { + unawaited( + File(r'E:\Projects\icarus-cloud\debug-16ee23.log').writeAsString( + '${jsonEncode({ + 'sessionId': '16ee23', + 'runId': runId, + 'hypothesisId': hypothesisId, + 'location': location, + 'message': message, + 'data': data, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + })}\n', + mode: FileMode.append, + flush: true, + ), + ); +} + class StrategyQuickSwitcher extends ConsumerStatefulWidget { const StrategyQuickSwitcher({super.key}); @@ -255,10 +283,46 @@ class _StrategyQuickSwitcherState extends ConsumerState { child: ValueListenableBuilder>( valueListenable: strategiesBox.listenable(), builder: (context, box, _) { - final recents = _recentStrategies( - box: box, - currentStrategyId: currentStrategy.strategyId!, - ); + final currentStrategyId = currentStrategy.strategyId; + late final List recents; + if (currentStrategyId == null) { + // #region agent log + _logStrategyQuickSwitcherDebug( + runId: 'pre-fix', + hypothesisId: 'H1', + location: 'strategy_quick_switcher.dart:258', + message: 'Quick switcher rebuilt with null strategy id', + data: { + 'mounted': mounted, + 'isOpen': _isOpen, + 'isSwitching': _isSwitching, + 'isEditingName': _isEditingName, + 'routeIsCurrent': ModalRoute.of(context)?.isCurrent, + 'boxIsOpen': box.isOpen, + 'strategyName': currentStrategy.strategyName, + }, + ); + // #endregion + // #region agent log + _logStrategyQuickSwitcherDebug( + runId: 'post-fix', + hypothesisId: 'H1', + location: 'strategy_quick_switcher.dart:286', + message: 'Quick switcher rendered null-safe fallback', + data: { + 'mounted': mounted, + 'routeIsCurrent': ModalRoute.of(context)?.isCurrent, + 'boxIsOpen': box.isOpen, + }, + ); + // #endregion + recents = const []; + } else { + recents = _recentStrategies( + box: box, + currentStrategyId: currentStrategyId, + ); + } return OverlayPortal( controller: _controller, @@ -457,7 +521,9 @@ class _StrategyQuickSwitcherState extends ConsumerState { SizedBox( width: 38, child: ShadIconButton.ghost( - onPressed: _isSwitching || _isEditingName + onPressed: currentStrategyId == null || + _isSwitching || + _isEditingName ? null : () => _isOpen ? _closePortal() : _openPortal(), icon: _isSwitching diff --git a/pubspec.lock b/pubspec.lock index 93be18dd..8d762ad5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -833,18 +833,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1318,10 +1318,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" theme_extensions_builder_annotation: dependency: transitive description: diff --git a/skills/convex-create-component/SKILL.md b/skills/convex-create-component/SKILL.md deleted file mode 100644 index a79c18e0..00000000 --- a/skills/convex-create-component/SKILL.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -name: convex-create-component -description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. ---- - -# Convex Create Component - -Create reusable Convex components with clear boundaries and a small app-facing API. - -## When to Use - -- Creating a new Convex component in an existing app -- Extracting reusable backend logic into a component -- Building a third-party integration that should own its own tables and workflows -- Packaging Convex functionality for reuse across multiple apps - -## When Not to Use - -- One-off business logic that belongs in the main app -- Thin utilities that do not need Convex tables or functions -- App-level orchestration that should stay in `convex/` -- Cases where a normal TypeScript library is enough - -## Workflow - -1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. -2. Choose the shape using the decision tree below and read the matching reference file. -3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. -4. Make a short plan for: - - what tables the component owns - - what public functions it exposes - - what data must be passed in from the app (auth, env vars, parent IDs) - - what stays in the app as wrappers or HTTP mounts -5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. -6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. -7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. -8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. -9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. -10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. - -## Choose the Shape - -Ask the user, then pick one path: - -| Goal | Shape | Reference | -|------|-------|-----------| -| Component for this app only | Local | `references/local-components.md` | -| Publish or share across apps | Packaged | `references/packaged-components.md` | -| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | -| Not sure | Default to local | `references/local-components.md` | - -Read exactly one reference file before proceeding. - -## Default Approach - -Unless the user explicitly wants an npm package, default to a local component: - -- Put it under `convex/components//` -- Define it with `defineComponent(...)` in its own `convex.config.ts` -- Install it from the app's `convex/convex.config.ts` with `app.use(...)` -- Let `npx convex dev` generate the component's own `_generated/` files - -## Component Skeleton - -A minimal local component with a table and two functions, plus the app wiring. - -```ts -// convex/components/notifications/convex.config.ts -import { defineComponent } from "convex/server"; - -export default defineComponent("notifications"); -``` - -```ts -// convex/components/notifications/schema.ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - notifications: defineTable({ - userId: v.string(), - message: v.string(), - read: v.boolean(), - }).index("by_user", ["userId"]), -}); -``` - -```ts -// convex/components/notifications/lib.ts -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server.js"; - -export const send = mutation({ - args: { userId: v.string(), message: v.string() }, - returns: v.id("notifications"), - handler: async (ctx, args) => { - return await ctx.db.insert("notifications", { - userId: args.userId, - message: args.message, - read: false, - }); - }, -}); - -export const listUnread = query({ - args: { userId: v.string() }, - returns: v.array( - v.object({ - _id: v.id("notifications"), - _creationTime: v.number(), - userId: v.string(), - message: v.string(), - read: v.boolean(), - }) - ), - handler: async (ctx, args) => { - return await ctx.db - .query("notifications") - .withIndex("by_user", (q) => q.eq("userId", args.userId)) - .filter((q) => q.eq(q.field("read"), false)) - .collect(); - }, -}); -``` - -```ts -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import notifications from "./components/notifications/convex.config.js"; - -const app = defineApp(); -app.use(notifications); - -export default app; -``` - -```ts -// convex/notifications.ts (app-side wrapper) -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; -import { components } from "./_generated/api"; -import { getAuthUserId } from "@convex-dev/auth/server"; - -export const sendNotification = mutation({ - args: { message: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - await ctx.runMutation(components.notifications.lib.send, { - userId, - message: args.message, - }); - return null; - }, -}); - -export const myUnread = query({ - args: {}, - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - return await ctx.runQuery(components.notifications.lib.listUnread, { - userId, - }); - }, -}); -``` - -Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. - -## Critical Rules - -- Keep authentication in the app, because `ctx.auth` is not available inside components. -- Keep environment access in the app, because component functions cannot read `process.env`. -- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. -- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. -- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. -- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. -- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. -- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. -- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. - -## Patterns - -### Authentication and environment access - -```ts -// Bad: component code cannot rely on app auth or env -const identity = await ctx.auth.getUserIdentity(); -const apiKey = process.env.OPENAI_API_KEY; -``` - -```ts -// Good: the app resolves auth and env, then passes explicit values -const userId = await getAuthUserId(ctx); -if (!userId) throw new Error("Not authenticated"); - -await ctx.runAction(components.translator.translate, { - userId, - apiKey: process.env.OPENAI_API_KEY, - text: args.text, -}); -``` - -### Client-facing API - -```ts -// Bad: assuming a component function is directly callable by clients -export const send = components.notifications.send; -``` - -```ts -// Good: re-export through an app mutation or query -export const sendNotification = mutation({ - args: { message: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Not authenticated"); - - await ctx.runMutation(components.notifications.lib.send, { - userId, - message: args.message, - }); - return null; - }, -}); -``` - -### IDs across the boundary - -```ts -// Bad: parent app table IDs are not valid component validators -args: { userId: v.id("users") } -``` - -```ts -// Good: treat parent-owned IDs as strings at the boundary -args: { userId: v.string() } -``` - -### Advanced Patterns - -For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. - -## Validation - -Try validation in this order: - -1. `npx convex codegen --component-dir convex/components/` -2. `npx convex codegen` -3. `npx convex dev` - -Important: - -- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. -- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. -- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. - -## Reference Files - -Read exactly one of these after the user confirms the goal: - -- `references/local-components.md` -- `references/packaged-components.md` -- `references/hybrid-components.md` - -Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) - -## Checklist - -- [ ] Asked the user what they want to build and confirmed the shape -- [ ] Read the matching reference file -- [ ] Confirmed a component is the right abstraction -- [ ] Planned tables, public API, boundaries, and app wrappers -- [ ] Component lives under `convex/components//` (or package layout if publishing) -- [ ] Component imports from its own `./_generated/server` -- [ ] Auth, env access, and HTTP routes stay in the app -- [ ] Parent app IDs cross the boundary as `v.string()` -- [ ] Public functions have `args` and `returns` validators -- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/skills/convex-create-component/agents/openai.yaml b/skills/convex-create-component/agents/openai.yaml deleted file mode 100644 index ba9287e4..00000000 --- a/skills/convex-create-component/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Create Component" - short_description: "Design and build reusable Convex components with clear boundaries." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#14B8A6" - default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." - -policy: - allow_implicit_invocation: true diff --git a/skills/convex-create-component/assets/icon.svg b/skills/convex-create-component/assets/icon.svg deleted file mode 100644 index 10f4c2c4..00000000 --- a/skills/convex-create-component/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/skills/convex-create-component/references/advanced-patterns.md b/skills/convex-create-component/references/advanced-patterns.md deleted file mode 100644 index 3deb684c..00000000 --- a/skills/convex-create-component/references/advanced-patterns.md +++ /dev/null @@ -1,134 +0,0 @@ -# Advanced Component Patterns - -Additional patterns for Convex components that go beyond the basics covered in the main skill file. - -## Function Handles for callbacks - -When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. - -```ts -// App side: create a handle and pass it to the component -import { createFunctionHandle } from "convex/server"; - -export const startJob = mutation({ - handler: async (ctx) => { - const handle = await createFunctionHandle(internal.myModule.processItem); - await ctx.runMutation(components.workpool.enqueue, { - callback: handle, - }); - }, -}); -``` - -```ts -// Component side: accept and invoke the handle -import { v } from "convex/values"; -import type { FunctionHandle } from "convex/server"; -import { mutation } from "./_generated/server.js"; - -export const enqueue = mutation({ - args: { callback: v.string() }, - handler: async (ctx, args) => { - const handle = args.callback as FunctionHandle<"mutation">; - await ctx.scheduler.runAfter(0, handle, {}); - }, -}); -``` - -## Deriving validators from schema - -Instead of manually repeating field types in return validators, extend the schema validator: - -```ts -import { v } from "convex/values"; -import schema from "./schema.js"; - -const notificationDoc = schema.tables.notifications.validator.extend({ - _id: v.id("notifications"), - _creationTime: v.number(), -}); - -export const getLatest = query({ - args: {}, - returns: v.nullable(notificationDoc), - handler: async (ctx) => { - return await ctx.db.query("notifications").order("desc").first(); - }, -}); -``` - -## Static configuration with a globals table - -A common pattern for component configuration is a single-document "globals" table: - -```ts -// schema.ts -export default defineSchema({ - globals: defineTable({ - maxRetries: v.number(), - webhookUrl: v.optional(v.string()), - }), - // ... other tables -}); -``` - -```ts -// lib.ts -export const configure = mutation({ - args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, - returns: v.null(), - handler: async (ctx, args) => { - const existing = await ctx.db.query("globals").first(); - if (existing) { - await ctx.db.patch(existing._id, args); - } else { - await ctx.db.insert("globals", args); - } - return null; - }, -}); -``` - -## Class-based client wrappers - -For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. - -```ts -// src/client/index.ts -import type { GenericMutationCtx, GenericDataModel } from "convex/server"; -import type { ComponentApi } from "../component/_generated/component.js"; - -type MutationCtx = Pick, "runMutation">; - -export class Notifications { - constructor( - private component: ComponentApi, - private options?: { defaultChannel?: string }, - ) {} - - async send(ctx: MutationCtx, args: { userId: string; message: string }) { - return await ctx.runMutation(this.component.lib.send, { - ...args, - channel: this.options?.defaultChannel ?? "default", - }); - } -} -``` - -```ts -// App usage -import { Notifications } from "@convex-dev/notifications"; -import { components } from "./_generated/api"; - -const notifications = new Notifications(components.notifications, { - defaultChannel: "alerts", -}); - -export const send = mutation({ - args: { message: v.string() }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - await notifications.send(ctx, { userId, message: args.message }); - }, -}); -``` diff --git a/skills/convex-create-component/references/hybrid-components.md b/skills/convex-create-component/references/hybrid-components.md deleted file mode 100644 index d2bb3514..00000000 --- a/skills/convex-create-component/references/hybrid-components.md +++ /dev/null @@ -1,37 +0,0 @@ -# Hybrid Convex Components - -Read this file only when the user explicitly wants a hybrid setup. - -## What This Means - -A hybrid component combines a local Convex component with shared library code. - -This can help when: - -- the user wants a local install but also shared package logic -- the component needs extension points or override hooks -- some logic should live in normal TypeScript code outside the component boundary - -## Default Advice - -Treat hybrid as an advanced option, not the default. - -Before choosing it, ask: - -- Why is a plain local component not enough? -- Why is a packaged component not enough? -- What exactly needs to stay overridable or shared? - -If the answer is vague, fall back to local or packaged. - -## Risks - -- More moving parts -- Harder upgrades and backwards compatibility -- Easier to blur the component boundary - -## Checklist - -- [ ] User explicitly needs hybrid behavior -- [ ] Local-only and packaged-only options were considered first -- [ ] The extension points are clearly defined before coding diff --git a/skills/convex-create-component/references/local-components.md b/skills/convex-create-component/references/local-components.md deleted file mode 100644 index 7fbfe21a..00000000 --- a/skills/convex-create-component/references/local-components.md +++ /dev/null @@ -1,38 +0,0 @@ -# Local Convex Components - -Read this file when the component should live inside the current app and does not need to be published as an npm package. - -## When to Choose This - -- The user wants the simplest path -- The component only needs to work in this repo -- The goal is extracting app logic into a cleaner boundary - -## Default Layout - -Use this structure unless the repo already has a clear alternative pattern: - -```text -convex/ - convex.config.ts - components/ - / - convex.config.ts - schema.ts - .ts -``` - -## Workflow Notes - -- Define the component with `defineComponent("")` -- Install it from the app with `defineApp()` and `app.use(...)` -- Keep auth, env access, public API wrappers, and HTTP route mounting in the app -- Let the component own isolated tables and reusable backend workflows -- Add app wrappers if clients need to call into the component - -## Checklist - -- [ ] Component is inside `convex/components//` -- [ ] App installs it with `app.use(...)` -- [ ] Component owns only its own tables -- [ ] App wrappers handle client-facing calls when needed diff --git a/skills/convex-create-component/references/packaged-components.md b/skills/convex-create-component/references/packaged-components.md deleted file mode 100644 index 5668e7ed..00000000 --- a/skills/convex-create-component/references/packaged-components.md +++ /dev/null @@ -1,51 +0,0 @@ -# Packaged Convex Components - -Read this file when the user wants a reusable npm package or a component shared across multiple apps. - -## When to Choose This - -- The user wants to publish the component -- The user wants a stable reusable package boundary -- The component will be shared across multiple apps or teams - -## Default Approach - -- Prefer starting from `npx create-convex@latest --component` when possible -- Keep the official authoring docs as the source of truth for package layout and exports -- Validate the bundled package through an example app, not just the source files - -## Build Flow - -When building a packaged component, make sure the bundled output exists before the example app tries to consume it. - -Recommended order: - -1. `npx convex codegen --component-dir ./path/to/component` -2. Run the package build command -3. Run `npx convex dev --typecheck-components` in the example app - -Do not assume normal app codegen is enough for packaged component workflows. - -## Package Exports - -If publishing to npm, make sure the package exposes the entry points apps need: - -- package root for client helpers, types, or classes -- `./convex.config.js` for installing the component -- `./_generated/component.js` for the app-facing `ComponentApi` type -- `./test` for testing helpers when applicable - -## Testing - -- Use `convex-test` for component logic -- Register the component schema and modules with the test instance -- Test app-side wrapper code from an example app that installs the package -- Export a small helper from `./test` if consumers need easy test registration - -## Checklist - -- [ ] Packaging is actually required -- [ ] Build order avoids bundle and codegen races -- [ ] Package exports include install and typing entry points -- [ ] Example app exercises the packaged component -- [ ] Core behavior is covered by tests diff --git a/skills/convex-migration-helper/SKILL.md b/skills/convex-migration-helper/SKILL.md deleted file mode 100644 index 97f64c1a..00000000 --- a/skills/convex-migration-helper/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: convex-migration-helper -description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. ---- - -# Convex Migration Helper - -Safely migrate Convex schemas and data when making breaking changes. - -## When to Use - -- Adding new required fields to existing tables -- Changing field types or structure -- Splitting or merging tables -- Renaming or deleting fields -- Migrating from nested to relational data - -## When Not to Use - -- Greenfield schema with no existing data in production or dev -- Adding optional fields that do not need backfilling -- Adding new tables with no existing data to migrate -- Adding or removing indexes with no correctness concern -- Questions about Convex schema design without a migration need - -## Key Concepts - -### Schema Validation Drives the Workflow - -Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: - -- You cannot add a required field if existing documents don't have it -- You cannot change a field's type if existing documents have the old type -- You cannot remove a field from the schema if existing documents still have it - -This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. - -### Online Migrations - -Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. - -### Prefer New Fields Over Changing Types - -When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. - -### Don't Delete Data - -Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. - -## Safe Changes (No Migration Needed) - -### Adding Optional Field - -```typescript -// Before -users: defineTable({ - name: v.string(), -}) - -// After - safe, new field is optional -users: defineTable({ - name: v.string(), - bio: v.optional(v.string()), -}) -``` - -### Adding New Table - -```typescript -posts: defineTable({ - userId: v.id("users"), - title: v.string(), -}).index("by_user", ["userId"]) -``` - -### Adding Index - -```typescript -users: defineTable({ - name: v.string(), - email: v.string(), -}) - .index("by_email", ["email"]) -``` - -## Breaking Changes: The Deployment Workflow - -Every breaking migration follows the same multi-deploy pattern: - -**Deploy 1 - Widen the schema:** - -1. Update schema to allow both old and new formats (e.g., add optional new field) -2. Update code to handle both formats when reading -3. Update code to write the new format for new documents -4. Deploy - -**Between deploys - Migrate data:** - -5. Run migration to backfill existing documents -6. Verify all documents are migrated - -**Deploy 2 - Narrow the schema:** - -7. Update schema to require the new format only -8. Remove code that handles the old format -9. Deploy - -## Using the Migrations Component - -For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. - -See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. - -## Common Migration Patterns - -See `references/migration-patterns.md` for complete patterns with code examples covering: - -- Adding a required field -- Deleting a field -- Changing a field type -- Splitting nested data into a separate table -- Cleaning up orphaned documents -- Zero-downtime strategies (dual write, dual read) -- Small table shortcut (single internalMutation without the component) -- Verifying a migration is complete - -## Common Pitfalls - -1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. -2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. -3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." -4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. -5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. -6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. - -## Migration Checklist - -- [ ] Identify the breaking change and plan the multi-deploy workflow -- [ ] Update schema to allow both old and new formats -- [ ] Update code to handle both formats when reading -- [ ] Update code to write the new format for new documents -- [ ] Deploy widened schema and updated code -- [ ] Define migration using the `@convex-dev/migrations` component -- [ ] Test with `dryRun: true` -- [ ] Run migration and monitor status -- [ ] Verify all documents are migrated -- [ ] Update schema to require new format only -- [ ] Clean up code that handled old format -- [ ] Deploy final schema and code -- [ ] Remove migration code once confirmed stable diff --git a/skills/convex-migration-helper/agents/openai.yaml b/skills/convex-migration-helper/agents/openai.yaml deleted file mode 100644 index c2a7fcc5..00000000 --- a/skills/convex-migration-helper/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Migration Helper" - short_description: "Plan and run safe Convex schema and data migrations." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#8B5CF6" - default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." - -policy: - allow_implicit_invocation: true diff --git a/skills/convex-migration-helper/assets/icon.svg b/skills/convex-migration-helper/assets/icon.svg deleted file mode 100644 index fba7241a..00000000 --- a/skills/convex-migration-helper/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/skills/convex-migration-helper/references/migration-patterns.md b/skills/convex-migration-helper/references/migration-patterns.md deleted file mode 100644 index 219583e0..00000000 --- a/skills/convex-migration-helper/references/migration-patterns.md +++ /dev/null @@ -1,231 +0,0 @@ -# Migration Patterns Reference - -Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. - -## Adding a Required Field - -```typescript -// Deploy 1: Schema allows both states -users: defineTable({ - name: v.string(), - role: v.optional(v.union(v.literal("user"), v.literal("admin"))), -}) - -// Migration: backfill the field -export const addDefaultRole = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.role === undefined) { - await ctx.db.patch(user._id, { role: "user" }); - } - }, -}); - -// Deploy 2: After migration completes, make it required -users: defineTable({ - name: v.string(), - role: v.union(v.literal("user"), v.literal("admin")), -}) -``` - -## Deleting a Field - -Mark the field optional first, migrate data to remove it, then remove from schema: - -```typescript -// Deploy 1: Make optional -// isPro: v.boolean() --> isPro: v.optional(v.boolean()) - -// Migration -export const removeIsPro = migrations.define({ - table: "teams", - migrateOne: async (ctx, team) => { - if (team.isPro !== undefined) { - await ctx.db.patch(team._id, { isPro: undefined }); - } - }, -}); - -// Deploy 2: Remove isPro from schema entirely -``` - -## Changing a Field Type - -Prefer creating a new field. You can combine adding and deleting in one migration: - -```typescript -// Deploy 1: Add new field, keep old field optional -// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) - -// Migration: convert old field to new field -export const convertToEnum = migrations.define({ - table: "teams", - migrateOne: async (ctx, team) => { - if (team.plan === undefined) { - await ctx.db.patch(team._id, { - plan: team.isPro ? "pro" : "basic", - isPro: undefined, - }); - } - }, -}); - -// Deploy 2: Remove isPro from schema, make plan required -``` - -## Splitting Nested Data Into a Separate Table - -```typescript -export const extractPreferences = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.preferences === undefined) return; - - const existing = await ctx.db - .query("userPreferences") - .withIndex("by_user", (q) => q.eq("userId", user._id)) - .first(); - - if (!existing) { - await ctx.db.insert("userPreferences", { - userId: user._id, - ...user.preferences, - }); - } - - await ctx.db.patch(user._id, { preferences: undefined }); - }, -}); -``` - -Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. - -## Cleaning Up Orphaned Documents - -```typescript -export const deleteOrphanedEmbeddings = migrations.define({ - table: "embeddings", - migrateOne: async (ctx, doc) => { - const chunk = await ctx.db - .query("chunks") - .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) - .first(); - - if (!chunk) { - await ctx.db.delete(doc._id); - } - }, -}); -``` - -## Zero-Downtime Strategies - -During the migration window, your app must handle both old and new data formats. There are two main strategies. - -### Dual Write (Preferred) - -Write to both old and new structures. Read from the old structure until migration is complete. - -1. Deploy code that writes both formats, reads old format -2. Run migration on existing data -3. Deploy code that reads new format, still writes both -4. Deploy code that only reads and writes new format - -This is preferred because you can safely roll back at any point, the old format is always up to date. - -```typescript -// Bad: only writing to new structure before migration is done -export const createTeam = mutation({ - args: { name: v.string(), isPro: v.boolean() }, - handler: async (ctx, args) => { - await ctx.db.insert("teams", { - name: args.name, - plan: args.isPro ? "pro" : "basic", - }); - }, -}); - -// Good: writing to both structures during migration -export const createTeam = mutation({ - args: { name: v.string(), isPro: v.boolean() }, - handler: async (ctx, args) => { - const plan = args.isPro ? "pro" : "basic"; - await ctx.db.insert("teams", { - name: args.name, - isPro: args.isPro, - plan, - }); - }, -}); -``` - -### Dual Read - -Read both formats. Write only the new format. - -1. Deploy code that reads both formats (preferring new), writes only new format -2. Run migration on existing data -3. Deploy code that reads and writes only new format - -This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. - -```typescript -// Good: reading both formats, preferring new -function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { - if (team.plan !== undefined) return team.plan; - return team.isPro ? "pro" : "basic"; -} -``` - -## Small Table Shortcut - -For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: - -```typescript -import { internalMutation } from "./_generated/server"; - -export const backfillSmallTable = internalMutation({ - handler: async (ctx) => { - const docs = await ctx.db.query("smallConfig").collect(); - for (const doc of docs) { - if (doc.newField === undefined) { - await ctx.db.patch(doc._id, { newField: "default" }); - } - } - }, -}); -``` - -```bash -npx convex run migrations:backfillSmallTable -``` - -Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. - -## Verifying a Migration - -Query to check remaining unmigrated documents: - -```typescript -import { query } from "./_generated/server"; - -export const verifyMigration = query({ - handler: async (ctx) => { - const remaining = await ctx.db - .query("users") - .filter((q) => q.eq(q.field("role"), undefined)) - .take(10); - - return { - complete: remaining.length === 0, - sampleRemaining: remaining.map((u) => u._id), - }; - }, -}); -``` - -Or use the component's built-in status monitoring: - -```bash -npx convex run --component migrations lib:getStatus --watch -``` diff --git a/skills/convex-migration-helper/references/migrations-component.md b/skills/convex-migration-helper/references/migrations-component.md deleted file mode 100644 index c80522f2..00000000 --- a/skills/convex-migration-helper/references/migrations-component.md +++ /dev/null @@ -1,170 +0,0 @@ -# Migrations Component Reference - -Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. - -## Installation - -```bash -npm install @convex-dev/migrations -``` - -## Setup - -```typescript -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import migrations from "@convex-dev/migrations/convex.config.js"; - -const app = defineApp(); -app.use(migrations); -export default app; -``` - -```typescript -// convex/migrations.ts -import { Migrations } from "@convex-dev/migrations"; -import { components } from "./_generated/api.js"; -import { DataModel } from "./_generated/dataModel.js"; - -export const migrations = new Migrations(components.migrations); -export const run = migrations.runner(); -``` - -The `DataModel` type parameter is optional but provides type safety for migration definitions. - -## Define a Migration - -The `migrateOne` function processes a single document. The component handles batching and pagination automatically. - -```typescript -// convex/migrations.ts -export const addDefaultRole = migrations.define({ - table: "users", - migrateOne: async (ctx, user) => { - if (user.role === undefined) { - await ctx.db.patch(user._id, { role: "user" }); - } - }, -}); -``` - -Shorthand: if you return an object, it is applied as a patch automatically. - -```typescript -export const clearDeprecatedField = migrations.define({ - table: "users", - migrateOne: () => ({ legacyField: undefined }), -}); -``` - -## Run a Migration - -From the CLI: - -```bash -# Define a one-off runner in convex/migrations.ts: -# export const runIt = migrations.runner(internal.migrations.addDefaultRole); -npx convex run migrations:runIt - -# Or use the general-purpose runner -npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' -``` - -Programmatically from another Convex function: - -```typescript -await migrations.runOne(ctx, internal.migrations.addDefaultRole); -``` - -## Run Multiple Migrations in Order - -```typescript -export const runAll = migrations.runner([ - internal.migrations.addDefaultRole, - internal.migrations.clearDeprecatedField, - internal.migrations.normalizeEmails, -]); -``` - -```bash -npx convex run migrations:runAll -``` - -If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. - -## Dry Run - -Test a migration before committing changes: - -```bash -npx convex run migrations:runIt '{"dryRun": true}' -``` - -This runs one batch and then rolls back, so you can see what it would do without changing any data. - -## Check Migration Status - -```bash -npx convex run --component migrations lib:getStatus --watch -``` - -## Cancel a Running Migration - -```bash -npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' -``` - -Or programmatically: - -```typescript -await migrations.cancel(ctx, internal.migrations.addDefaultRole); -``` - -## Run Migrations on Deploy - -Chain migration execution after deploying: - -```bash -npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod -``` - -## Configuration Options - -### Custom Batch Size - -If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: - -```typescript -export const migrateHeavyTable = migrations.define({ - table: "largeDocuments", - batchSize: 10, - migrateOne: async (ctx, doc) => { - // migration logic - }, -}); -``` - -### Migrate a Subset Using an Index - -Process only matching documents instead of the full table: - -```typescript -export const fixEmptyNames = migrations.define({ - table: "users", - customRange: (query) => - query.withIndex("by_name", (q) => q.eq("name", "")), - migrateOne: () => ({ name: "" }), -}); -``` - -### Parallelize Within a Batch - -By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: - -```typescript -export const clearField = migrations.define({ - table: "myTable", - parallelize: true, - migrateOne: () => ({ optionalField: undefined }), -}); -``` diff --git a/skills/convex-performance-audit/SKILL.md b/skills/convex-performance-audit/SKILL.md deleted file mode 100644 index 9d92b33c..00000000 --- a/skills/convex-performance-audit/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: convex-performance-audit -description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. ---- - -# Convex Performance Audit - -Diagnose and fix performance problems in Convex applications, one problem class at a time. - -## When to Use - -- A Convex page or feature feels slow or expensive -- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts -- Low-freshness read paths are using reactivity where point-in-time reads would do -- OCC conflict errors or excessive mutation retries -- High subscription count or slow UI updates -- Functions approaching execution or transaction limits -- The same performance pattern needs fixing across sibling functions - -## When Not to Use - -- Initial Convex setup, auth setup, or component extraction -- Pure schema migrations with no performance goal -- One-off micro-optimizations without a user-visible or deployment-visible problem - -## Guardrails - -- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak -- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path -- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale - -## First Step: Gather Signals - -Start with the strongest signal available: - -1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. -2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. - - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. -3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. -4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. - -## Signal Routing - -After gathering signals, identify the problem class and read the matching reference file. - -| Signal | Reference | -|---|---| -| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | -| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | -| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | -| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | -| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | - -Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. - -## Escalate Larger Fixes - -If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. - -Examples: - -- introducing digest or summary tables across multiple flows -- splitting documents to isolate frequently-updated fields -- reworking pagination or fetch strategy across several screens -- switching to a new index or denormalized field that needs migration-safe rollout - -When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. - -## Workflow - -### 1. Scope the problem - -Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. - -Write down: - -- entrypoint functions -- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` -- tables read -- tables written -- whether the path is high-read, high-write, or both - -### 2. Trace the full read and write set - -For each function in the path: - -1. Trace every `ctx.db.get()` and `ctx.db.query()` -2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` -3. Note foreign-key lookups, JS-side filtering, and full-document reads -4. Identify all sibling functions touching the same tables -5. Identify reactive stats, aggregates, or widgets rendered on the same page - -In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. - -### 3. Apply fixes from the relevant reference - -Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. - -Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. - -### 4. Fix sibling functions together - -When one function touching a table has a performance bug, audit sibling functions for the same pattern. - -After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. - -Examples: - -- If one list query switches from full docs to a digest table, inspect the other list queries for that table -- If one mutation needs no-op write protection, inspect the other writers to the same table -- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk - -Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. - -### 5. Verify before finishing - -Confirm all of these: - -1. Results are the same as before, no dropped records -2. Eliminated reads or writes are no longer in the path where expected -3. Fallback behavior works when denormalized or indexed fields are missing -4. New writes avoid unnecessary invalidation when data is unchanged -5. Every relevant sibling reader and writer was inspected, not just the original function - -## Reference Files - -- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables -- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting -- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads -- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size - -Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. - -## Checklist - -- [ ] Gathered signals from insights, dashboard, or code audit -- [ ] Identified the problem class and read the matching reference -- [ ] Scoped one concrete user flow or function path -- [ ] Traced every read and write in that path -- [ ] Identified sibling functions touching the same tables -- [ ] Applied fixes from the reference, following the recommended fix order -- [ ] Fixed sibling functions consistently -- [ ] Verified behavior and confirmed no regressions diff --git a/skills/convex-performance-audit/agents/openai.yaml b/skills/convex-performance-audit/agents/openai.yaml deleted file mode 100644 index 9a21f387..00000000 --- a/skills/convex-performance-audit/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Performance Audit" - short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#EF4444" - default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." - -policy: - allow_implicit_invocation: true diff --git a/skills/convex-performance-audit/assets/icon.svg b/skills/convex-performance-audit/assets/icon.svg deleted file mode 100644 index 7ab9e09c..00000000 --- a/skills/convex-performance-audit/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/skills/convex-performance-audit/references/function-budget.md b/skills/convex-performance-audit/references/function-budget.md deleted file mode 100644 index c71d14cb..00000000 --- a/skills/convex-performance-audit/references/function-budget.md +++ /dev/null @@ -1,232 +0,0 @@ -# Function Budget - -Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. - -## Core Principle - -Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. - -## Limits to Know - -These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. - -| Resource | Limit | -|---|---| -| Query/mutation execution time | 1 second (user code only, excludes DB operations) | -| Action execution time | 10 minutes | -| Data read per transaction | 16 MiB | -| Data written per transaction | 16 MiB | -| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | -| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | -| Documents written per transaction | 16,000 | -| Individual document size | 1 MiB | -| Function return value size | 16 MiB | - -## Symptoms - -- "Function execution took too long" errors -- "Transaction too large" or read/write set size errors -- Slow queries that read many documents -- Client receiving large payloads that slow down page load -- `npx convex insights --details` showing high bytes read - -## Common Causes - -### Unbounded collection - -A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. - -### Large document reads on hot paths - -Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. - -### Mutation doing too much work - -A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. - -### Returning too much data to the client - -A query returning full documents when the client only needs a few fields. - -## Fix Order - -### 1. Bound your reads - -Never `.collect()` without a limit on a table that can grow unbounded. - -```ts -// Bad: unbounded read, breaks as the table grows -const messages = await ctx.db.query("messages").collect(); -``` - -```ts -// Good: paginate or limit -const messages = await ctx.db - .query("messages") - .withIndex("by_channel", (q) => q.eq("channelId", channelId)) - .order("desc") - .take(50); -``` - -### 2. Read smaller shapes - -If the list page only needs title, author, and date, do not read full documents with rich content fields. - -Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. - -### 3. Break large mutations into batches - -If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. - -```ts -// Bad: one mutation updating every row -export const backfillAll = internalMutation({ - handler: async (ctx) => { - const docs = await ctx.db.query("items").collect(); - for (const doc of docs) { - await ctx.db.patch(doc._id, { newField: computeValue(doc) }); - } - }, -}); -``` - -```ts -// Good: cursor-based batch processing -export const backfillBatch = internalMutation({ - args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, - handler: async (ctx, args) => { - const batchSize = args.batchSize ?? 100; - const result = await ctx.db - .query("items") - .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); - - for (const doc of result.page) { - if (doc.newField === undefined) { - await ctx.db.patch(doc._id, { newField: computeValue(doc) }); - } - } - - if (!result.isDone) { - await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { - cursor: result.continueCursor, - batchSize, - }); - } - }, -}); -``` - -### 4. Move heavy work to actions - -Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. - -Actions run outside the transaction and can call mutations to write results back. - -```ts -// Bad: heavy computation inside a mutation -export const processUpload = mutation({ - handler: async (ctx, args) => { - const result = expensiveComputation(args.data); - await ctx.db.insert("results", result); - }, -}); -``` - -```ts -// Good: action for heavy work, mutation for the write -export const processUpload = action({ - handler: async (ctx, args) => { - const result = expensiveComputation(args.data); - await ctx.runMutation(internal.results.store, { result }); - }, -}); -``` - -### 5. Trim return values - -Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. - -```ts -// Bad: returns full documents including large content fields -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("articles").take(20); - }, -}); -``` - -```ts -// Good: project to only the fields the client needs -export const list = query({ - handler: async (ctx) => { - const articles = await ctx.db.query("articles").take(20); - return articles.map((a) => ({ - _id: a._id, - title: a.title, - author: a.author, - createdAt: a._creationTime, - })); - }, -}); -``` - -### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions - -Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. - -```ts -// Bad: unnecessary overhead from ctx.runQuery inside a mutation -export const createProject = mutation({ - handler: async (ctx, args) => { - const user = await ctx.runQuery(api.users.getCurrentUser); - await ctx.db.insert("projects", { ...args, ownerId: user._id }); - }, -}); -``` - -```ts -// Good: plain helper function, no extra overhead -export const createProject = mutation({ - handler: async (ctx, args) => { - const user = await getCurrentUser(ctx); - await ctx.db.insert("projects", { ...args, ownerId: user._id }); - }, -}); -``` - -Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. - -### 7. Avoid unnecessary `runAction` calls - -`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). - -```ts -// Bad: runAction overhead for no reason -export const processItems = action({ - handler: async (ctx, args) => { - for (const item of args.items) { - await ctx.runAction(internal.items.processOne, { item }); - } - }, -}); -``` - -```ts -// Good: plain function call -export const processItems = action({ - handler: async (ctx, args) => { - for (const item of args.items) { - await processOneItem(ctx, { item }); - } - }, -}); -``` - -## Verification - -1. No function execution or transaction size errors -2. `npx convex insights --details` shows reduced bytes read -3. Large mutations are batched and self-scheduling -4. Client payloads are reasonably sized for the UI they serve -5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible -6. Sibling functions with similar patterns were checked diff --git a/skills/convex-performance-audit/references/hot-path-rules.md b/skills/convex-performance-audit/references/hot-path-rules.md deleted file mode 100644 index e3e44b15..00000000 --- a/skills/convex-performance-audit/references/hot-path-rules.md +++ /dev/null @@ -1,371 +0,0 @@ -# Hot Path Rules - -Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. - -## Contents - -- Core Principle -- Consistency Rule -- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) -- 2. Minimize Data Sources (denormalization, fallback rule) -- 3. Minimize Row Size (digest tables) -- 4. Skip No-Op Writes -- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) -- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) -- Verification - -## Core Principle - -Every byte read or written multiplies with concurrency. - -Think: - -`cost x calls_per_second x 86400` - -In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. - -## Consistency Rule - -If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. - -Do this especially for: - -- multiple list queries over the same table -- multiple writers to the same table -- public browse and search queries over the same records -- helper functions reused by more than one endpoint - -## 1. Push Filters To Storage - -Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. - -Prefer: - -- `withIndex(...)` -- `.withSearchIndex(...)` for text search -- narrower tables -- summary tables - -before accepting a scan-plus-filter pattern. - -```ts -// Bad: scans then filters in JavaScript -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - const tasks = await ctx.db.query("tasks").collect(); - return tasks.filter((task) => task.status === "open"); - }, -}); -``` - -```ts -// Also bad: Convex .filter() does not push to storage either -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("tasks") - .filter((q) => q.eq(q.field("status"), "open")) - .collect(); - }, -}); -``` - -```ts -// Good: use an index so storage does the filtering -export const listOpen = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("tasks") - .withIndex("by_status", (q) => q.eq("status", "open")) - .collect(); - }, -}); -``` - -### Migration rule for indexes - -New indexes on partially backfilled fields can create correctness bugs during rollout. - -Important Convex detail: - -`undefined !== false` - -If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. - -Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. - -If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. - -```ts -// Bad: optional booleans can miss older rows where the field is undefined -const projects = await ctx.db - .query("projects") - .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) - .order("desc") - .take(20); -``` - -```ts -// Good: switch hot-path reads only after the rollout is migration-safe -// See the migration helper skill for dual-read / backfill / cutover patterns. -``` - -### Check for redundant indexes - -Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. - -```ts -// Bad: two indexes where one would do -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team", ["team"]) - .index("by_team_and_user", ["team", "user"]) -``` - -```ts -// Good: single compound index serves both query patterns -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team_and_user", ["team", "user"]) -``` - -Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. - -## 2. Minimize Data Sources - -Trace every read. - -If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. - -### When to denormalize - -Denormalize when all of these are true: - -- the path is hot -- the joined document is much larger than the field you need -- many readers are paying that join cost repeatedly - -Useful mental model: - -`join_cost = rows_per_page x foreign_doc_size x pages_per_second` - -Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. - -### Fallback rule - -Denormalized data is an optimization. Live data is the correctness path. - -Rules: - -- If the denormalized field is missing or null, fall back to the live read -- Do not show placeholders instead of falling back -- In lookup maps, only include fully populated entries - -```ts -// Bad: missing denormalized data becomes a placeholder and blocks correctness -const ownerName = project.ownerName ?? "Unknown owner"; -``` - -```ts -// Good: denormalized data is an optimization, not the only source of truth -const ownerName = - project.ownerName ?? - (await ctx.db.get(project.ownerId))?.name ?? - null; -``` - -Bad lookup map pattern: - -```ts -const ownersById = { - [project.ownerId]: { ownerName: null }, -}; -``` - -That blocks fallback because the map says "I have data" when it does not. - -Good lookup map pattern: - -```ts -const ownersById = - project.ownerName !== undefined && project.ownerName !== null - ? { [project.ownerId]: { ownerName: project.ownerName } } - : {}; -``` - -### No denormalized copy yet - -Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. - -If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. - -Rollout order: - -1. Update schema -2. Update write path -3. Backfill -4. Switch read path - -## 3. Minimize Row Size - -Hot list pages should read the smallest document shape that still answers the UI. - -Prefer summary or digest tables over full source tables when: - -- the list page only needs a subset of fields -- source documents are large -- the query is high volume - -An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. - -Digest tables are a tradeoff, not a default: - -- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost -- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit - -```ts -// Bad: list page reads source docs, then joins owner data per row -const projects = await ctx.db - .query("projects") - .withIndex("by_public", (q) => q.eq("isPublic", true)) - .collect(); -``` - -```ts -// Good: list page reads the smaller digest shape first -const projects = await ctx.db - .query("projectDigests") - .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) - .order("desc") - .take(20); -``` - -## 4. Skip No-Op Writes - -No-op writes still cost work in Convex: - -- invalidation -- replication -- trigger execution -- downstream sync - -Before `patch` or `replace`, compare against the existing document and skip the write if nothing changed. - -Apply this across sibling writers too. One careful writer does not help much if three other mutations still patch unconditionally. - -```ts -// Bad: patching unchanged values still triggers invalidation and downstream work -await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, -}); -``` - -```ts -// Good: only write when something actually changed -if (settings.theme !== args.theme || settings.locale !== args.locale) { - await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, - }); -} -``` - -## 5. Match Consistency To Read Patterns - -Choose read strategy based on traffic shape. - -### High-read, low-write - -Examples: - -- public browse pages -- search results -- landing pages -- directory listings - -Prefer: - -- point-in-time reads where appropriate -- explicit refresh -- local state for pagination -- caching where appropriate - -Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. - -### High-read, high-write - -Examples: - -- collaborative editors -- live dashboards -- presence-heavy views - -Reactive queries may be worth the ongoing cost. - -## Convex-Specific Notes - -### Reactive queries - -Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. - -On the client: - -- `useQuery` creates a live subscription -- `usePaginatedQuery` creates a live subscription per page - -For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. - -### Point-in-time reads - -Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. - -Use them for: - -- aggregate snapshots -- reports -- low-churn listings -- pages where explicit refresh is fine - -### Triggers and fan-out - -Triggers fire on every write, including writes that did not materially change the document. - -When a write exists only to keep derived state in sync: - -- diff before patching -- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate - -### Aggregates - -Reactive global counts invalidate frequently on busy tables. - -Prefer: - -- one-shot aggregate fetches -- periodic recomputation -- precomputed summary rows - -for global stats that do not need live updates every second. - -### Backfills - -For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. - -Deploy code that can handle both states before running the backfill. - -During the gap: - -- writes should populate the new shape -- reads should fall back safely - -## Verification - -Before closing the audit, confirm: - -1. Same results as before, no dropped records -2. The removed table or lookup is no longer in the hot-path read set -3. Tests or validation cover fallback behavior -4. Migration safety is preserved while fields or indexes are unbackfilled -5. Sibling functions were fixed consistently diff --git a/skills/convex-performance-audit/references/occ-conflicts.md b/skills/convex-performance-audit/references/occ-conflicts.md deleted file mode 100644 index a96d0466..00000000 --- a/skills/convex-performance-audit/references/occ-conflicts.md +++ /dev/null @@ -1,126 +0,0 @@ -# OCC Conflict Resolution - -Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. - -## Core Principle - -Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. - -## Symptoms - -- OCC conflict errors in deployment logs or health page -- Mutations retrying multiple times before succeeding -- User-visible latency spikes on write-heavy pages -- `npx convex insights --details` showing high conflict rates - -## Common Causes - -### Hot documents - -Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. - -### Broad read sets causing false conflicts - -A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. - -### Fan-out from triggers or cascading writes - -A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. - -Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. - -### Write-then-read chains - -A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. - -## Fix Order - -### 1. Reduce read set size - -Narrower reads mean fewer false conflicts. - -```ts -// Bad: broad scan creates a wide conflict surface -const allTasks = await ctx.db.query("tasks").collect(); -const mine = allTasks.filter((t) => t.ownerId === userId); -``` - -```ts -// Good: indexed query touches only relevant documents -const mine = await ctx.db - .query("tasks") - .withIndex("by_owner", (q) => q.eq("ownerId", userId)) - .collect(); -``` - -### 2. Split hot documents - -When many writers target the same document, split the contention point. - -```ts -// Bad: every vote increments the same counter document -const counter = await ctx.db.get(pollCounterId); -await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); -``` - -```ts -// Good: shard the counter across multiple documents, aggregate on read -const shardIndex = Math.floor(Math.random() * SHARD_COUNT); -const shardId = shardIds[shardIndex]; -const shard = await ctx.db.get(shardId); -await ctx.db.patch(shardId, { count: shard!.count + 1 }); -``` - -Aggregate the shards in a query or scheduled job when you need the total. - -### 3. Skip no-op writes - -Writes that do not change data still participate in conflict detection and trigger invalidation. - -```ts -// Bad: patches even when nothing changed -await ctx.db.patch(doc._id, { status: args.status }); -``` - -```ts -// Good: only write when the value actually differs -if (doc.status !== args.status) { - await ctx.db.patch(doc._id, { status: args.status }); -} -``` - -### 4. Move non-critical work to scheduled functions - -If a mutation does primary work plus secondary bookkeeping (analytics, notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. - -```ts -// Bad: analytics update in the same transaction as the user action -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.db.insert("analytics", { event: "action", userId, ts: Date.now() }); -``` - -```ts -// Good: schedule the bookkeeping so the primary transaction is smaller -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.scheduler.runAfter(0, internal.analytics.recordEvent, { - event: "action", - userId, -}); -``` - -### 5. Combine competing writes - -If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. - -Do not introduce artificial locks or queues unless the above steps have been tried first. - -## Related: Invalidation Scope - -Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. - -## Verification - -1. OCC conflict rate has dropped in insights or dashboard -2. Mutation latency is lower and more consistent -3. No data correctness regressions from splitting or scheduling changes -4. Sibling writers to the same hot documents were fixed consistently diff --git a/skills/convex-performance-audit/references/subscription-cost.md b/skills/convex-performance-audit/references/subscription-cost.md deleted file mode 100644 index ae7d1adb..00000000 --- a/skills/convex-performance-audit/references/subscription-cost.md +++ /dev/null @@ -1,252 +0,0 @@ -# Subscription Cost - -Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. - -## Core Principle - -Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: - -`subscriptions x invalidation_frequency x query_cost` - -Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. - -## Symptoms - -- Dashboard shows high active subscription count -- UI feels sluggish or laggy despite fast individual queries -- React profiling shows frequent re-renders from Convex state -- Pages with many components each running their own `useQuery` -- Paginated lists where every loaded page stays subscribed - -## Common Causes - -### Reactive queries on low-freshness flows - -Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. - -### Overly broad queries - -A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. - -### Too many subscriptions per page - -A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. - -### Paginated queries keeping all pages live - -`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. - -### Frequently-updated fields on widely-read documents - -A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. - -## Fix Order - -### 1. Use point-in-time reads when live updates are not valuable - -Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. - -Consider a point-in-time read instead when all of these are true: - -- the flow is high-read -- the underlying data changes less often than users need to see -- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable - -Possible implementations depend on environment: - -- a server-rendered fetch -- a framework helper like `fetchQuery` -- a point-in-time client read such as `ConvexHttpClient.query()` - -```ts -// Reactive by default when fresh live data matters -function TeamPresence() { - const presence = useQuery(api.teams.livePresence, { teamId }); - return ; -} -``` - -```ts -// Point-in-time read when explicit refresh is acceptable -import { ConvexHttpClient } from "convex/browser"; - -const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); - -function SnapshotView() { - const [items, setItems] = useState([]); - - useEffect(() => { - client.query(api.items.snapshot).then(setItems); - }, []); - - return ; -} -``` - -Good candidates for point-in-time reads: - -- aggregate snapshots -- reports -- low-churn listings -- flows where explicit refresh is already acceptable - -Keep reactive for: - -- collaborative editing -- live dashboards -- presence-heavy views -- any surface where users expect fresh changes to appear automatically - -### 2. Batch related data into fewer queries - -Instead of N components each fetching their own related data, fetch it in a single query. - -```ts -// Bad: each card fetches its own author -function ProjectCard({ project }: { project: Project }) { - const author = useQuery(api.users.get, { id: project.authorId }); - return ; -} -``` - -```ts -// Good: parent query returns projects with author names included -function ProjectList() { - const projects = useQuery(api.projects.listWithAuthors); - return projects?.map((p) => ( - - )); -} -``` - -This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. - -This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. - -### 3. Use skip to avoid unnecessary subscriptions - -The `"skip"` value prevents a subscription from being created when the arguments are not ready. - -```ts -// Bad: subscribes with undefined args, wastes a subscription slot -const profile = useQuery(api.users.getProfile, { userId: selectedId! }); -``` - -```ts -// Good: skip when there is nothing to fetch -const profile = useQuery( - api.users.getProfile, - selectedId ? { userId: selectedId } : "skip", -); -``` - -### 4. Isolate frequently-updated fields into separate documents - -If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. - -```ts -// Bad: lastSeen lives on the user doc, every heartbeat invalidates -// every query that reads this user -const users = defineTable({ - name: v.string(), - email: v.string(), - lastSeen: v.number(), -}); -``` - -```ts -// Good: lastSeen lives in a separate heartbeat doc -const users = defineTable({ - name: v.string(), - email: v.string(), - heartbeatId: v.id("heartbeats"), -}); - -const heartbeats = defineTable({ - lastSeen: v.number(), -}); -``` - -Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. - -For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. - -### 5. Use the aggregate component for counts and sums - -Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. - -Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. - -If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. - -### 6. Narrow query read sets - -Queries that return less data and touch fewer documents invalidate less often. - -```ts -// Bad: returns all fields, invalidates on any field change -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("projects").collect(); - }, -}); -``` - -```ts -// Good: use a digest table with only the fields the list needs -export const listDigests = query({ - handler: async (ctx) => { - return await ctx.db.query("projectDigests").collect(); - }, -}); -``` - -Writes to fields not in the digest table do not invalidate the digest query. - -### 7. Remove `Date.now()` from queries - -Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. - -```ts -// Bad: Date.now() defeats query caching and causes frequent re-evaluation -const releasedPosts = await ctx.db - .query("posts") - .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) - .take(100); -``` - -```ts -// Good: use a boolean field updated by a scheduled function -const releasedPosts = await ctx.db - .query("posts") - .withIndex("by_is_released", (q) => q.eq("isReleased", true)) - .take(100); -``` - -If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. - -### 8. Consider pagination strategy - -For long lists where users scroll through many pages: - -- If the data does not need live updates, use point-in-time fetching with manual "load more" -- If it does need live updates, accept the subscription cost but limit the number of loaded pages -- Consider whether older pages can be unloaded as the user scrolls forward - -### 9. Separate backend cost from UI churn - -If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. - -Treat this as a UX problem first when: - -- the underlying query is already reasonably cheap -- the complaint is flicker, loading flashes, or re-render churn -- live updates are still desirable once fresh data arrives - -## Verification - -1. Subscription count in dashboard is lower for the affected pages -2. UI responsiveness has improved -3. React profiling shows fewer unnecessary re-renders -4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily -5. Sibling pages with similar patterns were updated consistently diff --git a/skills/convex-quickstart/SKILL.md b/skills/convex-quickstart/SKILL.md deleted file mode 100644 index 792bba3d..00000000 --- a/skills/convex-quickstart/SKILL.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -name: convex-quickstart -description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." ---- - -# Convex Quickstart - -Set up a working Convex project as fast as possible. - -## When to Use - -- Starting a brand new project with Convex -- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app -- Scaffolding a Convex app for prototyping - -## When Not to Use - -- The project already has Convex installed and `convex/` exists - just start building -- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill - -## Workflow - -1. Determine the starting point: new project or existing app -2. If new project, pick a template and scaffold with `npm create convex@latest` -3. If existing app, install `convex` and wire up the provider -4. Run `npx convex dev` to connect a deployment and start the dev loop -5. Verify the setup works - -## Path 1: New Project (Recommended) - -Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. - -### Pick a template - -| Template | Stack | -|----------|-------| -| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | -| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | -| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | -| `nextjs-clerk` | Next.js + Clerk auth | -| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | -| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | -| `bare` | Convex backend only, no frontend | - -If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. - -You can also use any GitHub repo as a template: - -```bash -npm create convex@latest my-app -- -t owner/repo -npm create convex@latest my-app -- -t owner/repo#branch -``` - -### Scaffold the project - -Always pass the project name and template flag to avoid interactive prompts: - -```bash -npm create convex@latest my-app -- -t react-vite-shadcn -cd my-app -npm install -``` - -The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. - -To scaffold in the current directory (if it is empty): - -```bash -npm create convex@latest . -- -t react-vite-shadcn -npm install -``` - -### Start the dev loop - -`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. - -**Ask the user to run this themselves:** - -Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: -- Create a Convex project and dev deployment -- Write the deployment URL to `.env.local` -- Create the `convex/` directory with generated types -- Watch for changes and sync continuously - -The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. - -**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. - -### Start the frontend - -The user should also run the frontend dev server in a separate terminal: - -```bash -npm run dev -``` - -Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. - -### What you get - -After scaffolding, the project structure looks like: - -``` -my-app/ - convex/ # Backend functions and schema - _generated/ # Auto-generated types (check this into git) - schema.ts # Database schema (if template includes one) - src/ # Frontend code (or app/ for Next.js) - package.json - .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL -``` - -The template already has: -- `ConvexProvider` wired into the app root -- Correct env var names for the framework -- Tailwind and shadcn/ui ready (for shadcn templates) -- Auth provider configured (for auth templates) - -Proceed to adding schema, functions, and UI. - -## Path 2: Add Convex to an Existing App - -Use this when the user already has a frontend project and wants to add Convex as the backend. - -### Install - -```bash -npm install convex -``` - -### Initialize and start dev loop - -Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. - -### Wire up the provider - -The Convex client must wrap the app at the root. The setup varies by framework. - -Create the `ConvexReactClient` at module scope, not inside a component: - -```tsx -// Bad: re-creates the client on every render -function App() { - const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); - return ...; -} - -// Good: created once at module scope -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); -function App() { - return ...; -} -``` - -#### React (Vite) - -```tsx -// src/main.tsx -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { ConvexProvider, ConvexReactClient } from "convex/react"; -import App from "./App"; - -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); - -createRoot(document.getElementById("root")!).render( - - - - - , -); -``` - -#### Next.js (App Router) - -```tsx -// app/ConvexClientProvider.tsx -"use client"; - -import { ConvexProvider, ConvexReactClient } from "convex/react"; -import { ReactNode } from "react"; - -const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); - -export function ConvexClientProvider({ children }: { children: ReactNode }) { - return {children}; -} -``` - -```tsx -// app/layout.tsx -import { ConvexClientProvider } from "./ConvexClientProvider"; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); -} -``` - -#### Other frameworks - -For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: - -- [Vue](https://docs.convex.dev/quickstart/vue) -- [Svelte](https://docs.convex.dev/quickstart/svelte) -- [React Native](https://docs.convex.dev/quickstart/react-native) -- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) -- [Remix](https://docs.convex.dev/quickstart/remix) -- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) - -### Environment variables - -The env var name depends on the framework: - -| Framework | Variable | -|-----------|----------| -| Vite | `VITE_CONVEX_URL` | -| Next.js | `NEXT_PUBLIC_CONVEX_URL` | -| Remix | `CONVEX_URL` | -| React Native | `EXPO_PUBLIC_CONVEX_URL` | - -`npx convex dev` writes the correct variable to `.env.local` automatically. - -## Agent Mode (Cloud and Headless Agents) - -When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. - -Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: - -```bash -CONVEX_AGENT_MODE=anonymous npx convex dev -``` - -This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. - -## Verify the Setup - -After setup, confirm everything is working: - -1. The user confirms `npx convex dev` is running without errors -2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` -3. `.env.local` contains the deployment URL - -## Writing Your First Function - -Once the project is set up, create a schema and a query to verify the full loop works. - -`convex/schema.ts`: - -```ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - tasks: defineTable({ - text: v.string(), - completed: v.boolean(), - }), -}); -``` - -`convex/tasks.ts`: - -```ts -import { query, mutation } from "./_generated/server"; -import { v } from "convex/values"; - -export const list = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query("tasks").collect(); - }, -}); - -export const create = mutation({ - args: { text: v.string() }, - handler: async (ctx, args) => { - await ctx.db.insert("tasks", { text: args.text, completed: false }); - }, -}); -``` - -Use in a React component (adjust the import path based on your file location relative to `convex/`): - -```tsx -import { useQuery, useMutation } from "convex/react"; -import { api } from "../convex/_generated/api"; - -function Tasks() { - const tasks = useQuery(api.tasks.list); - const create = useMutation(api.tasks.create); - - return ( -
- - {tasks?.map((t) =>
{t.text}
)} -
- ); -} -``` - -## Development vs Production - -Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. - -When ready to ship, deploy to production: - -```bash -npx convex deploy -``` - -This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. - -## Next Steps - -- Add authentication: use the `convex-setup-auth` skill -- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) -- Build components: use the `convex-create-component` skill -- Plan a migration: use the `convex-migration-helper` skill -- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) -- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) - -## Checklist - -- [ ] Determined starting point: new project or existing app -- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template -- [ ] If existing app: installed `convex` and wired up the provider -- [ ] User has `npx convex dev` running and connected to a deployment -- [ ] `convex/_generated/` directory exists with types -- [ ] `.env.local` has the deployment URL -- [ ] Verified a basic query/mutation round-trip works diff --git a/skills/convex-quickstart/agents/openai.yaml b/skills/convex-quickstart/agents/openai.yaml deleted file mode 100644 index a51a6d09..00000000 --- a/skills/convex-quickstart/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Quickstart" - short_description: "Start a new Convex app or add Convex to an existing frontend." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#F97316" - default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." - -policy: - allow_implicit_invocation: true diff --git a/skills/convex-quickstart/assets/icon.svg b/skills/convex-quickstart/assets/icon.svg deleted file mode 100644 index d83a73f3..00000000 --- a/skills/convex-quickstart/assets/icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/skills/convex-setup-auth/SKILL.md b/skills/convex-setup-auth/SKILL.md deleted file mode 100644 index 0fa00e2f..00000000 --- a/skills/convex-setup-auth/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: convex-setup-auth -description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." ---- - -# Convex Authentication Setup - -Implement secure authentication in Convex with user management and access control. - -## When to Use - -- Setting up authentication for the first time -- Implementing user management (users table, identity mapping) -- Creating authentication helper functions -- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) - -## When Not to Use - -- Auth for a non-Convex backend -- Pure OAuth/OIDC documentation without a Convex implementation -- Debugging unrelated bugs that happen to surface near auth code -- The auth provider is already fully configured and the user only needs a one-line fix - -## First Step: Choose the Auth Provider - -Convex supports multiple authentication approaches. Do not assume a provider. - -Before writing setup code: - -1. Ask the user which auth solution they want, unless the repository already makes it obvious -2. If the repo already uses a provider, continue with that provider unless the user wants to switch -3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding - -Common options: - -- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex -- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features -- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically -- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 -- Custom JWT provider - use when integrating an existing auth system not covered above - -Look for signals in the repo before asking: - -- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages -- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components -- Environment variables that clearly point at a provider - -## After Choosing a Provider - -Read the provider's official guide and the matching local reference file: - -- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` -- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` -- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` -- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` - -The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. - -Use those sources for: - -- package installation -- client provider wiring -- environment variables -- `convex/auth.config.ts` setup -- login and logout UI patterns -- framework-specific setup for React, Vite, or Next.js - -For shared auth behavior, use the official Convex docs as the source of truth: - -- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` -- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage -- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance -- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth - -Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. -For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. -For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. -After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. - -## Core Pattern: Protecting Backend Functions - -The most common auth task is checking identity in Convex functions. - -```ts -// Bad: trusting a client-provided userId -export const getMyProfile = query({ - args: { userId: v.id("users") }, - handler: async (ctx, args) => { - return await ctx.db.get(args.userId); - }, -}); -``` - -```ts -// Good: verifying identity server-side -export const getMyProfile = query({ - args: {}, - handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("Not authenticated"); - - return await ctx.db - .query("users") - .withIndex("by_tokenIdentifier", (q) => - q.eq("tokenIdentifier", identity.tokenIdentifier) - ) - .unique(); - }, -}); -``` - -## Workflow - -1. Determine the provider, either by asking the user or inferring from the repo -2. Ask whether the user wants local-only setup or production-ready setup now -3. Read the matching provider reference file -4. Follow the official provider docs for current setup details -5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns -6. Only add app-level user storage if the docs and app requirements call for it -7. Add authorization checks for ownership, roles, or team access only where the app needs them -8. Verify login state, protected queries, environment variables, and production configuration if requested - -If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. -For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. -If the environment has browser automation tools, you can use them. -If it does not, give the user a short manual validation checklist instead. - -## Reference Files - -### Provider References - -- `references/convex-auth.md` -- `references/clerk.md` -- `references/workos-authkit.md` -- `references/auth0.md` - -## Checklist - -- [ ] Chosen the correct auth provider before writing setup code -- [ ] Read the relevant provider reference file -- [ ] Asked whether the user wants local-only setup or production-ready setup -- [ ] Used the official provider docs for provider-specific wiring -- [ ] Used the official Convex docs for shared auth behavior and authorization patterns -- [ ] Only added app-level user storage if the app actually needs it -- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth -- [ ] Added authentication checks in protected backend functions -- [ ] Added authorization checks where the app actually needs them -- [ ] Clear error messages ("Not authenticated", "Unauthorized") -- [ ] Client auth provider configured for the chosen provider -- [ ] If requested, production auth setup is covered too diff --git a/skills/convex-setup-auth/agents/openai.yaml b/skills/convex-setup-auth/agents/openai.yaml deleted file mode 100644 index d1c90a14..00000000 --- a/skills/convex-setup-auth/agents/openai.yaml +++ /dev/null @@ -1,10 +0,0 @@ -interface: - display_name: "Convex Setup Auth" - short_description: "Set up Convex auth, user identity mapping, and access control." - icon_small: "./assets/icon.svg" - icon_large: "./assets/icon.svg" - brand_color: "#2563EB" - default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." - -policy: - allow_implicit_invocation: true diff --git a/skills/convex-setup-auth/assets/icon.svg b/skills/convex-setup-auth/assets/icon.svg deleted file mode 100644 index 4917dbb4..00000000 --- a/skills/convex-setup-auth/assets/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/skills/convex-setup-auth/references/auth0.md b/skills/convex-setup-auth/references/auth0.md deleted file mode 100644 index 9c729c5a..00000000 --- a/skills/convex-setup-auth/references/auth0.md +++ /dev/null @@ -1,116 +0,0 @@ -# Auth0 - -Official docs: - -- https://docs.convex.dev/auth/auth0 -- https://auth0.github.io/auth0-cli/ -- https://auth0.github.io/auth0-cli/auth0_apps_create.html - -Use this when the app already uses Auth0 or the user wants Auth0 specifically. - -## Workflow - -1. Confirm the user wants Auth0 -2. Determine the app framework and whether Auth0 is already partly set up -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the official Convex and Auth0 guides before making changes -5. Ask whether they want the fastest setup path by installing the Auth0 CLI -6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI -7. If they do not want the CLI path, use the Auth0 dashboard path instead -8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up -9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID -10. Set environment variables for local and production environments -11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` -12. Gate Convex-backed UI with Convex auth state -13. Try to verify Convex reports the user as authenticated after Auth0 login -14. If the refresh-token path fails, stop improvising and send the user back to the official docs -15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered - -## What To Do - -- Read the official Convex and Auth0 guide before writing setup code -- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet -- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" -- Make sure the app has already completed the relevant Auth0 quickstart for its frontend -- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` -- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated - -## Key Setup Areas - -- install the Auth0 SDK for the app's framework -- configure `convex/auth.config.ts` with the Auth0 domain and client ID -- set environment variables for local and production environments -- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` -- use Convex auth state when gating Convex-backed UI - -## Files and Env Vars To Expect - -- `convex/auth.config.ts` -- frontend app entry or provider wrapper -- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` -- Auth0 environment variables commonly include: - - `AUTH0_DOMAIN` - - `AUTH0_CLIENT_ID` - - `VITE_AUTH0_DOMAIN` - - `VITE_AUTH0_CLIENT_ID` - -## Concrete Steps - -1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework -2. Ask whether the user wants the Auth0 CLI path -3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` -4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app -5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard -6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard -7. Install the Auth0 SDK for the app's framework -8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID -9. Set frontend and backend environment variables -10. Wrap the app in `Auth0Provider` -11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` -12. Run the normal Convex dev or deploy flow after backend config changes -13. Try the official provider config shown in the Convex docs -14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now -15. Only claim success if the user can sign in and Convex recognizes the authenticated session -16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too - -## Gotchas - -- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch -- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant -- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard -- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced -- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end -- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled -- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation -- Keep dev and prod tenants separate if the project uses different Auth0 environments -- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. -- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. -- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. -- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered -- Verify production environment variables and redirect settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can complete the Auth0 login flow -- Verify Convex-authenticated UI renders only after Convex auth state is ready -- Verify protected Convex queries succeed after login -- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions -- Verify the Auth0 app settings match the real local callback and logout URLs during development -- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully -- If production-ready setup was requested, verify the production Auth0 configuration is also covered - -## Checklist - -- [ ] Confirm the user wants Auth0 -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Complete the relevant Auth0 frontend setup -- [ ] Configure `convex/auth.config.ts` -- [ ] Set environment variables -- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs -- [ ] If requested, configure the production deployment too diff --git a/skills/convex-setup-auth/references/clerk.md b/skills/convex-setup-auth/references/clerk.md deleted file mode 100644 index 7dbde194..00000000 --- a/skills/convex-setup-auth/references/clerk.md +++ /dev/null @@ -1,113 +0,0 @@ -# Clerk - -Official docs: - -- https://docs.convex.dev/auth/clerk -- https://clerk.com/docs/guides/development/integrations/databases/convex - -Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. - -## Workflow - -1. Confirm the user wants Clerk -2. Make sure the user has a Clerk account and a Clerk application -3. Determine the app framework: - - React - - Next.js - - TanStack Start -4. Ask whether the user wants local-only setup or production-ready setup now -5. Gather the Clerk keys and the Clerk Frontend API URL -6. Follow the correct framework section in the official docs -7. Complete the backend and client wiring -8. Verify Convex reports the user as authenticated after login -9. If the user wants production-ready setup, make sure the production Clerk config is also covered - -## What To Do - -- Read the official Convex and Clerk guide before writing setup code -- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application -- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active -- Match the guide to the app's framework, usually React, Next.js, or TanStack Start -- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` - -## Key Setup Areas - -- install the Clerk SDK for the framework in use -- configure `convex/auth.config.ts` with the Clerk issuer domain -- set the required Clerk environment variables -- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` -- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` - -## Files and Env Vars To Expect - -- `convex/auth.config.ts` -- React or Vite client entry such as `src/main.tsx` -- Next.js client wrapper for Convex if using App Router -- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` -- Clerk app creation page: `https://dashboard.clerk.com/apps/new` -- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` -- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` -- Clerk environment variables: - - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs - - `CLERK_FRONTEND_API_URL` in the Clerk docs - - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps - - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps - - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required - -`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. - -## Concrete Steps - -1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` -2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` -3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed -4. Open `https://dashboard.clerk.com/apps/setup/convex` -5. Activate the Convex integration in Clerk if it is not already active -6. Copy the Clerk Frontend API URL shown there -7. Install the Clerk package for the app's framework -8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens -9. Set the publishable key in the frontend environment -10. Set the issuer domain or Frontend API URL so Convex can validate the JWT -11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` -12. Wrap the app in `ClerkProvider` -13. Use Convex auth helpers for authenticated rendering -14. Run the normal Convex dev or deploy flow after updating backend auth config -15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too - -## Gotchas - -- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render -- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper -- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config -- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. -- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. -- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. -- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. -- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` -- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included -- Verify production redirect URLs and any production Clerk domain values before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can sign in with Clerk -- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in -- Verify `useConvexAuth()` reaches the authenticated state after Clerk login -- Verify protected Convex queries run successfully inside authenticated UI -- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions -- If production-ready setup was requested, verify the production Clerk configuration is also covered - -## Checklist - -- [ ] Confirm the user wants Clerk -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Follow the correct framework section in the official guide -- [ ] Set Clerk environment variables -- [ ] Configure `convex/auth.config.ts` -- [ ] Verify Convex authenticated state after login -- [ ] If requested, configure the production deployment too diff --git a/skills/convex-setup-auth/references/convex-auth.md b/skills/convex-setup-auth/references/convex-auth.md deleted file mode 100644 index d4824d24..00000000 --- a/skills/convex-setup-auth/references/convex-auth.md +++ /dev/null @@ -1,143 +0,0 @@ -# Convex Auth - -Official docs: https://docs.convex.dev/auth/convex-auth -Setup guide: https://labs.convex.dev/auth/setup - -Use this when the user wants auth handled directly in Convex rather than through a third-party provider. - -## Workflow - -1. Confirm the user wants Convex Auth specifically -2. Determine which sign-in methods the app needs: - - magic links or OTPs - - OAuth providers - - passwords and password reset -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the Convex Auth setup guide before writing code -5. Make sure the project has a configured Convex deployment: - - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set - - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing -6. Install the auth packages: - - `npm install @convex-dev/auth @auth/core@0.37.0` -7. Run the initialization command: - - `npx @convex-dev/auth` -8. Confirm the initializer created: - - `convex/auth.config.ts` - - `convex/auth.ts` - - `convex/http.ts` -9. Add the required `authTables` to `convex/schema.ts` -10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` -11. Configure at least one auth method in `convex/auth.ts` -12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code -13. Verify the client can sign in successfully -14. Verify Convex receives authenticated identity in backend functions -15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well -16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex - -## What This Reference Is For - -- choosing Convex Auth as the default provider for a new Convex app -- understanding whether the app wants magic links, OTPs, OAuth, or passwords -- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior - -## What To Do - -- Read the Convex Auth setup guide before writing setup code -- Follow the setup flow from the docs rather than recreating it from memory -- If the app is new, consider starting from the official starter flow instead of hand-wiring everything -- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra - -## Concrete Steps - -1. Install `@convex-dev/auth` and `@auth/core@0.37.0` -2. Run `npx convex dev` if the project does not already have a configured deployment -3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment -4. Run `npx @convex-dev/auth` -5. Confirm the generated auth setup is present before continuing: - - `convex/auth.config.ts` - - `convex/auth.ts` - - `convex/http.ts` -6. Add `authTables` to `convex/schema.ts` -7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry -8. Configure the selected auth methods in `convex/auth.ts` -9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed -10. Verify login locally -11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment - -## Expected Files and Decisions - -- `convex/schema.ts` -- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file -- generated Convex Auth setup produced by `npx @convex-dev/auth` -- an existing configured Convex deployment, or the ability to create one with `npx convex dev` -- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods - -- Decide whether the user is creating a new app or adding auth to an existing app -- For a new app, prefer the official starter flow instead of rebuilding setup by hand -- Decide which auth methods the app needs: - - magic links or OTPs - - OAuth providers - - passwords -- Decide whether the user wants local-only setup or production-ready setup now -- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough - -## Gotchas - -- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. -- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. -- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. -- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. -- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. -- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. -- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. -- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. -- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. -- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment -- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Human Handoff - -If `npx convex dev` or deployment setup requires human input: - -- stop and explain exactly what the user needs to do -- say why that step is required -- resume the auth setup immediately after the user confirms it is done - -## Validation - -- Verify the user can complete a sign-in flow -- Offer to validate sign up, sign out, and sign back in with the configured auth method -- If browser automation is available in the environment, you can do this directly -- If browser automation is not available, give the user a short manual validation checklist instead -- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions -- Verify protected UI only renders after Convex-authenticated state is ready -- Verify environment variables and redirect settings match the current app environment -- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in -- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed -- If production-ready setup was requested, verify the production deployment is also configured correctly - -## Checklist - -- [ ] Confirm the user wants Convex Auth specifically -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Ensure a Convex deployment is configured before running auth initialization -- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` -- [ ] Run `npx convex dev` first if needed -- [ ] Run `npx @convex-dev/auth` -- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created -- [ ] Follow the setup guide for package install and wiring -- [ ] Add `authTables` to `convex/schema.ts` -- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` -- [ ] Configure at least one auth method in `convex/auth.ts` -- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes -- [ ] Confirm which sign-in methods the app needs -- [ ] Verify the client can sign in and the backend receives authenticated identity -- [ ] Offer end-to-end validation of sign up, sign out, and sign back in -- [ ] If requested, configure the production deployment too -- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/skills/convex-setup-auth/references/workos-authkit.md b/skills/convex-setup-auth/references/workos-authkit.md deleted file mode 100644 index 038cb9f3..00000000 --- a/skills/convex-setup-auth/references/workos-authkit.md +++ /dev/null @@ -1,114 +0,0 @@ -# WorkOS AuthKit - -Official docs: - -- https://docs.convex.dev/auth/authkit/ -- https://docs.convex.dev/auth/authkit/add-to-app -- https://docs.convex.dev/auth/authkit/auto-provision - -Use this when the app already uses WorkOS or the user wants AuthKit specifically. - -## Workflow - -1. Confirm the user wants WorkOS AuthKit -2. Determine whether they want: - - a Convex-managed WorkOS team - - an existing WorkOS team -3. Ask whether the user wants local-only setup or production-ready setup now -4. Read the official Convex and WorkOS AuthKit guide -5. Create or update `convex.json` for the app's framework and real local port -6. Follow the correct branch of the setup flow based on that choice -7. Configure the required WorkOS environment variables -8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs -9. Wire the client provider and callback flow -10. Verify authenticated requests reach Convex -11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too -12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex - -## What To Do - -- Read the official Convex and WorkOS AuthKit guide before writing setup code -- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team -- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra -- Follow the current setup flow from the docs instead of relying on older examples - -## Key Setup Areas - -- package installation for the app's framework -- `convex.json` with the `authKit` section for dev, and preview or prod if needed -- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration -- `convex/auth.config.ts` wiring for WorkOS-issued JWTs -- client provider setup and token flow into Convex -- login callback and redirect configuration - -## Files and Env Vars To Expect - -- `convex.json` -- `convex/auth.config.ts` -- frontend auth provider wiring -- callback or redirect route setup where the framework requires it -- WorkOS environment variables commonly include: - - `WORKOS_CLIENT_ID` - - `WORKOS_API_KEY` - - `WORKOS_COOKIE_PASSWORD` - - `VITE_WORKOS_CLIENT_ID` - - `VITE_WORKOS_REDIRECT_URI` - - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` - -For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. - -## Concrete Steps - -1. Choose Convex-managed or existing WorkOS team -2. Create or update `convex.json` with the `authKit` section for the framework in use -3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port -4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow -5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` -6. Create or update `convex/auth.config.ts` for WorkOS JWT validation -7. Run the normal Convex dev or deploy flow so backend config is synced -8. Wire the WorkOS client provider in the app -9. Configure callback and redirect handling -10. Verify the user can sign in and return to the app -11. Verify Convex sees the authenticated user after login -12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too - -## Gotchas - -- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious -- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys -- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex -- Do not mix dev and prod WorkOS credentials or redirect URIs -- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it -- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. -- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. -- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. -- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. -- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." - -## Production - -- Ask whether the user wants dev-only setup or production-ready setup -- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered -- Verify the production redirect and callback settings before calling the task complete -- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. - -## Validation - -- Verify the user can complete the login flow and return to the app -- Verify the callback URL matches the real frontend port in local dev -- Verify Convex receives authenticated requests after login -- Verify `convex.json` matches the framework and chosen WorkOS setup path -- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path -- Verify environment variables differ correctly between local and production where needed -- If production-ready setup was requested, verify the production WorkOS configuration is also covered - -## Checklist - -- [ ] Confirm the user wants WorkOS AuthKit -- [ ] Ask whether the user wants local-only setup or production-ready setup -- [ ] Choose Convex-managed or existing WorkOS team -- [ ] Create or update `convex.json` -- [ ] Configure WorkOS environment variables -- [ ] Configure `convex/auth.config.ts` -- [ ] Verify authenticated requests reach Convex after login -- [ ] If requested, configure the production deployment too diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 212c0d8f..a9e379a8 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -6,6 +6,7 @@ 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/maps.dart'; @@ -16,6 +17,7 @@ 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/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/agent_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'; @@ -376,6 +378,59 @@ void main() { 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), + ], + ); + + 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, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(agentProvider.notifier).addAgent( + PlacedAgent( + id: 'agent-1', + type: AgentType.jett, + position: const Offset(120, 160), + ), + ); + await _settle(); + + final pending = container.read(strategyOpQueueProvider).pending; + expect( + pending.any( + (entry) => + entry.op.kind == StrategyOpKind.add && + entry.op.entityType == StrategyOpEntityType.element && + entry.op.entityPublicId == 'agent-1' && + entry.op.pagePublicId == 'page-1', + ), + isTrue, + ); + }); + test('projected active-page merge prefers local overlay for touched entities', () async { const strategyId = 'cloud-strategy'; From 8945b5851db2f45dbed0b77f7981f2bde38089ea Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:57:40 -0400 Subject: [PATCH 04/15] Add share links for folders and strategies - add Convex share-link storage and redeem flows - propagate folder/strategy roles through hierarchy - surface shared folders, strategies, and share-link UI --- convex/_generated/api.d.ts | 2 + convex/folders.ts | 179 ++-- convex/lib/auth.ts | 88 +- convex/schema.ts | 25 + convex/shares.ts | 254 ++++++ convex/strategies.ts | 214 +++-- lib/collab/collab_models.dart | 37 + lib/collab/convex_strategy_repository.dart | 139 ++- lib/main.dart | 25 +- .../collab/remote_library_provider.dart | 17 +- .../strategy_capabilities_provider.dart | 12 +- lib/providers/library_workspace_provider.dart | 26 + lib/providers/share_link_provider.dart | 96 ++ lib/widgets/current_path_bar.dart | 33 +- lib/widgets/dialogs/share_links_dialog.dart | 376 ++++++++ lib/widgets/folder_content.dart | 47 +- lib/widgets/folder_navigator.dart | 31 +- lib/widgets/folder_navigator_sidebar.dart | 835 ++++++++++++++++++ lib/widgets/folder_pill.dart | 122 ++- lib/widgets/strategy_tile/strategy_tile.dart | 23 +- 20 files changed, 2354 insertions(+), 227 deletions(-) create mode 100644 convex/shares.ts create mode 100644 lib/providers/share_link_provider.dart create mode 100644 lib/widgets/dialogs/share_links_dialog.dart create mode 100644 lib/widgets/folder_navigator_sidebar.dart diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 97f292b1..8f77e1aa 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -20,6 +20,7 @@ import type * as lib_opTypes from "../lib/opTypes.js"; import type * as lineups from "../lineups.js"; import type * as ops from "../ops.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 users from "../users.js"; @@ -42,6 +43,7 @@ declare const fullApi: ApiFromModules<{ lineups: typeof lineups; ops: typeof ops; pages: typeof pages; + shares: typeof shares; strategies: typeof strategies; users: typeof users; }>; diff --git a/convex/folders.ts b/convex/folders.ts index 31bd20ca..c4519237 100644 --- a/convex/folders.ts +++ b/convex/folders.ts @@ -1,46 +1,97 @@ +import type { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; -import { requireCurrentUser } from "./lib/auth"; +import { + assertFolderRole, + getEffectiveFolderRoleForUser, + requireCurrentUser, +} from "./lib/auth"; import { getFolderByPublicId } from "./lib/entities"; +type FolderScope = "owned" | "shared" | "all"; +type AnyCtx = QueryCtx | MutationCtx; + +function matchesScope( + ownerId: string, + userId: string, + scope: FolderScope, +): boolean { + if (scope === "all") { + return true; + } + if (scope === "owned") { + return ownerId === userId; + } + return ownerId !== userId; +} + +async function listAccessibleFoldersForScope( + ctx: AnyCtx, + userId: Id<"users">, + scope: FolderScope, +) : Promise; role: "owner" | "editor" | "viewer" }>> { + const folders = await ctx.db.query("folders").collect(); + const results: Array<{ + folder: Doc<"folders">; + role: "owner" | "editor" | "viewer"; + }> = []; + + for (const folder of folders) { + const role = await getEffectiveFolderRoleForUser(ctx, folder, userId); + if (role === null) { + continue; + } + if (!matchesScope(folder.ownerId, userId, scope)) { + continue; + } + results.push({ folder, role }); + } + + return results; +} + +const folderScopeValidator = v.optional( + v.union(v.literal("owned"), v.literal("shared"), v.literal("all")), +); + export const listForParent = query({ args: { parentFolderPublicId: v.optional(v.string()), + scope: folderScopeValidator, }, handler: async (ctx, args) => { const user = await requireCurrentUser(ctx); + const scope = args.scope ?? "owned"; - let parentFolderId; + let parentFolderId: Id<"folders"> | undefined; if (args.parentFolderPublicId !== undefined) { const parent = await getFolderByPublicId(ctx, args.parentFolderPublicId); - if (parent.ownerId !== user._id) { - throw new Error("Forbidden"); - } + await assertFolderRole(ctx, parent, "viewer"); parentFolderId = parent._id; } - const folders = await ctx.db - .query("folders") - .withIndex("by_ownerId", (q) => q.eq("ownerId", user._id)) - .collect(); + const accessible = await listAccessibleFoldersForScope(ctx, user._id, scope); + const folderLookup = new Map(accessible.map(({ folder }) => [folder._id, folder])); - return folders - .filter((f) => f.parentFolderId === parentFolderId) - .sort((a, b) => a.createdAt - b.createdAt) - .map((f) => ({ - publicId: f.publicId, - name: f.name, - iconCodePoint: f.iconCodePoint ?? null, - iconFontFamily: f.iconFontFamily ?? null, - iconFontPackage: f.iconFontPackage ?? null, - color: f.color ?? null, - customColorValue: f.customColorValue ?? null, + return accessible + .filter(({ folder }) => folder.parentFolderId === parentFolderId) + .sort((a, b) => a.folder.createdAt - b.folder.createdAt) + .map(({ folder, role }) => ({ + publicId: folder.publicId, + name: folder.name, + iconCodePoint: folder.iconCodePoint ?? null, + iconFontFamily: folder.iconFontFamily ?? null, + iconFontPackage: folder.iconFontPackage ?? null, + color: folder.color ?? null, + customColorValue: folder.customColorValue ?? null, parentFolderPublicId: - f.parentFolderId === undefined + folder.parentFolderId === undefined ? null - : folders.find((p) => p._id === f.parentFolderId)?.publicId ?? null, - createdAt: f.createdAt, - updatedAt: f.updatedAt, + : folderLookup.get(folder.parentFolderId)?.publicId ?? null, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + role, })); }, }); @@ -60,10 +111,11 @@ export const create = mutation({ const user = await requireCurrentUser(ctx); const now = Date.now(); - let parentFolderId; + let parentFolderId: Id<"folders"> | undefined; if (args.parentFolderPublicId !== undefined) { const parent = await getFolderByPublicId(ctx, args.parentFolderPublicId); - if (parent.ownerId !== user._id) { + const { role } = await assertFolderRole(ctx, parent, "owner"); + if (role !== "owner") { throw new Error("Forbidden"); } parentFolderId = parent._id; @@ -113,10 +165,10 @@ export const update = mutation({ clearCustomColorValue: v.optional(v.boolean()), }, handler: async (ctx, args) => { - const user = await requireCurrentUser(ctx); const folder = await getFolderByPublicId(ctx, args.folderPublicId); + const { role } = await assertFolderRole(ctx, folder, "owner"); - if (folder.ownerId !== user._id) { + if (role !== "owner") { throw new Error("Forbidden"); } @@ -163,30 +215,32 @@ export const update = mutation({ }); export const listAll = query({ - args: {}, - handler: async (ctx) => { + args: { + scope: folderScopeValidator, + }, + handler: async (ctx, args) => { const user = await requireCurrentUser(ctx); - const folders = await ctx.db - .query("folders") - .withIndex("by_ownerId", (q) => q.eq("ownerId", user._id)) - .collect(); + const scope = args.scope ?? "all"; + const accessible = await listAccessibleFoldersForScope(ctx, user._id, scope); + const folderLookup = new Map(accessible.map(({ folder }) => [folder._id, folder])); - return folders - .sort((a, b) => a.createdAt - b.createdAt) - .map((f) => ({ - publicId: f.publicId, - name: f.name, - iconCodePoint: f.iconCodePoint ?? null, - iconFontFamily: f.iconFontFamily ?? null, - iconFontPackage: f.iconFontPackage ?? null, - color: f.color ?? null, - customColorValue: f.customColorValue ?? null, + return accessible + .sort((a, b) => a.folder.createdAt - b.folder.createdAt) + .map(({ folder, role }) => ({ + publicId: folder.publicId, + name: folder.name, + iconCodePoint: folder.iconCodePoint ?? null, + iconFontFamily: folder.iconFontFamily ?? null, + iconFontPackage: folder.iconFontPackage ?? null, + color: folder.color ?? null, + customColorValue: folder.customColorValue ?? null, parentFolderPublicId: - f.parentFolderId === undefined + folder.parentFolderId === undefined ? null - : folders.find((p) => p._id === f.parentFolderId)?.publicId ?? null, - createdAt: f.createdAt, - updatedAt: f.updatedAt, + : folderLookup.get(folder.parentFolderId)?.publicId ?? null, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + role, })); }, }); @@ -197,17 +251,18 @@ export const move = mutation({ parentFolderPublicId: v.optional(v.string()), }, handler: async (ctx, args) => { - const user = await requireCurrentUser(ctx); const folder = await getFolderByPublicId(ctx, args.folderPublicId); + const { role } = await assertFolderRole(ctx, folder, "owner"); - if (folder.ownerId !== user._id) { + if (role !== "owner") { throw new Error("Forbidden"); } - let parentFolderId; + let parentFolderId: Id<"folders"> | undefined; if (args.parentFolderPublicId !== undefined) { const parent = await getFolderByPublicId(ctx, args.parentFolderPublicId); - if (parent.ownerId !== user._id) { + const parentAccess = await assertFolderRole(ctx, parent, "owner"); + if (parentAccess.role !== "owner" || parent.ownerId !== folder.ownerId) { throw new Error("Forbidden"); } parentFolderId = parent._id; @@ -227,10 +282,10 @@ export const deleteFolder = mutation({ folderPublicId: v.string(), }, handler: async (ctx, args) => { - const user = await requireCurrentUser(ctx); const folder = await getFolderByPublicId(ctx, args.folderPublicId); + const { role } = await assertFolderRole(ctx, folder, "owner"); - if (folder.ownerId !== user._id) { + if (role !== "owner") { throw new Error("Forbidden"); } @@ -250,6 +305,22 @@ export const deleteFolder = mutation({ throw new Error("Folder has strategies"); } + const collaborators = await ctx.db + .query("folderCollaborators") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + for (const collaborator of collaborators) { + await ctx.db.delete(collaborator._id); + } + + const links = await ctx.db + .query("shareLinks") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + for (const link of links) { + await ctx.db.delete(link._id); + } + await ctx.db.delete(folder._id); return { ok: true }; }, diff --git a/convex/lib/auth.ts b/convex/lib/auth.ts index 37b601a2..4e01eefc 100644 --- a/convex/lib/auth.ts +++ b/convex/lib/auth.ts @@ -91,6 +91,77 @@ export async function getStrategyRoleForUser( return collaborator?.role ?? null; } +export async function getFolderRoleForUser( + ctx: AnyCtx, + folder: Doc<"folders">, + userId: Id<"users">, +): Promise { + if (folder.ownerId === userId) { + return "owner"; + } + + const collaborator = await ctx.db + .query("folderCollaborators") + .withIndex("by_folderId_userId", (q) => + q.eq("folderId", folder._id).eq("userId", userId), + ) + .first(); + + return collaborator?.role ?? null; +} + +function higherRole( + left: StrategyRole | null, + right: StrategyRole | null, +): StrategyRole | null { + if (left === null) return right; + if (right === null) return left; + return roleRank[left] >= roleRank[right] ? left : right; +} + +export async function getEffectiveFolderRoleForUser( + ctx: AnyCtx, + folder: Doc<"folders">, + userId: Id<"users">, +): Promise { + let current: Doc<"folders"> | null = folder; + let bestRole: StrategyRole | null = null; + + while (current !== null) { + bestRole = higherRole( + bestRole, + await getFolderRoleForUser(ctx, current, userId), + ); + const parentFolderId: Id<"folders"> | undefined = current.parentFolderId; + if (parentFolderId === undefined) { + break; + } + current = (await ctx.db.get(parentFolderId)) as Doc<"folders"> | null; + } + + return bestRole; +} + +export async function getEffectiveStrategyRoleForUser( + ctx: AnyCtx, + strategy: Doc<"strategies">, + userId: Id<"users">, +): Promise { + let bestRole = await getStrategyRoleForUser(ctx, strategy, userId); + + if (strategy.folderId !== undefined) { + const folder = await ctx.db.get(strategy.folderId); + if (folder !== null) { + bestRole = higherRole( + bestRole, + await getEffectiveFolderRoleForUser(ctx, folder, userId), + ); + } + } + + return bestRole; +} + export function hasRole( actual: StrategyRole | null, required: StrategyRole, @@ -105,7 +176,22 @@ export async function assertStrategyRole( required: StrategyRole, ): Promise<{ user: Doc<"users">; role: StrategyRole }> { const user = await requireCurrentUser(ctx); - const role = await getStrategyRoleForUser(ctx, strategy, user._id); + const role = await getEffectiveStrategyRoleForUser(ctx, strategy, user._id); + + if (!hasRole(role, required)) { + throw new Error("Forbidden"); + } + + return { user, role: role as StrategyRole }; +} + +export async function assertFolderRole( + ctx: AnyCtx, + folder: Doc<"folders">, + required: StrategyRole, +): Promise<{ user: Doc<"users">; role: StrategyRole }> { + const user = await requireCurrentUser(ctx); + const role = await getEffectiveFolderRoleForUser(ctx, folder, user._id); if (!hasRole(role, required)) { throw new Error("Forbidden"); diff --git a/convex/schema.ts b/convex/schema.ts index 47b11dc8..04fda54e 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -94,6 +94,17 @@ export default defineSchema({ .index("by_strategyId", ["strategyId"]) .index("by_userId", ["userId"]) .index("by_strategyId_userId", ["strategyId", "userId"]), + folderCollaborators: defineTable({ + folderId: v.id("folders"), + userId: v.id("users"), + role: v.union(v.literal("editor"), v.literal("viewer")), + invitedByUserId: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_folderId", ["folderId"]) + .index("by_userId", ["userId"]) + .index("by_folderId_userId", ["folderId", "userId"]), inviteTokens: defineTable({ token: v.string(), strategyId: v.id("strategies"), @@ -107,6 +118,20 @@ export default defineSchema({ }) .index("by_token", ["token"]) .index("by_strategyId", ["strategyId"]), + shareLinks: defineTable({ + token: v.string(), + targetType: v.union(v.literal("strategy"), v.literal("folder")), + strategyId: v.optional(v.id("strategies")), + folderId: v.optional(v.id("folders")), + role: v.union(v.literal("editor"), v.literal("viewer")), + createdByUserId: v.id("users"), + revokedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_token", ["token"]) + .index("by_strategyId", ["strategyId"]) + .index("by_folderId", ["folderId"]), imageAssets: defineTable({ publicId: v.string(), strategyId: v.id("strategies"), diff --git a/convex/shares.ts b/convex/shares.ts new file mode 100644 index 00000000..faed291f --- /dev/null +++ b/convex/shares.ts @@ -0,0 +1,254 @@ +import type { QueryCtx, MutationCtx } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { + assertFolderRole, + assertStrategyRole, + requireCurrentUser, +} from "./lib/auth"; +import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; + +const targetTypeValidator = v.union(v.literal("strategy"), v.literal("folder")); +const collaboratorRoleValidator = v.union(v.literal("viewer"), v.literal("editor")); +type AnyCtx = QueryCtx | MutationCtx; + +async function resolveTarget( + ctx: AnyCtx, + targetType: "strategy" | "folder", + targetPublicId: string, +) { + if (targetType === "strategy") { + const strategy = await getStrategyByPublicId(ctx, targetPublicId); + return { targetType, strategy, folder: null }; + } + + const folder = await getFolderByPublicId(ctx, targetPublicId); + return { targetType, strategy: null, folder }; +} + +export const list = query({ + args: { + targetType: targetTypeValidator, + targetPublicId: v.string(), + }, + handler: async (ctx, args) => { + const resolved = await resolveTarget(ctx, args.targetType, args.targetPublicId); + + if (resolved.strategy !== null) { + const { role } = await assertStrategyRole(ctx, resolved.strategy, "owner"); + if (role !== "owner") { + throw new Error("Forbidden"); + } + } else if (resolved.folder !== null) { + const { role } = await assertFolderRole(ctx, resolved.folder, "owner"); + if (role !== "owner") { + throw new Error("Forbidden"); + } + } + + const links = + resolved.strategy !== null + ? await ctx.db + .query("shareLinks") + .withIndex("by_strategyId", (q) => q.eq("strategyId", resolved.strategy!._id)) + .collect() + : await ctx.db + .query("shareLinks") + .withIndex("by_folderId", (q) => q.eq("folderId", resolved.folder!._id)) + .collect(); + + return links + .sort((a, b) => b.createdAt - a.createdAt) + .map((link) => ({ + token: link.token, + role: link.role, + createdAt: link.createdAt, + revokedAt: link.revokedAt ?? null, + })); + }, +}); + +export const create = mutation({ + args: { + targetType: targetTypeValidator, + targetPublicId: v.string(), + token: v.string(), + role: collaboratorRoleValidator, + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const resolved = await resolveTarget(ctx, args.targetType, args.targetPublicId); + + if (resolved.strategy !== null) { + const { role } = await assertStrategyRole(ctx, resolved.strategy, "owner"); + if (role !== "owner") { + throw new Error("Forbidden"); + } + } else if (resolved.folder !== null) { + const { role } = await assertFolderRole(ctx, resolved.folder, "owner"); + if (role !== "owner") { + throw new Error("Forbidden"); + } + } + + await ctx.db.insert("shareLinks", { + token: args.token, + targetType: args.targetType, + strategyId: resolved.strategy?._id, + folderId: resolved.folder?._id, + role: args.role, + createdByUserId: user._id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const revoke = mutation({ + args: { + targetType: targetTypeValidator, + targetPublicId: v.string(), + token: v.string(), + }, + handler: async (ctx, args) => { + const resolved = await resolveTarget(ctx, args.targetType, args.targetPublicId); + + if (resolved.strategy !== null) { + const { role } = await assertStrategyRole(ctx, resolved.strategy, "owner"); + if (role !== "owner") { + throw new Error("Forbidden"); + } + } else if (resolved.folder !== null) { + const { role } = await assertFolderRole(ctx, resolved.folder, "owner"); + if (role !== "owner") { + throw new Error("Forbidden"); + } + } + + const link = await ctx.db + .query("shareLinks") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (link === null) { + throw new Error("Share link not found"); + } + + if ( + (resolved.strategy !== null && link.strategyId !== resolved.strategy._id) || + (resolved.folder !== null && link.folderId !== resolved.folder._id) + ) { + throw new Error("Share link not found"); + } + + await ctx.db.patch(link._id, { + revokedAt: Date.now(), + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const redeem = mutation({ + args: { + token: v.string(), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const link = await ctx.db + .query("shareLinks") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (link === null) { + throw new Error("Share link not found"); + } + + if (link.revokedAt !== undefined) { + throw new Error("Share link revoked"); + } + + if (link.targetType === "strategy") { + const strategy = link.strategyId === undefined ? null : await ctx.db.get(link.strategyId); + if (strategy === null) { + throw new Error("Strategy not found"); + } + + if (strategy.ownerId !== user._id) { + const existingMembership = await ctx.db + .query("strategyCollaborators") + .withIndex("by_strategyId_userId", (q) => + q.eq("strategyId", strategy._id).eq("userId", user._id), + ) + .first(); + + if (existingMembership === null) { + await ctx.db.insert("strategyCollaborators", { + strategyId: strategy._id, + userId: user._id, + role: link.role, + invitedByUserId: link.createdByUserId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } else { + await ctx.db.patch(existingMembership._id, { + role: link.role, + updatedAt: Date.now(), + }); + } + } + + const folder = + strategy.folderId === undefined ? null : await ctx.db.get(strategy.folderId); + + return { + ok: true, + targetType: "strategy", + strategyPublicId: strategy.publicId, + folderPublicId: folder?.publicId ?? null, + role: strategy.ownerId === user._id ? "owner" : link.role, + }; + } + + const folder = link.folderId === undefined ? null : await ctx.db.get(link.folderId); + if (folder === null) { + throw new Error("Folder not found"); + } + + if (folder.ownerId !== user._id) { + const existingMembership = await ctx.db + .query("folderCollaborators") + .withIndex("by_folderId_userId", (q) => + q.eq("folderId", folder._id).eq("userId", user._id), + ) + .first(); + + if (existingMembership === null) { + await ctx.db.insert("folderCollaborators", { + folderId: folder._id, + userId: user._id, + role: link.role, + invitedByUserId: link.createdByUserId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } else { + await ctx.db.patch(existingMembership._id, { + role: link.role, + updatedAt: Date.now(), + }); + } + } + + return { + ok: true, + targetType: "folder", + folderPublicId: folder.publicId, + role: folder.ownerId === user._id ? "owner" : link.role, + }; + }, +}); diff --git a/convex/strategies.ts b/convex/strategies.ts index f2889b02..f98d4ac3 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -1,102 +1,157 @@ +import type { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { deleteImageAssetsForPage } from "./images"; -import { assertStrategyRole, requireCurrentUser } from "./lib/auth"; +import { + assertFolderRole, + assertStrategyRole, + getEffectiveStrategyRoleForUser, + requireCurrentUser, +} from "./lib/auth"; import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; -async function listAccessibleStrategies(ctx: any, userId: any) { - const owned = await ctx.db - .query("strategies") - .withIndex("by_ownerId", (q: any) => q.eq("ownerId", userId)) - .collect(); +type AnyCtx = QueryCtx | MutationCtx; +type StrategyScope = "owned" | "shared"; + +const strategyScopeValidator = v.optional( + v.union(v.literal("owned"), v.literal("shared")), +); + +async function listAccessibleStrategiesForScope( + ctx: AnyCtx, + userId: Id<"users">, + scope: StrategyScope, +): Promise; role: "owner" | "editor" | "viewer" }>> { + const strategies = await ctx.db.query("strategies").collect(); + const results: Array<{ + strategy: Doc<"strategies">; + role: "owner" | "editor" | "viewer"; + }> = []; + + for (const strategy of strategies) { + const role = await getEffectiveStrategyRoleForUser(ctx, strategy, userId); + if (role === null) { + continue; + } + if (scope === "owned" && strategy.ownerId !== userId) { + continue; + } + if (scope === "shared" && strategy.ownerId === userId) { + continue; + } + results.push({ strategy, role }); + } - const memberships = await ctx.db - .query("strategyCollaborators") - .withIndex("by_userId", (q: any) => q.eq("userId", userId)) - .collect(); + return results; +} - const fromMembership = await Promise.all( - memberships.map((m: any) => ctx.db.get(m.strategyId)), - ); +async function getAttackLabel(ctx: QueryCtx, strategyId: Id<"strategies">) { + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .collect(); - const dedup = new Map(); - for (const strategy of [...owned, ...fromMembership]) { - if (strategy !== null) { - dedup.set(strategy._id, strategy); - } + if (pages.length === 0) { + return "Unknown"; } - return Array.from(dedup.values()); + const first = pages[0]!.isAttack; + const mixed = pages.some((page) => page.isAttack !== first); + return mixed ? "Mixed" : first ? "Attack" : "Defend"; } export const listForFolder = query({ args: { folderPublicId: v.optional(v.string()), + scope: strategyScopeValidator, }, handler: async (ctx, args) => { const user = await requireCurrentUser(ctx); - const all = await listAccessibleStrategies(ctx as any, user._id); - const memberships = await ctx.db - .query("strategyCollaborators") - .withIndex("by_userId", (q) => q.eq("userId", user._id)) - .collect(); + const scope = args.scope ?? "owned"; - let folderId; + let folderId: Id<"folders"> | undefined; if (args.folderPublicId !== undefined) { const folder = await getFolderByPublicId(ctx, args.folderPublicId); - if (folder.ownerId !== user._id) { - throw new Error("Forbidden"); - } + await assertFolderRole(ctx, folder, "viewer"); folderId = folder._id; } - const folderIdToPublicId = new Map(); - for (const strategy of all) { - if ( - strategy.folderId !== undefined && - !folderIdToPublicId.has(strategy.folderId) - ) { - const strategyFolder = await ctx.db.get(strategy.folderId); - if (strategyFolder !== null) { - folderIdToPublicId.set(strategy.folderId, strategyFolder.publicId); - } + const accessible = await listAccessibleStrategiesForScope(ctx, user._id, scope); + const folderLookup = new Map, string>(); + for (const { strategy } of accessible) { + if (strategy.folderId === undefined || folderLookup.has(strategy.folderId)) { + continue; + } + const folder = await ctx.db.get(strategy.folderId); + if (folder !== null) { + folderLookup.set(strategy.folderId, folder.publicId); + } + } + + return await Promise.all( + accessible + .filter(({ strategy }) => strategy.folderId === folderId) + .sort((a, b) => b.strategy.updatedAt - a.strategy.updatedAt) + .map(async ({ strategy, role }) => ({ + publicId: strategy.publicId, + name: strategy.name, + mapData: strategy.mapData, + sequence: strategy.sequence, + createdAt: strategy.createdAt, + updatedAt: strategy.updatedAt, + role, + attackLabel: await getAttackLabel(ctx, strategy._id), + folderPublicId: + strategy.folderId === undefined + ? null + : folderLookup.get(strategy.folderId) ?? null, + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + })), + ); + }, +}); + +export const listSharedWithMe = query({ + args: {}, + handler: async (ctx) => { + const user = await requireCurrentUser(ctx); + const sharedStrategies = await listAccessibleStrategiesForScope( + ctx, + user._id, + "shared", + ); + const folderLookup = new Map, string>(); + for (const { strategy } of sharedStrategies) { + if (strategy.folderId === undefined || folderLookup.has(strategy.folderId)) { + continue; + } + const folder = await ctx.db.get(strategy.folderId); + if (folder !== null) { + folderLookup.set(strategy.folderId, folder.publicId); } } return await Promise.all( - all - .filter((s) => s.folderId === folderId) - .sort((a, b) => b.updatedAt - a.updatedAt) - .map(async (s) => { - const pages = await ctx.db - .query("pages") - .withIndex("by_strategyId", (q) => q.eq("strategyId", s._id)) - .collect(); - let attackLabel = "Unknown"; - if (pages.length > 0) { - const first = pages[0].isAttack; - const mixed = pages.some((page) => page.isAttack !== first); - attackLabel = mixed ? "Mixed" : first ? "Attack" : "Defend"; - } - const role = s.ownerId === user._id - ? "owner" - : memberships.find((m: any) => m.strategyId === s._id)?.role ?? "viewer"; - - return { - publicId: s.publicId, - name: s.name, - mapData: s.mapData, - sequence: s.sequence, - createdAt: s.createdAt, - updatedAt: s.updatedAt, + sharedStrategies + .sort((a, b) => b.strategy.updatedAt - a.strategy.updatedAt) + .map(async ({ strategy, role }) => ({ + publicId: strategy.publicId, + name: strategy.name, + mapData: strategy.mapData, + sequence: strategy.sequence, + createdAt: strategy.createdAt, + updatedAt: strategy.updatedAt, role, - attackLabel, + attackLabel: await getAttackLabel(ctx, strategy._id), folderPublicId: - s.folderId === undefined ? null : folderIdToPublicId.get(s.folderId) ?? null, - themeProfileId: s.themeProfileId ?? null, - themeOverridePalette: s.themeOverridePalette ?? null, - }; - }), + strategy.folderId === undefined + ? null + : folderLookup.get(strategy.folderId) ?? null, + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + })), ); }, }); @@ -136,10 +191,11 @@ export const create = mutation({ const user = await requireCurrentUser(ctx); const now = Date.now(); - let folderId; + let folderId: Id<"folders"> | undefined; if (args.folderPublicId !== undefined) { const folder = await getFolderByPublicId(ctx, args.folderPublicId); - if (folder.ownerId !== user._id) { + const { role } = await assertFolderRole(ctx, folder, "owner"); + if (role !== "owner") { throw new Error("Forbidden"); } folderId = folder._id; @@ -222,10 +278,11 @@ export const move = mutation({ const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - let folderId; + let folderId: Id<"folders"> | undefined; if (args.folderPublicId !== undefined) { const folder = await getFolderByPublicId(ctx, args.folderPublicId); - if (folder.ownerId !== strategy.ownerId) { + const parentAccess = await assertFolderRole(ctx, folder, "owner"); + if (parentAccess.role !== "owner" || folder.ownerId !== strategy.ownerId) { throw new Error("Forbidden"); } folderId = folder._id; @@ -296,12 +353,17 @@ export const deleteStrategy = mutation({ await ctx.db.delete(invite._id); } + const shareLinks = await ctx.db + .query("shareLinks") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + for (const link of shareLinks) { + await ctx.db.delete(link._id); + } + await ctx.db.delete(strategy._id); return { ok: true }; }, }); export { deleteStrategy as delete }; - - - diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index 46d24fca..f8be6e34 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -364,6 +364,7 @@ class CloudStrategySummary { required this.updatedAt, this.role, this.attackLabel, + this.folderPublicId, }); final String publicId; @@ -374,6 +375,7 @@ class CloudStrategySummary { final DateTime updatedAt; final String? role; final String? attackLabel; + final String? folderPublicId; factory CloudStrategySummary.fromJson(Map json) { return CloudStrategySummary( @@ -389,6 +391,7 @@ class CloudStrategySummary { ), role: json['role'] as String?, attackLabel: json['attackLabel'] as String?, + folderPublicId: json['folderPublicId'] as String?, ); } } @@ -405,6 +408,7 @@ class CloudFolderSummary { this.iconFontPackage, this.color, this.customColorValue, + this.role, }); final String publicId; @@ -417,6 +421,7 @@ class CloudFolderSummary { final String? iconFontPackage; final String? color; final int? customColorValue; + final String? role; factory CloudFolderSummary.fromJson(Map json) { return CloudFolderSummary( @@ -434,6 +439,38 @@ class CloudFolderSummary { iconFontPackage: json['iconFontPackage'] as String?, color: json['color'] as String?, customColorValue: (json['customColorValue'] as num?)?.toInt(), + role: json['role'] as String?, + ); + } +} + +class ShareLinkSummary { + const ShareLinkSummary({ + required this.token, + required this.role, + required this.createdAt, + this.revokedAt, + }); + + final String token; + final String role; + final DateTime createdAt; + final DateTime? revokedAt; + + bool get isRevoked => revokedAt != null; + + factory ShareLinkSummary.fromJson(Map json) { + return ShareLinkSummary( + token: json['token'] as String, + role: json['role'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ), + revokedAt: (json['revokedAt'] as num?) == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (json['revokedAt'] as num).toInt(), + ), ); } } diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index 368a160b..da1f9457 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -53,19 +53,22 @@ class ConvexStrategyRepository { } Future> listFoldersForParent( - String? parentFolderPublicId, - ) async { + String? parentFolderPublicId, { + String scope = 'owned', + }) async { final response = await _client.query('folders:listForParent', { if (parentFolderPublicId != null) 'parentFolderPublicId': parentFolderPublicId, + 'scope': scope, }); return _decodeObjectList(response) .map(CloudFolderSummary.fromJson) .toList(growable: false); } - Future> listAllFolders() async { - final response = await _client.query('folders:listAll', {}); + Future> listAllFolders( + {String scope = 'all'}) async { + final response = await _client.query('folders:listAll', {'scope': scope}); return _decodeObjectList(response) .map(CloudFolderSummary.fromJson) .toList(growable: false); @@ -84,7 +87,7 @@ class ConvexStrategyRepository { subscription = await _client.subscribe( name: 'folders:listAll', - args: const {}, + args: const {'scope': 'all'}, onUpdate: (value) { try { final mapped = _decodeObjectList(value) @@ -112,25 +115,36 @@ class ConvexStrategyRepository { } Future> listStrategiesForFolder( - String? folderPublicId, - ) async { + String? folderPublicId, { + String scope = 'owned', + }) async { final response = await _client.query('strategies:listForFolder', { if (folderPublicId != null) 'folderPublicId': folderPublicId, + 'scope': scope, }); return _decodeObjectList(response) .map(CloudStrategySummary.fromJson) .toList(growable: false); } + Future> listSharedStrategies() async { + final response = await _client.query('strategies:listSharedWithMe', {}); + return _decodeObjectList(response) + .map(CloudStrategySummary.fromJson) + .toList(growable: false); + } + Stream> watchFoldersForParent( - String? parentFolderPublicId, - ) { + String? parentFolderPublicId, { + String scope = 'owned', + }) { final controller = StreamController>.broadcast(); dynamic subscription; Future start() async { try { - controller.add(await listFoldersForParent(parentFolderPublicId)); + controller.add( + await listFoldersForParent(parentFolderPublicId, scope: scope)); } catch (error, stackTrace) { controller.addError(error, stackTrace); } @@ -140,6 +154,7 @@ class ConvexStrategyRepository { args: { if (parentFolderPublicId != null) 'parentFolderPublicId': parentFolderPublicId, + 'scope': scope, }, onUpdate: (value) { try { @@ -169,14 +184,16 @@ class ConvexStrategyRepository { } Stream> watchStrategiesForFolder( - String? folderPublicId, - ) { + String? folderPublicId, { + String scope = 'owned', + }) { final controller = StreamController>.broadcast(); dynamic subscription; Future start() async { try { - controller.add(await listStrategiesForFolder(folderPublicId)); + controller + .add(await listStrategiesForFolder(folderPublicId, scope: scope)); } catch (error, stackTrace) { controller.addError(error, stackTrace); } @@ -185,6 +202,7 @@ class ConvexStrategyRepository { name: 'strategies:listForFolder', args: { if (folderPublicId != null) 'folderPublicId': folderPublicId, + 'scope': scope, }, onUpdate: (value) { try { @@ -214,6 +232,48 @@ class ConvexStrategyRepository { return controller.stream; } + Stream> watchSharedStrategies() { + final controller = StreamController>.broadcast(); + dynamic subscription; + + Future start() async { + try { + controller.add(await listSharedStrategies()); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + + subscription = await _client.subscribe( + name: 'strategies:listSharedWithMe', + args: const {}, + onUpdate: (value) { + try { + final mapped = _decodeObjectList(value) + .map(CloudStrategySummary.fromJson) + .toList(growable: false); + controller.add(mapped); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + }, + onError: (message, value) { + controller.addError( + Exception('strategies:listSharedWithMe error: $message')); + }, + ); + } + + start(); + + controller.onCancel = () { + try { + subscription?.cancel(); + } catch (_) {} + }; + + return controller.stream; + } + Stream watchStrategyHeader(String strategyPublicId) { final controller = StreamController.broadcast(); dynamic subscription; @@ -430,4 +490,57 @@ class ConvexStrategyRepository { }, ); } + + Future> listShareLinks({ + required String targetType, + required String targetPublicId, + }) async { + final response = await _client.query('shares:list', { + 'targetType': targetType, + 'targetPublicId': targetPublicId, + }); + return _decodeObjectList(response) + .map(ShareLinkSummary.fromJson) + .toList(growable: false); + } + + Future createShareLink({ + required String targetType, + required String targetPublicId, + required String token, + required String role, + }) async { + await _client.mutation( + name: 'shares:create', + args: { + 'targetType': targetType, + 'targetPublicId': targetPublicId, + 'token': token, + 'role': role, + }, + ); + } + + Future revokeShareLink({ + required String targetType, + required String targetPublicId, + required String token, + }) async { + await _client.mutation( + name: 'shares:revoke', + args: { + 'targetType': targetType, + 'targetPublicId': targetPublicId, + 'token': token, + }, + ); + } + + Future> redeemShareLink(String token) async { + final response = await _client.mutation( + name: 'shares:redeem', + args: {'token': token}, + ); + return _decodeObject(response); + } } diff --git a/lib/main.dart b/lib/main.dart index af9e4119..fa065a59 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,7 @@ import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; @@ -351,11 +352,17 @@ class _MyAppState extends ConsumerState { .read(inAppDebugProvider.notifier) .bulkAddLogs(['Deep link [$source]: $uriText']); - unawaited( - ref + unawaited(() async { + final handledAuth = await ref .read(authProvider.notifier) - .handleAuthCallbackUri(uri, source: source), - ); + .handleAuthCallbackUri(uri, source: source); + if (handledAuth) { + return; + } + await ref + .read(shareLinkControllerProvider.notifier) + .handleIncomingUri(uri, source: source); + }()); } @override @@ -414,6 +421,16 @@ class _MyAppState extends ConsumerState { @override Widget build(BuildContext context) { + ref.listen(authProvider, (_, next) { + if (next.isAuthenticated && next.isConvexUserReady) { + unawaited( + ref + .read(shareLinkControllerProvider.notifier) + .redeemPendingIfPossible(), + ); + } + }); + return ToastificationWrapper( config: const ToastificationConfig( alignment: Alignment.bottomCenter, diff --git a/lib/providers/collab/remote_library_provider.dart b/lib/providers/collab/remote_library_provider.dart index 8417b261..80ba3b4b 100644 --- a/lib/providers/collab/remote_library_provider.dart +++ b/lib/providers/collab/remote_library_provider.dart @@ -17,10 +17,14 @@ final cloudFoldersProvider = return; } + final section = ref.watch(cloudLibrarySectionProvider); final parentFolderId = ref.watch(folderProvider); final repo = ref.watch(convexStrategyRepositoryProvider); try { - await for (final folders in repo.watchFoldersForParent(parentFolderId)) { + await for (final folders in repo.watchFoldersForParent( + parentFolderId, + scope: section == CloudLibrarySection.sharedWithMe ? 'shared' : 'owned', + )) { yield folders; } } catch (error, stackTrace) { @@ -55,14 +59,21 @@ final cloudStrategiesProvider = return; } + final section = ref.watch(cloudLibrarySectionProvider); final folderId = ref.watch(folderProvider); final repo = ref.watch(convexStrategyRepositoryProvider); try { - await for (final strategies in repo.watchStrategiesForFolder(folderId)) { + final stream = section == CloudLibrarySection.sharedWithMe + ? (folderId == null + ? repo.watchSharedStrategies() + : repo.watchStrategiesForFolder(folderId, scope: 'shared')) + : repo.watchStrategiesForFolder(folderId, scope: 'owned'); + await for (final strategies in stream) { yield strategies; } } catch (error, stackTrace) { - if (_isInvalidFolderError(error)) { + if (section != CloudLibrarySection.sharedWithMe && + _isInvalidFolderError(error)) { ref .read(folderProvider.notifier) .updateWorkspaceFolderId(LibraryWorkspace.cloud, null); diff --git a/lib/providers/collab/strategy_capabilities_provider.dart b/lib/providers/collab/strategy_capabilities_provider.dart index 49ef6b01..ee3fdf71 100644 --- a/lib/providers/collab/strategy_capabilities_provider.dart +++ b/lib/providers/collab/strategy_capabilities_provider.dart @@ -67,15 +67,16 @@ class StrategyCapabilities { canRenamePage: canEdit, canDeletePage: canEdit, canReorderPages: canEdit, - canCreateFolder: true, - canEditFolder: true, - canDeleteFolder: true, - canMoveFolder: true, + canCreateFolder: isOwner, + canEditFolder: isOwner, + canDeleteFolder: isOwner, + canMoveFolder: isOwner, ); } } -final currentStrategyCapabilitiesProvider = Provider((ref) { +final currentStrategyCapabilitiesProvider = + Provider((ref) { final strategySource = ref.watch(strategyProvider.select((value) => value.source)); if (strategySource != StrategySource.cloud || @@ -86,4 +87,3 @@ final currentStrategyCapabilitiesProvider = Provider((ref) ref.watch(remoteStrategySnapshotProvider).valueOrNull?.header.role; return StrategyCapabilities.fromCloudRole(role); }); - diff --git a/lib/providers/library_workspace_provider.dart b/lib/providers/library_workspace_provider.dart index dd4ea91e..61cccf7a 100644 --- a/lib/providers/library_workspace_provider.dart +++ b/lib/providers/library_workspace_provider.dart @@ -6,6 +6,11 @@ enum LibraryWorkspace { cloud, } +enum CloudLibrarySection { + home, + sharedWithMe, +} + final isCloudWorkspaceAvailableProvider = Provider((ref) { final auth = ref.watch(authProvider); return auth.isAuthenticated && auth.isConvexUserReady; @@ -20,6 +25,11 @@ final isCloudWorkspaceSelectedProvider = Provider((ref) { return ref.watch(libraryWorkspaceProvider) == LibraryWorkspace.cloud; }); +final cloudLibrarySectionProvider = + NotifierProvider( + CloudLibrarySectionNotifier.new, +); + class LibraryWorkspaceNotifier extends Notifier { @override LibraryWorkspace build() { @@ -40,3 +50,19 @@ class LibraryWorkspaceNotifier extends Notifier { state = workspace; } } + +class CloudLibrarySectionNotifier extends Notifier { + @override + CloudLibrarySection build() { + ref.listen(libraryWorkspaceProvider, (_, workspace) { + if (workspace != LibraryWorkspace.cloud) { + state = CloudLibrarySection.home; + } + }); + return CloudLibrarySection.home; + } + + void select(CloudLibrarySection section) { + state = section; + } +} diff --git a/lib/providers/share_link_provider.dart b/lib/providers/share_link_provider.dart new file mode 100644 index 00000000..9edc8b24 --- /dev/null +++ b/lib/providers/share_link_provider.dart @@ -0,0 +1,96 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +final shareLinkControllerProvider = + NotifierProvider(ShareLinkController.new); + +class ShareLinkController extends Notifier { + Future handleIncomingUri(Uri uri, {required String source}) async { + final isShareLink = uri.scheme.toLowerCase() == 'icarus' && + (uri.host.toLowerCase() == 'share' || + uri.pathSegments.contains('share')); + if (!isShareLink) { + return false; + } + + final token = uri.queryParameters['token']; + if (token == null || token.isEmpty) { + Settings.showToast( + message: 'That share link is missing a token.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return true; + } + + state = token; + await redeemPendingIfPossible(source: source); + return true; + } + + Future redeemPendingIfPossible({String source = 'pending'}) async { + final token = state; + if (token == null || token.isEmpty) { + return; + } + + final auth = ref.read(authProvider); + if (!auth.isAuthenticated || !auth.isConvexUserReady) { + Settings.showToast( + message: 'Sign in to redeem shared links.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + return; + } + + try { + final response = await ref + .read(convexStrategyRepositoryProvider) + .redeemShareLink(token); + state = null; + + ref + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.cloud); + ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.sharedWithMe); + ref.read(folderProvider.notifier).updateWorkspaceFolderId( + LibraryWorkspace.cloud, + response['folderPublicId'] as String?, + ); + + final targetType = response['targetType'] as String? ?? 'item'; + Settings.showToast( + message: targetType == 'folder' + ? 'Shared folder added to your library.' + : 'Shared strategy added to your library.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + await ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'share_link:$source', + error: error, + stackTrace: stackTrace, + ); + return; + } + Settings.showToast( + message: 'Failed to redeem share link.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + } + + Future redeemToken(String token) async { + state = token; + await redeemPendingIfPossible(source: 'manual'); + } + + @override + String? build() => null; +} diff --git a/lib/widgets/current_path_bar.dart b/lib/widgets/current_path_bar.dart index 6bebfb0a..ab311717 100644 --- a/lib/widgets/current_path_bar.dart +++ b/lib/widgets/current_path_bar.dart @@ -16,6 +16,7 @@ class CurrentPathBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final workspace = ref.watch(libraryWorkspaceProvider); final isCloud = workspace == LibraryWorkspace.cloud; + final cloudSection = ref.watch(cloudLibrarySectionProvider); final currentFolderId = ref.watch(folderProvider); final cloudFolders = isCloud ? (ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []) @@ -51,8 +52,11 @@ class CurrentPathBar extends ConsumerWidget { children: [ FolderTab( folder: null, - isActive: currentFolder == null, + isActive: currentFolder == null && + cloudSection != CloudLibrarySection.sharedWithMe, ), + if (isCloud && cloudSection == CloudLibrarySection.sharedWithMe) + const _StaticBreadcrumbLink(label: 'Shared with Me'), for (int i = 0; i < pathFolders.length; i++) FolderTab( folder: pathFolders[i], @@ -126,12 +130,39 @@ class FolderTab extends ConsumerWidget { }, ), onPressed: () { + if (ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud) { + final targetSection = folder == null + ? CloudLibrarySection.home + : ref.read(cloudLibrarySectionProvider); + ref + .read(cloudLibrarySectionProvider.notifier) + .select(targetSection); + } ref.read(folderProvider.notifier).updateID(folder?.id); }, ); } } +class _StaticBreadcrumbLink extends StatelessWidget { + const _StaticBreadcrumbLink({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return ShadBreadcrumbLink( + textStyle: ShadTheme.of(context).textTheme.lead, + normalColor: Settings.tacticalVioletTheme.foreground, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text(label), + ), + onPressed: () {}, + ); + } +} + extension on Iterable { Folder? get firstOrNull => isEmpty ? null : first; } diff --git a/lib/widgets/dialogs/share_links_dialog.dart b/lib/widgets/dialogs/share_links_dialog.dart new file mode 100644 index 00000000..f75c1d10 --- /dev/null +++ b/lib/widgets/dialogs/share_links_dialog.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:uuid/uuid.dart'; + +String buildIcarusShareLink(String token) => 'icarus://share?token=$token'; + +/// Headline like: Share "My Strategy Name" (`"` in [name] become `'`). +String _shareDialogHeadline(String name) { + final safe = name.replaceAll('"', "'"); + return 'Share "$safe"'; +} + +class ShareLinksDialog extends ConsumerStatefulWidget { + const ShareLinksDialog({ + super.key, + required this.targetType, + required this.targetPublicId, + required this.title, + }); + + final String targetType; + final String targetPublicId; + final String title; + + @override + ConsumerState createState() => _ShareLinksDialogState(); +} + +class _ShareLinksDialogState extends ConsumerState { + List _links = const []; + bool _isLoading = true; + bool _isCreating = false; + String _selectedRole = 'viewer'; + + @override + void initState() { + super.initState(); + _loadLinks(); + } + + Future _loadLinks() async { + setState(() => _isLoading = true); + try { + final links = + await ref.read(convexStrategyRepositoryProvider).listShareLinks( + targetType: widget.targetType, + targetPublicId: widget.targetPublicId, + ); + if (!mounted) return; + setState(() { + _links = links; + _isLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _isLoading = false); + Settings.showToast( + message: 'Failed to load share links.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + } + + Future _createLink() async { + setState(() => _isCreating = true); + final token = const Uuid().v4(); + try { + await ref.read(convexStrategyRepositoryProvider).createShareLink( + targetType: widget.targetType, + targetPublicId: widget.targetPublicId, + token: token, + role: _selectedRole, + ); + await Clipboard.setData(ClipboardData(text: buildIcarusShareLink(token))); + Settings.showToast( + message: 'Share link copied to clipboard.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + await _loadLinks(); + } catch (_) { + Settings.showToast( + message: 'Failed to create share link.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } finally { + if (mounted) { + setState(() => _isCreating = false); + } + } + } + + Future _copyLink(String token) async { + await Clipboard.setData(ClipboardData(text: buildIcarusShareLink(token))); + Settings.showToast( + message: 'Share link copied to clipboard.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } + + Future _revokeLink(String token) async { + try { + await ref.read(convexStrategyRepositoryProvider).revokeShareLink( + targetType: widget.targetType, + targetPublicId: widget.targetPublicId, + token: token, + ); + await _loadLinks(); + } catch (_) { + Settings.showToast( + message: 'Failed to revoke share link.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return ShadDialog( + title: Text( + _shareDialogHeadline(widget.title), + softWrap: true, + ), + description: const Text( + 'Links never expire. Anyone who opens one can join this item in your cloud library with the access you choose.', + ), + actions: [ + ShadButton.secondary( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Done'), + ), + ], + child: SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'New link', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 8), + ShadSelect( + initialValue: _selectedRole, + selectedOptionBuilder: (context, value) => Text( + value == 'editor' ? 'Can edit' : 'View only', + ), + options: const [ + ShadOption(value: 'viewer', child: Text('View only')), + ShadOption(value: 'editor', child: Text('Can edit')), + ], + onChanged: (value) { + if (value != null) { + setState(() => _selectedRole = value); + } + }, + ), + const SizedBox(height: 12), + ShadButton( + onPressed: _isCreating ? null : _createLink, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.copy, + size: 16, + color: theme.colorScheme.primaryForeground, + ), + const SizedBox(width: 8), + Text( + _isCreating ? 'Creating…' : 'Create link & copy', + ), + ], + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Text( + 'Active links', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + if (!_isLoading && _links.isNotEmpty) ...[ + const SizedBox(width: 8), + ShadBadge.secondary( + child: Text('${_links.length}'), + ), + ], + ], + ), + const SizedBox(height: 10), + if (_isLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center(child: CircularProgressIndicator()), + ) + else if (_links.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text( + 'No links yet. Create one above to invite collaborators.', + style: theme.textTheme.muted, + textAlign: TextAlign.center, + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 280), + child: ListView.separated( + shrinkWrap: true, + itemCount: _links.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final link = _links[index]; + final url = buildIcarusShareLink(link.token); + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border), + borderRadius: theme.radius, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Tooltip( + message: url, + child: SelectionArea( + child: Text( + url, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontFamily: 'monospace', + fontSize: 11, + height: 1.35, + ), + maxLines: 4, + overflow: TextOverflow.ellipsis, + softWrap: true, + ), + ), + ), + ), + const SizedBox(width: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: 'Copy link', + child: ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: () => _copyLink(link.token), + child: Icon( + LucideIcons.copy, + size: 16, + color: theme.colorScheme.foreground, + ), + ), + ), + Tooltip( + message: link.isRevoked + ? 'Revoked' + : 'Revoke link', + child: ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: link.isRevoked + ? null + : () => _revokeLink(link.token), + child: Icon( + LucideIcons.trash2, + size: 16, + color: link.isRevoked + ? theme.colorScheme.mutedForeground + : theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class JoinShareLinkDialog extends ConsumerStatefulWidget { + const JoinShareLinkDialog({super.key}); + + @override + ConsumerState createState() => + _JoinShareLinkDialogState(); +} + +class _JoinShareLinkDialogState extends ConsumerState { + final TextEditingController _controller = TextEditingController(); + bool _isSubmitting = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _extractToken(String value) { + final trimmed = value.trim(); + final uri = Uri.tryParse(trimmed); + if (uri != null) { + return uri.queryParameters['token'] ?? trimmed; + } + return trimmed; + } + + Future _submit() async { + final token = _extractToken(_controller.text); + if (token.isEmpty) { + return; + } + setState(() => _isSubmitting = true); + await ref.read(shareLinkControllerProvider.notifier).redeemToken(token); + if (!mounted) return; + setState(() => _isSubmitting = false); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return ShadDialog( + title: const Text('Join Shared Item'), + description: const Text( + 'Paste an Icarus share link to add it to your cloud library.'), + actions: [ + ShadButton.secondary( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ShadButton( + onPressed: _isSubmitting ? null : _submit, + child: Text(_isSubmitting ? 'Joining...' : 'Join'), + ), + ], + child: ShadInput( + controller: _controller, + placeholder: const Text('icarus://share?token=...'), + ), + ); + } +} diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index b8cfb73b..6cccc101 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -29,7 +29,8 @@ class FolderContent extends ConsumerWidget { return Hive.box(HiveBoxNames.strategiesBox).listenable(); }); - static final foldersListenable = Provider>>((ref) { + static final foldersListenable = + Provider>>((ref) { return Hive.box(HiveBoxNames.foldersBox).listenable(); }); @@ -38,6 +39,7 @@ class FolderContent extends ConsumerWidget { final workspace = ref.watch(libraryWorkspaceProvider); final isCloud = workspace == LibraryWorkspace.cloud; if (isCloud) { + final cloudSection = ref.watch(cloudLibrarySectionProvider); final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); if (!cloudAvailable) { return _buildCloudUnavailableState(context, ref); @@ -54,6 +56,12 @@ class FolderContent extends ConsumerWidget { localStrategies: const [], cloudStrategies: _filterCloudStrategies(ref, strategies), isCloud: true, + emptyStateTitle: cloudSection == CloudLibrarySection.sharedWithMe + ? 'No shared items yet' + : 'No cloud strategies yet', + emptyStateSubtitle: cloudSection == CloudLibrarySection.sharedWithMe + ? 'Shared folders and strategies will appear here' + : 'Create a cloud strategy to start your online workspace', ); } @@ -78,6 +86,9 @@ class FolderContent extends ConsumerWidget { localStrategies: _filterLocalStrategies(ref, strategies), cloudStrategies: const [], isCloud: false, + emptyStateTitle: 'No strategies available', + emptyStateSubtitle: + 'Create a new strategy or drop strategies, folders, or .zip archives', ); }, ); @@ -111,8 +122,8 @@ class FolderContent extends ConsumerWidget { } Comparator comparator = switch (filter.sortBy) { - SortBy.alphabetical => - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + SortBy.alphabetical => (a, b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), SortBy.dateCreated => (a, b) => a.createdAt.compareTo(b.createdAt), SortBy.dateUpdated => (a, b) => a.lastEdited.compareTo(b.lastEdited), }; @@ -136,8 +147,8 @@ class FolderContent extends ConsumerWidget { } Comparator comparator = switch (filter.sortBy) { - SortBy.alphabetical => - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + SortBy.alphabetical => (a, b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), SortBy.dateCreated => (a, b) => a.createdAt.compareTo(b.createdAt), SortBy.dateUpdated => (a, b) => a.updatedAt.compareTo(b.updatedAt), }; @@ -154,18 +165,17 @@ class FolderContent extends ConsumerWidget { required List localStrategies, required List cloudStrategies, required bool isCloud, + required String emptyStateTitle, + required String emptyStateSubtitle, }) { - final hasStrategies = localStrategies.isNotEmpty || cloudStrategies.isNotEmpty; + final hasStrategies = + localStrategies.isNotEmpty || cloudStrategies.isNotEmpty; final Widget emptyState = Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(isCloud ? 'No cloud strategies yet' : 'No strategies available'), - Text( - isCloud - ? 'Create a cloud strategy to start your online workspace' - : 'Create a new strategy or drop strategies, folders, or .zip archives', - ), + Text(emptyStateTitle), + Text(emptyStateSubtitle), ], ), ); @@ -174,9 +184,9 @@ class FolderContent extends ConsumerWidget { const double minTileWidth = 250; const double spacing = 20; const double padding = 32; - int crossAxisCount = - ((constraints.maxWidth - padding + spacing) / (minTileWidth + spacing)) - .floor(); + int crossAxisCount = ((constraints.maxWidth - padding + spacing) / + (minTileWidth + spacing)) + .floor(); crossAxisCount = crossAxisCount.clamp(1, double.infinity).toInt(); return CustomScrollView( @@ -243,8 +253,7 @@ class FolderContent extends ConsumerWidget { ); }, ); - final wrappedContent = - isCloud ? content : IcaDropTarget(child: content); + final wrappedContent = isCloud ? content : IcaDropTarget(child: content); return Stack( children: [ @@ -300,9 +309,7 @@ class FolderContent extends ConsumerWidget { ), Expanded( child: (folders.isEmpty && !hasStrategies) - ? (isCloud - ? emptyState - : IcaDropTarget(child: emptyState)) + ? (isCloud ? emptyState : IcaDropTarget(child: emptyState)) : wrappedContent, ), ], diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 5226cf8e..649b31c7 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -10,6 +10,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/const/update_checker.dart'; import 'package:icarus/main.dart'; import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -239,7 +240,14 @@ class _FolderNavigatorState extends ConsumerState { final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); final currentFolderId = ref.watch(folderProvider); final currentFolder = currentFolderId != null - ? ref.read(folderProvider.notifier).findLocalFolderByID(currentFolderId) + ? isCloudWorkspace + ? ref.read(folderProvider.notifier).findCloudFolderByID( + currentFolderId, + ref.watch(cloudAllFoldersProvider).valueOrNull ?? const [], + ) + : ref + .read(folderProvider.notifier) + .findLocalFolderByID(currentFolderId) : null; final authState = ref.watch(authProvider); Future navigateWithLoading( @@ -310,8 +318,8 @@ class _FolderNavigatorState extends ConsumerState { ), ); }, - ), - ); + ), + ); } else { await navigateWithLoading(context, strategyId); } @@ -355,7 +363,9 @@ class _FolderNavigatorState extends ConsumerState { ], onChanged: (value) { if (value == null) return; - ref.read(libraryWorkspaceProvider.notifier).select(value); + ref + .read(libraryWorkspaceProvider.notifier) + .select(value); }, ), ShadButton.secondary( @@ -363,7 +373,8 @@ class _FolderNavigatorState extends ConsumerState { ? null : () { if (authState.isAuthenticated) { - unawaited(ref.read(authProvider.notifier).signOut()); + unawaited( + ref.read(authProvider.notifier).signOut()); } else { showDialog( context: context, @@ -430,7 +441,8 @@ class _FolderNavigatorState extends ConsumerState { }, child: ShadButton.secondary( key: _importExportButtonKey, - onPressed: isCloudWorkspace ? null : _toggleImportExportPopover, + onPressed: + isCloudWorkspace ? null : _toggleImportExportPopover, leading: const Icon(Icons.import_export), trailing: const Icon(Icons.keyboard_arrow_down), child: const Text('Import / Export'), @@ -452,7 +464,9 @@ class _FolderNavigatorState extends ConsumerState { onPressed: showCreateDialog, leading: const Icon(Icons.add), child: Text( - isCloudWorkspace ? 'Create Cloud Strategy' : 'Create Strategy', + isCloudWorkspace + ? 'Create Cloud Strategy' + : 'Create Strategy', ), ), ], @@ -483,8 +497,7 @@ class StrategyItem extends GridItem { final String strategyId; final StrategyData? strategy; - StrategyItem.local(this.strategy) - : strategyId = strategy!.id; + StrategyItem.local(this.strategy) : strategyId = strategy!.id; StrategyItem.cloud(this.strategyId) : strategy = null; } diff --git a/lib/widgets/folder_navigator_sidebar.dart b/lib/widgets/folder_navigator_sidebar.dart new file mode 100644 index 00000000..5ae38728 --- /dev/null +++ b/lib/widgets/folder_navigator_sidebar.dart @@ -0,0 +1,835 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_filter_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/custom_search_field.dart'; +import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; +import 'package:icarus/widgets/folder_edit_dialog.dart'; +import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class FolderNavigatorSidebar extends ConsumerWidget { + const FolderNavigatorSidebar({ + super.key, + required this.onCreateStrategy, + required this.onAddFolder, + required this.onImportIca, + required this.onImportBackup, + required this.onExportLibrary, + }); + + final VoidCallback onCreateStrategy; + final Future Function() onAddFolder; + final Future Function() onImportIca; + final Future Function() onImportBackup; + final Future Function() onExportLibrary; + + static final foldersListenable = + Provider>>((ref) { + return Hive.box(HiveBoxNames.foldersBox).listenable(); + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final workspace = ref.watch(libraryWorkspaceProvider); + final isCloud = workspace == LibraryWorkspace.cloud; + + if (isCloud) { + final cloudFolders = + (ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []) + .map(FolderProvider.cloudSummaryToFolder) + .toList(growable: false); + return _SidebarShell( + folders: cloudFolders, + isCloud: true, + onCreateStrategy: onCreateStrategy, + onAddFolder: onAddFolder, + onImportIca: onImportIca, + onImportBackup: onImportBackup, + onExportLibrary: onExportLibrary, + ); + } + + final localFoldersListenable = ref.watch( + FolderNavigatorSidebar.foldersListenable, + ); + return ValueListenableBuilder>( + valueListenable: localFoldersListenable, + builder: (context, folderBox, _) { + return _SidebarShell( + folders: folderBox.values.toList(growable: false), + isCloud: false, + onCreateStrategy: onCreateStrategy, + onAddFolder: onAddFolder, + onImportIca: onImportIca, + onImportBackup: onImportBackup, + onExportLibrary: onExportLibrary, + ); + }, + ); + } +} + +class _SidebarShell extends ConsumerWidget { + const _SidebarShell({ + required this.folders, + required this.isCloud, + required this.onCreateStrategy, + required this.onAddFolder, + required this.onImportIca, + required this.onImportBackup, + required this.onExportLibrary, + }); + + final List folders; + final bool isCloud; + final VoidCallback onCreateStrategy; + final Future Function() onAddFolder; + final Future Function() onImportIca; + final Future Function() onImportBackup; + final Future Function() onExportLibrary; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentFolderId = ref.watch(folderProvider); + final cloudSection = ref.watch(cloudLibrarySectionProvider); + final canMutateCloudLibrary = + !isCloud || cloudSection == CloudLibrarySection.home; + final filterState = ref.watch(strategyFilterProvider); + final searchQuery = + ref.watch(strategySearchQueryProvider).trim().toLowerCase(); + final visibleRoots = _buildVisibleTree(folders, searchQuery); + + return Container( + width: 288, + margin: const EdgeInsets.fromLTRB(12, 12, 0, 12), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Settings.tacticalVioletTheme.border), + boxShadow: const [Settings.cardForegroundBackdrop], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ShadButton( + onPressed: canMutateCloudLibrary ? onCreateStrategy : null, + leading: const Icon(Icons.add), + child: Text( + isCloud ? 'Create Cloud Strategy' : 'Create Strategy', + ), + ), + const SizedBox(height: 8), + ShadButton.secondary( + onPressed: canMutateCloudLibrary ? onAddFolder : null, + leading: const Icon(LucideIcons.folderPlus), + child: const Text('Add Folder'), + ), + if (isCloud) ...[ + const SizedBox(height: 8), + ShadButton.secondary( + onPressed: () async { + await showShadDialog( + context: context, + builder: (_) => const JoinShareLinkDialog(), + ); + }, + leading: const Icon(LucideIcons.link), + child: const Text('Join Share Link'), + ), + ], + const SizedBox(height: 12), + const SizedBox( + height: 40, + child: SearchTextField( + collapsedWidth: double.infinity, + expandedWidth: double.infinity, + compact: true, + hintText: 'Search strategies and folders', + ), + ), + const SizedBox(height: 12), + _SidebarSelect( + currentValue: filterState.sortBy, + values: SortBy.values, + labels: StrategyFilterProvider.sortByLabels, + onChanged: (value) { + ref.read(strategyFilterProvider.notifier).setSortBy(value); + }, + ), + const SizedBox(height: 8), + _SidebarSelect( + currentValue: filterState.sortOrder, + values: SortOrder.values, + labels: StrategyFilterProvider.sortOrderLabels, + onChanged: (value) { + ref + .read(strategyFilterProvider.notifier) + .setSortOrder(value); + }, + ), + const SizedBox(height: 12), + _SidebarSectionLabel( + label: isCloud ? 'Cloud Tools' : 'Library Tools', + ), + const SizedBox(height: 8), + _SidebarActionButton( + icon: Icons.file_download_outlined, + label: 'Import .ica', + onPressed: isCloud ? null : onImportIca, + ), + const SizedBox(height: 6), + _SidebarActionButton( + icon: Icons.archive_outlined, + label: 'Import Backup', + onPressed: isCloud ? null : onImportBackup, + ), + const SizedBox(height: 6), + _SidebarActionButton( + icon: Icons.backup_outlined, + label: 'Export Library', + onPressed: isCloud ? null : onExportLibrary, + ), + ], + ), + ), + Divider( + height: 1, + color: Settings.tacticalVioletTheme.border, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 12, 10, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isCloud) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: _SidebarSectionLabel(label: 'Views'), + ), + const SizedBox(height: 8), + _SidebarSpecialItem( + icon: Icons.home_outlined, + label: 'Home', + isSelected: cloudSection == CloudLibrarySection.home && + currentFolderId == null, + onTap: () { + ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.home); + ref.read(folderProvider.notifier).updateID(null); + }, + ), + const SizedBox(height: 4), + _SidebarSpecialItem( + icon: Icons.people_outline, + label: 'Shared with Me', + isSelected: + cloudSection == CloudLibrarySection.sharedWithMe, + onTap: () { + ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.sharedWithMe); + ref.read(folderProvider.notifier).updateID(null); + }, + ), + const SizedBox(height: 12), + ], + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: _SidebarSectionLabel(label: 'Folders'), + ), + const SizedBox(height: 8), + Expanded( + child: ListView( + children: [ + _SidebarRootItem( + isSelected: currentFolderId == null && + (!isCloud || + cloudSection == CloudLibrarySection.home), + ), + const SizedBox(height: 4), + if (visibleRoots.isEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 12, + ), + child: Text( + searchQuery.isEmpty + ? 'No folders yet' + : 'No folders match your search', + style: TextStyle( + color: Settings + .tacticalVioletTheme.mutedForeground, + fontSize: 13, + ), + ), + ) + else + ...visibleRoots.map( + (node) => _FolderSidebarItem( + node: node, + depth: 0, + selectedFolderId: currentFolderId, + folderLookup: { + for (final folder in folders) folder.id: folder, + }, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _SidebarRootItem extends ConsumerWidget { + const _SidebarRootItem({required this.isSelected}); + + final bool isSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DragTarget( + onAcceptWithDetails: (details) { + final item = details.data; + if (item is StrategyItem) { + ref.read(strategyProvider.notifier).moveToFolder( + strategyID: item.strategyId, + parentID: null, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); + } else if (item is FolderItem) { + ref.read(folderProvider.notifier).moveToFolder( + folderID: item.folder.id, + parentID: null, + workspace: ref.read(libraryWorkspaceProvider), + ); + } + }, + builder: (context, candidateData, rejectedData) { + final isDropTarget = candidateData.isNotEmpty; + return _SidebarRowShell( + selected: isSelected, + isDropTarget: isDropTarget, + onTap: () { + if (ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud) { + ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.home); + } + ref.read(folderProvider.notifier).updateID(null); + }, + child: const Row( + children: [ + Icon(Icons.home_outlined, size: 18), + SizedBox(width: 12), + Expanded( + child: Text( + 'Home', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _SidebarSpecialItem extends StatelessWidget { + const _SidebarSpecialItem({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return _SidebarRowShell( + selected: isSelected, + isDropTarget: false, + onTap: onTap, + child: Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _FolderSidebarItem extends ConsumerStatefulWidget { + const _FolderSidebarItem({ + required this.node, + required this.depth, + required this.selectedFolderId, + required this.folderLookup, + }); + + final _FolderTreeNode node; + final int depth; + final String? selectedFolderId; + final Map folderLookup; + + @override + ConsumerState<_FolderSidebarItem> createState() => _FolderSidebarItemState(); +} + +class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { + final ShadContextMenuController _menuButtonController = + ShadContextMenuController(); + final ShadContextMenuController _rightClickMenuController = + ShadContextMenuController(); + bool _hovered = false; + + Folder get folder => widget.node.folder; + + @override + void dispose() { + _menuButtonController.dispose(); + _rightClickMenuController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = folder.customColor ?? + Folder.folderColorMap[folder.color] ?? + Colors.white; + final selected = widget.selectedFolderId == folder.id; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DragTarget( + onWillAcceptWithDetails: (details) { + final item = details.data; + if (item is FolderItem) { + return item.folder.id != folder.id && + !_isAncestor( + targetFolder: folder, draggedFolderId: item.folder.id); + } + return true; + }, + onAcceptWithDetails: (details) { + final item = details.data; + if (item is StrategyItem) { + ref.read(strategyProvider.notifier).moveToFolder( + strategyID: item.strategyId, + parentID: folder.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); + } else if (item is FolderItem) { + ref.read(folderProvider.notifier).moveToFolder( + folderID: item.folder.id, + parentID: folder.id, + workspace: ref.read(libraryWorkspaceProvider), + ); + } + }, + builder: (context, candidateData, rejectedData) { + return Padding( + padding: EdgeInsets.only(left: widget.depth * 16.0), + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: ShadContextMenuRegion( + controller: _rightClickMenuController, + items: _buildMenuItems(), + child: Draggable( + data: FolderItem(folder), + feedback: _FolderDragPreview(folder: folder), + dragAnchorStrategy: pointerDragAnchorStrategy, + child: _SidebarRowShell( + selected: selected, + isDropTarget: candidateData.isNotEmpty, + onTap: () { + if (ref.read(libraryWorkspaceProvider) == + LibraryWorkspace.cloud) { + ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.home); + } + ref.read(folderProvider.notifier).updateID(folder.id); + }, + child: Row( + children: [ + Icon(folder.icon, size: 18, color: color), + const SizedBox(width: 12), + Expanded( + child: Text( + folder.name, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(fontWeight: FontWeight.w500), + ), + ), + if (_hovered || selected) + ShadContextMenuRegion( + controller: _menuButtonController, + items: _buildMenuItems(), + child: ShadIconButton.ghost( + width: 26, + height: 26, + onPressed: _menuButtonController.toggle, + icon: const Icon(Icons.more_horiz, size: 16), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + if (widget.node.children.isNotEmpty) + ...widget.node.children.map( + (child) => _FolderSidebarItem( + node: child, + depth: widget.depth + 1, + selectedFolderId: widget.selectedFolderId, + folderLookup: widget.folderLookup, + ), + ), + ], + ); + } + + List _buildMenuItems() { + final isCloud = + ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; + final allFolders = + ref.read(cloudAllFoldersProvider).valueOrNull ?? const []; + final cloudRole = allFolders + .where((item) => item.publicId == folder.id) + .map((item) => item.role) + .firstOrNull; + final canManage = !isCloud || cloudRole == 'owner'; + + return [ + ShadContextMenuItem( + leading: const Icon(Icons.text_fields), + child: const Text('Edit'), + onPressed: !canManage + ? null + : () async { + await showDialog( + context: context, + builder: (context) => FolderEditDialog(folder: folder), + ); + }, + ), + if (isCloud && cloudRole == 'owner') + ShadContextMenuItem( + leading: const Icon(LucideIcons.link2), + child: const Text('Share'), + onPressed: () async { + await showShadDialog( + context: context, + builder: (_) => ShareLinksDialog( + targetType: 'folder', + targetPublicId: folder.id, + title: folder.name, + ), + ); + }, + ), + ShadContextMenuItem( + leading: const Icon(Icons.file_upload_outlined), + child: const Text('Export'), + onPressed: () async { + await StrategyImportExportService(ref).exportFolder(folder.id); + }, + ), + ShadContextMenuItem( + leading: const Icon(Icons.delete_outline, color: Colors.redAccent), + child: const Text( + 'Delete', + style: TextStyle(color: Colors.redAccent), + ), + onPressed: !canManage + ? null + : () async { + final confirmed = await ConfirmAlertDialog.show( + context: context, + title: "Delete '${folder.name}'?", + content: + 'This also removes every strategy and subfolder inside it.', + confirmText: 'Delete', + isDestructive: true, + ); + if (!confirmed) { + return; + } + ref.read(folderProvider.notifier).deleteFolder( + folder.id, + workspace: ref.read(libraryWorkspaceProvider), + ); + }, + ), + ]; + } + + bool _isAncestor({ + required Folder targetFolder, + required String draggedFolderId, + }) { + String? currentParentId = targetFolder.parentID; + while (currentParentId != null) { + if (currentParentId == draggedFolderId) { + return true; + } + currentParentId = widget.folderLookup[currentParentId]?.parentID; + } + return false; + } +} + +class _SidebarRowShell extends StatelessWidget { + const _SidebarRowShell({ + required this.child, + required this.onTap, + required this.selected, + required this.isDropTarget, + }); + + final Widget child; + final VoidCallback onTap; + final bool selected; + final bool isDropTarget; + + @override + Widget build(BuildContext context) { + final borderColor = isDropTarget + ? Settings.tacticalVioletTheme.ring + : (selected + ? Settings.tacticalVioletTheme.primary + : Colors.transparent); + final backgroundColor = selected + ? Settings.tacticalVioletTheme.primary.withValues(alpha: 0.18) + : (isDropTarget + ? Settings.tacticalVioletTheme.primary.withValues(alpha: 0.10) + : Colors.transparent); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + height: 38, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: child, + ), + ), + ), + ); + } +} + +extension on Iterable { + String? get firstOrNull => isEmpty ? null : first; +} + +class _SidebarSectionLabel extends StatelessWidget { + const _SidebarSectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Text( + label, + style: TextStyle( + color: Settings.tacticalVioletTheme.mutedForeground, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ); + } +} + +class _SidebarActionButton extends StatelessWidget { + const _SidebarActionButton({ + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final Future Function()? onPressed; + + @override + Widget build(BuildContext context) { + return ShadButton.ghost( + onPressed: onPressed, + mainAxisAlignment: MainAxisAlignment.start, + leading: Icon(icon, size: 18), + child: Text(label), + ); + } +} + +class _SidebarSelect extends StatelessWidget { + const _SidebarSelect({ + required this.currentValue, + required this.values, + required this.labels, + required this.onChanged, + }); + + final T currentValue; + final Iterable values; + final Map labels; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return ShadSelect( + initialValue: currentValue, + selectedOptionBuilder: (context, value) => Text(labels[value]!), + options: [ + for (final value in values) + ShadOption( + value: value, + child: Text(labels[value]!), + ), + ], + onChanged: (value) { + if (value != null) { + onChanged(value); + } + }, + ); + } +} + +class _FolderDragPreview extends StatelessWidget { + const _FolderDragPreview({required this.folder}); + + final Folder folder; + + @override + Widget build(BuildContext context) { + final color = folder.customColor ?? + Folder.folderColorMap[folder.color] ?? + Colors.white; + return Material( + color: Colors.transparent, + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Settings.tacticalVioletTheme.ring), + boxShadow: const [Settings.cardForegroundBackdrop], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(folder.icon, size: 18, color: color), + const SizedBox(width: 10), + Text( + folder.name, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ); + } +} + +class _FolderTreeNode { + const _FolderTreeNode({ + required this.folder, + required this.children, + }); + + final Folder folder; + final List<_FolderTreeNode> children; +} + +List<_FolderTreeNode> _buildVisibleTree( + List folders, + String searchQuery, +) { + final byParent = >{}; + for (final folder in folders) { + byParent.putIfAbsent(folder.parentID, () => []).add(folder); + } + + for (final entry in byParent.entries) { + entry.value.sort((a, b) => a.dateCreated.compareTo(b.dateCreated)); + } + + List<_FolderTreeNode> walk(String? parentId) { + final children = byParent[parentId] ?? const []; + final nodes = <_FolderTreeNode>[]; + for (final folder in children) { + final nested = walk(folder.id); + final matchesSearch = searchQuery.isEmpty || + folder.name.toLowerCase().contains(searchQuery); + if (matchesSearch || nested.isNotEmpty) { + nodes.add(_FolderTreeNode(folder: folder, children: nested)); + } + } + return nodes; + } + + return walk(null); +} diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index edba8321..c621286c 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -7,6 +7,7 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -62,6 +63,23 @@ class _FolderPillState extends ConsumerState Folder.folderColorMap[widget.folder.color] ?? Colors.grey; + bool get _isCloudWorkspace => + ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; + + String? get _cloudRole { + if (!_isCloudWorkspace) { + return null; + } + final allFolders = + ref.read(cloudAllFoldersProvider).valueOrNull ?? const []; + return allFolders + .where((folder) => folder.publicId == widget.folder.id) + .map((folder) => folder.role) + .firstOrNull; + } + + bool get _canManageCloudFolder => !_isCloudWorkspace || _cloudRole == 'owner'; + @override Widget build(BuildContext context) { return Draggable( @@ -72,6 +90,7 @@ class _FolderPillState extends ConsumerState onWillAcceptWithDetails: (details) { final item = details.data; if (widget.isDemo) return false; + if (!_canManageCloudFolder) return false; if (item is FolderItem) { return item.folder.id != widget.folder.id && !_isParentFolder(item.folder.id); @@ -83,18 +102,18 @@ class _FolderPillState extends ConsumerState final item = details.data; if (item is StrategyItem) { ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: widget.folder.id, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); + strategyID: item.strategyId, + parentID: widget.folder.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); } else if (item is FolderItem) { ref.read(folderProvider.notifier).moveToFolder( - folderID: item.folder.id, - parentID: widget.folder.id, - workspace: ref.read(libraryWorkspaceProvider), - ); + folderID: item.folder.id, + parentID: widget.folder.id, + workspace: ref.read(libraryWorkspaceProvider), + ); } }, builder: (context, candidateData, rejectedData) { @@ -207,16 +226,33 @@ class _FolderPillState extends ConsumerState ShadContextMenuItem( leading: const Icon(Icons.text_fields), child: const Text('Edit'), - onPressed: () async { - if (widget.isDemo) return; - await showDialog( - context: context, - builder: (context) { - return FolderEditDialog(folder: widget.folder); - }, - ); - }, + onPressed: !_canManageCloudFolder + ? null + : () async { + if (widget.isDemo) return; + await showDialog( + context: context, + builder: (context) { + return FolderEditDialog(folder: widget.folder); + }, + ); + }, ), + if (_isCloudWorkspace && _cloudRole == 'owner') + ShadContextMenuItem( + leading: const Icon(LucideIcons.link2), + child: const Text('Share'), + onPressed: () async { + await showShadDialog( + context: context, + builder: (_) => ShareLinksDialog( + targetType: 'folder', + targetPublicId: widget.folder.id, + title: widget.folder.name, + ), + ); + }, + ), ShadContextMenuItem( leading: const Icon(Icons.file_upload), child: const Text('Export'), @@ -227,25 +263,27 @@ class _FolderPillState extends ConsumerState ShadContextMenuItem( leading: const Icon(Icons.delete, color: Colors.redAccent), child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), - onPressed: () async { - ConfirmAlertDialog.show( - context: context, - title: - "Are you sure you want to delete '${widget.folder.name}' folder?", - content: - "This will also delete all strategies and subfolders within it.", - confirmText: "Delete", - isDestructive: true, - ).then((confirmed) { - if (confirmed) { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).deleteFolder( - widget.folder.id, - workspace: ref.read(libraryWorkspaceProvider), - ); - } - }); - }, + onPressed: !_canManageCloudFolder + ? null + : () async { + ConfirmAlertDialog.show( + context: context, + title: + "Are you sure you want to delete '${widget.folder.name}' folder?", + content: + "This will also delete all strategies and subfolders within it.", + confirmText: "Delete", + isDestructive: true, + ).then((confirmed) { + if (confirmed) { + if (widget.isDemo) return; + ref.read(folderProvider.notifier).deleteFolder( + widget.folder.id, + workspace: ref.read(libraryWorkspaceProvider), + ); + } + }); + }, ), ]; } @@ -293,7 +331,9 @@ class _FolderPillState extends ConsumerState while (currentParentId != null) { if (currentParentId == folderId) return true; final parentFolder = workspace == LibraryWorkspace.local - ? ref.read(folderProvider.notifier).findLocalFolderByID(currentParentId) + ? ref + .read(folderProvider.notifier) + .findLocalFolderByID(currentParentId) : ref.read(folderProvider.notifier).findCloudFolderByID( currentParentId, ref.read(cloudAllFoldersProvider).valueOrNull ?? const [], @@ -303,3 +343,7 @@ class _FolderPillState extends ConsumerState return false; } } + +extension on Iterable { + String? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 2da72e8b..c552909d 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -12,6 +12,7 @@ import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/strategy/rename_strategy_dialog.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -56,6 +57,7 @@ class _StrategyTileState extends ConsumerState { ShadContextMenuController(); bool get _isCloud => widget.cloudStrategy != null; + bool get _canShare => _isCloud && widget.cloudStrategy?.role == 'owner'; String get _strategyId => widget.strategyData?.id ?? widget.cloudStrategy!.publicId; String get _strategyName => @@ -167,6 +169,12 @@ class _StrategyTileState extends ConsumerState { onPressed: () => _exportStrategy(), child: const Text('Export'), ), + if (_canShare) + ShadContextMenuItem( + leading: const Icon(LucideIcons.link2), + onPressed: _showShareDialog, + child: const Text('Share'), + ), ShadContextMenuItem( leading: const Icon(LucideIcons.trash2, color: Colors.redAccent), onPressed: widget.canDelete ? () => _showDeleteDialog() : null, @@ -185,7 +193,9 @@ class _StrategyTileState extends ConsumerState { try { if (_isCloud) { - await ref.read(strategyProvider.notifier).openCloudStrategy(_strategyId); + await ref + .read(strategyProvider.notifier) + .openCloudStrategy(_strategyId); } else { await ref.read(strategyProvider.notifier).loadFromHive(_strategyId); } @@ -266,6 +276,17 @@ class _StrategyTileState extends ConsumerState { ); } + Future _showShareDialog() async { + await showShadDialog( + context: context, + builder: (_) => ShareLinksDialog( + targetType: 'strategy', + targetPublicId: _strategyId, + title: _strategyName, + ), + ); + } + void _showDeleteDialog() { showDialog( context: context, From 34054d2844c43b9f6de3eb667efd6f466c778c3e Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:45:49 -0400 Subject: [PATCH 05/15] Add Convex skill docs for components and migrations - Add shared skill definitions and references under `.agents/`, `.claude/`, and `.windsurf/` - Update generated Convex AI guidance files --- .../skills/convex-create-component/SKILL.md | 288 ++++++++++++++ .../agents/openai.yaml | 10 + .../convex-create-component/assets/icon.svg | 3 + .../references/advanced-patterns.md | 134 +++++++ .../references/hybrid-components.md | 37 ++ .../references/local-components.md | 38 ++ .../references/packaged-components.md | 51 +++ .../skills/convex-migration-helper/SKILL.md | 149 +++++++ .../agents/openai.yaml | 10 + .../convex-migration-helper/assets/icon.svg | 3 + .../references/migration-patterns.md | 231 +++++++++++ .../references/migrations-component.md | 169 ++++++++ .../skills/convex-performance-audit/SKILL.md | 143 +++++++ .../agents/openai.yaml | 10 + .../convex-performance-audit/assets/icon.svg | 3 + .../references/function-budget.md | 232 +++++++++++ .../references/hot-path-rules.md | 369 ++++++++++++++++++ .../references/occ-conflicts.md | 114 ++++++ .../references/subscription-cost.md | 252 ++++++++++++ .agents/skills/convex-quickstart/SKILL.md | 347 ++++++++++++++++ .../convex-quickstart/agents/openai.yaml | 10 + .../skills/convex-quickstart/assets/icon.svg | 4 + .agents/skills/convex-setup-auth/SKILL.md | 150 +++++++ .../convex-setup-auth/agents/openai.yaml | 10 + .../skills/convex-setup-auth/assets/icon.svg | 3 + .../convex-setup-auth/references/auth0.md | 116 ++++++ .../convex-setup-auth/references/clerk.md | 113 ++++++ .../references/convex-auth.md | 143 +++++++ .../references/workos-authkit.md | 114 ++++++ .claude/skills/convex-create-component | 1 - .../skills/convex-create-component/SKILL.md | 288 ++++++++++++++ .../agents/openai.yaml | 10 + .../convex-create-component/assets/icon.svg | 3 + .../references/advanced-patterns.md | 134 +++++++ .../references/hybrid-components.md | 37 ++ .../references/local-components.md | 38 ++ .../references/packaged-components.md | 51 +++ .claude/skills/convex-migration-helper | 1 - .../skills/convex-migration-helper/SKILL.md | 149 +++++++ .../agents/openai.yaml | 10 + .../convex-migration-helper/assets/icon.svg | 3 + .../references/migration-patterns.md | 231 +++++++++++ .../references/migrations-component.md | 169 ++++++++ .claude/skills/convex-performance-audit | 1 - .../skills/convex-performance-audit/SKILL.md | 143 +++++++ .../agents/openai.yaml | 10 + .../convex-performance-audit/assets/icon.svg | 3 + .../references/function-budget.md | 232 +++++++++++ .../references/hot-path-rules.md | 369 ++++++++++++++++++ .../references/occ-conflicts.md | 114 ++++++ .../references/subscription-cost.md | 252 ++++++++++++ .claude/skills/convex-quickstart | 1 - .claude/skills/convex-quickstart/SKILL.md | 347 ++++++++++++++++ .../convex-quickstart/agents/openai.yaml | 10 + .../skills/convex-quickstart/assets/icon.svg | 4 + .claude/skills/convex-setup-auth | 1 - .claude/skills/convex-setup-auth/SKILL.md | 150 +++++++ .../convex-setup-auth/agents/openai.yaml | 10 + .../skills/convex-setup-auth/assets/icon.svg | 3 + .../convex-setup-auth/references/auth0.md | 116 ++++++ .../convex-setup-auth/references/clerk.md | 113 ++++++ .../references/convex-auth.md | 143 +++++++ .../references/workos-authkit.md | 114 ++++++ .windsurf/skills/convex-create-component | 1 - .../skills/convex-create-component/SKILL.md | 288 ++++++++++++++ .../agents/openai.yaml | 10 + .../convex-create-component/assets/icon.svg | 3 + .../references/advanced-patterns.md | 134 +++++++ .../references/hybrid-components.md | 37 ++ .../references/local-components.md | 38 ++ .../references/packaged-components.md | 51 +++ .windsurf/skills/convex-migration-helper | 1 - .../skills/convex-migration-helper/SKILL.md | 149 +++++++ .../agents/openai.yaml | 10 + .../convex-migration-helper/assets/icon.svg | 3 + .../references/migration-patterns.md | 231 +++++++++++ .../references/migrations-component.md | 169 ++++++++ .windsurf/skills/convex-performance-audit | 1 - .../skills/convex-performance-audit/SKILL.md | 143 +++++++ .../agents/openai.yaml | 10 + .../convex-performance-audit/assets/icon.svg | 3 + .../references/function-budget.md | 232 +++++++++++ .../references/hot-path-rules.md | 369 ++++++++++++++++++ .../references/occ-conflicts.md | 114 ++++++ .../references/subscription-cost.md | 252 ++++++++++++ .windsurf/skills/convex-quickstart | 1 - .windsurf/skills/convex-quickstart/SKILL.md | 347 ++++++++++++++++ .../convex-quickstart/agents/openai.yaml | 10 + .../skills/convex-quickstart/assets/icon.svg | 4 + .windsurf/skills/convex-setup-auth | 1 - .windsurf/skills/convex-setup-auth/SKILL.md | 150 +++++++ .../convex-setup-auth/agents/openai.yaml | 10 + .../skills/convex-setup-auth/assets/icon.svg | 3 + .../convex-setup-auth/references/auth0.md | 116 ++++++ .../convex-setup-auth/references/clerk.md | 113 ++++++ .../references/convex-auth.md | 143 +++++++ .../references/workos-authkit.md | 114 ++++++ convex/_generated/ai/ai-files.state.json | 4 +- convex/_generated/ai/guidelines.md | 177 +++++---- convex/images.ts | 160 +------- convex/ops.ts | 2 - convex/pages.ts | 3 - convex/schema.ts | 11 +- convex/strategies.ts | 6 +- lib/collab/cloud_media_models.dart | 28 +- lib/collab/collab_models.dart | 16 - lib/collab/convex_strategy_repository.dart | 7 - lib/hive/hive_adapters.dart | 1 - lib/hive/hive_adapters.g.dart | 50 +-- lib/hive/hive_adapters.g.yaml | 14 - lib/hive/hive_registrar.g.dart | 2 - .../cloud_media_upload_queue_provider.dart | 80 +--- .../collab/strategy_op_queue_provider.dart | 174 +++++---- lib/providers/image_provider.dart | 11 +- lib/strategy/strategy_page_source.dart | 85 +++- lib/widgets/dialogs/create_lineup_dialog.dart | 17 +- skills-lock.json | 10 +- skills/convex-create-component/SKILL.md | 288 ++++++++++++++ .../agents/openai.yaml | 10 + .../convex-create-component/assets/icon.svg | 3 + .../references/advanced-patterns.md | 134 +++++++ .../references/hybrid-components.md | 37 ++ .../references/local-components.md | 38 ++ .../references/packaged-components.md | 51 +++ skills/convex-migration-helper/SKILL.md | 149 +++++++ .../agents/openai.yaml | 10 + .../convex-migration-helper/assets/icon.svg | 3 + .../references/migration-patterns.md | 231 +++++++++++ .../references/migrations-component.md | 169 ++++++++ skills/convex-performance-audit/SKILL.md | 143 +++++++ .../agents/openai.yaml | 10 + .../convex-performance-audit/assets/icon.svg | 3 + .../references/function-budget.md | 232 +++++++++++ .../references/hot-path-rules.md | 369 ++++++++++++++++++ .../references/occ-conflicts.md | 114 ++++++ .../references/subscription-cost.md | 252 ++++++++++++ skills/convex-quickstart/SKILL.md | 347 ++++++++++++++++ skills/convex-quickstart/agents/openai.yaml | 10 + skills/convex-quickstart/assets/icon.svg | 4 + skills/convex-setup-auth/SKILL.md | 150 +++++++ skills/convex-setup-auth/agents/openai.yaml | 10 + skills/convex-setup-auth/assets/icon.svg | 3 + skills/convex-setup-auth/references/auth0.md | 116 ++++++ skills/convex-setup-auth/references/clerk.md | 113 ++++++ .../references/convex-auth.md | 143 +++++++ .../references/workos-authkit.md | 114 ++++++ test/strategy_page_session_provider_test.dart | 59 ++- 147 files changed, 13423 insertions(+), 528 deletions(-) create mode 100644 .agents/skills/convex-create-component/SKILL.md create mode 100644 .agents/skills/convex-create-component/agents/openai.yaml create mode 100644 .agents/skills/convex-create-component/assets/icon.svg create mode 100644 .agents/skills/convex-create-component/references/advanced-patterns.md create mode 100644 .agents/skills/convex-create-component/references/hybrid-components.md create mode 100644 .agents/skills/convex-create-component/references/local-components.md create mode 100644 .agents/skills/convex-create-component/references/packaged-components.md create mode 100644 .agents/skills/convex-migration-helper/SKILL.md create mode 100644 .agents/skills/convex-migration-helper/agents/openai.yaml create mode 100644 .agents/skills/convex-migration-helper/assets/icon.svg create mode 100644 .agents/skills/convex-migration-helper/references/migration-patterns.md create mode 100644 .agents/skills/convex-migration-helper/references/migrations-component.md create mode 100644 .agents/skills/convex-performance-audit/SKILL.md create mode 100644 .agents/skills/convex-performance-audit/agents/openai.yaml create mode 100644 .agents/skills/convex-performance-audit/assets/icon.svg create mode 100644 .agents/skills/convex-performance-audit/references/function-budget.md create mode 100644 .agents/skills/convex-performance-audit/references/hot-path-rules.md create mode 100644 .agents/skills/convex-performance-audit/references/occ-conflicts.md create mode 100644 .agents/skills/convex-performance-audit/references/subscription-cost.md create mode 100644 .agents/skills/convex-quickstart/SKILL.md create mode 100644 .agents/skills/convex-quickstart/agents/openai.yaml create mode 100644 .agents/skills/convex-quickstart/assets/icon.svg create mode 100644 .agents/skills/convex-setup-auth/SKILL.md create mode 100644 .agents/skills/convex-setup-auth/agents/openai.yaml create mode 100644 .agents/skills/convex-setup-auth/assets/icon.svg create mode 100644 .agents/skills/convex-setup-auth/references/auth0.md create mode 100644 .agents/skills/convex-setup-auth/references/clerk.md create mode 100644 .agents/skills/convex-setup-auth/references/convex-auth.md create mode 100644 .agents/skills/convex-setup-auth/references/workos-authkit.md delete mode 120000 .claude/skills/convex-create-component create mode 100644 .claude/skills/convex-create-component/SKILL.md create mode 100644 .claude/skills/convex-create-component/agents/openai.yaml create mode 100644 .claude/skills/convex-create-component/assets/icon.svg create mode 100644 .claude/skills/convex-create-component/references/advanced-patterns.md create mode 100644 .claude/skills/convex-create-component/references/hybrid-components.md create mode 100644 .claude/skills/convex-create-component/references/local-components.md create mode 100644 .claude/skills/convex-create-component/references/packaged-components.md delete mode 120000 .claude/skills/convex-migration-helper create mode 100644 .claude/skills/convex-migration-helper/SKILL.md create mode 100644 .claude/skills/convex-migration-helper/agents/openai.yaml create mode 100644 .claude/skills/convex-migration-helper/assets/icon.svg create mode 100644 .claude/skills/convex-migration-helper/references/migration-patterns.md create mode 100644 .claude/skills/convex-migration-helper/references/migrations-component.md delete mode 120000 .claude/skills/convex-performance-audit create mode 100644 .claude/skills/convex-performance-audit/SKILL.md create mode 100644 .claude/skills/convex-performance-audit/agents/openai.yaml create mode 100644 .claude/skills/convex-performance-audit/assets/icon.svg create mode 100644 .claude/skills/convex-performance-audit/references/function-budget.md create mode 100644 .claude/skills/convex-performance-audit/references/hot-path-rules.md create mode 100644 .claude/skills/convex-performance-audit/references/occ-conflicts.md create mode 100644 .claude/skills/convex-performance-audit/references/subscription-cost.md delete mode 120000 .claude/skills/convex-quickstart create mode 100644 .claude/skills/convex-quickstart/SKILL.md create mode 100644 .claude/skills/convex-quickstart/agents/openai.yaml create mode 100644 .claude/skills/convex-quickstart/assets/icon.svg delete mode 120000 .claude/skills/convex-setup-auth create mode 100644 .claude/skills/convex-setup-auth/SKILL.md create mode 100644 .claude/skills/convex-setup-auth/agents/openai.yaml create mode 100644 .claude/skills/convex-setup-auth/assets/icon.svg create mode 100644 .claude/skills/convex-setup-auth/references/auth0.md create mode 100644 .claude/skills/convex-setup-auth/references/clerk.md create mode 100644 .claude/skills/convex-setup-auth/references/convex-auth.md create mode 100644 .claude/skills/convex-setup-auth/references/workos-authkit.md delete mode 120000 .windsurf/skills/convex-create-component create mode 100644 .windsurf/skills/convex-create-component/SKILL.md create mode 100644 .windsurf/skills/convex-create-component/agents/openai.yaml create mode 100644 .windsurf/skills/convex-create-component/assets/icon.svg create mode 100644 .windsurf/skills/convex-create-component/references/advanced-patterns.md create mode 100644 .windsurf/skills/convex-create-component/references/hybrid-components.md create mode 100644 .windsurf/skills/convex-create-component/references/local-components.md create mode 100644 .windsurf/skills/convex-create-component/references/packaged-components.md delete mode 120000 .windsurf/skills/convex-migration-helper create mode 100644 .windsurf/skills/convex-migration-helper/SKILL.md create mode 100644 .windsurf/skills/convex-migration-helper/agents/openai.yaml create mode 100644 .windsurf/skills/convex-migration-helper/assets/icon.svg create mode 100644 .windsurf/skills/convex-migration-helper/references/migration-patterns.md create mode 100644 .windsurf/skills/convex-migration-helper/references/migrations-component.md delete mode 120000 .windsurf/skills/convex-performance-audit create mode 100644 .windsurf/skills/convex-performance-audit/SKILL.md create mode 100644 .windsurf/skills/convex-performance-audit/agents/openai.yaml create mode 100644 .windsurf/skills/convex-performance-audit/assets/icon.svg create mode 100644 .windsurf/skills/convex-performance-audit/references/function-budget.md create mode 100644 .windsurf/skills/convex-performance-audit/references/hot-path-rules.md create mode 100644 .windsurf/skills/convex-performance-audit/references/occ-conflicts.md create mode 100644 .windsurf/skills/convex-performance-audit/references/subscription-cost.md delete mode 120000 .windsurf/skills/convex-quickstart create mode 100644 .windsurf/skills/convex-quickstart/SKILL.md create mode 100644 .windsurf/skills/convex-quickstart/agents/openai.yaml create mode 100644 .windsurf/skills/convex-quickstart/assets/icon.svg delete mode 120000 .windsurf/skills/convex-setup-auth create mode 100644 .windsurf/skills/convex-setup-auth/SKILL.md create mode 100644 .windsurf/skills/convex-setup-auth/agents/openai.yaml create mode 100644 .windsurf/skills/convex-setup-auth/assets/icon.svg create mode 100644 .windsurf/skills/convex-setup-auth/references/auth0.md create mode 100644 .windsurf/skills/convex-setup-auth/references/clerk.md create mode 100644 .windsurf/skills/convex-setup-auth/references/convex-auth.md create mode 100644 .windsurf/skills/convex-setup-auth/references/workos-authkit.md create mode 100644 skills/convex-create-component/SKILL.md create mode 100644 skills/convex-create-component/agents/openai.yaml create mode 100644 skills/convex-create-component/assets/icon.svg create mode 100644 skills/convex-create-component/references/advanced-patterns.md create mode 100644 skills/convex-create-component/references/hybrid-components.md create mode 100644 skills/convex-create-component/references/local-components.md create mode 100644 skills/convex-create-component/references/packaged-components.md create mode 100644 skills/convex-migration-helper/SKILL.md create mode 100644 skills/convex-migration-helper/agents/openai.yaml create mode 100644 skills/convex-migration-helper/assets/icon.svg create mode 100644 skills/convex-migration-helper/references/migration-patterns.md create mode 100644 skills/convex-migration-helper/references/migrations-component.md create mode 100644 skills/convex-performance-audit/SKILL.md create mode 100644 skills/convex-performance-audit/agents/openai.yaml create mode 100644 skills/convex-performance-audit/assets/icon.svg create mode 100644 skills/convex-performance-audit/references/function-budget.md create mode 100644 skills/convex-performance-audit/references/hot-path-rules.md create mode 100644 skills/convex-performance-audit/references/occ-conflicts.md create mode 100644 skills/convex-performance-audit/references/subscription-cost.md create mode 100644 skills/convex-quickstart/SKILL.md create mode 100644 skills/convex-quickstart/agents/openai.yaml create mode 100644 skills/convex-quickstart/assets/icon.svg create mode 100644 skills/convex-setup-auth/SKILL.md create mode 100644 skills/convex-setup-auth/agents/openai.yaml create mode 100644 skills/convex-setup-auth/assets/icon.svg create mode 100644 skills/convex-setup-auth/references/auth0.md create mode 100644 skills/convex-setup-auth/references/clerk.md create mode 100644 skills/convex-setup-auth/references/convex-auth.md create mode 100644 skills/convex-setup-auth/references/workos-authkit.md diff --git a/.agents/skills/convex-create-component/SKILL.md b/.agents/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..22af601f --- /dev/null +++ b/.agents/skills/convex-create-component/SKILL.md @@ -0,0 +1,288 @@ +--- +name: convex-create-component +description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +--- + +# Convex Create Component + +Create reusable Convex components with clear boundaries and a small app-facing API. + +## When to Use + +- Creating a new Convex component in an existing app +- Extracting reusable backend logic into a component +- Building a third-party integration that should own its own tables and workflows +- Packaging Convex functionality for reuse across multiple apps + +## When Not to Use + +- One-off business logic that belongs in the main app +- Thin utilities that do not need Convex tables or functions +- App-level orchestration that should stay in `convex/` +- Cases where a normal TypeScript library is enough + +## Workflow + +1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. +2. Choose the shape using the decision tree below and read the matching reference file. +3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. +4. Make a short plan for: + - what tables the component owns + - what public functions it exposes + - what data must be passed in from the app (auth, env vars, parent IDs) + - what stays in the app as wrappers or HTTP mounts +5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. +6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. +7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. +8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. +9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. +10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. + +## Choose the Shape + +Ask the user, then pick one path: + +| Goal | Shape | Reference | +| ------------------------------------------------- | ---------------- | ----------------------------------- | +| Component for this app only | Local | `references/local-components.md` | +| Publish or share across apps | Packaged | `references/packaged-components.md` | +| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | +| Not sure | Default to local | `references/local-components.md` | + +Read exactly one reference file before proceeding. + +## Default Approach + +Unless the user explicitly wants an npm package, default to a local component: + +- Put it under `convex/components//` +- Define it with `defineComponent(...)` in its own `convex.config.ts` +- Install it from the app's `convex/convex.config.ts` with `app.use(...)` +- Let `npx convex dev` generate the component's own `_generated/` files + +## Component Skeleton + +A minimal local component with a table and two functions, plus the app wiring. + +```ts +// convex/components/notifications/convex.config.ts +import { defineComponent } from "convex/server"; + +export default defineComponent("notifications"); +``` + +```ts +// convex/components/notifications/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + notifications: defineTable({ + userId: v.string(), + message: v.string(), + read: v.boolean(), + }).index("by_user", ["userId"]), +}); +``` + +```ts +// convex/components/notifications/lib.ts +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; + +export const send = mutation({ + args: { userId: v.string(), message: v.string() }, + returns: v.id("notifications"), + handler: async (ctx, args) => { + return await ctx.db.insert("notifications", { + userId: args.userId, + message: args.message, + read: false, + }); + }, +}); + +export const listUnread = query({ + args: { userId: v.string() }, + returns: v.array( + v.object({ + _id: v.id("notifications"), + _creationTime: v.number(), + userId: v.string(), + message: v.string(), + read: v.boolean(), + }), + ), + handler: async (ctx, args) => { + return await ctx.db + .query("notifications") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .filter((q) => q.eq(q.field("read"), false)) + .collect(); + }, +}); +``` + +```ts +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import notifications from "./components/notifications/convex.config.js"; + +const app = defineApp(); +app.use(notifications); + +export default app; +``` + +```ts +// convex/notifications.ts (app-side wrapper) +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { components } from "./_generated/api"; +import { getAuthUserId } from "@convex-dev/auth/server"; + +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); + +export const myUnread = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + return await ctx.runQuery(components.notifications.lib.listUnread, { + userId, + }); + }, +}); +``` + +Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. + +## Critical Rules + +- Keep authentication in the app, because `ctx.auth` is not available inside components. +- Keep environment access in the app, because component functions cannot read `process.env`. +- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. +- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. +- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. +- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. +- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. +- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. +- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. + +## Patterns + +### Authentication and environment access + +```ts +// Bad: component code cannot rely on app auth or env +const identity = await ctx.auth.getUserIdentity(); +const apiKey = process.env.OPENAI_API_KEY; +``` + +```ts +// Good: the app resolves auth and env, then passes explicit values +const userId = await getAuthUserId(ctx); +if (!userId) throw new Error("Not authenticated"); + +await ctx.runAction(components.translator.translate, { + userId, + apiKey: process.env.OPENAI_API_KEY, + text: args.text, +}); +``` + +### Client-facing API + +```ts +// Bad: assuming a component function is directly callable by clients +export const send = components.notifications.send; +``` + +```ts +// Good: re-export through an app mutation or query +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); +``` + +### IDs across the boundary + +```ts +// Bad: parent app table IDs are not valid component validators +args: { + userId: v.id("users"); +} +``` + +```ts +// Good: treat parent-owned IDs as strings at the boundary +args: { + userId: v.string(); +} +``` + +### Advanced Patterns + +For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. + +## Validation + +Try validation in this order: + +1. `npx convex codegen --component-dir convex/components/` +2. `npx convex codegen` +3. `npx convex dev` + +Important: + +- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. +- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. +- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. + +## Reference Files + +Read exactly one of these after the user confirms the goal: + +- `references/local-components.md` +- `references/packaged-components.md` +- `references/hybrid-components.md` + +Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) + +## Checklist + +- [ ] Asked the user what they want to build and confirmed the shape +- [ ] Read the matching reference file +- [ ] Confirmed a component is the right abstraction +- [ ] Planned tables, public API, boundaries, and app wrappers +- [ ] Component lives under `convex/components//` (or package layout if publishing) +- [ ] Component imports from its own `./_generated/server` +- [ ] Auth, env access, and HTTP routes stay in the app +- [ ] Parent app IDs cross the boundary as `v.string()` +- [ ] Public functions have `args` and `returns` validators +- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/.agents/skills/convex-create-component/agents/openai.yaml b/.agents/skills/convex-create-component/agents/openai.yaml new file mode 100644 index 00000000..ba9287e4 --- /dev/null +++ b/.agents/skills/convex-create-component/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Create Component" + short_description: "Design and build reusable Convex components with clear boundaries." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#14B8A6" + default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-create-component/assets/icon.svg b/.agents/skills/convex-create-component/assets/icon.svg new file mode 100644 index 00000000..10f4c2c4 --- /dev/null +++ b/.agents/skills/convex-create-component/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agents/skills/convex-create-component/references/advanced-patterns.md b/.agents/skills/convex-create-component/references/advanced-patterns.md new file mode 100644 index 00000000..3deb684c --- /dev/null +++ b/.agents/skills/convex-create-component/references/advanced-patterns.md @@ -0,0 +1,134 @@ +# Advanced Component Patterns + +Additional patterns for Convex components that go beyond the basics covered in the main skill file. + +## Function Handles for callbacks + +When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. + +```ts +// App side: create a handle and pass it to the component +import { createFunctionHandle } from "convex/server"; + +export const startJob = mutation({ + handler: async (ctx) => { + const handle = await createFunctionHandle(internal.myModule.processItem); + await ctx.runMutation(components.workpool.enqueue, { + callback: handle, + }); + }, +}); +``` + +```ts +// Component side: accept and invoke the handle +import { v } from "convex/values"; +import type { FunctionHandle } from "convex/server"; +import { mutation } from "./_generated/server.js"; + +export const enqueue = mutation({ + args: { callback: v.string() }, + handler: async (ctx, args) => { + const handle = args.callback as FunctionHandle<"mutation">; + await ctx.scheduler.runAfter(0, handle, {}); + }, +}); +``` + +## Deriving validators from schema + +Instead of manually repeating field types in return validators, extend the schema validator: + +```ts +import { v } from "convex/values"; +import schema from "./schema.js"; + +const notificationDoc = schema.tables.notifications.validator.extend({ + _id: v.id("notifications"), + _creationTime: v.number(), +}); + +export const getLatest = query({ + args: {}, + returns: v.nullable(notificationDoc), + handler: async (ctx) => { + return await ctx.db.query("notifications").order("desc").first(); + }, +}); +``` + +## Static configuration with a globals table + +A common pattern for component configuration is a single-document "globals" table: + +```ts +// schema.ts +export default defineSchema({ + globals: defineTable({ + maxRetries: v.number(), + webhookUrl: v.optional(v.string()), + }), + // ... other tables +}); +``` + +```ts +// lib.ts +export const configure = mutation({ + args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.query("globals").first(); + if (existing) { + await ctx.db.patch(existing._id, args); + } else { + await ctx.db.insert("globals", args); + } + return null; + }, +}); +``` + +## Class-based client wrappers + +For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. + +```ts +// src/client/index.ts +import type { GenericMutationCtx, GenericDataModel } from "convex/server"; +import type { ComponentApi } from "../component/_generated/component.js"; + +type MutationCtx = Pick, "runMutation">; + +export class Notifications { + constructor( + private component: ComponentApi, + private options?: { defaultChannel?: string }, + ) {} + + async send(ctx: MutationCtx, args: { userId: string; message: string }) { + return await ctx.runMutation(this.component.lib.send, { + ...args, + channel: this.options?.defaultChannel ?? "default", + }); + } +} +``` + +```ts +// App usage +import { Notifications } from "@convex-dev/notifications"; +import { components } from "./_generated/api"; + +const notifications = new Notifications(components.notifications, { + defaultChannel: "alerts", +}); + +export const send = mutation({ + args: { message: v.string() }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + await notifications.send(ctx, { userId, message: args.message }); + }, +}); +``` diff --git a/.agents/skills/convex-create-component/references/hybrid-components.md b/.agents/skills/convex-create-component/references/hybrid-components.md new file mode 100644 index 00000000..d2bb3514 --- /dev/null +++ b/.agents/skills/convex-create-component/references/hybrid-components.md @@ -0,0 +1,37 @@ +# Hybrid Convex Components + +Read this file only when the user explicitly wants a hybrid setup. + +## What This Means + +A hybrid component combines a local Convex component with shared library code. + +This can help when: + +- the user wants a local install but also shared package logic +- the component needs extension points or override hooks +- some logic should live in normal TypeScript code outside the component boundary + +## Default Advice + +Treat hybrid as an advanced option, not the default. + +Before choosing it, ask: + +- Why is a plain local component not enough? +- Why is a packaged component not enough? +- What exactly needs to stay overridable or shared? + +If the answer is vague, fall back to local or packaged. + +## Risks + +- More moving parts +- Harder upgrades and backwards compatibility +- Easier to blur the component boundary + +## Checklist + +- [ ] User explicitly needs hybrid behavior +- [ ] Local-only and packaged-only options were considered first +- [ ] The extension points are clearly defined before coding diff --git a/.agents/skills/convex-create-component/references/local-components.md b/.agents/skills/convex-create-component/references/local-components.md new file mode 100644 index 00000000..7fbfe21a --- /dev/null +++ b/.agents/skills/convex-create-component/references/local-components.md @@ -0,0 +1,38 @@ +# Local Convex Components + +Read this file when the component should live inside the current app and does not need to be published as an npm package. + +## When to Choose This + +- The user wants the simplest path +- The component only needs to work in this repo +- The goal is extracting app logic into a cleaner boundary + +## Default Layout + +Use this structure unless the repo already has a clear alternative pattern: + +```text +convex/ + convex.config.ts + components/ + / + convex.config.ts + schema.ts + .ts +``` + +## Workflow Notes + +- Define the component with `defineComponent("")` +- Install it from the app with `defineApp()` and `app.use(...)` +- Keep auth, env access, public API wrappers, and HTTP route mounting in the app +- Let the component own isolated tables and reusable backend workflows +- Add app wrappers if clients need to call into the component + +## Checklist + +- [ ] Component is inside `convex/components//` +- [ ] App installs it with `app.use(...)` +- [ ] Component owns only its own tables +- [ ] App wrappers handle client-facing calls when needed diff --git a/.agents/skills/convex-create-component/references/packaged-components.md b/.agents/skills/convex-create-component/references/packaged-components.md new file mode 100644 index 00000000..5668e7ed --- /dev/null +++ b/.agents/skills/convex-create-component/references/packaged-components.md @@ -0,0 +1,51 @@ +# Packaged Convex Components + +Read this file when the user wants a reusable npm package or a component shared across multiple apps. + +## When to Choose This + +- The user wants to publish the component +- The user wants a stable reusable package boundary +- The component will be shared across multiple apps or teams + +## Default Approach + +- Prefer starting from `npx create-convex@latest --component` when possible +- Keep the official authoring docs as the source of truth for package layout and exports +- Validate the bundled package through an example app, not just the source files + +## Build Flow + +When building a packaged component, make sure the bundled output exists before the example app tries to consume it. + +Recommended order: + +1. `npx convex codegen --component-dir ./path/to/component` +2. Run the package build command +3. Run `npx convex dev --typecheck-components` in the example app + +Do not assume normal app codegen is enough for packaged component workflows. + +## Package Exports + +If publishing to npm, make sure the package exposes the entry points apps need: + +- package root for client helpers, types, or classes +- `./convex.config.js` for installing the component +- `./_generated/component.js` for the app-facing `ComponentApi` type +- `./test` for testing helpers when applicable + +## Testing + +- Use `convex-test` for component logic +- Register the component schema and modules with the test instance +- Test app-side wrapper code from an example app that installs the package +- Export a small helper from `./test` if consumers need easy test registration + +## Checklist + +- [ ] Packaging is actually required +- [ ] Build order avoids bundle and codegen races +- [ ] Package exports include install and typing entry points +- [ ] Example app exercises the packaged component +- [ ] Core behavior is covered by tests diff --git a/.agents/skills/convex-migration-helper/SKILL.md b/.agents/skills/convex-migration-helper/SKILL.md new file mode 100644 index 00000000..db36c622 --- /dev/null +++ b/.agents/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,149 @@ +--- +name: convex-migration-helper +description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +--- + +# Convex Migration Helper + +Safely migrate Convex schemas and data when making breaking changes. + +## When to Use + +- Adding new required fields to existing tables +- Changing field types or structure +- Splitting or merging tables +- Renaming or deleting fields +- Migrating from nested to relational data + +## When Not to Use + +- Greenfield schema with no existing data in production or dev +- Adding optional fields that do not need backfilling +- Adding new tables with no existing data to migrate +- Adding or removing indexes with no correctness concern +- Questions about Convex schema design without a migration need + +## Key Concepts + +### Schema Validation Drives the Workflow + +Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: + +- You cannot add a required field if existing documents don't have it +- You cannot change a field's type if existing documents have the old type +- You cannot remove a field from the schema if existing documents still have it + +This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. + +### Online Migrations + +Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. + +### Prefer New Fields Over Changing Types + +When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. + +### Don't Delete Data + +Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. + +## Safe Changes (No Migration Needed) + +### Adding Optional Field + +```typescript +// Before +users: defineTable({ + name: v.string(), +}); + +// After - safe, new field is optional +users: defineTable({ + name: v.string(), + bio: v.optional(v.string()), +}); +``` + +### Adding New Table + +```typescript +posts: defineTable({ + userId: v.id("users"), + title: v.string(), +}).index("by_user", ["userId"]); +``` + +### Adding Index + +```typescript +users: defineTable({ + name: v.string(), + email: v.string(), +}).index("by_email", ["email"]); +``` + +## Breaking Changes: The Deployment Workflow + +Every breaking migration follows the same multi-deploy pattern: + +**Deploy 1 - Widen the schema:** + +1. Update schema to allow both old and new formats (e.g., add optional new field) +2. Update code to handle both formats when reading +3. Update code to write the new format for new documents +4. Deploy + +**Between deploys - Migrate data:** + +5. Run migration to backfill existing documents +6. Verify all documents are migrated + +**Deploy 2 - Narrow the schema:** + +7. Update schema to require the new format only +8. Remove code that handles the old format +9. Deploy + +## Using the Migrations Component + +For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. + +See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. + +## Common Migration Patterns + +See `references/migration-patterns.md` for complete patterns with code examples covering: + +- Adding a required field +- Deleting a field +- Changing a field type +- Splitting nested data into a separate table +- Cleaning up orphaned documents +- Zero-downtime strategies (dual write, dual read) +- Small table shortcut (single internalMutation without the component) +- Verifying a migration is complete + +## Common Pitfalls + +1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. +2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. +3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." +4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. +5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. +6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. + +## Migration Checklist + +- [ ] Identify the breaking change and plan the multi-deploy workflow +- [ ] Update schema to allow both old and new formats +- [ ] Update code to handle both formats when reading +- [ ] Update code to write the new format for new documents +- [ ] Deploy widened schema and updated code +- [ ] Define migration using the `@convex-dev/migrations` component +- [ ] Test with `dryRun: true` +- [ ] Run migration and monitor status +- [ ] Verify all documents are migrated +- [ ] Update schema to require new format only +- [ ] Clean up code that handled old format +- [ ] Deploy final schema and code +- [ ] Remove migration code once confirmed stable diff --git a/.agents/skills/convex-migration-helper/agents/openai.yaml b/.agents/skills/convex-migration-helper/agents/openai.yaml new file mode 100644 index 00000000..c2a7fcc5 --- /dev/null +++ b/.agents/skills/convex-migration-helper/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Migration Helper" + short_description: "Plan and run safe Convex schema and data migrations." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#8B5CF6" + default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-migration-helper/assets/icon.svg b/.agents/skills/convex-migration-helper/assets/icon.svg new file mode 100644 index 00000000..fba7241a --- /dev/null +++ b/.agents/skills/convex-migration-helper/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agents/skills/convex-migration-helper/references/migration-patterns.md b/.agents/skills/convex-migration-helper/references/migration-patterns.md new file mode 100644 index 00000000..53b4946f --- /dev/null +++ b/.agents/skills/convex-migration-helper/references/migration-patterns.md @@ -0,0 +1,231 @@ +# Migration Patterns Reference + +Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. + +## Adding a Required Field + +```typescript +// Deploy 1: Schema allows both states +users: defineTable({ + name: v.string(), + role: v.optional(v.union(v.literal("user"), v.literal("admin"))), +}); + +// Migration: backfill the field +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); + +// Deploy 2: After migration completes, make it required +users: defineTable({ + name: v.string(), + role: v.union(v.literal("user"), v.literal("admin")), +}); +``` + +## Deleting a Field + +Mark the field optional first, migrate data to remove it, then remove from schema: + +```typescript +// Deploy 1: Make optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()) + +// Migration +export const removeIsPro = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.isPro !== undefined) { + await ctx.db.patch(team._id, { isPro: undefined }); + } + }, +}); + +// Deploy 2: Remove isPro from schema entirely +``` + +## Changing a Field Type + +Prefer creating a new field. You can combine adding and deleting in one migration: + +```typescript +// Deploy 1: Add new field, keep old field optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) + +// Migration: convert old field to new field +export const convertToEnum = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.plan === undefined) { + await ctx.db.patch(team._id, { + plan: team.isPro ? "pro" : "basic", + isPro: undefined, + }); + } + }, +}); + +// Deploy 2: Remove isPro from schema, make plan required +``` + +## Splitting Nested Data Into a Separate Table + +```typescript +export const extractPreferences = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.preferences === undefined) return; + + const existing = await ctx.db + .query("userPreferences") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!existing) { + await ctx.db.insert("userPreferences", { + userId: user._id, + ...user.preferences, + }); + } + + await ctx.db.patch(user._id, { preferences: undefined }); + }, +}); +``` + +Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. + +## Cleaning Up Orphaned Documents + +```typescript +export const deleteOrphanedEmbeddings = migrations.define({ + table: "embeddings", + migrateOne: async (ctx, doc) => { + const chunk = await ctx.db + .query("chunks") + .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) + .first(); + + if (!chunk) { + await ctx.db.delete(doc._id); + } + }, +}); +``` + +## Zero-Downtime Strategies + +During the migration window, your app must handle both old and new data formats. There are two main strategies. + +### Dual Write (Preferred) + +Write to both old and new structures. Read from the old structure until migration is complete. + +1. Deploy code that writes both formats, reads old format +2. Run migration on existing data +3. Deploy code that reads new format, still writes both +4. Deploy code that only reads and writes new format + +This is preferred because you can safely roll back at any point, the old format is always up to date. + +```typescript +// Bad: only writing to new structure before migration is done +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + await ctx.db.insert("teams", { + name: args.name, + plan: args.isPro ? "pro" : "basic", + }); + }, +}); + +// Good: writing to both structures during migration +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + const plan = args.isPro ? "pro" : "basic"; + await ctx.db.insert("teams", { + name: args.name, + isPro: args.isPro, + plan, + }); + }, +}); +``` + +### Dual Read + +Read both formats. Write only the new format. + +1. Deploy code that reads both formats (preferring new), writes only new format +2. Run migration on existing data +3. Deploy code that reads and writes only new format + +This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. + +```typescript +// Good: reading both formats, preferring new +function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { + if (team.plan !== undefined) return team.plan; + return team.isPro ? "pro" : "basic"; +} +``` + +## Small Table Shortcut + +For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: + +```typescript +import { internalMutation } from "./_generated/server"; + +export const backfillSmallTable = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("smallConfig").collect(); + for (const doc of docs) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: "default" }); + } + } + }, +}); +``` + +```bash +npx convex run migrations:backfillSmallTable +``` + +Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. + +## Verifying a Migration + +Query to check remaining unmigrated documents: + +```typescript +import { query } from "./_generated/server"; + +export const verifyMigration = query({ + handler: async (ctx) => { + const remaining = await ctx.db + .query("users") + .filter((q) => q.eq(q.field("role"), undefined)) + .take(10); + + return { + complete: remaining.length === 0, + sampleRemaining: remaining.map((u) => u._id), + }; + }, +}); +``` + +Or use the component's built-in status monitoring: + +```bash +npx convex run --component migrations lib:getStatus --watch +``` diff --git a/.agents/skills/convex-migration-helper/references/migrations-component.md b/.agents/skills/convex-migration-helper/references/migrations-component.md new file mode 100644 index 00000000..95ec2921 --- /dev/null +++ b/.agents/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,169 @@ +# Migrations Component Reference + +Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. + +## Installation + +```bash +npm install @convex-dev/migrations +``` + +## Setup + +```typescript +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import migrations from "@convex-dev/migrations/convex.config.js"; + +const app = defineApp(); +app.use(migrations); +export default app; +``` + +```typescript +// convex/migrations.ts +import { Migrations } from "@convex-dev/migrations"; +import { components } from "./_generated/api.js"; +import { DataModel } from "./_generated/dataModel.js"; + +export const migrations = new Migrations(components.migrations); +export const run = migrations.runner(); +``` + +The `DataModel` type parameter is optional but provides type safety for migration definitions. + +## Define a Migration + +The `migrateOne` function processes a single document. The component handles batching and pagination automatically. + +```typescript +// convex/migrations.ts +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); +``` + +Shorthand: if you return an object, it is applied as a patch automatically. + +```typescript +export const clearDeprecatedField = migrations.define({ + table: "users", + migrateOne: () => ({ legacyField: undefined }), +}); +``` + +## Run a Migration + +From the CLI: + +```bash +# Define a one-off runner in convex/migrations.ts: +# export const runIt = migrations.runner(internal.migrations.addDefaultRole); +npx convex run migrations:runIt + +# Or use the general-purpose runner +npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' +``` + +Programmatically from another Convex function: + +```typescript +await migrations.runOne(ctx, internal.migrations.addDefaultRole); +``` + +## Run Multiple Migrations in Order + +```typescript +export const runAll = migrations.runner([ + internal.migrations.addDefaultRole, + internal.migrations.clearDeprecatedField, + internal.migrations.normalizeEmails, +]); +``` + +```bash +npx convex run migrations:runAll +``` + +If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. + +## Dry Run + +Test a migration before committing changes: + +```bash +npx convex run migrations:runIt '{"dryRun": true}' +``` + +This runs one batch and then rolls back, so you can see what it would do without changing any data. + +## Check Migration Status + +```bash +npx convex run --component migrations lib:getStatus --watch +``` + +## Cancel a Running Migration + +```bash +npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' +``` + +Or programmatically: + +```typescript +await migrations.cancel(ctx, internal.migrations.addDefaultRole); +``` + +## Run Migrations on Deploy + +Chain migration execution after deploying: + +```bash +npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod +``` + +## Configuration Options + +### Custom Batch Size + +If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: + +```typescript +export const migrateHeavyTable = migrations.define({ + table: "largeDocuments", + batchSize: 10, + migrateOne: async (ctx, doc) => { + // migration logic + }, +}); +``` + +### Migrate a Subset Using an Index + +Process only matching documents instead of the full table: + +```typescript +export const fixEmptyNames = migrations.define({ + table: "users", + customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")), + migrateOne: () => ({ name: "" }), +}); +``` + +### Parallelize Within a Batch + +By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: + +```typescript +export const clearField = migrations.define({ + table: "myTable", + parallelize: true, + migrateOne: () => ({ optionalField: undefined }), +}); +``` diff --git a/.agents/skills/convex-performance-audit/SKILL.md b/.agents/skills/convex-performance-audit/SKILL.md new file mode 100644 index 00000000..382951cf --- /dev/null +++ b/.agents/skills/convex-performance-audit/SKILL.md @@ -0,0 +1,143 @@ +--- +name: convex-performance-audit +description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +--- + +# Convex Performance Audit + +Diagnose and fix performance problems in Convex applications, one problem class at a time. + +## When to Use + +- A Convex page or feature feels slow or expensive +- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts +- Low-freshness read paths are using reactivity where point-in-time reads would do +- OCC conflict errors or excessive mutation retries +- High subscription count or slow UI updates +- Functions approaching execution or transaction limits +- The same performance pattern needs fixing across sibling functions + +## When Not to Use + +- Initial Convex setup, auth setup, or component extraction +- Pure schema migrations with no performance goal +- One-off micro-optimizations without a user-visible or deployment-visible problem + +## Guardrails + +- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak +- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path +- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale + +## First Step: Gather Signals + +Start with the strongest signal available: + +1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. +2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. + - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. +3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. +4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. + +## Signal Routing + +After gathering signals, identify the problem class and read the matching reference file. + +| Signal | Reference | +| -------------------------------------------------------------- | ----------------------------------------- | +| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | +| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | +| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | +| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | +| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | + +Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. + +## Escalate Larger Fixes + +If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. + +Examples: + +- introducing digest or summary tables across multiple flows +- splitting documents to isolate frequently-updated fields +- reworking pagination or fetch strategy across several screens +- switching to a new index or denormalized field that needs migration-safe rollout + +When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. + +## Workflow + +### 1. Scope the problem + +Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. + +Write down: + +- entrypoint functions +- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` +- tables read +- tables written +- whether the path is high-read, high-write, or both + +### 2. Trace the full read and write set + +For each function in the path: + +1. Trace every `ctx.db.get()` and `ctx.db.query()` +2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` +3. Note foreign-key lookups, JS-side filtering, and full-document reads +4. Identify all sibling functions touching the same tables +5. Identify reactive stats, aggregates, or widgets rendered on the same page + +In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. + +### 3. Apply fixes from the relevant reference + +Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. + +Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. + +### 4. Fix sibling functions together + +When one function touching a table has a performance bug, audit sibling functions for the same pattern. + +After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. + +Examples: + +- If one list query switches from full docs to a digest table, inspect the other list queries for that table +- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table +- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk + +Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. + +### 5. Verify before finishing + +Confirm all of these: + +1. Results are the same as before, no dropped records +2. Eliminated reads or writes are no longer in the path where expected +3. Fallback behavior works when denormalized or indexed fields are missing +4. Frequently-updated fields are isolated from widely-read documents where needed +5. Every relevant sibling reader and writer was inspected, not just the original function + +## Reference Files + +- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables +- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting +- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads +- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size + +Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. + +## Checklist + +- [ ] Gathered signals from insights, dashboard, or code audit +- [ ] Identified the problem class and read the matching reference +- [ ] Scoped one concrete user flow or function path +- [ ] Traced every read and write in that path +- [ ] Identified sibling functions touching the same tables +- [ ] Applied fixes from the reference, following the recommended fix order +- [ ] Fixed sibling functions consistently +- [ ] Verified behavior and confirmed no regressions diff --git a/.agents/skills/convex-performance-audit/agents/openai.yaml b/.agents/skills/convex-performance-audit/agents/openai.yaml new file mode 100644 index 00000000..9a21f387 --- /dev/null +++ b/.agents/skills/convex-performance-audit/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Performance Audit" + short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#EF4444" + default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-performance-audit/assets/icon.svg b/.agents/skills/convex-performance-audit/assets/icon.svg new file mode 100644 index 00000000..7ab9e09c --- /dev/null +++ b/.agents/skills/convex-performance-audit/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agents/skills/convex-performance-audit/references/function-budget.md b/.agents/skills/convex-performance-audit/references/function-budget.md new file mode 100644 index 00000000..d4d4aa5a --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/function-budget.md @@ -0,0 +1,232 @@ +# Function Budget + +Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. + +## Core Principle + +Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. + +## Limits to Know + +These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. + +| Resource | Limit | +| --------------------------------- | ----------------------------------------------------- | +| Query/mutation execution time | 1 second (user code only, excludes DB operations) | +| Action execution time | 10 minutes | +| Data read per transaction | 16 MiB | +| Data written per transaction | 16 MiB | +| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | +| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | +| Documents written per transaction | 16,000 | +| Individual document size | 1 MiB | +| Function return value size | 16 MiB | + +## Symptoms + +- "Function execution took too long" errors +- "Transaction too large" or read/write set size errors +- Slow queries that read many documents +- Client receiving large payloads that slow down page load +- `npx convex insights --details` showing high bytes read + +## Common Causes + +### Unbounded collection + +A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. + +### Large document reads on hot paths + +Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. + +### Mutation doing too much work + +A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. + +### Returning too much data to the client + +A query returning full documents when the client only needs a few fields. + +## Fix Order + +### 1. Bound your reads + +Never `.collect()` without a limit on a table that can grow unbounded. + +```ts +// Bad: unbounded read, breaks as the table grows +const messages = await ctx.db.query("messages").collect(); +``` + +```ts +// Good: paginate or limit +const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", channelId)) + .order("desc") + .take(50); +``` + +### 2. Read smaller shapes + +If the list page only needs title, author, and date, do not read full documents with rich content fields. + +Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. + +### 3. Break large mutations into batches + +If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. + +```ts +// Bad: one mutation updating every row +export const backfillAll = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("items").collect(); + for (const doc of docs) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + }, +}); +``` + +```ts +// Good: cursor-based batch processing +export const backfillBatch = internalMutation({ + args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + const result = await ctx.db + .query("items") + .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); + + for (const doc of result.page) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + } + + if (!result.isDone) { + await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { + cursor: result.continueCursor, + batchSize, + }); + } + }, +}); +``` + +### 4. Move heavy work to actions + +Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. + +Actions run outside the transaction and can call mutations to write results back. + +```ts +// Bad: heavy computation inside a mutation +export const processUpload = mutation({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.db.insert("results", result); + }, +}); +``` + +```ts +// Good: action for heavy work, mutation for the write +export const processUpload = action({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.runMutation(internal.results.store, { result }); + }, +}); +``` + +### 5. Trim return values + +Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. + +```ts +// Bad: returns full documents including large content fields +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("articles").take(20); + }, +}); +``` + +```ts +// Good: project to only the fields the client needs +export const list = query({ + handler: async (ctx) => { + const articles = await ctx.db.query("articles").take(20); + return articles.map((a) => ({ + _id: a._id, + title: a.title, + author: a.author, + createdAt: a._creationTime, + })); + }, +}); +``` + +### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions + +Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. + +```ts +// Bad: unnecessary overhead from ctx.runQuery inside a mutation +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await ctx.runQuery(api.users.getCurrentUser); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +```ts +// Good: plain helper function, no extra overhead +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await getCurrentUser(ctx); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. + +### 7. Avoid unnecessary `runAction` calls + +`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). + +```ts +// Bad: runAction overhead for no reason +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await ctx.runAction(internal.items.processOne, { item }); + } + }, +}); +``` + +```ts +// Good: plain function call +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await processOneItem(ctx, { item }); + } + }, +}); +``` + +## Verification + +1. No function execution or transaction size errors +2. `npx convex insights --details` shows reduced bytes read +3. Large mutations are batched and self-scheduling +4. Client payloads are reasonably sized for the UI they serve +5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible +6. Sibling functions with similar patterns were checked diff --git a/.agents/skills/convex-performance-audit/references/hot-path-rules.md b/.agents/skills/convex-performance-audit/references/hot-path-rules.md new file mode 100644 index 00000000..e003e052 --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,369 @@ +# Hot Path Rules + +Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. + +## Contents + +- Core Principle +- Consistency Rule +- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) +- 2. Minimize Data Sources (denormalization, fallback rule) +- 3. Minimize Row Size (digest tables) +- 4. Skip No-Op Writes +- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) +- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) +- Verification + +## Core Principle + +Every byte read or written multiplies with concurrency. + +Think: + +`cost x calls_per_second x 86400` + +In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. + +## Consistency Rule + +If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. + +Do this especially for: + +- multiple list queries over the same table +- multiple writers to the same table +- public browse and search queries over the same records +- helper functions reused by more than one endpoint + +## 1. Push Filters To Storage + +Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. + +Prefer: + +- `withIndex(...)` +- `.withSearchIndex(...)` for text search +- narrower tables +- summary tables + +before accepting a scan-plus-filter pattern. + +```ts +// Bad: scans then filters in JavaScript +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + const tasks = await ctx.db.query("tasks").collect(); + return tasks.filter((task) => task.status === "open"); + }, +}); +``` + +```ts +// Also bad: Convex .filter() does not push to storage either +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .filter((q) => q.eq(q.field("status"), "open")) + .collect(); + }, +}); +``` + +```ts +// Good: use an index so storage does the filtering +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .withIndex("by_status", (q) => q.eq("status", "open")) + .collect(); + }, +}); +``` + +### Migration rule for indexes + +New indexes on partially backfilled fields can create correctness bugs during rollout. + +Important Convex detail: + +`undefined !== false` + +If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. + +Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. + +If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. + +```ts +// Bad: optional booleans can miss older rows where the field is undefined +const projects = await ctx.db + .query("projects") + .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) + .order("desc") + .take(20); +``` + +```ts +// Good: switch hot-path reads only after the rollout is migration-safe +// See the migration helper skill for dual-read / backfill / cutover patterns. +``` + +### Check for redundant indexes + +Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. + +```ts +// Bad: two indexes where one would do +defineTable({ team: v.id("teams"), user: v.id("users") }) + .index("by_team", ["team"]) + .index("by_team_and_user", ["team", "user"]); +``` + +```ts +// Good: single compound index serves both query patterns +defineTable({ team: v.id("teams"), user: v.id("users") }).index( + "by_team_and_user", + ["team", "user"], +); +``` + +Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. + +## 2. Minimize Data Sources + +Trace every read. + +If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. + +### When to denormalize + +Denormalize when all of these are true: + +- the path is hot +- the joined document is much larger than the field you need +- many readers are paying that join cost repeatedly + +Useful mental model: + +`join_cost = rows_per_page x foreign_doc_size x pages_per_second` + +Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. + +### Fallback rule + +Denormalized data is an optimization. Live data is the correctness path. + +Rules: + +- If the denormalized field is missing or null, fall back to the live read +- Do not show placeholders instead of falling back +- In lookup maps, only include fully populated entries + +```ts +// Bad: missing denormalized data becomes a placeholder and blocks correctness +const ownerName = project.ownerName ?? "Unknown owner"; +``` + +```ts +// Good: denormalized data is an optimization, not the only source of truth +const ownerName = + project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null; +``` + +Bad lookup map pattern: + +```ts +const ownersById = { + [project.ownerId]: { ownerName: null }, +}; +``` + +That blocks fallback because the map says "I have data" when it does not. + +Good lookup map pattern: + +```ts +const ownersById = + project.ownerName !== undefined && project.ownerName !== null + ? { [project.ownerId]: { ownerName: project.ownerName } } + : {}; +``` + +### No denormalized copy yet + +Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. + +If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. + +Rollout order: + +1. Update schema +2. Update write path +3. Backfill +4. Switch read path + +## 3. Minimize Row Size + +Hot list pages should read the smallest document shape that still answers the UI. + +Prefer summary or digest tables over full source tables when: + +- the list page only needs a subset of fields +- source documents are large +- the query is high volume + +An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. + +Digest tables are a tradeoff, not a default: + +- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost +- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit + +```ts +// Bad: list page reads source docs, then joins owner data per row +const projects = await ctx.db + .query("projects") + .withIndex("by_public", (q) => q.eq("isPublic", true)) + .collect(); +``` + +```ts +// Good: list page reads the smaller digest shape first +const projects = await ctx.db + .query("projectDigests") + .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) + .order("desc") + .take(20); +``` + +## 4. Isolate Frequently-Updated Fields + +Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to. + +Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them. + +Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document. + +```ts +// Bad: every presence heartbeat invalidates subscribers to the whole profile +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, + lastSeen: Date.now(), +}); +``` + +```ts +// Good: keep profile reads stable, move heartbeat updates to a separate document +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, +}); + +await ctx.db.patch(presence._id, { + lastSeen: Date.now(), +}); +``` + +## 5. Match Consistency To Read Patterns + +Choose read strategy based on traffic shape. + +### High-read, low-write + +Examples: + +- public browse pages +- search results +- landing pages +- directory listings + +Prefer: + +- point-in-time reads where appropriate +- explicit refresh +- local state for pagination +- caching where appropriate + +Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. + +### High-read, high-write + +Examples: + +- collaborative editors +- live dashboards +- presence-heavy views + +Reactive queries may be worth the ongoing cost. + +## Convex-Specific Notes + +### Reactive queries + +Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. + +On the client: + +- `useQuery` creates a live subscription +- `usePaginatedQuery` creates a live subscription per page + +For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. + +### Point-in-time reads + +Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. + +Use them for: + +- aggregate snapshots +- reports +- low-churn listings +- pages where explicit refresh is fine + +### Triggers and fan-out + +Triggers fire on every write, including writes that did not materially change the document. + +When a write exists only to keep derived state in sync: + +- diff before patching +- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate + +### Aggregates + +Reactive global counts invalidate frequently on busy tables. + +Prefer: + +- one-shot aggregate fetches +- periodic recomputation +- precomputed summary rows + +for global stats that do not need live updates every second. + +### Backfills + +For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. + +Deploy code that can handle both states before running the backfill. + +During the gap: + +- writes should populate the new shape +- reads should fall back safely + +## Verification + +Before closing the audit, confirm: + +1. Same results as before, no dropped records +2. The removed table or lookup is no longer in the hot-path read set +3. Tests or validation cover fallback behavior +4. Migration safety is preserved while fields or indexes are unbackfilled +5. Sibling functions were fixed consistently diff --git a/.agents/skills/convex-performance-audit/references/occ-conflicts.md b/.agents/skills/convex-performance-audit/references/occ-conflicts.md new file mode 100644 index 00000000..1da43801 --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,114 @@ +# OCC Conflict Resolution + +Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. + +## Core Principle + +Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. + +## Symptoms + +- OCC conflict errors in deployment logs or health page +- Mutations retrying multiple times before succeeding +- User-visible latency spikes on write-heavy pages +- `npx convex insights --details` showing high conflict rates + +## Common Causes + +### Hot documents + +Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. + +### Broad read sets causing false conflicts + +A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. + +### Fan-out from triggers or cascading writes + +A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. + +Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. + +### Write-then-read chains + +A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. + +## Fix Order + +### 1. Reduce read set size + +Narrower reads mean fewer false conflicts. + +```ts +// Bad: broad scan creates a wide conflict surface +const allTasks = await ctx.db.query("tasks").collect(); +const mine = allTasks.filter((t) => t.ownerId === userId); +``` + +```ts +// Good: indexed query touches only relevant documents +const mine = await ctx.db + .query("tasks") + .withIndex("by_owner", (q) => q.eq("ownerId", userId)) + .collect(); +``` + +### 2. Split hot documents + +When many writers target the same document, split the contention point. + +```ts +// Bad: every vote increments the same counter document +const counter = await ctx.db.get(pollCounterId); +await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); +``` + +```ts +// Good: shard the counter across multiple documents, aggregate on read +const shardIndex = Math.floor(Math.random() * SHARD_COUNT); +const shardId = shardIds[shardIndex]; +const shard = await ctx.db.get(shardId); +await ctx.db.patch(shardId, { count: shard!.count + 1 }); +``` + +Aggregate the shards in a query or scheduled job when you need the total. + +### 3. Move non-critical work to scheduled functions + +If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. + +```ts +// Bad: canonical write and derived work happen in the same transaction +await ctx.db.patch(userId, { name: args.name }); +await ctx.db.insert("userUpdateAnalytics", { + userId, + kind: "name_changed", + name: args.name, +}); +``` + +```ts +// Good: keep the primary write small, defer the analytics work +await ctx.db.patch(userId, { name: args.name }); +await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, { + userId, + name: args.name, +}); +``` + +### 4. Combine competing writes + +If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. + +Do not introduce artificial locks or queues unless the above steps have been tried first. + +## Related: Invalidation Scope + +Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. + +## Verification + +1. OCC conflict rate has dropped in insights or dashboard +2. Mutation latency is lower and more consistent +3. No data correctness regressions from splitting or scheduling changes +4. Sibling writers to the same hot documents were fixed consistently diff --git a/.agents/skills/convex-performance-audit/references/subscription-cost.md b/.agents/skills/convex-performance-audit/references/subscription-cost.md new file mode 100644 index 00000000..ae7d1adb --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/subscription-cost.md @@ -0,0 +1,252 @@ +# Subscription Cost + +Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. + +## Core Principle + +Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: + +`subscriptions x invalidation_frequency x query_cost` + +Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. + +## Symptoms + +- Dashboard shows high active subscription count +- UI feels sluggish or laggy despite fast individual queries +- React profiling shows frequent re-renders from Convex state +- Pages with many components each running their own `useQuery` +- Paginated lists where every loaded page stays subscribed + +## Common Causes + +### Reactive queries on low-freshness flows + +Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. + +### Overly broad queries + +A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. + +### Too many subscriptions per page + +A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. + +### Paginated queries keeping all pages live + +`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. + +### Frequently-updated fields on widely-read documents + +A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. + +## Fix Order + +### 1. Use point-in-time reads when live updates are not valuable + +Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. + +Consider a point-in-time read instead when all of these are true: + +- the flow is high-read +- the underlying data changes less often than users need to see +- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable + +Possible implementations depend on environment: + +- a server-rendered fetch +- a framework helper like `fetchQuery` +- a point-in-time client read such as `ConvexHttpClient.query()` + +```ts +// Reactive by default when fresh live data matters +function TeamPresence() { + const presence = useQuery(api.teams.livePresence, { teamId }); + return ; +} +``` + +```ts +// Point-in-time read when explicit refresh is acceptable +import { ConvexHttpClient } from "convex/browser"; + +const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); + +function SnapshotView() { + const [items, setItems] = useState([]); + + useEffect(() => { + client.query(api.items.snapshot).then(setItems); + }, []); + + return ; +} +``` + +Good candidates for point-in-time reads: + +- aggregate snapshots +- reports +- low-churn listings +- flows where explicit refresh is already acceptable + +Keep reactive for: + +- collaborative editing +- live dashboards +- presence-heavy views +- any surface where users expect fresh changes to appear automatically + +### 2. Batch related data into fewer queries + +Instead of N components each fetching their own related data, fetch it in a single query. + +```ts +// Bad: each card fetches its own author +function ProjectCard({ project }: { project: Project }) { + const author = useQuery(api.users.get, { id: project.authorId }); + return ; +} +``` + +```ts +// Good: parent query returns projects with author names included +function ProjectList() { + const projects = useQuery(api.projects.listWithAuthors); + return projects?.map((p) => ( + + )); +} +``` + +This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. + +This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. + +### 3. Use skip to avoid unnecessary subscriptions + +The `"skip"` value prevents a subscription from being created when the arguments are not ready. + +```ts +// Bad: subscribes with undefined args, wastes a subscription slot +const profile = useQuery(api.users.getProfile, { userId: selectedId! }); +``` + +```ts +// Good: skip when there is nothing to fetch +const profile = useQuery( + api.users.getProfile, + selectedId ? { userId: selectedId } : "skip", +); +``` + +### 4. Isolate frequently-updated fields into separate documents + +If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. + +```ts +// Bad: lastSeen lives on the user doc, every heartbeat invalidates +// every query that reads this user +const users = defineTable({ + name: v.string(), + email: v.string(), + lastSeen: v.number(), +}); +``` + +```ts +// Good: lastSeen lives in a separate heartbeat doc +const users = defineTable({ + name: v.string(), + email: v.string(), + heartbeatId: v.id("heartbeats"), +}); + +const heartbeats = defineTable({ + lastSeen: v.number(), +}); +``` + +Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. + +For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. + +### 5. Use the aggregate component for counts and sums + +Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. + +Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. + +If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. + +### 6. Narrow query read sets + +Queries that return less data and touch fewer documents invalidate less often. + +```ts +// Bad: returns all fields, invalidates on any field change +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("projects").collect(); + }, +}); +``` + +```ts +// Good: use a digest table with only the fields the list needs +export const listDigests = query({ + handler: async (ctx) => { + return await ctx.db.query("projectDigests").collect(); + }, +}); +``` + +Writes to fields not in the digest table do not invalidate the digest query. + +### 7. Remove `Date.now()` from queries + +Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. + +```ts +// Bad: Date.now() defeats query caching and causes frequent re-evaluation +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) + .take(100); +``` + +```ts +// Good: use a boolean field updated by a scheduled function +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_is_released", (q) => q.eq("isReleased", true)) + .take(100); +``` + +If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. + +### 8. Consider pagination strategy + +For long lists where users scroll through many pages: + +- If the data does not need live updates, use point-in-time fetching with manual "load more" +- If it does need live updates, accept the subscription cost but limit the number of loaded pages +- Consider whether older pages can be unloaded as the user scrolls forward + +### 9. Separate backend cost from UI churn + +If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. + +Treat this as a UX problem first when: + +- the underlying query is already reasonably cheap +- the complaint is flicker, loading flashes, or re-render churn +- live updates are still desirable once fresh data arrives + +## Verification + +1. Subscription count in dashboard is lower for the affected pages +2. UI responsiveness has improved +3. React profiling shows fewer unnecessary re-renders +4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily +5. Sibling pages with similar patterns were updated consistently diff --git a/.agents/skills/convex-quickstart/SKILL.md b/.agents/skills/convex-quickstart/SKILL.md new file mode 100644 index 00000000..5bff17bc --- /dev/null +++ b/.agents/skills/convex-quickstart/SKILL.md @@ -0,0 +1,347 @@ +--- +name: convex-quickstart +description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +--- + +# Convex Quickstart + +Set up a working Convex project as fast as possible. + +## When to Use + +- Starting a brand new project with Convex +- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app +- Scaffolding a Convex app for prototyping + +## When Not to Use + +- The project already has Convex installed and `convex/` exists - just start building +- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill + +## Workflow + +1. Determine the starting point: new project or existing app +2. If new project, pick a template and scaffold with `npm create convex@latest` +3. If existing app, install `convex` and wire up the provider +4. Run `npx convex dev` to connect a deployment and start the dev loop +5. Verify the setup works + +## Path 1: New Project (Recommended) + +Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. + +### Pick a template + +| Template | Stack | +| -------------------------- | ----------------------------------------- | +| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | +| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | +| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | +| `nextjs-clerk` | Next.js + Clerk auth | +| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | +| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | +| `bare` | Convex backend only, no frontend | + +If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. + +You can also use any GitHub repo as a template: + +```bash +npm create convex@latest my-app -- -t owner/repo +npm create convex@latest my-app -- -t owner/repo#branch +``` + +### Scaffold the project + +Always pass the project name and template flag to avoid interactive prompts: + +```bash +npm create convex@latest my-app -- -t react-vite-shadcn +cd my-app +npm install +``` + +The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. + +To scaffold in the current directory (if it is empty): + +```bash +npm create convex@latest . -- -t react-vite-shadcn +npm install +``` + +### Start the dev loop + +`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. + +**Ask the user to run this themselves:** + +Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: + +- Create a Convex project and dev deployment +- Write the deployment URL to `.env.local` +- Create the `convex/` directory with generated types +- Watch for changes and sync continuously + +The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. + +**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. + +### Start the frontend + +The user should also run the frontend dev server in a separate terminal: + +```bash +npm run dev +``` + +Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. + +### What you get + +After scaffolding, the project structure looks like: + +``` +my-app/ + convex/ # Backend functions and schema + _generated/ # Auto-generated types (check this into git) + schema.ts # Database schema (if template includes one) + src/ # Frontend code (or app/ for Next.js) + package.json + .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL +``` + +The template already has: + +- `ConvexProvider` wired into the app root +- Correct env var names for the framework +- Tailwind and shadcn/ui ready (for shadcn templates) +- Auth provider configured (for auth templates) + +Proceed to adding schema, functions, and UI. + +## Path 2: Add Convex to an Existing App + +Use this when the user already has a frontend project and wants to add Convex as the backend. + +### Install + +```bash +npm install convex +``` + +### Initialize and start dev loop + +Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. + +### Wire up the provider + +The Convex client must wrap the app at the root. The setup varies by framework. + +Create the `ConvexReactClient` at module scope, not inside a component: + +```tsx +// Bad: re-creates the client on every render +function App() { + const convex = new ConvexReactClient( + import.meta.env.VITE_CONVEX_URL as string, + ); + return ...; +} + +// Good: created once at module scope +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); +function App() { + return ...; +} +``` + +#### React (Vite) + +```tsx +// src/main.tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import App from "./App"; + +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); + +createRoot(document.getElementById("root")!).render( + + + + + , +); +``` + +#### Next.js (App Router) + +```tsx +// app/ConvexClientProvider.tsx +"use client"; + +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return {children}; +} +``` + +```tsx +// app/layout.tsx +import { ConvexClientProvider } from "./ConvexClientProvider"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} +``` + +#### Other frameworks + +For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: + +- [Vue](https://docs.convex.dev/quickstart/vue) +- [Svelte](https://docs.convex.dev/quickstart/svelte) +- [React Native](https://docs.convex.dev/quickstart/react-native) +- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) +- [Remix](https://docs.convex.dev/quickstart/remix) +- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) + +### Environment variables + +The env var name depends on the framework: + +| Framework | Variable | +| ------------ | ------------------------ | +| Vite | `VITE_CONVEX_URL` | +| Next.js | `NEXT_PUBLIC_CONVEX_URL` | +| Remix | `CONVEX_URL` | +| React Native | `EXPO_PUBLIC_CONVEX_URL` | + +`npx convex dev` writes the correct variable to `.env.local` automatically. + +## Agent Mode (Cloud and Headless Agents) + +When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. + +Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: + +```bash +CONVEX_AGENT_MODE=anonymous npx convex dev +``` + +This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. + +## Verify the Setup + +After setup, confirm everything is working: + +1. The user confirms `npx convex dev` is running without errors +2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` +3. `.env.local` contains the deployment URL + +## Writing Your First Function + +Once the project is set up, create a schema and a query to verify the full loop works. + +`convex/schema.ts`: + +```ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + tasks: defineTable({ + text: v.string(), + completed: v.boolean(), + }), +}); +``` + +`convex/tasks.ts`: + +```ts +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("tasks").collect(); + }, +}); + +export const create = mutation({ + args: { text: v.string() }, + handler: async (ctx, args) => { + await ctx.db.insert("tasks", { text: args.text, completed: false }); + }, +}); +``` + +Use in a React component (adjust the import path based on your file location relative to `convex/`): + +```tsx +import { useQuery, useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function Tasks() { + const tasks = useQuery(api.tasks.list); + const create = useMutation(api.tasks.create); + + return ( +
+ + {tasks?.map((t) => ( +
{t.text}
+ ))} +
+ ); +} +``` + +## Development vs Production + +Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. + +When ready to ship, deploy to production: + +```bash +npx convex deploy +``` + +This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. + +## Next Steps + +- Add authentication: use the `convex-setup-auth` skill +- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) +- Build components: use the `convex-create-component` skill +- Plan a migration: use the `convex-migration-helper` skill +- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) +- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) + +## Checklist + +- [ ] Determined starting point: new project or existing app +- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template +- [ ] If existing app: installed `convex` and wired up the provider +- [ ] User has `npx convex dev` running and connected to a deployment +- [ ] `convex/_generated/` directory exists with types +- [ ] `.env.local` has the deployment URL +- [ ] Verified a basic query/mutation round-trip works diff --git a/.agents/skills/convex-quickstart/agents/openai.yaml b/.agents/skills/convex-quickstart/agents/openai.yaml new file mode 100644 index 00000000..a51a6d09 --- /dev/null +++ b/.agents/skills/convex-quickstart/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Quickstart" + short_description: "Start a new Convex app or add Convex to an existing frontend." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#F97316" + default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-quickstart/assets/icon.svg b/.agents/skills/convex-quickstart/assets/icon.svg new file mode 100644 index 00000000..d83a73f3 --- /dev/null +++ b/.agents/skills/convex-quickstart/assets/icon.svg @@ -0,0 +1,4 @@ + diff --git a/.agents/skills/convex-setup-auth/SKILL.md b/.agents/skills/convex-setup-auth/SKILL.md new file mode 100644 index 00000000..0d1d9dd6 --- /dev/null +++ b/.agents/skills/convex-setup-auth/SKILL.md @@ -0,0 +1,150 @@ +--- +name: convex-setup-auth +description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +--- + +# Convex Authentication Setup + +Implement secure authentication in Convex with user management and access control. + +## When to Use + +- Setting up authentication for the first time +- Implementing user management (users table, identity mapping) +- Creating authentication helper functions +- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) + +## When Not to Use + +- Auth for a non-Convex backend +- Pure OAuth/OIDC documentation without a Convex implementation +- Debugging unrelated bugs that happen to surface near auth code +- The auth provider is already fully configured and the user only needs a one-line fix + +## First Step: Choose the Auth Provider + +Convex supports multiple authentication approaches. Do not assume a provider. + +Before writing setup code: + +1. Ask the user which auth solution they want, unless the repository already makes it obvious +2. If the repo already uses a provider, continue with that provider unless the user wants to switch +3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding + +Common options: + +- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex +- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features +- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically +- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 +- Custom JWT provider - use when integrating an existing auth system not covered above + +Look for signals in the repo before asking: + +- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages +- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components +- Environment variables that clearly point at a provider + +## After Choosing a Provider + +Read the provider's official guide and the matching local reference file: + +- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` +- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` +- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` +- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` + +The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. + +Use those sources for: + +- package installation +- client provider wiring +- environment variables +- `convex/auth.config.ts` setup +- login and logout UI patterns +- framework-specific setup for React, Vite, or Next.js + +For shared auth behavior, use the official Convex docs as the source of truth: + +- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` +- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage +- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance +- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth + +Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. +For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. +For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. +After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. + +## Core Pattern: Protecting Backend Functions + +The most common auth task is checking identity in Convex functions. + +```ts +// Bad: trusting a client-provided userId +export const getMyProfile = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.userId); + }, +}); +``` + +```ts +// Good: verifying identity server-side +export const getMyProfile = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + return await ctx.db + .query("users") + .withIndex("by_tokenIdentifier", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier), + ) + .unique(); + }, +}); +``` + +## Workflow + +1. Determine the provider, either by asking the user or inferring from the repo +2. Ask whether the user wants local-only setup or production-ready setup now +3. Read the matching provider reference file +4. Follow the official provider docs for current setup details +5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns +6. Only add app-level user storage if the docs and app requirements call for it +7. Add authorization checks for ownership, roles, or team access only where the app needs them +8. Verify login state, protected queries, environment variables, and production configuration if requested + +If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. +For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. +If the environment has browser automation tools, you can use them. +If it does not, give the user a short manual validation checklist instead. + +## Reference Files + +### Provider References + +- `references/convex-auth.md` +- `references/clerk.md` +- `references/workos-authkit.md` +- `references/auth0.md` + +## Checklist + +- [ ] Chosen the correct auth provider before writing setup code +- [ ] Read the relevant provider reference file +- [ ] Asked whether the user wants local-only setup or production-ready setup +- [ ] Used the official provider docs for provider-specific wiring +- [ ] Used the official Convex docs for shared auth behavior and authorization patterns +- [ ] Only added app-level user storage if the app actually needs it +- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth +- [ ] Added authentication checks in protected backend functions +- [ ] Added authorization checks where the app actually needs them +- [ ] Clear error messages ("Not authenticated", "Unauthorized") +- [ ] Client auth provider configured for the chosen provider +- [ ] If requested, production auth setup is covered too diff --git a/.agents/skills/convex-setup-auth/agents/openai.yaml b/.agents/skills/convex-setup-auth/agents/openai.yaml new file mode 100644 index 00000000..d1c90a14 --- /dev/null +++ b/.agents/skills/convex-setup-auth/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Setup Auth" + short_description: "Set up Convex auth, user identity mapping, and access control." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-setup-auth/assets/icon.svg b/.agents/skills/convex-setup-auth/assets/icon.svg new file mode 100644 index 00000000..4917dbb4 --- /dev/null +++ b/.agents/skills/convex-setup-auth/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agents/skills/convex-setup-auth/references/auth0.md b/.agents/skills/convex-setup-auth/references/auth0.md new file mode 100644 index 00000000..9c729c5a --- /dev/null +++ b/.agents/skills/convex-setup-auth/references/auth0.md @@ -0,0 +1,116 @@ +# Auth0 + +Official docs: + +- https://docs.convex.dev/auth/auth0 +- https://auth0.github.io/auth0-cli/ +- https://auth0.github.io/auth0-cli/auth0_apps_create.html + +Use this when the app already uses Auth0 or the user wants Auth0 specifically. + +## Workflow + +1. Confirm the user wants Auth0 +2. Determine the app framework and whether Auth0 is already partly set up +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and Auth0 guides before making changes +5. Ask whether they want the fastest setup path by installing the Auth0 CLI +6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI +7. If they do not want the CLI path, use the Auth0 dashboard path instead +8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up +9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID +10. Set environment variables for local and production environments +11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +12. Gate Convex-backed UI with Convex auth state +13. Try to verify Convex reports the user as authenticated after Auth0 login +14. If the refresh-token path fails, stop improvising and send the user back to the official docs +15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered + +## What To Do + +- Read the official Convex and Auth0 guide before writing setup code +- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet +- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" +- Make sure the app has already completed the relevant Auth0 quickstart for its frontend +- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` +- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated + +## Key Setup Areas + +- install the Auth0 SDK for the app's framework +- configure `convex/auth.config.ts` with the Auth0 domain and client ID +- set environment variables for local and production environments +- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +- use Convex auth state when gating Convex-backed UI + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- frontend app entry or provider wrapper +- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` +- Auth0 environment variables commonly include: + - `AUTH0_DOMAIN` + - `AUTH0_CLIENT_ID` + - `VITE_AUTH0_DOMAIN` + - `VITE_AUTH0_CLIENT_ID` + +## Concrete Steps + +1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework +2. Ask whether the user wants the Auth0 CLI path +3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` +4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app +5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard +6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard +7. Install the Auth0 SDK for the app's framework +8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID +9. Set frontend and backend environment variables +10. Wrap the app in `Auth0Provider` +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` +12. Run the normal Convex dev or deploy flow after backend config changes +13. Try the official provider config shown in the Convex docs +14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now +15. Only claim success if the user can sign in and Convex recognizes the authenticated session +16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too + +## Gotchas + +- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch +- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant +- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard +- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced +- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end +- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled +- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation +- Keep dev and prod tenants separate if the project uses different Auth0 environments +- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. +- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. +- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. +- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered +- Verify production environment variables and redirect settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the Auth0 login flow +- Verify Convex-authenticated UI renders only after Convex auth state is ready +- Verify protected Convex queries succeed after login +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- Verify the Auth0 app settings match the real local callback and logout URLs during development +- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully +- If production-ready setup was requested, verify the production Auth0 configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Auth0 +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Complete the relevant Auth0 frontend setup +- [ ] Configure `convex/auth.config.ts` +- [ ] Set environment variables +- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs +- [ ] If requested, configure the production deployment too diff --git a/.agents/skills/convex-setup-auth/references/clerk.md b/.agents/skills/convex-setup-auth/references/clerk.md new file mode 100644 index 00000000..7dbde194 --- /dev/null +++ b/.agents/skills/convex-setup-auth/references/clerk.md @@ -0,0 +1,113 @@ +# Clerk + +Official docs: + +- https://docs.convex.dev/auth/clerk +- https://clerk.com/docs/guides/development/integrations/databases/convex + +Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. + +## Workflow + +1. Confirm the user wants Clerk +2. Make sure the user has a Clerk account and a Clerk application +3. Determine the app framework: + - React + - Next.js + - TanStack Start +4. Ask whether the user wants local-only setup or production-ready setup now +5. Gather the Clerk keys and the Clerk Frontend API URL +6. Follow the correct framework section in the official docs +7. Complete the backend and client wiring +8. Verify Convex reports the user as authenticated after login +9. If the user wants production-ready setup, make sure the production Clerk config is also covered + +## What To Do + +- Read the official Convex and Clerk guide before writing setup code +- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application +- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active +- Match the guide to the app's framework, usually React, Next.js, or TanStack Start +- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` + +## Key Setup Areas + +- install the Clerk SDK for the framework in use +- configure `convex/auth.config.ts` with the Clerk issuer domain +- set the required Clerk environment variables +- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` +- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- React or Vite client entry such as `src/main.tsx` +- Next.js client wrapper for Convex if using App Router +- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` +- Clerk app creation page: `https://dashboard.clerk.com/apps/new` +- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` +- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` +- Clerk environment variables: + - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs + - `CLERK_FRONTEND_API_URL` in the Clerk docs + - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps + - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps + - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required + +`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. + +## Concrete Steps + +1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` +2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` +3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed +4. Open `https://dashboard.clerk.com/apps/setup/convex` +5. Activate the Convex integration in Clerk if it is not already active +6. Copy the Clerk Frontend API URL shown there +7. Install the Clerk package for the app's framework +8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens +9. Set the publishable key in the frontend environment +10. Set the issuer domain or Frontend API URL so Convex can validate the JWT +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` +12. Wrap the app in `ClerkProvider` +13. Use Convex auth helpers for authenticated rendering +14. Run the normal Convex dev or deploy flow after updating backend auth config +15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too + +## Gotchas + +- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render +- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper +- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config +- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. +- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. +- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. +- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. +- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` +- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included +- Verify production redirect URLs and any production Clerk domain values before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can sign in with Clerk +- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in +- Verify `useConvexAuth()` reaches the authenticated state after Clerk login +- Verify protected Convex queries run successfully inside authenticated UI +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- If production-ready setup was requested, verify the production Clerk configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Clerk +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Follow the correct framework section in the official guide +- [ ] Set Clerk environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify Convex authenticated state after login +- [ ] If requested, configure the production deployment too diff --git a/.agents/skills/convex-setup-auth/references/convex-auth.md b/.agents/skills/convex-setup-auth/references/convex-auth.md new file mode 100644 index 00000000..d4824d24 --- /dev/null +++ b/.agents/skills/convex-setup-auth/references/convex-auth.md @@ -0,0 +1,143 @@ +# Convex Auth + +Official docs: https://docs.convex.dev/auth/convex-auth +Setup guide: https://labs.convex.dev/auth/setup + +Use this when the user wants auth handled directly in Convex rather than through a third-party provider. + +## Workflow + +1. Confirm the user wants Convex Auth specifically +2. Determine which sign-in methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords and password reset +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the Convex Auth setup guide before writing code +5. Make sure the project has a configured Convex deployment: + - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set + - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing +6. Install the auth packages: + - `npm install @convex-dev/auth @auth/core@0.37.0` +7. Run the initialization command: + - `npx @convex-dev/auth` +8. Confirm the initializer created: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +9. Add the required `authTables` to `convex/schema.ts` +10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` +11. Configure at least one auth method in `convex/auth.ts` +12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code +13. Verify the client can sign in successfully +14. Verify Convex receives authenticated identity in backend functions +15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well +16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex + +## What This Reference Is For + +- choosing Convex Auth as the default provider for a new Convex app +- understanding whether the app wants magic links, OTPs, OAuth, or passwords +- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior + +## What To Do + +- Read the Convex Auth setup guide before writing setup code +- Follow the setup flow from the docs rather than recreating it from memory +- If the app is new, consider starting from the official starter flow instead of hand-wiring everything +- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra + +## Concrete Steps + +1. Install `@convex-dev/auth` and `@auth/core@0.37.0` +2. Run `npx convex dev` if the project does not already have a configured deployment +3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment +4. Run `npx @convex-dev/auth` +5. Confirm the generated auth setup is present before continuing: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +6. Add `authTables` to `convex/schema.ts` +7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry +8. Configure the selected auth methods in `convex/auth.ts` +9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed +10. Verify login locally +11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment + +## Expected Files and Decisions + +- `convex/schema.ts` +- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file +- generated Convex Auth setup produced by `npx @convex-dev/auth` +- an existing configured Convex deployment, or the ability to create one with `npx convex dev` +- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods + +- Decide whether the user is creating a new app or adding auth to an existing app +- For a new app, prefer the official starter flow instead of rebuilding setup by hand +- Decide which auth methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords +- Decide whether the user wants local-only setup or production-ready setup now +- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough + +## Gotchas + +- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. +- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. +- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. +- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. +- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. +- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. +- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. +- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. +- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. +- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment +- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Human Handoff + +If `npx convex dev` or deployment setup requires human input: + +- stop and explain exactly what the user needs to do +- say why that step is required +- resume the auth setup immediately after the user confirms it is done + +## Validation + +- Verify the user can complete a sign-in flow +- Offer to validate sign up, sign out, and sign back in with the configured auth method +- If browser automation is available in the environment, you can do this directly +- If browser automation is not available, give the user a short manual validation checklist instead +- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions +- Verify protected UI only renders after Convex-authenticated state is ready +- Verify environment variables and redirect settings match the current app environment +- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in +- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed +- If production-ready setup was requested, verify the production deployment is also configured correctly + +## Checklist + +- [ ] Confirm the user wants Convex Auth specifically +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Ensure a Convex deployment is configured before running auth initialization +- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` +- [ ] Run `npx convex dev` first if needed +- [ ] Run `npx @convex-dev/auth` +- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created +- [ ] Follow the setup guide for package install and wiring +- [ ] Add `authTables` to `convex/schema.ts` +- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` +- [ ] Configure at least one auth method in `convex/auth.ts` +- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes +- [ ] Confirm which sign-in methods the app needs +- [ ] Verify the client can sign in and the backend receives authenticated identity +- [ ] Offer end-to-end validation of sign up, sign out, and sign back in +- [ ] If requested, configure the production deployment too +- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/.agents/skills/convex-setup-auth/references/workos-authkit.md b/.agents/skills/convex-setup-auth/references/workos-authkit.md new file mode 100644 index 00000000..038cb9f3 --- /dev/null +++ b/.agents/skills/convex-setup-auth/references/workos-authkit.md @@ -0,0 +1,114 @@ +# WorkOS AuthKit + +Official docs: + +- https://docs.convex.dev/auth/authkit/ +- https://docs.convex.dev/auth/authkit/add-to-app +- https://docs.convex.dev/auth/authkit/auto-provision + +Use this when the app already uses WorkOS or the user wants AuthKit specifically. + +## Workflow + +1. Confirm the user wants WorkOS AuthKit +2. Determine whether they want: + - a Convex-managed WorkOS team + - an existing WorkOS team +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and WorkOS AuthKit guide +5. Create or update `convex.json` for the app's framework and real local port +6. Follow the correct branch of the setup flow based on that choice +7. Configure the required WorkOS environment variables +8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs +9. Wire the client provider and callback flow +10. Verify authenticated requests reach Convex +11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too +12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex + +## What To Do + +- Read the official Convex and WorkOS AuthKit guide before writing setup code +- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team +- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra +- Follow the current setup flow from the docs instead of relying on older examples + +## Key Setup Areas + +- package installation for the app's framework +- `convex.json` with the `authKit` section for dev, and preview or prod if needed +- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration +- `convex/auth.config.ts` wiring for WorkOS-issued JWTs +- client provider setup and token flow into Convex +- login callback and redirect configuration + +## Files and Env Vars To Expect + +- `convex.json` +- `convex/auth.config.ts` +- frontend auth provider wiring +- callback or redirect route setup where the framework requires it +- WorkOS environment variables commonly include: + - `WORKOS_CLIENT_ID` + - `WORKOS_API_KEY` + - `WORKOS_COOKIE_PASSWORD` + - `VITE_WORKOS_CLIENT_ID` + - `VITE_WORKOS_REDIRECT_URI` + - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` + +For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. + +## Concrete Steps + +1. Choose Convex-managed or existing WorkOS team +2. Create or update `convex.json` with the `authKit` section for the framework in use +3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port +4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow +5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` +6. Create or update `convex/auth.config.ts` for WorkOS JWT validation +7. Run the normal Convex dev or deploy flow so backend config is synced +8. Wire the WorkOS client provider in the app +9. Configure callback and redirect handling +10. Verify the user can sign in and return to the app +11. Verify Convex sees the authenticated user after login +12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too + +## Gotchas + +- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious +- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys +- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex +- Do not mix dev and prod WorkOS credentials or redirect URIs +- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it +- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. +- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. +- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. +- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. +- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered +- Verify the production redirect and callback settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the login flow and return to the app +- Verify the callback URL matches the real frontend port in local dev +- Verify Convex receives authenticated requests after login +- Verify `convex.json` matches the framework and chosen WorkOS setup path +- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path +- Verify environment variables differ correctly between local and production where needed +- If production-ready setup was requested, verify the production WorkOS configuration is also covered + +## Checklist + +- [ ] Confirm the user wants WorkOS AuthKit +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Choose Convex-managed or existing WorkOS team +- [ ] Create or update `convex.json` +- [ ] Configure WorkOS environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify authenticated requests reach Convex after login +- [ ] If requested, configure the production deployment too diff --git a/.claude/skills/convex-create-component b/.claude/skills/convex-create-component deleted file mode 120000 index dfa8244f..00000000 --- a/.claude/skills/convex-create-component +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-create-component \ No newline at end of file diff --git a/.claude/skills/convex-create-component/SKILL.md b/.claude/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..22af601f --- /dev/null +++ b/.claude/skills/convex-create-component/SKILL.md @@ -0,0 +1,288 @@ +--- +name: convex-create-component +description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +--- + +# Convex Create Component + +Create reusable Convex components with clear boundaries and a small app-facing API. + +## When to Use + +- Creating a new Convex component in an existing app +- Extracting reusable backend logic into a component +- Building a third-party integration that should own its own tables and workflows +- Packaging Convex functionality for reuse across multiple apps + +## When Not to Use + +- One-off business logic that belongs in the main app +- Thin utilities that do not need Convex tables or functions +- App-level orchestration that should stay in `convex/` +- Cases where a normal TypeScript library is enough + +## Workflow + +1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. +2. Choose the shape using the decision tree below and read the matching reference file. +3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. +4. Make a short plan for: + - what tables the component owns + - what public functions it exposes + - what data must be passed in from the app (auth, env vars, parent IDs) + - what stays in the app as wrappers or HTTP mounts +5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. +6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. +7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. +8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. +9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. +10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. + +## Choose the Shape + +Ask the user, then pick one path: + +| Goal | Shape | Reference | +| ------------------------------------------------- | ---------------- | ----------------------------------- | +| Component for this app only | Local | `references/local-components.md` | +| Publish or share across apps | Packaged | `references/packaged-components.md` | +| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | +| Not sure | Default to local | `references/local-components.md` | + +Read exactly one reference file before proceeding. + +## Default Approach + +Unless the user explicitly wants an npm package, default to a local component: + +- Put it under `convex/components//` +- Define it with `defineComponent(...)` in its own `convex.config.ts` +- Install it from the app's `convex/convex.config.ts` with `app.use(...)` +- Let `npx convex dev` generate the component's own `_generated/` files + +## Component Skeleton + +A minimal local component with a table and two functions, plus the app wiring. + +```ts +// convex/components/notifications/convex.config.ts +import { defineComponent } from "convex/server"; + +export default defineComponent("notifications"); +``` + +```ts +// convex/components/notifications/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + notifications: defineTable({ + userId: v.string(), + message: v.string(), + read: v.boolean(), + }).index("by_user", ["userId"]), +}); +``` + +```ts +// convex/components/notifications/lib.ts +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; + +export const send = mutation({ + args: { userId: v.string(), message: v.string() }, + returns: v.id("notifications"), + handler: async (ctx, args) => { + return await ctx.db.insert("notifications", { + userId: args.userId, + message: args.message, + read: false, + }); + }, +}); + +export const listUnread = query({ + args: { userId: v.string() }, + returns: v.array( + v.object({ + _id: v.id("notifications"), + _creationTime: v.number(), + userId: v.string(), + message: v.string(), + read: v.boolean(), + }), + ), + handler: async (ctx, args) => { + return await ctx.db + .query("notifications") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .filter((q) => q.eq(q.field("read"), false)) + .collect(); + }, +}); +``` + +```ts +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import notifications from "./components/notifications/convex.config.js"; + +const app = defineApp(); +app.use(notifications); + +export default app; +``` + +```ts +// convex/notifications.ts (app-side wrapper) +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { components } from "./_generated/api"; +import { getAuthUserId } from "@convex-dev/auth/server"; + +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); + +export const myUnread = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + return await ctx.runQuery(components.notifications.lib.listUnread, { + userId, + }); + }, +}); +``` + +Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. + +## Critical Rules + +- Keep authentication in the app, because `ctx.auth` is not available inside components. +- Keep environment access in the app, because component functions cannot read `process.env`. +- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. +- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. +- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. +- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. +- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. +- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. +- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. + +## Patterns + +### Authentication and environment access + +```ts +// Bad: component code cannot rely on app auth or env +const identity = await ctx.auth.getUserIdentity(); +const apiKey = process.env.OPENAI_API_KEY; +``` + +```ts +// Good: the app resolves auth and env, then passes explicit values +const userId = await getAuthUserId(ctx); +if (!userId) throw new Error("Not authenticated"); + +await ctx.runAction(components.translator.translate, { + userId, + apiKey: process.env.OPENAI_API_KEY, + text: args.text, +}); +``` + +### Client-facing API + +```ts +// Bad: assuming a component function is directly callable by clients +export const send = components.notifications.send; +``` + +```ts +// Good: re-export through an app mutation or query +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); +``` + +### IDs across the boundary + +```ts +// Bad: parent app table IDs are not valid component validators +args: { + userId: v.id("users"); +} +``` + +```ts +// Good: treat parent-owned IDs as strings at the boundary +args: { + userId: v.string(); +} +``` + +### Advanced Patterns + +For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. + +## Validation + +Try validation in this order: + +1. `npx convex codegen --component-dir convex/components/` +2. `npx convex codegen` +3. `npx convex dev` + +Important: + +- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. +- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. +- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. + +## Reference Files + +Read exactly one of these after the user confirms the goal: + +- `references/local-components.md` +- `references/packaged-components.md` +- `references/hybrid-components.md` + +Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) + +## Checklist + +- [ ] Asked the user what they want to build and confirmed the shape +- [ ] Read the matching reference file +- [ ] Confirmed a component is the right abstraction +- [ ] Planned tables, public API, boundaries, and app wrappers +- [ ] Component lives under `convex/components//` (or package layout if publishing) +- [ ] Component imports from its own `./_generated/server` +- [ ] Auth, env access, and HTTP routes stay in the app +- [ ] Parent app IDs cross the boundary as `v.string()` +- [ ] Public functions have `args` and `returns` validators +- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/.claude/skills/convex-create-component/agents/openai.yaml b/.claude/skills/convex-create-component/agents/openai.yaml new file mode 100644 index 00000000..ba9287e4 --- /dev/null +++ b/.claude/skills/convex-create-component/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Create Component" + short_description: "Design and build reusable Convex components with clear boundaries." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#14B8A6" + default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." + +policy: + allow_implicit_invocation: true diff --git a/.claude/skills/convex-create-component/assets/icon.svg b/.claude/skills/convex-create-component/assets/icon.svg new file mode 100644 index 00000000..10f4c2c4 --- /dev/null +++ b/.claude/skills/convex-create-component/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.claude/skills/convex-create-component/references/advanced-patterns.md b/.claude/skills/convex-create-component/references/advanced-patterns.md new file mode 100644 index 00000000..3deb684c --- /dev/null +++ b/.claude/skills/convex-create-component/references/advanced-patterns.md @@ -0,0 +1,134 @@ +# Advanced Component Patterns + +Additional patterns for Convex components that go beyond the basics covered in the main skill file. + +## Function Handles for callbacks + +When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. + +```ts +// App side: create a handle and pass it to the component +import { createFunctionHandle } from "convex/server"; + +export const startJob = mutation({ + handler: async (ctx) => { + const handle = await createFunctionHandle(internal.myModule.processItem); + await ctx.runMutation(components.workpool.enqueue, { + callback: handle, + }); + }, +}); +``` + +```ts +// Component side: accept and invoke the handle +import { v } from "convex/values"; +import type { FunctionHandle } from "convex/server"; +import { mutation } from "./_generated/server.js"; + +export const enqueue = mutation({ + args: { callback: v.string() }, + handler: async (ctx, args) => { + const handle = args.callback as FunctionHandle<"mutation">; + await ctx.scheduler.runAfter(0, handle, {}); + }, +}); +``` + +## Deriving validators from schema + +Instead of manually repeating field types in return validators, extend the schema validator: + +```ts +import { v } from "convex/values"; +import schema from "./schema.js"; + +const notificationDoc = schema.tables.notifications.validator.extend({ + _id: v.id("notifications"), + _creationTime: v.number(), +}); + +export const getLatest = query({ + args: {}, + returns: v.nullable(notificationDoc), + handler: async (ctx) => { + return await ctx.db.query("notifications").order("desc").first(); + }, +}); +``` + +## Static configuration with a globals table + +A common pattern for component configuration is a single-document "globals" table: + +```ts +// schema.ts +export default defineSchema({ + globals: defineTable({ + maxRetries: v.number(), + webhookUrl: v.optional(v.string()), + }), + // ... other tables +}); +``` + +```ts +// lib.ts +export const configure = mutation({ + args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.query("globals").first(); + if (existing) { + await ctx.db.patch(existing._id, args); + } else { + await ctx.db.insert("globals", args); + } + return null; + }, +}); +``` + +## Class-based client wrappers + +For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. + +```ts +// src/client/index.ts +import type { GenericMutationCtx, GenericDataModel } from "convex/server"; +import type { ComponentApi } from "../component/_generated/component.js"; + +type MutationCtx = Pick, "runMutation">; + +export class Notifications { + constructor( + private component: ComponentApi, + private options?: { defaultChannel?: string }, + ) {} + + async send(ctx: MutationCtx, args: { userId: string; message: string }) { + return await ctx.runMutation(this.component.lib.send, { + ...args, + channel: this.options?.defaultChannel ?? "default", + }); + } +} +``` + +```ts +// App usage +import { Notifications } from "@convex-dev/notifications"; +import { components } from "./_generated/api"; + +const notifications = new Notifications(components.notifications, { + defaultChannel: "alerts", +}); + +export const send = mutation({ + args: { message: v.string() }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + await notifications.send(ctx, { userId, message: args.message }); + }, +}); +``` diff --git a/.claude/skills/convex-create-component/references/hybrid-components.md b/.claude/skills/convex-create-component/references/hybrid-components.md new file mode 100644 index 00000000..d2bb3514 --- /dev/null +++ b/.claude/skills/convex-create-component/references/hybrid-components.md @@ -0,0 +1,37 @@ +# Hybrid Convex Components + +Read this file only when the user explicitly wants a hybrid setup. + +## What This Means + +A hybrid component combines a local Convex component with shared library code. + +This can help when: + +- the user wants a local install but also shared package logic +- the component needs extension points or override hooks +- some logic should live in normal TypeScript code outside the component boundary + +## Default Advice + +Treat hybrid as an advanced option, not the default. + +Before choosing it, ask: + +- Why is a plain local component not enough? +- Why is a packaged component not enough? +- What exactly needs to stay overridable or shared? + +If the answer is vague, fall back to local or packaged. + +## Risks + +- More moving parts +- Harder upgrades and backwards compatibility +- Easier to blur the component boundary + +## Checklist + +- [ ] User explicitly needs hybrid behavior +- [ ] Local-only and packaged-only options were considered first +- [ ] The extension points are clearly defined before coding diff --git a/.claude/skills/convex-create-component/references/local-components.md b/.claude/skills/convex-create-component/references/local-components.md new file mode 100644 index 00000000..7fbfe21a --- /dev/null +++ b/.claude/skills/convex-create-component/references/local-components.md @@ -0,0 +1,38 @@ +# Local Convex Components + +Read this file when the component should live inside the current app and does not need to be published as an npm package. + +## When to Choose This + +- The user wants the simplest path +- The component only needs to work in this repo +- The goal is extracting app logic into a cleaner boundary + +## Default Layout + +Use this structure unless the repo already has a clear alternative pattern: + +```text +convex/ + convex.config.ts + components/ + / + convex.config.ts + schema.ts + .ts +``` + +## Workflow Notes + +- Define the component with `defineComponent("")` +- Install it from the app with `defineApp()` and `app.use(...)` +- Keep auth, env access, public API wrappers, and HTTP route mounting in the app +- Let the component own isolated tables and reusable backend workflows +- Add app wrappers if clients need to call into the component + +## Checklist + +- [ ] Component is inside `convex/components//` +- [ ] App installs it with `app.use(...)` +- [ ] Component owns only its own tables +- [ ] App wrappers handle client-facing calls when needed diff --git a/.claude/skills/convex-create-component/references/packaged-components.md b/.claude/skills/convex-create-component/references/packaged-components.md new file mode 100644 index 00000000..5668e7ed --- /dev/null +++ b/.claude/skills/convex-create-component/references/packaged-components.md @@ -0,0 +1,51 @@ +# Packaged Convex Components + +Read this file when the user wants a reusable npm package or a component shared across multiple apps. + +## When to Choose This + +- The user wants to publish the component +- The user wants a stable reusable package boundary +- The component will be shared across multiple apps or teams + +## Default Approach + +- Prefer starting from `npx create-convex@latest --component` when possible +- Keep the official authoring docs as the source of truth for package layout and exports +- Validate the bundled package through an example app, not just the source files + +## Build Flow + +When building a packaged component, make sure the bundled output exists before the example app tries to consume it. + +Recommended order: + +1. `npx convex codegen --component-dir ./path/to/component` +2. Run the package build command +3. Run `npx convex dev --typecheck-components` in the example app + +Do not assume normal app codegen is enough for packaged component workflows. + +## Package Exports + +If publishing to npm, make sure the package exposes the entry points apps need: + +- package root for client helpers, types, or classes +- `./convex.config.js` for installing the component +- `./_generated/component.js` for the app-facing `ComponentApi` type +- `./test` for testing helpers when applicable + +## Testing + +- Use `convex-test` for component logic +- Register the component schema and modules with the test instance +- Test app-side wrapper code from an example app that installs the package +- Export a small helper from `./test` if consumers need easy test registration + +## Checklist + +- [ ] Packaging is actually required +- [ ] Build order avoids bundle and codegen races +- [ ] Package exports include install and typing entry points +- [ ] Example app exercises the packaged component +- [ ] Core behavior is covered by tests diff --git a/.claude/skills/convex-migration-helper b/.claude/skills/convex-migration-helper deleted file mode 120000 index 81eeed18..00000000 --- a/.claude/skills/convex-migration-helper +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-migration-helper \ No newline at end of file diff --git a/.claude/skills/convex-migration-helper/SKILL.md b/.claude/skills/convex-migration-helper/SKILL.md new file mode 100644 index 00000000..db36c622 --- /dev/null +++ b/.claude/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,149 @@ +--- +name: convex-migration-helper +description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +--- + +# Convex Migration Helper + +Safely migrate Convex schemas and data when making breaking changes. + +## When to Use + +- Adding new required fields to existing tables +- Changing field types or structure +- Splitting or merging tables +- Renaming or deleting fields +- Migrating from nested to relational data + +## When Not to Use + +- Greenfield schema with no existing data in production or dev +- Adding optional fields that do not need backfilling +- Adding new tables with no existing data to migrate +- Adding or removing indexes with no correctness concern +- Questions about Convex schema design without a migration need + +## Key Concepts + +### Schema Validation Drives the Workflow + +Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: + +- You cannot add a required field if existing documents don't have it +- You cannot change a field's type if existing documents have the old type +- You cannot remove a field from the schema if existing documents still have it + +This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. + +### Online Migrations + +Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. + +### Prefer New Fields Over Changing Types + +When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. + +### Don't Delete Data + +Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. + +## Safe Changes (No Migration Needed) + +### Adding Optional Field + +```typescript +// Before +users: defineTable({ + name: v.string(), +}); + +// After - safe, new field is optional +users: defineTable({ + name: v.string(), + bio: v.optional(v.string()), +}); +``` + +### Adding New Table + +```typescript +posts: defineTable({ + userId: v.id("users"), + title: v.string(), +}).index("by_user", ["userId"]); +``` + +### Adding Index + +```typescript +users: defineTable({ + name: v.string(), + email: v.string(), +}).index("by_email", ["email"]); +``` + +## Breaking Changes: The Deployment Workflow + +Every breaking migration follows the same multi-deploy pattern: + +**Deploy 1 - Widen the schema:** + +1. Update schema to allow both old and new formats (e.g., add optional new field) +2. Update code to handle both formats when reading +3. Update code to write the new format for new documents +4. Deploy + +**Between deploys - Migrate data:** + +5. Run migration to backfill existing documents +6. Verify all documents are migrated + +**Deploy 2 - Narrow the schema:** + +7. Update schema to require the new format only +8. Remove code that handles the old format +9. Deploy + +## Using the Migrations Component + +For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. + +See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. + +## Common Migration Patterns + +See `references/migration-patterns.md` for complete patterns with code examples covering: + +- Adding a required field +- Deleting a field +- Changing a field type +- Splitting nested data into a separate table +- Cleaning up orphaned documents +- Zero-downtime strategies (dual write, dual read) +- Small table shortcut (single internalMutation without the component) +- Verifying a migration is complete + +## Common Pitfalls + +1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. +2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. +3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." +4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. +5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. +6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. + +## Migration Checklist + +- [ ] Identify the breaking change and plan the multi-deploy workflow +- [ ] Update schema to allow both old and new formats +- [ ] Update code to handle both formats when reading +- [ ] Update code to write the new format for new documents +- [ ] Deploy widened schema and updated code +- [ ] Define migration using the `@convex-dev/migrations` component +- [ ] Test with `dryRun: true` +- [ ] Run migration and monitor status +- [ ] Verify all documents are migrated +- [ ] Update schema to require new format only +- [ ] Clean up code that handled old format +- [ ] Deploy final schema and code +- [ ] Remove migration code once confirmed stable diff --git a/.claude/skills/convex-migration-helper/agents/openai.yaml b/.claude/skills/convex-migration-helper/agents/openai.yaml new file mode 100644 index 00000000..c2a7fcc5 --- /dev/null +++ b/.claude/skills/convex-migration-helper/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Migration Helper" + short_description: "Plan and run safe Convex schema and data migrations." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#8B5CF6" + default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." + +policy: + allow_implicit_invocation: true diff --git a/.claude/skills/convex-migration-helper/assets/icon.svg b/.claude/skills/convex-migration-helper/assets/icon.svg new file mode 100644 index 00000000..fba7241a --- /dev/null +++ b/.claude/skills/convex-migration-helper/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.claude/skills/convex-migration-helper/references/migration-patterns.md b/.claude/skills/convex-migration-helper/references/migration-patterns.md new file mode 100644 index 00000000..53b4946f --- /dev/null +++ b/.claude/skills/convex-migration-helper/references/migration-patterns.md @@ -0,0 +1,231 @@ +# Migration Patterns Reference + +Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. + +## Adding a Required Field + +```typescript +// Deploy 1: Schema allows both states +users: defineTable({ + name: v.string(), + role: v.optional(v.union(v.literal("user"), v.literal("admin"))), +}); + +// Migration: backfill the field +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); + +// Deploy 2: After migration completes, make it required +users: defineTable({ + name: v.string(), + role: v.union(v.literal("user"), v.literal("admin")), +}); +``` + +## Deleting a Field + +Mark the field optional first, migrate data to remove it, then remove from schema: + +```typescript +// Deploy 1: Make optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()) + +// Migration +export const removeIsPro = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.isPro !== undefined) { + await ctx.db.patch(team._id, { isPro: undefined }); + } + }, +}); + +// Deploy 2: Remove isPro from schema entirely +``` + +## Changing a Field Type + +Prefer creating a new field. You can combine adding and deleting in one migration: + +```typescript +// Deploy 1: Add new field, keep old field optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) + +// Migration: convert old field to new field +export const convertToEnum = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.plan === undefined) { + await ctx.db.patch(team._id, { + plan: team.isPro ? "pro" : "basic", + isPro: undefined, + }); + } + }, +}); + +// Deploy 2: Remove isPro from schema, make plan required +``` + +## Splitting Nested Data Into a Separate Table + +```typescript +export const extractPreferences = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.preferences === undefined) return; + + const existing = await ctx.db + .query("userPreferences") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!existing) { + await ctx.db.insert("userPreferences", { + userId: user._id, + ...user.preferences, + }); + } + + await ctx.db.patch(user._id, { preferences: undefined }); + }, +}); +``` + +Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. + +## Cleaning Up Orphaned Documents + +```typescript +export const deleteOrphanedEmbeddings = migrations.define({ + table: "embeddings", + migrateOne: async (ctx, doc) => { + const chunk = await ctx.db + .query("chunks") + .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) + .first(); + + if (!chunk) { + await ctx.db.delete(doc._id); + } + }, +}); +``` + +## Zero-Downtime Strategies + +During the migration window, your app must handle both old and new data formats. There are two main strategies. + +### Dual Write (Preferred) + +Write to both old and new structures. Read from the old structure until migration is complete. + +1. Deploy code that writes both formats, reads old format +2. Run migration on existing data +3. Deploy code that reads new format, still writes both +4. Deploy code that only reads and writes new format + +This is preferred because you can safely roll back at any point, the old format is always up to date. + +```typescript +// Bad: only writing to new structure before migration is done +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + await ctx.db.insert("teams", { + name: args.name, + plan: args.isPro ? "pro" : "basic", + }); + }, +}); + +// Good: writing to both structures during migration +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + const plan = args.isPro ? "pro" : "basic"; + await ctx.db.insert("teams", { + name: args.name, + isPro: args.isPro, + plan, + }); + }, +}); +``` + +### Dual Read + +Read both formats. Write only the new format. + +1. Deploy code that reads both formats (preferring new), writes only new format +2. Run migration on existing data +3. Deploy code that reads and writes only new format + +This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. + +```typescript +// Good: reading both formats, preferring new +function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { + if (team.plan !== undefined) return team.plan; + return team.isPro ? "pro" : "basic"; +} +``` + +## Small Table Shortcut + +For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: + +```typescript +import { internalMutation } from "./_generated/server"; + +export const backfillSmallTable = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("smallConfig").collect(); + for (const doc of docs) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: "default" }); + } + } + }, +}); +``` + +```bash +npx convex run migrations:backfillSmallTable +``` + +Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. + +## Verifying a Migration + +Query to check remaining unmigrated documents: + +```typescript +import { query } from "./_generated/server"; + +export const verifyMigration = query({ + handler: async (ctx) => { + const remaining = await ctx.db + .query("users") + .filter((q) => q.eq(q.field("role"), undefined)) + .take(10); + + return { + complete: remaining.length === 0, + sampleRemaining: remaining.map((u) => u._id), + }; + }, +}); +``` + +Or use the component's built-in status monitoring: + +```bash +npx convex run --component migrations lib:getStatus --watch +``` diff --git a/.claude/skills/convex-migration-helper/references/migrations-component.md b/.claude/skills/convex-migration-helper/references/migrations-component.md new file mode 100644 index 00000000..95ec2921 --- /dev/null +++ b/.claude/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,169 @@ +# Migrations Component Reference + +Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. + +## Installation + +```bash +npm install @convex-dev/migrations +``` + +## Setup + +```typescript +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import migrations from "@convex-dev/migrations/convex.config.js"; + +const app = defineApp(); +app.use(migrations); +export default app; +``` + +```typescript +// convex/migrations.ts +import { Migrations } from "@convex-dev/migrations"; +import { components } from "./_generated/api.js"; +import { DataModel } from "./_generated/dataModel.js"; + +export const migrations = new Migrations(components.migrations); +export const run = migrations.runner(); +``` + +The `DataModel` type parameter is optional but provides type safety for migration definitions. + +## Define a Migration + +The `migrateOne` function processes a single document. The component handles batching and pagination automatically. + +```typescript +// convex/migrations.ts +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); +``` + +Shorthand: if you return an object, it is applied as a patch automatically. + +```typescript +export const clearDeprecatedField = migrations.define({ + table: "users", + migrateOne: () => ({ legacyField: undefined }), +}); +``` + +## Run a Migration + +From the CLI: + +```bash +# Define a one-off runner in convex/migrations.ts: +# export const runIt = migrations.runner(internal.migrations.addDefaultRole); +npx convex run migrations:runIt + +# Or use the general-purpose runner +npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' +``` + +Programmatically from another Convex function: + +```typescript +await migrations.runOne(ctx, internal.migrations.addDefaultRole); +``` + +## Run Multiple Migrations in Order + +```typescript +export const runAll = migrations.runner([ + internal.migrations.addDefaultRole, + internal.migrations.clearDeprecatedField, + internal.migrations.normalizeEmails, +]); +``` + +```bash +npx convex run migrations:runAll +``` + +If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. + +## Dry Run + +Test a migration before committing changes: + +```bash +npx convex run migrations:runIt '{"dryRun": true}' +``` + +This runs one batch and then rolls back, so you can see what it would do without changing any data. + +## Check Migration Status + +```bash +npx convex run --component migrations lib:getStatus --watch +``` + +## Cancel a Running Migration + +```bash +npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' +``` + +Or programmatically: + +```typescript +await migrations.cancel(ctx, internal.migrations.addDefaultRole); +``` + +## Run Migrations on Deploy + +Chain migration execution after deploying: + +```bash +npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod +``` + +## Configuration Options + +### Custom Batch Size + +If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: + +```typescript +export const migrateHeavyTable = migrations.define({ + table: "largeDocuments", + batchSize: 10, + migrateOne: async (ctx, doc) => { + // migration logic + }, +}); +``` + +### Migrate a Subset Using an Index + +Process only matching documents instead of the full table: + +```typescript +export const fixEmptyNames = migrations.define({ + table: "users", + customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")), + migrateOne: () => ({ name: "" }), +}); +``` + +### Parallelize Within a Batch + +By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: + +```typescript +export const clearField = migrations.define({ + table: "myTable", + parallelize: true, + migrateOne: () => ({ optionalField: undefined }), +}); +``` diff --git a/.claude/skills/convex-performance-audit b/.claude/skills/convex-performance-audit deleted file mode 120000 index 1bff1e5e..00000000 --- a/.claude/skills/convex-performance-audit +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-performance-audit \ No newline at end of file diff --git a/.claude/skills/convex-performance-audit/SKILL.md b/.claude/skills/convex-performance-audit/SKILL.md new file mode 100644 index 00000000..382951cf --- /dev/null +++ b/.claude/skills/convex-performance-audit/SKILL.md @@ -0,0 +1,143 @@ +--- +name: convex-performance-audit +description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +--- + +# Convex Performance Audit + +Diagnose and fix performance problems in Convex applications, one problem class at a time. + +## When to Use + +- A Convex page or feature feels slow or expensive +- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts +- Low-freshness read paths are using reactivity where point-in-time reads would do +- OCC conflict errors or excessive mutation retries +- High subscription count or slow UI updates +- Functions approaching execution or transaction limits +- The same performance pattern needs fixing across sibling functions + +## When Not to Use + +- Initial Convex setup, auth setup, or component extraction +- Pure schema migrations with no performance goal +- One-off micro-optimizations without a user-visible or deployment-visible problem + +## Guardrails + +- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak +- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path +- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale + +## First Step: Gather Signals + +Start with the strongest signal available: + +1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. +2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. + - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. +3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. +4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. + +## Signal Routing + +After gathering signals, identify the problem class and read the matching reference file. + +| Signal | Reference | +| -------------------------------------------------------------- | ----------------------------------------- | +| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | +| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | +| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | +| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | +| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | + +Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. + +## Escalate Larger Fixes + +If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. + +Examples: + +- introducing digest or summary tables across multiple flows +- splitting documents to isolate frequently-updated fields +- reworking pagination or fetch strategy across several screens +- switching to a new index or denormalized field that needs migration-safe rollout + +When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. + +## Workflow + +### 1. Scope the problem + +Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. + +Write down: + +- entrypoint functions +- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` +- tables read +- tables written +- whether the path is high-read, high-write, or both + +### 2. Trace the full read and write set + +For each function in the path: + +1. Trace every `ctx.db.get()` and `ctx.db.query()` +2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` +3. Note foreign-key lookups, JS-side filtering, and full-document reads +4. Identify all sibling functions touching the same tables +5. Identify reactive stats, aggregates, or widgets rendered on the same page + +In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. + +### 3. Apply fixes from the relevant reference + +Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. + +Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. + +### 4. Fix sibling functions together + +When one function touching a table has a performance bug, audit sibling functions for the same pattern. + +After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. + +Examples: + +- If one list query switches from full docs to a digest table, inspect the other list queries for that table +- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table +- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk + +Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. + +### 5. Verify before finishing + +Confirm all of these: + +1. Results are the same as before, no dropped records +2. Eliminated reads or writes are no longer in the path where expected +3. Fallback behavior works when denormalized or indexed fields are missing +4. Frequently-updated fields are isolated from widely-read documents where needed +5. Every relevant sibling reader and writer was inspected, not just the original function + +## Reference Files + +- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables +- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting +- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads +- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size + +Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. + +## Checklist + +- [ ] Gathered signals from insights, dashboard, or code audit +- [ ] Identified the problem class and read the matching reference +- [ ] Scoped one concrete user flow or function path +- [ ] Traced every read and write in that path +- [ ] Identified sibling functions touching the same tables +- [ ] Applied fixes from the reference, following the recommended fix order +- [ ] Fixed sibling functions consistently +- [ ] Verified behavior and confirmed no regressions diff --git a/.claude/skills/convex-performance-audit/agents/openai.yaml b/.claude/skills/convex-performance-audit/agents/openai.yaml new file mode 100644 index 00000000..9a21f387 --- /dev/null +++ b/.claude/skills/convex-performance-audit/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Performance Audit" + short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#EF4444" + default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." + +policy: + allow_implicit_invocation: true diff --git a/.claude/skills/convex-performance-audit/assets/icon.svg b/.claude/skills/convex-performance-audit/assets/icon.svg new file mode 100644 index 00000000..7ab9e09c --- /dev/null +++ b/.claude/skills/convex-performance-audit/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.claude/skills/convex-performance-audit/references/function-budget.md b/.claude/skills/convex-performance-audit/references/function-budget.md new file mode 100644 index 00000000..d4d4aa5a --- /dev/null +++ b/.claude/skills/convex-performance-audit/references/function-budget.md @@ -0,0 +1,232 @@ +# Function Budget + +Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. + +## Core Principle + +Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. + +## Limits to Know + +These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. + +| Resource | Limit | +| --------------------------------- | ----------------------------------------------------- | +| Query/mutation execution time | 1 second (user code only, excludes DB operations) | +| Action execution time | 10 minutes | +| Data read per transaction | 16 MiB | +| Data written per transaction | 16 MiB | +| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | +| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | +| Documents written per transaction | 16,000 | +| Individual document size | 1 MiB | +| Function return value size | 16 MiB | + +## Symptoms + +- "Function execution took too long" errors +- "Transaction too large" or read/write set size errors +- Slow queries that read many documents +- Client receiving large payloads that slow down page load +- `npx convex insights --details` showing high bytes read + +## Common Causes + +### Unbounded collection + +A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. + +### Large document reads on hot paths + +Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. + +### Mutation doing too much work + +A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. + +### Returning too much data to the client + +A query returning full documents when the client only needs a few fields. + +## Fix Order + +### 1. Bound your reads + +Never `.collect()` without a limit on a table that can grow unbounded. + +```ts +// Bad: unbounded read, breaks as the table grows +const messages = await ctx.db.query("messages").collect(); +``` + +```ts +// Good: paginate or limit +const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", channelId)) + .order("desc") + .take(50); +``` + +### 2. Read smaller shapes + +If the list page only needs title, author, and date, do not read full documents with rich content fields. + +Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. + +### 3. Break large mutations into batches + +If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. + +```ts +// Bad: one mutation updating every row +export const backfillAll = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("items").collect(); + for (const doc of docs) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + }, +}); +``` + +```ts +// Good: cursor-based batch processing +export const backfillBatch = internalMutation({ + args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + const result = await ctx.db + .query("items") + .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); + + for (const doc of result.page) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + } + + if (!result.isDone) { + await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { + cursor: result.continueCursor, + batchSize, + }); + } + }, +}); +``` + +### 4. Move heavy work to actions + +Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. + +Actions run outside the transaction and can call mutations to write results back. + +```ts +// Bad: heavy computation inside a mutation +export const processUpload = mutation({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.db.insert("results", result); + }, +}); +``` + +```ts +// Good: action for heavy work, mutation for the write +export const processUpload = action({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.runMutation(internal.results.store, { result }); + }, +}); +``` + +### 5. Trim return values + +Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. + +```ts +// Bad: returns full documents including large content fields +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("articles").take(20); + }, +}); +``` + +```ts +// Good: project to only the fields the client needs +export const list = query({ + handler: async (ctx) => { + const articles = await ctx.db.query("articles").take(20); + return articles.map((a) => ({ + _id: a._id, + title: a.title, + author: a.author, + createdAt: a._creationTime, + })); + }, +}); +``` + +### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions + +Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. + +```ts +// Bad: unnecessary overhead from ctx.runQuery inside a mutation +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await ctx.runQuery(api.users.getCurrentUser); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +```ts +// Good: plain helper function, no extra overhead +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await getCurrentUser(ctx); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. + +### 7. Avoid unnecessary `runAction` calls + +`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). + +```ts +// Bad: runAction overhead for no reason +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await ctx.runAction(internal.items.processOne, { item }); + } + }, +}); +``` + +```ts +// Good: plain function call +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await processOneItem(ctx, { item }); + } + }, +}); +``` + +## Verification + +1. No function execution or transaction size errors +2. `npx convex insights --details` shows reduced bytes read +3. Large mutations are batched and self-scheduling +4. Client payloads are reasonably sized for the UI they serve +5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible +6. Sibling functions with similar patterns were checked diff --git a/.claude/skills/convex-performance-audit/references/hot-path-rules.md b/.claude/skills/convex-performance-audit/references/hot-path-rules.md new file mode 100644 index 00000000..e003e052 --- /dev/null +++ b/.claude/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,369 @@ +# Hot Path Rules + +Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. + +## Contents + +- Core Principle +- Consistency Rule +- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) +- 2. Minimize Data Sources (denormalization, fallback rule) +- 3. Minimize Row Size (digest tables) +- 4. Skip No-Op Writes +- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) +- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) +- Verification + +## Core Principle + +Every byte read or written multiplies with concurrency. + +Think: + +`cost x calls_per_second x 86400` + +In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. + +## Consistency Rule + +If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. + +Do this especially for: + +- multiple list queries over the same table +- multiple writers to the same table +- public browse and search queries over the same records +- helper functions reused by more than one endpoint + +## 1. Push Filters To Storage + +Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. + +Prefer: + +- `withIndex(...)` +- `.withSearchIndex(...)` for text search +- narrower tables +- summary tables + +before accepting a scan-plus-filter pattern. + +```ts +// Bad: scans then filters in JavaScript +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + const tasks = await ctx.db.query("tasks").collect(); + return tasks.filter((task) => task.status === "open"); + }, +}); +``` + +```ts +// Also bad: Convex .filter() does not push to storage either +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .filter((q) => q.eq(q.field("status"), "open")) + .collect(); + }, +}); +``` + +```ts +// Good: use an index so storage does the filtering +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .withIndex("by_status", (q) => q.eq("status", "open")) + .collect(); + }, +}); +``` + +### Migration rule for indexes + +New indexes on partially backfilled fields can create correctness bugs during rollout. + +Important Convex detail: + +`undefined !== false` + +If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. + +Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. + +If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. + +```ts +// Bad: optional booleans can miss older rows where the field is undefined +const projects = await ctx.db + .query("projects") + .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) + .order("desc") + .take(20); +``` + +```ts +// Good: switch hot-path reads only after the rollout is migration-safe +// See the migration helper skill for dual-read / backfill / cutover patterns. +``` + +### Check for redundant indexes + +Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. + +```ts +// Bad: two indexes where one would do +defineTable({ team: v.id("teams"), user: v.id("users") }) + .index("by_team", ["team"]) + .index("by_team_and_user", ["team", "user"]); +``` + +```ts +// Good: single compound index serves both query patterns +defineTable({ team: v.id("teams"), user: v.id("users") }).index( + "by_team_and_user", + ["team", "user"], +); +``` + +Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. + +## 2. Minimize Data Sources + +Trace every read. + +If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. + +### When to denormalize + +Denormalize when all of these are true: + +- the path is hot +- the joined document is much larger than the field you need +- many readers are paying that join cost repeatedly + +Useful mental model: + +`join_cost = rows_per_page x foreign_doc_size x pages_per_second` + +Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. + +### Fallback rule + +Denormalized data is an optimization. Live data is the correctness path. + +Rules: + +- If the denormalized field is missing or null, fall back to the live read +- Do not show placeholders instead of falling back +- In lookup maps, only include fully populated entries + +```ts +// Bad: missing denormalized data becomes a placeholder and blocks correctness +const ownerName = project.ownerName ?? "Unknown owner"; +``` + +```ts +// Good: denormalized data is an optimization, not the only source of truth +const ownerName = + project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null; +``` + +Bad lookup map pattern: + +```ts +const ownersById = { + [project.ownerId]: { ownerName: null }, +}; +``` + +That blocks fallback because the map says "I have data" when it does not. + +Good lookup map pattern: + +```ts +const ownersById = + project.ownerName !== undefined && project.ownerName !== null + ? { [project.ownerId]: { ownerName: project.ownerName } } + : {}; +``` + +### No denormalized copy yet + +Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. + +If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. + +Rollout order: + +1. Update schema +2. Update write path +3. Backfill +4. Switch read path + +## 3. Minimize Row Size + +Hot list pages should read the smallest document shape that still answers the UI. + +Prefer summary or digest tables over full source tables when: + +- the list page only needs a subset of fields +- source documents are large +- the query is high volume + +An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. + +Digest tables are a tradeoff, not a default: + +- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost +- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit + +```ts +// Bad: list page reads source docs, then joins owner data per row +const projects = await ctx.db + .query("projects") + .withIndex("by_public", (q) => q.eq("isPublic", true)) + .collect(); +``` + +```ts +// Good: list page reads the smaller digest shape first +const projects = await ctx.db + .query("projectDigests") + .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) + .order("desc") + .take(20); +``` + +## 4. Isolate Frequently-Updated Fields + +Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to. + +Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them. + +Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document. + +```ts +// Bad: every presence heartbeat invalidates subscribers to the whole profile +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, + lastSeen: Date.now(), +}); +``` + +```ts +// Good: keep profile reads stable, move heartbeat updates to a separate document +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, +}); + +await ctx.db.patch(presence._id, { + lastSeen: Date.now(), +}); +``` + +## 5. Match Consistency To Read Patterns + +Choose read strategy based on traffic shape. + +### High-read, low-write + +Examples: + +- public browse pages +- search results +- landing pages +- directory listings + +Prefer: + +- point-in-time reads where appropriate +- explicit refresh +- local state for pagination +- caching where appropriate + +Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. + +### High-read, high-write + +Examples: + +- collaborative editors +- live dashboards +- presence-heavy views + +Reactive queries may be worth the ongoing cost. + +## Convex-Specific Notes + +### Reactive queries + +Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. + +On the client: + +- `useQuery` creates a live subscription +- `usePaginatedQuery` creates a live subscription per page + +For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. + +### Point-in-time reads + +Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. + +Use them for: + +- aggregate snapshots +- reports +- low-churn listings +- pages where explicit refresh is fine + +### Triggers and fan-out + +Triggers fire on every write, including writes that did not materially change the document. + +When a write exists only to keep derived state in sync: + +- diff before patching +- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate + +### Aggregates + +Reactive global counts invalidate frequently on busy tables. + +Prefer: + +- one-shot aggregate fetches +- periodic recomputation +- precomputed summary rows + +for global stats that do not need live updates every second. + +### Backfills + +For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. + +Deploy code that can handle both states before running the backfill. + +During the gap: + +- writes should populate the new shape +- reads should fall back safely + +## Verification + +Before closing the audit, confirm: + +1. Same results as before, no dropped records +2. The removed table or lookup is no longer in the hot-path read set +3. Tests or validation cover fallback behavior +4. Migration safety is preserved while fields or indexes are unbackfilled +5. Sibling functions were fixed consistently diff --git a/.claude/skills/convex-performance-audit/references/occ-conflicts.md b/.claude/skills/convex-performance-audit/references/occ-conflicts.md new file mode 100644 index 00000000..1da43801 --- /dev/null +++ b/.claude/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,114 @@ +# OCC Conflict Resolution + +Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. + +## Core Principle + +Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. + +## Symptoms + +- OCC conflict errors in deployment logs or health page +- Mutations retrying multiple times before succeeding +- User-visible latency spikes on write-heavy pages +- `npx convex insights --details` showing high conflict rates + +## Common Causes + +### Hot documents + +Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. + +### Broad read sets causing false conflicts + +A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. + +### Fan-out from triggers or cascading writes + +A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. + +Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. + +### Write-then-read chains + +A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. + +## Fix Order + +### 1. Reduce read set size + +Narrower reads mean fewer false conflicts. + +```ts +// Bad: broad scan creates a wide conflict surface +const allTasks = await ctx.db.query("tasks").collect(); +const mine = allTasks.filter((t) => t.ownerId === userId); +``` + +```ts +// Good: indexed query touches only relevant documents +const mine = await ctx.db + .query("tasks") + .withIndex("by_owner", (q) => q.eq("ownerId", userId)) + .collect(); +``` + +### 2. Split hot documents + +When many writers target the same document, split the contention point. + +```ts +// Bad: every vote increments the same counter document +const counter = await ctx.db.get(pollCounterId); +await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); +``` + +```ts +// Good: shard the counter across multiple documents, aggregate on read +const shardIndex = Math.floor(Math.random() * SHARD_COUNT); +const shardId = shardIds[shardIndex]; +const shard = await ctx.db.get(shardId); +await ctx.db.patch(shardId, { count: shard!.count + 1 }); +``` + +Aggregate the shards in a query or scheduled job when you need the total. + +### 3. Move non-critical work to scheduled functions + +If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. + +```ts +// Bad: canonical write and derived work happen in the same transaction +await ctx.db.patch(userId, { name: args.name }); +await ctx.db.insert("userUpdateAnalytics", { + userId, + kind: "name_changed", + name: args.name, +}); +``` + +```ts +// Good: keep the primary write small, defer the analytics work +await ctx.db.patch(userId, { name: args.name }); +await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, { + userId, + name: args.name, +}); +``` + +### 4. Combine competing writes + +If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. + +Do not introduce artificial locks or queues unless the above steps have been tried first. + +## Related: Invalidation Scope + +Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. + +## Verification + +1. OCC conflict rate has dropped in insights or dashboard +2. Mutation latency is lower and more consistent +3. No data correctness regressions from splitting or scheduling changes +4. Sibling writers to the same hot documents were fixed consistently diff --git a/.claude/skills/convex-performance-audit/references/subscription-cost.md b/.claude/skills/convex-performance-audit/references/subscription-cost.md new file mode 100644 index 00000000..ae7d1adb --- /dev/null +++ b/.claude/skills/convex-performance-audit/references/subscription-cost.md @@ -0,0 +1,252 @@ +# Subscription Cost + +Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. + +## Core Principle + +Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: + +`subscriptions x invalidation_frequency x query_cost` + +Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. + +## Symptoms + +- Dashboard shows high active subscription count +- UI feels sluggish or laggy despite fast individual queries +- React profiling shows frequent re-renders from Convex state +- Pages with many components each running their own `useQuery` +- Paginated lists where every loaded page stays subscribed + +## Common Causes + +### Reactive queries on low-freshness flows + +Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. + +### Overly broad queries + +A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. + +### Too many subscriptions per page + +A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. + +### Paginated queries keeping all pages live + +`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. + +### Frequently-updated fields on widely-read documents + +A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. + +## Fix Order + +### 1. Use point-in-time reads when live updates are not valuable + +Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. + +Consider a point-in-time read instead when all of these are true: + +- the flow is high-read +- the underlying data changes less often than users need to see +- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable + +Possible implementations depend on environment: + +- a server-rendered fetch +- a framework helper like `fetchQuery` +- a point-in-time client read such as `ConvexHttpClient.query()` + +```ts +// Reactive by default when fresh live data matters +function TeamPresence() { + const presence = useQuery(api.teams.livePresence, { teamId }); + return ; +} +``` + +```ts +// Point-in-time read when explicit refresh is acceptable +import { ConvexHttpClient } from "convex/browser"; + +const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); + +function SnapshotView() { + const [items, setItems] = useState([]); + + useEffect(() => { + client.query(api.items.snapshot).then(setItems); + }, []); + + return ; +} +``` + +Good candidates for point-in-time reads: + +- aggregate snapshots +- reports +- low-churn listings +- flows where explicit refresh is already acceptable + +Keep reactive for: + +- collaborative editing +- live dashboards +- presence-heavy views +- any surface where users expect fresh changes to appear automatically + +### 2. Batch related data into fewer queries + +Instead of N components each fetching their own related data, fetch it in a single query. + +```ts +// Bad: each card fetches its own author +function ProjectCard({ project }: { project: Project }) { + const author = useQuery(api.users.get, { id: project.authorId }); + return ; +} +``` + +```ts +// Good: parent query returns projects with author names included +function ProjectList() { + const projects = useQuery(api.projects.listWithAuthors); + return projects?.map((p) => ( + + )); +} +``` + +This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. + +This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. + +### 3. Use skip to avoid unnecessary subscriptions + +The `"skip"` value prevents a subscription from being created when the arguments are not ready. + +```ts +// Bad: subscribes with undefined args, wastes a subscription slot +const profile = useQuery(api.users.getProfile, { userId: selectedId! }); +``` + +```ts +// Good: skip when there is nothing to fetch +const profile = useQuery( + api.users.getProfile, + selectedId ? { userId: selectedId } : "skip", +); +``` + +### 4. Isolate frequently-updated fields into separate documents + +If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. + +```ts +// Bad: lastSeen lives on the user doc, every heartbeat invalidates +// every query that reads this user +const users = defineTable({ + name: v.string(), + email: v.string(), + lastSeen: v.number(), +}); +``` + +```ts +// Good: lastSeen lives in a separate heartbeat doc +const users = defineTable({ + name: v.string(), + email: v.string(), + heartbeatId: v.id("heartbeats"), +}); + +const heartbeats = defineTable({ + lastSeen: v.number(), +}); +``` + +Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. + +For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. + +### 5. Use the aggregate component for counts and sums + +Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. + +Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. + +If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. + +### 6. Narrow query read sets + +Queries that return less data and touch fewer documents invalidate less often. + +```ts +// Bad: returns all fields, invalidates on any field change +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("projects").collect(); + }, +}); +``` + +```ts +// Good: use a digest table with only the fields the list needs +export const listDigests = query({ + handler: async (ctx) => { + return await ctx.db.query("projectDigests").collect(); + }, +}); +``` + +Writes to fields not in the digest table do not invalidate the digest query. + +### 7. Remove `Date.now()` from queries + +Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. + +```ts +// Bad: Date.now() defeats query caching and causes frequent re-evaluation +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) + .take(100); +``` + +```ts +// Good: use a boolean field updated by a scheduled function +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_is_released", (q) => q.eq("isReleased", true)) + .take(100); +``` + +If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. + +### 8. Consider pagination strategy + +For long lists where users scroll through many pages: + +- If the data does not need live updates, use point-in-time fetching with manual "load more" +- If it does need live updates, accept the subscription cost but limit the number of loaded pages +- Consider whether older pages can be unloaded as the user scrolls forward + +### 9. Separate backend cost from UI churn + +If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. + +Treat this as a UX problem first when: + +- the underlying query is already reasonably cheap +- the complaint is flicker, loading flashes, or re-render churn +- live updates are still desirable once fresh data arrives + +## Verification + +1. Subscription count in dashboard is lower for the affected pages +2. UI responsiveness has improved +3. React profiling shows fewer unnecessary re-renders +4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily +5. Sibling pages with similar patterns were updated consistently diff --git a/.claude/skills/convex-quickstart b/.claude/skills/convex-quickstart deleted file mode 120000 index 9edf1975..00000000 --- a/.claude/skills/convex-quickstart +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-quickstart \ No newline at end of file diff --git a/.claude/skills/convex-quickstart/SKILL.md b/.claude/skills/convex-quickstart/SKILL.md new file mode 100644 index 00000000..5bff17bc --- /dev/null +++ b/.claude/skills/convex-quickstart/SKILL.md @@ -0,0 +1,347 @@ +--- +name: convex-quickstart +description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +--- + +# Convex Quickstart + +Set up a working Convex project as fast as possible. + +## When to Use + +- Starting a brand new project with Convex +- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app +- Scaffolding a Convex app for prototyping + +## When Not to Use + +- The project already has Convex installed and `convex/` exists - just start building +- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill + +## Workflow + +1. Determine the starting point: new project or existing app +2. If new project, pick a template and scaffold with `npm create convex@latest` +3. If existing app, install `convex` and wire up the provider +4. Run `npx convex dev` to connect a deployment and start the dev loop +5. Verify the setup works + +## Path 1: New Project (Recommended) + +Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. + +### Pick a template + +| Template | Stack | +| -------------------------- | ----------------------------------------- | +| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | +| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | +| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | +| `nextjs-clerk` | Next.js + Clerk auth | +| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | +| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | +| `bare` | Convex backend only, no frontend | + +If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. + +You can also use any GitHub repo as a template: + +```bash +npm create convex@latest my-app -- -t owner/repo +npm create convex@latest my-app -- -t owner/repo#branch +``` + +### Scaffold the project + +Always pass the project name and template flag to avoid interactive prompts: + +```bash +npm create convex@latest my-app -- -t react-vite-shadcn +cd my-app +npm install +``` + +The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. + +To scaffold in the current directory (if it is empty): + +```bash +npm create convex@latest . -- -t react-vite-shadcn +npm install +``` + +### Start the dev loop + +`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. + +**Ask the user to run this themselves:** + +Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: + +- Create a Convex project and dev deployment +- Write the deployment URL to `.env.local` +- Create the `convex/` directory with generated types +- Watch for changes and sync continuously + +The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. + +**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. + +### Start the frontend + +The user should also run the frontend dev server in a separate terminal: + +```bash +npm run dev +``` + +Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. + +### What you get + +After scaffolding, the project structure looks like: + +``` +my-app/ + convex/ # Backend functions and schema + _generated/ # Auto-generated types (check this into git) + schema.ts # Database schema (if template includes one) + src/ # Frontend code (or app/ for Next.js) + package.json + .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL +``` + +The template already has: + +- `ConvexProvider` wired into the app root +- Correct env var names for the framework +- Tailwind and shadcn/ui ready (for shadcn templates) +- Auth provider configured (for auth templates) + +Proceed to adding schema, functions, and UI. + +## Path 2: Add Convex to an Existing App + +Use this when the user already has a frontend project and wants to add Convex as the backend. + +### Install + +```bash +npm install convex +``` + +### Initialize and start dev loop + +Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. + +### Wire up the provider + +The Convex client must wrap the app at the root. The setup varies by framework. + +Create the `ConvexReactClient` at module scope, not inside a component: + +```tsx +// Bad: re-creates the client on every render +function App() { + const convex = new ConvexReactClient( + import.meta.env.VITE_CONVEX_URL as string, + ); + return ...; +} + +// Good: created once at module scope +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); +function App() { + return ...; +} +``` + +#### React (Vite) + +```tsx +// src/main.tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import App from "./App"; + +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); + +createRoot(document.getElementById("root")!).render( + + + + + , +); +``` + +#### Next.js (App Router) + +```tsx +// app/ConvexClientProvider.tsx +"use client"; + +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return {children}; +} +``` + +```tsx +// app/layout.tsx +import { ConvexClientProvider } from "./ConvexClientProvider"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} +``` + +#### Other frameworks + +For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: + +- [Vue](https://docs.convex.dev/quickstart/vue) +- [Svelte](https://docs.convex.dev/quickstart/svelte) +- [React Native](https://docs.convex.dev/quickstart/react-native) +- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) +- [Remix](https://docs.convex.dev/quickstart/remix) +- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) + +### Environment variables + +The env var name depends on the framework: + +| Framework | Variable | +| ------------ | ------------------------ | +| Vite | `VITE_CONVEX_URL` | +| Next.js | `NEXT_PUBLIC_CONVEX_URL` | +| Remix | `CONVEX_URL` | +| React Native | `EXPO_PUBLIC_CONVEX_URL` | + +`npx convex dev` writes the correct variable to `.env.local` automatically. + +## Agent Mode (Cloud and Headless Agents) + +When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. + +Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: + +```bash +CONVEX_AGENT_MODE=anonymous npx convex dev +``` + +This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. + +## Verify the Setup + +After setup, confirm everything is working: + +1. The user confirms `npx convex dev` is running without errors +2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` +3. `.env.local` contains the deployment URL + +## Writing Your First Function + +Once the project is set up, create a schema and a query to verify the full loop works. + +`convex/schema.ts`: + +```ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + tasks: defineTable({ + text: v.string(), + completed: v.boolean(), + }), +}); +``` + +`convex/tasks.ts`: + +```ts +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("tasks").collect(); + }, +}); + +export const create = mutation({ + args: { text: v.string() }, + handler: async (ctx, args) => { + await ctx.db.insert("tasks", { text: args.text, completed: false }); + }, +}); +``` + +Use in a React component (adjust the import path based on your file location relative to `convex/`): + +```tsx +import { useQuery, useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function Tasks() { + const tasks = useQuery(api.tasks.list); + const create = useMutation(api.tasks.create); + + return ( +
+ + {tasks?.map((t) => ( +
{t.text}
+ ))} +
+ ); +} +``` + +## Development vs Production + +Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. + +When ready to ship, deploy to production: + +```bash +npx convex deploy +``` + +This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. + +## Next Steps + +- Add authentication: use the `convex-setup-auth` skill +- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) +- Build components: use the `convex-create-component` skill +- Plan a migration: use the `convex-migration-helper` skill +- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) +- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) + +## Checklist + +- [ ] Determined starting point: new project or existing app +- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template +- [ ] If existing app: installed `convex` and wired up the provider +- [ ] User has `npx convex dev` running and connected to a deployment +- [ ] `convex/_generated/` directory exists with types +- [ ] `.env.local` has the deployment URL +- [ ] Verified a basic query/mutation round-trip works diff --git a/.claude/skills/convex-quickstart/agents/openai.yaml b/.claude/skills/convex-quickstart/agents/openai.yaml new file mode 100644 index 00000000..a51a6d09 --- /dev/null +++ b/.claude/skills/convex-quickstart/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Quickstart" + short_description: "Start a new Convex app or add Convex to an existing frontend." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#F97316" + default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." + +policy: + allow_implicit_invocation: true diff --git a/.claude/skills/convex-quickstart/assets/icon.svg b/.claude/skills/convex-quickstart/assets/icon.svg new file mode 100644 index 00000000..d83a73f3 --- /dev/null +++ b/.claude/skills/convex-quickstart/assets/icon.svg @@ -0,0 +1,4 @@ + diff --git a/.claude/skills/convex-setup-auth b/.claude/skills/convex-setup-auth deleted file mode 120000 index a19c8377..00000000 --- a/.claude/skills/convex-setup-auth +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-setup-auth \ No newline at end of file diff --git a/.claude/skills/convex-setup-auth/SKILL.md b/.claude/skills/convex-setup-auth/SKILL.md new file mode 100644 index 00000000..0d1d9dd6 --- /dev/null +++ b/.claude/skills/convex-setup-auth/SKILL.md @@ -0,0 +1,150 @@ +--- +name: convex-setup-auth +description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +--- + +# Convex Authentication Setup + +Implement secure authentication in Convex with user management and access control. + +## When to Use + +- Setting up authentication for the first time +- Implementing user management (users table, identity mapping) +- Creating authentication helper functions +- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) + +## When Not to Use + +- Auth for a non-Convex backend +- Pure OAuth/OIDC documentation without a Convex implementation +- Debugging unrelated bugs that happen to surface near auth code +- The auth provider is already fully configured and the user only needs a one-line fix + +## First Step: Choose the Auth Provider + +Convex supports multiple authentication approaches. Do not assume a provider. + +Before writing setup code: + +1. Ask the user which auth solution they want, unless the repository already makes it obvious +2. If the repo already uses a provider, continue with that provider unless the user wants to switch +3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding + +Common options: + +- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex +- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features +- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically +- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 +- Custom JWT provider - use when integrating an existing auth system not covered above + +Look for signals in the repo before asking: + +- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages +- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components +- Environment variables that clearly point at a provider + +## After Choosing a Provider + +Read the provider's official guide and the matching local reference file: + +- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` +- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` +- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` +- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` + +The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. + +Use those sources for: + +- package installation +- client provider wiring +- environment variables +- `convex/auth.config.ts` setup +- login and logout UI patterns +- framework-specific setup for React, Vite, or Next.js + +For shared auth behavior, use the official Convex docs as the source of truth: + +- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` +- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage +- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance +- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth + +Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. +For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. +For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. +After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. + +## Core Pattern: Protecting Backend Functions + +The most common auth task is checking identity in Convex functions. + +```ts +// Bad: trusting a client-provided userId +export const getMyProfile = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.userId); + }, +}); +``` + +```ts +// Good: verifying identity server-side +export const getMyProfile = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + return await ctx.db + .query("users") + .withIndex("by_tokenIdentifier", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier), + ) + .unique(); + }, +}); +``` + +## Workflow + +1. Determine the provider, either by asking the user or inferring from the repo +2. Ask whether the user wants local-only setup or production-ready setup now +3. Read the matching provider reference file +4. Follow the official provider docs for current setup details +5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns +6. Only add app-level user storage if the docs and app requirements call for it +7. Add authorization checks for ownership, roles, or team access only where the app needs them +8. Verify login state, protected queries, environment variables, and production configuration if requested + +If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. +For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. +If the environment has browser automation tools, you can use them. +If it does not, give the user a short manual validation checklist instead. + +## Reference Files + +### Provider References + +- `references/convex-auth.md` +- `references/clerk.md` +- `references/workos-authkit.md` +- `references/auth0.md` + +## Checklist + +- [ ] Chosen the correct auth provider before writing setup code +- [ ] Read the relevant provider reference file +- [ ] Asked whether the user wants local-only setup or production-ready setup +- [ ] Used the official provider docs for provider-specific wiring +- [ ] Used the official Convex docs for shared auth behavior and authorization patterns +- [ ] Only added app-level user storage if the app actually needs it +- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth +- [ ] Added authentication checks in protected backend functions +- [ ] Added authorization checks where the app actually needs them +- [ ] Clear error messages ("Not authenticated", "Unauthorized") +- [ ] Client auth provider configured for the chosen provider +- [ ] If requested, production auth setup is covered too diff --git a/.claude/skills/convex-setup-auth/agents/openai.yaml b/.claude/skills/convex-setup-auth/agents/openai.yaml new file mode 100644 index 00000000..d1c90a14 --- /dev/null +++ b/.claude/skills/convex-setup-auth/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Setup Auth" + short_description: "Set up Convex auth, user identity mapping, and access control." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." + +policy: + allow_implicit_invocation: true diff --git a/.claude/skills/convex-setup-auth/assets/icon.svg b/.claude/skills/convex-setup-auth/assets/icon.svg new file mode 100644 index 00000000..4917dbb4 --- /dev/null +++ b/.claude/skills/convex-setup-auth/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.claude/skills/convex-setup-auth/references/auth0.md b/.claude/skills/convex-setup-auth/references/auth0.md new file mode 100644 index 00000000..9c729c5a --- /dev/null +++ b/.claude/skills/convex-setup-auth/references/auth0.md @@ -0,0 +1,116 @@ +# Auth0 + +Official docs: + +- https://docs.convex.dev/auth/auth0 +- https://auth0.github.io/auth0-cli/ +- https://auth0.github.io/auth0-cli/auth0_apps_create.html + +Use this when the app already uses Auth0 or the user wants Auth0 specifically. + +## Workflow + +1. Confirm the user wants Auth0 +2. Determine the app framework and whether Auth0 is already partly set up +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and Auth0 guides before making changes +5. Ask whether they want the fastest setup path by installing the Auth0 CLI +6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI +7. If they do not want the CLI path, use the Auth0 dashboard path instead +8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up +9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID +10. Set environment variables for local and production environments +11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +12. Gate Convex-backed UI with Convex auth state +13. Try to verify Convex reports the user as authenticated after Auth0 login +14. If the refresh-token path fails, stop improvising and send the user back to the official docs +15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered + +## What To Do + +- Read the official Convex and Auth0 guide before writing setup code +- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet +- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" +- Make sure the app has already completed the relevant Auth0 quickstart for its frontend +- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` +- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated + +## Key Setup Areas + +- install the Auth0 SDK for the app's framework +- configure `convex/auth.config.ts` with the Auth0 domain and client ID +- set environment variables for local and production environments +- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +- use Convex auth state when gating Convex-backed UI + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- frontend app entry or provider wrapper +- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` +- Auth0 environment variables commonly include: + - `AUTH0_DOMAIN` + - `AUTH0_CLIENT_ID` + - `VITE_AUTH0_DOMAIN` + - `VITE_AUTH0_CLIENT_ID` + +## Concrete Steps + +1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework +2. Ask whether the user wants the Auth0 CLI path +3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` +4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app +5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard +6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard +7. Install the Auth0 SDK for the app's framework +8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID +9. Set frontend and backend environment variables +10. Wrap the app in `Auth0Provider` +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` +12. Run the normal Convex dev or deploy flow after backend config changes +13. Try the official provider config shown in the Convex docs +14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now +15. Only claim success if the user can sign in and Convex recognizes the authenticated session +16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too + +## Gotchas + +- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch +- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant +- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard +- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced +- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end +- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled +- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation +- Keep dev and prod tenants separate if the project uses different Auth0 environments +- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. +- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. +- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. +- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered +- Verify production environment variables and redirect settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the Auth0 login flow +- Verify Convex-authenticated UI renders only after Convex auth state is ready +- Verify protected Convex queries succeed after login +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- Verify the Auth0 app settings match the real local callback and logout URLs during development +- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully +- If production-ready setup was requested, verify the production Auth0 configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Auth0 +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Complete the relevant Auth0 frontend setup +- [ ] Configure `convex/auth.config.ts` +- [ ] Set environment variables +- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs +- [ ] If requested, configure the production deployment too diff --git a/.claude/skills/convex-setup-auth/references/clerk.md b/.claude/skills/convex-setup-auth/references/clerk.md new file mode 100644 index 00000000..7dbde194 --- /dev/null +++ b/.claude/skills/convex-setup-auth/references/clerk.md @@ -0,0 +1,113 @@ +# Clerk + +Official docs: + +- https://docs.convex.dev/auth/clerk +- https://clerk.com/docs/guides/development/integrations/databases/convex + +Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. + +## Workflow + +1. Confirm the user wants Clerk +2. Make sure the user has a Clerk account and a Clerk application +3. Determine the app framework: + - React + - Next.js + - TanStack Start +4. Ask whether the user wants local-only setup or production-ready setup now +5. Gather the Clerk keys and the Clerk Frontend API URL +6. Follow the correct framework section in the official docs +7. Complete the backend and client wiring +8. Verify Convex reports the user as authenticated after login +9. If the user wants production-ready setup, make sure the production Clerk config is also covered + +## What To Do + +- Read the official Convex and Clerk guide before writing setup code +- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application +- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active +- Match the guide to the app's framework, usually React, Next.js, or TanStack Start +- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` + +## Key Setup Areas + +- install the Clerk SDK for the framework in use +- configure `convex/auth.config.ts` with the Clerk issuer domain +- set the required Clerk environment variables +- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` +- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- React or Vite client entry such as `src/main.tsx` +- Next.js client wrapper for Convex if using App Router +- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` +- Clerk app creation page: `https://dashboard.clerk.com/apps/new` +- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` +- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` +- Clerk environment variables: + - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs + - `CLERK_FRONTEND_API_URL` in the Clerk docs + - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps + - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps + - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required + +`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. + +## Concrete Steps + +1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` +2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` +3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed +4. Open `https://dashboard.clerk.com/apps/setup/convex` +5. Activate the Convex integration in Clerk if it is not already active +6. Copy the Clerk Frontend API URL shown there +7. Install the Clerk package for the app's framework +8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens +9. Set the publishable key in the frontend environment +10. Set the issuer domain or Frontend API URL so Convex can validate the JWT +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` +12. Wrap the app in `ClerkProvider` +13. Use Convex auth helpers for authenticated rendering +14. Run the normal Convex dev or deploy flow after updating backend auth config +15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too + +## Gotchas + +- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render +- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper +- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config +- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. +- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. +- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. +- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. +- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` +- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included +- Verify production redirect URLs and any production Clerk domain values before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can sign in with Clerk +- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in +- Verify `useConvexAuth()` reaches the authenticated state after Clerk login +- Verify protected Convex queries run successfully inside authenticated UI +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- If production-ready setup was requested, verify the production Clerk configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Clerk +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Follow the correct framework section in the official guide +- [ ] Set Clerk environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify Convex authenticated state after login +- [ ] If requested, configure the production deployment too diff --git a/.claude/skills/convex-setup-auth/references/convex-auth.md b/.claude/skills/convex-setup-auth/references/convex-auth.md new file mode 100644 index 00000000..d4824d24 --- /dev/null +++ b/.claude/skills/convex-setup-auth/references/convex-auth.md @@ -0,0 +1,143 @@ +# Convex Auth + +Official docs: https://docs.convex.dev/auth/convex-auth +Setup guide: https://labs.convex.dev/auth/setup + +Use this when the user wants auth handled directly in Convex rather than through a third-party provider. + +## Workflow + +1. Confirm the user wants Convex Auth specifically +2. Determine which sign-in methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords and password reset +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the Convex Auth setup guide before writing code +5. Make sure the project has a configured Convex deployment: + - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set + - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing +6. Install the auth packages: + - `npm install @convex-dev/auth @auth/core@0.37.0` +7. Run the initialization command: + - `npx @convex-dev/auth` +8. Confirm the initializer created: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +9. Add the required `authTables` to `convex/schema.ts` +10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` +11. Configure at least one auth method in `convex/auth.ts` +12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code +13. Verify the client can sign in successfully +14. Verify Convex receives authenticated identity in backend functions +15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well +16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex + +## What This Reference Is For + +- choosing Convex Auth as the default provider for a new Convex app +- understanding whether the app wants magic links, OTPs, OAuth, or passwords +- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior + +## What To Do + +- Read the Convex Auth setup guide before writing setup code +- Follow the setup flow from the docs rather than recreating it from memory +- If the app is new, consider starting from the official starter flow instead of hand-wiring everything +- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra + +## Concrete Steps + +1. Install `@convex-dev/auth` and `@auth/core@0.37.0` +2. Run `npx convex dev` if the project does not already have a configured deployment +3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment +4. Run `npx @convex-dev/auth` +5. Confirm the generated auth setup is present before continuing: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +6. Add `authTables` to `convex/schema.ts` +7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry +8. Configure the selected auth methods in `convex/auth.ts` +9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed +10. Verify login locally +11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment + +## Expected Files and Decisions + +- `convex/schema.ts` +- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file +- generated Convex Auth setup produced by `npx @convex-dev/auth` +- an existing configured Convex deployment, or the ability to create one with `npx convex dev` +- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods + +- Decide whether the user is creating a new app or adding auth to an existing app +- For a new app, prefer the official starter flow instead of rebuilding setup by hand +- Decide which auth methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords +- Decide whether the user wants local-only setup or production-ready setup now +- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough + +## Gotchas + +- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. +- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. +- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. +- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. +- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. +- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. +- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. +- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. +- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. +- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment +- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Human Handoff + +If `npx convex dev` or deployment setup requires human input: + +- stop and explain exactly what the user needs to do +- say why that step is required +- resume the auth setup immediately after the user confirms it is done + +## Validation + +- Verify the user can complete a sign-in flow +- Offer to validate sign up, sign out, and sign back in with the configured auth method +- If browser automation is available in the environment, you can do this directly +- If browser automation is not available, give the user a short manual validation checklist instead +- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions +- Verify protected UI only renders after Convex-authenticated state is ready +- Verify environment variables and redirect settings match the current app environment +- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in +- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed +- If production-ready setup was requested, verify the production deployment is also configured correctly + +## Checklist + +- [ ] Confirm the user wants Convex Auth specifically +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Ensure a Convex deployment is configured before running auth initialization +- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` +- [ ] Run `npx convex dev` first if needed +- [ ] Run `npx @convex-dev/auth` +- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created +- [ ] Follow the setup guide for package install and wiring +- [ ] Add `authTables` to `convex/schema.ts` +- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` +- [ ] Configure at least one auth method in `convex/auth.ts` +- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes +- [ ] Confirm which sign-in methods the app needs +- [ ] Verify the client can sign in and the backend receives authenticated identity +- [ ] Offer end-to-end validation of sign up, sign out, and sign back in +- [ ] If requested, configure the production deployment too +- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/.claude/skills/convex-setup-auth/references/workos-authkit.md b/.claude/skills/convex-setup-auth/references/workos-authkit.md new file mode 100644 index 00000000..038cb9f3 --- /dev/null +++ b/.claude/skills/convex-setup-auth/references/workos-authkit.md @@ -0,0 +1,114 @@ +# WorkOS AuthKit + +Official docs: + +- https://docs.convex.dev/auth/authkit/ +- https://docs.convex.dev/auth/authkit/add-to-app +- https://docs.convex.dev/auth/authkit/auto-provision + +Use this when the app already uses WorkOS or the user wants AuthKit specifically. + +## Workflow + +1. Confirm the user wants WorkOS AuthKit +2. Determine whether they want: + - a Convex-managed WorkOS team + - an existing WorkOS team +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and WorkOS AuthKit guide +5. Create or update `convex.json` for the app's framework and real local port +6. Follow the correct branch of the setup flow based on that choice +7. Configure the required WorkOS environment variables +8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs +9. Wire the client provider and callback flow +10. Verify authenticated requests reach Convex +11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too +12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex + +## What To Do + +- Read the official Convex and WorkOS AuthKit guide before writing setup code +- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team +- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra +- Follow the current setup flow from the docs instead of relying on older examples + +## Key Setup Areas + +- package installation for the app's framework +- `convex.json` with the `authKit` section for dev, and preview or prod if needed +- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration +- `convex/auth.config.ts` wiring for WorkOS-issued JWTs +- client provider setup and token flow into Convex +- login callback and redirect configuration + +## Files and Env Vars To Expect + +- `convex.json` +- `convex/auth.config.ts` +- frontend auth provider wiring +- callback or redirect route setup where the framework requires it +- WorkOS environment variables commonly include: + - `WORKOS_CLIENT_ID` + - `WORKOS_API_KEY` + - `WORKOS_COOKIE_PASSWORD` + - `VITE_WORKOS_CLIENT_ID` + - `VITE_WORKOS_REDIRECT_URI` + - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` + +For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. + +## Concrete Steps + +1. Choose Convex-managed or existing WorkOS team +2. Create or update `convex.json` with the `authKit` section for the framework in use +3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port +4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow +5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` +6. Create or update `convex/auth.config.ts` for WorkOS JWT validation +7. Run the normal Convex dev or deploy flow so backend config is synced +8. Wire the WorkOS client provider in the app +9. Configure callback and redirect handling +10. Verify the user can sign in and return to the app +11. Verify Convex sees the authenticated user after login +12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too + +## Gotchas + +- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious +- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys +- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex +- Do not mix dev and prod WorkOS credentials or redirect URIs +- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it +- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. +- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. +- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. +- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. +- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered +- Verify the production redirect and callback settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the login flow and return to the app +- Verify the callback URL matches the real frontend port in local dev +- Verify Convex receives authenticated requests after login +- Verify `convex.json` matches the framework and chosen WorkOS setup path +- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path +- Verify environment variables differ correctly between local and production where needed +- If production-ready setup was requested, verify the production WorkOS configuration is also covered + +## Checklist + +- [ ] Confirm the user wants WorkOS AuthKit +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Choose Convex-managed or existing WorkOS team +- [ ] Create or update `convex.json` +- [ ] Configure WorkOS environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify authenticated requests reach Convex after login +- [ ] If requested, configure the production deployment too diff --git a/.windsurf/skills/convex-create-component b/.windsurf/skills/convex-create-component deleted file mode 120000 index dfa8244f..00000000 --- a/.windsurf/skills/convex-create-component +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-create-component \ No newline at end of file diff --git a/.windsurf/skills/convex-create-component/SKILL.md b/.windsurf/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..22af601f --- /dev/null +++ b/.windsurf/skills/convex-create-component/SKILL.md @@ -0,0 +1,288 @@ +--- +name: convex-create-component +description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +--- + +# Convex Create Component + +Create reusable Convex components with clear boundaries and a small app-facing API. + +## When to Use + +- Creating a new Convex component in an existing app +- Extracting reusable backend logic into a component +- Building a third-party integration that should own its own tables and workflows +- Packaging Convex functionality for reuse across multiple apps + +## When Not to Use + +- One-off business logic that belongs in the main app +- Thin utilities that do not need Convex tables or functions +- App-level orchestration that should stay in `convex/` +- Cases where a normal TypeScript library is enough + +## Workflow + +1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. +2. Choose the shape using the decision tree below and read the matching reference file. +3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. +4. Make a short plan for: + - what tables the component owns + - what public functions it exposes + - what data must be passed in from the app (auth, env vars, parent IDs) + - what stays in the app as wrappers or HTTP mounts +5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. +6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. +7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. +8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. +9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. +10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. + +## Choose the Shape + +Ask the user, then pick one path: + +| Goal | Shape | Reference | +| ------------------------------------------------- | ---------------- | ----------------------------------- | +| Component for this app only | Local | `references/local-components.md` | +| Publish or share across apps | Packaged | `references/packaged-components.md` | +| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | +| Not sure | Default to local | `references/local-components.md` | + +Read exactly one reference file before proceeding. + +## Default Approach + +Unless the user explicitly wants an npm package, default to a local component: + +- Put it under `convex/components//` +- Define it with `defineComponent(...)` in its own `convex.config.ts` +- Install it from the app's `convex/convex.config.ts` with `app.use(...)` +- Let `npx convex dev` generate the component's own `_generated/` files + +## Component Skeleton + +A minimal local component with a table and two functions, plus the app wiring. + +```ts +// convex/components/notifications/convex.config.ts +import { defineComponent } from "convex/server"; + +export default defineComponent("notifications"); +``` + +```ts +// convex/components/notifications/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + notifications: defineTable({ + userId: v.string(), + message: v.string(), + read: v.boolean(), + }).index("by_user", ["userId"]), +}); +``` + +```ts +// convex/components/notifications/lib.ts +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; + +export const send = mutation({ + args: { userId: v.string(), message: v.string() }, + returns: v.id("notifications"), + handler: async (ctx, args) => { + return await ctx.db.insert("notifications", { + userId: args.userId, + message: args.message, + read: false, + }); + }, +}); + +export const listUnread = query({ + args: { userId: v.string() }, + returns: v.array( + v.object({ + _id: v.id("notifications"), + _creationTime: v.number(), + userId: v.string(), + message: v.string(), + read: v.boolean(), + }), + ), + handler: async (ctx, args) => { + return await ctx.db + .query("notifications") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .filter((q) => q.eq(q.field("read"), false)) + .collect(); + }, +}); +``` + +```ts +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import notifications from "./components/notifications/convex.config.js"; + +const app = defineApp(); +app.use(notifications); + +export default app; +``` + +```ts +// convex/notifications.ts (app-side wrapper) +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { components } from "./_generated/api"; +import { getAuthUserId } from "@convex-dev/auth/server"; + +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); + +export const myUnread = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + return await ctx.runQuery(components.notifications.lib.listUnread, { + userId, + }); + }, +}); +``` + +Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. + +## Critical Rules + +- Keep authentication in the app, because `ctx.auth` is not available inside components. +- Keep environment access in the app, because component functions cannot read `process.env`. +- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. +- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. +- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. +- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. +- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. +- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. +- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. + +## Patterns + +### Authentication and environment access + +```ts +// Bad: component code cannot rely on app auth or env +const identity = await ctx.auth.getUserIdentity(); +const apiKey = process.env.OPENAI_API_KEY; +``` + +```ts +// Good: the app resolves auth and env, then passes explicit values +const userId = await getAuthUserId(ctx); +if (!userId) throw new Error("Not authenticated"); + +await ctx.runAction(components.translator.translate, { + userId, + apiKey: process.env.OPENAI_API_KEY, + text: args.text, +}); +``` + +### Client-facing API + +```ts +// Bad: assuming a component function is directly callable by clients +export const send = components.notifications.send; +``` + +```ts +// Good: re-export through an app mutation or query +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); +``` + +### IDs across the boundary + +```ts +// Bad: parent app table IDs are not valid component validators +args: { + userId: v.id("users"); +} +``` + +```ts +// Good: treat parent-owned IDs as strings at the boundary +args: { + userId: v.string(); +} +``` + +### Advanced Patterns + +For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. + +## Validation + +Try validation in this order: + +1. `npx convex codegen --component-dir convex/components/` +2. `npx convex codegen` +3. `npx convex dev` + +Important: + +- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. +- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. +- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. + +## Reference Files + +Read exactly one of these after the user confirms the goal: + +- `references/local-components.md` +- `references/packaged-components.md` +- `references/hybrid-components.md` + +Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) + +## Checklist + +- [ ] Asked the user what they want to build and confirmed the shape +- [ ] Read the matching reference file +- [ ] Confirmed a component is the right abstraction +- [ ] Planned tables, public API, boundaries, and app wrappers +- [ ] Component lives under `convex/components//` (or package layout if publishing) +- [ ] Component imports from its own `./_generated/server` +- [ ] Auth, env access, and HTTP routes stay in the app +- [ ] Parent app IDs cross the boundary as `v.string()` +- [ ] Public functions have `args` and `returns` validators +- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/.windsurf/skills/convex-create-component/agents/openai.yaml b/.windsurf/skills/convex-create-component/agents/openai.yaml new file mode 100644 index 00000000..ba9287e4 --- /dev/null +++ b/.windsurf/skills/convex-create-component/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Create Component" + short_description: "Design and build reusable Convex components with clear boundaries." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#14B8A6" + default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." + +policy: + allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-create-component/assets/icon.svg b/.windsurf/skills/convex-create-component/assets/icon.svg new file mode 100644 index 00000000..10f4c2c4 --- /dev/null +++ b/.windsurf/skills/convex-create-component/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.windsurf/skills/convex-create-component/references/advanced-patterns.md b/.windsurf/skills/convex-create-component/references/advanced-patterns.md new file mode 100644 index 00000000..3deb684c --- /dev/null +++ b/.windsurf/skills/convex-create-component/references/advanced-patterns.md @@ -0,0 +1,134 @@ +# Advanced Component Patterns + +Additional patterns for Convex components that go beyond the basics covered in the main skill file. + +## Function Handles for callbacks + +When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. + +```ts +// App side: create a handle and pass it to the component +import { createFunctionHandle } from "convex/server"; + +export const startJob = mutation({ + handler: async (ctx) => { + const handle = await createFunctionHandle(internal.myModule.processItem); + await ctx.runMutation(components.workpool.enqueue, { + callback: handle, + }); + }, +}); +``` + +```ts +// Component side: accept and invoke the handle +import { v } from "convex/values"; +import type { FunctionHandle } from "convex/server"; +import { mutation } from "./_generated/server.js"; + +export const enqueue = mutation({ + args: { callback: v.string() }, + handler: async (ctx, args) => { + const handle = args.callback as FunctionHandle<"mutation">; + await ctx.scheduler.runAfter(0, handle, {}); + }, +}); +``` + +## Deriving validators from schema + +Instead of manually repeating field types in return validators, extend the schema validator: + +```ts +import { v } from "convex/values"; +import schema from "./schema.js"; + +const notificationDoc = schema.tables.notifications.validator.extend({ + _id: v.id("notifications"), + _creationTime: v.number(), +}); + +export const getLatest = query({ + args: {}, + returns: v.nullable(notificationDoc), + handler: async (ctx) => { + return await ctx.db.query("notifications").order("desc").first(); + }, +}); +``` + +## Static configuration with a globals table + +A common pattern for component configuration is a single-document "globals" table: + +```ts +// schema.ts +export default defineSchema({ + globals: defineTable({ + maxRetries: v.number(), + webhookUrl: v.optional(v.string()), + }), + // ... other tables +}); +``` + +```ts +// lib.ts +export const configure = mutation({ + args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.query("globals").first(); + if (existing) { + await ctx.db.patch(existing._id, args); + } else { + await ctx.db.insert("globals", args); + } + return null; + }, +}); +``` + +## Class-based client wrappers + +For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. + +```ts +// src/client/index.ts +import type { GenericMutationCtx, GenericDataModel } from "convex/server"; +import type { ComponentApi } from "../component/_generated/component.js"; + +type MutationCtx = Pick, "runMutation">; + +export class Notifications { + constructor( + private component: ComponentApi, + private options?: { defaultChannel?: string }, + ) {} + + async send(ctx: MutationCtx, args: { userId: string; message: string }) { + return await ctx.runMutation(this.component.lib.send, { + ...args, + channel: this.options?.defaultChannel ?? "default", + }); + } +} +``` + +```ts +// App usage +import { Notifications } from "@convex-dev/notifications"; +import { components } from "./_generated/api"; + +const notifications = new Notifications(components.notifications, { + defaultChannel: "alerts", +}); + +export const send = mutation({ + args: { message: v.string() }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + await notifications.send(ctx, { userId, message: args.message }); + }, +}); +``` diff --git a/.windsurf/skills/convex-create-component/references/hybrid-components.md b/.windsurf/skills/convex-create-component/references/hybrid-components.md new file mode 100644 index 00000000..d2bb3514 --- /dev/null +++ b/.windsurf/skills/convex-create-component/references/hybrid-components.md @@ -0,0 +1,37 @@ +# Hybrid Convex Components + +Read this file only when the user explicitly wants a hybrid setup. + +## What This Means + +A hybrid component combines a local Convex component with shared library code. + +This can help when: + +- the user wants a local install but also shared package logic +- the component needs extension points or override hooks +- some logic should live in normal TypeScript code outside the component boundary + +## Default Advice + +Treat hybrid as an advanced option, not the default. + +Before choosing it, ask: + +- Why is a plain local component not enough? +- Why is a packaged component not enough? +- What exactly needs to stay overridable or shared? + +If the answer is vague, fall back to local or packaged. + +## Risks + +- More moving parts +- Harder upgrades and backwards compatibility +- Easier to blur the component boundary + +## Checklist + +- [ ] User explicitly needs hybrid behavior +- [ ] Local-only and packaged-only options were considered first +- [ ] The extension points are clearly defined before coding diff --git a/.windsurf/skills/convex-create-component/references/local-components.md b/.windsurf/skills/convex-create-component/references/local-components.md new file mode 100644 index 00000000..7fbfe21a --- /dev/null +++ b/.windsurf/skills/convex-create-component/references/local-components.md @@ -0,0 +1,38 @@ +# Local Convex Components + +Read this file when the component should live inside the current app and does not need to be published as an npm package. + +## When to Choose This + +- The user wants the simplest path +- The component only needs to work in this repo +- The goal is extracting app logic into a cleaner boundary + +## Default Layout + +Use this structure unless the repo already has a clear alternative pattern: + +```text +convex/ + convex.config.ts + components/ + / + convex.config.ts + schema.ts + .ts +``` + +## Workflow Notes + +- Define the component with `defineComponent("")` +- Install it from the app with `defineApp()` and `app.use(...)` +- Keep auth, env access, public API wrappers, and HTTP route mounting in the app +- Let the component own isolated tables and reusable backend workflows +- Add app wrappers if clients need to call into the component + +## Checklist + +- [ ] Component is inside `convex/components//` +- [ ] App installs it with `app.use(...)` +- [ ] Component owns only its own tables +- [ ] App wrappers handle client-facing calls when needed diff --git a/.windsurf/skills/convex-create-component/references/packaged-components.md b/.windsurf/skills/convex-create-component/references/packaged-components.md new file mode 100644 index 00000000..5668e7ed --- /dev/null +++ b/.windsurf/skills/convex-create-component/references/packaged-components.md @@ -0,0 +1,51 @@ +# Packaged Convex Components + +Read this file when the user wants a reusable npm package or a component shared across multiple apps. + +## When to Choose This + +- The user wants to publish the component +- The user wants a stable reusable package boundary +- The component will be shared across multiple apps or teams + +## Default Approach + +- Prefer starting from `npx create-convex@latest --component` when possible +- Keep the official authoring docs as the source of truth for package layout and exports +- Validate the bundled package through an example app, not just the source files + +## Build Flow + +When building a packaged component, make sure the bundled output exists before the example app tries to consume it. + +Recommended order: + +1. `npx convex codegen --component-dir ./path/to/component` +2. Run the package build command +3. Run `npx convex dev --typecheck-components` in the example app + +Do not assume normal app codegen is enough for packaged component workflows. + +## Package Exports + +If publishing to npm, make sure the package exposes the entry points apps need: + +- package root for client helpers, types, or classes +- `./convex.config.js` for installing the component +- `./_generated/component.js` for the app-facing `ComponentApi` type +- `./test` for testing helpers when applicable + +## Testing + +- Use `convex-test` for component logic +- Register the component schema and modules with the test instance +- Test app-side wrapper code from an example app that installs the package +- Export a small helper from `./test` if consumers need easy test registration + +## Checklist + +- [ ] Packaging is actually required +- [ ] Build order avoids bundle and codegen races +- [ ] Package exports include install and typing entry points +- [ ] Example app exercises the packaged component +- [ ] Core behavior is covered by tests diff --git a/.windsurf/skills/convex-migration-helper b/.windsurf/skills/convex-migration-helper deleted file mode 120000 index 81eeed18..00000000 --- a/.windsurf/skills/convex-migration-helper +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-migration-helper \ No newline at end of file diff --git a/.windsurf/skills/convex-migration-helper/SKILL.md b/.windsurf/skills/convex-migration-helper/SKILL.md new file mode 100644 index 00000000..db36c622 --- /dev/null +++ b/.windsurf/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,149 @@ +--- +name: convex-migration-helper +description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +--- + +# Convex Migration Helper + +Safely migrate Convex schemas and data when making breaking changes. + +## When to Use + +- Adding new required fields to existing tables +- Changing field types or structure +- Splitting or merging tables +- Renaming or deleting fields +- Migrating from nested to relational data + +## When Not to Use + +- Greenfield schema with no existing data in production or dev +- Adding optional fields that do not need backfilling +- Adding new tables with no existing data to migrate +- Adding or removing indexes with no correctness concern +- Questions about Convex schema design without a migration need + +## Key Concepts + +### Schema Validation Drives the Workflow + +Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: + +- You cannot add a required field if existing documents don't have it +- You cannot change a field's type if existing documents have the old type +- You cannot remove a field from the schema if existing documents still have it + +This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. + +### Online Migrations + +Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. + +### Prefer New Fields Over Changing Types + +When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. + +### Don't Delete Data + +Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. + +## Safe Changes (No Migration Needed) + +### Adding Optional Field + +```typescript +// Before +users: defineTable({ + name: v.string(), +}); + +// After - safe, new field is optional +users: defineTable({ + name: v.string(), + bio: v.optional(v.string()), +}); +``` + +### Adding New Table + +```typescript +posts: defineTable({ + userId: v.id("users"), + title: v.string(), +}).index("by_user", ["userId"]); +``` + +### Adding Index + +```typescript +users: defineTable({ + name: v.string(), + email: v.string(), +}).index("by_email", ["email"]); +``` + +## Breaking Changes: The Deployment Workflow + +Every breaking migration follows the same multi-deploy pattern: + +**Deploy 1 - Widen the schema:** + +1. Update schema to allow both old and new formats (e.g., add optional new field) +2. Update code to handle both formats when reading +3. Update code to write the new format for new documents +4. Deploy + +**Between deploys - Migrate data:** + +5. Run migration to backfill existing documents +6. Verify all documents are migrated + +**Deploy 2 - Narrow the schema:** + +7. Update schema to require the new format only +8. Remove code that handles the old format +9. Deploy + +## Using the Migrations Component + +For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. + +See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. + +## Common Migration Patterns + +See `references/migration-patterns.md` for complete patterns with code examples covering: + +- Adding a required field +- Deleting a field +- Changing a field type +- Splitting nested data into a separate table +- Cleaning up orphaned documents +- Zero-downtime strategies (dual write, dual read) +- Small table shortcut (single internalMutation without the component) +- Verifying a migration is complete + +## Common Pitfalls + +1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. +2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. +3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." +4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. +5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. +6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. + +## Migration Checklist + +- [ ] Identify the breaking change and plan the multi-deploy workflow +- [ ] Update schema to allow both old and new formats +- [ ] Update code to handle both formats when reading +- [ ] Update code to write the new format for new documents +- [ ] Deploy widened schema and updated code +- [ ] Define migration using the `@convex-dev/migrations` component +- [ ] Test with `dryRun: true` +- [ ] Run migration and monitor status +- [ ] Verify all documents are migrated +- [ ] Update schema to require new format only +- [ ] Clean up code that handled old format +- [ ] Deploy final schema and code +- [ ] Remove migration code once confirmed stable diff --git a/.windsurf/skills/convex-migration-helper/agents/openai.yaml b/.windsurf/skills/convex-migration-helper/agents/openai.yaml new file mode 100644 index 00000000..c2a7fcc5 --- /dev/null +++ b/.windsurf/skills/convex-migration-helper/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Migration Helper" + short_description: "Plan and run safe Convex schema and data migrations." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#8B5CF6" + default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." + +policy: + allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-migration-helper/assets/icon.svg b/.windsurf/skills/convex-migration-helper/assets/icon.svg new file mode 100644 index 00000000..fba7241a --- /dev/null +++ b/.windsurf/skills/convex-migration-helper/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.windsurf/skills/convex-migration-helper/references/migration-patterns.md b/.windsurf/skills/convex-migration-helper/references/migration-patterns.md new file mode 100644 index 00000000..53b4946f --- /dev/null +++ b/.windsurf/skills/convex-migration-helper/references/migration-patterns.md @@ -0,0 +1,231 @@ +# Migration Patterns Reference + +Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. + +## Adding a Required Field + +```typescript +// Deploy 1: Schema allows both states +users: defineTable({ + name: v.string(), + role: v.optional(v.union(v.literal("user"), v.literal("admin"))), +}); + +// Migration: backfill the field +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); + +// Deploy 2: After migration completes, make it required +users: defineTable({ + name: v.string(), + role: v.union(v.literal("user"), v.literal("admin")), +}); +``` + +## Deleting a Field + +Mark the field optional first, migrate data to remove it, then remove from schema: + +```typescript +// Deploy 1: Make optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()) + +// Migration +export const removeIsPro = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.isPro !== undefined) { + await ctx.db.patch(team._id, { isPro: undefined }); + } + }, +}); + +// Deploy 2: Remove isPro from schema entirely +``` + +## Changing a Field Type + +Prefer creating a new field. You can combine adding and deleting in one migration: + +```typescript +// Deploy 1: Add new field, keep old field optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) + +// Migration: convert old field to new field +export const convertToEnum = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.plan === undefined) { + await ctx.db.patch(team._id, { + plan: team.isPro ? "pro" : "basic", + isPro: undefined, + }); + } + }, +}); + +// Deploy 2: Remove isPro from schema, make plan required +``` + +## Splitting Nested Data Into a Separate Table + +```typescript +export const extractPreferences = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.preferences === undefined) return; + + const existing = await ctx.db + .query("userPreferences") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!existing) { + await ctx.db.insert("userPreferences", { + userId: user._id, + ...user.preferences, + }); + } + + await ctx.db.patch(user._id, { preferences: undefined }); + }, +}); +``` + +Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. + +## Cleaning Up Orphaned Documents + +```typescript +export const deleteOrphanedEmbeddings = migrations.define({ + table: "embeddings", + migrateOne: async (ctx, doc) => { + const chunk = await ctx.db + .query("chunks") + .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) + .first(); + + if (!chunk) { + await ctx.db.delete(doc._id); + } + }, +}); +``` + +## Zero-Downtime Strategies + +During the migration window, your app must handle both old and new data formats. There are two main strategies. + +### Dual Write (Preferred) + +Write to both old and new structures. Read from the old structure until migration is complete. + +1. Deploy code that writes both formats, reads old format +2. Run migration on existing data +3. Deploy code that reads new format, still writes both +4. Deploy code that only reads and writes new format + +This is preferred because you can safely roll back at any point, the old format is always up to date. + +```typescript +// Bad: only writing to new structure before migration is done +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + await ctx.db.insert("teams", { + name: args.name, + plan: args.isPro ? "pro" : "basic", + }); + }, +}); + +// Good: writing to both structures during migration +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + const plan = args.isPro ? "pro" : "basic"; + await ctx.db.insert("teams", { + name: args.name, + isPro: args.isPro, + plan, + }); + }, +}); +``` + +### Dual Read + +Read both formats. Write only the new format. + +1. Deploy code that reads both formats (preferring new), writes only new format +2. Run migration on existing data +3. Deploy code that reads and writes only new format + +This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. + +```typescript +// Good: reading both formats, preferring new +function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { + if (team.plan !== undefined) return team.plan; + return team.isPro ? "pro" : "basic"; +} +``` + +## Small Table Shortcut + +For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: + +```typescript +import { internalMutation } from "./_generated/server"; + +export const backfillSmallTable = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("smallConfig").collect(); + for (const doc of docs) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: "default" }); + } + } + }, +}); +``` + +```bash +npx convex run migrations:backfillSmallTable +``` + +Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. + +## Verifying a Migration + +Query to check remaining unmigrated documents: + +```typescript +import { query } from "./_generated/server"; + +export const verifyMigration = query({ + handler: async (ctx) => { + const remaining = await ctx.db + .query("users") + .filter((q) => q.eq(q.field("role"), undefined)) + .take(10); + + return { + complete: remaining.length === 0, + sampleRemaining: remaining.map((u) => u._id), + }; + }, +}); +``` + +Or use the component's built-in status monitoring: + +```bash +npx convex run --component migrations lib:getStatus --watch +``` diff --git a/.windsurf/skills/convex-migration-helper/references/migrations-component.md b/.windsurf/skills/convex-migration-helper/references/migrations-component.md new file mode 100644 index 00000000..95ec2921 --- /dev/null +++ b/.windsurf/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,169 @@ +# Migrations Component Reference + +Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. + +## Installation + +```bash +npm install @convex-dev/migrations +``` + +## Setup + +```typescript +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import migrations from "@convex-dev/migrations/convex.config.js"; + +const app = defineApp(); +app.use(migrations); +export default app; +``` + +```typescript +// convex/migrations.ts +import { Migrations } from "@convex-dev/migrations"; +import { components } from "./_generated/api.js"; +import { DataModel } from "./_generated/dataModel.js"; + +export const migrations = new Migrations(components.migrations); +export const run = migrations.runner(); +``` + +The `DataModel` type parameter is optional but provides type safety for migration definitions. + +## Define a Migration + +The `migrateOne` function processes a single document. The component handles batching and pagination automatically. + +```typescript +// convex/migrations.ts +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); +``` + +Shorthand: if you return an object, it is applied as a patch automatically. + +```typescript +export const clearDeprecatedField = migrations.define({ + table: "users", + migrateOne: () => ({ legacyField: undefined }), +}); +``` + +## Run a Migration + +From the CLI: + +```bash +# Define a one-off runner in convex/migrations.ts: +# export const runIt = migrations.runner(internal.migrations.addDefaultRole); +npx convex run migrations:runIt + +# Or use the general-purpose runner +npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' +``` + +Programmatically from another Convex function: + +```typescript +await migrations.runOne(ctx, internal.migrations.addDefaultRole); +``` + +## Run Multiple Migrations in Order + +```typescript +export const runAll = migrations.runner([ + internal.migrations.addDefaultRole, + internal.migrations.clearDeprecatedField, + internal.migrations.normalizeEmails, +]); +``` + +```bash +npx convex run migrations:runAll +``` + +If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. + +## Dry Run + +Test a migration before committing changes: + +```bash +npx convex run migrations:runIt '{"dryRun": true}' +``` + +This runs one batch and then rolls back, so you can see what it would do without changing any data. + +## Check Migration Status + +```bash +npx convex run --component migrations lib:getStatus --watch +``` + +## Cancel a Running Migration + +```bash +npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' +``` + +Or programmatically: + +```typescript +await migrations.cancel(ctx, internal.migrations.addDefaultRole); +``` + +## Run Migrations on Deploy + +Chain migration execution after deploying: + +```bash +npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod +``` + +## Configuration Options + +### Custom Batch Size + +If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: + +```typescript +export const migrateHeavyTable = migrations.define({ + table: "largeDocuments", + batchSize: 10, + migrateOne: async (ctx, doc) => { + // migration logic + }, +}); +``` + +### Migrate a Subset Using an Index + +Process only matching documents instead of the full table: + +```typescript +export const fixEmptyNames = migrations.define({ + table: "users", + customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")), + migrateOne: () => ({ name: "" }), +}); +``` + +### Parallelize Within a Batch + +By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: + +```typescript +export const clearField = migrations.define({ + table: "myTable", + parallelize: true, + migrateOne: () => ({ optionalField: undefined }), +}); +``` diff --git a/.windsurf/skills/convex-performance-audit b/.windsurf/skills/convex-performance-audit deleted file mode 120000 index 1bff1e5e..00000000 --- a/.windsurf/skills/convex-performance-audit +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-performance-audit \ No newline at end of file diff --git a/.windsurf/skills/convex-performance-audit/SKILL.md b/.windsurf/skills/convex-performance-audit/SKILL.md new file mode 100644 index 00000000..382951cf --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/SKILL.md @@ -0,0 +1,143 @@ +--- +name: convex-performance-audit +description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +--- + +# Convex Performance Audit + +Diagnose and fix performance problems in Convex applications, one problem class at a time. + +## When to Use + +- A Convex page or feature feels slow or expensive +- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts +- Low-freshness read paths are using reactivity where point-in-time reads would do +- OCC conflict errors or excessive mutation retries +- High subscription count or slow UI updates +- Functions approaching execution or transaction limits +- The same performance pattern needs fixing across sibling functions + +## When Not to Use + +- Initial Convex setup, auth setup, or component extraction +- Pure schema migrations with no performance goal +- One-off micro-optimizations without a user-visible or deployment-visible problem + +## Guardrails + +- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak +- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path +- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale + +## First Step: Gather Signals + +Start with the strongest signal available: + +1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. +2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. + - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. +3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. +4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. + +## Signal Routing + +After gathering signals, identify the problem class and read the matching reference file. + +| Signal | Reference | +| -------------------------------------------------------------- | ----------------------------------------- | +| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | +| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | +| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | +| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | +| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | + +Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. + +## Escalate Larger Fixes + +If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. + +Examples: + +- introducing digest or summary tables across multiple flows +- splitting documents to isolate frequently-updated fields +- reworking pagination or fetch strategy across several screens +- switching to a new index or denormalized field that needs migration-safe rollout + +When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. + +## Workflow + +### 1. Scope the problem + +Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. + +Write down: + +- entrypoint functions +- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` +- tables read +- tables written +- whether the path is high-read, high-write, or both + +### 2. Trace the full read and write set + +For each function in the path: + +1. Trace every `ctx.db.get()` and `ctx.db.query()` +2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` +3. Note foreign-key lookups, JS-side filtering, and full-document reads +4. Identify all sibling functions touching the same tables +5. Identify reactive stats, aggregates, or widgets rendered on the same page + +In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. + +### 3. Apply fixes from the relevant reference + +Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. + +Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. + +### 4. Fix sibling functions together + +When one function touching a table has a performance bug, audit sibling functions for the same pattern. + +After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. + +Examples: + +- If one list query switches from full docs to a digest table, inspect the other list queries for that table +- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table +- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk + +Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. + +### 5. Verify before finishing + +Confirm all of these: + +1. Results are the same as before, no dropped records +2. Eliminated reads or writes are no longer in the path where expected +3. Fallback behavior works when denormalized or indexed fields are missing +4. Frequently-updated fields are isolated from widely-read documents where needed +5. Every relevant sibling reader and writer was inspected, not just the original function + +## Reference Files + +- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables +- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting +- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads +- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size + +Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. + +## Checklist + +- [ ] Gathered signals from insights, dashboard, or code audit +- [ ] Identified the problem class and read the matching reference +- [ ] Scoped one concrete user flow or function path +- [ ] Traced every read and write in that path +- [ ] Identified sibling functions touching the same tables +- [ ] Applied fixes from the reference, following the recommended fix order +- [ ] Fixed sibling functions consistently +- [ ] Verified behavior and confirmed no regressions diff --git a/.windsurf/skills/convex-performance-audit/agents/openai.yaml b/.windsurf/skills/convex-performance-audit/agents/openai.yaml new file mode 100644 index 00000000..9a21f387 --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Performance Audit" + short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#EF4444" + default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." + +policy: + allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-performance-audit/assets/icon.svg b/.windsurf/skills/convex-performance-audit/assets/icon.svg new file mode 100644 index 00000000..7ab9e09c --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.windsurf/skills/convex-performance-audit/references/function-budget.md b/.windsurf/skills/convex-performance-audit/references/function-budget.md new file mode 100644 index 00000000..d4d4aa5a --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/references/function-budget.md @@ -0,0 +1,232 @@ +# Function Budget + +Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. + +## Core Principle + +Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. + +## Limits to Know + +These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. + +| Resource | Limit | +| --------------------------------- | ----------------------------------------------------- | +| Query/mutation execution time | 1 second (user code only, excludes DB operations) | +| Action execution time | 10 minutes | +| Data read per transaction | 16 MiB | +| Data written per transaction | 16 MiB | +| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | +| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | +| Documents written per transaction | 16,000 | +| Individual document size | 1 MiB | +| Function return value size | 16 MiB | + +## Symptoms + +- "Function execution took too long" errors +- "Transaction too large" or read/write set size errors +- Slow queries that read many documents +- Client receiving large payloads that slow down page load +- `npx convex insights --details` showing high bytes read + +## Common Causes + +### Unbounded collection + +A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. + +### Large document reads on hot paths + +Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. + +### Mutation doing too much work + +A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. + +### Returning too much data to the client + +A query returning full documents when the client only needs a few fields. + +## Fix Order + +### 1. Bound your reads + +Never `.collect()` without a limit on a table that can grow unbounded. + +```ts +// Bad: unbounded read, breaks as the table grows +const messages = await ctx.db.query("messages").collect(); +``` + +```ts +// Good: paginate or limit +const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", channelId)) + .order("desc") + .take(50); +``` + +### 2. Read smaller shapes + +If the list page only needs title, author, and date, do not read full documents with rich content fields. + +Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. + +### 3. Break large mutations into batches + +If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. + +```ts +// Bad: one mutation updating every row +export const backfillAll = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("items").collect(); + for (const doc of docs) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + }, +}); +``` + +```ts +// Good: cursor-based batch processing +export const backfillBatch = internalMutation({ + args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + const result = await ctx.db + .query("items") + .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); + + for (const doc of result.page) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + } + + if (!result.isDone) { + await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { + cursor: result.continueCursor, + batchSize, + }); + } + }, +}); +``` + +### 4. Move heavy work to actions + +Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. + +Actions run outside the transaction and can call mutations to write results back. + +```ts +// Bad: heavy computation inside a mutation +export const processUpload = mutation({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.db.insert("results", result); + }, +}); +``` + +```ts +// Good: action for heavy work, mutation for the write +export const processUpload = action({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.runMutation(internal.results.store, { result }); + }, +}); +``` + +### 5. Trim return values + +Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. + +```ts +// Bad: returns full documents including large content fields +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("articles").take(20); + }, +}); +``` + +```ts +// Good: project to only the fields the client needs +export const list = query({ + handler: async (ctx) => { + const articles = await ctx.db.query("articles").take(20); + return articles.map((a) => ({ + _id: a._id, + title: a.title, + author: a.author, + createdAt: a._creationTime, + })); + }, +}); +``` + +### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions + +Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. + +```ts +// Bad: unnecessary overhead from ctx.runQuery inside a mutation +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await ctx.runQuery(api.users.getCurrentUser); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +```ts +// Good: plain helper function, no extra overhead +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await getCurrentUser(ctx); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. + +### 7. Avoid unnecessary `runAction` calls + +`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). + +```ts +// Bad: runAction overhead for no reason +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await ctx.runAction(internal.items.processOne, { item }); + } + }, +}); +``` + +```ts +// Good: plain function call +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await processOneItem(ctx, { item }); + } + }, +}); +``` + +## Verification + +1. No function execution or transaction size errors +2. `npx convex insights --details` shows reduced bytes read +3. Large mutations are batched and self-scheduling +4. Client payloads are reasonably sized for the UI they serve +5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible +6. Sibling functions with similar patterns were checked diff --git a/.windsurf/skills/convex-performance-audit/references/hot-path-rules.md b/.windsurf/skills/convex-performance-audit/references/hot-path-rules.md new file mode 100644 index 00000000..e003e052 --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,369 @@ +# Hot Path Rules + +Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. + +## Contents + +- Core Principle +- Consistency Rule +- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) +- 2. Minimize Data Sources (denormalization, fallback rule) +- 3. Minimize Row Size (digest tables) +- 4. Skip No-Op Writes +- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) +- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) +- Verification + +## Core Principle + +Every byte read or written multiplies with concurrency. + +Think: + +`cost x calls_per_second x 86400` + +In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. + +## Consistency Rule + +If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. + +Do this especially for: + +- multiple list queries over the same table +- multiple writers to the same table +- public browse and search queries over the same records +- helper functions reused by more than one endpoint + +## 1. Push Filters To Storage + +Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. + +Prefer: + +- `withIndex(...)` +- `.withSearchIndex(...)` for text search +- narrower tables +- summary tables + +before accepting a scan-plus-filter pattern. + +```ts +// Bad: scans then filters in JavaScript +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + const tasks = await ctx.db.query("tasks").collect(); + return tasks.filter((task) => task.status === "open"); + }, +}); +``` + +```ts +// Also bad: Convex .filter() does not push to storage either +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .filter((q) => q.eq(q.field("status"), "open")) + .collect(); + }, +}); +``` + +```ts +// Good: use an index so storage does the filtering +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .withIndex("by_status", (q) => q.eq("status", "open")) + .collect(); + }, +}); +``` + +### Migration rule for indexes + +New indexes on partially backfilled fields can create correctness bugs during rollout. + +Important Convex detail: + +`undefined !== false` + +If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. + +Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. + +If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. + +```ts +// Bad: optional booleans can miss older rows where the field is undefined +const projects = await ctx.db + .query("projects") + .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) + .order("desc") + .take(20); +``` + +```ts +// Good: switch hot-path reads only after the rollout is migration-safe +// See the migration helper skill for dual-read / backfill / cutover patterns. +``` + +### Check for redundant indexes + +Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. + +```ts +// Bad: two indexes where one would do +defineTable({ team: v.id("teams"), user: v.id("users") }) + .index("by_team", ["team"]) + .index("by_team_and_user", ["team", "user"]); +``` + +```ts +// Good: single compound index serves both query patterns +defineTable({ team: v.id("teams"), user: v.id("users") }).index( + "by_team_and_user", + ["team", "user"], +); +``` + +Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. + +## 2. Minimize Data Sources + +Trace every read. + +If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. + +### When to denormalize + +Denormalize when all of these are true: + +- the path is hot +- the joined document is much larger than the field you need +- many readers are paying that join cost repeatedly + +Useful mental model: + +`join_cost = rows_per_page x foreign_doc_size x pages_per_second` + +Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. + +### Fallback rule + +Denormalized data is an optimization. Live data is the correctness path. + +Rules: + +- If the denormalized field is missing or null, fall back to the live read +- Do not show placeholders instead of falling back +- In lookup maps, only include fully populated entries + +```ts +// Bad: missing denormalized data becomes a placeholder and blocks correctness +const ownerName = project.ownerName ?? "Unknown owner"; +``` + +```ts +// Good: denormalized data is an optimization, not the only source of truth +const ownerName = + project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null; +``` + +Bad lookup map pattern: + +```ts +const ownersById = { + [project.ownerId]: { ownerName: null }, +}; +``` + +That blocks fallback because the map says "I have data" when it does not. + +Good lookup map pattern: + +```ts +const ownersById = + project.ownerName !== undefined && project.ownerName !== null + ? { [project.ownerId]: { ownerName: project.ownerName } } + : {}; +``` + +### No denormalized copy yet + +Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. + +If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. + +Rollout order: + +1. Update schema +2. Update write path +3. Backfill +4. Switch read path + +## 3. Minimize Row Size + +Hot list pages should read the smallest document shape that still answers the UI. + +Prefer summary or digest tables over full source tables when: + +- the list page only needs a subset of fields +- source documents are large +- the query is high volume + +An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. + +Digest tables are a tradeoff, not a default: + +- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost +- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit + +```ts +// Bad: list page reads source docs, then joins owner data per row +const projects = await ctx.db + .query("projects") + .withIndex("by_public", (q) => q.eq("isPublic", true)) + .collect(); +``` + +```ts +// Good: list page reads the smaller digest shape first +const projects = await ctx.db + .query("projectDigests") + .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) + .order("desc") + .take(20); +``` + +## 4. Isolate Frequently-Updated Fields + +Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to. + +Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them. + +Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document. + +```ts +// Bad: every presence heartbeat invalidates subscribers to the whole profile +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, + lastSeen: Date.now(), +}); +``` + +```ts +// Good: keep profile reads stable, move heartbeat updates to a separate document +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, +}); + +await ctx.db.patch(presence._id, { + lastSeen: Date.now(), +}); +``` + +## 5. Match Consistency To Read Patterns + +Choose read strategy based on traffic shape. + +### High-read, low-write + +Examples: + +- public browse pages +- search results +- landing pages +- directory listings + +Prefer: + +- point-in-time reads where appropriate +- explicit refresh +- local state for pagination +- caching where appropriate + +Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. + +### High-read, high-write + +Examples: + +- collaborative editors +- live dashboards +- presence-heavy views + +Reactive queries may be worth the ongoing cost. + +## Convex-Specific Notes + +### Reactive queries + +Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. + +On the client: + +- `useQuery` creates a live subscription +- `usePaginatedQuery` creates a live subscription per page + +For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. + +### Point-in-time reads + +Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. + +Use them for: + +- aggregate snapshots +- reports +- low-churn listings +- pages where explicit refresh is fine + +### Triggers and fan-out + +Triggers fire on every write, including writes that did not materially change the document. + +When a write exists only to keep derived state in sync: + +- diff before patching +- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate + +### Aggregates + +Reactive global counts invalidate frequently on busy tables. + +Prefer: + +- one-shot aggregate fetches +- periodic recomputation +- precomputed summary rows + +for global stats that do not need live updates every second. + +### Backfills + +For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. + +Deploy code that can handle both states before running the backfill. + +During the gap: + +- writes should populate the new shape +- reads should fall back safely + +## Verification + +Before closing the audit, confirm: + +1. Same results as before, no dropped records +2. The removed table or lookup is no longer in the hot-path read set +3. Tests or validation cover fallback behavior +4. Migration safety is preserved while fields or indexes are unbackfilled +5. Sibling functions were fixed consistently diff --git a/.windsurf/skills/convex-performance-audit/references/occ-conflicts.md b/.windsurf/skills/convex-performance-audit/references/occ-conflicts.md new file mode 100644 index 00000000..1da43801 --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,114 @@ +# OCC Conflict Resolution + +Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. + +## Core Principle + +Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. + +## Symptoms + +- OCC conflict errors in deployment logs or health page +- Mutations retrying multiple times before succeeding +- User-visible latency spikes on write-heavy pages +- `npx convex insights --details` showing high conflict rates + +## Common Causes + +### Hot documents + +Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. + +### Broad read sets causing false conflicts + +A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. + +### Fan-out from triggers or cascading writes + +A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. + +Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. + +### Write-then-read chains + +A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. + +## Fix Order + +### 1. Reduce read set size + +Narrower reads mean fewer false conflicts. + +```ts +// Bad: broad scan creates a wide conflict surface +const allTasks = await ctx.db.query("tasks").collect(); +const mine = allTasks.filter((t) => t.ownerId === userId); +``` + +```ts +// Good: indexed query touches only relevant documents +const mine = await ctx.db + .query("tasks") + .withIndex("by_owner", (q) => q.eq("ownerId", userId)) + .collect(); +``` + +### 2. Split hot documents + +When many writers target the same document, split the contention point. + +```ts +// Bad: every vote increments the same counter document +const counter = await ctx.db.get(pollCounterId); +await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); +``` + +```ts +// Good: shard the counter across multiple documents, aggregate on read +const shardIndex = Math.floor(Math.random() * SHARD_COUNT); +const shardId = shardIds[shardIndex]; +const shard = await ctx.db.get(shardId); +await ctx.db.patch(shardId, { count: shard!.count + 1 }); +``` + +Aggregate the shards in a query or scheduled job when you need the total. + +### 3. Move non-critical work to scheduled functions + +If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. + +```ts +// Bad: canonical write and derived work happen in the same transaction +await ctx.db.patch(userId, { name: args.name }); +await ctx.db.insert("userUpdateAnalytics", { + userId, + kind: "name_changed", + name: args.name, +}); +``` + +```ts +// Good: keep the primary write small, defer the analytics work +await ctx.db.patch(userId, { name: args.name }); +await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, { + userId, + name: args.name, +}); +``` + +### 4. Combine competing writes + +If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. + +Do not introduce artificial locks or queues unless the above steps have been tried first. + +## Related: Invalidation Scope + +Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. + +## Verification + +1. OCC conflict rate has dropped in insights or dashboard +2. Mutation latency is lower and more consistent +3. No data correctness regressions from splitting or scheduling changes +4. Sibling writers to the same hot documents were fixed consistently diff --git a/.windsurf/skills/convex-performance-audit/references/subscription-cost.md b/.windsurf/skills/convex-performance-audit/references/subscription-cost.md new file mode 100644 index 00000000..ae7d1adb --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/references/subscription-cost.md @@ -0,0 +1,252 @@ +# Subscription Cost + +Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. + +## Core Principle + +Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: + +`subscriptions x invalidation_frequency x query_cost` + +Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. + +## Symptoms + +- Dashboard shows high active subscription count +- UI feels sluggish or laggy despite fast individual queries +- React profiling shows frequent re-renders from Convex state +- Pages with many components each running their own `useQuery` +- Paginated lists where every loaded page stays subscribed + +## Common Causes + +### Reactive queries on low-freshness flows + +Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. + +### Overly broad queries + +A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. + +### Too many subscriptions per page + +A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. + +### Paginated queries keeping all pages live + +`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. + +### Frequently-updated fields on widely-read documents + +A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. + +## Fix Order + +### 1. Use point-in-time reads when live updates are not valuable + +Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. + +Consider a point-in-time read instead when all of these are true: + +- the flow is high-read +- the underlying data changes less often than users need to see +- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable + +Possible implementations depend on environment: + +- a server-rendered fetch +- a framework helper like `fetchQuery` +- a point-in-time client read such as `ConvexHttpClient.query()` + +```ts +// Reactive by default when fresh live data matters +function TeamPresence() { + const presence = useQuery(api.teams.livePresence, { teamId }); + return ; +} +``` + +```ts +// Point-in-time read when explicit refresh is acceptable +import { ConvexHttpClient } from "convex/browser"; + +const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); + +function SnapshotView() { + const [items, setItems] = useState([]); + + useEffect(() => { + client.query(api.items.snapshot).then(setItems); + }, []); + + return ; +} +``` + +Good candidates for point-in-time reads: + +- aggregate snapshots +- reports +- low-churn listings +- flows where explicit refresh is already acceptable + +Keep reactive for: + +- collaborative editing +- live dashboards +- presence-heavy views +- any surface where users expect fresh changes to appear automatically + +### 2. Batch related data into fewer queries + +Instead of N components each fetching their own related data, fetch it in a single query. + +```ts +// Bad: each card fetches its own author +function ProjectCard({ project }: { project: Project }) { + const author = useQuery(api.users.get, { id: project.authorId }); + return ; +} +``` + +```ts +// Good: parent query returns projects with author names included +function ProjectList() { + const projects = useQuery(api.projects.listWithAuthors); + return projects?.map((p) => ( + + )); +} +``` + +This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. + +This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. + +### 3. Use skip to avoid unnecessary subscriptions + +The `"skip"` value prevents a subscription from being created when the arguments are not ready. + +```ts +// Bad: subscribes with undefined args, wastes a subscription slot +const profile = useQuery(api.users.getProfile, { userId: selectedId! }); +``` + +```ts +// Good: skip when there is nothing to fetch +const profile = useQuery( + api.users.getProfile, + selectedId ? { userId: selectedId } : "skip", +); +``` + +### 4. Isolate frequently-updated fields into separate documents + +If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. + +```ts +// Bad: lastSeen lives on the user doc, every heartbeat invalidates +// every query that reads this user +const users = defineTable({ + name: v.string(), + email: v.string(), + lastSeen: v.number(), +}); +``` + +```ts +// Good: lastSeen lives in a separate heartbeat doc +const users = defineTable({ + name: v.string(), + email: v.string(), + heartbeatId: v.id("heartbeats"), +}); + +const heartbeats = defineTable({ + lastSeen: v.number(), +}); +``` + +Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. + +For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. + +### 5. Use the aggregate component for counts and sums + +Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. + +Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. + +If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. + +### 6. Narrow query read sets + +Queries that return less data and touch fewer documents invalidate less often. + +```ts +// Bad: returns all fields, invalidates on any field change +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("projects").collect(); + }, +}); +``` + +```ts +// Good: use a digest table with only the fields the list needs +export const listDigests = query({ + handler: async (ctx) => { + return await ctx.db.query("projectDigests").collect(); + }, +}); +``` + +Writes to fields not in the digest table do not invalidate the digest query. + +### 7. Remove `Date.now()` from queries + +Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. + +```ts +// Bad: Date.now() defeats query caching and causes frequent re-evaluation +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) + .take(100); +``` + +```ts +// Good: use a boolean field updated by a scheduled function +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_is_released", (q) => q.eq("isReleased", true)) + .take(100); +``` + +If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. + +### 8. Consider pagination strategy + +For long lists where users scroll through many pages: + +- If the data does not need live updates, use point-in-time fetching with manual "load more" +- If it does need live updates, accept the subscription cost but limit the number of loaded pages +- Consider whether older pages can be unloaded as the user scrolls forward + +### 9. Separate backend cost from UI churn + +If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. + +Treat this as a UX problem first when: + +- the underlying query is already reasonably cheap +- the complaint is flicker, loading flashes, or re-render churn +- live updates are still desirable once fresh data arrives + +## Verification + +1. Subscription count in dashboard is lower for the affected pages +2. UI responsiveness has improved +3. React profiling shows fewer unnecessary re-renders +4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily +5. Sibling pages with similar patterns were updated consistently diff --git a/.windsurf/skills/convex-quickstart b/.windsurf/skills/convex-quickstart deleted file mode 120000 index 9edf1975..00000000 --- a/.windsurf/skills/convex-quickstart +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-quickstart \ No newline at end of file diff --git a/.windsurf/skills/convex-quickstart/SKILL.md b/.windsurf/skills/convex-quickstart/SKILL.md new file mode 100644 index 00000000..5bff17bc --- /dev/null +++ b/.windsurf/skills/convex-quickstart/SKILL.md @@ -0,0 +1,347 @@ +--- +name: convex-quickstart +description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +--- + +# Convex Quickstart + +Set up a working Convex project as fast as possible. + +## When to Use + +- Starting a brand new project with Convex +- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app +- Scaffolding a Convex app for prototyping + +## When Not to Use + +- The project already has Convex installed and `convex/` exists - just start building +- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill + +## Workflow + +1. Determine the starting point: new project or existing app +2. If new project, pick a template and scaffold with `npm create convex@latest` +3. If existing app, install `convex` and wire up the provider +4. Run `npx convex dev` to connect a deployment and start the dev loop +5. Verify the setup works + +## Path 1: New Project (Recommended) + +Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. + +### Pick a template + +| Template | Stack | +| -------------------------- | ----------------------------------------- | +| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | +| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | +| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | +| `nextjs-clerk` | Next.js + Clerk auth | +| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | +| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | +| `bare` | Convex backend only, no frontend | + +If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. + +You can also use any GitHub repo as a template: + +```bash +npm create convex@latest my-app -- -t owner/repo +npm create convex@latest my-app -- -t owner/repo#branch +``` + +### Scaffold the project + +Always pass the project name and template flag to avoid interactive prompts: + +```bash +npm create convex@latest my-app -- -t react-vite-shadcn +cd my-app +npm install +``` + +The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. + +To scaffold in the current directory (if it is empty): + +```bash +npm create convex@latest . -- -t react-vite-shadcn +npm install +``` + +### Start the dev loop + +`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. + +**Ask the user to run this themselves:** + +Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: + +- Create a Convex project and dev deployment +- Write the deployment URL to `.env.local` +- Create the `convex/` directory with generated types +- Watch for changes and sync continuously + +The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. + +**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. + +### Start the frontend + +The user should also run the frontend dev server in a separate terminal: + +```bash +npm run dev +``` + +Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. + +### What you get + +After scaffolding, the project structure looks like: + +``` +my-app/ + convex/ # Backend functions and schema + _generated/ # Auto-generated types (check this into git) + schema.ts # Database schema (if template includes one) + src/ # Frontend code (or app/ for Next.js) + package.json + .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL +``` + +The template already has: + +- `ConvexProvider` wired into the app root +- Correct env var names for the framework +- Tailwind and shadcn/ui ready (for shadcn templates) +- Auth provider configured (for auth templates) + +Proceed to adding schema, functions, and UI. + +## Path 2: Add Convex to an Existing App + +Use this when the user already has a frontend project and wants to add Convex as the backend. + +### Install + +```bash +npm install convex +``` + +### Initialize and start dev loop + +Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. + +### Wire up the provider + +The Convex client must wrap the app at the root. The setup varies by framework. + +Create the `ConvexReactClient` at module scope, not inside a component: + +```tsx +// Bad: re-creates the client on every render +function App() { + const convex = new ConvexReactClient( + import.meta.env.VITE_CONVEX_URL as string, + ); + return ...; +} + +// Good: created once at module scope +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); +function App() { + return ...; +} +``` + +#### React (Vite) + +```tsx +// src/main.tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import App from "./App"; + +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); + +createRoot(document.getElementById("root")!).render( + + + + + , +); +``` + +#### Next.js (App Router) + +```tsx +// app/ConvexClientProvider.tsx +"use client"; + +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return {children}; +} +``` + +```tsx +// app/layout.tsx +import { ConvexClientProvider } from "./ConvexClientProvider"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} +``` + +#### Other frameworks + +For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: + +- [Vue](https://docs.convex.dev/quickstart/vue) +- [Svelte](https://docs.convex.dev/quickstart/svelte) +- [React Native](https://docs.convex.dev/quickstart/react-native) +- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) +- [Remix](https://docs.convex.dev/quickstart/remix) +- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) + +### Environment variables + +The env var name depends on the framework: + +| Framework | Variable | +| ------------ | ------------------------ | +| Vite | `VITE_CONVEX_URL` | +| Next.js | `NEXT_PUBLIC_CONVEX_URL` | +| Remix | `CONVEX_URL` | +| React Native | `EXPO_PUBLIC_CONVEX_URL` | + +`npx convex dev` writes the correct variable to `.env.local` automatically. + +## Agent Mode (Cloud and Headless Agents) + +When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. + +Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: + +```bash +CONVEX_AGENT_MODE=anonymous npx convex dev +``` + +This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. + +## Verify the Setup + +After setup, confirm everything is working: + +1. The user confirms `npx convex dev` is running without errors +2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` +3. `.env.local` contains the deployment URL + +## Writing Your First Function + +Once the project is set up, create a schema and a query to verify the full loop works. + +`convex/schema.ts`: + +```ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + tasks: defineTable({ + text: v.string(), + completed: v.boolean(), + }), +}); +``` + +`convex/tasks.ts`: + +```ts +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("tasks").collect(); + }, +}); + +export const create = mutation({ + args: { text: v.string() }, + handler: async (ctx, args) => { + await ctx.db.insert("tasks", { text: args.text, completed: false }); + }, +}); +``` + +Use in a React component (adjust the import path based on your file location relative to `convex/`): + +```tsx +import { useQuery, useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function Tasks() { + const tasks = useQuery(api.tasks.list); + const create = useMutation(api.tasks.create); + + return ( +
+ + {tasks?.map((t) => ( +
{t.text}
+ ))} +
+ ); +} +``` + +## Development vs Production + +Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. + +When ready to ship, deploy to production: + +```bash +npx convex deploy +``` + +This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. + +## Next Steps + +- Add authentication: use the `convex-setup-auth` skill +- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) +- Build components: use the `convex-create-component` skill +- Plan a migration: use the `convex-migration-helper` skill +- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) +- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) + +## Checklist + +- [ ] Determined starting point: new project or existing app +- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template +- [ ] If existing app: installed `convex` and wired up the provider +- [ ] User has `npx convex dev` running and connected to a deployment +- [ ] `convex/_generated/` directory exists with types +- [ ] `.env.local` has the deployment URL +- [ ] Verified a basic query/mutation round-trip works diff --git a/.windsurf/skills/convex-quickstart/agents/openai.yaml b/.windsurf/skills/convex-quickstart/agents/openai.yaml new file mode 100644 index 00000000..a51a6d09 --- /dev/null +++ b/.windsurf/skills/convex-quickstart/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Quickstart" + short_description: "Start a new Convex app or add Convex to an existing frontend." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#F97316" + default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." + +policy: + allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-quickstart/assets/icon.svg b/.windsurf/skills/convex-quickstart/assets/icon.svg new file mode 100644 index 00000000..d83a73f3 --- /dev/null +++ b/.windsurf/skills/convex-quickstart/assets/icon.svg @@ -0,0 +1,4 @@ + diff --git a/.windsurf/skills/convex-setup-auth b/.windsurf/skills/convex-setup-auth deleted file mode 120000 index a19c8377..00000000 --- a/.windsurf/skills/convex-setup-auth +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex-setup-auth \ No newline at end of file diff --git a/.windsurf/skills/convex-setup-auth/SKILL.md b/.windsurf/skills/convex-setup-auth/SKILL.md new file mode 100644 index 00000000..0d1d9dd6 --- /dev/null +++ b/.windsurf/skills/convex-setup-auth/SKILL.md @@ -0,0 +1,150 @@ +--- +name: convex-setup-auth +description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +--- + +# Convex Authentication Setup + +Implement secure authentication in Convex with user management and access control. + +## When to Use + +- Setting up authentication for the first time +- Implementing user management (users table, identity mapping) +- Creating authentication helper functions +- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) + +## When Not to Use + +- Auth for a non-Convex backend +- Pure OAuth/OIDC documentation without a Convex implementation +- Debugging unrelated bugs that happen to surface near auth code +- The auth provider is already fully configured and the user only needs a one-line fix + +## First Step: Choose the Auth Provider + +Convex supports multiple authentication approaches. Do not assume a provider. + +Before writing setup code: + +1. Ask the user which auth solution they want, unless the repository already makes it obvious +2. If the repo already uses a provider, continue with that provider unless the user wants to switch +3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding + +Common options: + +- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex +- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features +- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically +- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 +- Custom JWT provider - use when integrating an existing auth system not covered above + +Look for signals in the repo before asking: + +- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages +- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components +- Environment variables that clearly point at a provider + +## After Choosing a Provider + +Read the provider's official guide and the matching local reference file: + +- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` +- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` +- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` +- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` + +The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. + +Use those sources for: + +- package installation +- client provider wiring +- environment variables +- `convex/auth.config.ts` setup +- login and logout UI patterns +- framework-specific setup for React, Vite, or Next.js + +For shared auth behavior, use the official Convex docs as the source of truth: + +- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` +- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage +- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance +- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth + +Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. +For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. +For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. +After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. + +## Core Pattern: Protecting Backend Functions + +The most common auth task is checking identity in Convex functions. + +```ts +// Bad: trusting a client-provided userId +export const getMyProfile = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.userId); + }, +}); +``` + +```ts +// Good: verifying identity server-side +export const getMyProfile = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + return await ctx.db + .query("users") + .withIndex("by_tokenIdentifier", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier), + ) + .unique(); + }, +}); +``` + +## Workflow + +1. Determine the provider, either by asking the user or inferring from the repo +2. Ask whether the user wants local-only setup or production-ready setup now +3. Read the matching provider reference file +4. Follow the official provider docs for current setup details +5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns +6. Only add app-level user storage if the docs and app requirements call for it +7. Add authorization checks for ownership, roles, or team access only where the app needs them +8. Verify login state, protected queries, environment variables, and production configuration if requested + +If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. +For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. +If the environment has browser automation tools, you can use them. +If it does not, give the user a short manual validation checklist instead. + +## Reference Files + +### Provider References + +- `references/convex-auth.md` +- `references/clerk.md` +- `references/workos-authkit.md` +- `references/auth0.md` + +## Checklist + +- [ ] Chosen the correct auth provider before writing setup code +- [ ] Read the relevant provider reference file +- [ ] Asked whether the user wants local-only setup or production-ready setup +- [ ] Used the official provider docs for provider-specific wiring +- [ ] Used the official Convex docs for shared auth behavior and authorization patterns +- [ ] Only added app-level user storage if the app actually needs it +- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth +- [ ] Added authentication checks in protected backend functions +- [ ] Added authorization checks where the app actually needs them +- [ ] Clear error messages ("Not authenticated", "Unauthorized") +- [ ] Client auth provider configured for the chosen provider +- [ ] If requested, production auth setup is covered too diff --git a/.windsurf/skills/convex-setup-auth/agents/openai.yaml b/.windsurf/skills/convex-setup-auth/agents/openai.yaml new file mode 100644 index 00000000..d1c90a14 --- /dev/null +++ b/.windsurf/skills/convex-setup-auth/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Setup Auth" + short_description: "Set up Convex auth, user identity mapping, and access control." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." + +policy: + allow_implicit_invocation: true diff --git a/.windsurf/skills/convex-setup-auth/assets/icon.svg b/.windsurf/skills/convex-setup-auth/assets/icon.svg new file mode 100644 index 00000000..4917dbb4 --- /dev/null +++ b/.windsurf/skills/convex-setup-auth/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.windsurf/skills/convex-setup-auth/references/auth0.md b/.windsurf/skills/convex-setup-auth/references/auth0.md new file mode 100644 index 00000000..9c729c5a --- /dev/null +++ b/.windsurf/skills/convex-setup-auth/references/auth0.md @@ -0,0 +1,116 @@ +# Auth0 + +Official docs: + +- https://docs.convex.dev/auth/auth0 +- https://auth0.github.io/auth0-cli/ +- https://auth0.github.io/auth0-cli/auth0_apps_create.html + +Use this when the app already uses Auth0 or the user wants Auth0 specifically. + +## Workflow + +1. Confirm the user wants Auth0 +2. Determine the app framework and whether Auth0 is already partly set up +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and Auth0 guides before making changes +5. Ask whether they want the fastest setup path by installing the Auth0 CLI +6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI +7. If they do not want the CLI path, use the Auth0 dashboard path instead +8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up +9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID +10. Set environment variables for local and production environments +11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +12. Gate Convex-backed UI with Convex auth state +13. Try to verify Convex reports the user as authenticated after Auth0 login +14. If the refresh-token path fails, stop improvising and send the user back to the official docs +15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered + +## What To Do + +- Read the official Convex and Auth0 guide before writing setup code +- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet +- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" +- Make sure the app has already completed the relevant Auth0 quickstart for its frontend +- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` +- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated + +## Key Setup Areas + +- install the Auth0 SDK for the app's framework +- configure `convex/auth.config.ts` with the Auth0 domain and client ID +- set environment variables for local and production environments +- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +- use Convex auth state when gating Convex-backed UI + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- frontend app entry or provider wrapper +- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` +- Auth0 environment variables commonly include: + - `AUTH0_DOMAIN` + - `AUTH0_CLIENT_ID` + - `VITE_AUTH0_DOMAIN` + - `VITE_AUTH0_CLIENT_ID` + +## Concrete Steps + +1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework +2. Ask whether the user wants the Auth0 CLI path +3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` +4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app +5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard +6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard +7. Install the Auth0 SDK for the app's framework +8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID +9. Set frontend and backend environment variables +10. Wrap the app in `Auth0Provider` +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` +12. Run the normal Convex dev or deploy flow after backend config changes +13. Try the official provider config shown in the Convex docs +14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now +15. Only claim success if the user can sign in and Convex recognizes the authenticated session +16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too + +## Gotchas + +- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch +- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant +- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard +- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced +- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end +- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled +- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation +- Keep dev and prod tenants separate if the project uses different Auth0 environments +- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. +- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. +- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. +- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered +- Verify production environment variables and redirect settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the Auth0 login flow +- Verify Convex-authenticated UI renders only after Convex auth state is ready +- Verify protected Convex queries succeed after login +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- Verify the Auth0 app settings match the real local callback and logout URLs during development +- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully +- If production-ready setup was requested, verify the production Auth0 configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Auth0 +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Complete the relevant Auth0 frontend setup +- [ ] Configure `convex/auth.config.ts` +- [ ] Set environment variables +- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs +- [ ] If requested, configure the production deployment too diff --git a/.windsurf/skills/convex-setup-auth/references/clerk.md b/.windsurf/skills/convex-setup-auth/references/clerk.md new file mode 100644 index 00000000..7dbde194 --- /dev/null +++ b/.windsurf/skills/convex-setup-auth/references/clerk.md @@ -0,0 +1,113 @@ +# Clerk + +Official docs: + +- https://docs.convex.dev/auth/clerk +- https://clerk.com/docs/guides/development/integrations/databases/convex + +Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. + +## Workflow + +1. Confirm the user wants Clerk +2. Make sure the user has a Clerk account and a Clerk application +3. Determine the app framework: + - React + - Next.js + - TanStack Start +4. Ask whether the user wants local-only setup or production-ready setup now +5. Gather the Clerk keys and the Clerk Frontend API URL +6. Follow the correct framework section in the official docs +7. Complete the backend and client wiring +8. Verify Convex reports the user as authenticated after login +9. If the user wants production-ready setup, make sure the production Clerk config is also covered + +## What To Do + +- Read the official Convex and Clerk guide before writing setup code +- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application +- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active +- Match the guide to the app's framework, usually React, Next.js, or TanStack Start +- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` + +## Key Setup Areas + +- install the Clerk SDK for the framework in use +- configure `convex/auth.config.ts` with the Clerk issuer domain +- set the required Clerk environment variables +- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` +- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- React or Vite client entry such as `src/main.tsx` +- Next.js client wrapper for Convex if using App Router +- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` +- Clerk app creation page: `https://dashboard.clerk.com/apps/new` +- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` +- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` +- Clerk environment variables: + - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs + - `CLERK_FRONTEND_API_URL` in the Clerk docs + - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps + - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps + - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required + +`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. + +## Concrete Steps + +1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` +2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` +3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed +4. Open `https://dashboard.clerk.com/apps/setup/convex` +5. Activate the Convex integration in Clerk if it is not already active +6. Copy the Clerk Frontend API URL shown there +7. Install the Clerk package for the app's framework +8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens +9. Set the publishable key in the frontend environment +10. Set the issuer domain or Frontend API URL so Convex can validate the JWT +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` +12. Wrap the app in `ClerkProvider` +13. Use Convex auth helpers for authenticated rendering +14. Run the normal Convex dev or deploy flow after updating backend auth config +15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too + +## Gotchas + +- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render +- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper +- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config +- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. +- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. +- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. +- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. +- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` +- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included +- Verify production redirect URLs and any production Clerk domain values before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can sign in with Clerk +- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in +- Verify `useConvexAuth()` reaches the authenticated state after Clerk login +- Verify protected Convex queries run successfully inside authenticated UI +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- If production-ready setup was requested, verify the production Clerk configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Clerk +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Follow the correct framework section in the official guide +- [ ] Set Clerk environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify Convex authenticated state after login +- [ ] If requested, configure the production deployment too diff --git a/.windsurf/skills/convex-setup-auth/references/convex-auth.md b/.windsurf/skills/convex-setup-auth/references/convex-auth.md new file mode 100644 index 00000000..d4824d24 --- /dev/null +++ b/.windsurf/skills/convex-setup-auth/references/convex-auth.md @@ -0,0 +1,143 @@ +# Convex Auth + +Official docs: https://docs.convex.dev/auth/convex-auth +Setup guide: https://labs.convex.dev/auth/setup + +Use this when the user wants auth handled directly in Convex rather than through a third-party provider. + +## Workflow + +1. Confirm the user wants Convex Auth specifically +2. Determine which sign-in methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords and password reset +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the Convex Auth setup guide before writing code +5. Make sure the project has a configured Convex deployment: + - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set + - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing +6. Install the auth packages: + - `npm install @convex-dev/auth @auth/core@0.37.0` +7. Run the initialization command: + - `npx @convex-dev/auth` +8. Confirm the initializer created: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +9. Add the required `authTables` to `convex/schema.ts` +10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` +11. Configure at least one auth method in `convex/auth.ts` +12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code +13. Verify the client can sign in successfully +14. Verify Convex receives authenticated identity in backend functions +15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well +16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex + +## What This Reference Is For + +- choosing Convex Auth as the default provider for a new Convex app +- understanding whether the app wants magic links, OTPs, OAuth, or passwords +- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior + +## What To Do + +- Read the Convex Auth setup guide before writing setup code +- Follow the setup flow from the docs rather than recreating it from memory +- If the app is new, consider starting from the official starter flow instead of hand-wiring everything +- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra + +## Concrete Steps + +1. Install `@convex-dev/auth` and `@auth/core@0.37.0` +2. Run `npx convex dev` if the project does not already have a configured deployment +3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment +4. Run `npx @convex-dev/auth` +5. Confirm the generated auth setup is present before continuing: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +6. Add `authTables` to `convex/schema.ts` +7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry +8. Configure the selected auth methods in `convex/auth.ts` +9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed +10. Verify login locally +11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment + +## Expected Files and Decisions + +- `convex/schema.ts` +- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file +- generated Convex Auth setup produced by `npx @convex-dev/auth` +- an existing configured Convex deployment, or the ability to create one with `npx convex dev` +- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods + +- Decide whether the user is creating a new app or adding auth to an existing app +- For a new app, prefer the official starter flow instead of rebuilding setup by hand +- Decide which auth methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords +- Decide whether the user wants local-only setup or production-ready setup now +- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough + +## Gotchas + +- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. +- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. +- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. +- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. +- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. +- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. +- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. +- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. +- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. +- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment +- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Human Handoff + +If `npx convex dev` or deployment setup requires human input: + +- stop and explain exactly what the user needs to do +- say why that step is required +- resume the auth setup immediately after the user confirms it is done + +## Validation + +- Verify the user can complete a sign-in flow +- Offer to validate sign up, sign out, and sign back in with the configured auth method +- If browser automation is available in the environment, you can do this directly +- If browser automation is not available, give the user a short manual validation checklist instead +- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions +- Verify protected UI only renders after Convex-authenticated state is ready +- Verify environment variables and redirect settings match the current app environment +- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in +- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed +- If production-ready setup was requested, verify the production deployment is also configured correctly + +## Checklist + +- [ ] Confirm the user wants Convex Auth specifically +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Ensure a Convex deployment is configured before running auth initialization +- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` +- [ ] Run `npx convex dev` first if needed +- [ ] Run `npx @convex-dev/auth` +- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created +- [ ] Follow the setup guide for package install and wiring +- [ ] Add `authTables` to `convex/schema.ts` +- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` +- [ ] Configure at least one auth method in `convex/auth.ts` +- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes +- [ ] Confirm which sign-in methods the app needs +- [ ] Verify the client can sign in and the backend receives authenticated identity +- [ ] Offer end-to-end validation of sign up, sign out, and sign back in +- [ ] If requested, configure the production deployment too +- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/.windsurf/skills/convex-setup-auth/references/workos-authkit.md b/.windsurf/skills/convex-setup-auth/references/workos-authkit.md new file mode 100644 index 00000000..038cb9f3 --- /dev/null +++ b/.windsurf/skills/convex-setup-auth/references/workos-authkit.md @@ -0,0 +1,114 @@ +# WorkOS AuthKit + +Official docs: + +- https://docs.convex.dev/auth/authkit/ +- https://docs.convex.dev/auth/authkit/add-to-app +- https://docs.convex.dev/auth/authkit/auto-provision + +Use this when the app already uses WorkOS or the user wants AuthKit specifically. + +## Workflow + +1. Confirm the user wants WorkOS AuthKit +2. Determine whether they want: + - a Convex-managed WorkOS team + - an existing WorkOS team +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and WorkOS AuthKit guide +5. Create or update `convex.json` for the app's framework and real local port +6. Follow the correct branch of the setup flow based on that choice +7. Configure the required WorkOS environment variables +8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs +9. Wire the client provider and callback flow +10. Verify authenticated requests reach Convex +11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too +12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex + +## What To Do + +- Read the official Convex and WorkOS AuthKit guide before writing setup code +- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team +- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra +- Follow the current setup flow from the docs instead of relying on older examples + +## Key Setup Areas + +- package installation for the app's framework +- `convex.json` with the `authKit` section for dev, and preview or prod if needed +- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration +- `convex/auth.config.ts` wiring for WorkOS-issued JWTs +- client provider setup and token flow into Convex +- login callback and redirect configuration + +## Files and Env Vars To Expect + +- `convex.json` +- `convex/auth.config.ts` +- frontend auth provider wiring +- callback or redirect route setup where the framework requires it +- WorkOS environment variables commonly include: + - `WORKOS_CLIENT_ID` + - `WORKOS_API_KEY` + - `WORKOS_COOKIE_PASSWORD` + - `VITE_WORKOS_CLIENT_ID` + - `VITE_WORKOS_REDIRECT_URI` + - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` + +For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. + +## Concrete Steps + +1. Choose Convex-managed or existing WorkOS team +2. Create or update `convex.json` with the `authKit` section for the framework in use +3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port +4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow +5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` +6. Create or update `convex/auth.config.ts` for WorkOS JWT validation +7. Run the normal Convex dev or deploy flow so backend config is synced +8. Wire the WorkOS client provider in the app +9. Configure callback and redirect handling +10. Verify the user can sign in and return to the app +11. Verify Convex sees the authenticated user after login +12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too + +## Gotchas + +- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious +- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys +- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex +- Do not mix dev and prod WorkOS credentials or redirect URIs +- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it +- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. +- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. +- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. +- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. +- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered +- Verify the production redirect and callback settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the login flow and return to the app +- Verify the callback URL matches the real frontend port in local dev +- Verify Convex receives authenticated requests after login +- Verify `convex.json` matches the framework and chosen WorkOS setup path +- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path +- Verify environment variables differ correctly between local and production where needed +- If production-ready setup was requested, verify the production WorkOS configuration is also covered + +## Checklist + +- [ ] Confirm the user wants WorkOS AuthKit +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Choose Convex-managed or existing WorkOS team +- [ ] Create or update `convex.json` +- [ ] Configure WorkOS environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify authenticated requests reach Convex after login +- [ ] If requested, configure the production deployment too diff --git a/convex/_generated/ai/ai-files.state.json b/convex/_generated/ai/ai-files.state.json index a8f6e5f4..50cbe884 100644 --- a/convex/_generated/ai/ai-files.state.json +++ b/convex/_generated/ai/ai-files.state.json @@ -1,8 +1,8 @@ { - "guidelinesHash": "294b619f8246c26bd6bfb6a57122503f0e2149872fc6b26609b7a95bfefaf2b8", + "guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57", "agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", "claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", - "agentSkillsSha": "dc8ff761cfe4da450af2ea8a9ec708f737064bed", + "agentSkillsSha": "4de8fba05b0a506661116985649072777049f67b", "installedSkillNames": [ "convex-create-component", "convex-migration-helper", diff --git a/convex/_generated/ai/guidelines.md b/convex/_generated/ai/guidelines.md index 151cdf71..e41beddc 100644 --- a/convex/_generated/ai/guidelines.md +++ b/convex/_generated/ai/guidelines.md @@ -1,78 +1,90 @@ # Convex guidelines + ## Function guidelines + ### Http endpoint syntax + - HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: + ```typescript import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; const http = httpRouter(); http.route({ - path: "/echo", - method: "POST", - handler: httpAction(async (ctx, req) => { + path: "/echo", + method: "POST", + handler: httpAction(async (ctx, req) => { const body = await req.bytes(); return new Response(body, { status: 200 }); - }), + }), }); ``` + - HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. ### Validators + - Below is an example of an array validator: + ```typescript import { mutation } from "./_generated/server"; import { v } from "convex/values"; export default mutation({ -args: { + args: { simpleArray: v.array(v.union(v.string(), v.number())), -}, -handler: async (ctx, args) => { + }, + handler: async (ctx, args) => { //... -}, + }, }); ``` + - Below is an example of a schema with validators that codify a discriminated union type: + ```typescript import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ - results: defineTable( - v.union( - v.object({ - kind: v.literal("error"), - errorMessage: v.string(), - }), - v.object({ - kind: v.literal("success"), - value: v.number(), - }), - ), - ) + results: defineTable( + v.union( + v.object({ + kind: v.literal("error"), + errorMessage: v.string(), + }), + v.object({ + kind: v.literal("success"), + value: v.number(), + }), + ), + ), }); ``` + - Here are the valid Convex types along with their respective validators: -Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | -| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Id | string | `doc._id` | `v.id(tableName)` | | -| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | -| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | -| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | -| Boolean | boolean | `true` | `v.boolean()` | -| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | -| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | -| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | -| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | -| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". | + Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | + | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | Id | string | `doc._id` | `v.id(tableName)` | | + | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | + | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | + | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | + | Boolean | boolean | `true` | `v.boolean()` | + | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | + | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | + | Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | + | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | +| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". | ### Function registration + - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. - Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. - You CANNOT register a function through the `api` or `internal` objects. - ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. ### Function calling + - Use `ctx.runQuery` to call a query from a query, mutation, or action. - Use `ctx.runMutation` to call a mutation from a mutation or action. - Use `ctx.runAction` to call an action from an action. @@ -80,6 +92,7 @@ Convex Type | TS/JS type | Example Usage | Validator for argument val - Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. - All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. - When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, + ``` export const f = query({ args: { name: v.string() }, @@ -98,6 +111,7 @@ export const g = query({ ``` ### Function references + - Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. - Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. - Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. @@ -105,6 +119,7 @@ export const g = query({ - Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. ### Pagination + - Define pagination using the following syntax: ```ts @@ -112,17 +127,19 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; export const listWithExtraArg = query({ - args: { paginationOpts: paginationOptsValidator, author: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("messages") - .withIndex("by_author", (q) => q.eq("author", args.author)) - .order("desc") - .paginate(args.paginationOpts); - }, + args: { paginationOpts: paginationOptsValidator, author: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .withIndex("by_author", (q) => q.eq("author", args.author)) + .order("desc") + .paginate(args.paginationOpts); + }, }); ``` + Note: `paginationOpts` is an object with the following properties: + - `numItems`: the maximum number of documents to return (the validator is `v.number()`) - `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) - A query that ends in `.paginate()` returns an object that has the following properties: @@ -130,8 +147,8 @@ Note: `paginationOpts` is an object with the following properties: - isDone (a boolean that represents whether or not this is the last page of documents) - continueCursor (a string that represents the cursor to use to fetch the next page of documents) - ## Schema guidelines + - Always define your schema in `convex/schema.ts`. - Always import the schema definition functions from `convex/server`. - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. @@ -141,8 +158,10 @@ Note: `paginationOpts` is an object with the following properties: - Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record. ## Authentication guidelines + - Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`. - Example `convex/auth.config.ts`: + ```typescript export default { providers: [ @@ -153,11 +172,14 @@ export default { ], }; ``` + The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim. + - Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier. - In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key. - NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`. - When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`: + ```tsx import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react"; @@ -171,45 +193,51 @@ function App({ children }: { children: React.ReactNode }) { ); } ``` + The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests. ## Typescript guidelines -- You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. + +- You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. - Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table. - Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type. - If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: + ```ts import { query } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; export const exampleQuery = query({ - args: { userIds: v.array(v.id("users")) }, - handler: async (ctx, args) => { - const idToUsername: Record, string> = {}; - for (const userId of args.userIds) { - const user = await ctx.db.get("users", userId); - if (user) { - idToUsername[user._id] = user.username; - } - } - - return idToUsername; - }, + args: { userIds: v.array(v.id("users")) }, + handler: async (ctx, args) => { + const idToUsername: Record, string> = {}; + for (const userId of args.userIds) { + const user = await ctx.db.get("users", userId); + if (user) { + idToUsername[user._id] = user.username; + } + } + + return idToUsername; + }, }); ``` + - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. ## Full text search guidelines + - A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: const messages = await ctx.db - .query("messages") - .withSearchIndex("search_body", (q) => - q.search("body", "hello hi").eq("channel", "#general"), - ) - .take(10); +.query("messages") +.withSearchIndex("search_body", (q) => +q.search("body", "hello hi").eq("channel", "#general"), +) +.take(10); ## Query guidelines + - Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. - If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way. - Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations. @@ -217,39 +245,46 @@ const messages = await ctx.db - Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits. - Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. - When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. + ### Ordering + - By default Convex always returns documents in ascending `_creationTime` order. - You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. - Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. - ## Mutation guidelines + - Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` - Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` ## Action guidelines + - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. - Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file. - `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`. - Never use `ctx.db` inside of an action. Actions don't have access to the database. - Below is an example of the syntax for an action: + ```ts import { action } from "./_generated/server"; export const exampleAction = action({ - args: {}, - handler: async (ctx, args) => { - console.log("This action does not return anything"); - return null; - }, + args: {}, + handler: async (ctx, args) => { + console.log("This action does not return anything"); + return null; + }, }); ``` ## Scheduling guidelines + ### Cron guidelines + - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. - Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. - Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, + ```ts import { cronJobs } from "convex/server"; import { internal } from "./_generated/api"; @@ -269,14 +304,16 @@ crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); export default crons; ``` -- You can register Convex functions within `crons.ts` just like any other file. -- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file. +- You can register Convex functions within `crons.ts` just like any other file. +- If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file. ## Testing guidelines + - Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`. Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`: + ```typescript /// import { convexTest } from "convex-test"; @@ -293,13 +330,16 @@ test("some behavior", async () => { expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]); }); ``` + The `modules` argument is required so convex-test can discover and load function files. The `/// ` directive is needed for TypeScript to recognize `import.meta.glob`. ## File storage guidelines + - The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. - Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. + ``` import { query } from "./_generated/server"; import { Id } from "./_generated/dataModel"; @@ -321,6 +361,5 @@ export const exampleQuery = query({ }, }); ``` -- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. - +- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. diff --git a/convex/images.ts b/convex/images.ts index 89aad317..41a32c6c 100644 --- a/convex/images.ts +++ b/convex/images.ts @@ -1,39 +1,10 @@ import type { Doc, Id } from "./_generated/dataModel"; import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"; -import { ConvexError, v } from "convex/values"; +import { v } from "convex/values"; import { assertStrategyRole, requireCurrentUser } from "./lib/auth"; -import { - getElementByPublicId, - getLineupByPublicId, - getPageByPublicId, - getStrategyByPublicId, -} from "./lib/entities"; +import { getStrategyByPublicId } from "./lib/entities"; type AnyCtx = MutationCtx | QueryCtx; -type ImageAssetOwnerType = "element" | "lineup"; - -function normalizeOwnerType( - asset: Pick, "ownerType">, -): ImageAssetOwnerType { - return asset.ownerType ?? "element"; -} - -function ownerNotFoundError( - ownerType: ImageAssetOwnerType, - ownerPublicId: string, -): ConvexError<{ - code: "OWNER_NOT_FOUND"; - message: "owner_not_found"; - ownerType: ImageAssetOwnerType; - ownerPublicId: string; -}> { - return new ConvexError({ - code: "OWNER_NOT_FOUND", - message: "owner_not_found", - ownerType, - ownerPublicId, - }); -} async function getImageAssetByPublicId( ctx: AnyCtx, @@ -45,65 +16,6 @@ async function getImageAssetByPublicId( .unique(); } -function inferFileExtension( - asset: Pick, "fileExtension" | "storagePath">, -): string { - if (asset.fileExtension !== undefined && asset.fileExtension.length > 0) { - return asset.fileExtension; - } - - const legacyPath = asset.storagePath ?? ""; - const match = legacyPath.match(/(\.[A-Za-z0-9]+)(?:$|[?#])/); - return match?.[1]?.toLowerCase() ?? ""; -} - -async function resolveOwnerAttachment( - ctx: MutationCtx, - args: { - strategyId: Id<"strategies">; - pageId: Id<"pages">; - ownerType: ImageAssetOwnerType; - ownerPublicId: string; - }, -): Promise<{ - elementId: Id<"elements"> | undefined; - lineupId: Id<"lineups"> | undefined; -}> { - if (args.ownerType === "element") { - try { - const element = await getElementByPublicId(ctx, args.ownerPublicId); - if (element.strategyId !== args.strategyId || element.pageId !== args.pageId) { - throw new Error("Element context mismatch"); - } - return { - elementId: element._id, - lineupId: undefined, - }; - } catch (error) { - if (error instanceof Error && error.message.startsWith("Element not found:")) { - throw ownerNotFoundError(args.ownerType, args.ownerPublicId); - } - throw error; - } - } - - try { - const lineup = await getLineupByPublicId(ctx, args.ownerPublicId); - if (lineup.strategyId !== args.strategyId || lineup.pageId !== args.pageId) { - throw new Error("Lineup context mismatch"); - } - return { - elementId: undefined, - lineupId: lineup._id, - }; - } catch (error) { - if (error instanceof Error && error.message.startsWith("Lineup not found:")) { - throw ownerNotFoundError(args.ownerType, args.ownerPublicId); - } - throw error; - } -} - async function serializeAssetForViewer( ctx: QueryCtx, strategyPublicId: string, @@ -111,34 +23,12 @@ async function serializeAssetForViewer( ): Promise<{ publicId: string; strategyPublicId: string; - pagePublicId: string; - ownerType: ImageAssetOwnerType; - ownerPublicId: string; fileExtension: string; mimeType: string; width: number | null; height: number | null; url: string | null; - legacyStoragePath: string | null; -} | null> { - const page = await ctx.db.get(asset.pageId); - if (page === null) { - return null; - } - - const ownerType = normalizeOwnerType(asset); - const ownerDoc = ownerType === "lineup" - ? asset.lineupId === undefined - ? null - : await ctx.db.get(asset.lineupId) - : asset.elementId === undefined - ? null - : await ctx.db.get(asset.elementId); - - if (ownerDoc === null) { - return null; - } - +}> { const url = asset.storageId === undefined ? null : await ctx.storage.getUrl(asset.storageId); @@ -146,15 +36,11 @@ async function serializeAssetForViewer( return { publicId: asset.publicId, strategyPublicId, - pagePublicId: page.publicId, - ownerType, - ownerPublicId: ownerDoc.publicId, - fileExtension: inferFileExtension(asset), + fileExtension: asset.fileExtension ?? "", mimeType: asset.mimeType, width: asset.width ?? null, height: asset.height ?? null, url, - legacyStoragePath: asset.storagePath ?? null, }; } @@ -168,13 +54,13 @@ export async function deleteImageAsset( await ctx.db.delete(asset._id); } -export async function deleteImageAssetsForPage( +export async function deleteImageAssetsForStrategy( ctx: MutationCtx, - pageId: Id<"pages">, + strategyId: Id<"strategies">, ): Promise { const assets = await ctx.db .query("imageAssets") - .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) .collect(); for (const asset of assets) { @@ -199,10 +85,7 @@ export const generateUploadUrl = mutation({ export const completeUpload = mutation({ args: { strategyPublicId: v.string(), - pagePublicId: v.string(), assetPublicId: v.string(), - ownerType: v.union(v.literal("element"), v.literal("lineup")), - ownerPublicId: v.string(), storageId: v.id("_storage"), mimeType: v.string(), fileExtension: v.string(), @@ -212,18 +95,6 @@ export const completeUpload = mutation({ handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); const { user } = await assertStrategyRole(ctx, strategy, "editor"); - const page = await getPageByPublicId(ctx, args.pagePublicId); - - if (page.strategyId !== strategy._id) { - throw new Error("Page strategy mismatch"); - } - - const { elementId, lineupId } = await resolveOwnerAttachment(ctx, { - strategyId: strategy._id, - pageId: page._id, - ownerType: args.ownerType, - ownerPublicId: args.ownerPublicId, - }); const existing = await getImageAssetByPublicId(ctx, args.assetPublicId); const now = Date.now(); @@ -231,10 +102,6 @@ export const completeUpload = mutation({ await ctx.db.insert("imageAssets", { publicId: args.assetPublicId, strategyId: strategy._id, - pageId: page._id, - ownerType: args.ownerType, - elementId, - lineupId, storageId: args.storageId, fileExtension: args.fileExtension, mimeType: args.mimeType, @@ -250,11 +117,6 @@ export const completeUpload = mutation({ } await ctx.db.patch(existing._id, { - strategyId: strategy._id, - pageId: page._id, - ownerType: args.ownerType, - elementId, - lineupId, storageId: args.storageId, fileExtension: args.fileExtension, mimeType: args.mimeType, @@ -281,11 +143,9 @@ export const listForStrategy = query({ .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) .collect(); - const serialized = await Promise.all( + return await Promise.all( assets.map((asset) => serializeAssetForViewer(ctx, strategy.publicId, asset)), ); - - return serialized.filter((asset) => asset !== null); }, }); @@ -344,12 +204,10 @@ export const listPotentiallyStale = query({ .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) .collect(); - const serialized = await Promise.all( + return await Promise.all( assets .filter((asset) => asset.createdByUserId === user._id) .map((asset) => serializeAssetForViewer(ctx, strategy.publicId, asset)), ); - - return serialized.filter((asset) => asset !== null); }, }); diff --git a/convex/ops.ts b/convex/ops.ts index 9f739522..27216d23 100644 --- a/convex/ops.ts +++ b/convex/ops.ts @@ -1,6 +1,5 @@ import { mutation } from "./_generated/server"; import { v } from "convex/values"; -import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole } from "./lib/auth"; import { getElementByPublicId, @@ -226,7 +225,6 @@ export const applyBatch = mutation({ await ctx.db.delete(lineup._id); } - await deleteImageAssetsForPage(ctx, page._id); await ctx.db.delete(page._id); appliedRevision = page.revision + 1; strategy = await incrementSequence(ctx, strategy); diff --git a/convex/pages.ts b/convex/pages.ts index 907cc667..fa02f09c 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -1,6 +1,5 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; -import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole } from "./lib/auth"; import { getPageByPublicId, @@ -176,8 +175,6 @@ export const deletePage = mutation({ await ctx.db.delete(lineup._id); } - await deleteImageAssetsForPage(ctx, page._id); - await ctx.db.delete(page._id); const ordered = sortByNumberField( diff --git a/convex/schema.ts b/convex/schema.ts index 04fda54e..465d296d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -135,15 +135,8 @@ export default defineSchema({ imageAssets: defineTable({ publicId: v.string(), strategyId: v.id("strategies"), - pageId: v.id("pages"), - // Legacy rows predate ownerType and are treated as element-owned. - ownerType: v.optional(v.union(v.literal("element"), v.literal("lineup"))), - elementId: v.optional(v.id("elements")), - lineupId: v.optional(v.id("lineups")), storageId: v.optional(v.id("_storage")), - // Legacy rows predate fileExtension and may need best-effort inference. fileExtension: v.optional(v.string()), - storagePath: v.optional(v.string()), mimeType: v.string(), width: v.optional(v.number()), height: v.optional(v.number()), @@ -152,9 +145,7 @@ export default defineSchema({ updatedAt: v.number(), }) .index("by_publicId", ["publicId"]) - .index("by_strategyId", ["strategyId"]) - .index("by_pageId", ["pageId"]) - .index("by_lineupId", ["lineupId"]), + .index("by_strategyId", ["strategyId"]), operationEvents: defineTable({ strategyId: v.id("strategies"), pageId: v.optional(v.id("pages")), diff --git a/convex/strategies.ts b/convex/strategies.ts index f98d4ac3..74854582 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -2,7 +2,7 @@ import type { Doc, Id } from "./_generated/dataModel"; import type { QueryCtx, MutationCtx } from "./_generated/server"; import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; -import { deleteImageAssetsForPage } from "./images"; +import { deleteImageAssetsForStrategy } from "./images"; import { assertFolderRole, assertStrategyRole, @@ -332,11 +332,11 @@ export const deleteStrategy = mutation({ await ctx.db.delete(lineup._id); } - await deleteImageAssetsForPage(ctx, page._id); - await ctx.db.delete(page._id); } + await deleteImageAssetsForStrategy(ctx, strategy._id); + const collaborators = await ctx.db .query("strategyCollaborators") .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) diff --git a/lib/collab/cloud_media_models.dart b/lib/collab/cloud_media_models.dart index ccb8e32e..734cc957 100644 --- a/lib/collab/cloud_media_models.dart +++ b/lib/collab/cloud_media_models.dart @@ -2,15 +2,15 @@ import 'package:hive_ce/hive.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; -enum CloudMediaOwnerType { element, lineup } - enum CloudMediaJobState { pendingUpload, pendingAttach, failed } String normalizeImageExtension(String extension) { if (extension.isEmpty) { return extension; } - return extension.startsWith('.') ? extension.toLowerCase() : '.${extension.toLowerCase()}'; + return extension.startsWith('.') + ? extension.toLowerCase() + : '.${extension.toLowerCase()}'; } String mimeTypeForImageExtension(String extension) { @@ -35,9 +35,6 @@ class CloudMediaUploadJob extends HiveObject { CloudMediaUploadJob({ required this.jobId, required this.strategyPublicId, - required this.pagePublicId, - required this.ownerType, - required this.ownerPublicId, required this.assetPublicId, required this.fileExtension, required this.mimeType, @@ -52,9 +49,6 @@ class CloudMediaUploadJob extends HiveObject { final String jobId; final String strategyPublicId; - final String pagePublicId; - final CloudMediaOwnerType ownerType; - final String ownerPublicId; final String assetPublicId; final String fileExtension; final String mimeType; @@ -71,9 +65,6 @@ class CloudMediaUploadJob extends HiveObject { CloudMediaUploadJob copyWith({ String? jobId, String? strategyPublicId, - String? pagePublicId, - CloudMediaOwnerType? ownerType, - String? ownerPublicId, String? assetPublicId, String? fileExtension, String? mimeType, @@ -88,20 +79,19 @@ class CloudMediaUploadJob extends HiveObject { return CloudMediaUploadJob( jobId: jobId ?? this.jobId, strategyPublicId: strategyPublicId ?? this.strategyPublicId, - pagePublicId: pagePublicId ?? this.pagePublicId, - ownerType: ownerType ?? this.ownerType, - ownerPublicId: ownerPublicId ?? this.ownerPublicId, assetPublicId: assetPublicId ?? this.assetPublicId, fileExtension: fileExtension ?? this.fileExtension, mimeType: mimeType ?? this.mimeType, width: width ?? this.width, height: height ?? this.height, - storageId: - identical(storageId, _noChange) ? this.storageId : storageId as String?, + storageId: identical(storageId, _noChange) + ? this.storageId + : storageId as String?, state: state ?? this.state, attempts: attempts ?? this.attempts, - lastError: - identical(lastError, _noChange) ? this.lastError : lastError as String?, + lastError: identical(lastError, _noChange) + ? this.lastError + : lastError as String?, updatedAt: updatedAt ?? this.updatedAt, ); } diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index f8be6e34..2434eac2 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:icarus/collab/cloud_media_models.dart'; - enum StrategyOpKind { add, move, patch, delete, reorder } enum StrategyOpEntityType { strategy, page, element, lineup } @@ -296,44 +294,30 @@ class RemoteImageAsset { const RemoteImageAsset({ required this.publicId, required this.strategyPublicId, - required this.pagePublicId, - required this.ownerType, - required this.ownerPublicId, required this.fileExtension, required this.mimeType, required this.width, required this.height, required this.url, - required this.legacyStoragePath, }); final String publicId; final String strategyPublicId; - final String pagePublicId; - final CloudMediaOwnerType ownerType; - final String ownerPublicId; final String fileExtension; final String mimeType; final int? width; final int? height; final String? url; - final String? legacyStoragePath; factory RemoteImageAsset.fromJson(Map json) { return RemoteImageAsset( publicId: json['publicId'] as String, strategyPublicId: json['strategyPublicId'] as String, - pagePublicId: json['pagePublicId'] as String, - ownerType: (json['ownerType'] as String?) == 'lineup' - ? CloudMediaOwnerType.lineup - : CloudMediaOwnerType.element, - ownerPublicId: json['ownerPublicId'] as String, fileExtension: json['fileExtension'] as String? ?? '', mimeType: json['mimeType'] as String, width: (json['width'] as num?)?.toInt(), height: (json['height'] as num?)?.toInt(), url: json['url'] as String?, - legacyStoragePath: json['legacyStoragePath'] as String?, ); } } diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index da1f9457..97a8ee1d 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:convex_flutter/convex_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; -import 'package:icarus/collab/cloud_media_models.dart'; final convexStrategyRepositoryProvider = Provider( (ref) => ConvexStrategyRepository(ConvexClient.instance), @@ -376,10 +375,7 @@ class ConvexStrategyRepository { Future completeImageUpload({ required String strategyPublicId, - required String pagePublicId, required String assetPublicId, - required CloudMediaOwnerType ownerType, - required String ownerPublicId, required String storageId, required String mimeType, required String fileExtension, @@ -390,10 +386,7 @@ class ConvexStrategyRepository { name: 'images:completeUpload', args: { 'strategyPublicId': strategyPublicId, - 'pagePublicId': pagePublicId, 'assetPublicId': assetPublicId, - 'ownerType': ownerType.name, - 'ownerPublicId': ownerPublicId, 'storageId': storageId, 'mimeType': mimeType, 'fileExtension': fileExtension, diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 5522cc7a..c88218d2 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -43,7 +43,6 @@ import 'package:icarus/strategy/strategy_models.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), - AdapterSpec(), AdapterSpec(), AdapterSpec(), AdapterSpec(), diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index b3a4f709..a7539ab1 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1709,43 +1709,6 @@ class AbilityVisualStateAdapter extends TypeAdapter { typeId == other.typeId; } -class CloudMediaOwnerTypeAdapter extends TypeAdapter { - @override - final typeId = 33; - - @override - CloudMediaOwnerType read(BinaryReader reader) { - switch (reader.readByte()) { - case 0: - return CloudMediaOwnerType.element; - case 1: - return CloudMediaOwnerType.lineup; - default: - return CloudMediaOwnerType.element; - } - } - - @override - void write(BinaryWriter writer, CloudMediaOwnerType obj) { - switch (obj) { - case CloudMediaOwnerType.element: - writer.writeByte(0); - case CloudMediaOwnerType.lineup: - writer.writeByte(1); - } - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is CloudMediaOwnerTypeAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - class CloudMediaJobStateAdapter extends TypeAdapter { @override final typeId = 34; @@ -1800,10 +1763,7 @@ class CloudMediaUploadJobAdapter extends TypeAdapter { return CloudMediaUploadJob( jobId: fields[0] as String, strategyPublicId: fields[1] as String, - pagePublicId: fields[2] as String, - ownerType: fields[3] as CloudMediaOwnerType, - ownerPublicId: fields[4] as String, - assetPublicId: fields[5] as String, + assetPublicId: (fields[5] ?? fields[0]) as String, fileExtension: fields[6] as String, mimeType: fields[7] as String, state: fields[11] as CloudMediaJobState, @@ -1819,17 +1779,11 @@ class CloudMediaUploadJobAdapter extends TypeAdapter { @override void write(BinaryWriter writer, CloudMediaUploadJob obj) { writer - ..writeByte(15) + ..writeByte(10) ..writeByte(0) ..write(obj.jobId) ..writeByte(1) ..write(obj.strategyPublicId) - ..writeByte(2) - ..write(obj.pagePublicId) - ..writeByte(3) - ..write(obj.ownerType) - ..writeByte(4) - ..write(obj.ownerPublicId) ..writeByte(5) ..write(obj.assetPublicId) ..writeByte(6) diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 0d0e40a5..b79950f8 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -585,14 +585,6 @@ types: index: 4 showInnerFill: index: 5 - CloudMediaOwnerType: - typeId: 33 - nextIndex: 2 - fields: - element: - index: 0 - lineup: - index: 1 CloudMediaJobState: typeId: 34 nextIndex: 3 @@ -611,12 +603,6 @@ types: index: 0 strategyPublicId: index: 1 - pagePublicId: - index: 2 - ownerType: - index: 3 - ownerPublicId: - index: 4 assetPublicId: index: 5 fileExtension: diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index 71a7e173..cd9b0757 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -15,7 +15,6 @@ extension HiveRegistrar on HiveInterface { registerAdapter(AppPreferencesAdapter()); registerAdapter(BoundingBoxAdapter()); registerAdapter(CloudMediaJobStateAdapter()); - registerAdapter(CloudMediaOwnerTypeAdapter()); registerAdapter(CloudMediaUploadJobAdapter()); registerAdapter(EllipseDrawingAdapter()); registerAdapter(FolderColorAdapter()); @@ -54,7 +53,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(AppPreferencesAdapter()); registerAdapter(BoundingBoxAdapter()); registerAdapter(CloudMediaJobStateAdapter()); - registerAdapter(CloudMediaOwnerTypeAdapter()); registerAdapter(CloudMediaUploadJobAdapter()); registerAdapter(EllipseDrawingAdapter()); registerAdapter(FolderColorAdapter()); diff --git a/lib/providers/collab/cloud_media_upload_queue_provider.dart b/lib/providers/collab/cloud_media_upload_queue_provider.dart index 9a056086..78e942fa 100644 --- a/lib/providers/collab/cloud_media_upload_queue_provider.dart +++ b/lib/providers/collab/cloud_media_upload_queue_provider.dart @@ -13,7 +13,6 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -56,12 +55,13 @@ class CloudMediaUploadQueueState { } } -final cloudMediaUploadQueueProvider = NotifierProvider< - CloudMediaUploadQueueNotifier, CloudMediaUploadQueueState>( +final cloudMediaUploadQueueProvider = + NotifierProvider( CloudMediaUploadQueueNotifier.new, ); -class CloudMediaUploadQueueNotifier extends Notifier { +class CloudMediaUploadQueueNotifier + extends Notifier { Timer? _retryTimer; Box get _box => @@ -99,7 +99,6 @@ class CloudMediaUploadQueueNotifier extends Notifier Future enqueuePlacedImageUpload({ required String imagePublicId, String? strategyPublicId, - String? pagePublicId, String? fileExtension, String? mimeType, int? width, @@ -107,11 +106,8 @@ class CloudMediaUploadQueueNotifier extends Notifier }) async { final strategyState = ref.read(strategyProvider); final resolvedStrategyId = strategyPublicId ?? strategyState.strategyId; - final resolvedPageId = - pagePublicId ?? ref.read(strategyPageSessionProvider).activePageId; if (strategyState.source != StrategySource.cloud || - resolvedStrategyId == null || - resolvedPageId == null) { + resolvedStrategyId == null) { return; } @@ -120,9 +116,6 @@ class CloudMediaUploadQueueNotifier extends Notifier CloudMediaUploadJob( jobId: imagePublicId, strategyPublicId: resolvedStrategyId, - pagePublicId: resolvedPageId, - ownerType: CloudMediaOwnerType.element, - ownerPublicId: imagePublicId, assetPublicId: imagePublicId, fileExtension: normalizedExtension, mimeType: mimeType ?? mimeTypeForImageExtension(normalizedExtension), @@ -138,9 +131,6 @@ class CloudMediaUploadQueueNotifier extends Notifier Future enqueueJobForLocalFile({ required String strategyPublicId, - required String pagePublicId, - required CloudMediaOwnerType ownerType, - required String ownerPublicId, required String assetPublicId, required String fileExtension, String? mimeType, @@ -152,9 +142,6 @@ class CloudMediaUploadQueueNotifier extends Notifier CloudMediaUploadJob( jobId: assetPublicId, strategyPublicId: strategyPublicId, - pagePublicId: pagePublicId, - ownerType: ownerType, - ownerPublicId: ownerPublicId, assetPublicId: assetPublicId, fileExtension: normalizedExtension, mimeType: mimeType ?? mimeTypeForImageExtension(normalizedExtension), @@ -170,8 +157,6 @@ class CloudMediaUploadQueueNotifier extends Notifier Future enqueueLineupMediaJobs({ required String strategyPublicId, - required String pagePublicId, - required String lineupPublicId, required Iterable images, }) async { for (final image in images) { @@ -180,9 +165,6 @@ class CloudMediaUploadQueueNotifier extends Notifier CloudMediaUploadJob( jobId: image.id, strategyPublicId: strategyPublicId, - pagePublicId: pagePublicId, - ownerType: CloudMediaOwnerType.lineup, - ownerPublicId: lineupPublicId, assetPublicId: image.id, fileExtension: normalizedExtension, mimeType: mimeTypeForImageExtension(normalizedExtension), @@ -237,7 +219,10 @@ class CloudMediaUploadQueueNotifier extends Notifier } if (_readJobs().isNotEmpty) { - unawaited(_processNextJob(ignoreBackoff: ignoreBackoff)); + // Only bypass backoff for the initial user-triggered kick. Follow-up + // attempts must honor retry timing so transient attach failures do not + // hammer Convex in a tight loop. + unawaited(_processNextJob(ignoreBackoff: false)); } } @@ -268,7 +253,8 @@ class CloudMediaUploadQueueNotifier extends Notifier return; } - if (job.state == CloudMediaJobState.pendingUpload || job.storageId == null) { + if (job.state == CloudMediaJobState.pendingUpload || + job.storageId == null) { await _uploadJobBlob(job); return; } @@ -292,7 +278,8 @@ class CloudMediaUploadQueueNotifier extends Notifier return; } - final uploadUrl = await _repo.generateImageUploadUrl(job.strategyPublicId); + final uploadUrl = + await _repo.generateImageUploadUrl(job.strategyPublicId); if (uploadUrl.isEmpty) { throw StateError('Empty Convex upload URL'); } @@ -335,10 +322,7 @@ class CloudMediaUploadQueueNotifier extends Notifier try { await _repo.completeImageUpload( strategyPublicId: job.strategyPublicId, - pagePublicId: job.pagePublicId, assetPublicId: job.assetPublicId, - ownerType: job.ownerType, - ownerPublicId: job.ownerPublicId, storageId: job.storageId!, mimeType: job.mimeType, fileExtension: job.fileExtension, @@ -348,21 +332,6 @@ class CloudMediaUploadQueueNotifier extends Notifier await _box.delete(job.jobId); _refreshState(); } catch (error) { - if (_isOwnerNotFoundError(error)) { - await _box.put( - job.jobId, - job.copyWith( - state: CloudMediaJobState.pendingAttach, - attempts: job.attempts + 1, - lastError: 'owner_not_found', - updatedAt: DateTime.now(), - ), - ); - _refreshState(); - _scheduleRetryForNextEligibleJob(); - return; - } - await _markJobFailed( job, '$error', @@ -425,7 +394,8 @@ class CloudMediaUploadQueueNotifier extends Notifier return; } - final delay = earliest.isAfter(now) ? earliest.difference(now) : Duration.zero; + final delay = + earliest.isAfter(now) ? earliest.difference(now) : Duration.zero; _retryTimer = Timer(delay, () { unawaited(_processNextJob(ignoreBackoff: false)); }); @@ -436,9 +406,6 @@ class CloudMediaUploadQueueNotifier extends Notifier if (existing != null) { final merged = existing.copyWith( strategyPublicId: nextJob.strategyPublicId, - pagePublicId: nextJob.pagePublicId, - ownerType: nextJob.ownerType, - ownerPublicId: nextJob.ownerPublicId, assetPublicId: nextJob.assetPublicId, fileExtension: nextJob.fileExtension, mimeType: nextJob.mimeType, @@ -477,20 +444,7 @@ class CloudMediaUploadQueueNotifier extends Notifier return storageId; } } - throw const FormatException('Convex upload response did not include storageId'); - } - - bool _isOwnerNotFoundError(Object error) { - if (error is Map) { - final code = error['code']?.toString().toUpperCase(); - final message = error['message']?.toString().toLowerCase(); - if (code == 'OWNER_NOT_FOUND' || message == 'owner_not_found') { - return true; - } - } - - final normalized = error.toString().toLowerCase(); - return normalized.contains('owner_not_found') || - normalized.contains('owner not found'); + throw const FormatException( + 'Convex upload response did not include storageId'); } } diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 61658364..31993ca7 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -163,8 +163,8 @@ class StrategyOpQueueNotifier extends Notifier { ); continue; } - opsByPage.putIfAbsent(pageId, () => {})[entityKey] = - op; + opsByPage.putIfAbsent( + pageId, () => {})[entityKey] = op; } if (!mapEquals(genericQueued, state.queuedByEntityKey)) { @@ -185,84 +185,44 @@ class StrategyOpQueueNotifier extends Notifier { _scheduleFlush(flushImmediately: flushImmediately); } + void syncDesiredOp({ + required EntitySyncKey entityKey, + required StrategyOp? desiredOp, + bool flushImmediately = false, + }) { + final changed = _syncDesiredOpsForKeys( + keys: {entityKey}, + desiredOpsByEntityKey: desiredOp == null + ? const {} + : {entityKey: desiredOp}, + ); + if (!changed) { + return; + } + _scheduleFlush(flushImmediately: flushImmediately); + } + void syncDesiredOpsForPage({ required String pageId, required Map desiredOpsByEntityKey, bool clearMissing = true, bool flushImmediately = false, }) { - final queued = Map.from( - state.queuedByEntityKey, - ); final pageKeys = clearMissing ? { - ...queued.keys.where((key) => pageIdForEntityKey(key) == pageId), + ...state.queuedByEntityKey.keys + .where((key) => pageIdForEntityKey(key) == pageId), ...desiredOpsByEntityKey.keys, } : desiredOpsByEntityKey.keys.toSet(); - 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'); - } - continue; - } - - if (inFlight != null && _sameIntent(desired, inFlight)) { - if (queued.remove(key) != null) { - changed = true; - _debugLog('queued.drop $key reason=covered_by_in_flight'); - } - 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'); - } - 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}', - ); - } - + final changed = _syncDesiredOpsForKeys( + keys: pageKeys, + desiredOpsByEntityKey: desiredOpsByEntityKey, + ); if (!changed) { return; } - - state = state.copyWith( - queuedByEntityKey: queued, - clearError: true, - ); _scheduleFlush(flushImmediately: flushImmediately); } @@ -307,12 +267,14 @@ class StrategyOpQueueNotifier extends Notifier { ? 'Cloud user setup is not ready.' : 'Cloud connection is offline.'), ); - _scheduleRetry(incremented.values.map((intent) => intent.pending).toList()); + _scheduleRetry( + incremented.values.map((intent) => intent.pending).toList()); return; } final batch = state.queuedByEntityKey.values - .where((intent) => !state.inFlightByEntityKey.containsKey(intent.entityKey)) + .where((intent) => + !state.inFlightByEntityKey.containsKey(intent.entityKey)) .take(_maxBatchSize) .toList(growable: false); if (batch.isEmpty) { @@ -335,7 +297,8 @@ class StrategyOpQueueNotifier extends Notifier { sentAt: sentAt, ); batchByOpId[intent.pending.op.opId] = intent; - _debugLog('inflight.send ${intent.entityKey} op=${intent.pending.op.opId}'); + _debugLog( + 'inflight.send ${intent.entityKey} op=${intent.pending.op.opId}'); } state = state.copyWith( @@ -483,6 +446,78 @@ class StrategyOpQueueNotifier extends Notifier { }); } + bool _syncDesiredOpsForKeys({ + required Set keys, + required Map desiredOpsByEntityKey, + }) { + final queued = Map.from( + state.queuedByEntityKey, + ); + + var changed = false; + for (final key in keys) { + 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'); + } + continue; + } + + if (inFlight != null && _sameIntent(desired, inFlight)) { + if (queued.remove(key) != null) { + changed = true; + _debugLog('queued.drop $key reason=covered_by_in_flight'); + } + 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'); + } + 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}', + ); + } + + if (changed) { + state = state.copyWith( + queuedByEntityKey: queued, + clearError: true, + ); + } + return changed; + } + bool _sameIntent(StrategyOp left, StrategyOp right) { return left.kind == right.kind && left.entityType == right.entityType && @@ -500,7 +535,8 @@ class StrategyOpQueueNotifier extends Notifier { return null; } - if (existing.kind == StrategyOpKind.add && desired.kind == StrategyOpKind.patch) { + if (existing.kind == StrategyOpKind.add && + desired.kind == StrategyOpKind.patch) { return StrategyOp( opId: existing.opId, kind: StrategyOpKind.add, diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 06561fa9..50dc463f 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -14,7 +14,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/const/placed_classes.dart'; -import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:path/path.dart' as path; @@ -162,8 +161,9 @@ class PlacedImageProvider extends Notifier { group: ActionGroup.image, objectDelta: ObjectHistoryDelta( after: ActionObjectState.image(placedImage), - afterImageSizes: - ref.read(imageWidgetSizeProvider.notifier).takeSnapshotForIds([imageID]), + afterImageSizes: ref + .read(imageWidgetSizeProvider.notifier) + .takeSnapshotForIds([imageID]), ), ); @@ -172,15 +172,12 @@ class PlacedImageProvider extends Notifier { state = state.copyWith(images: [...state.images, placedImage]); final strategyState = ref.read(strategyProvider); - final pagePublicId = ref.read(strategyPageSessionProvider).activePageId; if (strategyState.source == StrategySource.cloud && - strategyState.strategyId != null && - pagePublicId != null) { + strategyState.strategyId != null) { await ref .read(cloudMediaUploadQueueProvider.notifier) .enqueuePlacedImageUpload( strategyPublicId: strategyState.strategyId!, - pagePublicId: pagePublicId, imagePublicId: placedImage.id, fileExtension: fileExtension, width: null, diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart index 78590fbb..571c4493 100644 --- a/lib/strategy/strategy_page_source.dart +++ b/lib/strategy/strategy_page_source.dart @@ -25,6 +25,7 @@ import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:uuid/uuid.dart'; abstract class StrategyPageSource { Future> listPageIds(); @@ -169,13 +170,16 @@ class CloudStrategyPageSource implements StrategyPageSource { orElse: () => pages.first, ); - final projected = ref.read(activePageLiveSyncProvider.notifier).projectPageState( - strategyPublicId: strategyId, - pageId: page.publicId, - ); + final projected = + ref.read(activePageLiveSyncProvider.notifier).projectPageState( + strategyPublicId: strategyId, + pageId: page.publicId, + ); if (projected != null && (page.publicId == activePageId() || - ref.read(activePageLiveSyncProvider.notifier).hasOverlayForPage(page.publicId))) { + ref + .read(activePageLiveSyncProvider.notifier) + .hasOverlayForPage(page.publicId))) { return _hydrateProjectedPage(snapshot, page, projected); } @@ -243,7 +247,8 @@ class CloudStrategyPageSource implements StrategyPageSource { if (decoded is Map) { parsedLineups.add(LineUp.fromJson(decoded)); } else if (decoded is Map) { - parsedLineups.add(LineUp.fromJson(Map.from(decoded))); + parsedLineups + .add(LineUp.fromJson(Map.from(decoded))); } } catch (_) { // Ignore malformed payloads during hydration. @@ -258,8 +263,9 @@ class CloudStrategyPageSource implements StrategyPageSource { StrategySettings pageSettings = StrategySettings(); if (page.settings != null && page.settings!.isNotEmpty) { try { - pageSettings = - ref.read(strategySettingsProvider.notifier).fromJson(page.settings!); + pageSettings = ref + .read(strategySettingsProvider.notifier) + .fromJson(page.settings!); } catch (_) { pageSettings = StrategySettings(); } @@ -288,6 +294,8 @@ class CloudStrategyPageSource implements StrategyPageSource { return; } + _syncStrategyMetadata(); + final desiredOpsByEntityKey = ref.read(activePageLiveSyncProvider.notifier).syncLocalPage( strategyPublicId: strategyId, @@ -300,6 +308,63 @@ class CloudStrategyPageSource implements StrategyPageSource { ); } + void _syncStrategyMetadata() { + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null) { + return; + } + + final currentMapData = Maps.mapNames[ref.read(mapProvider).currentMap]; + if (currentMapData == null) { + return; + } + + final strategyTheme = ref.read(strategyThemeProvider); + final desiredThemeOverride = strategyTheme.overridePalette == null + ? null + : jsonEncode(strategyTheme.overridePalette!.toJson()); + final header = snapshot.header; + + final mapMatches = header.mapData == currentMapData; + final themeProfileMatches = + header.themeProfileId == strategyTheme.profileId; + final themeOverrideMatches = + header.themeOverridePalette == desiredThemeOverride; + + if (mapMatches && themeProfileMatches && themeOverrideMatches) { + ref.read(strategyOpQueueProvider.notifier).syncDesiredOp( + entityKey: 'strategy', + desiredOp: null, + flushImmediately: false, + ); + return; + } + + final payload = { + 'mapData': currentMapData, + if (strategyTheme.profileId != null) + 'themeProfileId': strategyTheme.profileId + else + 'clearThemeProfileId': true, + if (desiredThemeOverride != null) + 'themeOverridePalette': desiredThemeOverride + else + 'clearThemeOverridePalette': true, + }; + + ref.read(strategyOpQueueProvider.notifier).syncDesiredOp( + entityKey: 'strategy', + desiredOp: StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.strategy, + payload: jsonEncode(payload), + expectedSequence: header.sequence, + ), + flushImmediately: false, + ); + } + StrategyEditorPageData _hydrateProjectedPage( RemoteStrategySnapshot snapshot, RemotePage page, @@ -360,7 +425,8 @@ class CloudStrategyPageSource implements StrategyPageSource { if (decoded is Map) { parsedLineups.add(LineUp.fromJson(decoded)); } else if (decoded is Map) { - parsedLineups.add(LineUp.fromJson(Map.from(decoded))); + parsedLineups + .add(LineUp.fromJson(Map.from(decoded))); } } catch (_) { // Ignore malformed payloads during hydration. @@ -415,5 +481,4 @@ class CloudStrategyPageSource implements StrategyPageSource { } return {}; } - } diff --git a/lib/widgets/dialogs/create_lineup_dialog.dart b/lib/widgets/dialogs/create_lineup_dialog.dart index db21d60d..52b0f26b 100644 --- a/lib/widgets/dialogs/create_lineup_dialog.dart +++ b/lib/widgets/dialogs/create_lineup_dialog.dart @@ -3,13 +3,11 @@ import 'dart:typed_data' show Uint8List; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; -import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/services/clipboard_service.dart'; @@ -32,7 +30,6 @@ class _CreateLineupDialogState extends ConsumerState { final List _imagePaths = []; Future _enqueueLineupMediaJobs({ - required String lineupId, required List images, }) async { final strategyState = ref.read(strategyProvider); @@ -41,17 +38,11 @@ class _CreateLineupDialogState extends ConsumerState { return; } - final pageId = ref.read(strategyPageSessionProvider).activePageId; - if (pageId == null) { - return; - } - for (final image in images) { - await ref.read(cloudMediaUploadQueueProvider.notifier).enqueueJobForLocalFile( + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalFile( strategyPublicId: strategyState.strategyId!, - pagePublicId: pageId, - ownerType: CloudMediaOwnerType.lineup, - ownerPublicId: lineupId, assetPublicId: image.id, fileExtension: image.fileExtension, ); @@ -121,7 +112,6 @@ class _CreateLineupDialogState extends ConsumerState { ref.read(lineUpProvider.notifier).updateLineUp(lineUp); await _enqueueLineupMediaJobs( - lineupId: lineUp.id, images: lineUp.images, ); } else { @@ -144,7 +134,6 @@ class _CreateLineupDialogState extends ConsumerState { ref.read(lineUpProvider.notifier).addLineUp(currentLineUp); await _enqueueLineupMediaJobs( - lineupId: currentLineUp.id, images: currentLineUp.images, ); } diff --git a/skills-lock.json b/skills-lock.json index 9f627f87..ab93355c 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -4,27 +4,27 @@ "convex-create-component": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "c97d71b524ea9f784d344af20a766de5d48f14b7b824bf791fa517d1da753aca" + "computedHash": "770b2f36955024f066da5e12e7fa94a2f4416bb7ca2d3ae6e8948c99e2dfc7b6" }, "convex-migration-helper": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "bfb51e5b743ac31b313e5e1d206faee7d46bbbc9195360ce9636eead6f13fd3a" + "computedHash": "ea0d0bec29573dc668f06b13969e6c5c7bf2b6014c314eacae2c2389353eda41" }, "convex-performance-audit": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "3e411d8250678ca8ec739a40c2874f21e4b5ab17c4dd65e72e3d9d9df6851c31" + "computedHash": "b176537653f4d22ecb30bbc441b84512a2936ef5abf785bef24700ba3d73ef6c" }, "convex-quickstart": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "7879afbb2b954be36ef29d294f3b6c2851cad54e9f5e5ac90461f0f97d4d1fff" + "computedHash": "f97654ffe564f0dacf9d5f9216c43bb39d2ba6d07332df62241071e7a021836f" }, "convex-setup-auth": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "92a5c170cf238f45f08439d9d5d7947af490b880ee8f15306183240483cbed02" + "computedHash": "36d7a5c86a60a2eb75a5590e16ac5a92a93dc010681ad8c7dbc9d456dc426895" } } } diff --git a/skills/convex-create-component/SKILL.md b/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..22af601f --- /dev/null +++ b/skills/convex-create-component/SKILL.md @@ -0,0 +1,288 @@ +--- +name: convex-create-component +description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +--- + +# Convex Create Component + +Create reusable Convex components with clear boundaries and a small app-facing API. + +## When to Use + +- Creating a new Convex component in an existing app +- Extracting reusable backend logic into a component +- Building a third-party integration that should own its own tables and workflows +- Packaging Convex functionality for reuse across multiple apps + +## When Not to Use + +- One-off business logic that belongs in the main app +- Thin utilities that do not need Convex tables or functions +- App-level orchestration that should stay in `convex/` +- Cases where a normal TypeScript library is enough + +## Workflow + +1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. +2. Choose the shape using the decision tree below and read the matching reference file. +3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. +4. Make a short plan for: + - what tables the component owns + - what public functions it exposes + - what data must be passed in from the app (auth, env vars, parent IDs) + - what stays in the app as wrappers or HTTP mounts +5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. +6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. +7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. +8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. +9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. +10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. + +## Choose the Shape + +Ask the user, then pick one path: + +| Goal | Shape | Reference | +| ------------------------------------------------- | ---------------- | ----------------------------------- | +| Component for this app only | Local | `references/local-components.md` | +| Publish or share across apps | Packaged | `references/packaged-components.md` | +| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | +| Not sure | Default to local | `references/local-components.md` | + +Read exactly one reference file before proceeding. + +## Default Approach + +Unless the user explicitly wants an npm package, default to a local component: + +- Put it under `convex/components//` +- Define it with `defineComponent(...)` in its own `convex.config.ts` +- Install it from the app's `convex/convex.config.ts` with `app.use(...)` +- Let `npx convex dev` generate the component's own `_generated/` files + +## Component Skeleton + +A minimal local component with a table and two functions, plus the app wiring. + +```ts +// convex/components/notifications/convex.config.ts +import { defineComponent } from "convex/server"; + +export default defineComponent("notifications"); +``` + +```ts +// convex/components/notifications/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + notifications: defineTable({ + userId: v.string(), + message: v.string(), + read: v.boolean(), + }).index("by_user", ["userId"]), +}); +``` + +```ts +// convex/components/notifications/lib.ts +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; + +export const send = mutation({ + args: { userId: v.string(), message: v.string() }, + returns: v.id("notifications"), + handler: async (ctx, args) => { + return await ctx.db.insert("notifications", { + userId: args.userId, + message: args.message, + read: false, + }); + }, +}); + +export const listUnread = query({ + args: { userId: v.string() }, + returns: v.array( + v.object({ + _id: v.id("notifications"), + _creationTime: v.number(), + userId: v.string(), + message: v.string(), + read: v.boolean(), + }), + ), + handler: async (ctx, args) => { + return await ctx.db + .query("notifications") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .filter((q) => q.eq(q.field("read"), false)) + .collect(); + }, +}); +``` + +```ts +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import notifications from "./components/notifications/convex.config.js"; + +const app = defineApp(); +app.use(notifications); + +export default app; +``` + +```ts +// convex/notifications.ts (app-side wrapper) +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { components } from "./_generated/api"; +import { getAuthUserId } from "@convex-dev/auth/server"; + +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); + +export const myUnread = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + return await ctx.runQuery(components.notifications.lib.listUnread, { + userId, + }); + }, +}); +``` + +Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. + +## Critical Rules + +- Keep authentication in the app, because `ctx.auth` is not available inside components. +- Keep environment access in the app, because component functions cannot read `process.env`. +- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. +- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. +- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. +- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. +- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. +- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. +- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. + +## Patterns + +### Authentication and environment access + +```ts +// Bad: component code cannot rely on app auth or env +const identity = await ctx.auth.getUserIdentity(); +const apiKey = process.env.OPENAI_API_KEY; +``` + +```ts +// Good: the app resolves auth and env, then passes explicit values +const userId = await getAuthUserId(ctx); +if (!userId) throw new Error("Not authenticated"); + +await ctx.runAction(components.translator.translate, { + userId, + apiKey: process.env.OPENAI_API_KEY, + text: args.text, +}); +``` + +### Client-facing API + +```ts +// Bad: assuming a component function is directly callable by clients +export const send = components.notifications.send; +``` + +```ts +// Good: re-export through an app mutation or query +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); +``` + +### IDs across the boundary + +```ts +// Bad: parent app table IDs are not valid component validators +args: { + userId: v.id("users"); +} +``` + +```ts +// Good: treat parent-owned IDs as strings at the boundary +args: { + userId: v.string(); +} +``` + +### Advanced Patterns + +For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. + +## Validation + +Try validation in this order: + +1. `npx convex codegen --component-dir convex/components/` +2. `npx convex codegen` +3. `npx convex dev` + +Important: + +- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. +- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. +- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. + +## Reference Files + +Read exactly one of these after the user confirms the goal: + +- `references/local-components.md` +- `references/packaged-components.md` +- `references/hybrid-components.md` + +Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) + +## Checklist + +- [ ] Asked the user what they want to build and confirmed the shape +- [ ] Read the matching reference file +- [ ] Confirmed a component is the right abstraction +- [ ] Planned tables, public API, boundaries, and app wrappers +- [ ] Component lives under `convex/components//` (or package layout if publishing) +- [ ] Component imports from its own `./_generated/server` +- [ ] Auth, env access, and HTTP routes stay in the app +- [ ] Parent app IDs cross the boundary as `v.string()` +- [ ] Public functions have `args` and `returns` validators +- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/skills/convex-create-component/agents/openai.yaml b/skills/convex-create-component/agents/openai.yaml new file mode 100644 index 00000000..ba9287e4 --- /dev/null +++ b/skills/convex-create-component/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Create Component" + short_description: "Design and build reusable Convex components with clear boundaries." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#14B8A6" + default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." + +policy: + allow_implicit_invocation: true diff --git a/skills/convex-create-component/assets/icon.svg b/skills/convex-create-component/assets/icon.svg new file mode 100644 index 00000000..10f4c2c4 --- /dev/null +++ b/skills/convex-create-component/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/skills/convex-create-component/references/advanced-patterns.md b/skills/convex-create-component/references/advanced-patterns.md new file mode 100644 index 00000000..3deb684c --- /dev/null +++ b/skills/convex-create-component/references/advanced-patterns.md @@ -0,0 +1,134 @@ +# Advanced Component Patterns + +Additional patterns for Convex components that go beyond the basics covered in the main skill file. + +## Function Handles for callbacks + +When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. + +```ts +// App side: create a handle and pass it to the component +import { createFunctionHandle } from "convex/server"; + +export const startJob = mutation({ + handler: async (ctx) => { + const handle = await createFunctionHandle(internal.myModule.processItem); + await ctx.runMutation(components.workpool.enqueue, { + callback: handle, + }); + }, +}); +``` + +```ts +// Component side: accept and invoke the handle +import { v } from "convex/values"; +import type { FunctionHandle } from "convex/server"; +import { mutation } from "./_generated/server.js"; + +export const enqueue = mutation({ + args: { callback: v.string() }, + handler: async (ctx, args) => { + const handle = args.callback as FunctionHandle<"mutation">; + await ctx.scheduler.runAfter(0, handle, {}); + }, +}); +``` + +## Deriving validators from schema + +Instead of manually repeating field types in return validators, extend the schema validator: + +```ts +import { v } from "convex/values"; +import schema from "./schema.js"; + +const notificationDoc = schema.tables.notifications.validator.extend({ + _id: v.id("notifications"), + _creationTime: v.number(), +}); + +export const getLatest = query({ + args: {}, + returns: v.nullable(notificationDoc), + handler: async (ctx) => { + return await ctx.db.query("notifications").order("desc").first(); + }, +}); +``` + +## Static configuration with a globals table + +A common pattern for component configuration is a single-document "globals" table: + +```ts +// schema.ts +export default defineSchema({ + globals: defineTable({ + maxRetries: v.number(), + webhookUrl: v.optional(v.string()), + }), + // ... other tables +}); +``` + +```ts +// lib.ts +export const configure = mutation({ + args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.query("globals").first(); + if (existing) { + await ctx.db.patch(existing._id, args); + } else { + await ctx.db.insert("globals", args); + } + return null; + }, +}); +``` + +## Class-based client wrappers + +For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. + +```ts +// src/client/index.ts +import type { GenericMutationCtx, GenericDataModel } from "convex/server"; +import type { ComponentApi } from "../component/_generated/component.js"; + +type MutationCtx = Pick, "runMutation">; + +export class Notifications { + constructor( + private component: ComponentApi, + private options?: { defaultChannel?: string }, + ) {} + + async send(ctx: MutationCtx, args: { userId: string; message: string }) { + return await ctx.runMutation(this.component.lib.send, { + ...args, + channel: this.options?.defaultChannel ?? "default", + }); + } +} +``` + +```ts +// App usage +import { Notifications } from "@convex-dev/notifications"; +import { components } from "./_generated/api"; + +const notifications = new Notifications(components.notifications, { + defaultChannel: "alerts", +}); + +export const send = mutation({ + args: { message: v.string() }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + await notifications.send(ctx, { userId, message: args.message }); + }, +}); +``` diff --git a/skills/convex-create-component/references/hybrid-components.md b/skills/convex-create-component/references/hybrid-components.md new file mode 100644 index 00000000..d2bb3514 --- /dev/null +++ b/skills/convex-create-component/references/hybrid-components.md @@ -0,0 +1,37 @@ +# Hybrid Convex Components + +Read this file only when the user explicitly wants a hybrid setup. + +## What This Means + +A hybrid component combines a local Convex component with shared library code. + +This can help when: + +- the user wants a local install but also shared package logic +- the component needs extension points or override hooks +- some logic should live in normal TypeScript code outside the component boundary + +## Default Advice + +Treat hybrid as an advanced option, not the default. + +Before choosing it, ask: + +- Why is a plain local component not enough? +- Why is a packaged component not enough? +- What exactly needs to stay overridable or shared? + +If the answer is vague, fall back to local or packaged. + +## Risks + +- More moving parts +- Harder upgrades and backwards compatibility +- Easier to blur the component boundary + +## Checklist + +- [ ] User explicitly needs hybrid behavior +- [ ] Local-only and packaged-only options were considered first +- [ ] The extension points are clearly defined before coding diff --git a/skills/convex-create-component/references/local-components.md b/skills/convex-create-component/references/local-components.md new file mode 100644 index 00000000..7fbfe21a --- /dev/null +++ b/skills/convex-create-component/references/local-components.md @@ -0,0 +1,38 @@ +# Local Convex Components + +Read this file when the component should live inside the current app and does not need to be published as an npm package. + +## When to Choose This + +- The user wants the simplest path +- The component only needs to work in this repo +- The goal is extracting app logic into a cleaner boundary + +## Default Layout + +Use this structure unless the repo already has a clear alternative pattern: + +```text +convex/ + convex.config.ts + components/ + / + convex.config.ts + schema.ts + .ts +``` + +## Workflow Notes + +- Define the component with `defineComponent("")` +- Install it from the app with `defineApp()` and `app.use(...)` +- Keep auth, env access, public API wrappers, and HTTP route mounting in the app +- Let the component own isolated tables and reusable backend workflows +- Add app wrappers if clients need to call into the component + +## Checklist + +- [ ] Component is inside `convex/components//` +- [ ] App installs it with `app.use(...)` +- [ ] Component owns only its own tables +- [ ] App wrappers handle client-facing calls when needed diff --git a/skills/convex-create-component/references/packaged-components.md b/skills/convex-create-component/references/packaged-components.md new file mode 100644 index 00000000..5668e7ed --- /dev/null +++ b/skills/convex-create-component/references/packaged-components.md @@ -0,0 +1,51 @@ +# Packaged Convex Components + +Read this file when the user wants a reusable npm package or a component shared across multiple apps. + +## When to Choose This + +- The user wants to publish the component +- The user wants a stable reusable package boundary +- The component will be shared across multiple apps or teams + +## Default Approach + +- Prefer starting from `npx create-convex@latest --component` when possible +- Keep the official authoring docs as the source of truth for package layout and exports +- Validate the bundled package through an example app, not just the source files + +## Build Flow + +When building a packaged component, make sure the bundled output exists before the example app tries to consume it. + +Recommended order: + +1. `npx convex codegen --component-dir ./path/to/component` +2. Run the package build command +3. Run `npx convex dev --typecheck-components` in the example app + +Do not assume normal app codegen is enough for packaged component workflows. + +## Package Exports + +If publishing to npm, make sure the package exposes the entry points apps need: + +- package root for client helpers, types, or classes +- `./convex.config.js` for installing the component +- `./_generated/component.js` for the app-facing `ComponentApi` type +- `./test` for testing helpers when applicable + +## Testing + +- Use `convex-test` for component logic +- Register the component schema and modules with the test instance +- Test app-side wrapper code from an example app that installs the package +- Export a small helper from `./test` if consumers need easy test registration + +## Checklist + +- [ ] Packaging is actually required +- [ ] Build order avoids bundle and codegen races +- [ ] Package exports include install and typing entry points +- [ ] Example app exercises the packaged component +- [ ] Core behavior is covered by tests diff --git a/skills/convex-migration-helper/SKILL.md b/skills/convex-migration-helper/SKILL.md new file mode 100644 index 00000000..db36c622 --- /dev/null +++ b/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,149 @@ +--- +name: convex-migration-helper +description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +--- + +# Convex Migration Helper + +Safely migrate Convex schemas and data when making breaking changes. + +## When to Use + +- Adding new required fields to existing tables +- Changing field types or structure +- Splitting or merging tables +- Renaming or deleting fields +- Migrating from nested to relational data + +## When Not to Use + +- Greenfield schema with no existing data in production or dev +- Adding optional fields that do not need backfilling +- Adding new tables with no existing data to migrate +- Adding or removing indexes with no correctness concern +- Questions about Convex schema design without a migration need + +## Key Concepts + +### Schema Validation Drives the Workflow + +Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: + +- You cannot add a required field if existing documents don't have it +- You cannot change a field's type if existing documents have the old type +- You cannot remove a field from the schema if existing documents still have it + +This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. + +### Online Migrations + +Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. + +### Prefer New Fields Over Changing Types + +When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. + +### Don't Delete Data + +Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. + +## Safe Changes (No Migration Needed) + +### Adding Optional Field + +```typescript +// Before +users: defineTable({ + name: v.string(), +}); + +// After - safe, new field is optional +users: defineTable({ + name: v.string(), + bio: v.optional(v.string()), +}); +``` + +### Adding New Table + +```typescript +posts: defineTable({ + userId: v.id("users"), + title: v.string(), +}).index("by_user", ["userId"]); +``` + +### Adding Index + +```typescript +users: defineTable({ + name: v.string(), + email: v.string(), +}).index("by_email", ["email"]); +``` + +## Breaking Changes: The Deployment Workflow + +Every breaking migration follows the same multi-deploy pattern: + +**Deploy 1 - Widen the schema:** + +1. Update schema to allow both old and new formats (e.g., add optional new field) +2. Update code to handle both formats when reading +3. Update code to write the new format for new documents +4. Deploy + +**Between deploys - Migrate data:** + +5. Run migration to backfill existing documents +6. Verify all documents are migrated + +**Deploy 2 - Narrow the schema:** + +7. Update schema to require the new format only +8. Remove code that handles the old format +9. Deploy + +## Using the Migrations Component + +For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. + +See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. + +## Common Migration Patterns + +See `references/migration-patterns.md` for complete patterns with code examples covering: + +- Adding a required field +- Deleting a field +- Changing a field type +- Splitting nested data into a separate table +- Cleaning up orphaned documents +- Zero-downtime strategies (dual write, dual read) +- Small table shortcut (single internalMutation without the component) +- Verifying a migration is complete + +## Common Pitfalls + +1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. +2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. +3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." +4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. +5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. +6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. + +## Migration Checklist + +- [ ] Identify the breaking change and plan the multi-deploy workflow +- [ ] Update schema to allow both old and new formats +- [ ] Update code to handle both formats when reading +- [ ] Update code to write the new format for new documents +- [ ] Deploy widened schema and updated code +- [ ] Define migration using the `@convex-dev/migrations` component +- [ ] Test with `dryRun: true` +- [ ] Run migration and monitor status +- [ ] Verify all documents are migrated +- [ ] Update schema to require new format only +- [ ] Clean up code that handled old format +- [ ] Deploy final schema and code +- [ ] Remove migration code once confirmed stable diff --git a/skills/convex-migration-helper/agents/openai.yaml b/skills/convex-migration-helper/agents/openai.yaml new file mode 100644 index 00000000..c2a7fcc5 --- /dev/null +++ b/skills/convex-migration-helper/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Migration Helper" + short_description: "Plan and run safe Convex schema and data migrations." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#8B5CF6" + default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." + +policy: + allow_implicit_invocation: true diff --git a/skills/convex-migration-helper/assets/icon.svg b/skills/convex-migration-helper/assets/icon.svg new file mode 100644 index 00000000..fba7241a --- /dev/null +++ b/skills/convex-migration-helper/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/skills/convex-migration-helper/references/migration-patterns.md b/skills/convex-migration-helper/references/migration-patterns.md new file mode 100644 index 00000000..53b4946f --- /dev/null +++ b/skills/convex-migration-helper/references/migration-patterns.md @@ -0,0 +1,231 @@ +# Migration Patterns Reference + +Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. + +## Adding a Required Field + +```typescript +// Deploy 1: Schema allows both states +users: defineTable({ + name: v.string(), + role: v.optional(v.union(v.literal("user"), v.literal("admin"))), +}); + +// Migration: backfill the field +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); + +// Deploy 2: After migration completes, make it required +users: defineTable({ + name: v.string(), + role: v.union(v.literal("user"), v.literal("admin")), +}); +``` + +## Deleting a Field + +Mark the field optional first, migrate data to remove it, then remove from schema: + +```typescript +// Deploy 1: Make optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()) + +// Migration +export const removeIsPro = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.isPro !== undefined) { + await ctx.db.patch(team._id, { isPro: undefined }); + } + }, +}); + +// Deploy 2: Remove isPro from schema entirely +``` + +## Changing a Field Type + +Prefer creating a new field. You can combine adding and deleting in one migration: + +```typescript +// Deploy 1: Add new field, keep old field optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) + +// Migration: convert old field to new field +export const convertToEnum = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.plan === undefined) { + await ctx.db.patch(team._id, { + plan: team.isPro ? "pro" : "basic", + isPro: undefined, + }); + } + }, +}); + +// Deploy 2: Remove isPro from schema, make plan required +``` + +## Splitting Nested Data Into a Separate Table + +```typescript +export const extractPreferences = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.preferences === undefined) return; + + const existing = await ctx.db + .query("userPreferences") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!existing) { + await ctx.db.insert("userPreferences", { + userId: user._id, + ...user.preferences, + }); + } + + await ctx.db.patch(user._id, { preferences: undefined }); + }, +}); +``` + +Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. + +## Cleaning Up Orphaned Documents + +```typescript +export const deleteOrphanedEmbeddings = migrations.define({ + table: "embeddings", + migrateOne: async (ctx, doc) => { + const chunk = await ctx.db + .query("chunks") + .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) + .first(); + + if (!chunk) { + await ctx.db.delete(doc._id); + } + }, +}); +``` + +## Zero-Downtime Strategies + +During the migration window, your app must handle both old and new data formats. There are two main strategies. + +### Dual Write (Preferred) + +Write to both old and new structures. Read from the old structure until migration is complete. + +1. Deploy code that writes both formats, reads old format +2. Run migration on existing data +3. Deploy code that reads new format, still writes both +4. Deploy code that only reads and writes new format + +This is preferred because you can safely roll back at any point, the old format is always up to date. + +```typescript +// Bad: only writing to new structure before migration is done +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + await ctx.db.insert("teams", { + name: args.name, + plan: args.isPro ? "pro" : "basic", + }); + }, +}); + +// Good: writing to both structures during migration +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + const plan = args.isPro ? "pro" : "basic"; + await ctx.db.insert("teams", { + name: args.name, + isPro: args.isPro, + plan, + }); + }, +}); +``` + +### Dual Read + +Read both formats. Write only the new format. + +1. Deploy code that reads both formats (preferring new), writes only new format +2. Run migration on existing data +3. Deploy code that reads and writes only new format + +This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. + +```typescript +// Good: reading both formats, preferring new +function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { + if (team.plan !== undefined) return team.plan; + return team.isPro ? "pro" : "basic"; +} +``` + +## Small Table Shortcut + +For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: + +```typescript +import { internalMutation } from "./_generated/server"; + +export const backfillSmallTable = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("smallConfig").collect(); + for (const doc of docs) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: "default" }); + } + } + }, +}); +``` + +```bash +npx convex run migrations:backfillSmallTable +``` + +Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. + +## Verifying a Migration + +Query to check remaining unmigrated documents: + +```typescript +import { query } from "./_generated/server"; + +export const verifyMigration = query({ + handler: async (ctx) => { + const remaining = await ctx.db + .query("users") + .filter((q) => q.eq(q.field("role"), undefined)) + .take(10); + + return { + complete: remaining.length === 0, + sampleRemaining: remaining.map((u) => u._id), + }; + }, +}); +``` + +Or use the component's built-in status monitoring: + +```bash +npx convex run --component migrations lib:getStatus --watch +``` diff --git a/skills/convex-migration-helper/references/migrations-component.md b/skills/convex-migration-helper/references/migrations-component.md new file mode 100644 index 00000000..95ec2921 --- /dev/null +++ b/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,169 @@ +# Migrations Component Reference + +Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. + +## Installation + +```bash +npm install @convex-dev/migrations +``` + +## Setup + +```typescript +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import migrations from "@convex-dev/migrations/convex.config.js"; + +const app = defineApp(); +app.use(migrations); +export default app; +``` + +```typescript +// convex/migrations.ts +import { Migrations } from "@convex-dev/migrations"; +import { components } from "./_generated/api.js"; +import { DataModel } from "./_generated/dataModel.js"; + +export const migrations = new Migrations(components.migrations); +export const run = migrations.runner(); +``` + +The `DataModel` type parameter is optional but provides type safety for migration definitions. + +## Define a Migration + +The `migrateOne` function processes a single document. The component handles batching and pagination automatically. + +```typescript +// convex/migrations.ts +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); +``` + +Shorthand: if you return an object, it is applied as a patch automatically. + +```typescript +export const clearDeprecatedField = migrations.define({ + table: "users", + migrateOne: () => ({ legacyField: undefined }), +}); +``` + +## Run a Migration + +From the CLI: + +```bash +# Define a one-off runner in convex/migrations.ts: +# export const runIt = migrations.runner(internal.migrations.addDefaultRole); +npx convex run migrations:runIt + +# Or use the general-purpose runner +npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' +``` + +Programmatically from another Convex function: + +```typescript +await migrations.runOne(ctx, internal.migrations.addDefaultRole); +``` + +## Run Multiple Migrations in Order + +```typescript +export const runAll = migrations.runner([ + internal.migrations.addDefaultRole, + internal.migrations.clearDeprecatedField, + internal.migrations.normalizeEmails, +]); +``` + +```bash +npx convex run migrations:runAll +``` + +If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. + +## Dry Run + +Test a migration before committing changes: + +```bash +npx convex run migrations:runIt '{"dryRun": true}' +``` + +This runs one batch and then rolls back, so you can see what it would do without changing any data. + +## Check Migration Status + +```bash +npx convex run --component migrations lib:getStatus --watch +``` + +## Cancel a Running Migration + +```bash +npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' +``` + +Or programmatically: + +```typescript +await migrations.cancel(ctx, internal.migrations.addDefaultRole); +``` + +## Run Migrations on Deploy + +Chain migration execution after deploying: + +```bash +npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod +``` + +## Configuration Options + +### Custom Batch Size + +If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: + +```typescript +export const migrateHeavyTable = migrations.define({ + table: "largeDocuments", + batchSize: 10, + migrateOne: async (ctx, doc) => { + // migration logic + }, +}); +``` + +### Migrate a Subset Using an Index + +Process only matching documents instead of the full table: + +```typescript +export const fixEmptyNames = migrations.define({ + table: "users", + customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")), + migrateOne: () => ({ name: "" }), +}); +``` + +### Parallelize Within a Batch + +By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: + +```typescript +export const clearField = migrations.define({ + table: "myTable", + parallelize: true, + migrateOne: () => ({ optionalField: undefined }), +}); +``` diff --git a/skills/convex-performance-audit/SKILL.md b/skills/convex-performance-audit/SKILL.md new file mode 100644 index 00000000..382951cf --- /dev/null +++ b/skills/convex-performance-audit/SKILL.md @@ -0,0 +1,143 @@ +--- +name: convex-performance-audit +description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +--- + +# Convex Performance Audit + +Diagnose and fix performance problems in Convex applications, one problem class at a time. + +## When to Use + +- A Convex page or feature feels slow or expensive +- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts +- Low-freshness read paths are using reactivity where point-in-time reads would do +- OCC conflict errors or excessive mutation retries +- High subscription count or slow UI updates +- Functions approaching execution or transaction limits +- The same performance pattern needs fixing across sibling functions + +## When Not to Use + +- Initial Convex setup, auth setup, or component extraction +- Pure schema migrations with no performance goal +- One-off micro-optimizations without a user-visible or deployment-visible problem + +## Guardrails + +- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak +- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path +- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale + +## First Step: Gather Signals + +Start with the strongest signal available: + +1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. +2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. + - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. +3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. +4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. + +## Signal Routing + +After gathering signals, identify the problem class and read the matching reference file. + +| Signal | Reference | +| -------------------------------------------------------------- | ----------------------------------------- | +| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | +| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | +| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | +| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | +| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | + +Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. + +## Escalate Larger Fixes + +If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. + +Examples: + +- introducing digest or summary tables across multiple flows +- splitting documents to isolate frequently-updated fields +- reworking pagination or fetch strategy across several screens +- switching to a new index or denormalized field that needs migration-safe rollout + +When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. + +## Workflow + +### 1. Scope the problem + +Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. + +Write down: + +- entrypoint functions +- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` +- tables read +- tables written +- whether the path is high-read, high-write, or both + +### 2. Trace the full read and write set + +For each function in the path: + +1. Trace every `ctx.db.get()` and `ctx.db.query()` +2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` +3. Note foreign-key lookups, JS-side filtering, and full-document reads +4. Identify all sibling functions touching the same tables +5. Identify reactive stats, aggregates, or widgets rendered on the same page + +In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. + +### 3. Apply fixes from the relevant reference + +Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. + +Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. + +### 4. Fix sibling functions together + +When one function touching a table has a performance bug, audit sibling functions for the same pattern. + +After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. + +Examples: + +- If one list query switches from full docs to a digest table, inspect the other list queries for that table +- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table +- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk + +Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. + +### 5. Verify before finishing + +Confirm all of these: + +1. Results are the same as before, no dropped records +2. Eliminated reads or writes are no longer in the path where expected +3. Fallback behavior works when denormalized or indexed fields are missing +4. Frequently-updated fields are isolated from widely-read documents where needed +5. Every relevant sibling reader and writer was inspected, not just the original function + +## Reference Files + +- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables +- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting +- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads +- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size + +Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. + +## Checklist + +- [ ] Gathered signals from insights, dashboard, or code audit +- [ ] Identified the problem class and read the matching reference +- [ ] Scoped one concrete user flow or function path +- [ ] Traced every read and write in that path +- [ ] Identified sibling functions touching the same tables +- [ ] Applied fixes from the reference, following the recommended fix order +- [ ] Fixed sibling functions consistently +- [ ] Verified behavior and confirmed no regressions diff --git a/skills/convex-performance-audit/agents/openai.yaml b/skills/convex-performance-audit/agents/openai.yaml new file mode 100644 index 00000000..9a21f387 --- /dev/null +++ b/skills/convex-performance-audit/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Performance Audit" + short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#EF4444" + default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." + +policy: + allow_implicit_invocation: true diff --git a/skills/convex-performance-audit/assets/icon.svg b/skills/convex-performance-audit/assets/icon.svg new file mode 100644 index 00000000..7ab9e09c --- /dev/null +++ b/skills/convex-performance-audit/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/skills/convex-performance-audit/references/function-budget.md b/skills/convex-performance-audit/references/function-budget.md new file mode 100644 index 00000000..d4d4aa5a --- /dev/null +++ b/skills/convex-performance-audit/references/function-budget.md @@ -0,0 +1,232 @@ +# Function Budget + +Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. + +## Core Principle + +Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. + +## Limits to Know + +These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. + +| Resource | Limit | +| --------------------------------- | ----------------------------------------------------- | +| Query/mutation execution time | 1 second (user code only, excludes DB operations) | +| Action execution time | 10 minutes | +| Data read per transaction | 16 MiB | +| Data written per transaction | 16 MiB | +| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | +| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | +| Documents written per transaction | 16,000 | +| Individual document size | 1 MiB | +| Function return value size | 16 MiB | + +## Symptoms + +- "Function execution took too long" errors +- "Transaction too large" or read/write set size errors +- Slow queries that read many documents +- Client receiving large payloads that slow down page load +- `npx convex insights --details` showing high bytes read + +## Common Causes + +### Unbounded collection + +A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. + +### Large document reads on hot paths + +Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. + +### Mutation doing too much work + +A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. + +### Returning too much data to the client + +A query returning full documents when the client only needs a few fields. + +## Fix Order + +### 1. Bound your reads + +Never `.collect()` without a limit on a table that can grow unbounded. + +```ts +// Bad: unbounded read, breaks as the table grows +const messages = await ctx.db.query("messages").collect(); +``` + +```ts +// Good: paginate or limit +const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", channelId)) + .order("desc") + .take(50); +``` + +### 2. Read smaller shapes + +If the list page only needs title, author, and date, do not read full documents with rich content fields. + +Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. + +### 3. Break large mutations into batches + +If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. + +```ts +// Bad: one mutation updating every row +export const backfillAll = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("items").collect(); + for (const doc of docs) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + }, +}); +``` + +```ts +// Good: cursor-based batch processing +export const backfillBatch = internalMutation({ + args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + const result = await ctx.db + .query("items") + .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); + + for (const doc of result.page) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + } + + if (!result.isDone) { + await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { + cursor: result.continueCursor, + batchSize, + }); + } + }, +}); +``` + +### 4. Move heavy work to actions + +Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. + +Actions run outside the transaction and can call mutations to write results back. + +```ts +// Bad: heavy computation inside a mutation +export const processUpload = mutation({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.db.insert("results", result); + }, +}); +``` + +```ts +// Good: action for heavy work, mutation for the write +export const processUpload = action({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.runMutation(internal.results.store, { result }); + }, +}); +``` + +### 5. Trim return values + +Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. + +```ts +// Bad: returns full documents including large content fields +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("articles").take(20); + }, +}); +``` + +```ts +// Good: project to only the fields the client needs +export const list = query({ + handler: async (ctx) => { + const articles = await ctx.db.query("articles").take(20); + return articles.map((a) => ({ + _id: a._id, + title: a.title, + author: a.author, + createdAt: a._creationTime, + })); + }, +}); +``` + +### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions + +Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. + +```ts +// Bad: unnecessary overhead from ctx.runQuery inside a mutation +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await ctx.runQuery(api.users.getCurrentUser); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +```ts +// Good: plain helper function, no extra overhead +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await getCurrentUser(ctx); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. + +### 7. Avoid unnecessary `runAction` calls + +`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). + +```ts +// Bad: runAction overhead for no reason +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await ctx.runAction(internal.items.processOne, { item }); + } + }, +}); +``` + +```ts +// Good: plain function call +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await processOneItem(ctx, { item }); + } + }, +}); +``` + +## Verification + +1. No function execution or transaction size errors +2. `npx convex insights --details` shows reduced bytes read +3. Large mutations are batched and self-scheduling +4. Client payloads are reasonably sized for the UI they serve +5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible +6. Sibling functions with similar patterns were checked diff --git a/skills/convex-performance-audit/references/hot-path-rules.md b/skills/convex-performance-audit/references/hot-path-rules.md new file mode 100644 index 00000000..e003e052 --- /dev/null +++ b/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,369 @@ +# Hot Path Rules + +Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. + +## Contents + +- Core Principle +- Consistency Rule +- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) +- 2. Minimize Data Sources (denormalization, fallback rule) +- 3. Minimize Row Size (digest tables) +- 4. Skip No-Op Writes +- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) +- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) +- Verification + +## Core Principle + +Every byte read or written multiplies with concurrency. + +Think: + +`cost x calls_per_second x 86400` + +In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. + +## Consistency Rule + +If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. + +Do this especially for: + +- multiple list queries over the same table +- multiple writers to the same table +- public browse and search queries over the same records +- helper functions reused by more than one endpoint + +## 1. Push Filters To Storage + +Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. + +Prefer: + +- `withIndex(...)` +- `.withSearchIndex(...)` for text search +- narrower tables +- summary tables + +before accepting a scan-plus-filter pattern. + +```ts +// Bad: scans then filters in JavaScript +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + const tasks = await ctx.db.query("tasks").collect(); + return tasks.filter((task) => task.status === "open"); + }, +}); +``` + +```ts +// Also bad: Convex .filter() does not push to storage either +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .filter((q) => q.eq(q.field("status"), "open")) + .collect(); + }, +}); +``` + +```ts +// Good: use an index so storage does the filtering +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .withIndex("by_status", (q) => q.eq("status", "open")) + .collect(); + }, +}); +``` + +### Migration rule for indexes + +New indexes on partially backfilled fields can create correctness bugs during rollout. + +Important Convex detail: + +`undefined !== false` + +If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. + +Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. + +If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. + +```ts +// Bad: optional booleans can miss older rows where the field is undefined +const projects = await ctx.db + .query("projects") + .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) + .order("desc") + .take(20); +``` + +```ts +// Good: switch hot-path reads only after the rollout is migration-safe +// See the migration helper skill for dual-read / backfill / cutover patterns. +``` + +### Check for redundant indexes + +Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. + +```ts +// Bad: two indexes where one would do +defineTable({ team: v.id("teams"), user: v.id("users") }) + .index("by_team", ["team"]) + .index("by_team_and_user", ["team", "user"]); +``` + +```ts +// Good: single compound index serves both query patterns +defineTable({ team: v.id("teams"), user: v.id("users") }).index( + "by_team_and_user", + ["team", "user"], +); +``` + +Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. + +## 2. Minimize Data Sources + +Trace every read. + +If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. + +### When to denormalize + +Denormalize when all of these are true: + +- the path is hot +- the joined document is much larger than the field you need +- many readers are paying that join cost repeatedly + +Useful mental model: + +`join_cost = rows_per_page x foreign_doc_size x pages_per_second` + +Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. + +### Fallback rule + +Denormalized data is an optimization. Live data is the correctness path. + +Rules: + +- If the denormalized field is missing or null, fall back to the live read +- Do not show placeholders instead of falling back +- In lookup maps, only include fully populated entries + +```ts +// Bad: missing denormalized data becomes a placeholder and blocks correctness +const ownerName = project.ownerName ?? "Unknown owner"; +``` + +```ts +// Good: denormalized data is an optimization, not the only source of truth +const ownerName = + project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null; +``` + +Bad lookup map pattern: + +```ts +const ownersById = { + [project.ownerId]: { ownerName: null }, +}; +``` + +That blocks fallback because the map says "I have data" when it does not. + +Good lookup map pattern: + +```ts +const ownersById = + project.ownerName !== undefined && project.ownerName !== null + ? { [project.ownerId]: { ownerName: project.ownerName } } + : {}; +``` + +### No denormalized copy yet + +Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. + +If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. + +Rollout order: + +1. Update schema +2. Update write path +3. Backfill +4. Switch read path + +## 3. Minimize Row Size + +Hot list pages should read the smallest document shape that still answers the UI. + +Prefer summary or digest tables over full source tables when: + +- the list page only needs a subset of fields +- source documents are large +- the query is high volume + +An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. + +Digest tables are a tradeoff, not a default: + +- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost +- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit + +```ts +// Bad: list page reads source docs, then joins owner data per row +const projects = await ctx.db + .query("projects") + .withIndex("by_public", (q) => q.eq("isPublic", true)) + .collect(); +``` + +```ts +// Good: list page reads the smaller digest shape first +const projects = await ctx.db + .query("projectDigests") + .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) + .order("desc") + .take(20); +``` + +## 4. Isolate Frequently-Updated Fields + +Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to. + +Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them. + +Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document. + +```ts +// Bad: every presence heartbeat invalidates subscribers to the whole profile +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, + lastSeen: Date.now(), +}); +``` + +```ts +// Good: keep profile reads stable, move heartbeat updates to a separate document +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, +}); + +await ctx.db.patch(presence._id, { + lastSeen: Date.now(), +}); +``` + +## 5. Match Consistency To Read Patterns + +Choose read strategy based on traffic shape. + +### High-read, low-write + +Examples: + +- public browse pages +- search results +- landing pages +- directory listings + +Prefer: + +- point-in-time reads where appropriate +- explicit refresh +- local state for pagination +- caching where appropriate + +Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. + +### High-read, high-write + +Examples: + +- collaborative editors +- live dashboards +- presence-heavy views + +Reactive queries may be worth the ongoing cost. + +## Convex-Specific Notes + +### Reactive queries + +Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. + +On the client: + +- `useQuery` creates a live subscription +- `usePaginatedQuery` creates a live subscription per page + +For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. + +### Point-in-time reads + +Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. + +Use them for: + +- aggregate snapshots +- reports +- low-churn listings +- pages where explicit refresh is fine + +### Triggers and fan-out + +Triggers fire on every write, including writes that did not materially change the document. + +When a write exists only to keep derived state in sync: + +- diff before patching +- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate + +### Aggregates + +Reactive global counts invalidate frequently on busy tables. + +Prefer: + +- one-shot aggregate fetches +- periodic recomputation +- precomputed summary rows + +for global stats that do not need live updates every second. + +### Backfills + +For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. + +Deploy code that can handle both states before running the backfill. + +During the gap: + +- writes should populate the new shape +- reads should fall back safely + +## Verification + +Before closing the audit, confirm: + +1. Same results as before, no dropped records +2. The removed table or lookup is no longer in the hot-path read set +3. Tests or validation cover fallback behavior +4. Migration safety is preserved while fields or indexes are unbackfilled +5. Sibling functions were fixed consistently diff --git a/skills/convex-performance-audit/references/occ-conflicts.md b/skills/convex-performance-audit/references/occ-conflicts.md new file mode 100644 index 00000000..1da43801 --- /dev/null +++ b/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,114 @@ +# OCC Conflict Resolution + +Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. + +## Core Principle + +Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. + +## Symptoms + +- OCC conflict errors in deployment logs or health page +- Mutations retrying multiple times before succeeding +- User-visible latency spikes on write-heavy pages +- `npx convex insights --details` showing high conflict rates + +## Common Causes + +### Hot documents + +Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. + +### Broad read sets causing false conflicts + +A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. + +### Fan-out from triggers or cascading writes + +A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. + +Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. + +### Write-then-read chains + +A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. + +## Fix Order + +### 1. Reduce read set size + +Narrower reads mean fewer false conflicts. + +```ts +// Bad: broad scan creates a wide conflict surface +const allTasks = await ctx.db.query("tasks").collect(); +const mine = allTasks.filter((t) => t.ownerId === userId); +``` + +```ts +// Good: indexed query touches only relevant documents +const mine = await ctx.db + .query("tasks") + .withIndex("by_owner", (q) => q.eq("ownerId", userId)) + .collect(); +``` + +### 2. Split hot documents + +When many writers target the same document, split the contention point. + +```ts +// Bad: every vote increments the same counter document +const counter = await ctx.db.get(pollCounterId); +await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); +``` + +```ts +// Good: shard the counter across multiple documents, aggregate on read +const shardIndex = Math.floor(Math.random() * SHARD_COUNT); +const shardId = shardIds[shardIndex]; +const shard = await ctx.db.get(shardId); +await ctx.db.patch(shardId, { count: shard!.count + 1 }); +``` + +Aggregate the shards in a query or scheduled job when you need the total. + +### 3. Move non-critical work to scheduled functions + +If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. + +```ts +// Bad: canonical write and derived work happen in the same transaction +await ctx.db.patch(userId, { name: args.name }); +await ctx.db.insert("userUpdateAnalytics", { + userId, + kind: "name_changed", + name: args.name, +}); +``` + +```ts +// Good: keep the primary write small, defer the analytics work +await ctx.db.patch(userId, { name: args.name }); +await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, { + userId, + name: args.name, +}); +``` + +### 4. Combine competing writes + +If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. + +Do not introduce artificial locks or queues unless the above steps have been tried first. + +## Related: Invalidation Scope + +Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. + +## Verification + +1. OCC conflict rate has dropped in insights or dashboard +2. Mutation latency is lower and more consistent +3. No data correctness regressions from splitting or scheduling changes +4. Sibling writers to the same hot documents were fixed consistently diff --git a/skills/convex-performance-audit/references/subscription-cost.md b/skills/convex-performance-audit/references/subscription-cost.md new file mode 100644 index 00000000..ae7d1adb --- /dev/null +++ b/skills/convex-performance-audit/references/subscription-cost.md @@ -0,0 +1,252 @@ +# Subscription Cost + +Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. + +## Core Principle + +Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: + +`subscriptions x invalidation_frequency x query_cost` + +Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. + +## Symptoms + +- Dashboard shows high active subscription count +- UI feels sluggish or laggy despite fast individual queries +- React profiling shows frequent re-renders from Convex state +- Pages with many components each running their own `useQuery` +- Paginated lists where every loaded page stays subscribed + +## Common Causes + +### Reactive queries on low-freshness flows + +Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. + +### Overly broad queries + +A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. + +### Too many subscriptions per page + +A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. + +### Paginated queries keeping all pages live + +`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. + +### Frequently-updated fields on widely-read documents + +A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. + +## Fix Order + +### 1. Use point-in-time reads when live updates are not valuable + +Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. + +Consider a point-in-time read instead when all of these are true: + +- the flow is high-read +- the underlying data changes less often than users need to see +- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable + +Possible implementations depend on environment: + +- a server-rendered fetch +- a framework helper like `fetchQuery` +- a point-in-time client read such as `ConvexHttpClient.query()` + +```ts +// Reactive by default when fresh live data matters +function TeamPresence() { + const presence = useQuery(api.teams.livePresence, { teamId }); + return ; +} +``` + +```ts +// Point-in-time read when explicit refresh is acceptable +import { ConvexHttpClient } from "convex/browser"; + +const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); + +function SnapshotView() { + const [items, setItems] = useState([]); + + useEffect(() => { + client.query(api.items.snapshot).then(setItems); + }, []); + + return ; +} +``` + +Good candidates for point-in-time reads: + +- aggregate snapshots +- reports +- low-churn listings +- flows where explicit refresh is already acceptable + +Keep reactive for: + +- collaborative editing +- live dashboards +- presence-heavy views +- any surface where users expect fresh changes to appear automatically + +### 2. Batch related data into fewer queries + +Instead of N components each fetching their own related data, fetch it in a single query. + +```ts +// Bad: each card fetches its own author +function ProjectCard({ project }: { project: Project }) { + const author = useQuery(api.users.get, { id: project.authorId }); + return ; +} +``` + +```ts +// Good: parent query returns projects with author names included +function ProjectList() { + const projects = useQuery(api.projects.listWithAuthors); + return projects?.map((p) => ( + + )); +} +``` + +This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. + +This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. + +### 3. Use skip to avoid unnecessary subscriptions + +The `"skip"` value prevents a subscription from being created when the arguments are not ready. + +```ts +// Bad: subscribes with undefined args, wastes a subscription slot +const profile = useQuery(api.users.getProfile, { userId: selectedId! }); +``` + +```ts +// Good: skip when there is nothing to fetch +const profile = useQuery( + api.users.getProfile, + selectedId ? { userId: selectedId } : "skip", +); +``` + +### 4. Isolate frequently-updated fields into separate documents + +If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. + +```ts +// Bad: lastSeen lives on the user doc, every heartbeat invalidates +// every query that reads this user +const users = defineTable({ + name: v.string(), + email: v.string(), + lastSeen: v.number(), +}); +``` + +```ts +// Good: lastSeen lives in a separate heartbeat doc +const users = defineTable({ + name: v.string(), + email: v.string(), + heartbeatId: v.id("heartbeats"), +}); + +const heartbeats = defineTable({ + lastSeen: v.number(), +}); +``` + +Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. + +For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. + +### 5. Use the aggregate component for counts and sums + +Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. + +Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. + +If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. + +### 6. Narrow query read sets + +Queries that return less data and touch fewer documents invalidate less often. + +```ts +// Bad: returns all fields, invalidates on any field change +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("projects").collect(); + }, +}); +``` + +```ts +// Good: use a digest table with only the fields the list needs +export const listDigests = query({ + handler: async (ctx) => { + return await ctx.db.query("projectDigests").collect(); + }, +}); +``` + +Writes to fields not in the digest table do not invalidate the digest query. + +### 7. Remove `Date.now()` from queries + +Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. + +```ts +// Bad: Date.now() defeats query caching and causes frequent re-evaluation +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) + .take(100); +``` + +```ts +// Good: use a boolean field updated by a scheduled function +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_is_released", (q) => q.eq("isReleased", true)) + .take(100); +``` + +If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. + +### 8. Consider pagination strategy + +For long lists where users scroll through many pages: + +- If the data does not need live updates, use point-in-time fetching with manual "load more" +- If it does need live updates, accept the subscription cost but limit the number of loaded pages +- Consider whether older pages can be unloaded as the user scrolls forward + +### 9. Separate backend cost from UI churn + +If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. + +Treat this as a UX problem first when: + +- the underlying query is already reasonably cheap +- the complaint is flicker, loading flashes, or re-render churn +- live updates are still desirable once fresh data arrives + +## Verification + +1. Subscription count in dashboard is lower for the affected pages +2. UI responsiveness has improved +3. React profiling shows fewer unnecessary re-renders +4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily +5. Sibling pages with similar patterns were updated consistently diff --git a/skills/convex-quickstart/SKILL.md b/skills/convex-quickstart/SKILL.md new file mode 100644 index 00000000..5bff17bc --- /dev/null +++ b/skills/convex-quickstart/SKILL.md @@ -0,0 +1,347 @@ +--- +name: convex-quickstart +description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +--- + +# Convex Quickstart + +Set up a working Convex project as fast as possible. + +## When to Use + +- Starting a brand new project with Convex +- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app +- Scaffolding a Convex app for prototyping + +## When Not to Use + +- The project already has Convex installed and `convex/` exists - just start building +- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill + +## Workflow + +1. Determine the starting point: new project or existing app +2. If new project, pick a template and scaffold with `npm create convex@latest` +3. If existing app, install `convex` and wire up the provider +4. Run `npx convex dev` to connect a deployment and start the dev loop +5. Verify the setup works + +## Path 1: New Project (Recommended) + +Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. + +### Pick a template + +| Template | Stack | +| -------------------------- | ----------------------------------------- | +| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | +| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | +| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | +| `nextjs-clerk` | Next.js + Clerk auth | +| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | +| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | +| `bare` | Convex backend only, no frontend | + +If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. + +You can also use any GitHub repo as a template: + +```bash +npm create convex@latest my-app -- -t owner/repo +npm create convex@latest my-app -- -t owner/repo#branch +``` + +### Scaffold the project + +Always pass the project name and template flag to avoid interactive prompts: + +```bash +npm create convex@latest my-app -- -t react-vite-shadcn +cd my-app +npm install +``` + +The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. + +To scaffold in the current directory (if it is empty): + +```bash +npm create convex@latest . -- -t react-vite-shadcn +npm install +``` + +### Start the dev loop + +`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. + +**Ask the user to run this themselves:** + +Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: + +- Create a Convex project and dev deployment +- Write the deployment URL to `.env.local` +- Create the `convex/` directory with generated types +- Watch for changes and sync continuously + +The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. + +**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. + +### Start the frontend + +The user should also run the frontend dev server in a separate terminal: + +```bash +npm run dev +``` + +Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. + +### What you get + +After scaffolding, the project structure looks like: + +``` +my-app/ + convex/ # Backend functions and schema + _generated/ # Auto-generated types (check this into git) + schema.ts # Database schema (if template includes one) + src/ # Frontend code (or app/ for Next.js) + package.json + .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL +``` + +The template already has: + +- `ConvexProvider` wired into the app root +- Correct env var names for the framework +- Tailwind and shadcn/ui ready (for shadcn templates) +- Auth provider configured (for auth templates) + +Proceed to adding schema, functions, and UI. + +## Path 2: Add Convex to an Existing App + +Use this when the user already has a frontend project and wants to add Convex as the backend. + +### Install + +```bash +npm install convex +``` + +### Initialize and start dev loop + +Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. + +### Wire up the provider + +The Convex client must wrap the app at the root. The setup varies by framework. + +Create the `ConvexReactClient` at module scope, not inside a component: + +```tsx +// Bad: re-creates the client on every render +function App() { + const convex = new ConvexReactClient( + import.meta.env.VITE_CONVEX_URL as string, + ); + return ...; +} + +// Good: created once at module scope +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); +function App() { + return ...; +} +``` + +#### React (Vite) + +```tsx +// src/main.tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import App from "./App"; + +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); + +createRoot(document.getElementById("root")!).render( + + + + + , +); +``` + +#### Next.js (App Router) + +```tsx +// app/ConvexClientProvider.tsx +"use client"; + +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return {children}; +} +``` + +```tsx +// app/layout.tsx +import { ConvexClientProvider } from "./ConvexClientProvider"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} +``` + +#### Other frameworks + +For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: + +- [Vue](https://docs.convex.dev/quickstart/vue) +- [Svelte](https://docs.convex.dev/quickstart/svelte) +- [React Native](https://docs.convex.dev/quickstart/react-native) +- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) +- [Remix](https://docs.convex.dev/quickstart/remix) +- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) + +### Environment variables + +The env var name depends on the framework: + +| Framework | Variable | +| ------------ | ------------------------ | +| Vite | `VITE_CONVEX_URL` | +| Next.js | `NEXT_PUBLIC_CONVEX_URL` | +| Remix | `CONVEX_URL` | +| React Native | `EXPO_PUBLIC_CONVEX_URL` | + +`npx convex dev` writes the correct variable to `.env.local` automatically. + +## Agent Mode (Cloud and Headless Agents) + +When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. + +Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: + +```bash +CONVEX_AGENT_MODE=anonymous npx convex dev +``` + +This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. + +## Verify the Setup + +After setup, confirm everything is working: + +1. The user confirms `npx convex dev` is running without errors +2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` +3. `.env.local` contains the deployment URL + +## Writing Your First Function + +Once the project is set up, create a schema and a query to verify the full loop works. + +`convex/schema.ts`: + +```ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + tasks: defineTable({ + text: v.string(), + completed: v.boolean(), + }), +}); +``` + +`convex/tasks.ts`: + +```ts +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("tasks").collect(); + }, +}); + +export const create = mutation({ + args: { text: v.string() }, + handler: async (ctx, args) => { + await ctx.db.insert("tasks", { text: args.text, completed: false }); + }, +}); +``` + +Use in a React component (adjust the import path based on your file location relative to `convex/`): + +```tsx +import { useQuery, useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function Tasks() { + const tasks = useQuery(api.tasks.list); + const create = useMutation(api.tasks.create); + + return ( +
+ + {tasks?.map((t) => ( +
{t.text}
+ ))} +
+ ); +} +``` + +## Development vs Production + +Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. + +When ready to ship, deploy to production: + +```bash +npx convex deploy +``` + +This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. + +## Next Steps + +- Add authentication: use the `convex-setup-auth` skill +- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) +- Build components: use the `convex-create-component` skill +- Plan a migration: use the `convex-migration-helper` skill +- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) +- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) + +## Checklist + +- [ ] Determined starting point: new project or existing app +- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template +- [ ] If existing app: installed `convex` and wired up the provider +- [ ] User has `npx convex dev` running and connected to a deployment +- [ ] `convex/_generated/` directory exists with types +- [ ] `.env.local` has the deployment URL +- [ ] Verified a basic query/mutation round-trip works diff --git a/skills/convex-quickstart/agents/openai.yaml b/skills/convex-quickstart/agents/openai.yaml new file mode 100644 index 00000000..a51a6d09 --- /dev/null +++ b/skills/convex-quickstart/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Quickstart" + short_description: "Start a new Convex app or add Convex to an existing frontend." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#F97316" + default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." + +policy: + allow_implicit_invocation: true diff --git a/skills/convex-quickstart/assets/icon.svg b/skills/convex-quickstart/assets/icon.svg new file mode 100644 index 00000000..d83a73f3 --- /dev/null +++ b/skills/convex-quickstart/assets/icon.svg @@ -0,0 +1,4 @@ + diff --git a/skills/convex-setup-auth/SKILL.md b/skills/convex-setup-auth/SKILL.md new file mode 100644 index 00000000..0d1d9dd6 --- /dev/null +++ b/skills/convex-setup-auth/SKILL.md @@ -0,0 +1,150 @@ +--- +name: convex-setup-auth +description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +--- + +# Convex Authentication Setup + +Implement secure authentication in Convex with user management and access control. + +## When to Use + +- Setting up authentication for the first time +- Implementing user management (users table, identity mapping) +- Creating authentication helper functions +- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) + +## When Not to Use + +- Auth for a non-Convex backend +- Pure OAuth/OIDC documentation without a Convex implementation +- Debugging unrelated bugs that happen to surface near auth code +- The auth provider is already fully configured and the user only needs a one-line fix + +## First Step: Choose the Auth Provider + +Convex supports multiple authentication approaches. Do not assume a provider. + +Before writing setup code: + +1. Ask the user which auth solution they want, unless the repository already makes it obvious +2. If the repo already uses a provider, continue with that provider unless the user wants to switch +3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding + +Common options: + +- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex +- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features +- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically +- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 +- Custom JWT provider - use when integrating an existing auth system not covered above + +Look for signals in the repo before asking: + +- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages +- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components +- Environment variables that clearly point at a provider + +## After Choosing a Provider + +Read the provider's official guide and the matching local reference file: + +- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` +- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` +- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` +- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` + +The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. + +Use those sources for: + +- package installation +- client provider wiring +- environment variables +- `convex/auth.config.ts` setup +- login and logout UI patterns +- framework-specific setup for React, Vite, or Next.js + +For shared auth behavior, use the official Convex docs as the source of truth: + +- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` +- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage +- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance +- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth + +Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. +For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. +For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. +After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. + +## Core Pattern: Protecting Backend Functions + +The most common auth task is checking identity in Convex functions. + +```ts +// Bad: trusting a client-provided userId +export const getMyProfile = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.userId); + }, +}); +``` + +```ts +// Good: verifying identity server-side +export const getMyProfile = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + return await ctx.db + .query("users") + .withIndex("by_tokenIdentifier", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier), + ) + .unique(); + }, +}); +``` + +## Workflow + +1. Determine the provider, either by asking the user or inferring from the repo +2. Ask whether the user wants local-only setup or production-ready setup now +3. Read the matching provider reference file +4. Follow the official provider docs for current setup details +5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns +6. Only add app-level user storage if the docs and app requirements call for it +7. Add authorization checks for ownership, roles, or team access only where the app needs them +8. Verify login state, protected queries, environment variables, and production configuration if requested + +If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. +For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. +If the environment has browser automation tools, you can use them. +If it does not, give the user a short manual validation checklist instead. + +## Reference Files + +### Provider References + +- `references/convex-auth.md` +- `references/clerk.md` +- `references/workos-authkit.md` +- `references/auth0.md` + +## Checklist + +- [ ] Chosen the correct auth provider before writing setup code +- [ ] Read the relevant provider reference file +- [ ] Asked whether the user wants local-only setup or production-ready setup +- [ ] Used the official provider docs for provider-specific wiring +- [ ] Used the official Convex docs for shared auth behavior and authorization patterns +- [ ] Only added app-level user storage if the app actually needs it +- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth +- [ ] Added authentication checks in protected backend functions +- [ ] Added authorization checks where the app actually needs them +- [ ] Clear error messages ("Not authenticated", "Unauthorized") +- [ ] Client auth provider configured for the chosen provider +- [ ] If requested, production auth setup is covered too diff --git a/skills/convex-setup-auth/agents/openai.yaml b/skills/convex-setup-auth/agents/openai.yaml new file mode 100644 index 00000000..d1c90a14 --- /dev/null +++ b/skills/convex-setup-auth/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Setup Auth" + short_description: "Set up Convex auth, user identity mapping, and access control." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." + +policy: + allow_implicit_invocation: true diff --git a/skills/convex-setup-auth/assets/icon.svg b/skills/convex-setup-auth/assets/icon.svg new file mode 100644 index 00000000..4917dbb4 --- /dev/null +++ b/skills/convex-setup-auth/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/skills/convex-setup-auth/references/auth0.md b/skills/convex-setup-auth/references/auth0.md new file mode 100644 index 00000000..9c729c5a --- /dev/null +++ b/skills/convex-setup-auth/references/auth0.md @@ -0,0 +1,116 @@ +# Auth0 + +Official docs: + +- https://docs.convex.dev/auth/auth0 +- https://auth0.github.io/auth0-cli/ +- https://auth0.github.io/auth0-cli/auth0_apps_create.html + +Use this when the app already uses Auth0 or the user wants Auth0 specifically. + +## Workflow + +1. Confirm the user wants Auth0 +2. Determine the app framework and whether Auth0 is already partly set up +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and Auth0 guides before making changes +5. Ask whether they want the fastest setup path by installing the Auth0 CLI +6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI +7. If they do not want the CLI path, use the Auth0 dashboard path instead +8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up +9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID +10. Set environment variables for local and production environments +11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +12. Gate Convex-backed UI with Convex auth state +13. Try to verify Convex reports the user as authenticated after Auth0 login +14. If the refresh-token path fails, stop improvising and send the user back to the official docs +15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered + +## What To Do + +- Read the official Convex and Auth0 guide before writing setup code +- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet +- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" +- Make sure the app has already completed the relevant Auth0 quickstart for its frontend +- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` +- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated + +## Key Setup Areas + +- install the Auth0 SDK for the app's framework +- configure `convex/auth.config.ts` with the Auth0 domain and client ID +- set environment variables for local and production environments +- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +- use Convex auth state when gating Convex-backed UI + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- frontend app entry or provider wrapper +- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` +- Auth0 environment variables commonly include: + - `AUTH0_DOMAIN` + - `AUTH0_CLIENT_ID` + - `VITE_AUTH0_DOMAIN` + - `VITE_AUTH0_CLIENT_ID` + +## Concrete Steps + +1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework +2. Ask whether the user wants the Auth0 CLI path +3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` +4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app +5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard +6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard +7. Install the Auth0 SDK for the app's framework +8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID +9. Set frontend and backend environment variables +10. Wrap the app in `Auth0Provider` +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` +12. Run the normal Convex dev or deploy flow after backend config changes +13. Try the official provider config shown in the Convex docs +14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now +15. Only claim success if the user can sign in and Convex recognizes the authenticated session +16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too + +## Gotchas + +- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch +- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant +- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard +- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced +- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end +- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled +- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation +- Keep dev and prod tenants separate if the project uses different Auth0 environments +- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. +- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. +- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. +- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered +- Verify production environment variables and redirect settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the Auth0 login flow +- Verify Convex-authenticated UI renders only after Convex auth state is ready +- Verify protected Convex queries succeed after login +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- Verify the Auth0 app settings match the real local callback and logout URLs during development +- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully +- If production-ready setup was requested, verify the production Auth0 configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Auth0 +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Complete the relevant Auth0 frontend setup +- [ ] Configure `convex/auth.config.ts` +- [ ] Set environment variables +- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs +- [ ] If requested, configure the production deployment too diff --git a/skills/convex-setup-auth/references/clerk.md b/skills/convex-setup-auth/references/clerk.md new file mode 100644 index 00000000..7dbde194 --- /dev/null +++ b/skills/convex-setup-auth/references/clerk.md @@ -0,0 +1,113 @@ +# Clerk + +Official docs: + +- https://docs.convex.dev/auth/clerk +- https://clerk.com/docs/guides/development/integrations/databases/convex + +Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. + +## Workflow + +1. Confirm the user wants Clerk +2. Make sure the user has a Clerk account and a Clerk application +3. Determine the app framework: + - React + - Next.js + - TanStack Start +4. Ask whether the user wants local-only setup or production-ready setup now +5. Gather the Clerk keys and the Clerk Frontend API URL +6. Follow the correct framework section in the official docs +7. Complete the backend and client wiring +8. Verify Convex reports the user as authenticated after login +9. If the user wants production-ready setup, make sure the production Clerk config is also covered + +## What To Do + +- Read the official Convex and Clerk guide before writing setup code +- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application +- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active +- Match the guide to the app's framework, usually React, Next.js, or TanStack Start +- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` + +## Key Setup Areas + +- install the Clerk SDK for the framework in use +- configure `convex/auth.config.ts` with the Clerk issuer domain +- set the required Clerk environment variables +- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` +- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- React or Vite client entry such as `src/main.tsx` +- Next.js client wrapper for Convex if using App Router +- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` +- Clerk app creation page: `https://dashboard.clerk.com/apps/new` +- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` +- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` +- Clerk environment variables: + - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs + - `CLERK_FRONTEND_API_URL` in the Clerk docs + - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps + - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps + - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required + +`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. + +## Concrete Steps + +1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` +2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` +3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed +4. Open `https://dashboard.clerk.com/apps/setup/convex` +5. Activate the Convex integration in Clerk if it is not already active +6. Copy the Clerk Frontend API URL shown there +7. Install the Clerk package for the app's framework +8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens +9. Set the publishable key in the frontend environment +10. Set the issuer domain or Frontend API URL so Convex can validate the JWT +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` +12. Wrap the app in `ClerkProvider` +13. Use Convex auth helpers for authenticated rendering +14. Run the normal Convex dev or deploy flow after updating backend auth config +15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too + +## Gotchas + +- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render +- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper +- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config +- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. +- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. +- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. +- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. +- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` +- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included +- Verify production redirect URLs and any production Clerk domain values before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can sign in with Clerk +- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in +- Verify `useConvexAuth()` reaches the authenticated state after Clerk login +- Verify protected Convex queries run successfully inside authenticated UI +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- If production-ready setup was requested, verify the production Clerk configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Clerk +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Follow the correct framework section in the official guide +- [ ] Set Clerk environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify Convex authenticated state after login +- [ ] If requested, configure the production deployment too diff --git a/skills/convex-setup-auth/references/convex-auth.md b/skills/convex-setup-auth/references/convex-auth.md new file mode 100644 index 00000000..d4824d24 --- /dev/null +++ b/skills/convex-setup-auth/references/convex-auth.md @@ -0,0 +1,143 @@ +# Convex Auth + +Official docs: https://docs.convex.dev/auth/convex-auth +Setup guide: https://labs.convex.dev/auth/setup + +Use this when the user wants auth handled directly in Convex rather than through a third-party provider. + +## Workflow + +1. Confirm the user wants Convex Auth specifically +2. Determine which sign-in methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords and password reset +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the Convex Auth setup guide before writing code +5. Make sure the project has a configured Convex deployment: + - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set + - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing +6. Install the auth packages: + - `npm install @convex-dev/auth @auth/core@0.37.0` +7. Run the initialization command: + - `npx @convex-dev/auth` +8. Confirm the initializer created: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +9. Add the required `authTables` to `convex/schema.ts` +10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` +11. Configure at least one auth method in `convex/auth.ts` +12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code +13. Verify the client can sign in successfully +14. Verify Convex receives authenticated identity in backend functions +15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well +16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex + +## What This Reference Is For + +- choosing Convex Auth as the default provider for a new Convex app +- understanding whether the app wants magic links, OTPs, OAuth, or passwords +- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior + +## What To Do + +- Read the Convex Auth setup guide before writing setup code +- Follow the setup flow from the docs rather than recreating it from memory +- If the app is new, consider starting from the official starter flow instead of hand-wiring everything +- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra + +## Concrete Steps + +1. Install `@convex-dev/auth` and `@auth/core@0.37.0` +2. Run `npx convex dev` if the project does not already have a configured deployment +3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment +4. Run `npx @convex-dev/auth` +5. Confirm the generated auth setup is present before continuing: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +6. Add `authTables` to `convex/schema.ts` +7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry +8. Configure the selected auth methods in `convex/auth.ts` +9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed +10. Verify login locally +11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment + +## Expected Files and Decisions + +- `convex/schema.ts` +- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file +- generated Convex Auth setup produced by `npx @convex-dev/auth` +- an existing configured Convex deployment, or the ability to create one with `npx convex dev` +- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods + +- Decide whether the user is creating a new app or adding auth to an existing app +- For a new app, prefer the official starter flow instead of rebuilding setup by hand +- Decide which auth methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords +- Decide whether the user wants local-only setup or production-ready setup now +- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough + +## Gotchas + +- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. +- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. +- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. +- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. +- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. +- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. +- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. +- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. +- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. +- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment +- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Human Handoff + +If `npx convex dev` or deployment setup requires human input: + +- stop and explain exactly what the user needs to do +- say why that step is required +- resume the auth setup immediately after the user confirms it is done + +## Validation + +- Verify the user can complete a sign-in flow +- Offer to validate sign up, sign out, and sign back in with the configured auth method +- If browser automation is available in the environment, you can do this directly +- If browser automation is not available, give the user a short manual validation checklist instead +- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions +- Verify protected UI only renders after Convex-authenticated state is ready +- Verify environment variables and redirect settings match the current app environment +- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in +- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed +- If production-ready setup was requested, verify the production deployment is also configured correctly + +## Checklist + +- [ ] Confirm the user wants Convex Auth specifically +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Ensure a Convex deployment is configured before running auth initialization +- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` +- [ ] Run `npx convex dev` first if needed +- [ ] Run `npx @convex-dev/auth` +- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created +- [ ] Follow the setup guide for package install and wiring +- [ ] Add `authTables` to `convex/schema.ts` +- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` +- [ ] Configure at least one auth method in `convex/auth.ts` +- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes +- [ ] Confirm which sign-in methods the app needs +- [ ] Verify the client can sign in and the backend receives authenticated identity +- [ ] Offer end-to-end validation of sign up, sign out, and sign back in +- [ ] If requested, configure the production deployment too +- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/skills/convex-setup-auth/references/workos-authkit.md b/skills/convex-setup-auth/references/workos-authkit.md new file mode 100644 index 00000000..038cb9f3 --- /dev/null +++ b/skills/convex-setup-auth/references/workos-authkit.md @@ -0,0 +1,114 @@ +# WorkOS AuthKit + +Official docs: + +- https://docs.convex.dev/auth/authkit/ +- https://docs.convex.dev/auth/authkit/add-to-app +- https://docs.convex.dev/auth/authkit/auto-provision + +Use this when the app already uses WorkOS or the user wants AuthKit specifically. + +## Workflow + +1. Confirm the user wants WorkOS AuthKit +2. Determine whether they want: + - a Convex-managed WorkOS team + - an existing WorkOS team +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and WorkOS AuthKit guide +5. Create or update `convex.json` for the app's framework and real local port +6. Follow the correct branch of the setup flow based on that choice +7. Configure the required WorkOS environment variables +8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs +9. Wire the client provider and callback flow +10. Verify authenticated requests reach Convex +11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too +12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex + +## What To Do + +- Read the official Convex and WorkOS AuthKit guide before writing setup code +- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team +- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra +- Follow the current setup flow from the docs instead of relying on older examples + +## Key Setup Areas + +- package installation for the app's framework +- `convex.json` with the `authKit` section for dev, and preview or prod if needed +- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration +- `convex/auth.config.ts` wiring for WorkOS-issued JWTs +- client provider setup and token flow into Convex +- login callback and redirect configuration + +## Files and Env Vars To Expect + +- `convex.json` +- `convex/auth.config.ts` +- frontend auth provider wiring +- callback or redirect route setup where the framework requires it +- WorkOS environment variables commonly include: + - `WORKOS_CLIENT_ID` + - `WORKOS_API_KEY` + - `WORKOS_COOKIE_PASSWORD` + - `VITE_WORKOS_CLIENT_ID` + - `VITE_WORKOS_REDIRECT_URI` + - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` + +For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. + +## Concrete Steps + +1. Choose Convex-managed or existing WorkOS team +2. Create or update `convex.json` with the `authKit` section for the framework in use +3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port +4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow +5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` +6. Create or update `convex/auth.config.ts` for WorkOS JWT validation +7. Run the normal Convex dev or deploy flow so backend config is synced +8. Wire the WorkOS client provider in the app +9. Configure callback and redirect handling +10. Verify the user can sign in and return to the app +11. Verify Convex sees the authenticated user after login +12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too + +## Gotchas + +- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious +- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys +- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex +- Do not mix dev and prod WorkOS credentials or redirect URIs +- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it +- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. +- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. +- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. +- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. +- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered +- Verify the production redirect and callback settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the login flow and return to the app +- Verify the callback URL matches the real frontend port in local dev +- Verify Convex receives authenticated requests after login +- Verify `convex.json` matches the framework and chosen WorkOS setup path +- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path +- Verify environment variables differ correctly between local and production where needed +- If production-ready setup was requested, verify the production WorkOS configuration is also covered + +## Checklist + +- [ ] Confirm the user wants WorkOS AuthKit +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Choose Convex-managed or existing WorkOS team +- [ ] Create or update `convex.json` +- [ ] Configure WorkOS environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify authenticated requests reach Convex after login +- [ ] If requested, configure the production deployment too diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index a9e379a8..8bbce230 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -18,6 +18,7 @@ import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_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'; @@ -431,6 +432,55 @@ void main() { ); }); + 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, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(mapProvider.notifier).updateMap(MapValue.bind); + 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( + jsonDecode(strategyPatch.payload!) as Map, + containsPair('mapData', Maps.mapNames[MapValue.bind]), + ); + }); + test('projected active-page merge prefers local overlay for touched entities', () async { const strategyId = 'cloud-strategy'; @@ -483,7 +533,8 @@ void main() { await container.read(remoteStrategySnapshotProvider.future); final localTextPayload = Map.from( - (PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'local-a') + (PlacedText(id: 'text-1', position: const Offset(10, 20)) + ..text = 'local-a') .toJson(), )..putIfAbsent('elementType', () => 'text'); container.read(activePageLiveSyncProvider.notifier).setStateForTest( @@ -518,7 +569,8 @@ void main() { expect(textsById['text-2'], 'remote-b-updated'); }); - test('reject refresh preserves local state and queues follow-up sync', () async { + 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); @@ -833,7 +885,8 @@ void main() { expect(container.read(textProvider).single.text, 'before'); }); - test('pending cloud sync does not block projected active-page rehydrate', () async { + 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); From 50a28acf86d2d0f2201aeb25b4b21751f754c675 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 17 Apr 2026 02:21:36 -0400 Subject: [PATCH 06/15] Reformat Convex skill docs --- .../skills/convex-create-component/SKILL.md | 22 ++- .../skills/convex-migration-helper/SKILL.md | 9 +- .../references/migration-patterns.md | 4 +- .../references/migrations-component.md | 3 +- .../skills/convex-performance-audit/SKILL.md | 18 +- .../references/function-budget.md | 20 +- .../references/hot-path-rules.md | 50 +++-- .../references/occ-conflicts.md | 40 ++-- .agents/skills/convex-quickstart/SKILL.md | 44 +++-- .agents/skills/convex-setup-auth/SKILL.md | 2 +- .agents/skills/convex/SKILL.md | 47 +++++ .claude/skills/convex | 1 + .windsurf/skills/convex | 1 + convex/_generated/ai/ai-files.state.json | 5 +- convex/_generated/ai/guidelines.md | 177 +++++++++++------- convex/ops.ts | 16 +- lib/providers/action_provider.dart | 60 +++--- lib/providers/map_provider.dart | 6 - lib/providers/strategy_provider.dart | 90 ++++++++- lib/widgets/strategy_quick_switcher.dart | 8 +- pubspec.lock | 16 +- skills-lock.json | 15 +- 22 files changed, 413 insertions(+), 241 deletions(-) create mode 100644 .agents/skills/convex/SKILL.md create mode 120000 .claude/skills/convex create mode 120000 .windsurf/skills/convex diff --git a/.agents/skills/convex-create-component/SKILL.md b/.agents/skills/convex-create-component/SKILL.md index a79c18e0..22af601f 100644 --- a/.agents/skills/convex-create-component/SKILL.md +++ b/.agents/skills/convex-create-component/SKILL.md @@ -42,12 +42,12 @@ Create reusable Convex components with clear boundaries and a small app-facing A Ask the user, then pick one path: -| Goal | Shape | Reference | -|------|-------|-----------| -| Component for this app only | Local | `references/local-components.md` | -| Publish or share across apps | Packaged | `references/packaged-components.md` | -| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | -| Not sure | Default to local | `references/local-components.md` | +| Goal | Shape | Reference | +| ------------------------------------------------- | ---------------- | ----------------------------------- | +| Component for this app only | Local | `references/local-components.md` | +| Publish or share across apps | Packaged | `references/packaged-components.md` | +| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | +| Not sure | Default to local | `references/local-components.md` | Read exactly one reference file before proceeding. @@ -111,7 +111,7 @@ export const listUnread = query({ userId: v.string(), message: v.string(), read: v.boolean(), - }) + }), ), handler: async (ctx, args) => { return await ctx.db @@ -234,12 +234,16 @@ export const sendNotification = mutation({ ```ts // Bad: parent app table IDs are not valid component validators -args: { userId: v.id("users") } +args: { + userId: v.id("users"); +} ``` ```ts // Good: treat parent-owned IDs as strings at the boundary -args: { userId: v.string() } +args: { + userId: v.string(); +} ``` ### Advanced Patterns diff --git a/.agents/skills/convex-migration-helper/SKILL.md b/.agents/skills/convex-migration-helper/SKILL.md index 97f64c1a..db36c622 100644 --- a/.agents/skills/convex-migration-helper/SKILL.md +++ b/.agents/skills/convex-migration-helper/SKILL.md @@ -55,13 +55,13 @@ Unless you are certain, prefer deprecating fields over deleting them. Mark the f // Before users: defineTable({ name: v.string(), -}) +}); // After - safe, new field is optional users: defineTable({ name: v.string(), bio: v.optional(v.string()), -}) +}); ``` ### Adding New Table @@ -70,7 +70,7 @@ users: defineTable({ posts: defineTable({ userId: v.id("users"), title: v.string(), -}).index("by_user", ["userId"]) +}).index("by_user", ["userId"]); ``` ### Adding Index @@ -79,8 +79,7 @@ posts: defineTable({ users: defineTable({ name: v.string(), email: v.string(), -}) - .index("by_email", ["email"]) +}).index("by_email", ["email"]); ``` ## Breaking Changes: The Deployment Workflow diff --git a/.agents/skills/convex-migration-helper/references/migration-patterns.md b/.agents/skills/convex-migration-helper/references/migration-patterns.md index 219583e0..53b4946f 100644 --- a/.agents/skills/convex-migration-helper/references/migration-patterns.md +++ b/.agents/skills/convex-migration-helper/references/migration-patterns.md @@ -9,7 +9,7 @@ Common migration patterns, zero-downtime strategies, and verification techniques users: defineTable({ name: v.string(), role: v.optional(v.union(v.literal("user"), v.literal("admin"))), -}) +}); // Migration: backfill the field export const addDefaultRole = migrations.define({ @@ -25,7 +25,7 @@ export const addDefaultRole = migrations.define({ users: defineTable({ name: v.string(), role: v.union(v.literal("user"), v.literal("admin")), -}) +}); ``` ## Deleting a Field diff --git a/.agents/skills/convex-migration-helper/references/migrations-component.md b/.agents/skills/convex-migration-helper/references/migrations-component.md index c80522f2..95ec2921 100644 --- a/.agents/skills/convex-migration-helper/references/migrations-component.md +++ b/.agents/skills/convex-migration-helper/references/migrations-component.md @@ -151,8 +151,7 @@ Process only matching documents instead of the full table: ```typescript export const fixEmptyNames = migrations.define({ table: "users", - customRange: (query) => - query.withIndex("by_name", (q) => q.eq("name", "")), + customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")), migrateOne: () => ({ name: "" }), }); ``` diff --git a/.agents/skills/convex-performance-audit/SKILL.md b/.agents/skills/convex-performance-audit/SKILL.md index 9d92b33c..382951cf 100644 --- a/.agents/skills/convex-performance-audit/SKILL.md +++ b/.agents/skills/convex-performance-audit/SKILL.md @@ -43,13 +43,13 @@ Start with the strongest signal available: After gathering signals, identify the problem class and read the matching reference file. -| Signal | Reference | -|---|---| -| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | -| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | -| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | -| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | -| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | +| Signal | Reference | +| -------------------------------------------------------------- | ----------------------------------------- | +| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | +| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | +| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | +| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | +| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. @@ -107,7 +107,7 @@ After finding one problem, inspect both sibling readers and sibling writers for Examples: - If one list query switches from full docs to a digest table, inspect the other list queries for that table -- If one mutation needs no-op write protection, inspect the other writers to the same table +- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table - If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. @@ -119,7 +119,7 @@ Confirm all of these: 1. Results are the same as before, no dropped records 2. Eliminated reads or writes are no longer in the path where expected 3. Fallback behavior works when denormalized or indexed fields are missing -4. New writes avoid unnecessary invalidation when data is unchanged +4. Frequently-updated fields are isolated from widely-read documents where needed 5. Every relevant sibling reader and writer was inspected, not just the original function ## Reference Files diff --git a/.agents/skills/convex-performance-audit/references/function-budget.md b/.agents/skills/convex-performance-audit/references/function-budget.md index c71d14cb..d4d4aa5a 100644 --- a/.agents/skills/convex-performance-audit/references/function-budget.md +++ b/.agents/skills/convex-performance-audit/references/function-budget.md @@ -10,17 +10,17 @@ Convex functions run inside transactions with budgets for time, reads, and write These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. -| Resource | Limit | -|---|---| -| Query/mutation execution time | 1 second (user code only, excludes DB operations) | -| Action execution time | 10 minutes | -| Data read per transaction | 16 MiB | -| Data written per transaction | 16 MiB | +| Resource | Limit | +| --------------------------------- | ----------------------------------------------------- | +| Query/mutation execution time | 1 second (user code only, excludes DB operations) | +| Action execution time | 10 minutes | +| Data read per transaction | 16 MiB | +| Data written per transaction | 16 MiB | | Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | -| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | -| Documents written per transaction | 16,000 | -| Individual document size | 1 MiB | -| Function return value size | 16 MiB | +| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | +| Documents written per transaction | 16,000 | +| Individual document size | 1 MiB | +| Function return value size | 16 MiB | ## Symptoms diff --git a/.agents/skills/convex-performance-audit/references/hot-path-rules.md b/.agents/skills/convex-performance-audit/references/hot-path-rules.md index e3e44b15..e003e052 100644 --- a/.agents/skills/convex-performance-audit/references/hot-path-rules.md +++ b/.agents/skills/convex-performance-audit/references/hot-path-rules.md @@ -121,13 +121,15 @@ Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need // Bad: two indexes where one would do defineTable({ team: v.id("teams"), user: v.id("users") }) .index("by_team", ["team"]) - .index("by_team_and_user", ["team", "user"]) + .index("by_team_and_user", ["team", "user"]); ``` ```ts // Good: single compound index serves both query patterns -defineTable({ team: v.id("teams"), user: v.id("users") }) - .index("by_team_and_user", ["team", "user"]) +defineTable({ team: v.id("teams"), user: v.id("users") }).index( + "by_team_and_user", + ["team", "user"], +); ``` Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. @@ -170,9 +172,7 @@ const ownerName = project.ownerName ?? "Unknown owner"; ```ts // Good: denormalized data is an optimization, not the only source of truth const ownerName = - project.ownerName ?? - (await ctx.db.get(project.ownerId))?.name ?? - null; + project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null; ``` Bad lookup map pattern: @@ -241,35 +241,33 @@ const projects = await ctx.db .take(20); ``` -## 4. Skip No-Op Writes - -No-op writes still cost work in Convex: +## 4. Isolate Frequently-Updated Fields -- invalidation -- replication -- trigger execution -- downstream sync +Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to. -Before `patch` or `replace`, compare against the existing document and skip the write if nothing changed. +Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them. -Apply this across sibling writers too. One careful writer does not help much if three other mutations still patch unconditionally. +Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document. ```ts -// Bad: patching unchanged values still triggers invalidation and downstream work -await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, +// Bad: every presence heartbeat invalidates subscribers to the whole profile +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, + lastSeen: Date.now(), }); ``` ```ts -// Good: only write when something actually changed -if (settings.theme !== args.theme || settings.locale !== args.locale) { - await ctx.db.patch(settings._id, { - theme: args.theme, - locale: args.locale, - }); -} +// Good: keep profile reads stable, move heartbeat updates to a separate document +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, +}); + +await ctx.db.patch(presence._id, { + lastSeen: Date.now(), +}); ``` ## 5. Match Consistency To Read Patterns diff --git a/.agents/skills/convex-performance-audit/references/occ-conflicts.md b/.agents/skills/convex-performance-audit/references/occ-conflicts.md index a96d0466..1da43801 100644 --- a/.agents/skills/convex-performance-audit/references/occ-conflicts.md +++ b/.agents/skills/convex-performance-audit/references/occ-conflicts.md @@ -73,42 +73,30 @@ await ctx.db.patch(shardId, { count: shard!.count + 1 }); Aggregate the shards in a query or scheduled job when you need the total. -### 3. Skip no-op writes +### 3. Move non-critical work to scheduled functions -Writes that do not change data still participate in conflict detection and trigger invalidation. +If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. ```ts -// Bad: patches even when nothing changed -await ctx.db.patch(doc._id, { status: args.status }); -``` - -```ts -// Good: only write when the value actually differs -if (doc.status !== args.status) { - await ctx.db.patch(doc._id, { status: args.status }); -} -``` - -### 4. Move non-critical work to scheduled functions - -If a mutation does primary work plus secondary bookkeeping (analytics, notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. - -```ts -// Bad: analytics update in the same transaction as the user action -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.db.insert("analytics", { event: "action", userId, ts: Date.now() }); +// Bad: canonical write and derived work happen in the same transaction +await ctx.db.patch(userId, { name: args.name }); +await ctx.db.insert("userUpdateAnalytics", { + userId, + kind: "name_changed", + name: args.name, +}); ``` ```ts -// Good: schedule the bookkeeping so the primary transaction is smaller -await ctx.db.patch(userId, { lastActiveAt: Date.now() }); -await ctx.scheduler.runAfter(0, internal.analytics.recordEvent, { - event: "action", +// Good: keep the primary write small, defer the analytics work +await ctx.db.patch(userId, { name: args.name }); +await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, { userId, + name: args.name, }); ``` -### 5. Combine competing writes +### 4. Combine competing writes If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. diff --git a/.agents/skills/convex-quickstart/SKILL.md b/.agents/skills/convex-quickstart/SKILL.md index 792bba3d..5bff17bc 100644 --- a/.agents/skills/convex-quickstart/SKILL.md +++ b/.agents/skills/convex-quickstart/SKILL.md @@ -32,15 +32,15 @@ Use the official scaffolding tool. It creates a complete project with the fronte ### Pick a template -| Template | Stack | -|----------|-------| -| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | -| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | -| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | -| `nextjs-clerk` | Next.js + Clerk auth | -| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | -| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | -| `bare` | Convex backend only, no frontend | +| Template | Stack | +| -------------------------- | ----------------------------------------- | +| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | +| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | +| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | +| `nextjs-clerk` | Next.js + Clerk auth | +| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | +| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | +| `bare` | Convex backend only, no frontend | If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. @@ -77,6 +77,7 @@ npm install **Ask the user to run this themselves:** Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: + - Create a Convex project and dev deployment - Write the deployment URL to `.env.local` - Create the `convex/` directory with generated types @@ -111,6 +112,7 @@ my-app/ ``` The template already has: + - `ConvexProvider` wired into the app root - Correct env var names for the framework - Tailwind and shadcn/ui ready (for shadcn templates) @@ -141,7 +143,9 @@ Create the `ConvexReactClient` at module scope, not inside a component: ```tsx // Bad: re-creates the client on every render function App() { - const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); + const convex = new ConvexReactClient( + import.meta.env.VITE_CONVEX_URL as string, + ); return ...; } @@ -192,7 +196,11 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) { // app/layout.tsx import { ConvexClientProvider } from "./ConvexClientProvider"; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { return ( @@ -218,11 +226,11 @@ For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the mat The env var name depends on the framework: -| Framework | Variable | -|-----------|----------| -| Vite | `VITE_CONVEX_URL` | -| Next.js | `NEXT_PUBLIC_CONVEX_URL` | -| Remix | `CONVEX_URL` | +| Framework | Variable | +| ------------ | ------------------------ | +| Vite | `VITE_CONVEX_URL` | +| Next.js | `NEXT_PUBLIC_CONVEX_URL` | +| Remix | `CONVEX_URL` | | React Native | `EXPO_PUBLIC_CONVEX_URL` | `npx convex dev` writes the correct variable to `.env.local` automatically. @@ -299,7 +307,9 @@ function Tasks() { return (
- {tasks?.map((t) =>
{t.text}
)} + {tasks?.map((t) => ( +
{t.text}
+ ))}
); } diff --git a/.agents/skills/convex-setup-auth/SKILL.md b/.agents/skills/convex-setup-auth/SKILL.md index 0fa00e2f..0d1d9dd6 100644 --- a/.agents/skills/convex-setup-auth/SKILL.md +++ b/.agents/skills/convex-setup-auth/SKILL.md @@ -102,7 +102,7 @@ export const getMyProfile = query({ return await ctx.db .query("users") .withIndex("by_tokenIdentifier", (q) => - q.eq("tokenIdentifier", identity.tokenIdentifier) + q.eq("tokenIdentifier", identity.tokenIdentifier), ) .unique(); }, diff --git a/.agents/skills/convex/SKILL.md b/.agents/skills/convex/SKILL.md new file mode 100644 index 00000000..d4678270 --- /dev/null +++ b/.agents/skills/convex/SKILL.md @@ -0,0 +1,47 @@ +--- +name: convex +description: Routing skill for Convex work in this repo. Use when the user explicitly invokes the `convex` skill, asks which Convex workflow or skill to use, or says they are working on a Convex app without naming a specific task yet. Do not prefer this skill when the request is clearly about setting up Convex, authentication, components, migrations, or performance. +--- + +# Convex + +Use this as the routing skill for Convex work in this repo. + +If a more specific Convex skill clearly matches the request, use that instead. + +## Start Here + +If the project does not already have Convex AI guidance installed, or the existing guidance looks stale, strongly recommend installing it first. + +Preferred: + +```bash +npx convex ai-files install +``` + +This installs or refreshes the managed Convex AI files. It is the recommended starting point for getting the official Convex guidelines in place and following the current Convex AI setup described in the docs: + +- [Convex AI docs](https://docs.convex.dev/ai) + +Simple fallback: + +- [convex_rules.txt](https://convex.link/convex_rules.txt) + +Prefer `npx convex ai-files install` over copying rules by hand when possible. + +## Route to the Right Skill + +After that, use the most specific Convex skill for the task: + +- New project or adding Convex to an app: `convex-quickstart` +- Authentication setup: `convex-setup-auth` +- Building a reusable Convex component: `convex-create-component` +- Planning or running a migration: `convex-migration-helper` +- Investigating performance issues: `convex-performance-audit` + +If one of those clearly matches the user's goal, switch to it instead of staying in this skill. + +## When Not to Use + +- The user has already named a more specific Convex workflow +- Another Convex skill obviously fits the request better diff --git a/.claude/skills/convex b/.claude/skills/convex new file mode 120000 index 00000000..9ba9fd9e --- /dev/null +++ b/.claude/skills/convex @@ -0,0 +1 @@ +../../.agents/skills/convex \ No newline at end of file diff --git a/.windsurf/skills/convex b/.windsurf/skills/convex new file mode 120000 index 00000000..9ba9fd9e --- /dev/null +++ b/.windsurf/skills/convex @@ -0,0 +1 @@ +../../.agents/skills/convex \ No newline at end of file diff --git a/convex/_generated/ai/ai-files.state.json b/convex/_generated/ai/ai-files.state.json index a8f6e5f4..7fc165fd 100644 --- a/convex/_generated/ai/ai-files.state.json +++ b/convex/_generated/ai/ai-files.state.json @@ -1,9 +1,10 @@ { - "guidelinesHash": "294b619f8246c26bd6bfb6a57122503f0e2149872fc6b26609b7a95bfefaf2b8", + "guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57", "agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", "claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", - "agentSkillsSha": "dc8ff761cfe4da450af2ea8a9ec708f737064bed", + "agentSkillsSha": "4de8fba05b0a506661116985649072777049f67b", "installedSkillNames": [ + "convex", "convex-create-component", "convex-migration-helper", "convex-performance-audit", diff --git a/convex/_generated/ai/guidelines.md b/convex/_generated/ai/guidelines.md index 151cdf71..e41beddc 100644 --- a/convex/_generated/ai/guidelines.md +++ b/convex/_generated/ai/guidelines.md @@ -1,78 +1,90 @@ # Convex guidelines + ## Function guidelines + ### Http endpoint syntax + - HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: + ```typescript import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; const http = httpRouter(); http.route({ - path: "/echo", - method: "POST", - handler: httpAction(async (ctx, req) => { + path: "/echo", + method: "POST", + handler: httpAction(async (ctx, req) => { const body = await req.bytes(); return new Response(body, { status: 200 }); - }), + }), }); ``` + - HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. ### Validators + - Below is an example of an array validator: + ```typescript import { mutation } from "./_generated/server"; import { v } from "convex/values"; export default mutation({ -args: { + args: { simpleArray: v.array(v.union(v.string(), v.number())), -}, -handler: async (ctx, args) => { + }, + handler: async (ctx, args) => { //... -}, + }, }); ``` + - Below is an example of a schema with validators that codify a discriminated union type: + ```typescript import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ - results: defineTable( - v.union( - v.object({ - kind: v.literal("error"), - errorMessage: v.string(), - }), - v.object({ - kind: v.literal("success"), - value: v.number(), - }), - ), - ) + results: defineTable( + v.union( + v.object({ + kind: v.literal("error"), + errorMessage: v.string(), + }), + v.object({ + kind: v.literal("success"), + value: v.number(), + }), + ), + ), }); ``` + - Here are the valid Convex types along with their respective validators: -Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | -| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Id | string | `doc._id` | `v.id(tableName)` | | -| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | -| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | -| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | -| Boolean | boolean | `true` | `v.boolean()` | -| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | -| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | -| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | -| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | -| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". | + Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | + | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | Id | string | `doc._id` | `v.id(tableName)` | | + | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | + | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | + | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | + | Boolean | boolean | `true` | `v.boolean()` | + | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | + | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | + | Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | + | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | +| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". | ### Function registration + - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. - Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. - You CANNOT register a function through the `api` or `internal` objects. - ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. ### Function calling + - Use `ctx.runQuery` to call a query from a query, mutation, or action. - Use `ctx.runMutation` to call a mutation from a mutation or action. - Use `ctx.runAction` to call an action from an action. @@ -80,6 +92,7 @@ Convex Type | TS/JS type | Example Usage | Validator for argument val - Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. - All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. - When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, + ``` export const f = query({ args: { name: v.string() }, @@ -98,6 +111,7 @@ export const g = query({ ``` ### Function references + - Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. - Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. - Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. @@ -105,6 +119,7 @@ export const g = query({ - Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. ### Pagination + - Define pagination using the following syntax: ```ts @@ -112,17 +127,19 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; export const listWithExtraArg = query({ - args: { paginationOpts: paginationOptsValidator, author: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("messages") - .withIndex("by_author", (q) => q.eq("author", args.author)) - .order("desc") - .paginate(args.paginationOpts); - }, + args: { paginationOpts: paginationOptsValidator, author: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .withIndex("by_author", (q) => q.eq("author", args.author)) + .order("desc") + .paginate(args.paginationOpts); + }, }); ``` + Note: `paginationOpts` is an object with the following properties: + - `numItems`: the maximum number of documents to return (the validator is `v.number()`) - `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) - A query that ends in `.paginate()` returns an object that has the following properties: @@ -130,8 +147,8 @@ Note: `paginationOpts` is an object with the following properties: - isDone (a boolean that represents whether or not this is the last page of documents) - continueCursor (a string that represents the cursor to use to fetch the next page of documents) - ## Schema guidelines + - Always define your schema in `convex/schema.ts`. - Always import the schema definition functions from `convex/server`. - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. @@ -141,8 +158,10 @@ Note: `paginationOpts` is an object with the following properties: - Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record. ## Authentication guidelines + - Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`. - Example `convex/auth.config.ts`: + ```typescript export default { providers: [ @@ -153,11 +172,14 @@ export default { ], }; ``` + The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim. + - Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier. - In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key. - NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`. - When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`: + ```tsx import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react"; @@ -171,45 +193,51 @@ function App({ children }: { children: React.ReactNode }) { ); } ``` + The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests. ## Typescript guidelines -- You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. + +- You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. - Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table. - Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type. - If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: + ```ts import { query } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; export const exampleQuery = query({ - args: { userIds: v.array(v.id("users")) }, - handler: async (ctx, args) => { - const idToUsername: Record, string> = {}; - for (const userId of args.userIds) { - const user = await ctx.db.get("users", userId); - if (user) { - idToUsername[user._id] = user.username; - } - } - - return idToUsername; - }, + args: { userIds: v.array(v.id("users")) }, + handler: async (ctx, args) => { + const idToUsername: Record, string> = {}; + for (const userId of args.userIds) { + const user = await ctx.db.get("users", userId); + if (user) { + idToUsername[user._id] = user.username; + } + } + + return idToUsername; + }, }); ``` + - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. ## Full text search guidelines + - A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: const messages = await ctx.db - .query("messages") - .withSearchIndex("search_body", (q) => - q.search("body", "hello hi").eq("channel", "#general"), - ) - .take(10); +.query("messages") +.withSearchIndex("search_body", (q) => +q.search("body", "hello hi").eq("channel", "#general"), +) +.take(10); ## Query guidelines + - Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. - If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way. - Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations. @@ -217,39 +245,46 @@ const messages = await ctx.db - Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits. - Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. - When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. + ### Ordering + - By default Convex always returns documents in ascending `_creationTime` order. - You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. - Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. - ## Mutation guidelines + - Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` - Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` ## Action guidelines + - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. - Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file. - `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`. - Never use `ctx.db` inside of an action. Actions don't have access to the database. - Below is an example of the syntax for an action: + ```ts import { action } from "./_generated/server"; export const exampleAction = action({ - args: {}, - handler: async (ctx, args) => { - console.log("This action does not return anything"); - return null; - }, + args: {}, + handler: async (ctx, args) => { + console.log("This action does not return anything"); + return null; + }, }); ``` ## Scheduling guidelines + ### Cron guidelines + - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. - Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. - Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, + ```ts import { cronJobs } from "convex/server"; import { internal } from "./_generated/api"; @@ -269,14 +304,16 @@ crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); export default crons; ``` -- You can register Convex functions within `crons.ts` just like any other file. -- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file. +- You can register Convex functions within `crons.ts` just like any other file. +- If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file. ## Testing guidelines + - Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`. Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`: + ```typescript /// import { convexTest } from "convex-test"; @@ -293,13 +330,16 @@ test("some behavior", async () => { expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]); }); ``` + The `modules` argument is required so convex-test can discover and load function files. The `/// ` directive is needed for TypeScript to recognize `import.meta.glob`. ## File storage guidelines + - The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. - Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. + ``` import { query } from "./_generated/server"; import { Id } from "./_generated/dataModel"; @@ -321,6 +361,5 @@ export const exampleQuery = query({ }, }); ``` -- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. - +- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. diff --git a/convex/ops.ts b/convex/ops.ts index 9f739522..dde4e386 100644 --- a/convex/ops.ts +++ b/convex/ops.ts @@ -1,5 +1,6 @@ import { mutation } from "./_generated/server"; import { v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole } from "./lib/auth"; import { @@ -82,6 +83,7 @@ export const applyBatch = mutation({ let appliedRevision: number | undefined; let latestRevision: number | undefined; let latestPayload: string | undefined; + let eventPageId: Id<"pages"> | undefined; try { if ( @@ -137,6 +139,7 @@ export const applyBatch = mutation({ if (existingPage.strategyId !== strategy._id) { throw new Error("Page strategy mismatch"); } + eventPageId = existingPage._id; await ctx.db.patch(existingPage._id, { name: @@ -157,7 +160,7 @@ export const applyBatch = mutation({ }); appliedRevision = existingPage.revision + 1; } else { - await ctx.db.insert("pages", { + const insertedPageId = await ctx.db.insert("pages", { publicId: pagePublicId, strategyId: strategy._id, name: typeof payload.name === "string" ? payload.name : "Page", @@ -171,6 +174,7 @@ export const applyBatch = mutation({ createdAt: now, updatedAt: now, }); + eventPageId = insertedPageId; appliedRevision = 1; } @@ -184,6 +188,7 @@ export const applyBatch = mutation({ if (page.strategyId !== strategy._id) { throw new Error("Page strategy mismatch"); } + eventPageId = page._id; latestRevision = page.revision; @@ -253,6 +258,7 @@ export const applyBatch = mutation({ if (page.strategyId !== strategy._id) { throw new Error("Page strategy mismatch"); } + eventPageId = page._id; const payload = parsePayload(op.payload); const elementType = typeof payload.elementType === "string" @@ -302,6 +308,7 @@ export const applyBatch = mutation({ if (element.strategyId !== strategy._id) { throw new Error("Element strategy mismatch"); } + eventPageId = element.pageId; latestRevision = element.revision; latestPayload = element.payload; @@ -337,6 +344,7 @@ export const applyBatch = mutation({ throw new Error("Page strategy mismatch"); } patch.pageId = page._id; + eventPageId = page._id; } await ctx.db.patch(element._id, patch); @@ -365,6 +373,7 @@ export const applyBatch = mutation({ if (page.strategyId !== strategy._id) { throw new Error("Page strategy mismatch"); } + eventPageId = page._id; const now = Date.now(); const existingLineup = await ctx.db .query("lineups") @@ -407,6 +416,7 @@ export const applyBatch = mutation({ if (lineup.strategyId !== strategy._id) { throw new Error("Lineup strategy mismatch"); } + eventPageId = lineup.pageId; latestRevision = lineup.revision; latestPayload = lineup.payload; @@ -442,6 +452,7 @@ export const applyBatch = mutation({ throw new Error("Page strategy mismatch"); } patch.pageId = page._id; + eventPageId = page._id; } await ctx.db.patch(lineup._id, patch); appliedRevision = lineup.revision + 1; @@ -468,7 +479,7 @@ export const applyBatch = mutation({ await ctx.db.insert("operationEvents", { strategyId: strategy._id, - pageId: undefined, + pageId: eventPageId, clientId: args.clientId, opId: op.opId, opType: `${op.entityType}.${op.kind}`, @@ -503,4 +514,3 @@ export const applyBatch = mutation({ }, }); - diff --git a/lib/providers/action_provider.dart b/lib/providers/action_provider.dart index b7fc222a..f8e957d1 100644 --- a/lib/providers/action_provider.dart +++ b/lib/providers/action_provider.dart @@ -12,7 +12,6 @@ import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/map_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/text_widget_height_provider.dart'; @@ -99,7 +98,8 @@ class BulkActionSnapshot { BulkActionSnapshot copy() { return BulkActionSnapshot( targetGroups: [...targetGroups], - actionStateBefore: actionStateBefore.map((action) => action.copy()).toList(), + actionStateBefore: + actionStateBefore.map((action) => action.copy()).toList(), redoStateBefore: redoStateBefore.map((action) => action.copy()).toList(), agentSnapshot: agentSnapshot == null ? null @@ -131,7 +131,8 @@ class BulkActionSnapshot { updateCounter: drawingSnapshot!.state.updateCounter, currentElement: drawingSnapshot!.state.currentElement == null ? null - : cloneDrawingElement(drawingSnapshot!.state.currentElement!), + : cloneDrawingElement( + drawingSnapshot!.state.currentElement!), ), poppedElements: drawingSnapshot!.poppedElements .map((element) => cloneDrawingElement(element)) @@ -140,8 +141,9 @@ class BulkActionSnapshot { textSnapshot: textSnapshot == null ? null : TextProviderSnapshot( - texts: - textSnapshot!.texts.map((text) => clonePlacedText(text)).toList(), + texts: textSnapshot!.texts + .map((text) => clonePlacedText(text)) + .toList(), poppedText: textSnapshot!.poppedText .map((text) => clonePlacedText(text)) .toList(), @@ -184,8 +186,9 @@ class BulkActionSnapshot { BulkActionSnapshot switchSides(ActionHistoryTransformContext context) { return BulkActionSnapshot( targetGroups: [...targetGroups], - actionStateBefore: - actionStateBefore.map((action) => action.switchSides(context)).toList(), + actionStateBefore: actionStateBefore + .map((action) => action.switchSides(context)) + .toList(), redoStateBefore: redoStateBefore.map((action) => action.switchSides(context)).toList(), agentSnapshot: agentSnapshot == null @@ -193,14 +196,14 @@ class BulkActionSnapshot { : AgentProviderSnapshot( agents: agentSnapshot!.agents .map( - (agent) => - clonePlacedAgentNode(agent)..switchSides(context.agentSize), + (agent) => clonePlacedAgentNode(agent) + ..switchSides(context.agentSize), ) .toList(), poppedAgents: agentSnapshot!.poppedAgents .map( - (agent) => - clonePlacedAgentNode(agent)..switchSides(context.agentSize), + (agent) => clonePlacedAgentNode(agent) + ..switchSides(context.agentSize), ) .toList(), ), @@ -270,13 +273,15 @@ class BulkActionSnapshot { images: imageSnapshot!.images .map( (image) => clonePlacedImage(image) - ..switchSides(context.imageSizes[image.id] ?? Offset.zero), + ..switchSides( + context.imageSizes[image.id] ?? Offset.zero), ) .toList(), poppedImages: imageSnapshot!.poppedImages .map( (image) => clonePlacedImage(image) - ..switchSides(context.imageSizes[image.id] ?? Offset.zero), + ..switchSides( + context.imageSizes[image.id] ?? Offset.zero), ) .toList(), ), @@ -409,7 +414,6 @@ class ActionProvider extends Notifier> { if (_recordingDisabled) { return; } - ref.read(strategyProvider.notifier).setUnsaved(); if (action.group != ActionGroup.ability) { ref .read(abilityBarProvider.notifier) @@ -458,9 +462,6 @@ class ActionProvider extends Notifier> { final newState = [...state]; newState.add(poppedItems.removeLast()); - - ref.read(strategyProvider.notifier).setUnsaved(); - state = newState; // log("\n Current state \n ${state.toString()}"); } @@ -500,9 +501,6 @@ class ActionProvider extends Notifier> { // log("Undo action was called"); final newState = [...state]; poppedItems.add(newState.removeLast()); - - ref.read(strategyProvider.notifier).setUnsaved(); - state = newState; // log("\n Current state \n ${state.toString()}"); @@ -522,15 +520,11 @@ class ActionProvider extends Notifier> { ref.read(imageWidgetSizeProvider.notifier).clearAll(); ref.read(textWidgetHeightProvider.notifier).clearAll(); - ref.read(strategyProvider.notifier).setUnsaved(); state = []; } - void clearActionHistory({bool markUnsaved = false}) { + void clearActionHistory() { poppedItems = []; - if (markUnsaved) { - ref.read(strategyProvider.notifier).setUnsaved(); - } state = []; } @@ -549,7 +543,8 @@ class ActionProvider extends Notifier> { textHeights: Map.from(ref.read(textWidgetHeightProvider)), ); state = state.map((action) => action.switchSides(context)).toList(); - poppedItems = poppedItems.map((action) => action.switchSides(context)).toList(); + poppedItems = + poppedItems.map((action) => action.switchSides(context)).toList(); } void clearAllAsAction() { @@ -813,7 +808,6 @@ class ActionProvider extends Notifier> { _restoreBulkSnapshot(snapshot); poppedItems.add(action); - ref.read(strategyProvider.notifier).setUnsaved(); state = snapshot.actionStateBefore.map((item) => item.copy()).toList(); } @@ -827,7 +821,6 @@ class ActionProvider extends Notifier> { final newState = _filterActionsForGroups(state, snapshot.targetGroups) ..add(poppedItems.removeLast()); - ref.read(strategyProvider.notifier).setUnsaved(); ref.read(abilityBarProvider.notifier).updateData(null); state = newState; } @@ -839,7 +832,6 @@ class ActionProvider extends Notifier> { _restoreBulkSnapshot(snapshot.before); final newState = [...state]; poppedItems.add(newState.removeLast()); - ref.read(strategyProvider.notifier).setUnsaved(); state = newState; } @@ -850,7 +842,6 @@ class ActionProvider extends Notifier> { _restoreBulkSnapshot(snapshot.after); final newState = [...state]; newState.add(poppedItems.removeLast()); - ref.read(strategyProvider.notifier).setUnsaved(); ref.read(abilityBarProvider.notifier).updateData(null); state = newState; } @@ -869,7 +860,8 @@ class ActionProvider extends Notifier> { } bool _canKeepEditAction(ObjectHistoryDelta delta) { - final current = _currentObjectState(delta.id, delta.before?.kind ?? delta.after?.kind); + final current = + _currentObjectState(delta.id, delta.before?.kind ?? delta.after?.kind); if (current == null) { return false; } @@ -891,9 +883,11 @@ class ActionProvider extends Notifier> { if (index < 0) return null; return ActionObjectState.ability(ref.read(abilityProvider)[index]); case ActionObjectKind.drawing: - final index = DrawingElement.getIndexByID(id, ref.read(drawingProvider).elements); + final index = + DrawingElement.getIndexByID(id, ref.read(drawingProvider).elements); if (index < 0) return null; - return ActionObjectState.drawing(ref.read(drawingProvider).elements[index]); + return ActionObjectState.drawing( + ref.read(drawingProvider).elements[index]); case ActionObjectKind.text: final index = PlacedWidget.getIndexByID(id, ref.read(textProvider)); if (index < 0) return null; diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index b29584fe..f4a5a27c 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -10,7 +10,6 @@ import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; final mapProvider = NotifierProvider(MapProvider.new); @@ -54,7 +53,6 @@ class MapProvider extends Notifier { void updateMap(MapValue map) { state = state.copyWith(currentMap: map); - ref.read(strategyProvider.notifier).setUnsaved(); } void fromHive(MapValue map, bool isAttack) { @@ -84,12 +82,10 @@ class MapProvider extends Notifier { ref.read(placedImageProvider.notifier).switchSides(); ref.read(actionProvider.notifier).switchSides(); state = state.copyWith(isAttack: !state.isAttack); - ref.read(strategyProvider.notifier).setUnsaved(); } void setAttack(bool isAttack) { state = state.copyWith(isAttack: isAttack); - ref.read(strategyProvider.notifier).setUnsaved(); } String toJson() { @@ -105,5 +101,3 @@ class MapProvider extends Notifier { return mapValue; } } - - diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 3ed07604..3329ddf0 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -49,13 +49,14 @@ final strategyProvider = class StrategyProvider extends Notifier { @override StrategyState build() { + _registerPersistenceTrackingListeners(); ref.listen(authProvider, (previous, next) { final strategyId = state.strategyId; if (state.source != StrategySource.cloud || strategyId == null) { return; } - final becameReady = !(previous?.isConvexUserReady ?? false) && - next.isConvexUserReady; + final becameReady = + !(previous?.isConvexUserReady ?? false) && next.isConvexUserReady; if (!becameReady) { return; } @@ -79,6 +80,61 @@ class StrategyProvider extends Notifier { bool _saveInProgress = false; bool _pendingSave = false; + bool _cloudMutationSyncScheduled = false; + int _persistenceTrackingSuspensionCount = 0; + + void _registerPersistenceTrackingListeners() { + _listenForPersistedEditorState(agentProvider); + _listenForPersistedEditorState(abilityProvider); + _listenForPersistedEditorState( + drawingProvider.select((drawing) => drawing.elements), + ); + _listenForPersistedEditorState(textProvider); + _listenForPersistedEditorState( + placedImageProvider.select((images) => images.images), + ); + _listenForPersistedEditorState(utilityProvider); + _listenForPersistedEditorState( + lineUpProvider.select((lineups) => lineups.lineUps), + ); + _listenForPersistedEditorState(strategySettingsProvider); + _listenForPersistedEditorState( + mapProvider.select( + (map) => ( + currentMap: map.currentMap, + isAttack: map.isAttack, + ), + ), + ); + } + + void _listenForPersistedEditorState(ProviderListenable provider) { + ref.listen(provider, (_, __) { + if (!_shouldTrackPersistedEditorMutation()) { + return; + } + setUnsaved(); + }); + } + + bool _shouldTrackPersistedEditorMutation() { + if (_persistenceTrackingSuspensionCount > 0) { + return false; + } + if (!state.isOpen || state.strategyId == null || state.source == null) { + return false; + } + return !ref.read(strategyPageSessionProvider).isApplyingPage; + } + + T _withoutPersistenceTracking(T Function() callback) { + _persistenceTrackingSuspensionCount += 1; + try { + return callback(); + } finally { + _persistenceTrackingSuspensionCount -= 1; + } + } //Used For Images void setFromState(StrategyState newState) { @@ -167,8 +223,9 @@ class StrategyProvider extends Notifier { return; } - final storageDirectory = - kIsWeb ? null : (await setStorageDirectory(snapshot.header.publicId)).path; + final storageDirectory = kIsWeb + ? null + : (await setStorageDirectory(snapshot.header.publicId)).path; state = state.copyWith( strategyId: snapshot.header.publicId, strategyName: snapshot.header.name, @@ -218,6 +275,7 @@ class StrategyProvider extends Notifier { } Future notifyCloudMutation({bool flushImmediately = false}) async { + _cloudMutationSyncScheduled = false; if (!_currentStrategyIsCloud()) { return; } @@ -231,7 +289,23 @@ class StrategyProvider extends Notifier { .flushCurrentPage(flushImmediately: flushImmediately); } - void setUnsaved() async { + void _scheduleCloudMutationSync() { + if (_cloudMutationSyncScheduled) { + return; + } + _cloudMutationSyncScheduled = true; + scheduleMicrotask(() async { + if (!_cloudMutationSyncScheduled) { + return; + } + await notifyCloudMutation(flushImmediately: false); + }); + } + + void setUnsaved() { + if (!state.isOpen || state.strategyId == null || state.source == null) { + return; + } if (ref.read(strategyPageSessionProvider).isApplyingPage) { return; } @@ -241,7 +315,7 @@ class StrategyProvider extends Notifier { ..markDirty() ..setPendingCloudSync(true) ..setCloudSyncError(null); - unawaited(notifyCloudMutation(flushImmediately: false)); + _scheduleCloudMutationSync(); return; } @@ -629,7 +703,9 @@ class StrategyProvider extends Notifier { if (newStrat == null) { return; } - ref.read(actionProvider.notifier).resetActionState(); + _withoutPersistenceTracking(() { + ref.read(actionProvider.notifier).resetActionState(); + }); List pageImageData = []; for (final page in newStrat.pages) { diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index f14387e0..1435b6fe 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -245,6 +245,12 @@ class _StrategyQuickSwitcherState extends ConsumerState { @override Widget build(BuildContext context) { final currentStrategy = ref.watch(strategyProvider); + assert( + currentStrategy.strategyId != null, + 'StrategyQuickSwitcher requires an active strategy with a non-null ' + 'strategyId.', + ); + final currentStrategyId = currentStrategy.strategyId!; final strategyName = currentStrategy.strategyName ?? 'Untitled Strategy'; final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); @@ -257,7 +263,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { builder: (context, box, _) { final recents = _recentStrategies( box: box, - currentStrategyId: currentStrategy.strategyId!, + currentStrategyId: currentStrategyId, ); return OverlayPortal( diff --git a/pubspec.lock b/pubspec.lock index 93be18dd..8d762ad5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -833,18 +833,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1318,10 +1318,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" theme_extensions_builder_annotation: dependency: transitive description: diff --git a/skills-lock.json b/skills-lock.json index 9f627f87..bf33b964 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,30 +1,35 @@ { "version": 1, "skills": { + "convex": { + "source": "get-convex/agent-skills", + "sourceType": "github", + "computedHash": "613ee9955985085d0fca8f96e1fc6d7cfd204dffa203499a1d508b8def76577b" + }, "convex-create-component": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "c97d71b524ea9f784d344af20a766de5d48f14b7b824bf791fa517d1da753aca" + "computedHash": "d110fca7f65b4919367e6fc63a93bf54abea2cf5e4e097234c947559ffa6e527" }, "convex-migration-helper": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "bfb51e5b743ac31b313e5e1d206faee7d46bbbc9195360ce9636eead6f13fd3a" + "computedHash": "46d1ac354eefbed05e1367d828e893816c13302276080bfaf6bcd828281be486" }, "convex-performance-audit": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "3e411d8250678ca8ec739a40c2874f21e4b5ab17c4dd65e72e3d9d9df6851c31" + "computedHash": "30ea0d3c259df011e44ea9b70502ab272f5ac3bd1fb3672ae18489ba99b2c4ae" }, "convex-quickstart": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "7879afbb2b954be36ef29d294f3b6c2851cad54e9f5e5ac90461f0f97d4d1fff" + "computedHash": "8ae9e1b02f526ea65e7895fac82af74142cd8e70e364d9dae9dbf79a296fb5ef" }, "convex-setup-auth": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "92a5c170cf238f45f08439d9d5d7947af490b880ee8f15306183240483cbed02" + "computedHash": "e719d31d1ab0d19ca7b942d1154d3ff436b5c156900eea9866c2aaeb910a1388" } } } From 23bae8b9ddd797d4103aba776ee046c79a6ffdad Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 17 Apr 2026 02:35:22 -0400 Subject: [PATCH 07/15] Centralize cloud sync by state type --- .../collab/strategy_op_queue_provider.dart | 88 ++++++++++- lib/providers/strategy_provider.dart | 143 +++++++++++++++--- 2 files changed, 204 insertions(+), 27 deletions(-) diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 61658364..aaddec92 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -163,8 +163,8 @@ class StrategyOpQueueNotifier extends Notifier { ); continue; } - opsByPage.putIfAbsent(pageId, () => {})[entityKey] = - op; + opsByPage.putIfAbsent( + pageId, () => {})[entityKey] = op; } if (!mapEquals(genericQueued, state.queuedByEntityKey)) { @@ -185,6 +185,75 @@ class StrategyOpQueueNotifier extends Notifier { _scheduleFlush(flushImmediately: flushImmediately); } + void 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); + } + void syncDesiredOpsForPage({ required String pageId, required Map desiredOpsByEntityKey, @@ -223,7 +292,8 @@ class StrategyOpQueueNotifier extends Notifier { continue; } - if (existingQueued != null && _sameIntent(existingQueued.pending.op, desired)) { + if (existingQueued != null && + _sameIntent(existingQueued.pending.op, desired)) { continue; } @@ -307,12 +377,14 @@ class StrategyOpQueueNotifier extends Notifier { ? 'Cloud user setup is not ready.' : 'Cloud connection is offline.'), ); - _scheduleRetry(incremented.values.map((intent) => intent.pending).toList()); + _scheduleRetry( + incremented.values.map((intent) => intent.pending).toList()); return; } final batch = state.queuedByEntityKey.values - .where((intent) => !state.inFlightByEntityKey.containsKey(intent.entityKey)) + .where((intent) => + !state.inFlightByEntityKey.containsKey(intent.entityKey)) .take(_maxBatchSize) .toList(growable: false); if (batch.isEmpty) { @@ -335,7 +407,8 @@ class StrategyOpQueueNotifier extends Notifier { sentAt: sentAt, ); batchByOpId[intent.pending.op.opId] = intent; - _debugLog('inflight.send ${intent.entityKey} op=${intent.pending.op.opId}'); + _debugLog( + 'inflight.send ${intent.entityKey} op=${intent.pending.op.opId}'); } state = state.copyWith( @@ -500,7 +573,8 @@ class StrategyOpQueueNotifier extends Notifier { return null; } - if (existing.kind == StrategyOpKind.add && desired.kind == StrategyOpKind.patch) { + if (existing.kind == StrategyOpKind.add && + desired.kind == StrategyOpKind.patch) { return StrategyOp( opId: existing.opId, kind: StrategyOpKind.add, diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 3329ddf0..9a56273c 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -81,34 +81,31 @@ class StrategyProvider extends Notifier { bool _saveInProgress = false; bool _pendingSave = false; bool _cloudMutationSyncScheduled = false; + bool _cloudStrategyMutationSyncScheduled = false; int _persistenceTrackingSuspensionCount = 0; void _registerPersistenceTrackingListeners() { - _listenForPersistedEditorState(agentProvider); - _listenForPersistedEditorState(abilityProvider); - _listenForPersistedEditorState( + _listenForPageBackedState(agentProvider); + _listenForPageBackedState(abilityProvider); + _listenForPageBackedState( drawingProvider.select((drawing) => drawing.elements), ); - _listenForPersistedEditorState(textProvider); - _listenForPersistedEditorState( + _listenForPageBackedState(textProvider); + _listenForPageBackedState( placedImageProvider.select((images) => images.images), ); - _listenForPersistedEditorState(utilityProvider); - _listenForPersistedEditorState( + _listenForPageBackedState(utilityProvider); + _listenForPageBackedState( lineUpProvider.select((lineups) => lineups.lineUps), ); - _listenForPersistedEditorState(strategySettingsProvider); - _listenForPersistedEditorState( - mapProvider.select( - (map) => ( - currentMap: map.currentMap, - isAttack: map.isAttack, - ), - ), - ); + _listenForPageBackedState(strategySettingsProvider); + _listenForPageBackedState(mapProvider.select((map) => map.isAttack)); + + _listenForStrategyBackedState(mapProvider.select((map) => map.currentMap)); + _listenForStrategyBackedState(strategyThemeProvider); } - void _listenForPersistedEditorState(ProviderListenable provider) { + void _listenForPageBackedState(ProviderListenable provider) { ref.listen(provider, (_, __) { if (!_shouldTrackPersistedEditorMutation()) { return; @@ -117,6 +114,15 @@ class StrategyProvider extends Notifier { }); } + void _listenForStrategyBackedState(ProviderListenable provider) { + ref.listen(provider, (_, __) { + if (!_shouldTrackPersistedEditorMutation()) { + return; + } + _markStrategyBackedStateUnsaved(); + }); + } + bool _shouldTrackPersistedEditorMutation() { if (_persistenceTrackingSuspensionCount > 0) { return false; @@ -289,6 +295,30 @@ class StrategyProvider extends Notifier { .flushCurrentPage(flushImmediately: flushImmediately); } + Future notifyCloudStrategyMutation({ + bool flushImmediately = false, + }) async { + _cloudStrategyMutationSyncScheduled = false; + if (!_currentStrategyIsCloud()) { + return; + } + + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + + final desiredOp = _buildDesiredStrategySyncOp(); + ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( + entityKey: 'strategy', + desiredOp: desiredOp, + flushImmediately: flushImmediately, + ); + if (flushImmediately) { + await ref.read(strategyOpQueueProvider.notifier).flushNow(); + } + } + void _scheduleCloudMutationSync() { if (_cloudMutationSyncScheduled) { return; @@ -302,6 +332,81 @@ class StrategyProvider extends Notifier { }); } + void _scheduleCloudStrategySync() { + if (_cloudStrategyMutationSyncScheduled) { + return; + } + _cloudStrategyMutationSyncScheduled = true; + scheduleMicrotask(() async { + if (!_cloudStrategyMutationSyncScheduled) { + return; + } + await notifyCloudStrategyMutation(flushImmediately: false); + }); + } + + void _markStrategyBackedStateUnsaved() { + if (!state.isOpen || state.strategyId == null || state.source == null) { + return; + } + if (ref.read(strategyPageSessionProvider).isApplyingPage) { + return; + } + + if (_currentStrategyIsCloud()) { + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + _scheduleCloudStrategySync(); + return; + } + + ref.read(strategySaveStateProvider.notifier).markDirty(); + refreshAutosaveScheduling(); + } + + StrategyOp? _buildDesiredStrategySyncOp() { + final strategyId = state.strategyId; + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (strategyId == null || + snapshot == null || + snapshot.header.publicId != strategyId) { + return null; + } + + final strategyTheme = ref.read(strategyThemeProvider); + final localMapData = Maps.mapNames[ref.read(mapProvider).currentMap] ?? + snapshot.header.mapData; + final localThemeProfileId = strategyTheme.profileId; + final localThemeOverridePalette = strategyTheme.overridePalette == null + ? null + : jsonEncode(strategyTheme.overridePalette!.toJson()); + + final matchesRemote = snapshot.header.mapData == localMapData && + snapshot.header.themeProfileId == localThemeProfileId && + snapshot.header.themeOverridePalette == localThemeOverridePalette; + if (matchesRemote) { + return null; + } + + return StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.strategy, + entityPublicId: strategyId, + payload: jsonEncode({ + 'mapData': localMapData, + if (localThemeProfileId != null) 'themeProfileId': localThemeProfileId, + if (localThemeProfileId == null) 'clearThemeProfileId': true, + if (localThemeOverridePalette != null) + 'themeOverridePalette': localThemeOverridePalette, + if (localThemeOverridePalette == null) + 'clearThemeOverridePalette': true, + }), + ); + } + void setUnsaved() { if (!state.isOpen || state.strategyId == null || state.source == null) { return; @@ -345,6 +450,7 @@ class StrategyProvider extends Notifier { ref.read(autoSaveProvider.notifier).ping(); // UI: Saving... ref.read(strategySaveStateProvider.notifier).markSaving(true); if (_currentStrategyIsCloud()) { + await notifyCloudStrategyMutation(flushImmediately: true); await ref .read(strategyPageSessionProvider.notifier) .flushCurrentPage(flushImmediately: true); @@ -831,17 +937,14 @@ class StrategyProvider extends Notifier { void setThemeProfileForCurrentStrategy(String profileId) { ref.read(strategyThemeProvider.notifier).setProfile(profileId); - setUnsaved(); } void setThemeOverrideForCurrentStrategy(MapThemePalette palette) { ref.read(strategyThemeProvider.notifier).setOverride(palette); - setUnsaved(); } void clearThemeOverrideForCurrentStrategy() { ref.read(strategyThemeProvider.notifier).clearOverride(); - setUnsaved(); } Future renameStrategy( From bdcc059654389360ba0878c3aedb08613d102cfc Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 17 Apr 2026 03:59:34 -0400 Subject: [PATCH 08/15] Pass strategy IDs into image saves --- lib/providers/image_provider.dart | 25 +++++++++++-------- lib/strategy_view.dart | 2 +- lib/widgets/dialogs/create_lineup_dialog.dart | 16 ++++++++++-- lib/widgets/image_drop_target.dart | 4 +++ lib/widgets/sidebar_widgets/tool_grid.dart | 4 +++ lib/widgets/strategy_quick_switcher.dart | 10 +++----- 6 files changed, 41 insertions(+), 20 deletions(-) diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 06561fa9..2a197c4d 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -15,7 +15,6 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; @@ -134,15 +133,20 @@ class PlacedImageProvider extends Notifier { Future addImage( {required Uint8List imageBytes, + required String? strategyId, + required StrategySource? strategySource, required String fileExtension, Offset? position, double? aspectRatio, int? tagColorValue}) async { final imageID = const Uuid().v4(); - await ref - .read(placedImageProvider.notifier) - .saveSecureImage(imageBytes, imageID, fileExtension); + await saveSecureImage( + imageBytes, + imageID, + fileExtension, + strategyId: strategyId, + ); final effectiveAspectRatio = aspectRatio ?? await getImageAspectRatio(imageBytes); @@ -171,15 +175,14 @@ class PlacedImageProvider extends Notifier { state = state.copyWith(images: [...state.images, placedImage]); - final strategyState = ref.read(strategyProvider); final pagePublicId = ref.read(strategyPageSessionProvider).activePageId; - if (strategyState.source == StrategySource.cloud && - strategyState.strategyId != null && + if (strategySource == StrategySource.cloud && + strategyId != null && pagePublicId != null) { await ref .read(cloudMediaUploadQueueProvider.notifier) .enqueuePlacedImageUpload( - strategyPublicId: strategyState.strategyId!, + strategyPublicId: strategyId, pagePublicId: pagePublicId, imagePublicId: placedImage.id, fileExtension: fileExtension, @@ -485,12 +488,12 @@ class PlacedImageProvider extends Notifier { Uint8List imageBytes, String imageID, String fileExtenstion, + {required String? strategyId} ) async { - final strategyID = ref.read(strategyProvider).strategyId; - if (strategyID == null) return; + if (strategyId == null) return; await writeImageBytes( imageBytes: imageBytes, - strategyID: strategyID, + strategyID: strategyId, imageID: imageID, fileExtension: fileExtenstion, ); diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index dbcdc06b..dc9d56f8 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -69,10 +69,10 @@ class _StrategyViewState extends ConsumerState .read(agentFilterProvider.notifier) .updateFilterState(FilterState.all); ref.read(deleteMenuProvider.notifier).requestClose(); - await ref.read(strategyProvider.notifier).clearCurrentStrategy(); if (mounted) { Navigator.pop(context); } + await ref.read(strategyProvider.notifier).clearCurrentStrategy(); }, ); } diff --git a/lib/widgets/dialogs/create_lineup_dialog.dart b/lib/widgets/dialogs/create_lineup_dialog.dart index db21d60d..4a320d96 100644 --- a/lib/widgets/dialogs/create_lineup_dialog.dart +++ b/lib/widgets/dialogs/create_lineup_dialog.dart @@ -176,13 +176,19 @@ class _CreateLineupDialogState extends ConsumerState { final String fileExtension = path.extension(imageFile.path); final Uint8List imageBytes = await imageFile.readAsBytes(); final id = const Uuid().v4(); + final strategyId = ref.read(strategyProvider).strategyId; final SimpleImageData imageData = SimpleImageData(id: id, fileExtension: fileExtension); await ref .read(placedImageProvider.notifier) - .saveSecureImage(imageBytes, id, fileExtension); + .saveSecureImage( + imageBytes, + id, + fileExtension, + strategyId: strategyId, + ); setState(() { _imagePaths.add(imageData); @@ -211,12 +217,18 @@ class _CreateLineupDialogState extends ConsumerState { } final id = const Uuid().v4(); + final strategyId = ref.read(strategyProvider).strategyId; final SimpleImageData imageData = SimpleImageData(id: id, fileExtension: fileExtension); await ref .read(placedImageProvider.notifier) - .saveSecureImage(bytes, id, fileExtension); + .saveSecureImage( + bytes, + id, + fileExtension, + strategyId: strategyId, + ); setState(() { _imagePaths.add(imageData); diff --git a/lib/widgets/image_drop_target.dart b/lib/widgets/image_drop_target.dart index 7bcc9009..f68aeeac 100644 --- a/lib/widgets/image_drop_target.dart +++ b/lib/widgets/image_drop_target.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; class ImageDropTarget extends ConsumerStatefulWidget { const ImageDropTarget({super.key, required this.child}); @@ -47,8 +48,11 @@ class _ImageDropTargetState extends ConsumerState { if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] .contains(rawExtension)) { final fileExtension = '.$rawExtension'; + final strategyState = ref.read(strategyProvider); await ref.read(placedImageProvider.notifier).addImage( imageBytes: await file.readAsBytes(), + strategyId: strategyState.strategyId, + strategySource: strategyState.source, fileExtension: fileExtension, ); } diff --git a/lib/widgets/sidebar_widgets/tool_grid.dart b/lib/widgets/sidebar_widgets/tool_grid.dart index d858e486..ef70dbb2 100644 --- a/lib/widgets/sidebar_widgets/tool_grid.dart +++ b/lib/widgets/sidebar_widgets/tool_grid.dart @@ -12,6 +12,7 @@ import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/pen_provider.dart'; import 'package:icarus/providers/placement_center_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/dialogs/upload_image_dialog.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; @@ -220,6 +221,7 @@ class ToolGrid extends ConsumerWidget { final aspectRatio = await ref .read(placedImageProvider.notifier) .getImageAspectRatio(imageBytes); + final strategyState = ref.read(strategyProvider); final placementCenter = ref.read(placementCenterProvider); final imageHeight = _defaultImageSpawnWidth / aspectRatio; final centeredTopLeft = @@ -231,6 +233,8 @@ class ToolGrid extends ConsumerWidget { ref.read(placedImageProvider.notifier).addImage( imageBytes: imageBytes, + strategyId: strategyState.strategyId, + strategySource: strategyState.source, fileExtension: fileExtension, aspectRatio: aspectRatio, position: centeredTopLeft, diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index 1435b6fe..3f60d3c0 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -245,12 +245,10 @@ class _StrategyQuickSwitcherState extends ConsumerState { @override Widget build(BuildContext context) { final currentStrategy = ref.watch(strategyProvider); - assert( - currentStrategy.strategyId != null, - 'StrategyQuickSwitcher requires an active strategy with a non-null ' - 'strategyId.', - ); - final currentStrategyId = currentStrategy.strategyId!; + final currentStrategyId = currentStrategy.strategyId; + if (currentStrategyId == null) { + return const SizedBox.shrink(); + } final strategyName = currentStrategy.strategyName ?? 'Untitled Strategy'; final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); From ce87df7e7f54996fcb1548b9e69617fee244e62c Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 17 Apr 2026 12:23:02 -0400 Subject: [PATCH 09/15] Refactor image assets to durable asset IDs --- convex/images.ts | 286 +++++++----------- convex/ops.ts | 3 - convex/pages.ts | 3 - convex/schema.ts | 23 +- convex/strategies.ts | 4 - lib/collab/cloud_media_models.dart | 28 +- lib/collab/collab_models.dart | 22 +- lib/collab/convex_strategy_repository.dart | 15 +- lib/hive/hive_adapters.dart | 1 - lib/hive/hive_adapters.g.dart | 48 +-- lib/hive/hive_adapters.g.yaml | 14 - lib/hive/hive_registrar.g.dart | 2 - .../cloud_media_upload_queue_provider.dart | 75 +---- lib/providers/image_provider.dart | 19 +- lib/widgets/dialogs/create_lineup_dialog.dart | 25 +- 15 files changed, 157 insertions(+), 411 deletions(-) diff --git a/convex/images.ts b/convex/images.ts index 89aad317..db72a8d4 100644 --- a/convex/images.ts +++ b/convex/images.ts @@ -1,39 +1,10 @@ -import type { Doc, Id } from "./_generated/dataModel"; +import type { Doc } from "./_generated/dataModel"; import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"; -import { ConvexError, v } from "convex/values"; -import { assertStrategyRole, requireCurrentUser } from "./lib/auth"; -import { - getElementByPublicId, - getLineupByPublicId, - getPageByPublicId, - getStrategyByPublicId, -} from "./lib/entities"; +import { v } from "convex/values"; +import { assertStrategyRole } from "./lib/auth"; +import { getStrategyByPublicId } from "./lib/entities"; type AnyCtx = MutationCtx | QueryCtx; -type ImageAssetOwnerType = "element" | "lineup"; - -function normalizeOwnerType( - asset: Pick, "ownerType">, -): ImageAssetOwnerType { - return asset.ownerType ?? "element"; -} - -function ownerNotFoundError( - ownerType: ImageAssetOwnerType, - ownerPublicId: string, -): ConvexError<{ - code: "OWNER_NOT_FOUND"; - message: "owner_not_found"; - ownerType: ImageAssetOwnerType; - ownerPublicId: string; -}> { - return new ConvexError({ - code: "OWNER_NOT_FOUND", - message: "owner_not_found", - ownerType, - ownerPublicId, - }); -} async function getImageAssetByPublicId( ctx: AnyCtx, @@ -57,103 +28,113 @@ function inferFileExtension( return match?.[1]?.toLowerCase() ?? ""; } -async function resolveOwnerAttachment( - ctx: MutationCtx, - args: { - strategyId: Id<"strategies">; - pageId: Id<"pages">; - ownerType: ImageAssetOwnerType; - ownerPublicId: string; - }, -): Promise<{ - elementId: Id<"elements"> | undefined; - lineupId: Id<"lineups"> | undefined; -}> { - if (args.ownerType === "element") { - try { - const element = await getElementByPublicId(ctx, args.ownerPublicId); - if (element.strategyId !== args.strategyId || element.pageId !== args.pageId) { - throw new Error("Element context mismatch"); - } - return { - elementId: element._id, - lineupId: undefined, - }; - } catch (error) { - if (error instanceof Error && error.message.startsWith("Element not found:")) { - throw ownerNotFoundError(args.ownerType, args.ownerPublicId); - } - throw error; +function decodeObject(payload: string): Record | null { + try { + const decoded = JSON.parse(payload); + if (typeof decoded === "object" && decoded !== null) { + return decoded as Record; } + } catch (_) { + // Ignore malformed payloads while gathering asset references. } + return null; +} - try { - const lineup = await getLineupByPublicId(ctx, args.ownerPublicId); - if (lineup.strategyId !== args.strategyId || lineup.pageId !== args.pageId) { - throw new Error("Lineup context mismatch"); +function collectAssetIdFromElementPayload(payload: string): string | null { + const decoded = decodeObject(payload); + if (decoded === null) { + return null; + } + return typeof decoded.id === "string" ? decoded.id : null; +} + +function collectAssetIdsFromLineupPayload(payload: string): Set { + const assetIds = new Set(); + const decoded = decodeObject(payload); + if (decoded === null) { + return assetIds; + } + + const rawImages = decoded.images; + if (!Array.isArray(rawImages)) { + return assetIds; + } + + for (const image of rawImages) { + if (typeof image === "object" && image !== null && typeof image.id === "string") { + assetIds.add(image.id); } - return { - elementId: undefined, - lineupId: lineup._id, - }; - } catch (error) { - if (error instanceof Error && error.message.startsWith("Lineup not found:")) { - throw ownerNotFoundError(args.ownerType, args.ownerPublicId); + } + return assetIds; +} + +async function collectReferencedAssetIdsForStrategy( + ctx: AnyCtx, + strategyId: Doc<"strategies">["_id"], +): Promise> { + const assetIds = new Set(); + + const elements = await ctx.db + .query("elements") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .collect(); + for (const element of elements) { + if (element.deleted || element.elementType !== "image") { + continue; + } + + const assetId = collectAssetIdFromElementPayload(element.payload); + if (assetId !== null) { + assetIds.add(assetId); + } + } + + const lineups = await ctx.db + .query("lineups") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .collect(); + for (const lineup of lineups) { + if (lineup.deleted) { + continue; + } + + for (const assetId of collectAssetIdsFromLineupPayload(lineup.payload)) { + assetIds.add(assetId); } - throw error; } + + return assetIds; +} + +async function strategyReferencesAsset( + ctx: AnyCtx, + strategyId: Doc<"strategies">["_id"], + assetPublicId: string, +): Promise { + const referencedAssetIds = await collectReferencedAssetIdsForStrategy(ctx, strategyId); + return referencedAssetIds.has(assetPublicId); } async function serializeAssetForViewer( ctx: QueryCtx, - strategyPublicId: string, asset: Doc<"imageAssets">, ): Promise<{ publicId: string; - strategyPublicId: string; - pagePublicId: string; - ownerType: ImageAssetOwnerType; - ownerPublicId: string; fileExtension: string; - mimeType: string; + mimeType: string | null; width: number | null; height: number | null; url: string | null; legacyStoragePath: string | null; -} | null> { - const page = await ctx.db.get(asset.pageId); - if (page === null) { - return null; - } - - const ownerType = normalizeOwnerType(asset); - const ownerDoc = ownerType === "lineup" - ? asset.lineupId === undefined - ? null - : await ctx.db.get(asset.lineupId) - : asset.elementId === undefined - ? null - : await ctx.db.get(asset.elementId); - - if (ownerDoc === null) { - return null; - } - - const url = asset.storageId === undefined - ? null - : await ctx.storage.getUrl(asset.storageId); - +}> { return { publicId: asset.publicId, - strategyPublicId, - pagePublicId: page.publicId, - ownerType, - ownerPublicId: ownerDoc.publicId, fileExtension: inferFileExtension(asset), - mimeType: asset.mimeType, + mimeType: asset.mimeType ?? null, width: asset.width ?? null, height: asset.height ?? null, - url, + url: + asset.storageId === undefined ? null : await ctx.storage.getUrl(asset.storageId), legacyStoragePath: asset.storagePath ?? null, }; } @@ -168,20 +149,6 @@ export async function deleteImageAsset( await ctx.db.delete(asset._id); } -export async function deleteImageAssetsForPage( - ctx: MutationCtx, - pageId: Id<"pages">, -): Promise { - const assets = await ctx.db - .query("imageAssets") - .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) - .collect(); - - for (const asset of assets) { - await deleteImageAsset(ctx, asset); - } -} - export const generateUploadUrl = mutation({ args: { strategyPublicId: v.string(), @@ -199,62 +166,33 @@ export const generateUploadUrl = mutation({ export const completeUpload = mutation({ args: { strategyPublicId: v.string(), - pagePublicId: v.string(), assetPublicId: v.string(), - ownerType: v.union(v.literal("element"), v.literal("lineup")), - ownerPublicId: v.string(), storageId: v.id("_storage"), - mimeType: v.string(), - fileExtension: v.string(), + mimeType: v.optional(v.string()), + fileExtension: v.optional(v.string()), width: v.optional(v.number()), height: v.optional(v.number()), }, handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); - const { user } = await assertStrategyRole(ctx, strategy, "editor"); - const page = await getPageByPublicId(ctx, args.pagePublicId); - - if (page.strategyId !== strategy._id) { - throw new Error("Page strategy mismatch"); - } + await assertStrategyRole(ctx, strategy, "editor"); - const { elementId, lineupId } = await resolveOwnerAttachment(ctx, { - strategyId: strategy._id, - pageId: page._id, - ownerType: args.ownerType, - ownerPublicId: args.ownerPublicId, - }); const existing = await getImageAssetByPublicId(ctx, args.assetPublicId); const now = Date.now(); if (existing === null) { await ctx.db.insert("imageAssets", { publicId: args.assetPublicId, - strategyId: strategy._id, - pageId: page._id, - ownerType: args.ownerType, - elementId, - lineupId, storageId: args.storageId, fileExtension: args.fileExtension, mimeType: args.mimeType, width: args.width, height: args.height, - createdByUserId: user._id, createdAt: now, updatedAt: now, }); } else { - if (existing.strategyId !== strategy._id) { - throw new Error("Asset strategy mismatch"); - } - await ctx.db.patch(existing._id, { - strategyId: strategy._id, - pageId: page._id, - ownerType: args.ownerType, - elementId, - lineupId, storageId: args.storageId, fileExtension: args.fileExtension, mimeType: args.mimeType, @@ -276,16 +214,20 @@ export const listForStrategy = query({ const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "viewer"); - const assets = await ctx.db - .query("imageAssets") - .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) - .collect(); + const referencedAssetIds = await collectReferencedAssetIdsForStrategy(ctx, strategy._id); + const assets = await Promise.all( + [...referencedAssetIds].map((assetPublicId) => + getImageAssetByPublicId(ctx, assetPublicId), + ), + ); const serialized = await Promise.all( - assets.map((asset) => serializeAssetForViewer(ctx, strategy.publicId, asset)), + assets + .filter((asset): asset is Doc<"imageAssets"> => asset !== null) + .map((asset) => serializeAssetForViewer(ctx, asset)), ); - return serialized.filter((asset) => asset !== null); + return serialized; }, }); @@ -299,7 +241,10 @@ export const getAssetUrl = query({ await assertStrategyRole(ctx, strategy, "viewer"); const asset = await getImageAssetByPublicId(ctx, args.assetPublicId); - if (asset === null || asset.strategyId !== strategy._id) { + if ( + asset === null || + !(await strategyReferencesAsset(ctx, strategy._id, args.assetPublicId)) + ) { throw new Error("Asset not found"); } @@ -320,8 +265,10 @@ export const deleteAssetRef = mutation({ await assertStrategyRole(ctx, strategy, "editor"); const asset = await getImageAssetByPublicId(ctx, args.assetPublicId); - - if (asset === null || asset.strategyId !== strategy._id) { + if ( + asset === null || + !(await strategyReferencesAsset(ctx, strategy._id, args.assetPublicId)) + ) { throw new Error("Asset not found"); } @@ -335,21 +282,8 @@ export const listPotentiallyStale = query({ strategyPublicId: v.string(), }, handler: async (ctx, args) => { - const user = await requireCurrentUser(ctx); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - - const assets = await ctx.db - .query("imageAssets") - .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) - .collect(); - - const serialized = await Promise.all( - assets - .filter((asset) => asset.createdByUserId === user._id) - .map((asset) => serializeAssetForViewer(ctx, strategy.publicId, asset)), - ); - - return serialized.filter((asset) => asset !== null); + return []; }, }); diff --git a/convex/ops.ts b/convex/ops.ts index dde4e386..a9e21280 100644 --- a/convex/ops.ts +++ b/convex/ops.ts @@ -1,7 +1,6 @@ import { mutation } from "./_generated/server"; import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; -import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole } from "./lib/auth"; import { getElementByPublicId, @@ -231,7 +230,6 @@ export const applyBatch = mutation({ await ctx.db.delete(lineup._id); } - await deleteImageAssetsForPage(ctx, page._id); await ctx.db.delete(page._id); appliedRevision = page.revision + 1; strategy = await incrementSequence(ctx, strategy); @@ -513,4 +511,3 @@ export const applyBatch = mutation({ }; }, }); - diff --git a/convex/pages.ts b/convex/pages.ts index 907cc667..fa02f09c 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -1,6 +1,5 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; -import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole } from "./lib/auth"; import { getPageByPublicId, @@ -176,8 +175,6 @@ export const deletePage = mutation({ await ctx.db.delete(lineup._id); } - await deleteImageAssetsForPage(ctx, page._id); - await ctx.db.delete(page._id); const ordered = sortByNumberField( diff --git a/convex/schema.ts b/convex/schema.ts index 47b11dc8..f78fd974 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -109,27 +109,17 @@ export default defineSchema({ .index("by_strategyId", ["strategyId"]), imageAssets: defineTable({ publicId: v.string(), - strategyId: v.id("strategies"), - pageId: v.id("pages"), - // Legacy rows predate ownerType and are treated as element-owned. - ownerType: v.optional(v.union(v.literal("element"), v.literal("lineup"))), - elementId: v.optional(v.id("elements")), - lineupId: v.optional(v.id("lineups")), storageId: v.optional(v.id("_storage")), - // Legacy rows predate fileExtension and may need best-effort inference. fileExtension: v.optional(v.string()), - storagePath: v.optional(v.string()), - mimeType: v.string(), + mimeType: v.optional(v.string()), width: v.optional(v.number()), height: v.optional(v.number()), - createdByUserId: v.id("users"), - createdAt: v.number(), - updatedAt: v.number(), + createdAt: v.optional(v.number()), + updatedAt: v.optional(v.number()), + // Legacy rows may still have a storagePath that can help infer the extension. + storagePath: v.optional(v.string()), }) - .index("by_publicId", ["publicId"]) - .index("by_strategyId", ["strategyId"]) - .index("by_pageId", ["pageId"]) - .index("by_lineupId", ["lineupId"]), + .index("by_publicId", ["publicId"]), operationEvents: defineTable({ strategyId: v.id("strategies"), pageId: v.optional(v.id("pages")), @@ -148,4 +138,3 @@ export default defineSchema({ .index("by_strategyId_clientId_opId", ["strategyId", "clientId", "opId"]), }); - diff --git a/convex/strategies.ts b/convex/strategies.ts index f2889b02..61939f2a 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -1,6 +1,5 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; -import { deleteImageAssetsForPage } from "./images"; import { assertStrategyRole, requireCurrentUser } from "./lib/auth"; import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; @@ -275,8 +274,6 @@ export const deleteStrategy = mutation({ await ctx.db.delete(lineup._id); } - await deleteImageAssetsForPage(ctx, page._id); - await ctx.db.delete(page._id); } @@ -304,4 +301,3 @@ export const deleteStrategy = mutation({ export { deleteStrategy as delete }; - diff --git a/lib/collab/cloud_media_models.dart b/lib/collab/cloud_media_models.dart index ccb8e32e..734cc957 100644 --- a/lib/collab/cloud_media_models.dart +++ b/lib/collab/cloud_media_models.dart @@ -2,15 +2,15 @@ import 'package:hive_ce/hive.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; -enum CloudMediaOwnerType { element, lineup } - enum CloudMediaJobState { pendingUpload, pendingAttach, failed } String normalizeImageExtension(String extension) { if (extension.isEmpty) { return extension; } - return extension.startsWith('.') ? extension.toLowerCase() : '.${extension.toLowerCase()}'; + return extension.startsWith('.') + ? extension.toLowerCase() + : '.${extension.toLowerCase()}'; } String mimeTypeForImageExtension(String extension) { @@ -35,9 +35,6 @@ class CloudMediaUploadJob extends HiveObject { CloudMediaUploadJob({ required this.jobId, required this.strategyPublicId, - required this.pagePublicId, - required this.ownerType, - required this.ownerPublicId, required this.assetPublicId, required this.fileExtension, required this.mimeType, @@ -52,9 +49,6 @@ class CloudMediaUploadJob extends HiveObject { final String jobId; final String strategyPublicId; - final String pagePublicId; - final CloudMediaOwnerType ownerType; - final String ownerPublicId; final String assetPublicId; final String fileExtension; final String mimeType; @@ -71,9 +65,6 @@ class CloudMediaUploadJob extends HiveObject { CloudMediaUploadJob copyWith({ String? jobId, String? strategyPublicId, - String? pagePublicId, - CloudMediaOwnerType? ownerType, - String? ownerPublicId, String? assetPublicId, String? fileExtension, String? mimeType, @@ -88,20 +79,19 @@ class CloudMediaUploadJob extends HiveObject { return CloudMediaUploadJob( jobId: jobId ?? this.jobId, strategyPublicId: strategyPublicId ?? this.strategyPublicId, - pagePublicId: pagePublicId ?? this.pagePublicId, - ownerType: ownerType ?? this.ownerType, - ownerPublicId: ownerPublicId ?? this.ownerPublicId, assetPublicId: assetPublicId ?? this.assetPublicId, fileExtension: fileExtension ?? this.fileExtension, mimeType: mimeType ?? this.mimeType, width: width ?? this.width, height: height ?? this.height, - storageId: - identical(storageId, _noChange) ? this.storageId : storageId as String?, + storageId: identical(storageId, _noChange) + ? this.storageId + : storageId as String?, state: state ?? this.state, attempts: attempts ?? this.attempts, - lastError: - identical(lastError, _noChange) ? this.lastError : lastError as String?, + lastError: identical(lastError, _noChange) + ? this.lastError + : lastError as String?, updatedAt: updatedAt ?? this.updatedAt, ); } diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index 46d24fca..b5050d8b 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:icarus/collab/cloud_media_models.dart'; - enum StrategyOpKind { add, move, patch, delete, reorder } enum StrategyOpEntityType { strategy, page, element, lineup } @@ -295,25 +293,17 @@ class RemoteLineup { class RemoteImageAsset { const RemoteImageAsset({ required this.publicId, - required this.strategyPublicId, - required this.pagePublicId, - required this.ownerType, - required this.ownerPublicId, required this.fileExtension, - required this.mimeType, required this.width, required this.height, required this.url, required this.legacyStoragePath, + this.mimeType, }); final String publicId; - final String strategyPublicId; - final String pagePublicId; - final CloudMediaOwnerType ownerType; - final String ownerPublicId; final String fileExtension; - final String mimeType; + final String? mimeType; final int? width; final int? height; final String? url; @@ -322,14 +312,8 @@ class RemoteImageAsset { factory RemoteImageAsset.fromJson(Map json) { return RemoteImageAsset( publicId: json['publicId'] as String, - strategyPublicId: json['strategyPublicId'] as String, - pagePublicId: json['pagePublicId'] as String, - ownerType: (json['ownerType'] as String?) == 'lineup' - ? CloudMediaOwnerType.lineup - : CloudMediaOwnerType.element, - ownerPublicId: json['ownerPublicId'] as String, fileExtension: json['fileExtension'] as String? ?? '', - mimeType: json['mimeType'] as String, + mimeType: json['mimeType'] as String?, width: (json['width'] as num?)?.toInt(), height: (json['height'] as num?)?.toInt(), url: json['url'] as String?, diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index 368a160b..73f9f67b 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:convex_flutter/convex_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; -import 'package:icarus/collab/cloud_media_models.dart'; final convexStrategyRepositoryProvider = Provider( (ref) => ConvexStrategyRepository(ConvexClient.instance), @@ -316,13 +315,10 @@ class ConvexStrategyRepository { Future completeImageUpload({ required String strategyPublicId, - required String pagePublicId, required String assetPublicId, - required CloudMediaOwnerType ownerType, - required String ownerPublicId, required String storageId, - required String mimeType, - required String fileExtension, + String? mimeType, + String? fileExtension, int? width, int? height, }) async { @@ -330,13 +326,10 @@ class ConvexStrategyRepository { name: 'images:completeUpload', args: { 'strategyPublicId': strategyPublicId, - 'pagePublicId': pagePublicId, 'assetPublicId': assetPublicId, - 'ownerType': ownerType.name, - 'ownerPublicId': ownerPublicId, 'storageId': storageId, - 'mimeType': mimeType, - 'fileExtension': fileExtension, + if (mimeType != null) 'mimeType': mimeType, + if (fileExtension != null) 'fileExtension': fileExtension, if (width != null) 'width': width, if (height != null) 'height': height, }, diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 5522cc7a..c88218d2 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -43,7 +43,6 @@ import 'package:icarus/strategy/strategy_models.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), - AdapterSpec(), AdapterSpec(), AdapterSpec(), AdapterSpec(), diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index b3a4f709..22623a17 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1709,43 +1709,6 @@ class AbilityVisualStateAdapter extends TypeAdapter { typeId == other.typeId; } -class CloudMediaOwnerTypeAdapter extends TypeAdapter { - @override - final typeId = 33; - - @override - CloudMediaOwnerType read(BinaryReader reader) { - switch (reader.readByte()) { - case 0: - return CloudMediaOwnerType.element; - case 1: - return CloudMediaOwnerType.lineup; - default: - return CloudMediaOwnerType.element; - } - } - - @override - void write(BinaryWriter writer, CloudMediaOwnerType obj) { - switch (obj) { - case CloudMediaOwnerType.element: - writer.writeByte(0); - case CloudMediaOwnerType.lineup: - writer.writeByte(1); - } - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is CloudMediaOwnerTypeAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - class CloudMediaJobStateAdapter extends TypeAdapter { @override final typeId = 34; @@ -1800,9 +1763,6 @@ class CloudMediaUploadJobAdapter extends TypeAdapter { return CloudMediaUploadJob( jobId: fields[0] as String, strategyPublicId: fields[1] as String, - pagePublicId: fields[2] as String, - ownerType: fields[3] as CloudMediaOwnerType, - ownerPublicId: fields[4] as String, assetPublicId: fields[5] as String, fileExtension: fields[6] as String, mimeType: fields[7] as String, @@ -1819,17 +1779,11 @@ class CloudMediaUploadJobAdapter extends TypeAdapter { @override void write(BinaryWriter writer, CloudMediaUploadJob obj) { writer - ..writeByte(15) + ..writeByte(12) ..writeByte(0) ..write(obj.jobId) ..writeByte(1) ..write(obj.strategyPublicId) - ..writeByte(2) - ..write(obj.pagePublicId) - ..writeByte(3) - ..write(obj.ownerType) - ..writeByte(4) - ..write(obj.ownerPublicId) ..writeByte(5) ..write(obj.assetPublicId) ..writeByte(6) diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 0d0e40a5..b79950f8 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -585,14 +585,6 @@ types: index: 4 showInnerFill: index: 5 - CloudMediaOwnerType: - typeId: 33 - nextIndex: 2 - fields: - element: - index: 0 - lineup: - index: 1 CloudMediaJobState: typeId: 34 nextIndex: 3 @@ -611,12 +603,6 @@ types: index: 0 strategyPublicId: index: 1 - pagePublicId: - index: 2 - ownerType: - index: 3 - ownerPublicId: - index: 4 assetPublicId: index: 5 fileExtension: diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index 71a7e173..cd9b0757 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -15,7 +15,6 @@ extension HiveRegistrar on HiveInterface { registerAdapter(AppPreferencesAdapter()); registerAdapter(BoundingBoxAdapter()); registerAdapter(CloudMediaJobStateAdapter()); - registerAdapter(CloudMediaOwnerTypeAdapter()); registerAdapter(CloudMediaUploadJobAdapter()); registerAdapter(EllipseDrawingAdapter()); registerAdapter(FolderColorAdapter()); @@ -54,7 +53,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(AppPreferencesAdapter()); registerAdapter(BoundingBoxAdapter()); registerAdapter(CloudMediaJobStateAdapter()); - registerAdapter(CloudMediaOwnerTypeAdapter()); registerAdapter(CloudMediaUploadJobAdapter()); registerAdapter(EllipseDrawingAdapter()); registerAdapter(FolderColorAdapter()); diff --git a/lib/providers/collab/cloud_media_upload_queue_provider.dart b/lib/providers/collab/cloud_media_upload_queue_provider.dart index 9a056086..fae5b8e2 100644 --- a/lib/providers/collab/cloud_media_upload_queue_provider.dart +++ b/lib/providers/collab/cloud_media_upload_queue_provider.dart @@ -13,7 +13,6 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -56,12 +55,13 @@ class CloudMediaUploadQueueState { } } -final cloudMediaUploadQueueProvider = NotifierProvider< - CloudMediaUploadQueueNotifier, CloudMediaUploadQueueState>( +final cloudMediaUploadQueueProvider = + NotifierProvider( CloudMediaUploadQueueNotifier.new, ); -class CloudMediaUploadQueueNotifier extends Notifier { +class CloudMediaUploadQueueNotifier + extends Notifier { Timer? _retryTimer; Box get _box => @@ -99,7 +99,6 @@ class CloudMediaUploadQueueNotifier extends Notifier Future enqueuePlacedImageUpload({ required String imagePublicId, String? strategyPublicId, - String? pagePublicId, String? fileExtension, String? mimeType, int? width, @@ -107,11 +106,8 @@ class CloudMediaUploadQueueNotifier extends Notifier }) async { final strategyState = ref.read(strategyProvider); final resolvedStrategyId = strategyPublicId ?? strategyState.strategyId; - final resolvedPageId = - pagePublicId ?? ref.read(strategyPageSessionProvider).activePageId; if (strategyState.source != StrategySource.cloud || - resolvedStrategyId == null || - resolvedPageId == null) { + resolvedStrategyId == null) { return; } @@ -120,9 +116,6 @@ class CloudMediaUploadQueueNotifier extends Notifier CloudMediaUploadJob( jobId: imagePublicId, strategyPublicId: resolvedStrategyId, - pagePublicId: resolvedPageId, - ownerType: CloudMediaOwnerType.element, - ownerPublicId: imagePublicId, assetPublicId: imagePublicId, fileExtension: normalizedExtension, mimeType: mimeType ?? mimeTypeForImageExtension(normalizedExtension), @@ -138,9 +131,6 @@ class CloudMediaUploadQueueNotifier extends Notifier Future enqueueJobForLocalFile({ required String strategyPublicId, - required String pagePublicId, - required CloudMediaOwnerType ownerType, - required String ownerPublicId, required String assetPublicId, required String fileExtension, String? mimeType, @@ -152,9 +142,6 @@ class CloudMediaUploadQueueNotifier extends Notifier CloudMediaUploadJob( jobId: assetPublicId, strategyPublicId: strategyPublicId, - pagePublicId: pagePublicId, - ownerType: ownerType, - ownerPublicId: ownerPublicId, assetPublicId: assetPublicId, fileExtension: normalizedExtension, mimeType: mimeType ?? mimeTypeForImageExtension(normalizedExtension), @@ -170,8 +157,6 @@ class CloudMediaUploadQueueNotifier extends Notifier Future enqueueLineupMediaJobs({ required String strategyPublicId, - required String pagePublicId, - required String lineupPublicId, required Iterable images, }) async { for (final image in images) { @@ -180,9 +165,6 @@ class CloudMediaUploadQueueNotifier extends Notifier CloudMediaUploadJob( jobId: image.id, strategyPublicId: strategyPublicId, - pagePublicId: pagePublicId, - ownerType: CloudMediaOwnerType.lineup, - ownerPublicId: lineupPublicId, assetPublicId: image.id, fileExtension: normalizedExtension, mimeType: mimeTypeForImageExtension(normalizedExtension), @@ -268,7 +250,8 @@ class CloudMediaUploadQueueNotifier extends Notifier return; } - if (job.state == CloudMediaJobState.pendingUpload || job.storageId == null) { + if (job.state == CloudMediaJobState.pendingUpload || + job.storageId == null) { await _uploadJobBlob(job); return; } @@ -292,7 +275,8 @@ class CloudMediaUploadQueueNotifier extends Notifier return; } - final uploadUrl = await _repo.generateImageUploadUrl(job.strategyPublicId); + final uploadUrl = + await _repo.generateImageUploadUrl(job.strategyPublicId); if (uploadUrl.isEmpty) { throw StateError('Empty Convex upload URL'); } @@ -335,10 +319,7 @@ class CloudMediaUploadQueueNotifier extends Notifier try { await _repo.completeImageUpload( strategyPublicId: job.strategyPublicId, - pagePublicId: job.pagePublicId, assetPublicId: job.assetPublicId, - ownerType: job.ownerType, - ownerPublicId: job.ownerPublicId, storageId: job.storageId!, mimeType: job.mimeType, fileExtension: job.fileExtension, @@ -348,21 +329,6 @@ class CloudMediaUploadQueueNotifier extends Notifier await _box.delete(job.jobId); _refreshState(); } catch (error) { - if (_isOwnerNotFoundError(error)) { - await _box.put( - job.jobId, - job.copyWith( - state: CloudMediaJobState.pendingAttach, - attempts: job.attempts + 1, - lastError: 'owner_not_found', - updatedAt: DateTime.now(), - ), - ); - _refreshState(); - _scheduleRetryForNextEligibleJob(); - return; - } - await _markJobFailed( job, '$error', @@ -425,7 +391,8 @@ class CloudMediaUploadQueueNotifier extends Notifier return; } - final delay = earliest.isAfter(now) ? earliest.difference(now) : Duration.zero; + final delay = + earliest.isAfter(now) ? earliest.difference(now) : Duration.zero; _retryTimer = Timer(delay, () { unawaited(_processNextJob(ignoreBackoff: false)); }); @@ -436,9 +403,6 @@ class CloudMediaUploadQueueNotifier extends Notifier if (existing != null) { final merged = existing.copyWith( strategyPublicId: nextJob.strategyPublicId, - pagePublicId: nextJob.pagePublicId, - ownerType: nextJob.ownerType, - ownerPublicId: nextJob.ownerPublicId, assetPublicId: nextJob.assetPublicId, fileExtension: nextJob.fileExtension, mimeType: nextJob.mimeType, @@ -477,20 +441,7 @@ class CloudMediaUploadQueueNotifier extends Notifier return storageId; } } - throw const FormatException('Convex upload response did not include storageId'); - } - - bool _isOwnerNotFoundError(Object error) { - if (error is Map) { - final code = error['code']?.toString().toUpperCase(); - final message = error['message']?.toString().toLowerCase(); - if (code == 'OWNER_NOT_FOUND' || message == 'owner_not_found') { - return true; - } - } - - final normalized = error.toString().toLowerCase(); - return normalized.contains('owner_not_found') || - normalized.contains('owner not found'); + throw const FormatException( + 'Convex upload response did not include storageId'); } } diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 2a197c4d..963a9aa8 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -14,7 +14,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/const/placed_classes.dart'; -import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; @@ -166,8 +165,9 @@ class PlacedImageProvider extends Notifier { group: ActionGroup.image, objectDelta: ObjectHistoryDelta( after: ActionObjectState.image(placedImage), - afterImageSizes: - ref.read(imageWidgetSizeProvider.notifier).takeSnapshotForIds([imageID]), + afterImageSizes: ref + .read(imageWidgetSizeProvider.notifier) + .takeSnapshotForIds([imageID]), ), ); @@ -175,15 +175,11 @@ class PlacedImageProvider extends Notifier { state = state.copyWith(images: [...state.images, placedImage]); - final pagePublicId = ref.read(strategyPageSessionProvider).activePageId; - if (strategySource == StrategySource.cloud && - strategyId != null && - pagePublicId != null) { + if (strategySource == StrategySource.cloud && strategyId != null) { await ref .read(cloudMediaUploadQueueProvider.notifier) .enqueuePlacedImageUpload( strategyPublicId: strategyId, - pagePublicId: pagePublicId, imagePublicId: placedImage.id, fileExtension: fileExtension, width: null, @@ -485,11 +481,8 @@ class PlacedImageProvider extends Notifier { } Future saveSecureImage( - Uint8List imageBytes, - String imageID, - String fileExtenstion, - {required String? strategyId} - ) async { + Uint8List imageBytes, String imageID, String fileExtenstion, + {required String? strategyId}) async { if (strategyId == null) return; await writeImageBytes( imageBytes: imageBytes, diff --git a/lib/widgets/dialogs/create_lineup_dialog.dart b/lib/widgets/dialogs/create_lineup_dialog.dart index 4a320d96..002af270 100644 --- a/lib/widgets/dialogs/create_lineup_dialog.dart +++ b/lib/widgets/dialogs/create_lineup_dialog.dart @@ -3,13 +3,11 @@ import 'dart:typed_data' show Uint8List; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; -import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/services/clipboard_service.dart'; @@ -32,7 +30,6 @@ class _CreateLineupDialogState extends ConsumerState { final List _imagePaths = []; Future _enqueueLineupMediaJobs({ - required String lineupId, required List images, }) async { final strategyState = ref.read(strategyProvider); @@ -41,17 +38,11 @@ class _CreateLineupDialogState extends ConsumerState { return; } - final pageId = ref.read(strategyPageSessionProvider).activePageId; - if (pageId == null) { - return; - } - for (final image in images) { - await ref.read(cloudMediaUploadQueueProvider.notifier).enqueueJobForLocalFile( + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalFile( strategyPublicId: strategyState.strategyId!, - pagePublicId: pageId, - ownerType: CloudMediaOwnerType.lineup, - ownerPublicId: lineupId, assetPublicId: image.id, fileExtension: image.fileExtension, ); @@ -121,7 +112,6 @@ class _CreateLineupDialogState extends ConsumerState { ref.read(lineUpProvider.notifier).updateLineUp(lineUp); await _enqueueLineupMediaJobs( - lineupId: lineUp.id, images: lineUp.images, ); } else { @@ -144,7 +134,6 @@ class _CreateLineupDialogState extends ConsumerState { ref.read(lineUpProvider.notifier).addLineUp(currentLineUp); await _enqueueLineupMediaJobs( - lineupId: currentLineUp.id, images: currentLineUp.images, ); } @@ -181,9 +170,7 @@ class _CreateLineupDialogState extends ConsumerState { final SimpleImageData imageData = SimpleImageData(id: id, fileExtension: fileExtension); - await ref - .read(placedImageProvider.notifier) - .saveSecureImage( + await ref.read(placedImageProvider.notifier).saveSecureImage( imageBytes, id, fileExtension, @@ -221,9 +208,7 @@ class _CreateLineupDialogState extends ConsumerState { final SimpleImageData imageData = SimpleImageData(id: id, fileExtension: fileExtension); - await ref - .read(placedImageProvider.notifier) - .saveSecureImage( + await ref.read(placedImageProvider.notifier).saveSecureImage( bytes, id, fileExtension, From c7d86fa10c94bde8e63b51ae303cbd089839c878 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:52:00 -0400 Subject: [PATCH 10/15] Support scope in strategies listForFolder --- .../skills/convex-create-component/SKILL.md | 2 +- .../skills/convex-migration-helper/SKILL.md | 2 +- .../skills/convex-performance-audit/SKILL.md | 2 +- .agents/skills/convex-quickstart/SKILL.md | 2 +- .agents/skills/convex-setup-auth/SKILL.md | 2 +- .agents/skills/convex/SKILL.md | 2 +- .claude/skills/convex | 1 - .../skills/convex-create-component/SKILL.md | 2 +- .../skills/convex-migration-helper/SKILL.md | 2 +- .../skills/convex-performance-audit/SKILL.md | 2 +- .claude/skills/convex-quickstart/SKILL.md | 2 +- .claude/skills/convex-setup-auth/SKILL.md | 2 +- .claude/skills/convex/SKILL.md | 47 ++++++++++++++ .windsurf/skills/convex | 1 - .../skills/convex-create-component/SKILL.md | 2 +- .../skills/convex-migration-helper/SKILL.md | 2 +- .../skills/convex-performance-audit/SKILL.md | 2 +- .windsurf/skills/convex-quickstart/SKILL.md | 2 +- .windsurf/skills/convex-setup-auth/SKILL.md | 2 +- .windsurf/skills/convex/SKILL.md | 47 ++++++++++++++ convex/_generated/ai/ai-files.state.json | 2 +- convex/strategies.ts | 64 +++++++++++++++---- lib/collab/collab_models.dart | 34 ++++++++++ lib/strategy/strategy_page_source.dart | 4 +- skills-lock.json | 18 ++++-- skills/convex-create-component/SKILL.md | 2 +- skills/convex-migration-helper/SKILL.md | 2 +- skills/convex-performance-audit/SKILL.md | 2 +- skills/convex-quickstart/SKILL.md | 2 +- skills/convex-setup-auth/SKILL.md | 2 +- skills/convex/SKILL.md | 47 ++++++++++++++ 31 files changed, 261 insertions(+), 46 deletions(-) delete mode 120000 .claude/skills/convex create mode 100644 .claude/skills/convex/SKILL.md delete mode 120000 .windsurf/skills/convex create mode 100644 .windsurf/skills/convex/SKILL.md create mode 100644 skills/convex/SKILL.md diff --git a/.agents/skills/convex-create-component/SKILL.md b/.agents/skills/convex-create-component/SKILL.md index 22af601f..64bd42f9 100644 --- a/.agents/skills/convex-create-component/SKILL.md +++ b/.agents/skills/convex-create-component/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-create-component -description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +description: Builds reusable Convex components with isolated tables and app-facing APIs. Use for new components, reusable backend modules, integrations, or component boundary work. --- # Convex Create Component diff --git a/.agents/skills/convex-migration-helper/SKILL.md b/.agents/skills/convex-migration-helper/SKILL.md index db36c622..4a4ed167 100644 --- a/.agents/skills/convex-migration-helper/SKILL.md +++ b/.agents/skills/convex-migration-helper/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-migration-helper -description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +description: Plans Convex schema and data migrations with widen-migrate-narrow and @convex-dev/migrations. Use for breaking schema changes, backfills, table reshaping, or zero-downtime rollouts. --- # Convex Migration Helper diff --git a/.agents/skills/convex-performance-audit/SKILL.md b/.agents/skills/convex-performance-audit/SKILL.md index 382951cf..f2554dca 100644 --- a/.agents/skills/convex-performance-audit/SKILL.md +++ b/.agents/skills/convex-performance-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-performance-audit -description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +description: Audits Convex performance for reads, subscriptions, write contention, and function limits. Use for slow features, insights findings, OCC conflicts, or read amplification. --- # Convex Performance Audit diff --git a/.agents/skills/convex-quickstart/SKILL.md b/.agents/skills/convex-quickstart/SKILL.md index 5bff17bc..f506b3e4 100644 --- a/.agents/skills/convex-quickstart/SKILL.md +++ b/.agents/skills/convex-quickstart/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-quickstart -description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +description: Creates or adds Convex to an app. Use for new Convex projects, npm create convex@latest, frontend setup, env vars, or the first npx convex dev run. --- # Convex Quickstart diff --git a/.agents/skills/convex-setup-auth/SKILL.md b/.agents/skills/convex-setup-auth/SKILL.md index 0d1d9dd6..59a92285 100644 --- a/.agents/skills/convex-setup-auth/SKILL.md +++ b/.agents/skills/convex-setup-auth/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-setup-auth -description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +description: Sets up Convex auth, identity mapping, and access control. Use for login, auth providers, users tables, protected functions, or roles in a Convex app. --- # Convex Authentication Setup diff --git a/.agents/skills/convex/SKILL.md b/.agents/skills/convex/SKILL.md index d4678270..9e8ebbd8 100644 --- a/.agents/skills/convex/SKILL.md +++ b/.agents/skills/convex/SKILL.md @@ -1,6 +1,6 @@ --- name: convex -description: Routing skill for Convex work in this repo. Use when the user explicitly invokes the `convex` skill, asks which Convex workflow or skill to use, or says they are working on a Convex app without naming a specific task yet. Do not prefer this skill when the request is clearly about setting up Convex, authentication, components, migrations, or performance. +description: Routes general Convex requests to the right project skill. Use when the user asks which Convex skill to use or gives an underspecified Convex app task. --- # Convex diff --git a/.claude/skills/convex b/.claude/skills/convex deleted file mode 120000 index 9ba9fd9e..00000000 --- a/.claude/skills/convex +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex \ No newline at end of file diff --git a/.claude/skills/convex-create-component/SKILL.md b/.claude/skills/convex-create-component/SKILL.md index 22af601f..64bd42f9 100644 --- a/.claude/skills/convex-create-component/SKILL.md +++ b/.claude/skills/convex-create-component/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-create-component -description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +description: Builds reusable Convex components with isolated tables and app-facing APIs. Use for new components, reusable backend modules, integrations, or component boundary work. --- # Convex Create Component diff --git a/.claude/skills/convex-migration-helper/SKILL.md b/.claude/skills/convex-migration-helper/SKILL.md index db36c622..4a4ed167 100644 --- a/.claude/skills/convex-migration-helper/SKILL.md +++ b/.claude/skills/convex-migration-helper/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-migration-helper -description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +description: Plans Convex schema and data migrations with widen-migrate-narrow and @convex-dev/migrations. Use for breaking schema changes, backfills, table reshaping, or zero-downtime rollouts. --- # Convex Migration Helper diff --git a/.claude/skills/convex-performance-audit/SKILL.md b/.claude/skills/convex-performance-audit/SKILL.md index 382951cf..f2554dca 100644 --- a/.claude/skills/convex-performance-audit/SKILL.md +++ b/.claude/skills/convex-performance-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-performance-audit -description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +description: Audits Convex performance for reads, subscriptions, write contention, and function limits. Use for slow features, insights findings, OCC conflicts, or read amplification. --- # Convex Performance Audit diff --git a/.claude/skills/convex-quickstart/SKILL.md b/.claude/skills/convex-quickstart/SKILL.md index 5bff17bc..f506b3e4 100644 --- a/.claude/skills/convex-quickstart/SKILL.md +++ b/.claude/skills/convex-quickstart/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-quickstart -description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +description: Creates or adds Convex to an app. Use for new Convex projects, npm create convex@latest, frontend setup, env vars, or the first npx convex dev run. --- # Convex Quickstart diff --git a/.claude/skills/convex-setup-auth/SKILL.md b/.claude/skills/convex-setup-auth/SKILL.md index 0d1d9dd6..59a92285 100644 --- a/.claude/skills/convex-setup-auth/SKILL.md +++ b/.claude/skills/convex-setup-auth/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-setup-auth -description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +description: Sets up Convex auth, identity mapping, and access control. Use for login, auth providers, users tables, protected functions, or roles in a Convex app. --- # Convex Authentication Setup diff --git a/.claude/skills/convex/SKILL.md b/.claude/skills/convex/SKILL.md new file mode 100644 index 00000000..9e8ebbd8 --- /dev/null +++ b/.claude/skills/convex/SKILL.md @@ -0,0 +1,47 @@ +--- +name: convex +description: Routes general Convex requests to the right project skill. Use when the user asks which Convex skill to use or gives an underspecified Convex app task. +--- + +# Convex + +Use this as the routing skill for Convex work in this repo. + +If a more specific Convex skill clearly matches the request, use that instead. + +## Start Here + +If the project does not already have Convex AI guidance installed, or the existing guidance looks stale, strongly recommend installing it first. + +Preferred: + +```bash +npx convex ai-files install +``` + +This installs or refreshes the managed Convex AI files. It is the recommended starting point for getting the official Convex guidelines in place and following the current Convex AI setup described in the docs: + +- [Convex AI docs](https://docs.convex.dev/ai) + +Simple fallback: + +- [convex_rules.txt](https://convex.link/convex_rules.txt) + +Prefer `npx convex ai-files install` over copying rules by hand when possible. + +## Route to the Right Skill + +After that, use the most specific Convex skill for the task: + +- New project or adding Convex to an app: `convex-quickstart` +- Authentication setup: `convex-setup-auth` +- Building a reusable Convex component: `convex-create-component` +- Planning or running a migration: `convex-migration-helper` +- Investigating performance issues: `convex-performance-audit` + +If one of those clearly matches the user's goal, switch to it instead of staying in this skill. + +## When Not to Use + +- The user has already named a more specific Convex workflow +- Another Convex skill obviously fits the request better diff --git a/.windsurf/skills/convex b/.windsurf/skills/convex deleted file mode 120000 index 9ba9fd9e..00000000 --- a/.windsurf/skills/convex +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/convex \ No newline at end of file diff --git a/.windsurf/skills/convex-create-component/SKILL.md b/.windsurf/skills/convex-create-component/SKILL.md index 22af601f..64bd42f9 100644 --- a/.windsurf/skills/convex-create-component/SKILL.md +++ b/.windsurf/skills/convex-create-component/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-create-component -description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +description: Builds reusable Convex components with isolated tables and app-facing APIs. Use for new components, reusable backend modules, integrations, or component boundary work. --- # Convex Create Component diff --git a/.windsurf/skills/convex-migration-helper/SKILL.md b/.windsurf/skills/convex-migration-helper/SKILL.md index db36c622..4a4ed167 100644 --- a/.windsurf/skills/convex-migration-helper/SKILL.md +++ b/.windsurf/skills/convex-migration-helper/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-migration-helper -description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +description: Plans Convex schema and data migrations with widen-migrate-narrow and @convex-dev/migrations. Use for breaking schema changes, backfills, table reshaping, or zero-downtime rollouts. --- # Convex Migration Helper diff --git a/.windsurf/skills/convex-performance-audit/SKILL.md b/.windsurf/skills/convex-performance-audit/SKILL.md index 382951cf..f2554dca 100644 --- a/.windsurf/skills/convex-performance-audit/SKILL.md +++ b/.windsurf/skills/convex-performance-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-performance-audit -description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +description: Audits Convex performance for reads, subscriptions, write contention, and function limits. Use for slow features, insights findings, OCC conflicts, or read amplification. --- # Convex Performance Audit diff --git a/.windsurf/skills/convex-quickstart/SKILL.md b/.windsurf/skills/convex-quickstart/SKILL.md index 5bff17bc..f506b3e4 100644 --- a/.windsurf/skills/convex-quickstart/SKILL.md +++ b/.windsurf/skills/convex-quickstart/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-quickstart -description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +description: Creates or adds Convex to an app. Use for new Convex projects, npm create convex@latest, frontend setup, env vars, or the first npx convex dev run. --- # Convex Quickstart diff --git a/.windsurf/skills/convex-setup-auth/SKILL.md b/.windsurf/skills/convex-setup-auth/SKILL.md index 0d1d9dd6..59a92285 100644 --- a/.windsurf/skills/convex-setup-auth/SKILL.md +++ b/.windsurf/skills/convex-setup-auth/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-setup-auth -description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +description: Sets up Convex auth, identity mapping, and access control. Use for login, auth providers, users tables, protected functions, or roles in a Convex app. --- # Convex Authentication Setup diff --git a/.windsurf/skills/convex/SKILL.md b/.windsurf/skills/convex/SKILL.md new file mode 100644 index 00000000..9e8ebbd8 --- /dev/null +++ b/.windsurf/skills/convex/SKILL.md @@ -0,0 +1,47 @@ +--- +name: convex +description: Routes general Convex requests to the right project skill. Use when the user asks which Convex skill to use or gives an underspecified Convex app task. +--- + +# Convex + +Use this as the routing skill for Convex work in this repo. + +If a more specific Convex skill clearly matches the request, use that instead. + +## Start Here + +If the project does not already have Convex AI guidance installed, or the existing guidance looks stale, strongly recommend installing it first. + +Preferred: + +```bash +npx convex ai-files install +``` + +This installs or refreshes the managed Convex AI files. It is the recommended starting point for getting the official Convex guidelines in place and following the current Convex AI setup described in the docs: + +- [Convex AI docs](https://docs.convex.dev/ai) + +Simple fallback: + +- [convex_rules.txt](https://convex.link/convex_rules.txt) + +Prefer `npx convex ai-files install` over copying rules by hand when possible. + +## Route to the Right Skill + +After that, use the most specific Convex skill for the task: + +- New project or adding Convex to an app: `convex-quickstart` +- Authentication setup: `convex-setup-auth` +- Building a reusable Convex component: `convex-create-component` +- Planning or running a migration: `convex-migration-helper` +- Investigating performance issues: `convex-performance-audit` + +If one of those clearly matches the user's goal, switch to it instead of staying in this skill. + +## When Not to Use + +- The user has already named a more specific Convex workflow +- Another Convex skill obviously fits the request better diff --git a/convex/_generated/ai/ai-files.state.json b/convex/_generated/ai/ai-files.state.json index 7fc165fd..41c8ea62 100644 --- a/convex/_generated/ai/ai-files.state.json +++ b/convex/_generated/ai/ai-files.state.json @@ -2,7 +2,7 @@ "guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57", "agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", "claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", - "agentSkillsSha": "4de8fba05b0a506661116985649072777049f67b", + "agentSkillsSha": "d0fa8085af313029add5740f67198aa42ca60c8d", "installedSkillNames": [ "convex", "convex-create-component", diff --git a/convex/strategies.ts b/convex/strategies.ts index 61939f2a..64455685 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -1,26 +1,62 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; -import { assertStrategyRole, requireCurrentUser } from "./lib/auth"; +import type { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx } from "./_generated/server"; +import { + assertFolderRole, + assertStrategyRole, + getEffectiveStrategyRoleForUser, + requireCurrentUser, +} from "./lib/auth"; import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; -async function listAccessibleStrategies(ctx: any, userId: any) { +type StrategyScope = "owned" | "shared" | "all"; + +const strategyScopeValidator = v.optional( + v.union(v.literal("owned"), v.literal("shared"), v.literal("all")), +); + +function matchesScope( + ownerId: Id<"users">, + userId: Id<"users">, + scope: StrategyScope, +): boolean { + if (scope === "all") { + return true; + } + if (scope === "owned") { + return ownerId === userId; + } + return ownerId !== userId; +} + +async function listAccessibleStrategies( + ctx: QueryCtx, + userId: Id<"users">, + scope: StrategyScope, +) { const owned = await ctx.db .query("strategies") - .withIndex("by_ownerId", (q: any) => q.eq("ownerId", userId)) + .withIndex("by_ownerId", (q) => q.eq("ownerId", userId)) .collect(); const memberships = await ctx.db .query("strategyCollaborators") - .withIndex("by_userId", (q: any) => q.eq("userId", userId)) + .withIndex("by_userId", (q) => q.eq("userId", userId)) .collect(); const fromMembership = await Promise.all( - memberships.map((m: any) => ctx.db.get(m.strategyId)), + memberships.map((m) => ctx.db.get(m.strategyId)), ); - const dedup = new Map(); - for (const strategy of [...owned, ...fromMembership]) { - if (strategy !== null) { + const strategies = await ctx.db.query("strategies").collect(); + const dedup = new Map, Doc<"strategies">>(); + for (const strategy of [...owned, ...fromMembership, ...strategies]) { + if ( + strategy !== null && + matchesScope(strategy.ownerId, userId, scope) && + (await getEffectiveStrategyRoleForUser(ctx, strategy, userId)) !== null + ) { dedup.set(strategy._id, strategy); } } @@ -31,21 +67,21 @@ async function listAccessibleStrategies(ctx: any, userId: any) { export const listForFolder = query({ args: { folderPublicId: v.optional(v.string()), + scope: strategyScopeValidator, }, handler: async (ctx, args) => { const user = await requireCurrentUser(ctx); - const all = await listAccessibleStrategies(ctx as any, user._id); + const scope = args.scope ?? "owned"; + const all = await listAccessibleStrategies(ctx, user._id, scope); const memberships = await ctx.db .query("strategyCollaborators") .withIndex("by_userId", (q) => q.eq("userId", user._id)) .collect(); - let folderId; + let folderId: Id<"folders"> | undefined; if (args.folderPublicId !== undefined) { const folder = await getFolderByPublicId(ctx, args.folderPublicId); - if (folder.ownerId !== user._id) { - throw new Error("Forbidden"); - } + await assertFolderRole(ctx, folder, "viewer"); folderId = folder._id; } @@ -73,7 +109,7 @@ export const listForFolder = query({ .collect(); let attackLabel = "Unknown"; if (pages.length > 0) { - const first = pages[0].isAttack; + const first = pages[0]!.isAttack; const mixed = pages.some((page) => page.isAttack !== first); attackLabel = mixed ? "Mixed" : first ? "Attack" : "Defend"; } diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index b5050d8b..1a496525 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -383,6 +383,7 @@ class CloudFolderSummary { required this.name, required this.createdAt, required this.updatedAt, + this.role, this.parentFolderPublicId, this.iconCodePoint, this.iconFontFamily, @@ -395,6 +396,7 @@ class CloudFolderSummary { final String name; final DateTime createdAt; final DateTime updatedAt; + final String? role; final String? parentFolderPublicId; final int? iconCodePoint; final String? iconFontFamily; @@ -412,6 +414,7 @@ class CloudFolderSummary { updatedAt: DateTime.fromMillisecondsSinceEpoch( (json['updatedAt'] as num?)?.toInt() ?? 0, ), + role: json['role'] as String?, parentFolderPublicId: json['parentFolderPublicId'] as String?, iconCodePoint: (json['iconCodePoint'] as num?)?.toInt(), iconFontFamily: json['iconFontFamily'] as String?, @@ -421,3 +424,34 @@ class CloudFolderSummary { ); } } + +class ShareLinkSummary { + const ShareLinkSummary({ + required this.token, + required this.role, + required this.createdAt, + this.revokedAt, + }); + + final String token; + final String role; + final DateTime createdAt; + final DateTime? revokedAt; + + bool get isRevoked => revokedAt != null; + + factory ShareLinkSummary.fromJson(Map json) { + return ShareLinkSummary( + token: json['token'] as String, + role: json['role'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ), + revokedAt: json['revokedAt'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (json['revokedAt'] as num).toInt(), + ), + ); + } +} diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart index 571c4493..cd5821cb 100644 --- a/lib/strategy/strategy_page_source.dart +++ b/lib/strategy/strategy_page_source.dart @@ -332,7 +332,7 @@ class CloudStrategyPageSource implements StrategyPageSource { header.themeOverridePalette == desiredThemeOverride; if (mapMatches && themeProfileMatches && themeOverrideMatches) { - ref.read(strategyOpQueueProvider.notifier).syncDesiredOp( + ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( entityKey: 'strategy', desiredOp: null, flushImmediately: false, @@ -352,7 +352,7 @@ class CloudStrategyPageSource implements StrategyPageSource { 'clearThemeOverridePalette': true, }; - ref.read(strategyOpQueueProvider.notifier).syncDesiredOp( + ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( entityKey: 'strategy', desiredOp: StrategyOp( opId: const Uuid().v4(), diff --git a/skills-lock.json b/skills-lock.json index bf33b964..b8f0e2f1 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -4,32 +4,38 @@ "convex": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "613ee9955985085d0fca8f96e1fc6d7cfd204dffa203499a1d508b8def76577b" + "skillPath": "skills/convex/SKILL.md", + "computedHash": "1515307c00539d299b2b2ac7084d39a12d6abbc86f665eec585f7de04220328e" }, "convex-create-component": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "d110fca7f65b4919367e6fc63a93bf54abea2cf5e4e097234c947559ffa6e527" + "skillPath": "skills/convex-create-component/SKILL.md", + "computedHash": "d46d3f80f9701612651a087c4cd37b03ec2bba7f53ac2e661efb2838b0c18f29" }, "convex-migration-helper": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "46d1ac354eefbed05e1367d828e893816c13302276080bfaf6bcd828281be486" + "skillPath": "skills/convex-migration-helper/SKILL.md", + "computedHash": "8e839a8a51ac6fa5c341bf1bb57d8e5af229395467f1e606c51a8ddde42136d6" }, "convex-performance-audit": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "30ea0d3c259df011e44ea9b70502ab272f5ac3bd1fb3672ae18489ba99b2c4ae" + "skillPath": "skills/convex-performance-audit/SKILL.md", + "computedHash": "c150925d80d8b003b25dd80a52da016bce2451ebb9da5b4adc75cbf6b6ff37c9" }, "convex-quickstart": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "8ae9e1b02f526ea65e7895fac82af74142cd8e70e364d9dae9dbf79a296fb5ef" + "skillPath": "skills/convex-quickstart/SKILL.md", + "computedHash": "60bb7707dc0a87a51f17cfeb004e3fd403346e56694076f30a746ad285a5a7cc" }, "convex-setup-auth": { "source": "get-convex/agent-skills", "sourceType": "github", - "computedHash": "e719d31d1ab0d19ca7b942d1154d3ff436b5c156900eea9866c2aaeb910a1388" + "skillPath": "skills/convex-setup-auth/SKILL.md", + "computedHash": "152289aa8150432c2c843e5861b554a46aa5992eb44a67041c333a8679d6c5f8" } } } diff --git a/skills/convex-create-component/SKILL.md b/skills/convex-create-component/SKILL.md index 22af601f..64bd42f9 100644 --- a/skills/convex-create-component/SKILL.md +++ b/skills/convex-create-component/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-create-component -description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +description: Builds reusable Convex components with isolated tables and app-facing APIs. Use for new components, reusable backend modules, integrations, or component boundary work. --- # Convex Create Component diff --git a/skills/convex-migration-helper/SKILL.md b/skills/convex-migration-helper/SKILL.md index db36c622..4a4ed167 100644 --- a/skills/convex-migration-helper/SKILL.md +++ b/skills/convex-migration-helper/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-migration-helper -description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +description: Plans Convex schema and data migrations with widen-migrate-narrow and @convex-dev/migrations. Use for breaking schema changes, backfills, table reshaping, or zero-downtime rollouts. --- # Convex Migration Helper diff --git a/skills/convex-performance-audit/SKILL.md b/skills/convex-performance-audit/SKILL.md index 382951cf..f2554dca 100644 --- a/skills/convex-performance-audit/SKILL.md +++ b/skills/convex-performance-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-performance-audit -description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +description: Audits Convex performance for reads, subscriptions, write contention, and function limits. Use for slow features, insights findings, OCC conflicts, or read amplification. --- # Convex Performance Audit diff --git a/skills/convex-quickstart/SKILL.md b/skills/convex-quickstart/SKILL.md index 5bff17bc..f506b3e4 100644 --- a/skills/convex-quickstart/SKILL.md +++ b/skills/convex-quickstart/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-quickstart -description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +description: Creates or adds Convex to an app. Use for new Convex projects, npm create convex@latest, frontend setup, env vars, or the first npx convex dev run. --- # Convex Quickstart diff --git a/skills/convex-setup-auth/SKILL.md b/skills/convex-setup-auth/SKILL.md index 0d1d9dd6..59a92285 100644 --- a/skills/convex-setup-auth/SKILL.md +++ b/skills/convex-setup-auth/SKILL.md @@ -1,6 +1,6 @@ --- name: convex-setup-auth -description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +description: Sets up Convex auth, identity mapping, and access control. Use for login, auth providers, users tables, protected functions, or roles in a Convex app. --- # Convex Authentication Setup diff --git a/skills/convex/SKILL.md b/skills/convex/SKILL.md new file mode 100644 index 00000000..9e8ebbd8 --- /dev/null +++ b/skills/convex/SKILL.md @@ -0,0 +1,47 @@ +--- +name: convex +description: Routes general Convex requests to the right project skill. Use when the user asks which Convex skill to use or gives an underspecified Convex app task. +--- + +# Convex + +Use this as the routing skill for Convex work in this repo. + +If a more specific Convex skill clearly matches the request, use that instead. + +## Start Here + +If the project does not already have Convex AI guidance installed, or the existing guidance looks stale, strongly recommend installing it first. + +Preferred: + +```bash +npx convex ai-files install +``` + +This installs or refreshes the managed Convex AI files. It is the recommended starting point for getting the official Convex guidelines in place and following the current Convex AI setup described in the docs: + +- [Convex AI docs](https://docs.convex.dev/ai) + +Simple fallback: + +- [convex_rules.txt](https://convex.link/convex_rules.txt) + +Prefer `npx convex ai-files install` over copying rules by hand when possible. + +## Route to the Right Skill + +After that, use the most specific Convex skill for the task: + +- New project or adding Convex to an app: `convex-quickstart` +- Authentication setup: `convex-setup-auth` +- Building a reusable Convex component: `convex-create-component` +- Planning or running a migration: `convex-migration-helper` +- Investigating performance issues: `convex-performance-audit` + +If one of those clearly matches the user's goal, switch to it instead of staying in this skill. + +## When Not to Use + +- The user has already named a more specific Convex workflow +- Another Convex skill obviously fits the request better From 2557fc6ab5d8f7fde7411165f8f928ed604a6c74 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:22:28 -0400 Subject: [PATCH 11/15] Add community workspace and rail navigation --- assets/icarus-icon.webp | Bin 0 -> 754 bytes convex/schema.ts | 25 + lib/providers/folder_provider.dart | 10 +- lib/providers/library_workspace_provider.dart | 1 + lib/widgets/folder_content.dart | 55 ++ lib/widgets/folder_navigator.dart | 595 ++++++++++++++++-- lib/widgets/folder_navigator_sidebar.dart | 21 +- lib/widgets/settings_tab.dart | 42 -- 8 files changed, 640 insertions(+), 109 deletions(-) create mode 100644 assets/icarus-icon.webp diff --git a/assets/icarus-icon.webp b/assets/icarus-icon.webp new file mode 100644 index 0000000000000000000000000000000000000000..e77acefcc9aa5d69e4956524ef85be3d9d5ebaf2 GIT binary patch literal 754 zcmVC=<}clu!u-!aLAEXa2ws9o znrb@+Yd8WN%>w`cWZAY_H*nK`$ds!TEcBs)GR|j0d9q2BH zH|964Z?iEd?y?SN;a98JbMxofi~zk)*}w_DP~T)4fUz^yYn zc5{7>0@RDlH5v=IitA`H4L?J2D{M>D%__}cC?n$O1RZ=Vupvik$|IP zZcR$To%enzY}Z#QEZ;e)$X2-Rz0;YxPdLrhUM>DB`<~CzWv-)OD#F@oV_QEr6k8ou{R$*=E_-0KW47iaB6vKSW9$mH z+ri8X@U1b{(uQq~gobWi8bh3+L!>ZfUjhLv{aWkCE&&UmuE&^r z3rNsc8o?q%AWL`LWJqL)Btu_i1cwZn%#bA$D+&5K&2WDGB!ntf4olu!f>7bI!9!AS z5%hJ~cp!?6CRjlh;@&K3YQ(NN;-=go8-?6SQ z#%A8CW&R%|6h>d#Q0JyqYn%9`H1OZmuEmeQ77e|!3;^kwEsA5Oyw)}9ddWmrUYaj_ zo`x@~j^#T8v}XN%nVNyZ7`wWG%h)w$0}MP&v5YW^E%dCC>qfQU3eTF2{|7x+dA#BE z+8&g+UCLLqcc3Ji^~sYx$#M;`Y9NCWrMi=q2Q5nS&JK=kO7PAWhOJ8Q&d#pwivP|6 kb!=JucNR-C~PfDZFlz{V{}y0{Rg|E(+&07N5d#{d8T literal 0 HcmV?d00001 diff --git a/convex/schema.ts b/convex/schema.ts index f78fd974..ad4f37c9 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -94,6 +94,31 @@ export default defineSchema({ .index("by_strategyId", ["strategyId"]) .index("by_userId", ["userId"]) .index("by_strategyId_userId", ["strategyId", "userId"]), + folderCollaborators: defineTable({ + folderId: v.id("folders"), + userId: v.id("users"), + role: v.union(v.literal("editor"), v.literal("viewer")), + invitedByUserId: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_folderId", ["folderId"]) + .index("by_userId", ["userId"]) + .index("by_folderId_userId", ["folderId", "userId"]), + shareLinks: defineTable({ + token: v.string(), + targetType: v.union(v.literal("strategy"), v.literal("folder")), + strategyId: v.optional(v.id("strategies")), + folderId: v.optional(v.id("folders")), + role: v.union(v.literal("editor"), v.literal("viewer")), + createdByUserId: v.id("users"), + revokedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_token", ["token"]) + .index("by_strategyId", ["strategyId"]) + .index("by_folderId", ["folderId"]), inviteTokens: defineTable({ token: v.string(), strategyId: v.id("strategies"), diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 10b0a2a3..73f5e67b 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -208,7 +208,8 @@ class FolderProvider extends Notifier { } } - await Hive.box(HiveBoxNames.foldersBox).put(newFolder.id, newFolder); + await Hive.box(HiveBoxNames.foldersBox) + .put(newFolder.id, newFolder); return newFolder; } @@ -342,7 +343,8 @@ class FolderProvider extends Notifier { 'customColorValue': newCustomColor.toARGB32(), if (newCustomColor == null) 'clearCustomColorValue': true, }; - await ConvexClient.instance.mutation(name: 'folders:update', args: args); + await ConvexClient.instance + .mutation(name: 'folders:update', args: args); ref.invalidate(cloudFoldersProvider); ref.invalidate(cloudAllFoldersProvider); } catch (error, stackTrace) { @@ -398,6 +400,7 @@ class FolderProvider extends Notifier { return switch (workspace) { LibraryWorkspace.local => _localCurrentFolderId, LibraryWorkspace.cloud => _cloudCurrentFolderId, + LibraryWorkspace.community => null, }; } @@ -406,6 +409,9 @@ class FolderProvider extends Notifier { _localCurrentFolderId = id; return; } + if (workspace == LibraryWorkspace.community) { + return; + } _cloudCurrentFolderId = id; } diff --git a/lib/providers/library_workspace_provider.dart b/lib/providers/library_workspace_provider.dart index 61cccf7a..250a7130 100644 --- a/lib/providers/library_workspace_provider.dart +++ b/lib/providers/library_workspace_provider.dart @@ -4,6 +4,7 @@ import 'package:icarus/providers/auth_provider.dart'; enum LibraryWorkspace { local, cloud, + community, } enum CloudLibrarySection { diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 6cccc101..8fe8c028 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -37,6 +37,10 @@ class FolderContent extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final workspace = ref.watch(libraryWorkspaceProvider); + if (workspace == LibraryWorkspace.community) { + return _buildCommunityPlaceholder(context, ref); + } + final isCloud = workspace == LibraryWorkspace.cloud; if (isCloud) { final cloudSection = ref.watch(cloudLibrarySectionProvider); @@ -342,6 +346,57 @@ class FolderContent extends ConsumerWidget { ), ); } + + Widget _buildCommunityPlaceholder(BuildContext context, WidgetRef ref) { + return Stack( + children: [ + const Positioned.fill( + child: Padding( + padding: EdgeInsets.all(4.0), + child: DotGrid(), + ), + ), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.public, + size: 38, + color: Settings.tacticalVioletTheme.primary, + ), + const SizedBox(height: 16), + const Text( + 'Community strats are coming soon', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + 'This space is reserved for public lineups, team executes, and discoverable strategy packs.', + textAlign: TextAlign.center, + style: TextStyle( + color: Settings.tacticalVioletTheme.mutedForeground, + ), + ), + const SizedBox(height: 18), + ShadButton.secondary( + onPressed: () { + ref + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.local); + }, + child: const Text('Back to Local'), + ), + ], + ), + ), + ), + ], + ); + } } class _SortSelect extends StatelessWidget { diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 649b31c7..e57cd9d5 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -237,7 +237,7 @@ class _FolderNavigatorState extends ConsumerState { CoordinateSystem(playAreaSize: playAreaSize); final workspace = ref.watch(libraryWorkspaceProvider); final isCloudWorkspace = workspace == LibraryWorkspace.cloud; - final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); + final isCommunityWorkspace = workspace == LibraryWorkspace.community; final currentFolderId = ref.watch(folderProvider); final currentFolder = currentFolderId != null ? isCloudWorkspace @@ -249,7 +249,6 @@ class _FolderNavigatorState extends ConsumerState { .read(folderProvider.notifier) .findLocalFolderByID(currentFolderId) : null; - final authState = ref.watch(authProvider); Future navigateWithLoading( BuildContext context, String strategyId) async { // Show loading overlay @@ -326,11 +325,16 @@ class _FolderNavigatorState extends ConsumerState { } } + const double railReservedWidth = 64; + return Stack( children: [ Scaffold( appBar: AppBar( - title: const CurrentPathBar(), + title: const Padding( + padding: EdgeInsets.only(left: railReservedWidth), + child: CurrentPathBar(), + ), toolbarHeight: 70, actionsPadding: const EdgeInsets.only(right: 24), @@ -343,54 +347,6 @@ class _FolderNavigatorState extends ConsumerState { Row( spacing: 15, children: [ - if (cloudAvailable) - ShadSelect( - initialValue: workspace, - selectedOptionBuilder: (context, value) { - return Text( - value == LibraryWorkspace.cloud ? 'Cloud' : 'Local', - ); - }, - options: const [ - ShadOption( - value: LibraryWorkspace.local, - child: Text('Local'), - ), - ShadOption( - value: LibraryWorkspace.cloud, - child: Text('Cloud'), - ), - ], - onChanged: (value) { - if (value == null) return; - ref - .read(libraryWorkspaceProvider.notifier) - .select(value); - }, - ), - ShadButton.secondary( - onPressed: authState.isLoading - ? null - : () { - if (authState.isAuthenticated) { - unawaited( - ref.read(authProvider.notifier).signOut()); - } else { - showDialog( - context: context, - builder: (_) => const AuthDialog(), - ); - } - }, - leading: Icon( - authState.isAuthenticated ? Icons.logout : Icons.login, - ), - child: Text( - authState.isLoading - ? 'Please wait...' - : (authState.isAuthenticated ? 'Sign Out' : 'Log In'), - ), - ), ShadPopover( controller: _importExportPopoverController, padding: const EdgeInsets.all(8), @@ -441,8 +397,9 @@ class _FolderNavigatorState extends ConsumerState { }, child: ShadButton.secondary( key: _importExportButtonKey, - onPressed: - isCloudWorkspace ? null : _toggleImportExportPopover, + onPressed: isCloudWorkspace || isCommunityWorkspace + ? null + : _toggleImportExportPopover, leading: const Icon(Icons.import_export), trailing: const Icon(Icons.keyboard_arrow_down), child: const Text('Import / Export'), @@ -450,18 +407,20 @@ class _FolderNavigatorState extends ConsumerState { ), ShadButton.secondary( leading: const Icon(LucideIcons.folderPlus), + onPressed: isCommunityWorkspace + ? null + : () async { + await showDialog( + context: context, + builder: (context) { + return const FolderEditDialog(); + }, + ); + }, child: const Text('Add Folder'), - onPressed: () async { - await showDialog( - context: context, - builder: (context) { - return const FolderEditDialog(); - }, - ); - }, ), ShadButton( - onPressed: showCreateDialog, + onPressed: isCommunityWorkspace ? null : showCreateDialog, leading: const Icon(Icons.add), child: Text( isCloudWorkspace @@ -474,7 +433,16 @@ class _FolderNavigatorState extends ConsumerState { ], // ... your existing actions ), - body: FolderContent(folder: currentFolder), + body: Padding( + padding: const EdgeInsets.only(left: railReservedWidth), + child: FolderContent(folder: currentFolder), + ), + ), + const Positioned( + left: 0, + top: 0, + bottom: 0, + child: LibraryNavigationRail(), ), if (_desktopUpdaterController != null) DesktopUpdateDialogListener( @@ -501,3 +469,504 @@ class StrategyItem extends GridItem { StrategyItem.cloud(this.strategyId) : strategy = null; } + +class LibraryNavigationRail extends ConsumerStatefulWidget { + const LibraryNavigationRail({super.key}); + + @override + ConsumerState createState() => + _LibraryNavigationRailState(); +} + +class _LibraryNavigationRailState extends ConsumerState { + static const _closeDelay = Duration(milliseconds: 120); + static const _detailsDelay = Duration(milliseconds: 190); + + bool _expanded = false; + bool _showExpandedContent = false; + Timer? _closeTimer; + + @override + void dispose() { + _closeTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final workspace = ref.watch(libraryWorkspaceProvider); + final cloudSection = ref.watch(cloudLibrarySectionProvider); + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); + final authState = ref.watch(authProvider); + + final items = [ + _LibraryRailItemData( + icon: LucideIcons.monitor, + label: 'This Computer', + description: 'Local strategies and imports', + selected: workspace == LibraryWorkspace.local, + onTap: () => _selectLocal(), + ), + _LibraryRailItemData( + icon: LucideIcons.cloud, + label: 'Cloud', + description: cloudAvailable + ? 'Your online strategies' + : 'Log in to sync strategies', + selected: workspace == LibraryWorkspace.cloud && + cloudSection == CloudLibrarySection.home, + onTap: cloudAvailable ? () => _selectCloudHome() : null, + ), + _LibraryRailItemData( + icon: LucideIcons.users, + label: 'Shared', + description: cloudAvailable + ? 'Strategies shared with you' + : 'Log in to view shared strats', + selected: workspace == LibraryWorkspace.cloud && + cloudSection == CloudLibrarySection.sharedWithMe, + onTap: cloudAvailable ? () => _selectShared() : null, + ), + _LibraryRailItemData( + icon: Icons.public, + label: 'Community', + description: 'Public strategy library', + selected: workspace == LibraryWorkspace.community, + onTap: () => _selectCommunity(), + ), + ]; + + return MouseRegion( + onEnter: (_) { + _closeTimer?.cancel(); + setState(() => _expanded = true); + Future.delayed(_detailsDelay, () { + if (!mounted || !_expanded) { + return; + } + setState(() => _showExpandedContent = true); + }); + }, + onExit: (_) { + _closeTimer?.cancel(); + _closeTimer = Timer(_closeDelay, () { + if (!mounted) { + return; + } + setState(() { + _showExpandedContent = false; + _expanded = false; + }); + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + width: _expanded ? 226 : 64, + margin: EdgeInsets.zero, + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card.withValues(alpha: 0.96), + borderRadius: const BorderRadius.only( + // topRight: Radius.circular(14), + // bottomRight: Radius.circular(14), + ), + border: Border.all(color: Settings.tacticalVioletTheme.border), + boxShadow: const [Settings.cardForegroundBackdrop], + ), + child: ClipRRect( + borderRadius: const BorderRadius.only( + // topRight: Radius.circular(14), + // bottomRight: Radius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 8), + child: _RailHeader( + expanded: _expanded, + showDetails: _showExpandedContent, + ), + ), + Divider(height: 1, color: Settings.tacticalVioletTheme.border), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 10, 8, 10), + child: Column( + children: [ + for (final item in items) ...[ + _LibraryRailItem( + data: item, + expanded: _expanded, + showDetails: _showExpandedContent, + ), + const SizedBox(height: 8), + ], + const Spacer(), + _AccountRailItem( + expanded: _expanded, + showDetails: _showExpandedContent, + isLoading: authState.isLoading, + isAuthenticated: authState.isAuthenticated, + avatarUrl: authState.avatarUrl, + label: authState.isAuthenticated + ? authState.displayName + : 'Log In', + onAuthAction: authState.isLoading + ? null + : () { + if (authState.isAuthenticated) { + unawaited( + ref.read(authProvider.notifier).signOut(), + ); + } else { + showDialog( + context: context, + builder: (_) => const AuthDialog(), + ); + } + }, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _selectLocal() { + ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.local); + ref.read(folderProvider.notifier).updateID(null); + } + + void _selectCloudHome() { + ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.cloud); + ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.home); + ref.read(folderProvider.notifier).updateID(null); + } + + void _selectShared() { + ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.cloud); + ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.sharedWithMe); + ref.read(folderProvider.notifier).updateID(null); + } + + void _selectCommunity() { + ref + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.community); + ref.read(folderProvider.notifier).updateID(null); + } +} + +class _RailHeader extends StatelessWidget { + const _RailHeader({ + required this.expanded, + required this.showDetails, + }); + + final bool expanded; + final bool showDetails; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 42, + child: LayoutBuilder( + builder: (context, constraints) { + final showLabel = showDetails && constraints.maxWidth >= 96; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 48, + child: Center( + child: SizedBox( + width: 32, + height: 32, + child: Image.asset( + 'assets/icarus-icon.webp', + fit: BoxFit.contain, + ), + ), + ), + ), + Positioned.fill( + left: 50, + child: IgnorePointer( + ignoring: !showLabel, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: expanded && showLabel ? 1 : 0, + child: const Align( + alignment: Alignment.centerLeft, + child: Text( + 'Icarus', + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w800), + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _LibraryRailItemData { + const _LibraryRailItemData({ + required this.icon, + required this.label, + required this.description, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String label; + final String description; + final bool selected; + final VoidCallback? onTap; +} + +class _LibraryRailItem extends StatelessWidget { + const _LibraryRailItem({ + required this.data, + required this.expanded, + required this.showDetails, + }); + + final _LibraryRailItemData data; + final bool expanded; + final bool showDetails; + + @override + Widget build(BuildContext context) { + final selectedColor = + Settings.tacticalVioletTheme.primary.withValues(alpha: 0.18); + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + mouseCursor: data.onTap == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: data.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: data.selected ? selectedColor : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: data.selected + ? Settings.tacticalVioletTheme.primary + : Colors.transparent, + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final showLabel = showDetails && constraints.maxWidth >= 96; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 26, + child: Align( + alignment: Alignment.center, + child: Icon( + data.icon, + size: 21, + color: data.onTap == null + ? Settings.tacticalVioletTheme.mutedForeground + : null, + ), + ), + ), + Positioned.fill( + left: 33, + child: IgnorePointer( + ignoring: !showLabel, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: expanded && showLabel ? 1 : 0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 1), + Text( + data.description, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Settings + .tacticalVioletTheme.mutedForeground, + fontSize: 10, + ), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _AccountRailItem extends StatelessWidget { + const _AccountRailItem({ + required this.expanded, + required this.showDetails, + required this.isLoading, + required this.isAuthenticated, + required this.avatarUrl, + required this.label, + required this.onAuthAction, + }); + + final bool expanded; + final bool showDetails; + final bool isLoading; + final bool isAuthenticated; + final String? avatarUrl; + final String label; + final VoidCallback? onAuthAction; + + @override + Widget build(BuildContext context) { + final showExpandedLayout = expanded && showDetails; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + mouseCursor: onAuthAction == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: showExpandedLayout ? null : onAuthAction, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.secondary.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final showLabel = showDetails && constraints.maxWidth >= 96; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 28, + child: Align( + alignment: Alignment.center, + child: _AccountAvatar( + avatarUrl: avatarUrl, + isAuthenticated: isAuthenticated, + ), + ), + ), + Positioned.fill( + left: 38, + child: IgnorePointer( + ignoring: !showLabel, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + opacity: expanded && showLabel ? 1 : 0, + child: showLabel + ? Row( + children: [ + Expanded( + child: Text( + isLoading ? 'Please wait...' : label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ) + : const SizedBox.shrink(), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _AccountAvatar extends StatelessWidget { + const _AccountAvatar({ + required this.avatarUrl, + required this.isAuthenticated, + }); + + final String? avatarUrl; + final bool isAuthenticated; + + @override + Widget build(BuildContext context) { + if (isAuthenticated && avatarUrl != null) { + return CircleAvatar( + radius: 14, + backgroundImage: NetworkImage(avatarUrl!), + ); + } + + return CircleAvatar( + radius: 14, + backgroundColor: Settings.tacticalVioletTheme.card, + child: Icon( + isAuthenticated ? Icons.person : LucideIcons.userRound, + size: 15, + ), + ); + } +} diff --git a/lib/widgets/folder_navigator_sidebar.dart b/lib/widgets/folder_navigator_sidebar.dart index 5ae38728..f4b418dd 100644 --- a/lib/widgets/folder_navigator_sidebar.dart +++ b/lib/widgets/folder_navigator_sidebar.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -416,16 +418,20 @@ class _FolderSidebarItem extends ConsumerStatefulWidget { } class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { + static const _hoverExitDelay = Duration(milliseconds: 500); + final ShadContextMenuController _menuButtonController = ShadContextMenuController(); final ShadContextMenuController _rightClickMenuController = ShadContextMenuController(); bool _hovered = false; + Timer? _hoverExitTimer; Folder get folder => widget.node.folder; @override void dispose() { + _hoverExitTimer?.cancel(); _menuButtonController.dispose(); _rightClickMenuController.dispose(); super.dispose(); @@ -472,8 +478,19 @@ class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { return Padding( padding: EdgeInsets.only(left: widget.depth * 16.0), child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + onEnter: (_) { + _hoverExitTimer?.cancel(); + setState(() => _hovered = true); + }, + onExit: (_) { + _hoverExitTimer?.cancel(); + _hoverExitTimer = Timer(_hoverExitDelay, () { + if (!mounted) { + return; + } + setState(() => _hovered = false); + }); + }, child: ShadContextMenuRegion( controller: _rightClickMenuController, items: _buildMenuItems(), diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index 94397581..8957ea39 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -11,7 +11,6 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; -import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/map_theme_settings_section.dart'; import 'package:icarus/widgets/settings_scope_card.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -94,47 +93,6 @@ class SettingsTab extends ConsumerWidget { ), ], ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: authState.isAuthenticated - ? ShadButton.secondary( - onPressed: authState.isLoading - ? null - : () => ref - .read(authProvider.notifier) - .signOut(), - child: authState.isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Text('Sign out'), - ) - : ShadButton( - onPressed: authState.isLoading - ? null - : () { - showDialog( - context: context, - builder: (_) => - const AuthDialog(), - ); - }, - child: authState.isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Text('Sign in / sign up'), - ), - ), if (authState.errorMessage != null) ...[ const SizedBox(height: 8), Text( From 19e134468a623bebb71f1de8fc6a2720299f8354 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:41:57 -0400 Subject: [PATCH 12/15] Delay library rail hover after leaving strategy --- lib/providers/library_rail_hover_provider.dart | 3 +++ lib/strategy_view.dart | 2 ++ lib/widgets/folder_navigator.dart | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+) create mode 100644 lib/providers/library_rail_hover_provider.dart diff --git a/lib/providers/library_rail_hover_provider.dart b/lib/providers/library_rail_hover_provider.dart new file mode 100644 index 00000000..1733a4a5 --- /dev/null +++ b/lib/providers/library_rail_hover_provider.dart @@ -0,0 +1,3 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final suppressLibraryRailHoverProvider = StateProvider((ref) => false); diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index dc9d56f8..3bfe2352 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -7,6 +7,7 @@ import 'package:icarus/interactive_map.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; +import 'package:icarus/providers/library_rail_hover_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; import 'package:icarus/sidebar.dart'; @@ -70,6 +71,7 @@ class _StrategyViewState extends ConsumerState .updateFilterState(FilterState.all); ref.read(deleteMenuProvider.notifier).requestClose(); if (mounted) { + ref.read(suppressLibraryRailHoverProvider.notifier).state = true; Navigator.pop(context); } await ref.read(strategyProvider.notifier).clearCurrentStrategy(); diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index e57cd9d5..f0e46b00 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -12,6 +12,7 @@ import 'package:icarus/main.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_rail_hover_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; @@ -481,14 +482,17 @@ class LibraryNavigationRail extends ConsumerStatefulWidget { class _LibraryNavigationRailState extends ConsumerState { static const _closeDelay = Duration(milliseconds: 120); static const _detailsDelay = Duration(milliseconds: 190); + static const _routeArrivalHoverDelay = Duration(seconds: 2); bool _expanded = false; bool _showExpandedContent = false; Timer? _closeTimer; + Timer? _routeArrivalHoverTimer; @override void dispose() { _closeTimer?.cancel(); + _routeArrivalHoverTimer?.cancel(); super.dispose(); } @@ -538,6 +542,16 @@ class _LibraryNavigationRailState extends ConsumerState { return MouseRegion( onEnter: (_) { + if (ref.read(suppressLibraryRailHoverProvider)) { + _routeArrivalHoverTimer?.cancel(); + _routeArrivalHoverTimer = Timer(_routeArrivalHoverDelay, () { + if (!mounted) { + return; + } + ref.read(suppressLibraryRailHoverProvider.notifier).state = false; + }); + return; + } _closeTimer?.cancel(); setState(() => _expanded = true); Future.delayed(_detailsDelay, () { @@ -548,6 +562,10 @@ class _LibraryNavigationRailState extends ConsumerState { }); }, onExit: (_) { + _routeArrivalHoverTimer?.cancel(); + if (ref.read(suppressLibraryRailHoverProvider)) { + ref.read(suppressLibraryRailHoverProvider.notifier).state = false; + } _closeTimer?.cancel(); _closeTimer = Timer(_closeDelay, () { if (!mounted) { From d9c2b9898852512db08d06625c9ace8da5916d50 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 12 May 2026 15:32:10 -0400 Subject: [PATCH 13/15] Handle missing text measurements when switching sides --- lib/providers/action_provider.dart | 8 +++- lib/providers/text_provider.dart | 58 +++++++++++++++++++----- test/action_history_hydration_test.dart | 24 ++++++++-- test/text_widget_resilience_test.dart | 59 +++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 15 deletions(-) diff --git a/lib/providers/action_provider.dart b/lib/providers/action_provider.dart index f8e957d1..6b1df226 100644 --- a/lib/providers/action_provider.dart +++ b/lib/providers/action_provider.dart @@ -535,12 +535,18 @@ class ActionProvider extends Notifier> { void switchSides() { final mapState = ref.read(mapProvider); + final textSizes = + Map.from(ref.read(textWidgetHeightProvider)); + final textNotifier = ref.read(textProvider.notifier); + for (final text in ref.read(textProvider)) { + textSizes[text.id] = textNotifier.switchSizeForText(text); + } final context = ActionHistoryTransformContext( agentSize: ref.read(strategySettingsProvider).agentSize, abilitySize: ref.read(strategySettingsProvider).abilitySize, mapScale: Maps.mapScale[mapState.currentMap] ?? 1.0, imageSizes: Map.from(ref.read(imageWidgetSizeProvider)), - textHeights: Map.from(ref.read(textWidgetHeightProvider)), + textHeights: textSizes, ); state = state.map((action) => action.switchSides(context)).toList(); poppedItems = diff --git a/lib/providers/text_provider.dart b/lib/providers/text_provider.dart index 055d0c93..3e6282a5 100644 --- a/lib/providers/text_provider.dart +++ b/lib/providers/text_provider.dart @@ -26,6 +26,9 @@ class TextProvider extends Notifier> { (1000.0 * (16 / 9)) / CoordinateSystem.screenShotSize.width; static final double _legacyFontToWorldFactor = 1000.0 / CoordinateSystem.screenShotSize.height; + static const double _textWidgetHorizontalChrome = 18.0; + static const double _textWidgetVerticalChrome = 34.0; + static const double _textWidgetMinHeight = 48.0; List poppedText = []; @@ -51,8 +54,9 @@ class TextProvider extends Notifier> { group: ActionGroup.text, objectDelta: ObjectHistoryDelta( after: ActionObjectState.text(text), - afterTextHeights: - ref.read(textWidgetHeightProvider.notifier).takeSnapshotForIds([text.id]), + afterTextHeights: ref + .read(textWidgetHeightProvider.notifier) + .takeSnapshotForIds([text.id]), ), ); @@ -101,10 +105,12 @@ class TextProvider extends Notifier> { objectDelta: ObjectHistoryDelta( before: before, after: ActionObjectState.text(temp), - beforeTextHeights: - ref.read(textWidgetHeightProvider.notifier).takeSnapshotForIds([id]), - afterTextHeights: - ref.read(textWidgetHeightProvider.notifier).takeSnapshotForIds([id]), + beforeTextHeights: ref + .read(textWidgetHeightProvider.notifier) + .takeSnapshotForIds([id]), + afterTextHeights: ref + .read(textWidgetHeightProvider.notifier) + .takeSnapshotForIds([id]), ), ); ref.read(actionProvider.notifier).addAction(action); @@ -115,18 +121,47 @@ class TextProvider extends Notifier> { void switchSides() { final newState = [...state]; for (final text in newState) { - text.switchSides( - ref.read(textWidgetHeightProvider.notifier).getOffset(text.id)); + text.switchSides(_switchSizeForText(text)); } for (final text in poppedText) { - text.switchSides( - ref.read(textWidgetHeightProvider.notifier).getOffset(text.id)); + text.switchSides(_switchSizeForText(text)); } state = newState; } + Offset _switchSizeForText(PlacedText text) { + final measuredSize = + ref.read(textWidgetHeightProvider.notifier).getOffset(text.id); + if (measuredSize != Offset.zero) return measuredSize; + + final coordinateSystem = CoordinateSystem.instance; + final totalWidth = measuredSize.dx > 0 + ? measuredSize.dx + : coordinateSystem.worldWidthToScreen(text.size); + final fontSize = coordinateSystem.worldHeightToScreen(text.fontSize); + final contentWidth = + (totalWidth - _textWidgetHorizontalChrome).clamp(1.0, double.infinity); + final displayText = + ref.read(textDraftProvider.notifier).draftFor(text.id) ?? text.text; + final painter = TextPainter( + text: TextSpan( + text: displayText.isEmpty ? 'Write here...' : displayText, + style: TextStyle(fontSize: fontSize), + ), + maxLines: null, + textDirection: TextDirection.ltr, + )..layout(maxWidth: contentWidth); + final totalHeight = (painter.height + _textWidgetVerticalChrome) + .clamp(_textWidgetMinHeight, double.infinity); + return Offset(totalWidth, totalHeight); + } + + Offset switchSizeForText(PlacedText text) { + return _switchSizeForText(text); + } + void commitText(String id, String nextText) { final newState = [...state]; final index = PlacedWidget.getIndexByID(id, newState); @@ -341,7 +376,8 @@ class TextProvider extends Notifier> { void restoreSnapshot(TextProviderSnapshot snapshot) { ref.read(textDraftProvider.notifier).clearAllDrafts(); - poppedText = snapshot.poppedText.map((text) => clonePlacedText(text)).toList(); + poppedText = + snapshot.poppedText.map((text) => clonePlacedText(text)).toList(); state = snapshot.texts.map((text) => clonePlacedText(text)).toList(); } diff --git a/test/action_history_hydration_test.dart b/test/action_history_hydration_test.dart index a7c0107d..deed01b3 100644 --- a/test/action_history_hydration_test.dart +++ b/test/action_history_hydration_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/drawing_element.dart'; -import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; @@ -16,7 +15,7 @@ import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( strategyId: 'test-strategy', strategyName: 'Test Strategy', source: StrategySource.local, @@ -56,7 +55,8 @@ void main() { CoordinateSystem(playAreaSize: const Size(1920, 1080)); }); - test('preserveHistory keeps text undo/redo working across hydration', () async { + test('preserveHistory keeps text undo/redo working across hydration', + () async { final container = _createContainer(); final notifier = container.read(textProvider.notifier); @@ -139,4 +139,22 @@ void main() { expect(flippedDeleted.lineStart, _flipPoint(const Offset(10, 20))); expect(flippedDeleted.lineEnd, _flipPoint(const Offset(40, 50))); }); + + test('switchSide mirrors text width when measurement is missing', () { + final container = _createContainer(); + final text = PlacedText( + id: 'text-1', + position: const Offset(10, 20), + size: 100, + fontSize: 20, + sizeVersion: worldSizedMediaVersion, + )..text = 'One line'; + + container.read(textProvider.notifier).fromHive([text]); + container.read(mapProvider.notifier).switchSide(); + + final flipped = container.read(textProvider).single.position; + + expect(flipped.dx, lessThan(_flipPoint(text.position).dx)); + }); } diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index b8f9f6e7..03c706b6 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -18,6 +18,7 @@ import 'package:icarus/providers/strategy_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/text_widget_height_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; @@ -323,4 +324,62 @@ void main() { expect(feedbackSize.width, editableSize.width); expect(feedbackSize.height, editableSize.height); }); + + testWidgets('side switch mirrors text like a bounded widget when unmeasured', + (tester) async { + final container = createContainer(); + final placedText = PlacedText( + id: 'text-1', + position: const Offset(10, 20), + size: 220, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = 'same text\nsecond line'; + + container.read(textProvider.notifier).fromHive([placedText]); + await tester.pumpWidget(buildTextHarness(container)); + await tester.pump(); + + final renderedSize = tester.getSize(find.byType(TextWidget)); + container.read(textWidgetHeightProvider.notifier).clearEntries(['text-1']); + + container.read(textProvider.notifier).switchSides(); + + expect( + container.read(textProvider).single.position, + getFlippedPosition( + position: placedText.position, + scaledSize: Offset(renderedSize.width, renderedSize.height), + ), + ); + }); + + testWidgets( + 'side switch uses the measured rendered text height for vertical placement', + (tester) async { + final container = createContainer(); + final placedText = PlacedText( + id: 'text-1', + position: const Offset(10, 20), + size: 220, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = 'one line'; + + container.read(textProvider.notifier).fromHive([placedText]); + await tester.pumpWidget(buildTextHarness(container)); + await tester.pump(); + + final renderedSize = tester.getSize(find.byType(TextWidget)); + + container.read(textProvider.notifier).switchSides(); + + expect( + container.read(textProvider).single.position, + getFlippedPosition( + position: placedText.position, + scaledSize: Offset(renderedSize.width, renderedSize.height), + ), + ); + }); } From a71ea700c16bb695551aceda1a2ce2aea936b39e Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 12 May 2026 21:59:09 -0400 Subject: [PATCH 14/15] Remove widget size state from mirrored media actions - Derive image and text flip dimensions from content directly - Delete per-widget size providers and snapshot plumbing - Add dimension helpers and cover side-switch behavior in tests --- lib/const/placed_media_dimensions.dart | 84 +++++++++ lib/providers/action_history_models.dart | 61 +++---- lib/providers/action_provider.dart | 85 ++------- lib/providers/image_provider.dart | 44 ++--- lib/providers/image_widget_size_provider.dart | 49 ------ lib/providers/map_provider.dart | 3 + lib/providers/text_provider.dart | 71 +------- .../text_widget_height_provider.dart | 49 ------ lib/strategy/strategy_page_apply.dart | 4 - .../draggable_widgets/image/image_widget.dart | 131 ++++++-------- .../draggable_widgets/text/text_widget.dart | 161 +++++++++--------- test/action_provider_bulk_clear_test.dart | 25 --- test/placed_media_dimensions_test.dart | 100 +++++++++++ test/side_switch_media_test.dart | 103 +++++++++++ test/text_widget_resilience_test.dart | 15 +- 15 files changed, 493 insertions(+), 492 deletions(-) create mode 100644 lib/const/placed_media_dimensions.dart delete mode 100644 lib/providers/image_widget_size_provider.dart delete mode 100644 lib/providers/text_widget_height_provider.dart create mode 100644 test/placed_media_dimensions_test.dart create mode 100644 test/side_switch_media_test.dart diff --git a/lib/const/placed_media_dimensions.dart b/lib/const/placed_media_dimensions.dart new file mode 100644 index 00000000..cd47c21f --- /dev/null +++ b/lib/const/placed_media_dimensions.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/image_scale_policy.dart'; + +abstract final class PlacedImageDimensions { + static const double tagWidth = 10.0; + static const double tagGap = 2.0; + static const double imagePadding = 5.0; + + static Size screenSize({ + required CoordinateSystem coordinateSystem, + required double scale, + required double aspectRatio, + }) { + final safeAspectRatio = aspectRatio <= 0 ? 1.0 : aspectRatio; + final totalWidth = + coordinateSystem.worldWidthToScreen(ImageScalePolicy.clamp(scale)); + final cardWidth = + (totalWidth - tagWidth - tagGap).clamp(1.0, double.infinity); + final contentWidth = + (cardWidth - (imagePadding * 2)).clamp(1.0, double.infinity); + final totalHeight = (contentWidth / safeAspectRatio) + (imagePadding * 2); + + return Size(totalWidth, totalHeight); + } +} + +abstract final class PlacedTextDimensions { + static const double tagWidth = 6.0; + static const double tagGap = 2.0; + static const double cardHorizontalPadding = 5.0; + static const double cardVerticalPadding = 6.0; + static const double minHeight = 64.0; + static const String emptyTextPlaceholder = 'Write here...'; + + static Size screenSize({ + required CoordinateSystem coordinateSystem, + required double widthWorld, + required double fontSizeWorld, + required String text, + }) { + final totalWidth = coordinateSystem.worldWidthToScreen(widthWorld); + final maxContentWidth = contentWidth( + coordinateSystem: coordinateSystem, + widthWorld: widthWorld, + ); + + final displayText = text.isEmpty ? emptyTextPlaceholder : text; + final fontSizePx = PlacedTextDimensions.fontSizePx( + coordinateSystem: coordinateSystem, + fontSizeWorld: fontSizeWorld, + ); + + final painter = TextPainter( + text: TextSpan( + text: displayText, + style: TextStyle(fontSize: fontSizePx), + ), + maxLines: null, + textDirection: TextDirection.ltr, + textScaler: TextScaler.noScaling, + )..layout(maxWidth: maxContentWidth); + + final totalHeight = (painter.height + (cardVerticalPadding * 2)) + .clamp(minHeight, double.infinity); + return Size(totalWidth, totalHeight); + } + + static double contentWidth({ + required CoordinateSystem coordinateSystem, + required double widthWorld, + }) { + final totalWidth = coordinateSystem.worldWidthToScreen(widthWorld); + return (totalWidth - tagWidth - tagGap - (cardHorizontalPadding * 2)) + .clamp(1.0, double.infinity); + } + + static double fontSizePx({ + required CoordinateSystem coordinateSystem, + required double fontSizeWorld, + }) { + return coordinateSystem.worldHeightToScreen(fontSizeWorld); + } +} diff --git a/lib/providers/action_history_models.dart b/lib/providers/action_history_models.dart index 1c43d691..917836ce 100644 --- a/lib/providers/action_history_models.dart +++ b/lib/providers/action_history_models.dart @@ -5,20 +5,17 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; class ActionHistoryTransformContext { final double agentSize; final double abilitySize; final double mapScale; - final Map imageSizes; - final Map textHeights; const ActionHistoryTransformContext({ required this.agentSize, required this.abilitySize, required this.mapScale, - required this.imageSizes, - required this.textHeights, }); } @@ -51,8 +48,7 @@ class ActionObjectState { agent: clonePlacedAgentNode(value), ); - factory ActionObjectState.ability(PlacedAbility value) => - ActionObjectState._( + factory ActionObjectState.ability(PlacedAbility value) => ActionObjectState._( id: value.id, kind: ActionObjectKind.ability, ability: clonePlacedAbility(value), @@ -116,14 +112,8 @@ class ActionObjectState { ActionObjectKind.drawing => ActionObjectState.drawing( switchDrawingElementSides(cloneDrawingElement(drawing!)), ), - ActionObjectKind.text => ActionObjectState.text( - clonePlacedText(text!) - ..switchSides(context.textHeights[text!.id] ?? Offset.zero), - ), - ActionObjectKind.image => ActionObjectState.image( - clonePlacedImage(image!) - ..switchSides(context.imageSizes[image!.id] ?? Offset.zero), - ), + ActionObjectKind.text => _switchTextSides(), + ActionObjectKind.image => _switchImageSides(), ActionObjectKind.utility => ActionObjectState.utility( clonePlacedUtility(utility!) ..switchSides( @@ -142,6 +132,33 @@ class ActionObjectState { ), }; } + + ActionObjectState _switchTextSides() { + final value = clonePlacedText(text!); + final size = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: value.size, + fontSizeWorld: value.fontSize, + text: value.text, + ); + + return ActionObjectState.text( + value..switchSides(Offset(size.width, size.height)), + ); + } + + ActionObjectState _switchImageSides() { + final value = clonePlacedImage(image!); + final size = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: value.scale, + aspectRatio: value.aspectRatio, + ); + + return ActionObjectState.image( + value..switchSides(Offset(size.width, size.height)), + ); + } } enum ActionObjectKind { @@ -157,18 +174,10 @@ enum ActionObjectKind { class ObjectHistoryDelta { final ActionObjectState? before; final ActionObjectState? after; - final Map beforeImageSizes; - final Map afterImageSizes; - final Map beforeTextHeights; - final Map afterTextHeights; const ObjectHistoryDelta({ this.before, this.after, - this.beforeImageSizes = const {}, - this.afterImageSizes = const {}, - this.beforeTextHeights = const {}, - this.afterTextHeights = const {}, }); String get id => after?.id ?? before!.id; @@ -177,10 +186,6 @@ class ObjectHistoryDelta { return ObjectHistoryDelta( before: before?.clone(), after: after?.clone(), - beforeImageSizes: Map.from(beforeImageSizes), - afterImageSizes: Map.from(afterImageSizes), - beforeTextHeights: Map.from(beforeTextHeights), - afterTextHeights: Map.from(afterTextHeights), ); } @@ -188,10 +193,6 @@ class ObjectHistoryDelta { return ObjectHistoryDelta( before: before?.switchSides(context), after: after?.switchSides(context), - beforeImageSizes: Map.from(beforeImageSizes), - afterImageSizes: Map.from(afterImageSizes), - beforeTextHeights: Map.from(beforeTextHeights), - afterTextHeights: Map.from(afterTextHeights), ); } } diff --git a/lib/providers/action_provider.dart b/lib/providers/action_provider.dart index 6b1df226..5e53c012 100644 --- a/lib/providers/action_provider.dart +++ b/lib/providers/action_provider.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/line_provider.dart'; @@ -10,11 +8,9 @@ import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/providers/text_widget_height_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:uuid/uuid.dart'; @@ -77,8 +73,6 @@ class BulkActionSnapshot { final PlacedImageProviderSnapshot? imageSnapshot; final UtilityProviderSnapshot? utilitySnapshot; final LineUpProviderSnapshot? lineUpSnapshot; - final Map imageSizeSnapshot; - final Map textHeightSnapshot; const BulkActionSnapshot({ required this.targetGroups, @@ -91,8 +85,6 @@ class BulkActionSnapshot { this.imageSnapshot, this.utilitySnapshot, this.lineUpSnapshot, - this.imageSizeSnapshot = const {}, - this.textHeightSnapshot = const {}, }); BulkActionSnapshot copy() { @@ -178,8 +170,6 @@ class BulkActionSnapshot { .map((lineUp) => cloneLineUp(lineUp)) .toList(), ), - imageSizeSnapshot: Map.from(imageSizeSnapshot), - textHeightSnapshot: Map.from(textHeightSnapshot), ); } @@ -252,18 +242,14 @@ class BulkActionSnapshot { : TextProviderSnapshot( texts: textSnapshot!.texts .map( - (text) => clonePlacedText(text) - ..switchSides( - context.textHeights[text.id] ?? Offset.zero, - ), + (text) => + ActionObjectState.text(text).switchSides(context).text!, ) .toList(), poppedText: textSnapshot!.poppedText .map( - (text) => clonePlacedText(text) - ..switchSides( - context.textHeights[text.id] ?? Offset.zero, - ), + (text) => + ActionObjectState.text(text).switchSides(context).text!, ) .toList(), ), @@ -272,16 +258,16 @@ class BulkActionSnapshot { : PlacedImageProviderSnapshot( images: imageSnapshot!.images .map( - (image) => clonePlacedImage(image) - ..switchSides( - context.imageSizes[image.id] ?? Offset.zero), + (image) => ActionObjectState.image(image) + .switchSides(context) + .image!, ) .toList(), poppedImages: imageSnapshot!.poppedImages .map( - (image) => clonePlacedImage(image) - ..switchSides( - context.imageSizes[image.id] ?? Offset.zero), + (image) => ActionObjectState.image(image) + .switchSides(context) + .image!, ) .toList(), ), @@ -333,8 +319,6 @@ class BulkActionSnapshot { ) .toList(), ), - imageSizeSnapshot: Map.from(imageSizeSnapshot), - textHeightSnapshot: Map.from(textHeightSnapshot), ); } } @@ -518,8 +502,6 @@ class ActionProvider extends Notifier> { ref.read(utilityProvider.notifier).clearAll(); ref.read(lineUpProvider.notifier).clearAll(); - ref.read(imageWidgetSizeProvider.notifier).clearAll(); - ref.read(textWidgetHeightProvider.notifier).clearAll(); state = []; } @@ -535,18 +517,10 @@ class ActionProvider extends Notifier> { void switchSides() { final mapState = ref.read(mapProvider); - final textSizes = - Map.from(ref.read(textWidgetHeightProvider)); - final textNotifier = ref.read(textProvider.notifier); - for (final text in ref.read(textProvider)) { - textSizes[text.id] = textNotifier.switchSizeForText(text); - } final context = ActionHistoryTransformContext( agentSize: ref.read(strategySettingsProvider).agentSize, abilitySize: ref.read(strategySettingsProvider).abilitySize, mapScale: Maps.mapScale[mapState.currentMap] ?? 1.0, - imageSizes: Map.from(ref.read(imageWidgetSizeProvider)), - textHeights: textSizes, ); state = state.map((action) => action.switchSides(context)).toList(); poppedItems = @@ -620,7 +594,6 @@ class ActionProvider extends Notifier> { ); _clearProvidersForGroups(targetGroups); - _clearAncillaryState(snapshot); state = filteredActions; addAction( @@ -658,13 +631,6 @@ class ActionProvider extends Notifier> { } BulkActionSnapshot _captureBulkSnapshot(List groups) { - final imageIds = groups.contains(ActionGroup.image) - ? ref.read(placedImageProvider).images.map((image) => image.id) - : const []; - final textIds = groups.contains(ActionGroup.text) - ? ref.read(textProvider).map((text) => text.id) - : const []; - return BulkActionSnapshot( targetGroups: [...groups], actionStateBefore: state.map((action) => action.copy()).toList(), @@ -690,12 +656,6 @@ class ActionProvider extends Notifier> { lineUpSnapshot: groups.contains(ActionGroup.lineUp) ? ref.read(lineUpProvider.notifier).takeSnapshot() : null, - imageSizeSnapshot: ref - .read(imageWidgetSizeProvider.notifier) - .takeSnapshotForIds(imageIds), - textHeightSnapshot: ref - .read(textWidgetHeightProvider.notifier) - .takeSnapshotForIds(textIds), ); } @@ -750,19 +710,6 @@ class ActionProvider extends Notifier> { } } - void _clearAncillaryState(BulkActionSnapshot snapshot) { - if (snapshot.imageSizeSnapshot.isNotEmpty) { - ref - .read(imageWidgetSizeProvider.notifier) - .clearEntries(snapshot.imageSizeSnapshot.keys); - } - if (snapshot.textHeightSnapshot.isNotEmpty) { - ref - .read(textWidgetHeightProvider.notifier) - .clearEntries(snapshot.textHeightSnapshot.keys); - } - } - void _restoreBulkSnapshot(BulkActionSnapshot snapshot) { if (snapshot.agentSnapshot != null) { ref.read(agentProvider.notifier).restoreSnapshot(snapshot.agentSnapshot!); @@ -795,17 +742,6 @@ class ActionProvider extends Notifier> { .read(lineUpProvider.notifier) .restoreSnapshot(snapshot.lineUpSnapshot!); } - - if (snapshot.imageSizeSnapshot.isNotEmpty) { - ref - .read(imageWidgetSizeProvider.notifier) - .restoreSnapshot(snapshot.imageSizeSnapshot); - } - if (snapshot.textHeightSnapshot.isNotEmpty) { - ref - .read(textWidgetHeightProvider.notifier) - .restoreSnapshot(snapshot.textHeightSnapshot); - } } void _undoBulkAction(UserAction action) { @@ -822,7 +758,6 @@ class ActionProvider extends Notifier> { if (snapshot == null) return; _clearProvidersForGroups(snapshot.targetGroups); - _clearAncillaryState(snapshot); final newState = _filterActionsForGroups(state, snapshot.targetGroups) ..add(poppedItems.removeLast()); diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 963a9aa8..46553731 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/image_scale_policy.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:image/image.dart' as img; @@ -165,9 +165,6 @@ class PlacedImageProvider extends Notifier { group: ActionGroup.image, objectDelta: ObjectHistoryDelta( after: ActionObjectState.image(placedImage), - afterImageSizes: ref - .read(imageWidgetSizeProvider.notifier) - .takeSnapshotForIds([imageID]), ), ); @@ -199,9 +196,6 @@ class PlacedImageProvider extends Notifier { group: ActionGroup.image, objectDelta: ObjectHistoryDelta( before: ActionObjectState.image(state.images[index]), - beforeImageSizes: ref - .read(imageWidgetSizeProvider.notifier) - .takeSnapshotForIds([id]), ), ), ); @@ -237,10 +231,6 @@ class PlacedImageProvider extends Notifier { objectDelta: ObjectHistoryDelta( before: before, after: ActionObjectState.image(temp), - beforeImageSizes: - ref.read(imageWidgetSizeProvider.notifier).takeSnapshotForIds([id]), - afterImageSizes: - ref.read(imageWidgetSizeProvider.notifier).takeSnapshotForIds([id]), ), ); ref.read(actionProvider.notifier).addAction(action); @@ -260,16 +250,24 @@ class PlacedImageProvider extends Notifier { void switchSides() { final newImages = [...state.images]; for (final image in newImages) { - image.switchSides( - ref.read(imageWidgetSizeProvider.notifier).getSize(image.id)); + image.switchSides(_switchSizeForImage(image)); } for (final image in poppedImages) { - image.switchSides( - ref.read(imageWidgetSizeProvider.notifier).getSize(image.id)); + image.switchSides(_switchSizeForImage(image)); } state = state.copyWith(images: newImages); } + Offset _switchSizeForImage(PlacedImage image) { + final size = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: image.scale, + aspectRatio: image.aspectRatio, + ); + + return Offset(size.width, size.height); + } + void undoAction(UserAction action) { final delta = action.objectDelta; if (delta == null) { @@ -295,7 +293,6 @@ class PlacedImageProvider extends Notifier { } switch (action.type) { case ActionType.addition: - _clearImageSizes(delta.afterImageSizes.keys); removeImage(action.id); return; case ActionType.deletion: @@ -304,13 +301,11 @@ class PlacedImageProvider extends Notifier { return; } _upsertImage(clonePlacedImage(before)); - _restoreImageSizes(delta.beforeImageSizes); return; case ActionType.edit: final before = delta.before?.image; if (before == null) return; _upsertImage(clonePlacedImage(before)); - _restoreImageSizes(delta.beforeImageSizes); return; case ActionType.bulkDeletion: case ActionType.transaction: @@ -346,17 +341,14 @@ class PlacedImageProvider extends Notifier { final after = delta.after?.image; if (after == null) return; _upsertImage(clonePlacedImage(after)); - _restoreImageSizes(delta.afterImageSizes); return; case ActionType.deletion: - _clearImageSizes(delta.beforeImageSizes.keys); removeImage(action.id); return; case ActionType.edit: final after = delta.after?.image; if (after == null) return; _upsertImage(clonePlacedImage(after)); - _restoreImageSizes(delta.afterImageSizes); return; case ActionType.bulkDeletion: case ActionType.transaction: @@ -534,16 +526,6 @@ class PlacedImageProvider extends Notifier { } state = state.copyWith(images: newImages); } - - void _restoreImageSizes(Map snapshot) { - if (snapshot.isEmpty) return; - ref.read(imageWidgetSizeProvider.notifier).restoreSnapshot(snapshot); - } - - void _clearImageSizes(Iterable ids) { - if (ids.isEmpty) return; - ref.read(imageWidgetSizeProvider.notifier).clearEntries(ids); - } } /// A helper class to handle the asynchronous conversion of [PlacedImage]. diff --git a/lib/providers/image_widget_size_provider.dart b/lib/providers/image_widget_size_provider.dart deleted file mode 100644 index cdf30892..00000000 --- a/lib/providers/image_widget_size_provider.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final imageWidgetSizeProvider = - NotifierProvider>( - ImageWidgetSizeProvider.new, -); - -class ImageWidgetSizeProvider extends Notifier> { - @override - Map build() { - return {}; - } - - void updateSize(String id, Offset size) { - state = {...state, id: size}; - } - - Offset getSize(String id) { - return state[id] ?? Offset.zero; - } - - Map takeSnapshotForIds(Iterable ids) { - return { - for (final id in ids) - if (state.containsKey(id)) id: state[id]!, - }; - } - - void clearEntries(Iterable ids) { - final newState = {...state}; - for (final id in ids) { - newState.remove(id); - } - state = newState; - } - - void restoreSnapshot(Map snapshot) { - state = { - ...state, - ...snapshot, - }; - } - - void clearAll() { - state = {}; - } -} diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index f4a5a27c..37384d4f 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -9,6 +9,7 @@ import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; final mapProvider = NotifierProvider(MapProvider.new); @@ -72,6 +73,8 @@ class MapProvider extends Notifier { } void switchSide() { + ref.read(textDraftProvider.notifier).commitAllDrafts(); + // Flip all placed agents to mirror positions before toggling the side ref.read(agentProvider.notifier).switchSides(); ref.read(abilityProvider.notifier).switchSides(); diff --git a/lib/providers/text_provider.dart b/lib/providers/text_provider.dart index 3e6282a5..05a13fb0 100644 --- a/lib/providers/text_provider.dart +++ b/lib/providers/text_provider.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/text_draft_provider.dart'; -import 'package:icarus/providers/text_widget_height_provider.dart'; final textProvider = NotifierProvider>(TextProvider.new); @@ -26,9 +26,6 @@ class TextProvider extends Notifier> { (1000.0 * (16 / 9)) / CoordinateSystem.screenShotSize.width; static final double _legacyFontToWorldFactor = 1000.0 / CoordinateSystem.screenShotSize.height; - static const double _textWidgetHorizontalChrome = 18.0; - static const double _textWidgetVerticalChrome = 34.0; - static const double _textWidgetMinHeight = 48.0; List poppedText = []; @@ -54,9 +51,6 @@ class TextProvider extends Notifier> { group: ActionGroup.text, objectDelta: ObjectHistoryDelta( after: ActionObjectState.text(text), - afterTextHeights: ref - .read(textWidgetHeightProvider.notifier) - .takeSnapshotForIds([text.id]), ), ); @@ -76,9 +70,6 @@ class TextProvider extends Notifier> { group: ActionGroup.text, objectDelta: ObjectHistoryDelta( before: ActionObjectState.text(state[index]), - beforeTextHeights: ref - .read(textWidgetHeightProvider.notifier) - .takeSnapshotForIds([id]), ), ), ); @@ -105,12 +96,6 @@ class TextProvider extends Notifier> { objectDelta: ObjectHistoryDelta( before: before, after: ActionObjectState.text(temp), - beforeTextHeights: ref - .read(textWidgetHeightProvider.notifier) - .takeSnapshotForIds([id]), - afterTextHeights: ref - .read(textWidgetHeightProvider.notifier) - .takeSnapshotForIds([id]), ), ); ref.read(actionProvider.notifier).addAction(action); @@ -132,30 +117,14 @@ class TextProvider extends Notifier> { } Offset _switchSizeForText(PlacedText text) { - final measuredSize = - ref.read(textWidgetHeightProvider.notifier).getOffset(text.id); - if (measuredSize != Offset.zero) return measuredSize; - - final coordinateSystem = CoordinateSystem.instance; - final totalWidth = measuredSize.dx > 0 - ? measuredSize.dx - : coordinateSystem.worldWidthToScreen(text.size); - final fontSize = coordinateSystem.worldHeightToScreen(text.fontSize); - final contentWidth = - (totalWidth - _textWidgetHorizontalChrome).clamp(1.0, double.infinity); - final displayText = - ref.read(textDraftProvider.notifier).draftFor(text.id) ?? text.text; - final painter = TextPainter( - text: TextSpan( - text: displayText.isEmpty ? 'Write here...' : displayText, - style: TextStyle(fontSize: fontSize), - ), - maxLines: null, - textDirection: TextDirection.ltr, - )..layout(maxWidth: contentWidth); - final totalHeight = (painter.height + _textWidgetVerticalChrome) - .clamp(_textWidgetMinHeight, double.infinity); - return Offset(totalWidth, totalHeight); + final size = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: text.size, + fontSizeWorld: text.fontSize, + text: text.text, + ); + + return Offset(size.width, size.height); } Offset switchSizeForText(PlacedText text) { @@ -179,12 +148,6 @@ class TextProvider extends Notifier> { objectDelta: ObjectHistoryDelta( before: before, after: ActionObjectState.text(newState[index]), - beforeTextHeights: ref - .read(textWidgetHeightProvider.notifier) - .takeSnapshotForIds([id]), - afterTextHeights: ref - .read(textWidgetHeightProvider.notifier) - .takeSnapshotForIds([id]), ), ), ); @@ -225,21 +188,18 @@ class TextProvider extends Notifier> { } switch (action.type) { case ActionType.addition: - _clearTextHeights(delta.afterTextHeights.keys); removeText(action.id); return; case ActionType.deletion: final before = delta.before?.text; if (before == null) return; _upsertText(clonePlacedText(before)); - _restoreTextHeights(delta.beforeTextHeights); return; case ActionType.edit: final before = delta.before?.text; if (before == null) return; _upsertText(clonePlacedText(before)); - _restoreTextHeights(delta.beforeTextHeights); return; case ActionType.bulkDeletion: case ActionType.transaction: @@ -275,17 +235,14 @@ class TextProvider extends Notifier> { final after = delta.after?.text; if (after == null) return; _upsertText(clonePlacedText(after)); - _restoreTextHeights(delta.afterTextHeights); return; case ActionType.deletion: - _clearTextHeights(delta.beforeTextHeights.keys); removeText(action.id); return; case ActionType.edit: final after = delta.after?.text; if (after == null) return; _upsertText(clonePlacedText(after)); - _restoreTextHeights(delta.afterTextHeights); return; case ActionType.bulkDeletion: case ActionType.transaction: @@ -391,14 +348,4 @@ class TextProvider extends Notifier> { } state = newState; } - - void _restoreTextHeights(Map snapshot) { - if (snapshot.isEmpty) return; - ref.read(textWidgetHeightProvider.notifier).restoreSnapshot(snapshot); - } - - void _clearTextHeights(Iterable ids) { - if (ids.isEmpty) return; - ref.read(textWidgetHeightProvider.notifier).clearEntries(ids); - } } diff --git a/lib/providers/text_widget_height_provider.dart b/lib/providers/text_widget_height_provider.dart deleted file mode 100644 index 91896068..00000000 --- a/lib/providers/text_widget_height_provider.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final textWidgetHeightProvider = - NotifierProvider>( - TextWidgetHeightProvider.new, -); - -class TextWidgetHeightProvider extends Notifier> { - @override - Map build() { - return {}; - } - - void updateHeight(String id, Offset offset) { - state = {...state, id: offset}; - } - - Offset getOffset(String id) { - return state[id] ?? Offset.zero; - } - - Map takeSnapshotForIds(Iterable ids) { - return { - for (final id in ids) - if (state.containsKey(id)) id: state[id]!, - }; - } - - void clearEntries(Iterable ids) { - final newState = {...state}; - for (final id in ids) { - newState.remove(id); - } - state = newState; - } - - void restoreSnapshot(Map snapshot) { - state = { - ...state, - ...snapshot, - }; - } - - void clearAll() { - state = {}; - } -} diff --git a/lib/strategy/strategy_page_apply.dart b/lib/strategy/strategy_page_apply.dart index 05e0ee56..615567b1 100644 --- a/lib/strategy/strategy_page_apply.dart +++ b/lib/strategy/strategy_page_apply.dart @@ -6,12 +6,10 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/providers/text_widget_height_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -30,8 +28,6 @@ Future applyStrategyEditorPageData( ref.read(placedImageProvider.notifier).clearAll(); ref.read(utilityProvider.notifier).clearAll(); ref.read(lineUpProvider.notifier).clearAll(); - ref.read(imageWidgetSizeProvider.notifier).clearAll(); - ref.read(textWidgetHeightProvider.notifier).clearAll(); if (!preserveHistory) { ref.read(actionProvider.notifier).clearActionHistory(); } diff --git a/lib/widgets/draggable_widgets/image/image_widget.dart b/lib/widgets/draggable_widgets/image/image_widget.dart index 6df8cf1c..7aed75d8 100644 --- a/lib/widgets/draggable_widgets/image/image_widget.dart +++ b/lib/widgets/draggable_widgets/image/image_widget.dart @@ -4,9 +4,8 @@ import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:path/path.dart' as path; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -150,33 +149,21 @@ class ImageWidget extends ConsumerStatefulWidget { } class _ImageWidgetState extends ConsumerState { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (widget.isFeedback) return; - RenderObject? renderObject = context.findRenderObject(); - RenderBox? renderBox = renderObject as RenderBox; - // if (renderBox == null) return; - double height = renderBox.size.height; - double width = renderBox.size.width; - - Offset offset = Offset(width, height); - - ref.read(imageWidgetSizeProvider.notifier).updateSize(widget.id, offset); - }); - } - @override Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; - final clampedScale = ImageScalePolicy.clamp(widget.scale); - const leftChromeWidth = 12.0; // left bar (10) + spacer (2) + final metrics = PlacedImageDimensions.screenSize( + coordinateSystem: coordinateSystem, + scale: widget.scale, + aspectRatio: widget.aspectRatio, + ); final safeAspectRatio = widget.aspectRatio <= 0 ? 1.0 : widget.aspectRatio; - final totalWidth = coordinateSystem.worldWidthToScreen(clampedScale); - final cardWidth = - (totalWidth - leftChromeWidth).clamp(1.0, double.infinity); - final cardHeight = (cardWidth - 10) / safeAspectRatio + 10; + final cardWidth = (metrics.width - + PlacedImageDimensions.tagWidth - + PlacedImageDimensions.tagGap) + .clamp(1.0, double.infinity); + final contentWidth = (cardWidth - (PlacedImageDimensions.imagePadding * 2)) + .clamp(1.0, double.infinity); final file = File(path.join( ref.watch(strategyProvider).storageDirectory!, 'images', @@ -207,71 +194,53 @@ class _ImageWidgetState extends ConsumerState { aspectRatio: widget.aspectRatio, ); }, - child: NotificationListener( - onNotification: (notification) { - if (widget.isFeedback) return true; - RenderObject? renderObject = context.findRenderObject(); - RenderBox? renderBox = renderObject as RenderBox; - double height = renderBox.size.height; - double width = renderBox.size.width; - - Offset offset = Offset(width, height); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - ref - .read(imageWidgetSizeProvider.notifier) - .updateSize(widget.id, offset); - }); - return true; - }, - child: SizeChangedLayoutNotifier( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: totalWidth, minWidth: 0), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - //Tag container - width: 10, - height: cardHeight.toDouble(), - decoration: BoxDecoration( - color: Color(widget.tagColorValue ?? 0xFFC5C5C5), - borderRadius: BorderRadius.circular(3), - ), + child: SizedBox( + width: metrics.width, + height: metrics.height, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + //Tag container + width: PlacedImageDimensions.tagWidth, + decoration: BoxDecoration( + color: Color(widget.tagColorValue ?? 0xFFC5C5C5), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: PlacedImageDimensions.tagGap), + Expanded( + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(3), ), - const SizedBox(width: 2), - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(3), + margin: EdgeInsets.zero, + color: Colors.black, + child: Padding( + padding: const EdgeInsets.all( + PlacedImageDimensions.imagePadding, ), - margin: EdgeInsets.zero, - color: Colors.black, child: SizedBox( - width: cardWidth.toDouble(), - child: Padding( - padding: const EdgeInsets.all(5), - child: AspectRatio( - aspectRatio: safeAspectRatio, - child: Container( - decoration: BoxDecoration( - color: const Color.fromARGB(255, 20, 20, 20), - borderRadius: BorderRadius.circular(3), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: Hero( - tag: 'image_${widget.id}', - child: buildThumb(), - ), - ), + width: contentWidth, + height: contentWidth / safeAspectRatio, + child: Container( + decoration: BoxDecoration( + color: const Color.fromARGB(255, 20, 20, 20), + borderRadius: BorderRadius.circular(3), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Hero( + tag: 'image_${widget.id}', + child: buildThumb(), ), ), ), ), ), - ], + ), ), - ), + ], ), ), ); diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index 5c37bbc0..d71ee499 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/text_draft_provider.dart'; -import 'package:icarus/providers/text_widget_height_provider.dart'; class TextWidget extends ConsumerWidget { const TextWidget({ @@ -45,9 +45,11 @@ class TextWidget extends ConsumerWidget { } const _textFieldDecoration = InputDecoration( - hintText: "Write here...", + hintText: PlacedTextDimensions.emptyTextPlaceholder, hintStyle: TextStyle(color: Colors.grey), border: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.zero, ); class _EditableTextWidget extends ConsumerStatefulWidget { @@ -86,10 +88,6 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { textDraftProvider, (_, __) => _syncControllerWithExternalState(), ); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateMeasuredSize(); - }); } @override @@ -145,43 +143,30 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { ); } - void _updateMeasuredSize() { - if (!mounted) return; - - final renderObject = context.findRenderObject(); - if (renderObject is! RenderBox) return; - - final offset = Offset(renderObject.size.width, renderObject.size.height); - ref.read(textWidgetHeightProvider.notifier).updateHeight(widget.id, offset); - } - @override Widget build(BuildContext context) { + final metrics = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: widget.size, + fontSizeWorld: widget.fontSize, + text: _controller.text, + ); return Shortcuts( shortcuts: ShortcutInfo.textEditingOverrides, - child: NotificationListener( - onNotification: (notification) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateMeasuredSize(); - }); - return true; - }, - child: SizeChangedLayoutNotifier( - child: _TextBoxFrame( - size: widget.size, - tagColorValue: widget.tagColorValue, - child: _SharedTextField( - controller: _controller, - focusNode: _focusNode, - fontSize: widget.fontSize, - onChanged: (value) { - _draftNotifier.setDraft(widget.id, value); - }, - onTapOutside: (_) { - _focusNode.unfocus(); - }, - ), - ), + child: _TextBoxFrame( + metrics: metrics, + tagColorValue: widget.tagColorValue, + child: _SharedTextField( + controller: _controller, + focusNode: _focusNode, + fontSize: widget.fontSize, + onChanged: (value) { + _draftNotifier.setDraft(widget.id, value); + setState(() {}); + }, + onTapOutside: (_) { + _focusNode.unfocus(); + }, ), ), ); @@ -232,8 +217,14 @@ class _FeedbackTextWidgetState extends State<_FeedbackTextWidget> { @override Widget build(BuildContext context) { + final metrics = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: widget.size, + fontSizeWorld: widget.fontSize, + text: _controller.text, + ); return _TextBoxFrame( - size: widget.size, + metrics: metrics, tagColorValue: widget.tagColorValue, child: IgnorePointer( child: _SharedTextField( @@ -272,69 +263,75 @@ class _SharedTextField extends StatelessWidget { @override Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; - return TextField( - focusNode: focusNode, - controller: controller, - readOnly: readOnly, - enableInteractiveSelection: enableInteractiveSelection, - showCursor: showCursor, - style: TextStyle( - fontSize: coordinateSystem.worldHeightToScreen(fontSize), + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling), + child: TextField( + focusNode: focusNode, + controller: controller, + readOnly: readOnly, + enableInteractiveSelection: enableInteractiveSelection, + showCursor: showCursor, + style: TextStyle( + fontSize: coordinateSystem.worldHeightToScreen(fontSize), + ), + decoration: _textFieldDecoration, + maxLines: null, + minLines: 1, + expands: false, + scrollPhysics: const NeverScrollableScrollPhysics(), + textAlignVertical: TextAlignVertical.top, + keyboardType: TextInputType.multiline, + onChanged: onChanged, + onTapOutside: onTapOutside, ), - decoration: _textFieldDecoration, - maxLines: null, - minLines: null, - expands: true, - onChanged: onChanged, - onTapOutside: onTapOutside, ); } } class _TextBoxFrame extends StatelessWidget { const _TextBoxFrame({ - required this.size, + required this.metrics, required this.child, this.tagColorValue, }); - final double size; + final Size metrics; final Widget child; final int? tagColorValue; @override Widget build(BuildContext context) { - final coordinateSystem = CoordinateSystem.instance; return SizedBox( - width: coordinateSystem.worldWidthToScreen(size), - child: IntrinsicHeight( - child: Row( - children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(2)), - child: Container( - width: 6, - color: Color(tagColorValue ?? 0xFFC5C5C5), - ), + width: metrics.width, + height: metrics.height, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(2)), + child: Container( + width: 6, + color: Color(tagColorValue ?? 0xFFC5C5C5), ), - const SizedBox(width: 2), - Expanded( - child: Card( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(3)), - ), - margin: const EdgeInsets.all(0), - color: Colors.black, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 5, - ), - child: child, + ), + const SizedBox(width: 2), + Expanded( + child: Card( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(3)), + ), + margin: const EdgeInsets.all(0), + color: Colors.black, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: PlacedTextDimensions.cardHorizontalPadding, + vertical: PlacedTextDimensions.cardVerticalPadding, ), + child: child, ), ), - ], - ), + ), + ], ), ); } diff --git a/test/action_provider_bulk_clear_test.dart b/test/action_provider_bulk_clear_test.dart index 968e2986..6c7e8fac 100644 --- a/test/action_provider_bulk_clear_test.dart +++ b/test/action_provider_bulk_clear_test.dart @@ -13,10 +13,8 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/providers/text_widget_height_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -239,13 +237,6 @@ void main() { .read(lineUpProvider.notifier) .fromHive([_buildLineUp('lineup-all')]); - container - .read(imageWidgetSizeProvider.notifier) - .updateSize('image-all', const Offset(80, 60)); - container - .read(textWidgetHeightProvider.notifier) - .updateHeight('text-all', const Offset(120, 44)); - container.read(actionProvider.notifier).clearAllAsAction(); expect(container.read(agentProvider), isEmpty); @@ -255,14 +246,6 @@ void main() { expect(container.read(placedImageProvider).images, isEmpty); expect(container.read(utilityProvider), isEmpty); expect(container.read(lineUpProvider).lineUps, isEmpty); - expect( - container.read(imageWidgetSizeProvider.notifier).getSize('image-all'), - Offset.zero, - ); - expect( - container.read(textWidgetHeightProvider.notifier).getOffset('text-all'), - Offset.zero, - ); expect(container.read(actionProvider), hasLength(1)); expect( container.read(actionProvider).single.type, ActionType.bulkDeletion); @@ -276,14 +259,6 @@ void main() { expect(container.read(placedImageProvider).images, hasLength(1)); expect(container.read(utilityProvider), hasLength(1)); expect(container.read(lineUpProvider).lineUps, hasLength(1)); - expect( - container.read(imageWidgetSizeProvider.notifier).getSize('image-all'), - const Offset(80, 60), - ); - expect( - container.read(textWidgetHeightProvider.notifier).getOffset('text-all'), - const Offset(120, 44), - ); expect(container.read(actionProvider), isEmpty); }); diff --git a/test/placed_media_dimensions_test.dart b/test/placed_media_dimensions_test.dart new file mode 100644 index 00000000..f458a9e3 --- /dev/null +++ b/test/placed_media_dimensions_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + test('image helper returns expected width and height', () { + final size = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: ImageScalePolicy.defaultWidth, + aspectRatio: 2.0, + ); + + final expectedWidth = CoordinateSystem.instance + .worldWidthToScreen(ImageScalePolicy.defaultWidth); + final cardWidth = expectedWidth - + PlacedImageDimensions.tagWidth - + PlacedImageDimensions.tagGap; + final contentWidth = cardWidth - (PlacedImageDimensions.imagePadding * 2); + final expectedHeight = + (contentWidth / 2.0) + (PlacedImageDimensions.imagePadding * 2); + + expect(size.width, expectedWidth); + expect(size.height, expectedHeight); + }); + + test('image helper clamps scale', () { + final size = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: ImageScalePolicy.maxWidth * 10, + aspectRatio: 1.0, + ); + + expect( + size.width, + CoordinateSystem.instance.worldWidthToScreen(ImageScalePolicy.maxWidth), + ); + }); + + test('image helper falls back to square aspect ratio', () { + final zeroAspect = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: ImageScalePolicy.defaultWidth, + aspectRatio: 0, + ); + final squareAspect = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: ImageScalePolicy.defaultWidth, + aspectRatio: 1, + ); + + expect(zeroAspect, squareAspect); + }); + + test('text helper returns deterministic screen width', () { + final size = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 220, + fontSizeWorld: 16, + text: 'one line', + ); + + expect(size.width, CoordinateSystem.instance.worldWidthToScreen(220)); + }); + + test('text helper uses minimum height for empty text', () { + final size = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 220, + fontSizeWorld: 16, + text: '', + ); + + expect(size.height, greaterThanOrEqualTo(PlacedTextDimensions.minHeight)); + }); + + test('text helper height increases for wrapped text', () { + final singleLine = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 220, + fontSizeWorld: 16, + text: 'short text', + ); + final wrapped = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 80, + fontSizeWorld: 16, + text: 'this is a long annotation that should wrap across several lines', + ); + + expect(wrapped.height, greaterThan(singleLine.height)); + }); +} diff --git a/test/side_switch_media_test.dart b/test/side_switch_media_test.dart new file mode 100644 index 00000000..79c720ee --- /dev/null +++ b/test/side_switch_media_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + group('PlacedText side switch', () { + test('double switch returns empty text to original position', () { + final text = _placedText(text: ''); + + _switchText(text); + _switchText(text); + + _expectClose(text.position, const Offset(100, 120)); + }); + + test('double switch returns single-line text to original position', () { + final text = _placedText(text: 'one line'); + + _switchText(text); + _switchText(text); + + _expectClose(text.position, const Offset(100, 120)); + }); + + test('double switch returns wrapped text to original position', () { + final text = _placedText( + text: 'this text is long enough to wrap across multiple lines', + size: 90, + ); + + _switchText(text); + _switchText(text); + + _expectClose(text.position, const Offset(100, 120)); + }); + }); + + test( + 'PlacedImage double switch returns non-square image to original position', + () { + final image = PlacedImage( + id: 'image-1', + position: const Offset(200, 220), + aspectRatio: 16 / 9, + scale: ImageScalePolicy.defaultWidth, + fileExtension: '.png', + sizeVersion: worldSizedMediaVersion, + ); + + _switchImage(image); + _switchImage(image); + + _expectClose(image.position, const Offset(200, 220)); + }); +} + +PlacedText _placedText({ + required String text, + double size = 220, +}) { + return PlacedText( + id: 'text-1', + position: const Offset(100, 120), + size: size, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = text; +} + +void _switchText(PlacedText text) { + final size = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: text.size, + fontSizeWorld: text.fontSize, + text: text.text, + ); + + text.switchSides(Offset(size.width, size.height)); +} + +void _switchImage(PlacedImage image) { + final size = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: image.scale, + aspectRatio: image.aspectRatio, + ); + + image.switchSides(Offset(size.width, size.height)); +} + +void _expectClose(Offset actual, Offset expected) { + expect(actual.dx, closeTo(expected.dx, 1e-9)); + expect(actual.dy, closeTo(expected.dy, 1e-9)); +} diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index 03c706b6..642fbeeb 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -18,7 +18,6 @@ import 'package:icarus/providers/strategy_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/text_widget_height_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; @@ -312,6 +311,15 @@ void main() { expect(container.read(textDraftProvider), {'text-1': 'before edited'}); expect(savedStrategy, isNotNull); expect(savedStrategy!.pages.single.textData.single.text, 'before edited'); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: SizedBox.shrink()), + ), + ); + await tester.pump(); + container.read(strategyProvider.notifier).cancelPendingSave(); }); testWidgets('feedback widget matches editable widget size', (tester) async { @@ -325,7 +333,7 @@ void main() { expect(feedbackSize.height, editableSize.height); }); - testWidgets('side switch mirrors text like a bounded widget when unmeasured', + testWidgets('side switch mirrors text with deterministic widget bounds', (tester) async { final container = createContainer(); final placedText = PlacedText( @@ -341,7 +349,6 @@ void main() { await tester.pump(); final renderedSize = tester.getSize(find.byType(TextWidget)); - container.read(textWidgetHeightProvider.notifier).clearEntries(['text-1']); container.read(textProvider.notifier).switchSides(); @@ -355,7 +362,7 @@ void main() { }); testWidgets( - 'side switch uses the measured rendered text height for vertical placement', + 'side switch uses deterministic rendered text height for vertical placement', (tester) async { final container = createContainer(); final placedText = PlacedText( From 18984f44dde4c4d7d80b1e32bab86a971dd0dfd5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 27 May 2026 18:58:40 -0400 Subject: [PATCH 15/15] Refactor Convex cloud sync and indexed queries - Add strategy-level element and lineup queries - Tighten folder/strategy listing and page reorder null checks - Document cloud release gaps and sync refactor plan --- AGENTS.md | 1 + README.md | 16 + convex/elements.ts | 38 ++ convex/folders.ts | 71 ++- convex/lineups.ts | 37 ++ convex/pages.ts | 4 +- convex/strategies.ts | 404 +++++++++++---- docs/cloud_online_release_gaps.md | 139 ++++++ .../convex_sync_refactor_plan.md | 204 ++++++++ lib/collab/collab_models.dart | 75 +++ lib/collab/convex_strategy_repository.dart | 190 +++++-- lib/const/placed_media_dimensions.dart | 40 +- lib/main.dart | 22 +- lib/providers/auth_provider.dart | 69 ++- .../collab/active_page_live_sync_models.dart | 15 +- .../collab/cloud_migration_provider.dart | 27 +- .../remote_strategy_snapshot_provider.dart | 246 ++++----- .../strategy_page_session_provider.dart | 280 +++++++++-- lib/providers/strategy_provider.dart | 52 +- .../draggable_widgets/text/text_widget.dart | 7 +- lib/widgets/folder_navigator.dart | 3 +- test/collab_sync_models_test.dart | 110 +++++ test/placed_media_dimensions_test.dart | 13 +- test/strategy_op_queue_provider_test.dart | 16 + test/strategy_page_session_provider_test.dart | 467 +++++++++++++++++- test/text_widget_resilience_test.dart | 43 ++ 26 files changed, 2190 insertions(+), 399 deletions(-) create mode 100644 docs/cloud_online_release_gaps.md create mode 100644 docs/cloud_sync_refactor/convex_sync_refactor_plan.md diff --git a/AGENTS.md b/AGENTS.md index 3cb60907..e942f4ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ A Flutter desktop app for creating interactive Valorant game strategies. See `RE ### Key caveats - **FVM is required.** Flutter is pinned to `3.38.4` via `.fvmrc`. Always prefix Flutter/Dart commands with `fvm` (e.g. `fvm flutter run`, `fvm dart run`). +- **Cloud is still dev-build only.** The cloud backend has not shipped publicly yet, so backwards compatibility with existing dev cloud data is not automatically required. If a change breaks cloud data/API compatibility, explicitly tell the user first and let them decide whether to wipe/migrate the dev database or take another action. - **`xdg-user-dirs` must be initialized.** The `path_provider` plugin needs XDG user directories. Run `sudo apt-get install -y xdg-user-dirs && xdg-user-dirs-update` if the app crashes with `MissingPlatformDirectoryException`. - **Linux build deps.** `clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-14-dev` must be installed for Linux desktop builds. - **Code generation.** After changing Hive models, Riverpod providers, or JSON-serializable classes, regenerate with: `fvm flutter pub run build_runner build --delete-conflicting-outputs`. diff --git a/README.md b/README.md index 21e68a62..4d4f9d7c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,22 @@ flutter pub get flutter run ``` +### Windows dev OAuth callbacks +Discord OAuth redirects back into the desktop app through the `icarus://auth/callback` +protocol. On Windows, the installed app normally owns that protocol handler, so a +dev build may not receive the browser callback. + +For a temporary dev session, run the Windows build with the force protocol +registration flag: + +```powershell +fvm flutter run -d windows --dart-define=ICARUS_FORCE_PROTOCOL_REGISTER=true +``` + +This rewrites the current user's `icarus://` handler to the debug executable. +After testing OAuth, launch the installed Icarus app once to restore the handler +back to the installed build. + ## Build ```bash flutter build diff --git a/convex/elements.ts b/convex/elements.ts index f41ae5fa..4653aa8d 100644 --- a/convex/elements.ts +++ b/convex/elements.ts @@ -38,3 +38,41 @@ export const listForPage = query({ })); }, }); + +export const listForStrategy = query({ + 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(); + const pagePublicIds = new Map( + pages.map((page) => [page._id, page.publicId]), + ); + + const elements = await ctx.db + .query("elements") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + + return elements + .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, + })); + }, +}); diff --git a/convex/folders.ts b/convex/folders.ts index c4519237..b435df97 100644 --- a/convex/folders.ts +++ b/convex/folders.ts @@ -30,14 +30,55 @@ async function listAccessibleFoldersForScope( ctx: AnyCtx, userId: Id<"users">, scope: FolderScope, -) : Promise; role: "owner" | "editor" | "viewer" }>> { - const folders = await ctx.db.query("folders").collect(); +): Promise< + Array<{ folder: Doc<"folders">; role: "owner" | "editor" | "viewer" }> +> { + const candidates = new Map, Doc<"folders">>(); + + if (scope === "owned" || scope === "all") { + const owned = await ctx.db + .query("folders") + .withIndex("by_ownerId", (q) => q.eq("ownerId", userId)) + .collect(); + for (const folder of owned) { + candidates.set(folder._id, folder); + } + } + + if (scope === "shared" || scope === "all") { + const directShares = await ctx.db + .query("folderCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", userId)) + .collect(); + const queue = ( + await Promise.all(directShares.map((share) => ctx.db.get(share.folderId))) + ).filter( + (folder): folder is Doc<"folders"> => + folder !== null && folder.ownerId !== userId, + ); + + while (queue.length > 0) { + const folder = queue.shift()!; + if (candidates.has(folder._id)) { + continue; + } + candidates.set(folder._id, folder); + const children = await ctx.db + .query("folders") + .withIndex("by_parentFolderId", (q) => + q.eq("parentFolderId", folder._id), + ) + .collect(); + queue.push(...children.filter((child) => child.ownerId !== userId)); + } + } + const results: Array<{ folder: Doc<"folders">; role: "owner" | "editor" | "viewer"; }> = []; - for (const folder of folders) { + for (const folder of candidates.values()) { const role = await getEffectiveFolderRoleForUser(ctx, folder, userId); if (role === null) { continue; @@ -71,8 +112,14 @@ export const listForParent = query({ parentFolderId = parent._id; } - const accessible = await listAccessibleFoldersForScope(ctx, user._id, scope); - const folderLookup = new Map(accessible.map(({ folder }) => [folder._id, folder])); + const accessible = await listAccessibleFoldersForScope( + ctx, + user._id, + scope, + ); + const folderLookup = new Map( + accessible.map(({ folder }) => [folder._id, folder]), + ); return accessible .filter(({ folder }) => folder.parentFolderId === parentFolderId) @@ -88,7 +135,7 @@ export const listForParent = query({ parentFolderPublicId: folder.parentFolderId === undefined ? null - : folderLookup.get(folder.parentFolderId)?.publicId ?? null, + : (folderLookup.get(folder.parentFolderId)?.publicId ?? null), createdAt: folder.createdAt, updatedAt: folder.updatedAt, role, @@ -221,8 +268,14 @@ export const listAll = query({ handler: async (ctx, args) => { const user = await requireCurrentUser(ctx); const scope = args.scope ?? "all"; - const accessible = await listAccessibleFoldersForScope(ctx, user._id, scope); - const folderLookup = new Map(accessible.map(({ folder }) => [folder._id, folder])); + const accessible = await listAccessibleFoldersForScope( + ctx, + user._id, + scope, + ); + const folderLookup = new Map( + accessible.map(({ folder }) => [folder._id, folder]), + ); return accessible .sort((a, b) => a.folder.createdAt - b.folder.createdAt) @@ -237,7 +290,7 @@ export const listAll = query({ parentFolderPublicId: folder.parentFolderId === undefined ? null - : folderLookup.get(folder.parentFolderId)?.publicId ?? null, + : (folderLookup.get(folder.parentFolderId)?.publicId ?? null), createdAt: folder.createdAt, updatedAt: folder.updatedAt, role, diff --git a/convex/lineups.ts b/convex/lineups.ts index c3bcb902..88dba540 100644 --- a/convex/lineups.ts +++ b/convex/lineups.ts @@ -37,3 +37,40 @@ export const listForPage = query({ })); }, }); + +export const listForStrategy = query({ + 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(); + const pagePublicIds = new Map( + pages.map((page) => [page._id, page.publicId]), + ); + + const lineups = await ctx.db + .query("lineups") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + + return lineups + .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, + })); + }, +}); diff --git a/convex/pages.ts b/convex/pages.ts index fa02f09c..347a56d0 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -182,7 +182,7 @@ export const deletePage = mutation({ "sortIndex", ); for (let i = 0; i < ordered.length; i += 1) { - const current = ordered[i]; + const current = ordered[i]!; if (current.sortIndex !== i) { await ctx.db.patch(current._id, { sortIndex: i, @@ -223,7 +223,7 @@ export const reorder = mutation({ const now = Date.now(); for (let i = 0; i < args.orderedPagePublicIds.length; i += 1) { - const publicId = args.orderedPagePublicIds[i]; + const publicId = args.orderedPagePublicIds[i]!; const page = pageByPublicId.get(publicId); if (!page) { throw new Error(`Unknown page id: ${publicId}`); diff --git a/convex/strategies.ts b/convex/strategies.ts index 64455685..ed8e9cdb 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -1,7 +1,7 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; -import type { QueryCtx } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; import { assertFolderRole, assertStrategyRole, @@ -12,6 +12,30 @@ import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; type StrategyScope = "owned" | "shared" | "all"; +type StrategyCreateInput = { + publicId: string; + name: string; + mapData: string; + folderPublicId?: string; + themeProfileId?: string; + themeOverridePalette?: string; +}; + +type InitialPageInput = { + publicId: string; + name: string; + isAttack: boolean; + settings?: string; +}; + +function createPublicId(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { + const random = Math.floor(Math.random() * 16); + const value = char === "x" ? random : (random & 0x3) | 0x8; + return value.toString(16); + }); +} + const strategyScopeValidator = v.optional( v.union(v.literal("owned"), v.literal("shared"), v.literal("all")), ); @@ -30,40 +54,251 @@ function matchesScope( return ownerId !== userId; } -async function listAccessibleStrategies( +async function summarizeStrategies( ctx: QueryCtx, + strategies: Doc<"strategies">[], userId: Id<"users">, - scope: StrategyScope, ) { - const owned = await ctx.db - .query("strategies") - .withIndex("by_ownerId", (q) => q.eq("ownerId", userId)) - .collect(); - const memberships = await ctx.db .query("strategyCollaborators") .withIndex("by_userId", (q) => q.eq("userId", userId)) .collect(); - const fromMembership = await Promise.all( - memberships.map((m) => ctx.db.get(m.strategyId)), + const folderIdToPublicId = new Map, string>(); + for (const strategy of strategies) { + if ( + strategy.folderId !== undefined && + !folderIdToPublicId.has(strategy.folderId) + ) { + const folder = await ctx.db.get(strategy.folderId); + if (folder !== null) { + folderIdToPublicId.set(strategy.folderId, folder.publicId); + } + } + } + + return await Promise.all( + strategies + .sort((a, b) => b.updatedAt - a.updatedAt) + .map(async (s) => { + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", s._id)) + .collect(); + let attackLabel = "Unknown"; + if (pages.length > 0) { + const first = pages[0]!.isAttack; + const mixed = pages.some((page) => page.isAttack !== first); + attackLabel = mixed ? "Mixed" : first ? "Attack" : "Defend"; + } + const role = + s.ownerId === userId + ? "owner" + : ((await getEffectiveStrategyRoleForUser(ctx, s, userId)) ?? + memberships.find((m: any) => m.strategyId === s._id)?.role ?? + "viewer"); + + return { + publicId: s.publicId, + name: s.name, + mapData: s.mapData, + sequence: s.sequence, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + role, + attackLabel, + folderPublicId: + s.folderId === undefined + ? null + : (folderIdToPublicId.get(s.folderId) ?? null), + themeProfileId: s.themeProfileId ?? null, + themeOverridePalette: s.themeOverridePalette ?? null, + }; + }), ); +} + +async function listStrategiesInFolder( + ctx: QueryCtx, + folderId: Id<"folders"> | undefined, + userId: Id<"users">, + scope: StrategyScope, +) { + let candidates: Doc<"strategies">[]; + if (folderId !== undefined) { + candidates = await ctx.db + .query("strategies") + .withIndex("by_folderId", (q) => q.eq("folderId", folderId)) + .collect(); + } else if (scope === "shared") { + const memberships = await ctx.db + .query("strategyCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", userId)) + .collect(); + const shared = await Promise.all( + memberships.map((membership) => ctx.db.get(membership.strategyId)), + ); + candidates = shared.filter( + (strategy): strategy is Doc<"strategies"> => + strategy !== null && + strategy.ownerId !== userId && + strategy.folderId === undefined, + ); + } else { + candidates = await ctx.db + .query("strategies") + .withIndex("by_ownerId", (q) => q.eq("ownerId", userId)) + .collect(); + candidates = candidates.filter( + (strategy) => strategy.folderId === undefined, + ); + + if (scope === "all") { + const memberships = await ctx.db + .query("strategyCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", userId)) + .collect(); + const shared = await Promise.all( + memberships.map((membership) => ctx.db.get(membership.strategyId)), + ); + candidates.push( + ...shared.filter( + (strategy): strategy is Doc<"strategies"> => + strategy !== null && + strategy.ownerId !== userId && + strategy.folderId === undefined, + ), + ); + } + } - const strategies = await ctx.db.query("strategies").collect(); const dedup = new Map, Doc<"strategies">>(); - for (const strategy of [...owned, ...fromMembership, ...strategies]) { + for (const strategy of candidates) { if ( - strategy !== null && matchesScope(strategy.ownerId, userId, scope) && (await getEffectiveStrategyRoleForUser(ctx, strategy, userId)) !== null ) { dedup.set(strategy._id, strategy); } } - return Array.from(dedup.values()); } +async function resolveOwnedFolderId( + ctx: MutationCtx, + folderPublicId: string | undefined, + userId: Id<"users">, +) { + if (folderPublicId === undefined) { + return undefined; + } + const folder = await getFolderByPublicId(ctx, folderPublicId); + if (folder.ownerId !== userId) { + throw new Error("Forbidden"); + } + return folder._id; +} + +async function assertInitialPagePublicIdAvailable( + ctx: MutationCtx, + pagePublicId: string, + allowedStrategyId?: Id<"strategies">, +) { + const existingPage = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", pagePublicId)) + .first(); + if ( + existingPage !== null && + (allowedStrategyId === undefined || + existingPage.strategyId !== allowedStrategyId) + ) { + throw new Error(`Page publicId already exists: ${pagePublicId}`); + } +} + +async function insertInitialPage( + ctx: MutationCtx, + args: { + strategyId: Id<"strategies">; + initialPage: InitialPageInput; + now: number; + }, +) { + await ctx.db.insert("pages", { + publicId: args.initialPage.publicId, + strategyId: args.strategyId, + name: args.initialPage.name, + sortIndex: 0, + isAttack: args.initialPage.isAttack, + settings: args.initialPage.settings, + revision: 1, + createdAt: args.now, + updatedAt: args.now, + }); +} + +async function createStrategyWithInitialPageRecord( + ctx: MutationCtx, + args: StrategyCreateInput, + userId: Id<"users">, + initialPage: InitialPageInput, +) { + const now = Date.now(); + const folderId = await resolveOwnedFolderId(ctx, args.folderPublicId, userId); + + const existing = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", args.publicId)) + .collect(); + const existingOwned = existing.find((item) => item.ownerId === userId); + if (existingOwned !== undefined) { + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", existingOwned._id)) + .collect(); + if (pages.length === 0) { + await assertInitialPagePublicIdAvailable( + ctx, + initialPage.publicId, + existingOwned._id, + ); + await insertInitialPage(ctx, { + strategyId: existingOwned._id, + initialPage, + now, + }); + } + return { ok: true, reused: true }; + } + if (existing.length > 0) { + throw new Error(`Strategy publicId already exists: ${args.publicId}`); + } + + await assertInitialPagePublicIdAvailable(ctx, initialPage.publicId); + + const strategyId = await ctx.db.insert("strategies", { + publicId: args.publicId, + ownerId: userId, + folderId, + name: args.name, + mapData: args.mapData, + sequence: 0, + themeProfileId: args.themeProfileId, + themeOverridePalette: args.themeOverridePalette, + createdAt: now, + updatedAt: now, + }); + + await insertInitialPage(ctx, { + strategyId, + initialPage, + now, + }); + + return { ok: true }; +} + export const listForFolder = query({ args: { folderPublicId: v.optional(v.string()), @@ -72,11 +307,6 @@ export const listForFolder = query({ handler: async (ctx, args) => { const user = await requireCurrentUser(ctx); const scope = args.scope ?? "owned"; - const all = await listAccessibleStrategies(ctx, user._id, scope); - const memberships = await ctx.db - .query("strategyCollaborators") - .withIndex("by_userId", (q) => q.eq("userId", user._id)) - .collect(); let folderId: Id<"folders"> | undefined; if (args.folderPublicId !== undefined) { @@ -85,54 +315,34 @@ export const listForFolder = query({ folderId = folder._id; } - const folderIdToPublicId = new Map(); - for (const strategy of all) { - if ( - strategy.folderId !== undefined && - !folderIdToPublicId.has(strategy.folderId) - ) { - const strategyFolder = await ctx.db.get(strategy.folderId); - if (strategyFolder !== null) { - folderIdToPublicId.set(strategy.folderId, strategyFolder.publicId); - } - } - } - - return await Promise.all( - all - .filter((s) => s.folderId === folderId) - .sort((a, b) => b.updatedAt - a.updatedAt) - .map(async (s) => { - const pages = await ctx.db - .query("pages") - .withIndex("by_strategyId", (q) => q.eq("strategyId", s._id)) - .collect(); - let attackLabel = "Unknown"; - if (pages.length > 0) { - const first = pages[0]!.isAttack; - const mixed = pages.some((page) => page.isAttack !== first); - attackLabel = mixed ? "Mixed" : first ? "Attack" : "Defend"; - } - const role = s.ownerId === user._id - ? "owner" - : memberships.find((m: any) => m.strategyId === s._id)?.role ?? "viewer"; + const strategies = await listStrategiesInFolder( + ctx, + folderId, + user._id, + scope, + ); + return await summarizeStrategies(ctx, strategies, user._id); + }, +}); - return { - publicId: s.publicId, - name: s.name, - mapData: s.mapData, - sequence: s.sequence, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - role, - attackLabel, - folderPublicId: - s.folderId === undefined ? null : folderIdToPublicId.get(s.folderId) ?? null, - themeProfileId: s.themeProfileId ?? null, - themeOverridePalette: s.themeOverridePalette ?? null, - }; - }), +export const listSharedWithMe = query({ + args: {}, + handler: async (ctx) => { + const user = await requireCurrentUser(ctx); + const memberships = await ctx.db + .query("strategyCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", user._id)) + .collect(); + const shared = await Promise.all( + memberships.map((membership) => ctx.db.get(membership.strategyId)), + ); + const strategies = shared.filter( + (strategy): strategy is Doc<"strategies"> => + strategy !== null && + strategy.ownerId !== user._id && + strategy.folderId === undefined, ); + return await summarizeStrategies(ctx, strategies, user._id); }, }); @@ -169,43 +379,35 @@ export const create = mutation({ }, handler: async (ctx, args) => { const user = await requireCurrentUser(ctx); - const now = Date.now(); - - let folderId; - if (args.folderPublicId !== undefined) { - const folder = await getFolderByPublicId(ctx, args.folderPublicId); - if (folder.ownerId !== user._id) { - throw new Error("Forbidden"); - } - folderId = folder._id; - } - - const existing = await ctx.db - .query("strategies") - .withIndex("by_publicId", (q) => q.eq("publicId", args.publicId)) - .collect(); - const existingOwned = existing.find((item) => item.ownerId === user._id); - if (existingOwned !== undefined) { - return { ok: true, reused: true }; - } - if (existing.length > 0) { - throw new Error(`Strategy publicId already exists: ${args.publicId}`); - } - - await ctx.db.insert("strategies", { - publicId: args.publicId, - ownerId: user._id, - folderId, - name: args.name, - mapData: args.mapData, - sequence: 0, - themeProfileId: args.themeProfileId, - themeOverridePalette: args.themeOverridePalette, - createdAt: now, - updatedAt: now, + return await createStrategyWithInitialPageRecord(ctx, args, user._id, { + publicId: createPublicId(), + name: "Page 1", + isAttack: true, }); + }, +}); - return { ok: true }; +export const createWithInitialPage = mutation({ + args: { + publicId: v.string(), + name: v.string(), + mapData: v.string(), + initialPagePublicId: v.string(), + initialPageName: v.string(), + initialPageIsAttack: v.boolean(), + initialPageSettings: v.optional(v.string()), + folderPublicId: v.optional(v.string()), + themeProfileId: v.optional(v.string()), + themeOverridePalette: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + return await createStrategyWithInitialPageRecord(ctx, args, user._id, { + publicId: args.initialPagePublicId, + name: args.initialPageName, + isAttack: args.initialPageIsAttack, + settings: args.initialPageSettings, + }); }, }); @@ -335,5 +537,3 @@ export const deleteStrategy = mutation({ }); export { deleteStrategy as delete }; - - diff --git a/docs/cloud_online_release_gaps.md b/docs/cloud_online_release_gaps.md new file mode 100644 index 00000000..04ca1fc4 --- /dev/null +++ b/docs/cloud_online_release_gaps.md @@ -0,0 +1,139 @@ +# Cloud Online Release Gaps + +This note captures the current backend/provider gaps found while auditing the online Icarus experience. The app has a real cloud foundation, but these items should be revisited before a broad public release. + +## Current Recommendation + +Ship as a private beta only until the release blockers below are fixed. These issues affect shared library access, backend confidence, conflict behavior, permission clarity, and media cleanup. + +## Release Blockers + +### Missing Shared-With-Me Backend Function + +- Flutter calls `strategies:listSharedWithMe` from `lib/collab/convex_strategy_repository.dart`. +- The Convex backend currently appears to export `strategies:listForFolder`, but no `listSharedWithMe` function exists in `convex/strategies.ts`. +- Impact: the root "Shared with me" cloud library view may fail unless the deployed backend has an out-of-band function. + +Suggested fix: add `listSharedWithMe` or change the client to use `strategies:listForFolder` with `scope: "shared"` for the root shared view. + +### Convex TypeScript Check Fails + +- `npx tsc --noEmit` reports errors in `convex/pages.ts`. +- Observed errors: + - `current` is possibly `undefined` around page reorder patching. + - `string | undefined` passed where `string` is required around ordered page ids. + +Suggested fix: tighten the reorder loop null checks and ensure undefined page IDs are guarded before use. + +### Targeted Sync Tests Are Not Green + +- Command run: + +```powershell +fvm flutter test test\strategy_op_queue_provider_test.dart test\strategy_page_session_provider_test.dart test\collab_sync_models_test.dart +``` + +- Failures included: + - cloud agent addition did not queue an add op as expected + - cloud map change did not queue a strategy patch op as expected + - Hive boxes missing in two session-provider tests + +Suggested fix: stabilize the test harness first, then verify the cloud queue behavior failures are either expected test drift or real regressions. + +## Conflict Handling Gaps + +### Backend Rejects Stale Writes, But UX Is Thin + +- Backend rejects stale writes with `sequence_mismatch` and `revision_mismatch` in `convex/ops.ts`. +- Client receives rejected acks and pushes `ConflictResolution` objects through `strategyConflictProvider`. +- I did not find a user-facing conflict resolver UI. + +Impact: users may not clearly understand when their edit was rebased, retried, dropped, or overwritten by remote state. + +Suggested fix: add a small visible cloud sync/conflict surface that can show: + +- edit kept and retried +- remote edit won +- local edit needs manual retry +- sync failed and is paused + +### Conflict Provider Is Passive + +- `lib/providers/collab/strategy_conflict_provider.dart` stores conflicts. +- The provider is not enough by itself; it needs a clear consumer in the UI or a documented automatic-resolution behavior. + +Suggested fix: either wire conflicts to UI or remove/replace the provider with explicit automatic conflict policy and telemetry. + +## Permission And Sharing Gaps + +### Effective Folder Role May Be Misreported + +- Backend supports inherited folder roles via `getEffectiveStrategyRoleForUser`. +- Strategy list role display in `convex/strategies.ts` appears to use direct strategy membership first and falls back to `viewer`. + +Impact: a user who has editor access via a shared folder may appear as a viewer in the strategy list, causing UI controls to be hidden or disabled incorrectly. + +Suggested fix: return the effective role from `getEffectiveStrategyRoleForUser` in strategy summaries. + +### Link Revocation Does Not Remove Existing Access + +- `shares:revoke` marks a share link as revoked. +- Existing `strategyCollaborators` / `folderCollaborators` rows created by that link remain. + +Impact: this is okay if "revoke link" only means "stop future joins," but it is not enough for "remove access." + +Suggested fix: make UI copy explicit, or add separate collaborator management with remove/downgrade access. + +### Share Links Never Expire + +- The share dialog says links never expire. +- `inviteTokens` support expiry/revocation, but the visible Flutter share flow uses `shareLinks`, not `invites`. + +Impact: public users may expect expiring links or member management for team content. + +Suggested fix: either add expiration options to share links or reserve public launch for a simpler "private beta link sharing" framing. + +### Invite Token Flow Appears Unused + +- Backend has `convex/invites.ts` with expiry and redemption. +- I did not find a Flutter UX for creating/redeeming those invite tokens. + +Suggested fix: remove/defer this API if not needed, or wire it into the sharing UI. + +## Media Upload And Recovery Gaps + +### Upload Retry Exists + +- `cloud_media_upload_queue_provider.dart` persists jobs in Hive. +- Failed uploads retry with backoff. +- Save state tracks media sync errors. + +This part is a solid foundation. + +### Orphan Storage Risk + +- If the blob upload succeeds but `images:completeUpload` fails, the Convex storage object can be orphaned. + +Suggested fix: add a cleanup path for unattached storage IDs, or store an upload intent before posting the blob so old pending uploads can be swept. + +### Stale Asset Cleanup Is Not Implemented + +- `images:listPotentiallyStale` currently returns an empty list. +- `images:deleteAssetRef` only deletes assets still referenced by the strategy. + +Impact: once an image is removed from a strategy, the current delete path may no longer be able to clean up the asset row/storage object. + +Suggested fix: track asset ownership/strategy association directly on `imageAssets`, or keep a reference table so stale assets can be listed and deleted safely. + +## Follow-Up Checklist + +- [ ] Add or replace `strategies:listSharedWithMe`. +- [ ] Fix `convex/pages.ts` TypeScript errors. +- [ ] Re-run targeted sync tests and fix real failures. +- [ ] Return effective strategy role in cloud strategy summaries. +- [ ] Add visible conflict/sync status UI. +- [ ] Clarify "revoke link" versus "remove collaborator access." +- [ ] Decide whether share links need expiry before public launch. +- [ ] Implement stale/orphan media cleanup. +- [ ] Add backend tests for share redemption, revocation, role inheritance, and stale op rejection. +- [ ] Add client tests for conflict ack handling and media upload failure recovery. diff --git a/docs/cloud_sync_refactor/convex_sync_refactor_plan.md b/docs/cloud_sync_refactor/convex_sync_refactor_plan.md new file mode 100644 index 00000000..8f9e74c7 --- /dev/null +++ b/docs/cloud_sync_refactor/convex_sync_refactor_plan.md @@ -0,0 +1,204 @@ +# Convex Cloud Sync Refactor Plan + +## Summary + +Intent: refactor Icarus Cloud's Convex sync/query/subscription plumbing so it uses Convex more efficiently while preserving the current UI and all existing product behavior. No visual changes, no feature removals, and no intentional workflow changes. Any currently synced editor state must continue to sync; if inspection finds a currently local-only field that should be cloud-backed, add it to the cloud sync path with tests. + +## Scope + +In scope: Convex queries/mutations, Flutter repository/provider data plumbing, typed model helpers, sync coverage audit, backend/client tests, and performance-oriented query shape changes. + +Out of scope: UI redesign, new conflict UI, product behavior changes, changing the sequence/conflict model, and digest-table migrations unless explicitly approved later. + +## Current Problems To Address + +1. `remote_strategy_snapshot_provider.dart` subscribes to granular Convex data but mostly treats updates as dirty flags, then refetches the whole strategy snapshot. +2. Open strategies use per-page `elements:listForPage` and `lineups:listForPage` subscriptions, creating `3 + 2 * pageCount` active subscriptions. +3. Cloud library queries scan broad tables and filter in TypeScript, especially `strategies.ts` and `folders.ts`. +4. Cloud library summaries are computed from source tables each time; a digest table may help later, but should not be the first change. +5. Deferred follow-up: parent `strategy.sequence` / `updatedAt` is a hot invalidation point, but it is also the current remote change clock, so do not change it in this refactor. + +## Public API And Type Changes + +Add Convex public queries: + +- `elements:listForStrategy({ strategyPublicId })` +- `lineups:listForStrategy({ strategyPublicId })` +- `strategies:listSharedWithMe({})` if still missing in the current backend + +Keep existing queries for compatibility: + +- `elements:listForPage` +- `lineups:listForPage` +- `strategies:listForFolder` +- `pages:listForStrategy` +- `images:listForStrategy` +- `strategies:getHeader` + +Add Flutter repository methods: + +- `listElementsForStrategy(strategyPublicId)` +- `listLineupsForStrategy(strategyPublicId)` +- `watchStrategyHeader(strategyPublicId)` already exists; keep it. +- `watchPagesForStrategy(strategyPublicId)` +- `watchImageAssetsForStrategy(strategyPublicId)` +- `watchElementsForStrategy(strategyPublicId)` +- `watchLineupsForStrategy(strategyPublicId)` + +Add model helpers: + +- `RemoteStrategySnapshot.copyWith(...)` +- helper methods that replace header, pages, assets, elements, or lineups without mutating unrelated snapshot sections +- grouping helpers that convert strategy-level element/lineup lists into `elementsByPage` and `lineupsByPage` + +## Implementation Checklist + +### 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. + +### 2. Add Strategy-Level Element And Lineup Queries + +- [x] In `convex/elements.ts`, add `listForStrategy`. +- [x] Use `getStrategyByPublicId`, `assertStrategyRole(ctx, strategy, "viewer")`, query `elements` with `by_strategyId`, and query `pages` with `by_strategyId` to map internal `pageId` values back to page `publicId`. +- [x] Return the same client shape as `listForPage`, including `publicId`, `strategyPublicId`, `pagePublicId`, `elementType`, `payload`, `sortIndex`, `revision`, `deleted`, `createdAt`, and `updatedAt`. +- [x] In `convex/lineups.ts`, add `listForStrategy` with the same authorization and page lookup pattern. +- [x] Do not remove the page-level queries. + +### 3. Refactor Snapshot Fetching + +- [x] Update `ConvexStrategyRepository.fetchSnapshot` so it calls `strategies:getHeader`, `pages:listForStrategy`, `images:listForStrategy`, `elements:listForStrategy`, and `lineups:listForStrategy`. +- [x] Remove the loop that calls `elements:listForPage` and `lineups:listForPage` for every page. +- [x] Group returned elements and lineups by `pagePublicId`. +- [x] Preserve the same `RemoteStrategySnapshot` shape, page ordering, and deleted-row handling downstream. + +### 4. Use Subscription Payloads Directly + +- [x] Update `RemoteStrategySnapshotNotifier` so subscription callbacks decode their payloads and update only the relevant snapshot section. +- [x] Header update replaces only `snapshot.header`. +- [x] Pages update replaces `snapshot.pages`, updates available page IDs, and prunes maps only for pages that no longer exist. +- [x] Assets update replaces only `snapshot.assetsById`. +- [x] Elements update replaces only `snapshot.elementsByPage`. +- [x] Lineups update replaces only `snapshot.lineupsByPage`. +- [x] Initial open still performs a full `fetchSnapshot`. +- [x] Manual `refresh()` still performs a full `fetchSnapshot`. +- [x] Subscription errors still fall back to the existing refresh/error path. +- [x] Auth incident behavior remains unchanged. + +### 5. Collapse Per-Page Subscriptions + +- [x] Replace the per-page element and lineup subscription maps in `RemoteStrategySnapshotNotifier` with one strategy-level element subscription and one strategy-level lineup subscription. +- [x] Active strategy subscription set becomes `strategies:getHeader`, `pages:listForStrategy`, `images:listForStrategy`, `elements:listForStrategy`, and `lineups:listForStrategy`. +- [x] Remove `_syncPageSubscriptions` / `_syncPageWatchersFromIds` after strategy-level subscriptions are working. +- [x] Keep cleanup behavior in `_disposeSubscriptions`. + +### 6. Preserve Current Editor Behavior + +- [ ] Do not change `StrategyPageSessionNotifier` behavior except where needed to consume the updated snapshot shape. +- [ ] Preserve `header.sequence` as the signal that triggers remote page rehydration. +- [ ] Preserve pending local overlays over remote data. +- [ ] Preserve ack reconciliation refresh behavior when needed. +- [ ] Preserve conflict rejects through `strategyConflictProvider`. +- [ ] Preserve page switch flushes before changing active pages. +- [ ] Preserve media asset URL hydration. + +### 7. Fix Cloud Library Query Shapes + +- [ ] Refactor `convex/strategies.ts` to avoid global table scans for normal user-scoped views. +- [ ] Refactor concrete folder strategy listing to resolve the folder and query `strategies` with `by_folderId`. +- [ ] Refactor owned root strategies to query `strategies` with `by_ownerId` and keep only `folderId === undefined`. +- [ ] Refactor direct shared root strategies to query `strategyCollaborators` with `by_userId`, fetch those strategies, and keep non-owned root strategies. +- [ ] Preserve effective role checks through `getEffectiveStrategyRoleForUser`. +- [ ] Preserve returned `CloudStrategySummary` fields and sorting. +- [ ] Refactor `convex/folders.ts` to use `by_ownerId`, `by_parentFolderId`, and `folderCollaborators.by_userId` instead of scanning all folders. +- [ ] Preserve current visible hierarchy semantics unless a test proves existing behavior is broken. +- [ ] Keep inherited folder role behavior by traversing descendants from directly shared folders through `by_parentFolderId`. +- [ ] Add or fix `strategies:listSharedWithMe` so the Flutter repository call has a real backend function. + +### 8. Sync Coverage Audit + +Must remain synced: + +- [ ] strategy metadata: name, map, theme profile, theme override +- [ ] page data: name, order, attack/defense side, settings +- [ ] elements: agents, abilities, drawings, text, images, utilities +- [ ] lineups and lineup image references +- [ ] image asset metadata and URLs +- [ ] deletes, moves, reorder, payload patches +- [ ] role/capability behavior for owner/editor/viewer + +If a currently user-visible strategy/page field is local-only but should be cloud-backed, add serialization, Convex op handling, hydration, and tests in the same refactor. + +### 9. Digest Table Decision + +Do not implement digest tables in the first pass. + +Follow-up design: + +- Candidate table: `strategyDigests` +- Candidate fields: strategy id/public id, owner id, folder id, name, map data, role-facing summary fields, attack label, created/updated timestamps +- 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 + +`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. + +## Tests And Verification + +Add or update Dart unit tests for snapshot replacement helpers: + +- [ ] header update preserves pages/assets/elements/lineups +- [ ] pages update preserves unchanged page maps and prunes removed pages +- [ ] strategy-level elements are grouped by page +- [ ] strategy-level lineups are grouped by page +- [ ] deleted elements/lineups remain present in remote data but are ignored during hydration as today + +Add or update provider tests: + +- [ ] opening a cloud strategy does one full fetch then uses subscription payloads +- [ ] a header update triggers the same rehydration behavior as before +- [ ] an element update on the active page updates remote snapshot without full refetch +- [ ] a lineup update on the active page updates remote snapshot without full refetch +- [ ] page deletion/reorder keeps active page resolution behavior unchanged +- [ ] ack rejection still records a conflict and refreshes/rebases as before + +Add Convex tests if the test harness is introduced for this work: + +- [ ] `elements:listForStrategy` enforces viewer access +- [ ] `lineups:listForStrategy` enforces viewer access +- [ ] strategy-level queries return page public IDs correctly +- [ ] `strategies:listForFolder` does not expose unauthorized strategies +- [ ] `strategies:listSharedWithMe` returns direct shared strategies +- [ ] folder-shared access still works through inherited folder roles + +Run verification commands: + +```powershell +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 +fvm flutter analyze +``` + +Expected `fvm flutter analyze` result: no errors; pre-existing warnings/infos may remain. + +## Acceptance Criteria + +- [ ] No visible UI changes. +- [ ] No feature removal. +- [ ] Opening, editing, page switching, collaboration updates, media hydration, conflict handling, and library browsing behave the same as before. +- [ ] Active strategy subscriptions no longer scale with page count. +- [ ] Subscription payloads update local snapshot state directly instead of forcing full snapshot refreshes. +- [ ] Full snapshot refresh remains available for initial load, manual refresh, auth recovery, and error recovery. +- [ ] Cloud library queries no longer scan all strategies or all folders for normal user-scoped views. +- [ ] The saved plan contains a clear deferred note for the sequence/update hot-write concern. + +## Assumptions And Defaults + +- 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. +- 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/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index 1a496525..975aefca 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -336,6 +336,81 @@ class RemoteStrategySnapshot { final Map> elementsByPage; final Map> lineupsByPage; final Map assetsById; + + RemoteStrategySnapshot copyWith({ + RemoteStrategyHeader? header, + List? pages, + Map>? elementsByPage, + Map>? lineupsByPage, + Map? assetsById, + }) { + return RemoteStrategySnapshot( + header: header ?? this.header, + pages: pages ?? this.pages, + elementsByPage: elementsByPage ?? this.elementsByPage, + lineupsByPage: lineupsByPage ?? this.lineupsByPage, + assetsById: assetsById ?? this.assetsById, + ); + } + + 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)), + ), + ); + } + + RemoteStrategySnapshot replaceAssets(List next) { + return copyWith( + assetsById: { + for (final asset in next) asset.publicId: asset, + }, + ); + } + + RemoteStrategySnapshot replaceElements(List next) { + return copyWith(elementsByPage: groupElementsByPage(next)); + } + + RemoteStrategySnapshot replaceLineups(List next) { + return copyWith(lineupsByPage: groupLineupsByPage(next)); + } + + static Map> groupElementsByPage( + Iterable elements, + ) { + final grouped = >{}; + for (final element in elements) { + (grouped[element.pagePublicId] ??= []).add(element); + } + for (final elements in grouped.values) { + elements.sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + } + return grouped; + } + + static Map> groupLineupsByPage( + Iterable lineups, + ) { + final grouped = >{}; + for (final lineup in lineups) { + (grouped[lineup.pagePublicId] ??= []).add(lineup); + } + for (final lineups in grouped.values) { + lineups.sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + } + return grouped; + } } class CloudStrategySummary { diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index 1a4ae3eb..f80b93ce 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -305,60 +305,147 @@ class ConvexStrategyRepository { return controller.stream; } - Future fetchSnapshot(String strategyPublicId) async { - final headerRaw = await _client.query('strategies:getHeader', { + Future> listPagesForStrategy(String strategyPublicId) async { + final response = await _client.query('pages:listForStrategy', { 'strategyPublicId': strategyPublicId, }); - final header = RemoteStrategyHeader.fromJson(_decodeObject(headerRaw)); + return _decodeObjectList(response) + .map(RemotePage.fromJson) + .toList(growable: false) + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + } - final pagesRaw = await _client.query('pages:listForStrategy', { + Future> listImageAssetsForStrategy( + String strategyPublicId, + ) async { + final response = await _client.query('images:listForStrategy', { 'strategyPublicId': strategyPublicId, }); + return _decodeObjectList(response) + .map(RemoteImageAsset.fromJson) + .toList(growable: false); + } - final pages = _decodeObjectList(pagesRaw) - .map(RemotePage.fromJson) + Future> listElementsForStrategy( + String strategyPublicId, + ) async { + final response = await _client.query('elements:listForStrategy', { + 'strategyPublicId': strategyPublicId, + }); + return _decodeObjectList(response) + .map(RemoteElement.fromJson) .toList(growable: false) ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + } - final elementsByPage = >{}; - final lineupsByPage = >{}; - final assetsRaw = await _client.query('images:listForStrategy', { + Future> listLineupsForStrategy( + String strategyPublicId, + ) async { + final response = await _client.query('lineups:listForStrategy', { 'strategyPublicId': strategyPublicId, }); - final assets = _decodeObjectList(assetsRaw) - .map(RemoteImageAsset.fromJson) - .toList(growable: false); + return _decodeObjectList(response) + .map(RemoteLineup.fromJson) + .toList(growable: false) + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + } + + Stream> _watchList({ + required String name, + required Map args, + required T Function(Map) fromJson, + }) { + final controller = StreamController>.broadcast(); + dynamic subscription; + + Future start() async { + subscription = await _client.subscribe( + name: name, + args: args, + onUpdate: (value) { + try { + final mapped = + _decodeObjectList(value).map(fromJson).toList(growable: false); + controller.add(mapped); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + }, + onError: (message, value) { + controller.addError(Exception('$name error: $message')); + }, + ); + } + + start(); + controller.onCancel = () { + try { + subscription?.cancel(); + } catch (_) {} + }; + + return controller.stream; + } + + Stream> watchPagesForStrategy(String strategyPublicId) { + return _watchList( + name: 'pages:listForStrategy', + args: {'strategyPublicId': strategyPublicId}, + fromJson: RemotePage.fromJson, + ).map((pages) => pages..sort((a, b) => a.sortIndex.compareTo(b.sortIndex))); + } + + Stream> watchImageAssetsForStrategy( + String strategyPublicId, + ) { + return _watchList( + name: 'images:listForStrategy', + args: {'strategyPublicId': strategyPublicId}, + fromJson: RemoteImageAsset.fromJson, + ); + } + + Stream> watchElementsForStrategy( + String strategyPublicId, + ) { + return _watchList( + name: 'elements:listForStrategy', + args: {'strategyPublicId': strategyPublicId}, + fromJson: RemoteElement.fromJson, + ).map((elements) => + elements..sort((a, b) => a.sortIndex.compareTo(b.sortIndex))); + } + + Stream> watchLineupsForStrategy( + String strategyPublicId, + ) { + return _watchList( + name: 'lineups:listForStrategy', + args: {'strategyPublicId': strategyPublicId}, + fromJson: RemoteLineup.fromJson, + ).map((lineups) => + lineups..sort((a, b) => a.sortIndex.compareTo(b.sortIndex))); + } + + Future fetchSnapshot(String strategyPublicId) async { + final headerRaw = await _client.query('strategies:getHeader', { + 'strategyPublicId': strategyPublicId, + }); + final header = RemoteStrategyHeader.fromJson(_decodeObject(headerRaw)); + + final pages = await listPagesForStrategy(strategyPublicId); + final assets = await listImageAssetsForStrategy(strategyPublicId); + final elements = await listElementsForStrategy(strategyPublicId); + final lineups = await listLineupsForStrategy(strategyPublicId); final assetsById = { for (final asset in assets) asset.publicId: asset, }; - for (final page in pages) { - final elementsRaw = await _client.query('elements:listForPage', { - 'strategyPublicId': strategyPublicId, - 'pagePublicId': page.publicId, - }); - final elements = _decodeObjectList(elementsRaw) - .map(RemoteElement.fromJson) - .toList(growable: false) - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - elementsByPage[page.publicId] = elements; - - final lineupsRaw = await _client.query('lineups:listForPage', { - 'strategyPublicId': strategyPublicId, - 'pagePublicId': page.publicId, - }); - final lineups = _decodeObjectList(lineupsRaw) - .map(RemoteLineup.fromJson) - .toList(growable: false) - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - lineupsByPage[page.publicId] = lineups; - } - return RemoteStrategySnapshot( header: header, pages: pages, - elementsByPage: elementsByPage, - lineupsByPage: lineupsByPage, + elementsByPage: RemoteStrategySnapshot.groupElementsByPage(elements), + lineupsByPage: RemoteStrategySnapshot.groupLineupsByPage(lineups), assetsById: assetsById, ); } @@ -484,6 +571,37 @@ class ConvexStrategyRepository { ); } + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + required bool initialPageIsAttack, + String? folderPublicId, + String? themeProfileId, + String? themeOverridePalette, + String? initialPageSettings, + }) async { + await _client.mutation( + name: 'strategies:createWithInitialPage', + args: { + 'publicId': publicId, + 'name': name, + 'mapData': mapData, + 'initialPagePublicId': initialPagePublicId, + 'initialPageName': initialPageName, + 'initialPageIsAttack': initialPageIsAttack, + if (folderPublicId != null) 'folderPublicId': folderPublicId, + if (themeProfileId != null) 'themeProfileId': themeProfileId, + if (themeOverridePalette != null) + 'themeOverridePalette': themeOverridePalette, + if (initialPageSettings != null) + 'initialPageSettings': initialPageSettings, + }, + ); + } + Future> listShareLinks({ required String targetType, required String targetPublicId, diff --git a/lib/const/placed_media_dimensions.dart b/lib/const/placed_media_dimensions.dart index cd47c21f..daa2c3ed 100644 --- a/lib/const/placed_media_dimensions.dart +++ b/lib/const/placed_media_dimensions.dart @@ -30,7 +30,6 @@ abstract final class PlacedTextDimensions { static const double tagGap = 2.0; static const double cardHorizontalPadding = 5.0; static const double cardVerticalPadding = 6.0; - static const double minHeight = 64.0; static const String emptyTextPlaceholder = 'Write here...'; static Size screenSize({ @@ -45,8 +44,8 @@ abstract final class PlacedTextDimensions { widthWorld: widthWorld, ); - final displayText = text.isEmpty ? emptyTextPlaceholder : text; - final fontSizePx = PlacedTextDimensions.fontSizePx( + final displayText = text.isEmpty ? ' ' : _withBreakOpportunities(text); + final style = textStyle( coordinateSystem: coordinateSystem, fontSizeWorld: fontSizeWorld, ); @@ -54,15 +53,21 @@ abstract final class PlacedTextDimensions { final painter = TextPainter( text: TextSpan( text: displayText, - style: TextStyle(fontSize: fontSizePx), + style: style, ), maxLines: null, textDirection: TextDirection.ltr, textScaler: TextScaler.noScaling, - )..layout(maxWidth: maxContentWidth); + )..layout(minWidth: maxContentWidth, maxWidth: maxContentWidth); - final totalHeight = (painter.height + (cardVerticalPadding * 2)) - .clamp(minHeight, double.infinity); + final lineMetrics = painter.computeLineMetrics(); + // EditableText can keep one extra wrapped line in its scroll extent. + final wrappedTextSlack = lineMetrics.length > 1 && lineMetrics.isNotEmpty + ? lineMetrics.last.height.ceilToDouble() + : 0.0; + final totalHeight = painter.height.ceilToDouble() + + wrappedTextSlack + + (cardVerticalPadding * 2); return Size(totalWidth, totalHeight); } @@ -81,4 +86,25 @@ abstract final class PlacedTextDimensions { }) { return coordinateSystem.worldHeightToScreen(fontSizeWorld); } + + static TextStyle textStyle({ + required CoordinateSystem coordinateSystem, + required double fontSizeWorld, + }) { + final fontSizePx = PlacedTextDimensions.fontSizePx( + coordinateSystem: coordinateSystem, + fontSizeWorld: fontSizeWorld, + ); + return TextStyle(fontSize: fontSizePx, height: 1.0); + } + + static String _withBreakOpportunities(String text) { + final buffer = StringBuffer(); + for (final rune in text.runes) { + buffer + ..writeCharCode(rune) + ..write('\u200B'); + } + return buffer.toString(); + } } diff --git a/lib/main.dart b/lib/main.dart index fa065a59..b39b1380 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,7 +29,6 @@ import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; @@ -86,7 +85,13 @@ Future _initializeDeepLinkHandling() async { } void _publishDeepLink(Uri uri, {required String source}) { - developer.log('Deep link received [$source]: $uri', name: 'deep_link'); + final redactedUri = redactAuthUri(uri); + developer.log('Deep link received [$source]: $redactedUri', + name: 'deep_link'); + AppErrorReporter.reportInfo( + 'Deep link received [$source]: $redactedUri', + source: 'deep_link', + ); if (!_hasDeepLinkListener) { _bufferedDeepLinks.add(uri); return; @@ -341,16 +346,19 @@ class _MyAppState extends ConsumerState { final uriText = uri.toString(); if (!_processedDeepLinks.add(uriText)) { developer.log( - 'Ignoring duplicate deep link [$source]: $uriText', + 'Ignoring duplicate deep link [$source]: ${redactAuthUri(uri)}', name: 'deep_link', ); return; } - developer.log('Handling deep link [$source]: $uriText', name: 'deep_link'); - ref - .read(inAppDebugProvider.notifier) - .bulkAddLogs(['Deep link [$source]: $uriText']); + final redactedUri = redactAuthUri(uri); + developer.log('Handling deep link [$source]: $redactedUri', + name: 'deep_link'); + AppErrorReporter.reportInfo( + 'Handling deep link [$source]: $redactedUri', + source: 'deep_link', + ); unawaited(() async { final handledAuth = await ref diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index e31ace7a..7a82c88c 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -5,6 +5,7 @@ import 'package:convex_flutter/convex_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/app_navigator.dart'; +import 'package:icarus/services/app_error_reporter.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; final authProvider = @@ -49,6 +50,50 @@ bool isConvexUnauthenticatedError(Object error) { return isConvexUnauthenticatedMessage(error.toString()); } +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; + } + + final params = Uri.splitQueryString(fragment); + if (params.isEmpty) { + return ''; + } + + return params.entries.map((entry) { + final value = sensitiveKeys.contains(entry.key.toLowerCase()) + ? '' + : entry.value; + return '${Uri.encodeQueryComponent(entry.key)}=' + '${Uri.encodeQueryComponent(value)}'; + }).join('&'); + } + + final queryParameters = {}; + for (final entry in uri.queryParameters.entries) { + queryParameters[entry.key] = sensitiveKeys.contains(entry.key.toLowerCase()) + ? '' + : entry.value; + } + + return uri + .replace( + queryParameters: queryParameters.isEmpty ? null : queryParameters, + fragment: redactFragment(uri.fragment), + ) + .toString(); +} + class AppAuthState { const AppAuthState({ required this.isLoading, @@ -628,9 +673,18 @@ class AuthProvider extends Notifier { Future handleAuthCallbackUri(Uri uri, {required String source}) async { if (!isAuthCallbackUri(uri)) { + AppErrorReporter.reportInfo( + 'Deep link was not an auth callback [$source]: ${redactAuthUri(uri)}', + source: 'auth', + ); return false; } + AppErrorReporter.reportInfo( + 'Handling auth callback [$source]: ${redactAuthUri(uri)}', + source: 'auth', + ); + state = state.copyWith( isLoading: true, isConvexUserReady: false, @@ -641,7 +695,14 @@ class AuthProvider extends Notifier { try { await _supabaseApi.getSessionFromUrl(uri); state = state.copyWith(isLoading: false); - log('Handled auth callback [$source]: $uri', name: 'auth'); + log( + 'Handled auth callback [$source]: ${redactAuthUri(uri)}', + name: 'auth', + ); + AppErrorReporter.reportInfo( + 'Handled auth callback [$source]', + source: 'auth', + ); return true; } catch (error, stackTrace) { log( @@ -650,6 +711,12 @@ class AuthProvider extends Notifier { error: error, stackTrace: stackTrace, ); + AppErrorReporter.reportError( + 'Failed auth callback [$source]: ${redactAuthUri(uri)}', + source: 'auth', + error: error, + stackTrace: stackTrace, + ); state = state.copyWith( isLoading: false, isConvexUserReady: false, diff --git a/lib/providers/collab/active_page_live_sync_models.dart b/lib/providers/collab/active_page_live_sync_models.dart index 3fef43e9..4e2ef82e 100644 --- a/lib/providers/collab/active_page_live_sync_models.dart +++ b/lib/providers/collab/active_page_live_sync_models.dart @@ -4,20 +4,25 @@ typedef EntitySyncKey = String; enum ActivePageOverlayEntityType { pageSettings, element, lineup } -EntitySyncKey pageSettingsEntityKey(String pageId) => 'page:$pageId:settings'; +String _encodeEntityKeyPart(String value) => Uri.encodeComponent(value); + +String _decodeEntityKeyPart(String value) => Uri.decodeComponent(value); + +EntitySyncKey pageSettingsEntityKey(String pageId) => + 'page:${_encodeEntityKeyPart(pageId)}:settings'; EntitySyncKey elementEntityKey(String pageId, String elementId) => - 'element:$pageId:$elementId'; + 'element:${_encodeEntityKeyPart(pageId)}:${_encodeEntityKeyPart(elementId)}'; EntitySyncKey lineupEntityKey(String pageId, String lineupId) => - 'lineup:$pageId:$lineupId'; + 'lineup:${_encodeEntityKeyPart(pageId)}:${_encodeEntityKeyPart(lineupId)}'; String? pageIdForEntityKey(EntitySyncKey entityKey) { final parts = entityKey.split(':'); if (parts.length < 2) { return null; } - return parts[1]; + return _decodeEntityKeyPart(parts[1]); } String? entityIdForEntityKey(EntitySyncKey entityKey) { @@ -25,7 +30,7 @@ String? entityIdForEntityKey(EntitySyncKey entityKey) { if (parts.length < 3) { return null; } - return parts[2]; + return _decodeEntityKeyPart(parts[2]); } ActivePageOverlayEntityType? overlayEntityTypeForKey(EntitySyncKey entityKey) { diff --git a/lib/providers/collab/cloud_migration_provider.dart b/lib/providers/collab/cloud_migration_provider.dart index 7ac69b47..ef0629c4 100644 --- a/lib/providers/collab/cloud_migration_provider.dart +++ b/lib/providers/collab/cloud_migration_provider.dart @@ -50,11 +50,21 @@ class CloudMigrationNotifier extends Notifier { } for (final strategy in strategies) { + final pages = [...strategy.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final firstPage = pages.isNotEmpty ? pages.first : null; + final fallbackPageId = const Uuid().v4(); try { - await repo.createStrategy( + await repo.createStrategyWithInitialPage( publicId: strategy.id, name: strategy.name, mapData: Maps.mapNames[strategy.mapData] ?? 'ascent', + initialPagePublicId: firstPage?.id ?? fallbackPageId, + initialPageName: firstPage?.name ?? 'Page 1', + initialPageIsAttack: firstPage?.isAttack ?? true, + initialPageSettings: firstPage == null + ? ref.read(strategySettingsProvider.notifier).toJson() + : StrategySettingsProvider.objectToJson(firstPage.settings), folderPublicId: strategy.folderID, themeProfileId: strategy.themeProfileId, themeOverridePalette: strategy.themeOverridePalette == null @@ -69,13 +79,20 @@ class CloudMigrationNotifier extends Notifier { ); } - final pages = [...strategy.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final allOps = []; final usedElementIds = {}; final usedLineupIds = {}; - for (final page in pages) { + for (var i = 0; i < pages.length; i++) { + final page = pages[i]; + if (i == 0) { + appendMigratedPageOps( + allOps, + page, + usedElementIds: usedElementIds, + usedLineupIds: usedLineupIds, + ); + continue; + } try { await ConvexClient.instance.mutation(name: 'pages:add', args: { 'strategyPublicId': strategy.id, diff --git a/lib/providers/collab/remote_strategy_snapshot_provider.dart b/lib/providers/collab/remote_strategy_snapshot_provider.dart index f9afe1e3..2c13ed96 100644 --- a/lib/providers/collab/remote_strategy_snapshot_provider.dart +++ b/lib/providers/collab/remote_strategy_snapshot_provider.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:developer'; -import 'package:convex_flutter/convex_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; @@ -17,11 +15,11 @@ final remoteStrategySnapshotProvider = AsyncNotifierProvider< class RemoteStrategySnapshotNotifier extends AsyncNotifier { String? _activeStrategyPublicId; - dynamic _headerSubscription; - dynamic _pagesSubscription; - dynamic _assetsSubscription; - final Map _elementSubscriptions = {}; - final Map _lineupSubscriptions = {}; + StreamSubscription? _headerSubscription; + StreamSubscription>? _pagesSubscription; + StreamSubscription>? _assetsSubscription; + StreamSubscription>? _elementsSubscription; + StreamSubscription>? _lineupsSubscription; Timer? _refreshDebounce; @override @@ -34,7 +32,9 @@ class RemoteStrategySnapshotNotifier Future openStrategy(String strategyPublicId) async { _activeStrategyPublicId = strategyPublicId; - ref.read(strategyOpQueueProvider.notifier).setActiveStrategy(strategyPublicId); + ref + .read(strategyOpQueueProvider.notifier) + .setActiveStrategy(strategyPublicId); state = const AsyncLoading(); await _refreshFromServer(); @@ -72,7 +72,6 @@ class RemoteStrategySnapshotNotifier .read(convexStrategyRepositoryProvider) .fetchSnapshot(strategyPublicId); state = AsyncData(snapshot); - await _syncPageSubscriptions(snapshot); } catch (error, stackTrace) { if (isConvexUnauthenticatedError(error)) { unawaited( @@ -94,160 +93,109 @@ class RemoteStrategySnapshotNotifier Future _startSubscriptions(String strategyPublicId) async { _disposeSubscriptions(); - - _headerSubscription = await ConvexClient.instance.subscribe( - name: 'strategies:getHeader', - args: {'strategyPublicId': strategyPublicId}, - onUpdate: (_) => _scheduleRefresh(), - onError: (message, _) => _handleSubscriptionError( - source: 'remote_snapshot:header_subscription', - message: message, - ), - ); - - _pagesSubscription = await ConvexClient.instance.subscribe( - name: 'pages:listForStrategy', - args: {'strategyPublicId': strategyPublicId}, - onUpdate: (value) { - try { - final pageIds = _decodePageIds(value); - _syncPageWatchersFromIds(strategyPublicId, pageIds); - _scheduleRefresh(); - } catch (error, stackTrace) { - log( - 'Failed to decode pages subscription payload: $error', - name: 'remote_snapshot', + final repository = ref.read(convexStrategyRepositoryProvider); + + _headerSubscription = repository + .watchStrategyHeader(strategyPublicId) + .listen( + (header) => + _replaceSnapshot((snapshot) => snapshot.replaceHeader(header)), + onError: (error, stackTrace) => _handleSubscriptionError( + source: 'remote_snapshot:header_subscription', error: error, stackTrace: stackTrace, - ); - _scheduleRefresh(); - } - }, - onError: (message, _) => _handleSubscriptionError( - source: 'remote_snapshot:pages_subscription', - message: message, - ), - ); - - _assetsSubscription = await ConvexClient.instance.subscribe( - name: 'images:listForStrategy', - args: {'strategyPublicId': strategyPublicId}, - onUpdate: (_) => _scheduleRefresh(), - onError: (message, _) => _handleSubscriptionError( - source: 'remote_snapshot:assets_subscription', - message: message, - ), - ); - } + ), + ); - Set _decodePageIds(dynamic value) { - final decoded = value is String ? jsonDecode(value) : value; - if (decoded is! List) { - throw FormatException( - 'Expected list payload for pages subscription, got ${decoded.runtimeType}', - ); - } + _pagesSubscription = + repository.watchPagesForStrategy(strategyPublicId).listen( + (pages) => + _replaceSnapshot((snapshot) => snapshot.replacePages(pages)), + onError: (error, stackTrace) => _handleSubscriptionError( + source: 'remote_snapshot:pages_subscription', + error: error, + stackTrace: stackTrace, + ), + ); - return decoded - .map((item) => item is String ? jsonDecode(item) : item) - .whereType() - .map((item) => Map.from(item)) - .map((item) => item['publicId'] as String?) - .whereType() - .toSet(); - } + _assetsSubscription = + repository.watchImageAssetsForStrategy(strategyPublicId).listen( + (assets) => _replaceSnapshot( + (snapshot) => snapshot.replaceAssets(assets), + ), + onError: (error, stackTrace) => _handleSubscriptionError( + source: 'remote_snapshot:assets_subscription', + error: error, + stackTrace: stackTrace, + ), + ); - Future _syncPageSubscriptions(RemoteStrategySnapshot snapshot) async { - final strategyPublicId = _activeStrategyPublicId; - if (strategyPublicId == null) { - return; - } + _elementsSubscription = + repository.watchElementsForStrategy(strategyPublicId).listen( + (elements) => _replaceSnapshot( + (snapshot) => snapshot.replaceElements(elements), + ), + onError: (error, stackTrace) => _handleSubscriptionError( + source: 'remote_snapshot:elements_subscription', + error: error, + stackTrace: stackTrace, + ), + ); - final pageIds = snapshot.pages.map((page) => page.publicId).toSet(); - _syncPageWatchersFromIds(strategyPublicId, pageIds); + _lineupsSubscription = + repository.watchLineupsForStrategy(strategyPublicId).listen( + (lineups) => _replaceSnapshot( + (snapshot) => snapshot.replaceLineups(lineups), + ), + onError: (error, stackTrace) => _handleSubscriptionError( + source: 'remote_snapshot:lineups_subscription', + error: error, + stackTrace: stackTrace, + ), + ); } - void _syncPageWatchersFromIds( - String strategyPublicId, - Set pageIds, + void _replaceSnapshot( + RemoteStrategySnapshot Function(RemoteStrategySnapshot snapshot) replace, ) { - final existingElementPageIds = _elementSubscriptions.keys.toSet(); - final existingLineupPageIds = _lineupSubscriptions.keys.toSet(); - - for (final pageId in existingElementPageIds.difference(pageIds)) { - _cancelSubscription(_elementSubscriptions.remove(pageId)); + if (_activeStrategyPublicId == null) { + return; } - for (final pageId in existingLineupPageIds.difference(pageIds)) { - _cancelSubscription(_lineupSubscriptions.remove(pageId)); + if (ref.read(authProvider).hasActiveAuthIncident) { + return; } - for (final pageId in pageIds) { - if (!_elementSubscriptions.containsKey(pageId)) { - _elementSubscriptions[pageId] = true; - ConvexClient.instance - .subscribe( - name: 'elements:listForPage', - args: { - 'strategyPublicId': strategyPublicId, - 'pagePublicId': pageId, - }, - onUpdate: (_) => _scheduleRefresh(), - onError: (message, _) => _handleSubscriptionError( - source: 'remote_snapshot:elements_subscription', - message: message, - ), - ) - .then((subscription) { - final current = _elementSubscriptions[pageId]; - if (current == null) { - _cancelSubscription(subscription); - return; - } - _elementSubscriptions[pageId] = subscription; - }); - } - - if (!_lineupSubscriptions.containsKey(pageId)) { - _lineupSubscriptions[pageId] = true; - ConvexClient.instance - .subscribe( - name: 'lineups:listForPage', - args: { - 'strategyPublicId': strategyPublicId, - 'pagePublicId': pageId, - }, - onUpdate: (_) => _scheduleRefresh(), - onError: (message, _) => _handleSubscriptionError( - source: 'remote_snapshot:lineups_subscription', - message: message, - ), - ) - .then((subscription) { - final current = _lineupSubscriptions[pageId]; - if (current == null) { - _cancelSubscription(subscription); - return; - } - _lineupSubscriptions[pageId] = subscription; - }); - } + final current = state.valueOrNull; + if (current == null) { + _scheduleRefresh(); + return; } + state = AsyncData(replace(current)); } void _handleSubscriptionError({ required String source, - required String message, + required Object error, + StackTrace? stackTrace, }) { + final message = error.toString(); if (isConvexUnauthenticatedMessage(message)) { unawaited( ref.read(authProvider.notifier).reportConvexUnauthenticated( source: source, - error: Exception(message), + error: error, + stackTrace: stackTrace, ), ); return; } + log( + 'Remote snapshot subscription failed: $message', + name: 'remote_snapshot', + error: error, + stackTrace: stackTrace, + ); _scheduleRefresh(); } @@ -270,31 +218,19 @@ class RemoteStrategySnapshotNotifier _refreshDebounce?.cancel(); _refreshDebounce = null; - _cancelSubscription(_headerSubscription); + unawaited(_headerSubscription?.cancel()); _headerSubscription = null; - _cancelSubscription(_pagesSubscription); + unawaited(_pagesSubscription?.cancel()); _pagesSubscription = null; - _cancelSubscription(_assetsSubscription); + unawaited(_assetsSubscription?.cancel()); _assetsSubscription = null; - for (final subscription in _elementSubscriptions.values) { - _cancelSubscription(subscription); - } - _elementSubscriptions.clear(); - - for (final subscription in _lineupSubscriptions.values) { - _cancelSubscription(subscription); - } - _lineupSubscriptions.clear(); - } + unawaited(_elementsSubscription?.cancel()); + _elementsSubscription = null; - void _cancelSubscription(dynamic subscription) { - try { - subscription?.cancel(); - } catch (_) { - // Best-effort cleanup. - } + unawaited(_lineupsSubscription?.cancel()); + _lineupsSubscription = null; } } diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index c8d900dc..205de576 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -67,16 +67,53 @@ 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; + } + + @override + int get hashCode => Object.hash( + strategyPublicId, + sequence, + pageId, + fingerprint, + ); +} + final strategyPageSessionProvider = NotifierProvider( StrategyPageSessionNotifier.new, ); class StrategyPageSessionNotifier extends Notifier { - int? _lastHydratedRemoteSequence; - String? _lastHydratedRemoteStrategyId; - String? _lastHydratedRemotePageId; + _RemotePageHydrationKey? _lastHydratedRemotePageKey; + _RemotePageHydrationKey? _lastSequenceAdvancedHydrationKey; bool _pendingRemoteReapply = false; + bool _pendingRemoteSequenceAdvanced = false; @override StrategyPageSessionState build() { @@ -102,46 +139,52 @@ class StrategyPageSessionNotifier extends Notifier { state = state.copyWith(availablePageIds: orderedIds); } - final prevSequence = previous?.valueOrNull?.header.sequence; - final sequenceChanged = - prevSequence == null || prevSequence != snapshot.header.sequence; - if (!sequenceChanged) { + final targetPageId = _resolveHydrationTargetPage(snapshot); + if (targetPageId == null) { return; } - final targetPageId = _resolveHydrationTargetPage(snapshot); - if (targetPageId == null) { + final hydrationKey = + _buildRemotePageHydrationKey(snapshot, targetPageId); + if (hydrationKey == null) { return; } - final alreadyHydrated = - _lastHydratedRemoteStrategyId == snapshot.header.publicId && - _lastHydratedRemoteSequence == snapshot.header.sequence && - _lastHydratedRemotePageId == targetPageId; - if (alreadyHydrated) { + 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; + } + _requestRemoteRehydrate( + targetPageId, + hydrationKey: hydrationKey, + sequenceAdvanced: sequenceAdvanced, + ); return; } - if (_canSafelyReapplyRemotePage()) { - unawaited(_rehydrateActivePageFromSource(targetPageId)); - } else { - _pendingRemoteReapply = true; + if (_shouldRehydrateLateSectionReplacement(hydrationKey)) { + _requestRemoteRehydrate( + targetPageId, + hydrationKey: hydrationKey, + sequenceAdvanced: false, + ); } }, ); ref.listen(strategySaveStateProvider, (_, __) { - if (_pendingRemoteReapply && _canSafelyReapplyRemotePage()) { - _pendingRemoteReapply = false; - final pageId = state.activePageId; - if (pageId != null) { - unawaited(_rehydrateActivePageFromSource(pageId)); - } - } + _resumePendingRemoteReapplyIfPossible(); }); ref.listen(strategyOpQueueProvider, (previous, next) { - final previousAckBatch = previous?.lastAckBatch ?? const []; + final previousAckBatch = + previous?.lastAckBatch ?? const []; if (next.lastAckBatch.isEmpty || identical(previousAckBatch, next.lastAckBatch)) { return; @@ -307,10 +350,10 @@ class StrategyPageSessionNotifier extends Notifier { transitionState: PageTransitionState.idle, isApplyingPage: false, ); - _lastHydratedRemoteSequence = null; - _lastHydratedRemoteStrategyId = null; - _lastHydratedRemotePageId = null; + _lastHydratedRemotePageKey = null; + _lastSequenceAdvancedHydrationKey = null; _pendingRemoteReapply = false; + _pendingRemoteSequenceAdvanced = false; ref.read(activePageLiveSyncProvider.notifier).reset(); } @@ -348,7 +391,11 @@ class StrategyPageSessionNotifier extends Notifier { } } - Future _rehydrateActivePageFromSource(String pageId) async { + Future _rehydrateActivePageFromSource( + String pageId, { + _RemotePageHydrationKey? hydrationKey, + bool sequenceAdvanced = false, + }) async { final strategyState = ref.read(strategyProvider); final strategyId = strategyState.strategyId; final source = strategyState.source; @@ -360,11 +407,14 @@ class StrategyPageSessionNotifier extends Notifier { strategyPublicId: strategyId, activePageId: pageId, ); - final pageData = await _resolvePageSource(strategyId, source).loadPage(pageId); + final pageData = + await _resolvePageSource(strategyId, source).loadPage(pageId); await _applyLoadedPageData( pageData, strategyId: strategyId, source: source, + hydrationKey: hydrationKey, + sequenceAdvanced: sequenceAdvanced, ); } @@ -372,10 +422,12 @@ class StrategyPageSessionNotifier extends Notifier { StrategyEditorPageData pageData, { required String strategyId, required StrategySource source, + _RemotePageHydrationKey? hydrationKey, + bool sequenceAdvanced = false, }) async { final preserveHistory = source == StrategySource.cloud && - _lastHydratedRemoteStrategyId == strategyId && - _lastHydratedRemotePageId == pageData.pageId; + _lastHydratedRemotePageKey?.strategyPublicId == strategyId && + _lastHydratedRemotePageKey?.pageId == pageData.pageId; final themeProfileId = _resolveThemeProfileId(source, strategyId); final themeOverridePalette = _resolveThemeOverridePalette(source, strategyId); @@ -395,7 +447,11 @@ class StrategyPageSessionNotifier extends Notifier { themeOverridePalette: themeOverridePalette, preserveHistory: preserveHistory, ); - _updateHydrationBookkeeping(pageData.pageId); + _updateHydrationBookkeeping( + pageData.pageId, + hydrationKey: hydrationKey, + sequenceAdvanced: sequenceAdvanced, + ); } finally { state = state.copyWith( activePageId: pageData.pageId, @@ -476,6 +532,26 @@ class StrategyPageSessionNotifier extends Notifier { state.transitionState == PageTransitionState.idle; } + 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) { if (snapshot.pages.isEmpty) { return null; @@ -492,14 +568,131 @@ class StrategyPageSessionNotifier extends Notifier { return pages.first.publicId; } - void _updateHydrationBookkeeping(String pageId) { + 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; if (snapshot == null) { - return; + return null; + } + return _buildRemotePageHydrationKey(snapshot, pageId); + } + + _RemotePageHydrationKey? _buildRemotePageHydrationKey( + RemoteStrategySnapshot snapshot, + String pageId, + ) { + RemotePage? page; + for (final candidate in snapshot.pages) { + if (candidate.publicId == pageId) { + page = candidate; + break; + } + } + if (page == null) { + return null; + } + + final elements = [ + ...snapshot.elementsByPage[pageId] ?? const [] + ]..sort(_compareRemoteElements); + final lineups = [ + ...snapshot.lineupsByPage[pageId] ?? const [] + ]..sort(_compareRemoteLineups); + final assets = snapshot.assetsById.values.toList() + ..sort((a, b) => a.publicId.compareTo(b.publicId)); + + final fingerprint = jsonEncode({ + 'page': { + 'publicId': page.publicId, + 'name': page.name, + 'sortIndex': page.sortIndex, + 'isAttack': page.isAttack, + 'revision': page.revision, + 'settings': page.settings, + }, + 'elements': [ + for (final element in elements) + { + 'publicId': element.publicId, + 'elementType': element.elementType, + 'payload': element.payload, + 'sortIndex': element.sortIndex, + 'revision': element.revision, + 'deleted': element.deleted, + }, + ], + 'lineups': [ + for (final lineup in lineups) + { + 'publicId': lineup.publicId, + 'payload': lineup.payload, + 'sortIndex': lineup.sortIndex, + 'revision': lineup.revision, + 'deleted': lineup.deleted, + }, + ], + 'assets': [ + for (final asset in assets) + { + 'publicId': asset.publicId, + 'fileExtension': asset.fileExtension, + 'mimeType': asset.mimeType, + 'width': asset.width, + 'height': asset.height, + 'url': asset.url, + 'legacyStoragePath': asset.legacyStoragePath, + }, + ], + }); + + return _RemotePageHydrationKey( + strategyPublicId: snapshot.header.publicId, + sequence: snapshot.header.sequence, + pageId: pageId, + fingerprint: fingerprint, + ); + } + + int _compareRemoteElements(RemoteElement a, RemoteElement b) { + final sortCompare = a.sortIndex.compareTo(b.sortIndex); + if (sortCompare != 0) { + return sortCompare; + } + return a.publicId.compareTo(b.publicId); + } + + int _compareRemoteLineups(RemoteLineup a, RemoteLineup b) { + final sortCompare = a.sortIndex.compareTo(b.sortIndex); + if (sortCompare != 0) { + return sortCompare; } - _lastHydratedRemoteStrategyId = snapshot.header.publicId; - _lastHydratedRemoteSequence = snapshot.header.sequence; - _lastHydratedRemotePageId = pageId; + 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( @@ -570,10 +763,17 @@ 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)); + unawaited( + _rehydrateActivePageFromSource( + pageId, + sequenceAdvanced: sequenceAdvanced, + ), + ); } } diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 2537049a..d1f216c1 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -56,14 +56,14 @@ void _logStrategyProviderDebug({ unawaited( File(r'E:\Projects\icarus-cloud\debug-16ee23.log').writeAsString( '${jsonEncode({ - 'sessionId': '16ee23', - 'runId': runId, - 'hypothesisId': hypothesisId, - 'location': location, - 'message': message, - 'data': data, - 'timestamp': DateTime.now().millisecondsSinceEpoch, - })}\n', + 'sessionId': '16ee23', + 'runId': runId, + 'hypothesisId': hypothesisId, + 'location': location, + 'message': message, + 'data': data, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + })}\n', mode: FileMode.append, flush: true, ), @@ -915,21 +915,18 @@ class StrategyProvider extends Notifier { final defaultThemeProfileId = ref.read(mapThemeProfilesProvider).defaultProfileIdForNewStrategies; try { - await ref.read(convexStrategyRepositoryProvider).createStrategy( + await ref + .read(convexStrategyRepositoryProvider) + .createStrategyWithInitialPage( publicId: newID, name: name, mapData: Maps.mapNames[MapValue.ascent] ?? "ascent", + initialPagePublicId: pageID, + initialPageName: "Page 1", + initialPageIsAttack: true, folderPublicId: ref.read(folderProvider), themeProfileId: defaultThemeProfileId, ); - await ConvexClient.instance.mutation(name: "pages:add", args: { - "strategyPublicId": newID, - "pagePublicId": pageID, - "name": "Page 1", - "sortIndex": 0, - "isAttack": true, - "settings": ref.read(strategySettingsProvider.notifier).toJson(), - }); } catch (error, stackTrace) { final handled = await _reportCloudUnauthenticated( source: 'strategy:create_new', @@ -1052,20 +1049,31 @@ class StrategyProvider extends Notifier { .read(convexStrategyRepositoryProvider) .fetchSnapshot(strategyID); final newStrategyID = const Uuid().v4(); - await ref.read(convexStrategyRepositoryProvider).createStrategy( + final pages = [...snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final firstPage = pages.isNotEmpty ? pages.first : null; + final firstPageId = const Uuid().v4(); + await ref + .read(convexStrategyRepositoryProvider) + .createStrategyWithInitialPage( publicId: newStrategyID, name: "${snapshot.header.name} (Copy)", mapData: snapshot.header.mapData, + initialPagePublicId: firstPageId, + initialPageName: firstPage?.name ?? "Page 1", + initialPageIsAttack: firstPage?.isAttack ?? true, + initialPageSettings: firstPage?.settings, folderPublicId: ref.read(folderProvider), themeProfileId: snapshot.header.themeProfileId, themeOverridePalette: snapshot.header.themeOverridePalette, ); - final pages = [...snapshot.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final pageIdMap = {}; - for (final page in pages) { + if (firstPage != null) { + pageIdMap[firstPage.publicId] = firstPageId; + } + for (var i = firstPage == null ? 0 : 1; i < pages.length; i++) { + final page = pages[i]; final newPageId = const Uuid().v4(); pageIdMap[page.publicId] = newPageId; await ConvexClient.instance.mutation(name: "pages:add", args: { diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index d71ee499..d0ddf102 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -47,6 +47,7 @@ class TextWidget extends ConsumerWidget { const _textFieldDecoration = InputDecoration( hintText: PlacedTextDimensions.emptyTextPlaceholder, hintStyle: TextStyle(color: Colors.grey), + hintMaxLines: 1, border: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.zero, @@ -271,14 +272,16 @@ class _SharedTextField extends StatelessWidget { readOnly: readOnly, enableInteractiveSelection: enableInteractiveSelection, showCursor: showCursor, - style: TextStyle( - fontSize: coordinateSystem.worldHeightToScreen(fontSize), + style: PlacedTextDimensions.textStyle( + coordinateSystem: coordinateSystem, + fontSizeWorld: fontSize, ), decoration: _textFieldDecoration, maxLines: null, minLines: 1, expands: false, scrollPhysics: const NeverScrollableScrollPhysics(), + scrollPadding: EdgeInsets.zero, textAlignVertical: TextAlignVertical.top, keyboardType: TextInputType.multiline, onChanged: onChanged, diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index f0e46b00..16036217 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -884,7 +884,6 @@ class _AccountRailItem extends StatelessWidget { @override Widget build(BuildContext context) { - final showExpandedLayout = expanded && showDetails; return Material( color: Colors.transparent, child: InkWell( @@ -892,7 +891,7 @@ class _AccountRailItem extends StatelessWidget { mouseCursor: onAuthAction == null ? SystemMouseCursors.basic : SystemMouseCursors.click, - onTap: showExpandedLayout ? null : onAuthAction, + onTap: onAuthAction, child: AnimatedContainer( duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic, diff --git a/test/collab_sync_models_test.dart b/test/collab_sync_models_test.dart index 678045a6..c1b0f5ca 100644 --- a/test/collab_sync_models_test.dart +++ b/test/collab_sync_models_test.dart @@ -110,4 +110,114 @@ void main() { expect(remote.decodedPayload(), isEmpty); }); }); + + 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: '{}', + sortIndex: 1, + revision: 1, + deleted: false, + ); + const deletedElement = RemoteElement( + publicId: 'el-2', + strategyPublicId: 'strat-1', + pagePublicId: 'page-1', + elementType: 'text', + payload: '{}', + sortIndex: 0, + revision: 2, + deleted: true, + ); + const lineup = RemoteLineup( + publicId: 'lineup-1', + strategyPublicId: 'strat-1', + pagePublicId: 'page-2', + payload: '{}', + 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]); + }); + }); } diff --git a/test/placed_media_dimensions_test.dart b/test/placed_media_dimensions_test.dart index f458a9e3..cc9b482a 100644 --- a/test/placed_media_dimensions_test.dart +++ b/test/placed_media_dimensions_test.dart @@ -70,15 +70,22 @@ void main() { expect(size.width, CoordinateSystem.instance.worldWidthToScreen(220)); }); - test('text helper uses minimum height for empty text', () { - final size = PlacedTextDimensions.screenSize( + test('text helper uses one-line height for empty text', () { + final empty = PlacedTextDimensions.screenSize( coordinateSystem: CoordinateSystem.instance, widthWorld: 220, fontSizeWorld: 16, text: '', ); + final singleLine = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 220, + fontSizeWorld: 16, + text: 'one line', + ); - expect(size.height, greaterThanOrEqualTo(PlacedTextDimensions.minHeight)); + expect(empty.height, singleLine.height); + expect(empty.height, lessThan(64)); }); test('text helper height increases for wrapped text', () { diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 02312712..b036aabb 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -1,9 +1,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.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', () { + final pageId = 'strategy-1:page:1'; + final elementId = 'element-1'; + final lineupId = 'lineup-1'; + + expect(pageIdForEntityKey(pageSettingsEntityKey(pageId)), pageId); + expect(pageIdForEntityKey(elementEntityKey(pageId, elementId)), pageId); + expect( + entityIdForEntityKey(elementEntityKey(pageId, elementId)), elementId); + expect(pageIdForEntityKey(lineupEntityKey(pageId, lineupId)), pageId); + expect(entityIdForEntityKey(lineupEntityKey(pageId, lineupId)), lineupId); + }); + }); + group('StrategyOpQueueNotifier coalescing', () { late ProviderContainer container; late StrategyOpQueueNotifier notifier; diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 8bbce230..e62e1cd6 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -9,6 +9,7 @@ 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'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/transition_data.dart'; @@ -173,6 +174,7 @@ RemoteStrategySnapshot _cloudSnapshot({ required int sequence, required List pages, Map> elementsByPage = const {}, + Map> lineupsByPage = const {}, }) { final now = DateTime.utc(2026, 1, 1); return RemoteStrategySnapshot( @@ -186,7 +188,7 @@ RemoteStrategySnapshot _cloudSnapshot({ ), pages: pages, elementsByPage: elementsByPage, - lineupsByPage: const {}, + lineupsByPage: lineupsByPage, assetsById: const {}, ); } @@ -231,6 +233,40 @@ RemoteElement _remoteText({ ); } +RemoteLineup _remoteLineup({ + required String strategyId, + required String pageId, + required String lineupId, + required String notes, + int sortIndex = 0, +}) { + final lineup = LineUp( + id: lineupId, + agent: PlacedAgent( + id: '$lineupId-agent', + type: AgentType.jett, + position: const Offset(10, 20), + ), + ability: PlacedAbility( + id: '$lineupId-ability', + data: AgentData.agents[AgentType.jett]!.abilities.first, + position: const Offset(30, 40), + ), + youtubeLink: '', + images: const [], + notes: notes, + ); + return RemoteLineup( + publicId: lineupId, + strategyPublicId: strategyId, + pagePublicId: pageId, + payload: jsonEncode(lineup.toJson()), + sortIndex: sortIndex, + revision: 1, + deleted: false, + ); +} + StrategyData _localStrategy({ required String strategyId, required String firstText, @@ -379,6 +415,435 @@ void main() { expect(container.read(textProvider).single.text, 'after'); }); + 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', + ), + ], + }, + ); + final elementsArrivedSnapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 2, + pages: [pageOne], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'after', + ), + ], + }, + ); + + 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, + ); + 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(); + + expect(container.read(textProvider).single.text, 'after'); + expect(queueNotifier.enqueueAllCount, 0); + expect(queueNotifier.flushNowCount, 0); + }); + + 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); + 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, + ); + + remoteNotifier.setSnapshot(headerFirstSnapshot); + await _settle(); + expect(container.read(lineUpProvider).lineUps.single.notes, 'before'); + + remoteNotifier.setSnapshot(lineupsArrivedSnapshot); + await _settle(); + + expect(container.read(lineUpProvider).lineUps.single.notes, 'after'); + expect(queueNotifier.enqueueAllCount, 0); + expect(queueNotifier.flushNowCount, 0); + }); + + test('active-page elements wait for header sequence before 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 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 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, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + remoteNotifier.setSnapshot(elementsFirstSnapshot); + await _settle(); + expect(container.read(textProvider).single.text, 'before'); + + remoteNotifier.setSnapshot(headerArrivedSnapshot); + 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', + ), + ], + }, + ); + + 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, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + 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); + }); + + test('late same-sequence section rehydrate preserves local overlay', + () 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 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 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, + ); + + 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: { + elementEntityKey('page-1', 'text-1'): ActivePageOverlayEntry( + entityKey: elementEntityKey('page-1', 'text-1'), + entityType: ActivePageOverlayEntityType.element, + desiredPayload: jsonEncode(localTextPayload), + desiredSortIndex: 0, + deletion: false, + baseRevision: 1, + dirtyAt: DateTime.now(), + ), + }, + ), + ); + + remoteNotifier.setSnapshot(headerFirstSnapshot); + await _settle(); + remoteNotifier.setSnapshot(elementsArrivedSnapshot); + 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); + }); + test('cloud agent addition queues an add op immediately', () async { const strategyId = 'cloud-strategy'; final snapshot = _cloudSnapshot( diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index 642fbeeb..d4c924d2 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -333,6 +333,49 @@ void main() { expect(feedbackSize.height, editableSize.height); }); + testWidgets('text widget starts single-line and grows instead of scrolling', + (tester) async { + final container = createContainer(); + container.read(textProvider.notifier).fromHive([ + PlacedText( + id: 'text-1', + position: const Offset(10, 20), + size: 80, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = 'ew', + ]); + + await tester.pumpWidget(buildTextHarness(container)); + await tester.pump(); + + final initialSize = tester.getSize(find.byType(TextWidget)); + expect(initialSize.height, lessThan(64)); + + await tester.enterText( + find.byType(TextField), + 'this text is long enough to wrap across several lines in the editor', + ); + await tester.pump(); + + final wrappedSize = tester.getSize(find.byType(TextWidget)); + expect(wrappedSize.height, greaterThan(initialSize.height)); + + final scrollableFinder = find.descendant( + of: find.byType(TextField), + matching: find.byType(Scrollable), + ); + final scrollableState = + tester.state(scrollableFinder.first); + final scrollable = tester.widget(scrollableFinder.first); + expect(scrollable.axisDirection, AxisDirection.down); + expect( + scrollableState.position.maxScrollExtent, + 0, + reason: 'wrappedSize=$wrappedSize', + ); + }); + testWidgets('side switch mirrors text with deterministic widget bounds', (tester) async { final container = createContainer();