diff --git a/.agent/skills/convex-create-component/SKILL.md b/.agent/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..effe01fa --- /dev/null +++ b/.agent/skills/convex-create-component/SKILL.md @@ -0,0 +1,411 @@ +--- +name: convex-create-component +description: Design and build Convex components with clear boundaries, isolated state, and app-facing wrappers. Use when creating a new Convex component, extracting reusable backend logic into one, or packaging Convex functionality for reuse across apps. +--- + +# 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. `ctx.auth` is not available inside components. +- Keep environment access in the app. Component functions cannot read `process.env`. +- Pass parent app IDs across the boundary as strings. `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. +- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`. +- Do not expose component functions directly to clients. Create app wrappers when client access is needed. +- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`. +- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`. +- Add `args` and `returns` validators to all public component functions. + +## 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() } +``` + +### 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 }); + }, +}); +``` + +## 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/.agent/skills/convex-create-component/agents/openai.yaml b/.agent/skills/convex-create-component/agents/openai.yaml new file mode 100644 index 00000000..ba9287e4 --- /dev/null +++ b/.agent/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/.agent/skills/convex-create-component/assets/icon.svg b/.agent/skills/convex-create-component/assets/icon.svg new file mode 100644 index 00000000..10f4c2c4 --- /dev/null +++ b/.agent/skills/convex-create-component/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agent/skills/convex-create-component/references/hybrid-components.md b/.agent/skills/convex-create-component/references/hybrid-components.md new file mode 100644 index 00000000..d2bb3514 --- /dev/null +++ b/.agent/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/.agent/skills/convex-create-component/references/local-components.md b/.agent/skills/convex-create-component/references/local-components.md new file mode 100644 index 00000000..7fbfe21a --- /dev/null +++ b/.agent/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/.agent/skills/convex-create-component/references/packaged-components.md b/.agent/skills/convex-create-component/references/packaged-components.md new file mode 100644 index 00000000..5668e7ed --- /dev/null +++ b/.agent/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/.agent/skills/convex-migration-helper/SKILL.md b/.agent/skills/convex-migration-helper/SKILL.md new file mode 100644 index 00000000..f353678f --- /dev/null +++ b/.agent/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,524 @@ +--- +name: convex-migration-helper +description: Plan and execute Convex schema migrations safely, including adding fields, creating tables, and data transformations. Use when schema changes affect existing data. +--- + +# 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 + +## 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 (Recommended) + +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. + +### 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 }), +}); +``` + +## Common Migration Patterns + +### 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); + } + }, +}); +``` + +## Migration Strategies for Zero Downtime + +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 +``` + +## 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 + +## Common Pitfalls + +1. **Don't make a field required before migrating data**: Convex will reject the deploy. Always widen the schema first. +2. **Don't `.collect()` large tables**: Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. +3. **Don't forget to write the new format before migrating**: If your code doesn't write the new format for new documents, documents created during the migration window will be missed. +4. **Don't skip the dry run**: Use `dryRun: true` to validate your migration logic before committing changes to production data. +5. **Don't delete fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed. +6. **Don't use crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. diff --git a/.agent/skills/convex-migration-helper/agents/openai.yaml b/.agent/skills/convex-migration-helper/agents/openai.yaml new file mode 100644 index 00000000..c2a7fcc5 --- /dev/null +++ b/.agent/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/.agent/skills/convex-migration-helper/assets/icon.svg b/.agent/skills/convex-migration-helper/assets/icon.svg new file mode 100644 index 00000000..fba7241a --- /dev/null +++ b/.agent/skills/convex-migration-helper/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agent/skills/convex-performance-audit/SKILL.md b/.agent/skills/convex-performance-audit/SKILL.md new file mode 100644 index 00000000..6ee0417f --- /dev/null +++ b/.agent/skills/convex-performance-audit/SKILL.md @@ -0,0 +1,143 @@ +--- +name: convex-performance-audit +description: Audit and optimize Convex application performance, covering hot path reads, write contention, subscription cost, and function limits. Use when a Convex feature is slow, reads too much data, writes too often, has OCC conflicts, or needs performance investigation. +--- + +# 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/.agent/skills/convex-performance-audit/agents/openai.yaml b/.agent/skills/convex-performance-audit/agents/openai.yaml new file mode 100644 index 00000000..9a21f387 --- /dev/null +++ b/.agent/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/.agent/skills/convex-performance-audit/assets/icon.svg b/.agent/skills/convex-performance-audit/assets/icon.svg new file mode 100644 index 00000000..7ab9e09c --- /dev/null +++ b/.agent/skills/convex-performance-audit/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agent/skills/convex-performance-audit/references/function-budget.md b/.agent/skills/convex-performance-audit/references/function-budget.md new file mode 100644 index 00000000..c71d14cb --- /dev/null +++ b/.agent/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/.agent/skills/convex-performance-audit/references/hot-path-rules.md b/.agent/skills/convex-performance-audit/references/hot-path-rules.md new file mode 100644 index 00000000..96a7b94e --- /dev/null +++ b/.agent/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,359 @@ +# 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. + +## 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/.agent/skills/convex-performance-audit/references/occ-conflicts.md b/.agent/skills/convex-performance-audit/references/occ-conflicts.md new file mode 100644 index 00000000..a96d0466 --- /dev/null +++ b/.agent/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,126 @@ +# 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/.agent/skills/convex-performance-audit/references/subscription-cost.md b/.agent/skills/convex-performance-audit/references/subscription-cost.md new file mode 100644 index 00000000..ae7d1adb --- /dev/null +++ b/.agent/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/.agent/skills/convex-quickstart/SKILL.md b/.agent/skills/convex-quickstart/SKILL.md new file mode 100644 index 00000000..369a6c9b --- /dev/null +++ b/.agent/skills/convex-quickstart/SKILL.md @@ -0,0 +1,337 @@ +--- +name: convex-quickstart +description: Initialize a new Convex project from scratch or add Convex to an existing app. Use when starting a new project with Convex, scaffolding a Convex app, or integrating Convex into an existing frontend. +--- + +# 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 agents (Codex, Jules, Devin):** These environments cannot open a browser for login. See the Agent Mode section below for how 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) + +You are ready to start 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-Based Agents) + +When running in a cloud-based agent environment (Codex, Jules, Devin, Cursor Background Agents) where you cannot log in interactively, 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/.agent/skills/convex-quickstart/agents/openai.yaml b/.agent/skills/convex-quickstart/agents/openai.yaml new file mode 100644 index 00000000..a51a6d09 --- /dev/null +++ b/.agent/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/.agent/skills/convex-quickstart/assets/icon.svg b/.agent/skills/convex-quickstart/assets/icon.svg new file mode 100644 index 00000000..d83a73f3 --- /dev/null +++ b/.agent/skills/convex-quickstart/assets/icon.svg @@ -0,0 +1,4 @@ + diff --git a/.agent/skills/convex-setup-auth/SKILL.md b/.agent/skills/convex-setup-auth/SKILL.md new file mode 100644 index 00000000..5c0c994a --- /dev/null +++ b/.agent/skills/convex-setup-auth/SKILL.md @@ -0,0 +1,113 @@ +--- +name: convex-setup-auth +description: Set up Convex authentication with proper user management, identity mapping, and access control patterns. Use when implementing auth flows. +--- + +# 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) + +## 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 + +Do not invent a provider-agnostic user sync pattern from memory. +For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. +For Convex Auth, do not add a parallel `users` table plus `storeUser` flow. Follow the Convex Auth docs and built-in auth tables instead. + +Do not invent provider-specific setup from memory when the docs are available. +Do not assume provider initialization commands finish the entire integration. Verify generated files and complete the post-init wiring steps the provider reference calls out. + +## 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/.agent/skills/convex-setup-auth/agents/openai.yaml b/.agent/skills/convex-setup-auth/agents/openai.yaml new file mode 100644 index 00000000..d1c90a14 --- /dev/null +++ b/.agent/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/.agent/skills/convex-setup-auth/assets/icon.svg b/.agent/skills/convex-setup-auth/assets/icon.svg new file mode 100644 index 00000000..4917dbb4 --- /dev/null +++ b/.agent/skills/convex-setup-auth/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agent/skills/convex-setup-auth/references/auth0.md b/.agent/skills/convex-setup-auth/references/auth0.md new file mode 100644 index 00000000..9c729c5a --- /dev/null +++ b/.agent/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/.agent/skills/convex-setup-auth/references/clerk.md b/.agent/skills/convex-setup-auth/references/clerk.md new file mode 100644 index 00000000..7dbde194 --- /dev/null +++ b/.agent/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/.agent/skills/convex-setup-auth/references/convex-auth.md b/.agent/skills/convex-setup-auth/references/convex-auth.md new file mode 100644 index 00000000..d4824d24 --- /dev/null +++ b/.agent/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/.agent/skills/convex-setup-auth/references/workos-authkit.md b/.agent/skills/convex-setup-auth/references/workos-authkit.md new file mode 100644 index 00000000..038cb9f3 --- /dev/null +++ b/.agent/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/.agents/skills/convex-create-component/SKILL.md b/.agents/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..a79c18e0 --- /dev/null +++ b/.agents/skills/convex-create-component/SKILL.md @@ -0,0 +1,284 @@ +--- +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..97f64c1a --- /dev/null +++ b/.agents/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,150 @@ +--- +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..219583e0 --- /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..c80522f2 --- /dev/null +++ b/.agents/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,170 @@ +# 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..9d92b33c --- /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 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 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..c71d14cb --- /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..e3e44b15 --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,371 @@ +# 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 new file mode 100644 index 00000000..a96d0466 --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,126 @@ +# 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 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..792bba3d --- /dev/null +++ b/.agents/skills/convex-quickstart/SKILL.md @@ -0,0 +1,337 @@ +--- +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..0fa00e2f --- /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/SKILL.md b/.claude/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..a79c18e0 --- /dev/null +++ b/.claude/skills/convex-create-component/SKILL.md @@ -0,0 +1,284 @@ +--- +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/SKILL.md b/.claude/skills/convex-migration-helper/SKILL.md new file mode 100644 index 00000000..97f64c1a --- /dev/null +++ b/.claude/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,150 @@ +--- +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..219583e0 --- /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..c80522f2 --- /dev/null +++ b/.claude/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,170 @@ +# 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/SKILL.md b/.claude/skills/convex-performance-audit/SKILL.md new file mode 100644 index 00000000..9d92b33c --- /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 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 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..c71d14cb --- /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..e3e44b15 --- /dev/null +++ b/.claude/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,371 @@ +# 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 new file mode 100644 index 00000000..a96d0466 --- /dev/null +++ b/.claude/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,126 @@ +# 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 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/SKILL.md b/.claude/skills/convex-quickstart/SKILL.md new file mode 100644 index 00000000..792bba3d --- /dev/null +++ b/.claude/skills/convex-quickstart/SKILL.md @@ -0,0 +1,337 @@ +--- +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/SKILL.md b/.claude/skills/convex-setup-auth/SKILL.md new file mode 100644 index 00000000..0fa00e2f --- /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/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 100644 index 00000000..ebda9951 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/.gitignore b/.gitignore index b7137239..ec9800b2 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ app.*.map.json # FVM Version Cache .fvm/ +.env.local diff --git a/.windsurf/skills/convex-create-component/SKILL.md b/.windsurf/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..a79c18e0 --- /dev/null +++ b/.windsurf/skills/convex-create-component/SKILL.md @@ -0,0 +1,284 @@ +--- +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/SKILL.md b/.windsurf/skills/convex-migration-helper/SKILL.md new file mode 100644 index 00000000..97f64c1a --- /dev/null +++ b/.windsurf/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,150 @@ +--- +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..219583e0 --- /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..c80522f2 --- /dev/null +++ b/.windsurf/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,170 @@ +# 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/SKILL.md b/.windsurf/skills/convex-performance-audit/SKILL.md new file mode 100644 index 00000000..9d92b33c --- /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 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 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..c71d14cb --- /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..e3e44b15 --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,371 @@ +# 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 new file mode 100644 index 00000000..a96d0466 --- /dev/null +++ b/.windsurf/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,126 @@ +# 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 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/SKILL.md b/.windsurf/skills/convex-quickstart/SKILL.md new file mode 100644 index 00000000..792bba3d --- /dev/null +++ b/.windsurf/skills/convex-quickstart/SKILL.md @@ -0,0 +1,337 @@ +--- +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/SKILL.md b/.windsurf/skills/convex-setup-auth/SKILL.md new file mode 100644 index 00000000..0fa00e2f --- /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/AGENTS.md b/AGENTS.md index 0c8ff483..3cb60907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,3 +19,11 @@ A Flutter desktop app for creating interactive Valorant game strategies. See `RE - **No automated tests exist** in this codebase. `flutter test` will find nothing. - **Lint.** `fvm flutter analyze` — expect ~70 pre-existing warnings/infos (unused imports, deprecated APIs). No errors. - **Build.** `fvm flutter build linux --debug` produces the binary at `build/linux/x64/debug/bundle/icarus`. + + +This project uses [Convex](https://convex.dev) as its backend. + +When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data. + +Convex agent skills for common tasks can be installed by running `npx convex ai-files install`. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ba211633 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ + +This project uses [Convex](https://convex.dev) as its backend. + +When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data. + +Convex agent skills for common tasks can be installed by running `npx convex ai-files install`. + diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..c19d1a05 --- /dev/null +++ b/bun.lock @@ -0,0 +1,89 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "icarus", + "dependencies": { + "convex": "^1.32.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.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" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="], + + "esbuild": ["esbuild@0.27.0", "", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + } +} diff --git a/convex/_generated/ai/ai-files.state.json b/convex/_generated/ai/ai-files.state.json new file mode 100644 index 00000000..a8f6e5f4 --- /dev/null +++ b/convex/_generated/ai/ai-files.state.json @@ -0,0 +1,13 @@ +{ + "guidelinesHash": "294b619f8246c26bd6bfb6a57122503f0e2149872fc6b26609b7a95bfefaf2b8", + "agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", + "claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", + "agentSkillsSha": "dc8ff761cfe4da450af2ea8a9ec708f737064bed", + "installedSkillNames": [ + "convex-create-component", + "convex-migration-helper", + "convex-performance-audit", + "convex-quickstart", + "convex-setup-auth" + ] +} diff --git a/convex/_generated/ai/guidelines.md b/convex/_generated/ai/guidelines.md new file mode 100644 index 00000000..151cdf71 --- /dev/null +++ b/convex/_generated/ai/guidelines.md @@ -0,0 +1,326 @@ +# 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) => { + 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: { + simpleArray: v.array(v.union(v.string(), v.number())), +}, +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(), + }), + ), + ) +}); +``` +- 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 "_". | + +### 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. +- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. +- 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() }, + handler: async (ctx, args) => { + return "Hello " + args.name; + }, +}); + +export const g = query({ + args: {}, + handler: async (ctx, args) => { + const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); + return null; + }, +}); +``` + +### 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`. +- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. +- 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 +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); + }, +}); +``` +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: +- page (contains an array of documents that you fetches) +- 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)`. +- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". +- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. +- Do not store unbounded lists as an array field inside a document (e.g. `v.array(v.object({...}))`). As the array grows it will hit the 1MB document size limit, and every update rewrites the entire document. Instead, create a separate table for the child items with a foreign key back to the parent. +- 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: [ + { + domain: "https://your-auth-provider.com", + applicationID: "convex", + }, + ], +}; +``` +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"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +function App({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` +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. +- 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; + }, +}); +``` +- 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 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. +- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete(row._id)`, and repeat until no more results are returned. +- 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; + }, +}); +``` + +## 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"; +import { internalAction } from "./_generated/server"; + +const empty = internalAction({ + args: {}, + handler: async (ctx, args) => { + console.log("empty"); + }, +}); + +const crons = cronJobs(); + +// Run `internal.crons.empty` every two hours. +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. + + +## 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"; +import { expect, test } from "vitest"; +import { api } from "./_generated/api"; +import schema from "./schema"; + +const modules = import.meta.glob("./**/*.ts"); + +test("some behavior", async () => { + const t = convexTest(schema, modules); + await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" }); + const messages = await t.query(api.messages.list); + 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"; + +type FileMetadata = { + _id: Id<"_storage">; + _creationTime: number; + contentType?: string; + sha256: string; + size: number; +} + +export const exampleQuery = query({ + args: { fileId: v.id("_storage") }, + handler: async (ctx, args) => { + const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId); + console.log(metadata); + return null; + }, +}); +``` +- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. + + diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts new file mode 100644 index 00000000..97f292b1 --- /dev/null +++ b/convex/_generated/api.d.ts @@ -0,0 +1,75 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as elements from "../elements.js"; +import type * as folders from "../folders.js"; +import type * as health from "../health.js"; +import type * as images from "../images.js"; +import type * as invites from "../invites.js"; +import type * as lib_auth from "../lib/auth.js"; +import type * as lib_entities from "../lib/entities.js"; +import type * as lib_errors from "../lib/errors.js"; +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 strategies from "../strategies.js"; +import type * as users from "../users.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; + +declare const fullApi: ApiFromModules<{ + elements: typeof elements; + folders: typeof folders; + health: typeof health; + images: typeof images; + invites: typeof invites; + "lib/auth": typeof lib_auth; + "lib/entities": typeof lib_entities; + "lib/errors": typeof lib_errors; + "lib/opTypes": typeof lib_opTypes; + lineups: typeof lineups; + ops: typeof ops; + pages: typeof pages; + strategies: typeof strategies; + users: typeof users; +}>; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; + +export declare const components: {}; diff --git a/convex/_generated/api.js b/convex/_generated/api.js new file mode 100644 index 00000000..44bf9858 --- /dev/null +++ b/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts new file mode 100644 index 00000000..f97fd194 --- /dev/null +++ b/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts new file mode 100644 index 00000000..bec05e68 --- /dev/null +++ b/convex/_generated/server.d.ts @@ -0,0 +1,143 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/convex/_generated/server.js b/convex/_generated/server.js new file mode 100644 index 00000000..bf3d25ad --- /dev/null +++ b/convex/_generated/server.js @@ -0,0 +1,93 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction = httpActionGeneric; diff --git a/convex/auth.config.ts b/convex/auth.config.ts new file mode 100644 index 00000000..f49f1417 --- /dev/null +++ b/convex/auth.config.ts @@ -0,0 +1,13 @@ +import type { AuthConfig } from "convex/server"; + +export default { + providers: [ + { + type: "customJwt", + applicationID: "authenticated", + issuer: "https://gjdirtrtgnawqoruavqn.supabase.co/auth/v1", + jwks: "https://gjdirtrtgnawqoruavqn.supabase.co/auth/v1/.well-known/jwks.json", + algorithm: "ES256", + }, + ], +} satisfies AuthConfig; diff --git a/convex/elements.ts b/convex/elements.ts new file mode 100644 index 00000000..f41ae5fa --- /dev/null +++ b/convex/elements.ts @@ -0,0 +1,40 @@ +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyRole } from "./lib/auth"; +import { getPageByPublicId, getStrategyByPublicId } from "./lib/entities"; + +export const listForPage = query({ + args: { + strategyPublicId: v.string(), + pagePublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + + const elements = await ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + + return elements + .sort((a, b) => a.sortIndex - b.sortIndex) + .map((element) => ({ + publicId: element.publicId, + strategyPublicId: strategy.publicId, + pagePublicId: page.publicId, + 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 new file mode 100644 index 00000000..31bd20ca --- /dev/null +++ b/convex/folders.ts @@ -0,0 +1,258 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { requireCurrentUser } from "./lib/auth"; +import { getFolderByPublicId } from "./lib/entities"; + +export const listForParent = query({ + args: { + parentFolderPublicId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + + let parentFolderId; + if (args.parentFolderPublicId !== undefined) { + const parent = await getFolderByPublicId(ctx, args.parentFolderPublicId); + if (parent.ownerId !== user._id) { + throw new Error("Forbidden"); + } + parentFolderId = parent._id; + } + + const folders = await ctx.db + .query("folders") + .withIndex("by_ownerId", (q) => q.eq("ownerId", user._id)) + .collect(); + + 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, + parentFolderPublicId: + f.parentFolderId === undefined + ? null + : folders.find((p) => p._id === f.parentFolderId)?.publicId ?? null, + createdAt: f.createdAt, + updatedAt: f.updatedAt, + })); + }, +}); + +export const create = mutation({ + args: { + publicId: v.string(), + name: v.string(), + parentFolderPublicId: v.optional(v.string()), + iconCodePoint: v.optional(v.number()), + iconFontFamily: v.optional(v.string()), + iconFontPackage: v.optional(v.string()), + color: v.optional(v.string()), + customColorValue: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const now = Date.now(); + + let parentFolderId; + if (args.parentFolderPublicId !== undefined) { + const parent = await getFolderByPublicId(ctx, args.parentFolderPublicId); + if (parent.ownerId !== user._id) { + throw new Error("Forbidden"); + } + parentFolderId = parent._id; + } + + const existing = await ctx.db + .query("folders") + .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(`Folder publicId already exists: ${args.publicId}`); + } + + await ctx.db.insert("folders", { + publicId: args.publicId, + ownerId: user._id, + name: args.name, + parentFolderId, + iconCodePoint: args.iconCodePoint, + iconFontFamily: args.iconFontFamily, + iconFontPackage: args.iconFontPackage, + color: args.color, + customColorValue: args.customColorValue, + createdAt: now, + updatedAt: now, + }); + + return { ok: true }; + }, +}); + +export const update = mutation({ + args: { + folderPublicId: v.string(), + name: v.optional(v.string()), + iconCodePoint: v.optional(v.number()), + iconFontFamily: v.optional(v.string()), + iconFontPackage: v.optional(v.string()), + clearIconFontFamily: v.optional(v.boolean()), + clearIconFontPackage: v.optional(v.boolean()), + color: v.optional(v.string()), + customColorValue: v.optional(v.number()), + clearCustomColorValue: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + + if (folder.ownerId !== user._id) { + throw new Error("Forbidden"); + } + + const patch: { + name?: string; + iconCodePoint?: number; + iconFontFamily?: string; + iconFontPackage?: string; + color?: string; + customColorValue?: number; + updatedAt: number; + } = { + updatedAt: Date.now(), + }; + + if (args.name !== undefined) { + patch.name = args.name; + } + if (args.iconCodePoint !== undefined) { + patch.iconCodePoint = args.iconCodePoint; + } + if (args.clearIconFontFamily === true) { + patch.iconFontFamily = undefined; + } else if (args.iconFontFamily !== undefined) { + patch.iconFontFamily = args.iconFontFamily; + } + if (args.clearIconFontPackage === true) { + patch.iconFontPackage = undefined; + } else if (args.iconFontPackage !== undefined) { + patch.iconFontPackage = args.iconFontPackage; + } + if (args.color !== undefined) { + patch.color = args.color; + } + if (args.clearCustomColorValue === true) { + patch.customColorValue = undefined; + } else if (args.customColorValue !== undefined) { + patch.customColorValue = args.customColorValue; + } + + await ctx.db.patch(folder._id, patch); + return { ok: true }; + }, +}); + +export const listAll = query({ + args: {}, + handler: async (ctx) => { + const user = await requireCurrentUser(ctx); + const folders = await ctx.db + .query("folders") + .withIndex("by_ownerId", (q) => q.eq("ownerId", user._id)) + .collect(); + + 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, + parentFolderPublicId: + f.parentFolderId === undefined + ? null + : folders.find((p) => p._id === f.parentFolderId)?.publicId ?? null, + createdAt: f.createdAt, + updatedAt: f.updatedAt, + })); + }, +}); + +export const move = mutation({ + args: { + folderPublicId: v.string(), + parentFolderPublicId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + + if (folder.ownerId !== user._id) { + throw new Error("Forbidden"); + } + + let parentFolderId; + if (args.parentFolderPublicId !== undefined) { + const parent = await getFolderByPublicId(ctx, args.parentFolderPublicId); + if (parent.ownerId !== user._id) { + throw new Error("Forbidden"); + } + parentFolderId = parent._id; + } + + await ctx.db.patch(folder._id, { + parentFolderId, + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const deleteFolder = mutation({ + args: { + folderPublicId: v.string(), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + + if (folder.ownerId !== user._id) { + throw new Error("Forbidden"); + } + + const children = await ctx.db + .query("folders") + .withIndex("by_parentFolderId", (q) => q.eq("parentFolderId", folder._id)) + .collect(); + if (children.length > 0) { + throw new Error("Folder has children"); + } + + const strategies = await ctx.db + .query("strategies") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + if (strategies.length > 0) { + throw new Error("Folder has strategies"); + } + + await ctx.db.delete(folder._id); + return { ok: true }; + }, +}); + +export { deleteFolder as delete }; diff --git a/convex/health.ts b/convex/health.ts new file mode 100644 index 00000000..5abeedde --- /dev/null +++ b/convex/health.ts @@ -0,0 +1,9 @@ +// convex/health.ts +import { query } from "./_generated/server"; + +export const ping = query({ + args: {}, + handler: async () => { + return "ok"; + }, +}); \ No newline at end of file diff --git a/convex/images.ts b/convex/images.ts new file mode 100644 index 00000000..88b5be31 --- /dev/null +++ b/convex/images.ts @@ -0,0 +1,141 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyRole, requireCurrentUser } from "./lib/auth"; +import { + getElementByPublicId, + getPageByPublicId, + getStrategyByPublicId, +} from "./lib/entities"; + +export const registerAssetRef = mutation({ + args: { + strategyPublicId: v.string(), + pagePublicId: v.string(), + assetPublicId: v.string(), + elementPublicId: v.optional(v.string()), + storagePath: v.string(), + mimeType: 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"); + } + + 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(); + + if (existing === null) { + await ctx.db.insert("imageAssets", { + publicId: args.assetPublicId, + strategyId: strategy._id, + pageId: page._id, + elementId, + storagePath: args.storagePath, + mimeType: args.mimeType, + width: args.width, + height: args.height, + createdByUserId: user._id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } else { + await ctx.db.patch(existing._id, { + strategyId: strategy._id, + pageId: page._id, + elementId, + storagePath: args.storagePath, + mimeType: args.mimeType, + width: args.width, + height: args.height, + updatedAt: Date.now(), + }); + } + + return { ok: true }; + }, +}); + +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 assets = await ctx.db + .query("imageAssets") + .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, + })); + }, +}); + +export const deleteAssetRef = mutation({ + args: { + strategyPublicId: v.string(), + assetPublicId: v.string(), + }, + handler: async (ctx, args) => { + 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(); + + if (asset === null || asset.strategyId !== strategy._id) { + throw new Error("Asset not found"); + } + + await ctx.db.delete(asset._id); + return { ok: true }; + }, +}); + +export const listPotentiallyStale = query({ + args: { + 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(); + + return assets.filter((asset) => asset.createdByUserId === user._id); + }, +}); diff --git a/convex/invites.ts b/convex/invites.ts new file mode 100644 index 00000000..ff1b6d7f --- /dev/null +++ b/convex/invites.ts @@ -0,0 +1,192 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { + assertStrategyRole, + getStrategyRoleForUser, + requireCurrentUser, +} from "./lib/auth"; +import { getStrategyByPublicId } from "./lib/entities"; + +export const get = query({ + args: { + token: v.optional(v.string()), + strategyPublicId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + + if (args.token !== undefined) { + const invite = await ctx.db + .query("inviteTokens") + .withIndex("by_token", (q) => q.eq("token", args.token!)) + .first(); + if (invite === null) { + return null; + } + + const strategy = await ctx.db.get(invite.strategyId); + if (strategy === null) { + return null; + } + + const role = await getStrategyRoleForUser(ctx, strategy, user._id); + return { + token: invite.token, + strategyPublicId: strategy.publicId, + inviteRole: invite.role, + hasAccessAlready: role !== null, + revoked: invite.revokedAt !== undefined, + expiresAt: invite.expiresAt ?? null, + createdAt: invite.createdAt, + }; + } + + if (args.strategyPublicId !== undefined) { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "owner"); + + const invites = await ctx.db + .query("inviteTokens") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + + return invites + .sort((a, b) => b.createdAt - a.createdAt) + .map((invite) => ({ + token: invite.token, + role: invite.role, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt ?? null, + revokedAt: invite.revokedAt ?? null, + redeemedByUserId: invite.redeemedByUserId ?? null, + })); + } + + throw new Error("Either token or strategyPublicId is required"); + }, +}); + +export const create = mutation({ + args: { + strategyPublicId: v.string(), + token: v.string(), + role: v.union(v.literal("editor"), v.literal("viewer")), + expiresAt: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { user, role } = await assertStrategyRole(ctx, strategy, "owner"); + if (role !== "owner") { + throw new Error("Forbidden"); + } + + const now = Date.now(); + await ctx.db.insert("inviteTokens", { + token: args.token, + strategyId: strategy._id, + role: args.role, + createdByUserId: user._id, + expiresAt: args.expiresAt, + createdAt: now, + updatedAt: now, + }); + + return { ok: true }; + }, +}); + +export const redeem = mutation({ + args: { + token: v.string(), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const invite = await ctx.db + .query("inviteTokens") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (invite === null) { + throw new Error("Invite not found"); + } + + if (invite.revokedAt !== undefined) { + throw new Error("Invite revoked"); + } + + if (invite.expiresAt !== undefined && invite.expiresAt < Date.now()) { + throw new Error("Invite expired"); + } + + const strategy = await ctx.db.get(invite.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: invite.role, + invitedByUserId: invite.createdByUserId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } else { + await ctx.db.patch(existingMembership._id, { + role: invite.role, + updatedAt: Date.now(), + }); + } + } + + await ctx.db.patch(invite._id, { + redeemedByUserId: user._id, + updatedAt: Date.now(), + }); + + return { + ok: true, + strategyPublicId: strategy.publicId, + role: strategy.ownerId === user._id ? "owner" : invite.role, + }; + }, +}); + +export const revoke = mutation({ + args: { + strategyPublicId: v.string(), + token: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { role } = await assertStrategyRole(ctx, strategy, "owner"); + if (role !== "owner") { + throw new Error("Forbidden"); + } + + const invite = await ctx.db + .query("inviteTokens") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (invite === null || invite.strategyId !== strategy._id) { + throw new Error("Invite not found"); + } + + await ctx.db.patch(invite._id, { + revokedAt: Date.now(), + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); diff --git a/convex/lib/auth.ts b/convex/lib/auth.ts new file mode 100644 index 00000000..37b601a2 --- /dev/null +++ b/convex/lib/auth.ts @@ -0,0 +1,115 @@ +import type { QueryCtx, MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { unauthenticatedError } from "./errors"; + +export type StrategyRole = "owner" | "editor" | "viewer"; + +type AnyCtx = QueryCtx | MutationCtx; + +const roleRank: Record = { + viewer: 1, + editor: 2, + owner: 3, +}; + +export function getCanonicalExternalId(identity: { + tokenIdentifier: string; +}): string { + return identity.tokenIdentifier; +} + +export function getLegacyExternalId(identity: { + subject?: string | null; + tokenIdentifier: string; +}): string | null { + const subject = identity.subject; + if (subject == null || subject == identity.tokenIdentifier) { + return null; + } + return subject; +} + +export async function findUserByIdentity( + ctx: AnyCtx, + identity: { + subject?: string | null; + tokenIdentifier: string; + }, +): Promise | null> { + const canonicalExternalId = getCanonicalExternalId(identity); + const canonicalUser = await ctx.db + .query("users") + .withIndex("by_externalId", (q) => q.eq("externalId", canonicalExternalId)) + .first(); + + if (canonicalUser !== null) { + return canonicalUser; + } + + const legacyExternalId = getLegacyExternalId(identity); + if (legacyExternalId == null) { + return null; + } + + return await ctx.db + .query("users") + .withIndex("by_externalId", (q) => q.eq("externalId", legacyExternalId)) + .first(); +} + +export async function requireCurrentUser(ctx: AnyCtx): Promise> { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + + const user = await findUserByIdentity(ctx, identity); + + if (user === null) { + throw new Error("Missing user record. Call users:ensureCurrentUser before querying collaborative data."); + } + + return user; +} + +export async function getStrategyRoleForUser( + ctx: AnyCtx, + strategy: Doc<"strategies">, + userId: Id<"users">, +): Promise { + if (strategy.ownerId === userId) { + return "owner"; + } + + const collaborator = await ctx.db + .query("strategyCollaborators") + .withIndex("by_strategyId_userId", (q) => + q.eq("strategyId", strategy._id).eq("userId", userId), + ) + .first(); + + return collaborator?.role ?? null; +} + +export function hasRole( + actual: StrategyRole | null, + required: StrategyRole, +): boolean { + if (actual === null) return false; + return roleRank[actual] >= roleRank[required]; +} + +export async function assertStrategyRole( + ctx: AnyCtx, + strategy: Doc<"strategies">, + required: StrategyRole, +): Promise<{ user: Doc<"users">; role: StrategyRole }> { + const user = await requireCurrentUser(ctx); + const role = await getStrategyRoleForUser(ctx, strategy, user._id); + + if (!hasRole(role, required)) { + throw new Error("Forbidden"); + } + + return { user, role: role as StrategyRole }; +} diff --git a/convex/lib/entities.ts b/convex/lib/entities.ts new file mode 100644 index 00000000..3e4e8fb7 --- /dev/null +++ b/convex/lib/entities.ts @@ -0,0 +1,91 @@ +import type { QueryCtx, MutationCtx } from "../_generated/server"; +import type { Doc } from "../_generated/dataModel"; + +type AnyCtx = QueryCtx | MutationCtx; + +export async function getStrategyByPublicId( + ctx: AnyCtx, + strategyPublicId: string, +): Promise> { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .first(); + + if (strategy === null) { + throw new Error(`Strategy not found: ${strategyPublicId}`); + } + + return strategy; +} + +export async function getFolderByPublicId( + ctx: AnyCtx, + folderPublicId: string, +): Promise> { + const folder = await ctx.db + .query("folders") + .withIndex("by_publicId", (q) => q.eq("publicId", folderPublicId)) + .first(); + + if (folder === null) { + throw new Error(`Folder not found: ${folderPublicId}`); + } + + return folder; +} + +export async function getPageByPublicId( + ctx: AnyCtx, + pagePublicId: string, +): Promise> { + const page = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", pagePublicId)) + .first(); + + if (page === null) { + throw new Error(`Page not found: ${pagePublicId}`); + } + + return page; +} + +export async function getElementByPublicId( + ctx: AnyCtx, + elementPublicId: string, +): Promise> { + const element = await ctx.db + .query("elements") + .withIndex("by_publicId", (q) => q.eq("publicId", elementPublicId)) + .first(); + + if (element === null) { + throw new Error(`Element not found: ${elementPublicId}`); + } + + return element; +} + +export async function getLineupByPublicId( + ctx: AnyCtx, + lineupPublicId: string, +): Promise> { + const lineup = await ctx.db + .query("lineups") + .withIndex("by_publicId", (q) => q.eq("publicId", lineupPublicId)) + .first(); + + if (lineup === null) { + throw new Error(`Lineup not found: ${lineupPublicId}`); + } + + return lineup; +} + +export function sortByNumberField>( + input: T[], + field: keyof T, +): T[] { + return [...input].sort((a, b) => Number(a[field] ?? 0) - Number(b[field] ?? 0)); +} diff --git a/convex/lib/errors.ts b/convex/lib/errors.ts new file mode 100644 index 00000000..48463e5d --- /dev/null +++ b/convex/lib/errors.ts @@ -0,0 +1,11 @@ +import { ConvexError } from 'convex/values'; + +export function unauthenticatedError(): ConvexError<{ + code: 'UNAUTHENTICATED'; + message: string; +}> { + return new ConvexError({ + code: 'UNAUTHENTICATED', + message: 'Unauthenticated', + }); +} diff --git a/convex/lib/opTypes.ts b/convex/lib/opTypes.ts new file mode 100644 index 00000000..547f7160 --- /dev/null +++ b/convex/lib/opTypes.ts @@ -0,0 +1,28 @@ +import { v } from "convex/values"; + +export const opKindValidator = v.union( + v.literal("add"), + v.literal("move"), + v.literal("patch"), + v.literal("delete"), + v.literal("reorder"), +); + +export const entityTypeValidator = v.union( + v.literal("strategy"), + v.literal("page"), + v.literal("element"), + v.literal("lineup"), +); + +export const strategyOpValidator = v.object({ + opId: v.string(), + kind: opKindValidator, + entityType: entityTypeValidator, + entityPublicId: v.optional(v.string()), + pagePublicId: v.optional(v.string()), + payload: v.optional(v.string()), + sortIndex: v.optional(v.number()), + expectedRevision: v.optional(v.number()), + expectedSequence: v.optional(v.number()), +}); diff --git a/convex/lineups.ts b/convex/lineups.ts new file mode 100644 index 00000000..c3bcb902 --- /dev/null +++ b/convex/lineups.ts @@ -0,0 +1,39 @@ +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyRole } from "./lib/auth"; +import { getPageByPublicId, getStrategyByPublicId } from "./lib/entities"; + +export const listForPage = query({ + args: { + strategyPublicId: v.string(), + pagePublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + + const lineups = await ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + + return lineups + .sort((a, b) => a.sortIndex - b.sortIndex) + .map((lineup) => ({ + publicId: lineup.publicId, + strategyPublicId: strategy.publicId, + pagePublicId: page.publicId, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + revision: lineup.revision, + deleted: lineup.deleted, + createdAt: lineup.createdAt, + updatedAt: lineup.updatedAt, + })); + }, +}); diff --git a/convex/ops.ts b/convex/ops.ts new file mode 100644 index 00000000..27216d23 --- /dev/null +++ b/convex/ops.ts @@ -0,0 +1,504 @@ +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyRole } from "./lib/auth"; +import { + getElementByPublicId, + getLineupByPublicId, + getPageByPublicId, + getStrategyByPublicId, +} from "./lib/entities"; +import { strategyOpValidator } from "./lib/opTypes"; + +async function incrementSequence(ctx: any, strategy: any): Promise { + const nextSequence = strategy.sequence + 1; + const now = Date.now(); + await ctx.db.patch(strategy._id, { + sequence: nextSequence, + updatedAt: now, + }); + return { + ...strategy, + sequence: nextSequence, + updatedAt: now, + }; +} + +function parsePayload(payload: string | undefined): Record { + if (payload === undefined || payload.length === 0) { + return {}; + } + try { + const parsed = JSON.parse(payload); + if (typeof parsed === "object" && parsed !== null) { + return parsed as Record; + } + } catch (_) { + // ignore, validated at call sites + } + return {}; +} + +export const applyBatch = mutation({ + args: { + strategyPublicId: v.string(), + clientId: v.string(), + ops: v.array(strategyOpValidator), + }, + handler: async (ctx, args) => { + let strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const results: Array> = []; + + for (const op of args.ops) { + const existingEvent = await ctx.db + .query("operationEvents") + .withIndex("by_strategyId_clientId_opId", (q) => + q + .eq("strategyId", strategy._id) + .eq("clientId", args.clientId) + .eq("opId", op.opId), + ) + .first(); + + if (existingEvent !== null) { + results.push({ + opId: op.opId, + status: existingEvent.status, + reason: existingEvent.reason ?? null, + appliedSequence: existingEvent.appliedSequence ?? null, + expectedSequence: existingEvent.expectedSequence ?? null, + appliedRevision: existingEvent.appliedRevision ?? null, + expectedRevision: existingEvent.expectedRevision ?? null, + latestSequence: strategy.sequence, + latestRevision: null, + latestPayload: null, + }); + continue; + } + let status: "ack" | "reject" = "ack"; + let reason: string | undefined; + let appliedRevision: number | undefined; + let latestRevision: number | undefined; + let latestPayload: string | undefined; + + try { + if ( + op.expectedSequence !== undefined && + op.expectedSequence !== strategy.sequence + ) { + status = "reject"; + reason = "sequence_mismatch"; + } else if (op.entityType === "strategy") { + if (op.kind !== "patch") { + throw new Error("Unsupported strategy op"); + } + + const payload = parsePayload(op.payload); + const patch: Record = { + updatedAt: Date.now(), + }; + if (typeof payload.name === "string") { + patch.name = payload.name; + } + if (typeof payload.mapData === "string") { + patch.mapData = payload.mapData; + } + if (typeof payload.themeProfileId === "string") { + patch.themeProfileId = payload.themeProfileId; + } + if (payload.clearThemeProfileId === true) { + patch.themeProfileId = undefined; + } + if (typeof payload.themeOverridePalette === "string") { + patch.themeOverridePalette = payload.themeOverridePalette; + } + if (payload.clearThemeOverridePalette === true) { + patch.themeOverridePalette = undefined; + } + + await ctx.db.patch(strategy._id, patch); + strategy = await incrementSequence(ctx, strategy); + } else if (op.entityType === "page") { + if (op.kind === "add") { + const pagePublicId = op.pagePublicId; + if (!pagePublicId) { + throw new Error("Missing pagePublicId"); + } + const payload = parsePayload(op.payload); + const now = Date.now(); + const existingPage = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", pagePublicId)) + .first(); + + if (existingPage !== null) { + if (existingPage.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + + await ctx.db.patch(existingPage._id, { + name: + typeof payload.name === "string" + ? payload.name + : existingPage.name, + sortIndex: op.sortIndex ?? existingPage.sortIndex, + isAttack: + typeof payload.isAttack === "boolean" + ? payload.isAttack + : existingPage.isAttack, + settings: + typeof payload.settings === "string" + ? payload.settings + : existingPage.settings, + revision: existingPage.revision + 1, + updatedAt: now, + }); + appliedRevision = existingPage.revision + 1; + } else { + await ctx.db.insert("pages", { + publicId: pagePublicId, + strategyId: strategy._id, + name: typeof payload.name === "string" ? payload.name : "Page", + sortIndex: op.sortIndex ?? 0, + isAttack: payload.isAttack === false ? false : true, + settings: + typeof payload.settings === "string" + ? payload.settings + : undefined, + revision: 1, + createdAt: now, + updatedAt: now, + }); + appliedRevision = 1; + } + + strategy = await incrementSequence(ctx, strategy); + } else { + const pagePublicId = op.entityPublicId ?? op.pagePublicId; + if (!pagePublicId) { + throw new Error("Missing page id"); + } + const page = await getPageByPublicId(ctx, pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + + latestRevision = page.revision; + + if ( + op.expectedRevision !== undefined && + op.expectedRevision !== page.revision + ) { + status = "reject"; + reason = "revision_mismatch"; + } else if (op.kind === "patch") { + const payload = parsePayload(op.payload); + const patch: Record = { + revision: page.revision + 1, + updatedAt: Date.now(), + }; + if (typeof payload.name === "string") patch.name = payload.name; + if (typeof payload.settings === "string") { + patch.settings = payload.settings; + } + if (typeof payload.isAttack === "boolean") { + patch.isAttack = payload.isAttack; + } + await ctx.db.patch(page._id, patch); + appliedRevision = page.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else if (op.kind === "delete") { + const elements = await ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const element of elements) { + await ctx.db.delete(element._id); + } + + const lineups = await ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const lineup of lineups) { + await ctx.db.delete(lineup._id); + } + + await ctx.db.delete(page._id); + appliedRevision = page.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else if (op.kind === "reorder") { + await ctx.db.patch(page._id, { + sortIndex: op.sortIndex ?? page.sortIndex, + revision: page.revision + 1, + updatedAt: Date.now(), + }); + appliedRevision = page.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else { + throw new Error("Unsupported page op"); + } + } + } else if (op.entityType === "element") { + if (op.kind === "add") { + const elementPublicId = op.entityPublicId; + const pagePublicId = op.pagePublicId; + if (!elementPublicId || !pagePublicId || !op.payload) { + throw new Error("Missing add element args"); + } + const page = await getPageByPublicId(ctx, pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + const payload = parsePayload(op.payload); + const elementType = + typeof payload.elementType === "string" + ? payload.elementType + : "generic"; + const now = Date.now(); + const existingElement = await ctx.db + .query("elements") + .withIndex("by_publicId", (q) => q.eq("publicId", elementPublicId)) + .first(); + + if (existingElement !== null) { + if (existingElement.strategyId !== strategy._id) { + throw new Error("Element strategy mismatch"); + } + await ctx.db.patch(existingElement._id, { + pageId: page._id, + elementType, + payload: op.payload, + sortIndex: op.sortIndex ?? existingElement.sortIndex, + deleted: false, + revision: existingElement.revision + 1, + updatedAt: now, + }); + appliedRevision = existingElement.revision + 1; + } else { + await ctx.db.insert("elements", { + publicId: elementPublicId, + strategyId: strategy._id, + pageId: page._id, + elementType, + payload: op.payload, + sortIndex: op.sortIndex ?? 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + appliedRevision = 1; + } + strategy = await incrementSequence(ctx, strategy); + } else { + if (!op.entityPublicId) { + throw new Error("Missing entityPublicId"); + } + const element = await getElementByPublicId(ctx, op.entityPublicId); + if (element.strategyId !== strategy._id) { + throw new Error("Element strategy mismatch"); + } + + latestRevision = element.revision; + latestPayload = element.payload; + + if ( + op.expectedRevision !== undefined && + op.expectedRevision !== element.revision + ) { + status = "reject"; + reason = "revision_mismatch"; + } else if (op.kind === "delete") { + await ctx.db.patch(element._id, { + deleted: true, + revision: element.revision + 1, + updatedAt: Date.now(), + }); + appliedRevision = element.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else if (op.kind === "patch" || op.kind === "move") { + const patch: Record = { + revision: element.revision + 1, + updatedAt: Date.now(), + }; + if (op.payload !== undefined) { + patch.payload = op.payload; + } + if (op.sortIndex !== undefined) { + patch.sortIndex = op.sortIndex; + } + if (op.pagePublicId !== undefined) { + const page = await getPageByPublicId(ctx, op.pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + patch.pageId = page._id; + } + + await ctx.db.patch(element._id, patch); + appliedRevision = element.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else if (op.kind === "reorder") { + await ctx.db.patch(element._id, { + sortIndex: op.sortIndex ?? element.sortIndex, + revision: element.revision + 1, + updatedAt: Date.now(), + }); + appliedRevision = element.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else { + throw new Error("Unsupported element op"); + } + } + } else if (op.entityType === "lineup") { + if (op.kind === "add") { + const lineupPublicId = op.entityPublicId; + const pagePublicId = op.pagePublicId; + if (!lineupPublicId || !pagePublicId || !op.payload) { + throw new Error("Missing add lineup args"); + } + const page = await getPageByPublicId(ctx, pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + const now = Date.now(); + const existingLineup = await ctx.db + .query("lineups") + .withIndex("by_publicId", (q) => q.eq("publicId", lineupPublicId)) + .first(); + + if (existingLineup !== null) { + if (existingLineup.strategyId !== strategy._id) { + throw new Error("Lineup strategy mismatch"); + } + await ctx.db.patch(existingLineup._id, { + pageId: page._id, + payload: op.payload, + sortIndex: op.sortIndex ?? existingLineup.sortIndex, + deleted: false, + revision: existingLineup.revision + 1, + updatedAt: now, + }); + appliedRevision = existingLineup.revision + 1; + } else { + await ctx.db.insert("lineups", { + publicId: lineupPublicId, + strategyId: strategy._id, + pageId: page._id, + payload: op.payload, + sortIndex: op.sortIndex ?? 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + appliedRevision = 1; + } + strategy = await incrementSequence(ctx, strategy); + } else { + if (!op.entityPublicId) { + throw new Error("Missing entityPublicId"); + } + const lineup = await getLineupByPublicId(ctx, op.entityPublicId); + if (lineup.strategyId !== strategy._id) { + throw new Error("Lineup strategy mismatch"); + } + + latestRevision = lineup.revision; + latestPayload = lineup.payload; + + if ( + op.expectedRevision !== undefined && + op.expectedRevision !== lineup.revision + ) { + status = "reject"; + reason = "revision_mismatch"; + } else if (op.kind === "delete") { + await ctx.db.patch(lineup._id, { + deleted: true, + revision: lineup.revision + 1, + updatedAt: Date.now(), + }); + appliedRevision = lineup.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else if (op.kind === "patch" || op.kind === "move") { + const patch: Record = { + revision: lineup.revision + 1, + updatedAt: Date.now(), + }; + if (op.payload !== undefined) { + patch.payload = op.payload; + } + if (op.sortIndex !== undefined) { + patch.sortIndex = op.sortIndex; + } + if (op.pagePublicId !== undefined) { + const page = await getPageByPublicId(ctx, op.pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + patch.pageId = page._id; + } + await ctx.db.patch(lineup._id, patch); + appliedRevision = lineup.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else if (op.kind === "reorder") { + await ctx.db.patch(lineup._id, { + sortIndex: op.sortIndex ?? lineup.sortIndex, + revision: lineup.revision + 1, + updatedAt: Date.now(), + }); + appliedRevision = lineup.revision + 1; + strategy = await incrementSequence(ctx, strategy); + } else { + throw new Error("Unsupported lineup op"); + } + } + } else { + throw new Error("Unsupported entityType"); + } + } catch (error) { + status = "reject"; + reason = error instanceof Error ? error.message : "unknown_error"; + } + + await ctx.db.insert("operationEvents", { + strategyId: strategy._id, + pageId: undefined, + clientId: args.clientId, + opId: op.opId, + opType: `${op.entityType}.${op.kind}`, + status, + reason, + expectedSequence: op.expectedSequence, + appliedSequence: status === "ack" ? strategy.sequence : undefined, + expectedRevision: op.expectedRevision, + appliedRevision, + createdAt: Date.now(), + }); + + results.push({ + opId: op.opId, + status, + reason: reason ?? null, + appliedSequence: status === "ack" ? strategy.sequence : null, + expectedSequence: op.expectedSequence ?? null, + appliedRevision: appliedRevision ?? null, + expectedRevision: op.expectedRevision ?? null, + latestSequence: strategy.sequence, + latestRevision: latestRevision ?? null, + latestPayload: latestPayload ?? null, + }); + } + + return { + strategyPublicId: strategy.publicId, + sequence: strategy.sequence, + results, + }; + }, +}); + + diff --git a/convex/pages.ts b/convex/pages.ts new file mode 100644 index 00000000..daa465e6 --- /dev/null +++ b/convex/pages.ts @@ -0,0 +1,257 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyRole } from "./lib/auth"; +import { + getPageByPublicId, + getStrategyByPublicId, + sortByNumberField, +} from "./lib/entities"; + +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(); + + return sortByNumberField(pages, "sortIndex").map((page) => ({ + publicId: page.publicId, + strategyPublicId: strategy.publicId, + name: page.name, + sortIndex: page.sortIndex, + isAttack: page.isAttack, + settings: page.settings ?? null, + revision: page.revision, + createdAt: page.createdAt, + updatedAt: page.updatedAt, + })); + }, +}); + +export const add = mutation({ + args: { + strategyPublicId: v.string(), + pagePublicId: v.string(), + name: v.string(), + sortIndex: v.number(), + isAttack: v.boolean(), + settings: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const now = Date.now(); + const existingPage = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", args.pagePublicId)) + .first(); + if (existingPage !== null) { + if (existingPage.strategyId !== strategy._id) { + throw new Error(`Page publicId already exists: ${args.pagePublicId}`); + } + + const settingsChanged = + (existingPage.settings ?? null) !== (args.settings ?? null); + const hasChanges = + existingPage.name !== args.name || + existingPage.sortIndex !== args.sortIndex || + existingPage.isAttack !== args.isAttack || + settingsChanged; + if (!hasChanges) { + return { ok: true, reused: true }; + } + + await ctx.db.patch(existingPage._id, { + name: args.name, + sortIndex: args.sortIndex, + isAttack: args.isAttack, + settings: args.settings, + revision: existingPage.revision + 1, + updatedAt: now, + }); + + await ctx.db.patch(strategy._id, { + sequence: strategy.sequence + 1, + updatedAt: now, + }); + return { ok: true, reused: true }; + } + + await ctx.db.insert("pages", { + publicId: args.pagePublicId, + strategyId: strategy._id, + name: args.name, + sortIndex: args.sortIndex, + isAttack: args.isAttack, + settings: args.settings, + revision: 1, + createdAt: now, + updatedAt: now, + }); + + await ctx.db.patch(strategy._id, { + sequence: strategy.sequence + 1, + updatedAt: now, + }); + + return { ok: true }; + }, +}); + +export const rename = mutation({ + args: { + strategyPublicId: v.string(), + pagePublicId: v.string(), + name: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + + const now = Date.now(); + await ctx.db.patch(page._id, { + name: args.name, + revision: page.revision + 1, + updatedAt: now, + }); + + await ctx.db.patch(strategy._id, { + sequence: strategy.sequence + 1, + updatedAt: now, + }); + + return { ok: true }; + }, +}); + +export const deletePage = mutation({ + args: { + strategyPublicId: v.string(), + pagePublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + + if (pages.length <= 1) { + throw new Error("Cannot delete last page"); + } + + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw new Error("Page strategy mismatch"); + } + + const elements = await ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const element of elements) { + await ctx.db.delete(element._id); + } + + const lineups = await ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const lineup of lineups) { + 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 ctx.db.delete(page._id); + + const ordered = sortByNumberField( + pages.filter((p) => p._id !== page._id), + "sortIndex", + ); + for (let i = 0; i < ordered.length; i += 1) { + const current = ordered[i]; + if (current.sortIndex !== i) { + await ctx.db.patch(current._id, { + sortIndex: i, + revision: current.revision + 1, + updatedAt: Date.now(), + }); + } + } + + await ctx.db.patch(strategy._id, { + sequence: strategy.sequence + 1, + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const reorder = mutation({ + args: { + strategyPublicId: v.string(), + orderedPagePublicIds: v.array(v.string()), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + + if (pages.length !== args.orderedPagePublicIds.length) { + throw new Error("Page count mismatch"); + } + + const pageByPublicId = new Map(pages.map((p) => [p.publicId, p])); + const now = Date.now(); + + for (let i = 0; i < args.orderedPagePublicIds.length; i += 1) { + const publicId = args.orderedPagePublicIds[i]; + const page = pageByPublicId.get(publicId); + if (!page) { + throw new Error(`Unknown page id: ${publicId}`); + } + if (page.sortIndex !== i) { + await ctx.db.patch(page._id, { + sortIndex: i, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + + await ctx.db.patch(strategy._id, { + sequence: strategy.sequence + 1, + updatedAt: now, + }); + + return { ok: true }; + }, +}); + +export { deletePage as delete }; diff --git a/convex/schema.ts b/convex/schema.ts new file mode 100644 index 00000000..4ee76e00 --- /dev/null +++ b/convex/schema.ts @@ -0,0 +1,144 @@ +// convex/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + users: defineTable({ + externalId: v.string(), // auth provider subject + displayName: v.string(), + avatarUrl: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }).index("by_externalId", ["externalId"]), + folders: defineTable({ + publicId: v.string(), + ownerId: v.id("users"), + name: v.string(), + parentFolderId: v.optional(v.id("folders")), + iconCodePoint: v.optional(v.number()), + iconFontFamily: v.optional(v.string()), + iconFontPackage: v.optional(v.string()), + color: v.optional(v.string()), + customColorValue: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_ownerId", ["ownerId"]) + .index("by_parentFolderId", ["parentFolderId"]), + strategies: defineTable({ + publicId: v.string(), + ownerId: v.id("users"), + folderId: v.optional(v.id("folders")), + name: v.string(), + mapData: v.string(), + sequence: v.number(), + themeProfileId: v.optional(v.string()), + themeOverridePalette: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_ownerId", ["ownerId"]) + .index("by_folderId", ["folderId"]), + pages: defineTable({ + publicId: v.string(), + strategyId: v.id("strategies"), + name: v.string(), + sortIndex: v.number(), + isAttack: v.boolean(), + settings: v.optional(v.string()), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_strategyId", ["strategyId"]), + elements: defineTable({ + publicId: v.string(), + strategyId: v.id("strategies"), + pageId: v.id("pages"), + elementType: v.string(), + payload: v.string(), + sortIndex: v.number(), + revision: v.number(), + deleted: v.boolean(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_pageId", ["pageId"]) + .index("by_strategyId", ["strategyId"]), + lineups: defineTable({ + publicId: v.string(), + strategyId: v.id("strategies"), + pageId: v.id("pages"), + payload: v.string(), + sortIndex: v.number(), + revision: v.number(), + deleted: v.boolean(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_pageId", ["pageId"]) + .index("by_strategyId", ["strategyId"]), + strategyCollaborators: defineTable({ + strategyId: v.id("strategies"), + 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_strategyId", ["strategyId"]) + .index("by_userId", ["userId"]) + .index("by_strategyId_userId", ["strategyId", "userId"]), + inviteTokens: defineTable({ + token: v.string(), + strategyId: v.id("strategies"), + role: v.union(v.literal("editor"), v.literal("viewer")), + createdByUserId: v.id("users"), + redeemedByUserId: v.optional(v.id("users")), + expiresAt: v.optional(v.number()), + revokedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_token", ["token"]) + .index("by_strategyId", ["strategyId"]), + imageAssets: defineTable({ + publicId: v.string(), + strategyId: v.id("strategies"), + pageId: v.id("pages"), + elementId: v.optional(v.id("elements")), + storagePath: v.string(), + mimeType: v.string(), + width: v.optional(v.number()), + height: v.optional(v.number()), + createdByUserId: v.id("users"), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_strategyId", ["strategyId"]) + .index("by_pageId", ["pageId"]), + operationEvents: defineTable({ + strategyId: v.id("strategies"), + pageId: v.optional(v.id("pages")), + clientId: v.string(), + opId: v.string(), + opType: v.string(), + status: v.union(v.literal("ack"), v.literal("reject")), + reason: v.optional(v.string()), + expectedSequence: v.optional(v.number()), + appliedSequence: v.optional(v.number()), + expectedRevision: v.optional(v.number()), + appliedRevision: v.optional(v.number()), + createdAt: v.number(), + }) + .index("by_strategyId", ["strategyId"]) + .index("by_strategyId_clientId_opId", ["strategyId", "clientId", "opId"]), +}); + + diff --git a/convex/strategies.ts b/convex/strategies.ts new file mode 100644 index 00000000..5444e57e --- /dev/null +++ b/convex/strategies.ts @@ -0,0 +1,312 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyRole, 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(); + + const memberships = await ctx.db + .query("strategyCollaborators") + .withIndex("by_userId", (q: any) => q.eq("userId", userId)) + .collect(); + + const fromMembership = await Promise.all( + memberships.map((m: any) => ctx.db.get(m.strategyId)), + ); + + const dedup = new Map(); + for (const strategy of [...owned, ...fromMembership]) { + if (strategy !== null) { + dedup.set(strategy._id, strategy); + } + } + + return Array.from(dedup.values()); +} + +export const listForFolder = query({ + args: { + folderPublicId: v.optional(v.string()), + }, + 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(); + + 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 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"; + + 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 getHeader = query({ + args: { + strategyPublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { role } = await assertStrategyRole(ctx, strategy, "viewer"); + + return { + publicId: strategy.publicId, + name: strategy.name, + mapData: strategy.mapData, + sequence: strategy.sequence, + createdAt: strategy.createdAt, + updatedAt: strategy.updatedAt, + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + role, + }; + }, +}); + +export const create = mutation({ + args: { + publicId: v.string(), + name: v.string(), + mapData: 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); + 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 { ok: true }; + }, +}); + +export const update = mutation({ + args: { + strategyPublicId: v.string(), + name: v.optional(v.string()), + mapData: v.optional(v.string()), + themeProfileId: v.optional(v.string()), + clearThemeProfileId: v.optional(v.boolean()), + themeOverridePalette: v.optional(v.string()), + clearThemeOverridePalette: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const patch: Record = { + updatedAt: Date.now(), + sequence: strategy.sequence + 1, + }; + + if (args.name !== undefined) patch.name = args.name; + if (args.mapData !== undefined) patch.mapData = args.mapData; + + if (args.clearThemeProfileId === true) { + patch.themeProfileId = undefined; + } else if (args.themeProfileId !== undefined) { + patch.themeProfileId = args.themeProfileId; + } + + if (args.clearThemeOverridePalette === true) { + patch.themeOverridePalette = undefined; + } else if (args.themeOverridePalette !== undefined) { + patch.themeOverridePalette = args.themeOverridePalette; + } + + await ctx.db.patch(strategy._id, patch); + return { ok: true }; + }, +}); + +export const move = mutation({ + args: { + strategyPublicId: v.string(), + folderPublicId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + let folderId; + if (args.folderPublicId !== undefined) { + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + if (folder.ownerId !== strategy.ownerId) { + throw new Error("Forbidden"); + } + folderId = folder._id; + } + + await ctx.db.patch(strategy._id, { + folderId, + sequence: strategy.sequence + 1, + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const deleteStrategy = mutation({ + args: { + strategyPublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { role } = await assertStrategyRole(ctx, strategy, "owner"); + + if (role !== "owner") { + throw new Error("Forbidden"); + } + + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + + for (const page of pages) { + const pageElements = await ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const element of pageElements) { + await ctx.db.delete(element._id); + } + + const pageLineups = await ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const lineup of pageLineups) { + 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 ctx.db.delete(page._id); + } + + const collaborators = await ctx.db + .query("strategyCollaborators") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + for (const collaborator of collaborators) { + await ctx.db.delete(collaborator._id); + } + + const invites = await ctx.db + .query("inviteTokens") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + for (const invite of invites) { + await ctx.db.delete(invite._id); + } + + await ctx.db.delete(strategy._id); + return { ok: true }; + }, +}); + +export { deleteStrategy as delete }; + + + diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 00000000..39a07409 --- /dev/null +++ b/convex/users.ts @@ -0,0 +1,65 @@ +import { mutation, query } from "./_generated/server"; +import { + findUserByIdentity, + getCanonicalExternalId, +} from "./lib/auth"; +import { unauthenticatedError } from "./lib/errors"; + +export const ensureCurrentUser = mutation({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + + const externalId = getCanonicalExternalId(identity); + const displayName = identity.name ?? identity.nickname ?? "Discord user"; + const avatarUrl = identity.pictureUrl ?? undefined; + + const existingUser = await findUserByIdentity(ctx, identity); + + if (existingUser !== null) { + await ctx.db.patch(existingUser._id, { + externalId, + displayName, + avatarUrl, + updatedAt: Date.now(), + }); + return existingUser._id; + } + + return await ctx.db.insert("users", { + externalId, + displayName, + avatarUrl, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }, +}); + +export const me = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + return null; + } + + const user = await findUserByIdentity(ctx, identity); + + if (user === null) { + return null; + } + + return { + id: user._id, + externalId: user.externalId, + displayName: user.displayName, + avatarUrl: user.avatarUrl ?? null, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + }, +}); diff --git a/index.ts b/index.ts new file mode 100644 index 00000000..f67b2c64 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart new file mode 100644 index 00000000..1090468e --- /dev/null +++ b/lib/collab/collab_models.dart @@ -0,0 +1,389 @@ +import 'dart:convert'; + +enum StrategyOpKind { add, move, patch, delete, reorder } + +enum StrategyOpEntityType { strategy, page, element, lineup } + +class StrategyOp { + const StrategyOp({ + required this.opId, + required this.kind, + required this.entityType, + this.entityPublicId, + this.pagePublicId, + this.payload, + this.sortIndex, + this.expectedRevision, + this.expectedSequence, + }); + + final String opId; + final StrategyOpKind kind; + final StrategyOpEntityType entityType; + final String? entityPublicId; + final String? pagePublicId; + final String? payload; + final int? sortIndex; + final int? expectedRevision; + final int? expectedSequence; + + Map toConvexJson() { + return { + 'opId': opId, + 'kind': kind.name, + 'entityType': entityType.name, + if (entityPublicId != null) 'entityPublicId': entityPublicId, + if (pagePublicId != null) 'pagePublicId': pagePublicId, + if (payload != null) 'payload': payload, + if (sortIndex != null) 'sortIndex': sortIndex, + if (expectedRevision != null) 'expectedRevision': expectedRevision, + if (expectedSequence != null) 'expectedSequence': expectedSequence, + }; + } + + StrategyOp copyWith({ + int? expectedRevision, + int? expectedSequence, + }) { + return StrategyOp( + opId: opId, + kind: kind, + entityType: entityType, + entityPublicId: entityPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedRevision: expectedRevision ?? this.expectedRevision, + expectedSequence: expectedSequence ?? this.expectedSequence, + ); + } +} + +class PendingOp { + const PendingOp({ + required this.op, + required this.clientId, + this.attempts = 0, + this.lastAttemptAt, + }); + + final StrategyOp op; + final String clientId; + final int attempts; + final DateTime? lastAttemptAt; + + PendingOp incrementAttempt() { + return PendingOp( + op: op, + clientId: clientId, + attempts: attempts + 1, + lastAttemptAt: DateTime.now(), + ); + } +} + +class OpAck { + const OpAck({ + required this.opId, + required this.status, + this.reason, + this.appliedSequence, + this.latestSequence, + this.appliedRevision, + this.latestRevision, + this.latestPayload, + }); + + final String opId; + final String status; + final String? reason; + final int? appliedSequence; + final int? latestSequence; + final int? appliedRevision; + final int? latestRevision; + final String? latestPayload; + + bool get isAck => status == 'ack'; + + factory OpAck.fromJson(Map json) { + return OpAck( + opId: json['opId'] as String, + status: json['status'] as String, + reason: json['reason'] as String?, + appliedSequence: (json['appliedSequence'] as num?)?.toInt(), + latestSequence: (json['latestSequence'] as num?)?.toInt(), + appliedRevision: (json['appliedRevision'] as num?)?.toInt(), + latestRevision: (json['latestRevision'] as num?)?.toInt(), + latestPayload: json['latestPayload'] as String?, + ); + } +} + +enum ConflictResolutionType { rebase, drop, retry } + +class ConflictResolution { + const ConflictResolution({ + required this.type, + required this.opId, + this.message, + this.serverPayload, + this.serverRevision, + this.serverSequence, + }); + + final ConflictResolutionType type; + final String opId; + final String? message; + final Map? serverPayload; + final int? serverRevision; + final int? serverSequence; +} + +class RemoteStrategyHeader { + const RemoteStrategyHeader({ + required this.publicId, + required this.name, + required this.mapData, + required this.sequence, + required this.createdAt, + required this.updatedAt, + this.themeProfileId, + this.themeOverridePalette, + this.role, + }); + + final String publicId; + final String name; + final String mapData; + final int sequence; + final DateTime createdAt; + final DateTime updatedAt; + final String? themeProfileId; + final String? themeOverridePalette; + final String? role; + + factory RemoteStrategyHeader.fromJson(Map json) { + return RemoteStrategyHeader( + publicId: json['publicId'] as String, + name: json['name'] as String, + mapData: json['mapData'] as String, + sequence: (json['sequence'] as num?)?.toInt() ?? 0, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ), + updatedAt: DateTime.fromMillisecondsSinceEpoch( + (json['updatedAt'] as num?)?.toInt() ?? 0, + ), + themeProfileId: json['themeProfileId'] as String?, + themeOverridePalette: json['themeOverridePalette'] as String?, + role: json['role'] as String?, + ); + } +} + +class RemotePage { + const RemotePage({ + required this.publicId, + required this.strategyPublicId, + required this.name, + required this.sortIndex, + required this.isAttack, + required this.revision, + this.settings, + }); + + final String publicId; + final String strategyPublicId; + final String name; + final int sortIndex; + final bool isAttack; + final int revision; + final String? settings; + + factory RemotePage.fromJson(Map json) { + return RemotePage( + publicId: json['publicId'] as String, + strategyPublicId: json['strategyPublicId'] as String, + name: json['name'] as String, + sortIndex: (json['sortIndex'] as num).toInt(), + isAttack: json['isAttack'] as bool? ?? true, + revision: (json['revision'] as num?)?.toInt() ?? 0, + settings: json['settings'] as String?, + ); + } +} + +class RemoteElement { + const RemoteElement({ + required this.publicId, + required this.strategyPublicId, + required this.pagePublicId, + required this.elementType, + required this.payload, + required this.sortIndex, + required this.revision, + required this.deleted, + }); + + final String publicId; + final String strategyPublicId; + final String pagePublicId; + final String elementType; + final String payload; + final int sortIndex; + final int revision; + final bool deleted; + + Map decodedPayload() { + try { + final parsed = jsonDecode(payload); + if (parsed is Map) { + return parsed; + } + } catch (_) {} + return {}; + } + + factory RemoteElement.fromJson(Map json) { + return RemoteElement( + publicId: json['publicId'] as String, + strategyPublicId: json['strategyPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + elementType: json['elementType'] as String, + payload: json['payload'] as String, + sortIndex: (json['sortIndex'] as num?)?.toInt() ?? 0, + revision: (json['revision'] as num?)?.toInt() ?? 0, + deleted: json['deleted'] as bool? ?? false, + ); + } +} + +class RemoteLineup { + const RemoteLineup({ + required this.publicId, + required this.strategyPublicId, + required this.pagePublicId, + required this.payload, + required this.sortIndex, + required this.revision, + required this.deleted, + }); + + final String publicId; + final String strategyPublicId; + final String pagePublicId; + final String payload; + final int sortIndex; + final int revision; + final bool deleted; + + factory RemoteLineup.fromJson(Map json) { + return RemoteLineup( + publicId: json['publicId'] as String, + strategyPublicId: json['strategyPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + payload: json['payload'] as String, + sortIndex: (json['sortIndex'] as num?)?.toInt() ?? 0, + revision: (json['revision'] as num?)?.toInt() ?? 0, + deleted: json['deleted'] as bool? ?? false, + ); + } +} + +class RemoteStrategySnapshot { + const RemoteStrategySnapshot({ + required this.header, + required this.pages, + required this.elementsByPage, + required this.lineupsByPage, + }); + + final RemoteStrategyHeader header; + final List pages; + final Map> elementsByPage; + final Map> lineupsByPage; +} + +class CloudStrategySummary { + const CloudStrategySummary({ + required this.publicId, + required this.name, + required this.mapData, + required this.sequence, + required this.createdAt, + required this.updatedAt, + this.role, + this.attackLabel, + }); + + final String publicId; + final String name; + final String mapData; + final int sequence; + final DateTime createdAt; + final DateTime updatedAt; + final String? role; + final String? attackLabel; + + factory CloudStrategySummary.fromJson(Map json) { + return CloudStrategySummary( + publicId: json['publicId'] as String, + name: json['name'] as String, + mapData: json['mapData'] as String, + sequence: (json['sequence'] as num?)?.toInt() ?? 0, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ), + updatedAt: DateTime.fromMillisecondsSinceEpoch( + (json['updatedAt'] as num?)?.toInt() ?? 0, + ), + role: json['role'] as String?, + attackLabel: json['attackLabel'] as String?, + ); + } +} + +class CloudFolderSummary { + const CloudFolderSummary({ + required this.publicId, + required this.name, + required this.createdAt, + required this.updatedAt, + this.parentFolderPublicId, + this.iconCodePoint, + this.iconFontFamily, + this.iconFontPackage, + this.color, + this.customColorValue, + }); + + final String publicId; + final String name; + final DateTime createdAt; + final DateTime updatedAt; + final String? parentFolderPublicId; + final int? iconCodePoint; + final String? iconFontFamily; + final String? iconFontPackage; + final String? color; + final int? customColorValue; + + factory CloudFolderSummary.fromJson(Map json) { + return CloudFolderSummary( + publicId: json['publicId'] as String, + name: json['name'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ), + updatedAt: DateTime.fromMillisecondsSinceEpoch( + (json['updatedAt'] as num?)?.toInt() ?? 0, + ), + parentFolderPublicId: json['parentFolderPublicId'] as String?, + iconCodePoint: (json['iconCodePoint'] as num?)?.toInt(), + iconFontFamily: json['iconFontFamily'] as String?, + iconFontPackage: json['iconFontPackage'] as String?, + color: json['color'] as String?, + customColorValue: (json['customColorValue'] as num?)?.toInt(), + ); + } +} diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart new file mode 100644 index 00000000..489844f0 --- /dev/null +++ b/lib/collab/convex_strategy_repository.dart @@ -0,0 +1,324 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:convex_flutter/convex_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; + +final convexStrategyRepositoryProvider = Provider( + (ref) => ConvexStrategyRepository(ConvexClient.instance), +); + +class ConvexStrategyRepository { + ConvexStrategyRepository(this._client); + + final ConvexClient _client; + + Object? _decodeJsonPayload(dynamic value) { + if (value is String) { + try { + return jsonDecode(value); + } catch (_) { + return value; + } + } + return value; + } + + Map _decodeObject(dynamic value) { + final decoded = _decodeJsonPayload(value); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + throw FormatException('Expected object payload, received ${decoded.runtimeType}'); + } + + List> _decodeObjectList(dynamic value) { + final decoded = _decodeJsonPayload(value); + if (decoded is! List) { + throw FormatException('Expected list payload, received ${decoded.runtimeType}'); + } + + return decoded + .map((item) => _decodeJsonPayload(item)) + .whereType() + .map((item) => Map.from(item)) + .toList(growable: false); + } + + Future> listFoldersForParent( + String? parentFolderPublicId, + ) async { + final response = await _client.query('folders:listForParent', { + if (parentFolderPublicId != null) + 'parentFolderPublicId': parentFolderPublicId, + }); + return _decodeObjectList(response) + .map(CloudFolderSummary.fromJson) + .toList(growable: false); + } + + Future> listAllFolders() async { + final response = await _client.query('folders:listAll', {}); + return _decodeObjectList(response) + .map(CloudFolderSummary.fromJson) + .toList(growable: false); + } + + Future> listStrategiesForFolder( + String? folderPublicId, + ) async { + final response = await _client.query('strategies:listForFolder', { + if (folderPublicId != null) 'folderPublicId': folderPublicId, + }); + return _decodeObjectList(response) + .map(CloudStrategySummary.fromJson) + .toList(growable: false); + } + + Stream> watchFoldersForParent( + String? parentFolderPublicId, + ) { + final controller = StreamController>.broadcast(); + dynamic subscription; + + Future start() async { + try { + controller.add(await listFoldersForParent(parentFolderPublicId)); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + + subscription = await _client.subscribe( + name: 'folders:listForParent', + args: { + if (parentFolderPublicId != null) + 'parentFolderPublicId': parentFolderPublicId, + }, + onUpdate: (value) { + try { + final mapped = _decodeObjectList(value) + .map(CloudFolderSummary.fromJson) + .toList(growable: false); + controller.add(mapped); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + }, + onError: (message, value) { + controller.addError(Exception('folders:listForParent error: $message')); + }, + ); + } + + start(); + controller.onCancel = () { + try { + subscription?.cancel(); + } catch (_) {} + }; + + return controller.stream; + } + + Stream> watchStrategiesForFolder( + String? folderPublicId, + ) { + final controller = StreamController>.broadcast(); + dynamic subscription; + + Future start() async { + try { + controller.add(await listStrategiesForFolder(folderPublicId)); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + + subscription = await _client.subscribe( + name: 'strategies:listForFolder', + args: { + if (folderPublicId != null) 'folderPublicId': folderPublicId, + }, + 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:listForFolder error: $message')); + }, + ); + } + + start(); + + controller.onCancel = () { + try { + subscription?.cancel(); + } catch (_) {} + }; + + return controller.stream; + } + + Stream watchStrategyHeader(String strategyPublicId) { + final controller = StreamController.broadcast(); + dynamic subscription; + + Future start() async { + subscription = await _client.subscribe( + name: 'strategies:getHeader', + args: {'strategyPublicId': strategyPublicId}, + onUpdate: (value) { + try { + controller.add(RemoteStrategyHeader.fromJson(_decodeObject(value))); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + }, + onError: (message, value) { + controller.addError(Exception('strategies:getHeader error: $message')); + }, + ); + } + + start(); + controller.onCancel = () { + try { + subscription?.cancel(); + } catch (_) {} + }; + + return controller.stream; + } + + Future fetchSnapshot(String strategyPublicId) async { + final headerRaw = await _client.query('strategies:getHeader', { + 'strategyPublicId': strategyPublicId, + }); + final header = RemoteStrategyHeader.fromJson(_decodeObject(headerRaw)); + + final pagesRaw = await _client.query('pages:listForStrategy', { + 'strategyPublicId': strategyPublicId, + }); + + final pages = _decodeObjectList(pagesRaw) + .map(RemotePage.fromJson) + .toList(growable: false) + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + + final elementsByPage = >{}; + final lineupsByPage = >{}; + + 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, + ); + } + + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + if (ops.isEmpty) { + return const []; + } + + final response = await _client.mutation( + name: 'ops:applyBatch', + args: { + 'strategyPublicId': strategyPublicId, + 'clientId': clientId, + 'ops': ops.map((op) => op.toConvexJson()).toList(growable: false), + }, + ); + + final resultList = (_decodeObject(response)['results'] as List?) ?? const []; + return resultList + .whereType() + .map((item) => OpAck.fromJson(Map.from(item))) + .toList(growable: false); + } + + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconCodePoint, + String? iconFontFamily, + String? iconFontPackage, + String? color, + int? customColorValue, + }) async { + await _client.mutation( + name: 'folders:create', + args: { + 'publicId': publicId, + 'name': name, + if (parentFolderPublicId != null) + 'parentFolderPublicId': parentFolderPublicId, + if (iconCodePoint != null) 'iconCodePoint': iconCodePoint, + if (iconFontFamily != null) 'iconFontFamily': iconFontFamily, + if (iconFontPackage != null) 'iconFontPackage': iconFontPackage, + if (color != null) 'color': color, + if (customColorValue != null) 'customColorValue': customColorValue, + }, + ); + } + + Future createStrategy({ + required String publicId, + required String name, + required String mapData, + String? folderPublicId, + String? themeProfileId, + String? themeOverridePalette, + }) async { + await _client.mutation( + name: 'strategies:create', + args: { + 'publicId': publicId, + 'name': name, + 'mapData': mapData, + if (folderPublicId != null) 'folderPublicId': folderPublicId, + if (themeProfileId != null) 'themeProfileId': themeProfileId, + if (themeOverridePalette != null) + 'themeOverridePalette': themeOverridePalette, + }, + ); + } +} diff --git a/lib/const/update_checker.dart b/lib/const/update_checker.dart index 13cd94fc..554fd9e4 100644 --- a/lib/const/update_checker.dart +++ b/lib/const/update_checker.dart @@ -69,7 +69,8 @@ class UpdateChecker { bool? isWindowsOverride, }) async { final bool isWeb = isWebOverride ?? kIsWeb; - final bool isWindows = isWindowsOverride ?? (!isWeb && Platform.isWindows); + final bool isWindows = isWindowsOverride ?? + ((!isWeb && Platform.isWindows) || windowsStoreCheckOverride != null); if (isWindows) { final windowsResult = await _checkWindowsStoreSignal(); @@ -95,7 +96,8 @@ class UpdateChecker { bool? isWindowsOverride, }) async { final bool isWeb = isWebOverride ?? kIsWeb; - final bool isWindows = isWindowsOverride ?? (!isWeb && Platform.isWindows); + final bool isWindows = isWindowsOverride ?? + ((!isWeb && Platform.isWindows) || windowsStoreCheckOverride != null); if (!isWindows) { return _checkRemoteVersionSignal(); diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 17a10f2e..8d0d0c02 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -17,8 +17,8 @@ import 'package:icarus/const/utilities.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; @GenerateAdapters([ AdapterSpec(), diff --git a/lib/main.dart b/lib/main.dart index b3d92e13..5af6387f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,12 +3,16 @@ import 'dart:developer' as developer; import 'dart:io'; import 'dart:ui' show PlatformDispatcher; +import 'package:app_links/app_links.dart'; +import 'package:convex_flutter/convex_flutter.dart'; import 'package:custom_mouse_cursor/custom_mouse_cursor.dart'; import 'package:flutter/foundation.dart' show kIsWeb; 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/services/deep_link_registrar.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:windows_single_instance/windows_single_instance.dart'; import 'package:icarus/const/custom_icons.dart'; @@ -19,10 +23,14 @@ import 'package:icarus/const/routes.dart'; 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/folder_provider.dart'; +import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/global_shortcuts.dart'; @@ -36,14 +44,64 @@ import 'package:window_manager/window_manager.dart'; late CustomMouseCursor staticDrawingCursor; WebViewEnvironment? webViewEnvironment; bool isWebViewInitialized = false; +bool isWebViewWarmupComplete = false; +Future? _webViewEnvironmentWarmupFuture; +final AppLinks _appLinks = AppLinks(); +final StreamController _deepLinkUriController = + StreamController.broadcast(); +StreamSubscription? _deepLinkStreamSub; +final List _bufferedDeepLinks = []; +bool _hasDeepLinkListener = false; + +Future _initializeDeepLinkHandling() async { + try { + final initialLink = await _appLinks.getInitialLink(); + if (initialLink != null) { + _publishDeepLink(initialLink, source: 'initial'); + } + } catch (error, stackTrace) { + developer.log( + 'Failed to read initial deep link: $error', + name: 'deep_link', + error: error, + stackTrace: stackTrace, + ); + } + + _deepLinkStreamSub ??= _appLinks.uriLinkStream.listen( + (uri) => _publishDeepLink(uri, source: 'stream'), + onError: (Object error, StackTrace stackTrace) { + developer.log( + 'Deep link stream error: $error', + name: 'deep_link', + error: error, + stackTrace: stackTrace, + ); + }, + ); +} + +void _publishDeepLink(Uri uri, {required String source}) { + developer.log('Deep link received [$source]: $uri', name: 'deep_link'); + if (!_hasDeepLinkListener) { + _bufferedDeepLinks.add(uri); + return; + } + _deepLinkUriController.add(uri); +} + Future main(List args) async { await runZonedGuarded( () async { WidgetsFlutterBinding.ensureInitialized(); + appProviderContainer = ProviderContainer(); await _initializePersistedDebugLog(); _installGlobalErrorHandlers(); + await registerDeepLinkProtocol('icarus'); + await _initializeDeepLinkHandling(); + if (!kIsWeb && Platform.isWindows) { await WindowsSingleInstance.ensureSingleInstance( args, @@ -82,11 +140,24 @@ Future main(List args) async { await MapThemeProfilesProvider.bootstrap(); - await StrategyProvider.migrateAllStrategies(); + await StrategyMigrator.migrateAllStrategies(); - // await Hive.box(HiveBoxNames.strategiesBox).clear(); + await ConvexClient.initialize( + const ConvexConfig( + deploymentUrl: 'https://majestic-eel-413.convex.cloud', + clientId: 'dev:majestic-eel-413', + operationTimeout: Duration(seconds: 30), + healthCheckQuery: 'health:ping', + ), + ); + + await Supabase.initialize( + url: 'https://gjdirtrtgnawqoruavqn.supabase.co', + anonKey: 'sb_publishable_6M0VCSZCvRFrcgNANWPVWw_U06T_rUo', + authOptions: const FlutterAuthClientOptions(detectSessionInUri: false), + ); - await _initWebViewEnvironment(); + // await Hive.box(HiveBoxNames.strategiesBox).clear(); if (!kIsWeb) { await windowManager.ensureInitialized(); @@ -99,14 +170,6 @@ Future main(List args) async { await windowManager.focus(); }); } - - // Ensure WebView2 environment is initialized on Windows before any InAppWebView - // widgets are created. This is especially important in testing/dev where the - // WebView user-data folder and runtime selection can affect behavior. - // if (!kIsWeb && Platform.isWindows) { - // await _initWebViewEnvironment(); - // } - runApp( UncontrolledProviderScope( container: appProviderContainer, @@ -125,6 +188,33 @@ Future main(List args) async { ); } +Future warmUpWebViewEnvironment() { + if (kIsWeb || !Platform.isWindows) { + isWebViewWarmupComplete = true; + return Future.value(); + } + + return _webViewEnvironmentWarmupFuture ??= + _warmUpWebViewEnvironmentInternal(); +} + +Future _warmUpWebViewEnvironmentInternal() async { + try { + await _initWebViewEnvironment(); + } catch (error, stackTrace) { + webViewEnvironment = null; + isWebViewInitialized = false; + AppErrorReporter.reportWarning( + 'WebView failed to initialize. Youtube embeds will be unavailable.', + source: 'main.warmUpWebViewEnvironment', + error: error, + stackTrace: stackTrace, + ); + } finally { + isWebViewWarmupComplete = true; + } +} + void _installGlobalErrorHandlers() { final originalFlutterOnError = FlutterError.onError; @@ -181,21 +271,25 @@ Future _initializePersistedDebugLog() async { Future _initWebViewEnvironment() async { if (kIsWeb) return; if (Platform.isWindows) { + if (isWebViewInitialized && webViewEnvironment != null) { + return; + } + final dir = await getApplicationSupportDirectory(); final availableVersion = await WebViewEnvironment.getAvailableVersion(); if (availableVersion == null) { + webViewEnvironment = null; isWebViewInitialized = false; return; } - isWebViewInitialized = true; - webViewEnvironment = await WebViewEnvironment.create( settings: WebViewEnvironmentSettings( userDataFolder: path.join(dir.path, 'webview'), ), ); + isWebViewInitialized = true; } } @@ -209,10 +303,12 @@ class MyApp extends ConsumerStatefulWidget { class _MyAppState extends ConsumerState { StreamSubscription>? _secondInstanceSub; + StreamSubscription? _deepLinkSub; + final Set _processedDeepLinks = {}; Future _loadFromFilePathWithWarning(String filePath) async { try { - await ref.read(strategyProvider.notifier).loadFromFilePath(filePath); + await StrategyImportExportService(ref).loadFromFilePath(filePath); } on NewerVersionImportException catch (error, stackTrace) { AppErrorReporter.reportError( NewerVersionImportException.userMessage, @@ -223,11 +319,49 @@ class _MyAppState extends ConsumerState { } } + Future _handleIncomingArgument( + String argument, { + required String source, + }) async { + final uri = Uri.tryParse(argument); + if (uri != null && uri.scheme.toLowerCase() == 'icarus') { + _handleIncomingUri(uri, source: source); + return; + } + + await _loadFromFilePathWithWarning(argument); + } + + void _handleIncomingUri(Uri uri, {required String source}) { + final uriText = uri.toString(); + if (!_processedDeepLinks.add(uriText)) { + developer.log( + 'Ignoring duplicate deep link [$source]: $uriText', + name: 'deep_link', + ); + return; + } + + developer.log('Handling deep link [$source]: $uriText', name: 'deep_link'); + ref + .read(inAppDebugProvider.notifier) + .bulkAddLogs(['Deep link [$source]: $uriText']); + + unawaited( + ref + .read(authProvider.notifier) + .handleAuthCallbackUri(uri, source: source), + ); + } + @override void initState() { super.initState(); + ref.read(authProvider); WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(warmUpWebViewEnvironment()); + if (widget.data.isEmpty) return; for (final argument in widget.data) { @@ -235,26 +369,40 @@ class _MyAppState extends ConsumerState { 'Startup argument: $argument', source: 'main.startupArgs', ); + unawaited(_handleIncomingArgument(argument, source: 'startup_args')); } - _loadFromFilePathWithWarning(widget.data.first); }); _secondInstanceSub = secondInstanceArgsController.stream.listen((args) { if (args.isEmpty) return; - _loadFromFilePathWithWarning(args.first); for (final argument in args) { AppErrorReporter.reportInfo( 'Second-instance argument: $argument', source: 'main.secondInstanceArgs', ); + unawaited(_handleIncomingArgument(argument, source: 'second_instance')); } }); + + _deepLinkSub = _deepLinkUriController.stream.listen( + (uri) => _handleIncomingUri(uri, source: 'app_links'), + ); + _hasDeepLinkListener = true; + if (_bufferedDeepLinks.isNotEmpty) { + final pendingUris = List.from(_bufferedDeepLinks); + _bufferedDeepLinks.clear(); + for (final uri in pendingUris) { + _deepLinkUriController.add(uri); + } + } } @override void dispose() { _secondInstanceSub?.cancel(); + _deepLinkSub?.cancel(); + _hasDeepLinkListener = false; super.dispose(); } diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 00000000..2ef02739 --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -0,0 +1,1011 @@ +import 'dart:async'; +import 'dart:developer'; + +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:supabase_flutter/supabase_flutter.dart'; + +final authProvider = + NotifierProvider(AuthProvider.new); + +enum ConvexAuthStatus { + signedOut, + configuring, + ready, + incident, +} + +final RegExp _convexCodeRegex = RegExp(r'"code"\s*:\s*"([A-Z_]+)"'); + +String? _extractConvexErrorCodeFromText(String text) { + final match = _convexCodeRegex.firstMatch(text); + final code = match?.group(1); + if (code == null || code.isEmpty) { + return null; + } + return code; +} + +bool isConvexUnauthenticatedMessage(String message) { + final normalized = message.toUpperCase(); + final code = _extractConvexErrorCodeFromText(normalized); + if (code == 'UNAUTHENTICATED') { + return true; + } + + return normalized.contains('UNAUTHENTICATED'); +} + +bool isConvexUnauthenticatedError(Object error) { + if (error is Map) { + final code = error['code']?.toString().toUpperCase(); + if (code == 'UNAUTHENTICATED') { + return true; + } + } + + return isConvexUnauthenticatedMessage(error.toString()); +} + +class AppAuthState { + const AppAuthState({ + required this.isLoading, + required this.isAuthenticated, + required this.isConvexUserReady, + required this.convexAuthStatus, + required this.user, + this.errorMessage, + this.activeAuthIncidentId, + this.lastAuthIncidentSource, + this.isAuthIncidentPromptOpen = false, + }); + + factory AppAuthState.fromSession( + Session? session, { + bool isLoading = false, + bool isConvexUserReady = false, + ConvexAuthStatus? convexAuthStatus, + String? errorMessage, + int? activeAuthIncidentId, + String? lastAuthIncidentSource, + bool isAuthIncidentPromptOpen = false, + }) { + final status = convexAuthStatus ?? + (session == null + ? ConvexAuthStatus.signedOut + : (isConvexUserReady + ? ConvexAuthStatus.ready + : ConvexAuthStatus.configuring)); + + return AppAuthState( + isLoading: isLoading, + isAuthenticated: session != null, + isConvexUserReady: session != null && isConvexUserReady, + convexAuthStatus: status, + user: session?.user, + errorMessage: errorMessage, + activeAuthIncidentId: activeAuthIncidentId, + lastAuthIncidentSource: lastAuthIncidentSource, + isAuthIncidentPromptOpen: isAuthIncidentPromptOpen, + ); + } + + final bool isLoading; + final bool isAuthenticated; + final bool isConvexUserReady; + final ConvexAuthStatus convexAuthStatus; + final User? user; + final String? errorMessage; + final int? activeAuthIncidentId; + final String? lastAuthIncidentSource; + final bool isAuthIncidentPromptOpen; + + bool get hasActiveAuthIncident => activeAuthIncidentId != null; + + String get displayName { + final metadata = user?.userMetadata ?? const {}; + final String? name = metadata['full_name'] as String? ?? + metadata['name'] as String? ?? + metadata['user_name'] as String? ?? + user?.email; + return (name?.isNotEmpty ?? false) ? name! : 'Discord user'; + } + + String? get avatarUrl { + final metadata = user?.userMetadata ?? const {}; + return metadata['avatar_url'] as String?; + } + + AppAuthState copyWith({ + bool? isLoading, + bool? isAuthenticated, + bool? isConvexUserReady, + ConvexAuthStatus? convexAuthStatus, + User? user, + String? errorMessage, + bool clearError = false, + int? activeAuthIncidentId, + bool clearAuthIncident = false, + String? lastAuthIncidentSource, + bool clearLastAuthIncidentSource = false, + bool? isAuthIncidentPromptOpen, + }) { + return AppAuthState( + isLoading: isLoading ?? this.isLoading, + isAuthenticated: isAuthenticated ?? this.isAuthenticated, + isConvexUserReady: isConvexUserReady ?? this.isConvexUserReady, + convexAuthStatus: convexAuthStatus ?? this.convexAuthStatus, + user: user ?? this.user, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + activeAuthIncidentId: clearAuthIncident + ? null + : (activeAuthIncidentId ?? this.activeAuthIncidentId), + lastAuthIncidentSource: clearLastAuthIncidentSource + ? null + : (lastAuthIncidentSource ?? this.lastAuthIncidentSource), + isAuthIncidentPromptOpen: + isAuthIncidentPromptOpen ?? this.isAuthIncidentPromptOpen, + ); + } +} + +enum _AuthIncidentAction { + retry, + signOut, + dismiss, +} + +abstract class AuthProviderAuthHandle { + void dispose(); +} + +abstract class AuthProviderConvexApi { + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }); + + Stream get authState; + bool get isAuthenticated; + String? get currentConnectionStateLabel; + Future clearAuth(); + Future reconnect(); + Future mutation({ + required String name, + required Map args, + }); +} + +abstract class AuthProviderSupabaseApi { + Session? get currentSession; + Stream get onAuthStateChange; + + Future signInWithOAuth( + OAuthProvider provider, { + required String redirectTo, + required LaunchMode authScreenLaunchMode, + required String scopes, + }); + + Future signInWithPassword({ + required String email, + required String password, + }); + + Future signUp({ + required String email, + required String password, + }); + + Future signOut(); + Future getSessionFromUrl(Uri uri); + Future refreshSession(); +} + +class _DefaultAuthProviderAuthHandle implements AuthProviderAuthHandle { + _DefaultAuthProviderAuthHandle(this._inner); + + final AuthHandleWrapper _inner; + + @override + void dispose() => _inner.dispose(); +} + +class _DefaultAuthProviderConvexApi implements AuthProviderConvexApi { + const _DefaultAuthProviderConvexApi(); + + ConvexClient get _client => ConvexClient.instance; + + @override + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool p1)? onAuthChange, + }) async { + final handle = await _client.setAuthWithRefresh( + fetchToken: fetchToken, + onAuthChange: onAuthChange, + ); + return _DefaultAuthProviderAuthHandle(handle); + } + + @override + Stream get authState => _client.authState; + + @override + bool get isAuthenticated => _client.isAuthenticated; + + @override + String? get currentConnectionStateLabel => + _client.currentConnectionState.name; + + @override + Future clearAuth() => _client.clearAuth(); + + @override + Future reconnect() => _client.reconnect(); + + @override + Future mutation({ + required String name, + required Map args, + }) => + _client.mutation(name: name, args: args); +} + +class _DefaultAuthProviderSupabaseApi implements AuthProviderSupabaseApi { + const _DefaultAuthProviderSupabaseApi(); + + SupabaseClient get _client => Supabase.instance.client; + + @override + Session? get currentSession => _client.auth.currentSession; + + @override + Stream get onAuthStateChange => _client.auth.onAuthStateChange; + + @override + Future signInWithOAuth( + OAuthProvider provider, { + required String redirectTo, + required LaunchMode authScreenLaunchMode, + required String scopes, + }) { + return _client.auth.signInWithOAuth( + provider, + redirectTo: redirectTo, + authScreenLaunchMode: authScreenLaunchMode, + scopes: scopes, + ); + } + + @override + Future signInWithPassword({ + required String email, + required String password, + }) { + return _client.auth.signInWithPassword(email: email, password: password); + } + + @override + Future signUp({ + required String email, + required String password, + }) { + return _client.auth.signUp(email: email, password: password); + } + + @override + Future signOut() => _client.auth.signOut(); + + @override + Future getSessionFromUrl(Uri uri) => + _client.auth.getSessionFromUrl(uri); + + @override + Future refreshSession() => _client.auth.refreshSession(); +} + +class AuthProvider extends Notifier { + static final Uri _discordRedirectUri = Uri( + scheme: 'icarus', + host: 'auth', + path: '/callback', + ); + + StreamSubscription? _supabaseAuthSub; + AuthProviderAuthHandle? _convexAuthHandle; + Future? _inFlightConvexSetup; + bool _queuedConvexSetup = false; + bool _showingIncidentPrompt = false; + int _incidentCounter = 0; + + @visibleForTesting + static AuthProviderSupabaseApi? debugSupabaseApi; + + @visibleForTesting + static AuthProviderConvexApi? debugConvexApi; + + @visibleForTesting + static Duration? debugConvexAuthReadyTimeout; + + @visibleForTesting + static void resetTestOverrides() { + debugSupabaseApi = null; + debugConvexApi = null; + debugConvexAuthReadyTimeout = null; + } + + AuthProviderSupabaseApi get _supabaseApi => + debugSupabaseApi ?? const _DefaultAuthProviderSupabaseApi(); + + AuthProviderConvexApi get _convexApi => + debugConvexApi ?? const _DefaultAuthProviderConvexApi(); + + @override + AppAuthState build() { + final session = _supabaseApi.currentSession; + + _supabaseAuthSub ??= _supabaseApi.onAuthStateChange.listen( + _handleSupabaseAuthStateChange, + onError: _handleSupabaseAuthStreamError, + ); + + ref.onDispose(() { + _supabaseAuthSub?.cancel(); + _convexAuthHandle?.dispose(); + }); + + unawaited(_configureConvexAuth(trigger: 'build')); + + return AppAuthState.fromSession( + session, + isConvexUserReady: false, + convexAuthStatus: session == null + ? ConvexAuthStatus.signedOut + : ConvexAuthStatus.configuring, + ); + } + + void _handleSupabaseAuthStateChange(AuthState event) { + final currentSession = event.session; + state = AppAuthState.fromSession( + currentSession, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: currentSession == null + ? ConvexAuthStatus.signedOut + : ConvexAuthStatus.configuring, + ); + + if (currentSession == null) { + _clearAuthIncident(); + } + + unawaited(_configureConvexAuth(trigger: 'supabase:${event.event}')); + } + + void _handleSupabaseAuthStreamError(Object error, StackTrace stackTrace) { + log( + 'Supabase auth state stream error: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: 'Auth stream error: $error', + ); + } + + bool isAuthCallbackUri(Uri uri) { + final isIcarusScheme = uri.scheme.toLowerCase() == 'icarus'; + final isAuthCallback = uri.host.toLowerCase() == 'auth' && + uri.path.toLowerCase() == '/callback'; + if (!isIcarusScheme || !isAuthCallback) { + return false; + } + + final hasAuthPayload = uri.fragment.contains('access_token') || + uri.queryParameters.containsKey('code') || + uri.fragment.contains('error_description') || + uri.queryParameters.containsKey('error_description'); + return hasAuthPayload; + } + + Future signInWithDiscord() async { + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + final launched = await _supabaseApi.signInWithOAuth( + OAuthProvider.discord, + redirectTo: _discordRedirectUri.toString(), + authScreenLaunchMode: LaunchMode.externalApplication, + scopes: 'identify email', + ); + + if (!launched) { + throw StateError('Discord OAuth browser launch failed'); + } + } catch (error, stackTrace) { + log( + 'Discord sign-in failed: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: 'Discord sign-in failed: $error', + ); + return; + } + + state = state.copyWith(isLoading: false); + } + + Future signInWithEmailPassword({ + required String email, + required String password, + }) async { + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + final response = await _supabaseApi.signInWithPassword( + email: email, + password: password, + ); + + if (response.session == null) { + const message = 'Sign in did not return a session.'; + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: message, + ); + return message; + } + + await _configureConvexAuth(trigger: 'email_password_sign_in'); + state = state.copyWith(isLoading: false); + return null; + } catch (error, stackTrace) { + log( + 'Email/password sign-in failed: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + final message = 'Email/password sign-in failed: $error'; + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: message, + ); + return message; + } + } + + Future signUpWithEmailPassword({ + required String email, + required String password, + }) async { + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + final response = await _supabaseApi.signUp( + email: email, + password: password, + ); + + if (response.session == null) { + const message = + 'Account created, but email confirmation is required before sign in.'; + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + errorMessage: message, + ); + return message; + } + + await _configureConvexAuth(trigger: 'email_password_sign_up'); + state = state.copyWith(isLoading: false); + return null; + } catch (error, stackTrace) { + log( + 'Email/password sign-up failed: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + final message = 'Email/password sign-up failed: $error'; + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: message, + ); + return message; + } + } + + Future signOut() async { + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + clearError: true, + clearAuthIncident: true, + clearLastAuthIncidentSource: true, + isAuthIncidentPromptOpen: false, + ); + + try { + await _supabaseApi.signOut(); + _convexAuthHandle?.dispose(); + _convexAuthHandle = null; + await _convexApi.clearAuth(); + state = AppAuthState.fromSession( + null, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + ); + } catch (error, stackTrace) { + log( + 'Sign out failed: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: 'Sign out failed: $error', + ); + } + } + + Future handleAuthCallbackUri(Uri uri, {required String source}) async { + if (!isAuthCallbackUri(uri)) { + return false; + } + + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + await _supabaseApi.getSessionFromUrl(uri); + state = state.copyWith(isLoading: false); + log('Handled auth callback [$source]: $uri', name: 'auth'); + return true; + } catch (error, stackTrace) { + log( + 'Failed auth callback [$source]: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: 'Failed to complete login: $error', + ); + return true; + } + } + + Future _fetchSupabaseAccessToken() async { + try { + final session = _supabaseApi.currentSession; + if (session == null) { + log( + 'Convex token fetch skipped: no active Supabase session.', + name: 'auth', + ); + return null; + } + + final expiresAt = session.expiresAt; + if (expiresAt != null) { + final expiresAtUtc = DateTime.fromMillisecondsSinceEpoch( + expiresAt * 1000, + isUtc: true, + ); + final shouldRefresh = expiresAtUtc + .isBefore(DateTime.now().toUtc().add(const Duration(minutes: 1))); + + if (shouldRefresh) { + try { + final refreshed = await _supabaseApi.refreshSession(); + final refreshedToken = refreshed.session?.accessToken; + if (refreshedToken != null && refreshedToken.isNotEmpty) { + log( + 'Convex token fetch returning refreshed Supabase token.', + name: 'auth', + ); + return refreshedToken; + } + } catch (error, stackTrace) { + log( + 'Supabase refresh failed while fetching Convex token: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + } + } + } + + log( + 'Convex token fetch returning current Supabase token ' + '(nonEmpty: ${session.accessToken.isNotEmpty}).', + name: 'auth', + ); + return session.accessToken; + } catch (error, stackTrace) { + log( + 'Failed fetching Supabase access token for Convex: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + return null; + } + } + + Future reinitializeConvexAuth({String source = 'manual'}) async { + await _configureConvexAuth(trigger: 'reinitialize:$source'); + } + + Future reportConvexUnauthenticated({ + required String source, + Object? error, + StackTrace? stackTrace, + }) async { + if (error != null && !isConvexUnauthenticatedError(error)) { + return; + } + + if (_supabaseApi.currentSession == null) { + return; + } + + if (state.activeAuthIncidentId != null) { + return; + } + + final incidentId = ++_incidentCounter; + state = state.copyWith( + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + activeAuthIncidentId: incidentId, + lastAuthIncidentSource: source, + errorMessage: + 'Cloud authentication expired. Retry Convex auth or sign out.', + ); + + log( + 'Convex unauthenticated incident #$incidentId from $source: ${error ?? 'no error payload'}', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + + unawaited(_showAuthIncidentPrompt(incidentId)); + } + + Future _configureConvexAuth({required String trigger}) async { + await Future.value(); + + if (_inFlightConvexSetup != null) { + _queuedConvexSetup = true; + await _inFlightConvexSetup; + return; + } + + final completer = Completer(); + _inFlightConvexSetup = completer.future; + + try { + await _runConvexAuthSetup(trigger: trigger); + } finally { + completer.complete(); + _inFlightConvexSetup = null; + + if (_queuedConvexSetup) { + _queuedConvexSetup = false; + unawaited(_configureConvexAuth(trigger: 'queued')); + } + } + } + + Duration get _convexAuthReadyTimeout => + debugConvexAuthReadyTimeout ?? const Duration(seconds: 5); + + Future _waitForConvexAuthenticated({ + required String trigger, + required bool? reconnectResult, + }) async { + if (_convexApi.isAuthenticated) { + return 'immediate'; + } + + final authenticated = await _convexApi.authState + .firstWhere( + (isAuthenticated) => isAuthenticated, + ) + .timeout( + _convexAuthReadyTimeout, + onTimeout: () { + final connectionState = _convexApi.currentConnectionStateLabel; + throw TimeoutException( + 'Convex auth did not become ready within ' + '${_convexAuthReadyTimeout.inSeconds} seconds ' + 'for trigger "$trigger" ' + '(reconnectResult: ${reconnectResult?.toString() ?? 'unknown'}, ' + 'isAuthenticated: ${_convexApi.isAuthenticated}, ' + 'connectionState: ${connectionState ?? 'unavailable'}).', + ); + }, + ); + + if (!authenticated) { + throw StateError('Convex auth stream completed without authentication.'); + } + + return 'stream'; + } + + Future _runConvexAuthSetup({required String trigger}) async { + final session = _supabaseApi.currentSession; + log( + 'Starting Convex auth setup [$trigger] (hasSession: ${session != null})', + name: 'auth', + ); + if (session == null) { + _convexAuthHandle?.dispose(); + _convexAuthHandle = null; + await _convexApi.clearAuth(); + _clearAuthIncident(); + state = AppAuthState.fromSession( + null, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + ); + return; + } + + state = state.copyWith( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + _convexAuthHandle?.dispose(); + final wasAuthenticatedBeforeSetup = _convexApi.isAuthenticated; + bool? reconnectResult; + _convexAuthHandle = await _convexApi.setAuthWithRefresh( + fetchToken: _fetchSupabaseAccessToken, + onAuthChange: (isAuthenticated) { + if (isAuthenticated) { + return; + } + if (_supabaseApi.currentSession == null) { + return; + } + if (state.convexAuthStatus == ConvexAuthStatus.configuring) { + return; + } + + unawaited( + reportConvexUnauthenticated( + source: 'convex:onAuthChange', + error: Exception('Convex auth state changed to unauthenticated'), + ), + ); + }, + ); + + log( + 'Convex auth handle configured [$trigger] (wasAuthenticatedBeforeSetup: ' + '$wasAuthenticatedBeforeSetup, isAuthenticatedNow: ${_convexApi.isAuthenticated})', + name: 'auth', + ); + try { + reconnectResult = await _convexApi.reconnect(); + log( + 'Convex reconnect attempted [$trigger] (result: $reconnectResult, ' + 'isAuthenticatedAfterReconnect: ${_convexApi.isAuthenticated}, ' + 'connectionState: ' + '${_convexApi.currentConnectionStateLabel ?? 'unavailable'})', + name: 'auth', + ); + } catch (error, stackTrace) { + log( + 'Convex reconnect threw [$trigger]: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + } + final readinessSource = await _waitForConvexAuthenticated( + trigger: trigger, + reconnectResult: reconnectResult, + ); + log( + 'Convex auth ready [$trigger] via $readinessSource', + name: 'auth', + ); + await _convexApi.mutation(name: 'users:ensureCurrentUser', args: {}); + log( + 'Convex current user ensured [$trigger]', + name: 'auth', + ); + + _clearAuthIncident(); + state = state.copyWith( + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + clearError: true, + ); + } catch (error, stackTrace) { + log( + 'Failed configuring Convex auth [$trigger]: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + + if (isConvexUnauthenticatedError(error)) { + await reportConvexUnauthenticated( + source: 'setup:$trigger', + error: error, + stackTrace: stackTrace, + ); + return; + } + + if (error is TimeoutException) { + log( + 'Convex auth readiness timed out [$trigger]: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + } else { + log( + 'Convex auth setup failed after readiness or mutation [$trigger]: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + } + + state = state.copyWith( + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: 'Failed to configure Convex auth: $error', + ); + } + } + + void _clearAuthIncident() { + state = state.copyWith( + clearAuthIncident: true, + clearLastAuthIncidentSource: true, + isAuthIncidentPromptOpen: false, + ); + } + + Future _showAuthIncidentPrompt(int incidentId) async { + if (_showingIncidentPrompt) { + return; + } + + final navCtx = appNavigatorKey.currentContext ?? + appNavigatorKey.currentState?.overlay?.context; + if (navCtx == null) { + log( + 'Unable to show Convex auth incident prompt; navigator context unavailable.', + name: 'auth', + ); + return; + } + + _showingIncidentPrompt = true; + state = state.copyWith(isAuthIncidentPromptOpen: true); + + try { + final action = await showDialog<_AuthIncidentAction>( + context: navCtx, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + title: const Text('Cloud Session Needs Attention'), + content: const Text( + 'Convex reported your session as unauthenticated while Supabase is signed in. ' + 'Retry Convex auth, sign out, or dismiss to keep cloud features paused.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(_AuthIncidentAction.dismiss); + }, + child: const Text('Dismiss'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(_AuthIncidentAction.signOut); + }, + child: const Text('Sign Out'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(_AuthIncidentAction.retry); + }, + child: const Text('Retry Convex Auth'), + ), + ], + ); + }, + ); + + if (state.activeAuthIncidentId != incidentId) { + return; + } + + switch (action) { + case _AuthIncidentAction.retry: + await reinitializeConvexAuth(source: 'incident_prompt_retry'); + break; + case _AuthIncidentAction.signOut: + await signOut(); + break; + case _AuthIncidentAction.dismiss: + case null: + break; + } + } finally { + _showingIncidentPrompt = false; + if (state.activeAuthIncidentId == incidentId) { + state = state.copyWith(isAuthIncidentPromptOpen: false); + } + } + } +} diff --git a/lib/providers/collab/cloud_collab_provider.dart b/lib/providers/collab/cloud_collab_provider.dart new file mode 100644 index 00000000..16de25f2 --- /dev/null +++ b/lib/providers/collab/cloud_collab_provider.dart @@ -0,0 +1,70 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; + +class CloudCollabModeState { + const CloudCollabModeState({ + required this.featureFlagEnabled, + required this.forceLocalFallback, + }); + + final bool featureFlagEnabled; + final bool forceLocalFallback; + + bool isCloudEnabled({ + required bool isAuthenticated, + required bool isConvexUserReady, + }) { + return featureFlagEnabled && + isAuthenticated && + isConvexUserReady && + !forceLocalFallback; + } + + CloudCollabModeState copyWith({ + bool? featureFlagEnabled, + bool? forceLocalFallback, + }) { + return CloudCollabModeState( + featureFlagEnabled: featureFlagEnabled ?? this.featureFlagEnabled, + forceLocalFallback: forceLocalFallback ?? this.forceLocalFallback, + ); + } +} + +final cloudCollabModeProvider = + NotifierProvider( + CloudCollabModeNotifier.new, +); + +final isCloudCollabEnabledProvider = Provider((ref) { + AppAuthState auth; + try { + auth = ref.watch(authProvider); + } catch (_) { + return false; + } + final mode = ref.watch(cloudCollabModeProvider); + return mode.isCloudEnabled( + isAuthenticated: auth.isAuthenticated, + isConvexUserReady: auth.isConvexUserReady, + ); +}); + +class CloudCollabModeNotifier extends Notifier { + @override + CloudCollabModeState build() { + // Feature-flagged dual mode; default enabled for authenticated users. + return const CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: false, + ); + } + + void setFeatureFlagEnabled(bool enabled) { + state = state.copyWith(featureFlagEnabled: enabled); + } + + void setForceLocalFallback(bool enabled) { + state = state.copyWith(forceLocalFallback: enabled); + } +} diff --git a/lib/providers/collab/cloud_migration_provider.dart b/lib/providers/collab/cloud_migration_provider.dart new file mode 100644 index 00000000..7ac69b47 --- /dev/null +++ b/lib/providers/collab/cloud_migration_provider.dart @@ -0,0 +1,139 @@ +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:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_cloud_migration.dart'; +import 'package:uuid/uuid.dart'; + +final cloudMigrationProvider = + NotifierProvider(CloudMigrationNotifier.new); + +class CloudMigrationNotifier extends Notifier { + @override + bool build() => false; + + Future maybeMigrate() async { + if (state) return; + if (!ref.read(isCloudCollabEnabledProvider)) return; + + final repo = ref.read(convexStrategyRepositoryProvider); + final folders = Hive.box(HiveBoxNames.foldersBox).values.toList(); + final strategies = + Hive.box(HiveBoxNames.strategiesBox).values.toList(); + + for (final folder in folders) { + try { + await repo.createFolder( + publicId: folder.id, + name: folder.name, + parentFolderPublicId: folder.parentID, + ); + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'cloud_migration:create_folder', + error: error, + stackTrace: stackTrace, + ); + } + } + + for (final strategy in strategies) { + try { + await repo.createStrategy( + publicId: strategy.id, + name: strategy.name, + mapData: Maps.mapNames[strategy.mapData] ?? 'ascent', + folderPublicId: strategy.folderID, + themeProfileId: strategy.themeProfileId, + themeOverridePalette: strategy.themeOverridePalette == null + ? null + : jsonEncode(strategy.themeOverridePalette!.toJson()), + ); + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'cloud_migration:create_strategy', + error: error, + stackTrace: stackTrace, + ); + } + + final pages = [...strategy.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + + final allOps = []; + final usedElementIds = {}; + final usedLineupIds = {}; + for (final page in pages) { + try { + await ConvexClient.instance.mutation(name: 'pages:add', args: { + 'strategyPublicId': strategy.id, + 'pagePublicId': page.id, + 'name': page.name, + 'sortIndex': page.sortIndex, + 'isAttack': page.isAttack, + 'settings': StrategySettingsProvider.objectToJson(page.settings), + }); + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'cloud_migration:add_page', + error: error, + stackTrace: stackTrace, + ); + } + + appendMigratedPageOps( + allOps, + page, + usedElementIds: usedElementIds, + usedLineupIds: usedLineupIds, + ); + } + + if (allOps.isNotEmpty) { + try { + await repo.applyBatch( + strategyPublicId: strategy.id, + clientId: const Uuid().v4(), + ops: allOps, + ); + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'cloud_migration:apply_batch', + error: error, + stackTrace: stackTrace, + ); + log('Cloud migration ops failed for ${strategy.id}: $error'); + } + } + } + + state = true; + } + Future _maybeReportCloudUnauthenticated({ + required String source, + required Object error, + required StackTrace stackTrace, + }) async { + if (!isConvexUnauthenticatedError(error)) { + return; + } + + await ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ); + } +} diff --git a/lib/providers/collab/remote_library_provider.dart b/lib/providers/collab/remote_library_provider.dart new file mode 100644 index 00000000..afc81ddf --- /dev/null +++ b/lib/providers/collab/remote_library_provider.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +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/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +final cloudFoldersProvider = + FutureProvider.autoDispose>((ref) async { + final isCloud = ref.watch(isCloudCollabEnabledProvider); + final auth = ref.watch(authProvider); + if (!isCloud || auth.hasActiveAuthIncident) { + return const []; + } + + final parentFolderId = ref.watch(folderProvider); + final repo = ref.watch(convexStrategyRepositoryProvider); + try { + return await repo.listFoldersForParent(parentFolderId); + } catch (error, stackTrace) { + if (_isInvalidFolderError(error)) { + ref + .read(folderProvider.notifier) + .updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + return const []; + } + if (isConvexUnauthenticatedError(error)) { + unawaited( + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'remote_library:folders', + error: error, + stackTrace: stackTrace, + ), + ); + return const []; + } + rethrow; + } +}); + +final cloudStrategiesProvider = + FutureProvider.autoDispose>((ref) async { + final isCloud = ref.watch(isCloudCollabEnabledProvider); + final auth = ref.watch(authProvider); + if (!isCloud || auth.hasActiveAuthIncident) { + return const []; + } + + final folderId = ref.watch(folderProvider); + final repo = ref.watch(convexStrategyRepositoryProvider); + try { + return await repo.listStrategiesForFolder(folderId); + } catch (error, stackTrace) { + if (_isInvalidFolderError(error)) { + ref + .read(folderProvider.notifier) + .updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + return const []; + } + if (isConvexUnauthenticatedError(error)) { + unawaited( + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'remote_library:strategies', + error: error, + stackTrace: stackTrace, + ), + ); + return const []; + } + rethrow; + } +}); + +final cloudAllFoldersProvider = + FutureProvider.autoDispose>((ref) async { + final isCloud = ref.watch(isCloudCollabEnabledProvider); + final auth = ref.watch(authProvider); + if (!isCloud || auth.hasActiveAuthIncident) { + return const []; + } + + final repo = ref.watch(convexStrategyRepositoryProvider); + try { + return await repo.listAllFolders(); + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + unawaited( + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'remote_library:all_folders', + error: error, + stackTrace: stackTrace, + ), + ); + return const []; + } + rethrow; + } +}); + +bool _isInvalidFolderError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('folder not found') || message.contains('forbidden'); +} diff --git a/lib/providers/collab/remote_strategy_snapshot_provider.dart b/lib/providers/collab/remote_strategy_snapshot_provider.dart new file mode 100644 index 00000000..491fece3 --- /dev/null +++ b/lib/providers/collab/remote_strategy_snapshot_provider.dart @@ -0,0 +1,286 @@ +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'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; + +final remoteStrategySnapshotProvider = AsyncNotifierProvider< + RemoteStrategySnapshotNotifier, RemoteStrategySnapshot?>( + RemoteStrategySnapshotNotifier.new, +); + +class RemoteStrategySnapshotNotifier + extends AsyncNotifier { + String? _activeStrategyPublicId; + dynamic _headerSubscription; + dynamic _pagesSubscription; + final Map _elementSubscriptions = {}; + final Map _lineupSubscriptions = {}; + Timer? _refreshDebounce; + + @override + Future build() async { + ref.onDispose(_disposeSubscriptions); + return null; + } + + String? get activeStrategyPublicId => _activeStrategyPublicId; + + Future openStrategy(String strategyPublicId) async { + _activeStrategyPublicId = strategyPublicId; + ref.read(strategyOpQueueProvider.notifier).setActiveStrategy(strategyPublicId); + state = const AsyncLoading(); + + await _refreshFromServer(); + await _startSubscriptions(strategyPublicId); + } + + Future refresh() async { + if (_activeStrategyPublicId == null) { + return; + } + await _refreshFromServer(); + } + + void clear() { + _activeStrategyPublicId = null; + _disposeSubscriptions(); + ref.read(strategyOpQueueProvider.notifier).setActiveStrategy(null); + state = const AsyncData(null); + } + + Future _refreshFromServer() async { + final strategyPublicId = _activeStrategyPublicId; + if (strategyPublicId == null) { + return; + } + + final auth = ref.read(authProvider); + if (auth.hasActiveAuthIncident) { + state = const AsyncData(null); + return; + } + + try { + final snapshot = await ref + .read(convexStrategyRepositoryProvider) + .fetchSnapshot(strategyPublicId); + state = AsyncData(snapshot); + await _syncPageSubscriptions(snapshot); + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + unawaited( + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'remote_snapshot:refresh', + error: error, + stackTrace: stackTrace, + ), + ); + state = const AsyncData(null); + return; + } + + log('Failed to refresh remote snapshot: $error', + error: error, stackTrace: stackTrace); + state = AsyncError(error, stackTrace); + } + } + + 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', + error: error, + stackTrace: stackTrace, + ); + _scheduleRefresh(); + } + }, + onError: (message, _) => _handleSubscriptionError( + source: 'remote_snapshot:pages_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}', + ); + } + + return decoded + .map((item) => item is String ? jsonDecode(item) : item) + .whereType() + .map((item) => Map.from(item)) + .map((item) => item['publicId'] as String?) + .whereType() + .toSet(); + } + + Future _syncPageSubscriptions(RemoteStrategySnapshot snapshot) async { + final strategyPublicId = _activeStrategyPublicId; + if (strategyPublicId == null) { + return; + } + + final pageIds = snapshot.pages.map((page) => page.publicId).toSet(); + _syncPageWatchersFromIds(strategyPublicId, pageIds); + } + + void _syncPageWatchersFromIds( + String strategyPublicId, + Set pageIds, + ) { + final existingElementPageIds = _elementSubscriptions.keys.toSet(); + final existingLineupPageIds = _lineupSubscriptions.keys.toSet(); + + for (final pageId in existingElementPageIds.difference(pageIds)) { + _cancelSubscription(_elementSubscriptions.remove(pageId)); + } + for (final pageId in existingLineupPageIds.difference(pageIds)) { + _cancelSubscription(_lineupSubscriptions.remove(pageId)); + } + + 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; + }); + } + } + } + + void _handleSubscriptionError({ + required String source, + required String message, + }) { + if (isConvexUnauthenticatedMessage(message)) { + unawaited( + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: Exception(message), + ), + ); + return; + } + + _scheduleRefresh(); + } + + void _scheduleRefresh() { + if (_activeStrategyPublicId == null) { + return; + } + + if (ref.read(authProvider).hasActiveAuthIncident) { + return; + } + + _refreshDebounce?.cancel(); + _refreshDebounce = Timer(const Duration(milliseconds: 120), () async { + await _refreshFromServer(); + }); + } + + void _disposeSubscriptions() { + _refreshDebounce?.cancel(); + _refreshDebounce = null; + + _cancelSubscription(_headerSubscription); + _headerSubscription = null; + + _cancelSubscription(_pagesSubscription); + _pagesSubscription = null; + + for (final subscription in _elementSubscriptions.values) { + _cancelSubscription(subscription); + } + _elementSubscriptions.clear(); + + for (final subscription in _lineupSubscriptions.values) { + _cancelSubscription(subscription); + } + _lineupSubscriptions.clear(); + } + + void _cancelSubscription(dynamic subscription) { + try { + subscription?.cancel(); + } catch (_) { + // Best-effort cleanup. + } + } +} diff --git a/lib/providers/collab/strategy_capabilities_provider.dart b/lib/providers/collab/strategy_capabilities_provider.dart new file mode 100644 index 00000000..49ef6b01 --- /dev/null +++ b/lib/providers/collab/strategy_capabilities_provider.dart @@ -0,0 +1,89 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class StrategyCapabilities { + const StrategyCapabilities({ + required this.canRenameStrategy, + required this.canDeleteStrategy, + required this.canDuplicateStrategy, + required this.canMoveStrategy, + required this.canEditPages, + required this.canAddPage, + required this.canRenamePage, + required this.canDeletePage, + required this.canReorderPages, + required this.canCreateFolder, + required this.canEditFolder, + required this.canDeleteFolder, + required this.canMoveFolder, + }); + + final bool canRenameStrategy; + final bool canDeleteStrategy; + final bool canDuplicateStrategy; + final bool canMoveStrategy; + final bool canEditPages; + final bool canAddPage; + final bool canRenamePage; + final bool canDeletePage; + final bool canReorderPages; + final bool canCreateFolder; + final bool canEditFolder; + final bool canDeleteFolder; + final bool canMoveFolder; + + factory StrategyCapabilities.fullAccess() { + return const StrategyCapabilities( + canRenameStrategy: true, + canDeleteStrategy: true, + canDuplicateStrategy: true, + canMoveStrategy: true, + canEditPages: true, + canAddPage: true, + canRenamePage: true, + canDeletePage: true, + canReorderPages: true, + canCreateFolder: true, + canEditFolder: true, + canDeleteFolder: true, + canMoveFolder: true, + ); + } + + factory StrategyCapabilities.fromCloudRole(String? role) { + final normalized = role ?? 'viewer'; + final canEdit = normalized == 'owner' || normalized == 'editor'; + final isOwner = normalized == 'owner'; + return StrategyCapabilities( + canRenameStrategy: canEdit, + canDeleteStrategy: isOwner, + canDuplicateStrategy: canEdit, + canMoveStrategy: canEdit, + canEditPages: canEdit, + canAddPage: canEdit, + canRenamePage: canEdit, + canDeletePage: canEdit, + canReorderPages: canEdit, + canCreateFolder: true, + canEditFolder: true, + canDeleteFolder: true, + canMoveFolder: true, + ); + } +} + +final currentStrategyCapabilitiesProvider = Provider((ref) { + final strategySource = + ref.watch(strategyProvider.select((value) => value.source)); + if (strategySource != StrategySource.cloud || + !ref.watch(isCloudCollabEnabledProvider)) { + return StrategyCapabilities.fullAccess(); + } + final role = + ref.watch(remoteStrategySnapshotProvider).valueOrNull?.header.role; + return StrategyCapabilities.fromCloudRole(role); +}); + diff --git a/lib/providers/collab/strategy_conflict_provider.dart b/lib/providers/collab/strategy_conflict_provider.dart new file mode 100644 index 00000000..aa5c4921 --- /dev/null +++ b/lib/providers/collab/strategy_conflict_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; + +final strategyConflictProvider = + NotifierProvider>( + StrategyConflictNotifier.new, +); + +class StrategyConflictNotifier extends Notifier> { + @override + List build() { + return const []; + } + + void push(ConflictResolution resolution) { + state = [...state, resolution]; + } + + void clear(String opId) { + state = state.where((item) => item.opId != opId).toList(growable: false); + } + + void clearAll() { + state = const []; + } +} diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart new file mode 100644 index 00000000..af596efe --- /dev/null +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -0,0 +1,357 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:math' as math; + +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'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:uuid/uuid.dart'; + +class StrategyOpQueueState { + const StrategyOpQueueState({ + this.strategyPublicId, + this.clientId, + this.pending = const [], + this.isFlushing = false, + this.lastError, + this.lastFlushAt, + this.lastAcks = const [], + }); + + final String? strategyPublicId; + final String? clientId; + final List pending; + final bool isFlushing; + final String? lastError; + final DateTime? lastFlushAt; + final List lastAcks; + + StrategyOpQueueState copyWith({ + String? strategyPublicId, + String? clientId, + List? pending, + bool? isFlushing, + String? lastError, + bool clearError = false, + DateTime? lastFlushAt, + List? lastAcks, + }) { + return StrategyOpQueueState( + strategyPublicId: strategyPublicId ?? this.strategyPublicId, + clientId: clientId ?? this.clientId, + pending: pending ?? this.pending, + isFlushing: isFlushing ?? this.isFlushing, + lastError: clearError ? null : (lastError ?? this.lastError), + lastFlushAt: lastFlushAt ?? this.lastFlushAt, + lastAcks: lastAcks ?? this.lastAcks, + ); + } +} + +final strategyOpQueueProvider = + NotifierProvider( + StrategyOpQueueNotifier.new, +); + +final pendingStrategyOpsProvider = Provider>((ref) { + return ref.watch(strategyOpQueueProvider).pending.map((op) => op.op).toList(); +}); + +class StrategyOpQueueNotifier extends Notifier { + static const int _maxBatchSize = 40; + static const Duration _debounceDelay = Duration(milliseconds: 180); + Timer? _debounceTimer; + + ConvexStrategyRepository get _repo => + ref.read(convexStrategyRepositoryProvider); + + @override + StrategyOpQueueState build() { + ref.onDispose(() { + _debounceTimer?.cancel(); + }); + return StrategyOpQueueState(clientId: const Uuid().v4()); + } + + void setActiveStrategy(String? strategyPublicId) { + if (state.strategyPublicId == strategyPublicId) return; + + _debounceTimer?.cancel(); + state = state.copyWith( + strategyPublicId: strategyPublicId, + clientId: const Uuid().v4(), + pending: const [], + lastAcks: const [], + clearError: true, + ); + } + + void enqueue(StrategyOp op, {bool flushImmediately = false}) { + final currentStrategyId = state.strategyPublicId; + if (currentStrategyId == null) { + return; + } + + final incoming = PendingOp( + op: op, + clientId: state.clientId ?? const Uuid().v4(), + attempts: 0, + ); + final mergedPending = _mergePending(state.pending, incoming); + + state = state.copyWith( + pending: mergedPending, + clearError: true, + ); + + if (flushImmediately) { + unawaited(flushNow()); + return; + } + + _debounceTimer?.cancel(); + _debounceTimer = Timer(_debounceDelay, () { + unawaited(flushNow()); + }); + } + + List _mergePending(List pending, PendingOp incoming) { + final entityKey = _entityKeyForOp(incoming.op); + if (entityKey == null) { + return [...pending, incoming]; + } + + final merged = []; + var handled = false; + + for (final existing in pending) { + if (handled || _entityKeyForOp(existing.op) != entityKey) { + merged.add(existing); + continue; + } + + final replacement = _mergePendingOp(existing, incoming); + if (replacement != null) { + merged.add(replacement); + } + handled = true; + } + + if (!handled) { + merged.add(incoming); + } + + return merged; + } + + String? _entityKeyForOp(StrategyOp op) { + switch (op.entityType) { + case StrategyOpEntityType.strategy: + return 'strategy'; + case StrategyOpEntityType.page: + return op.entityPublicId == null ? null : 'page:${op.entityPublicId}'; + case StrategyOpEntityType.element: + if (op.pagePublicId == null || op.entityPublicId == null) { + return null; + } + return 'element:${op.pagePublicId}:${op.entityPublicId}'; + case StrategyOpEntityType.lineup: + if (op.pagePublicId == null || op.entityPublicId == null) { + return null; + } + return 'lineup:${op.pagePublicId}:${op.entityPublicId}'; + } + } + + PendingOp? _mergePendingOp(PendingOp existing, PendingOp incoming) { + final existingOp = existing.op; + final incomingOp = incoming.op; + + if (incomingOp.kind == StrategyOpKind.delete && + existingOp.kind == StrategyOpKind.add) { + return null; + } + + if (existingOp.kind == StrategyOpKind.add && + incomingOp.kind == StrategyOpKind.patch) { + return PendingOp( + op: StrategyOp( + opId: existingOp.opId, + kind: StrategyOpKind.add, + entityType: existingOp.entityType, + entityPublicId: existingOp.entityPublicId, + pagePublicId: existingOp.pagePublicId, + payload: incomingOp.payload ?? existingOp.payload, + sortIndex: incomingOp.sortIndex ?? existingOp.sortIndex, + expectedRevision: existingOp.expectedRevision, + expectedSequence: existingOp.expectedSequence, + ), + clientId: existing.clientId, + attempts: existing.attempts, + lastAttemptAt: existing.lastAttemptAt, + ); + } + + return PendingOp( + op: StrategyOp( + opId: existingOp.opId, + kind: incomingOp.kind, + entityType: incomingOp.entityType, + entityPublicId: incomingOp.entityPublicId ?? existingOp.entityPublicId, + pagePublicId: incomingOp.pagePublicId ?? existingOp.pagePublicId, + payload: incomingOp.payload ?? existingOp.payload, + sortIndex: incomingOp.sortIndex ?? existingOp.sortIndex, + expectedRevision: incomingOp.expectedRevision ?? existingOp.expectedRevision, + expectedSequence: incomingOp.expectedSequence ?? existingOp.expectedSequence, + ), + clientId: existing.clientId, + attempts: existing.attempts, + lastAttemptAt: existing.lastAttemptAt, + ); + } + + void enqueueAll(Iterable ops, {bool flushImmediately = false}) { + for (final op in ops) { + enqueue(op, flushImmediately: false); + } + if (flushImmediately) { + unawaited(flushNow()); + } + } + + Future flushNow() async { + if (state.isFlushing) { + return; + } + + final strategyPublicId = state.strategyPublicId; + if (strategyPublicId == null || state.pending.isEmpty) { + return; + } + + final auth = ref.read(authProvider); + final mode = ref.read(cloudCollabModeProvider); + final isConnected = ConvexClient.instance.isConnected; + + if (!mode.featureFlagEnabled || mode.forceLocalFallback) { + return; + } + + if (auth.hasActiveAuthIncident) { + state = state.copyWith( + lastError: 'Cloud auth incident active. Awaiting user action.', + ); + return; + } + + if (!auth.isAuthenticated || !auth.isConvexUserReady || !isConnected) { + final incremented = [ + for (final pending in state.pending) pending.incrementAttempt(), + ]; + state = state.copyWith( + pending: incremented, + lastError: !auth.isAuthenticated + ? 'Not authenticated for cloud sync.' + : (!auth.isConvexUserReady + ? 'Cloud user setup is not ready.' + : 'Cloud connection is offline.'), + ); + _scheduleRetry(incremented); + return; + } + + state = state.copyWith(isFlushing: true, clearError: true); + + final batch = state.pending.take(_maxBatchSize).toList(growable: false); + final ops = batch.map((pending) => pending.op).toList(growable: false); + + try { + final acks = await _repo.applyBatch( + strategyPublicId: strategyPublicId, + clientId: state.clientId ?? const Uuid().v4(), + ops: ops, + ); + + final rejected = {}; + for (final pending in batch) { + rejected[pending.op.opId] = pending; + } + + for (final ack in acks) { + if (ack.isAck) { + rejected.remove(ack.opId); + continue; + } + + final pending = rejected[ack.opId]; + if (pending != null) { + rejected[ack.opId] = pending.incrementAttempt(); + } + } + + final untouched = state.pending.skip(batch.length).toList(growable: false); + final retried = rejected.values.toList(growable: false); + state = state.copyWith( + pending: [...retried, ...untouched], + isFlushing: false, + lastFlushAt: DateTime.now(), + lastAcks: acks, + ); + + if (rejected.isNotEmpty) { + _scheduleRetry(retried); + } else if (untouched.isNotEmpty) { + unawaited(flushNow()); + } + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + unawaited( + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'strategy_op_queue:flush', + error: error, + stackTrace: stackTrace, + ), + ); + state = state.copyWith( + isFlushing: false, + lastError: 'Cloud authentication expired. Retry required.', + ); + return; + } + + log('Failed flushing op queue: $error', + error: error, stackTrace: stackTrace); + + final incremented = [ + for (final pending in state.pending) pending.incrementAttempt(), + ]; + + state = state.copyWith( + pending: incremented, + isFlushing: false, + lastError: '$error', + ); + + _scheduleRetry(incremented); + } + } + + void _scheduleRetry(List pending) { + if (pending.isEmpty) return; + + final maxAttempt = pending.fold( + 0, + (acc, next) => math.max(acc, next.attempts), + ); + final exponent = maxAttempt.clamp(0, 6); + final delayMs = 300 * (1 << exponent); + + _debounceTimer?.cancel(); + _debounceTimer = Timer(Duration(milliseconds: delayMs), () { + unawaited(flushNow()); + }); + } +} diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 9a7377a8..10b0a2a3 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -1,10 +1,18 @@ +import 'package:convex_flutter/convex_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:uuid/uuid.dart'; enum FolderColor { @@ -114,34 +122,113 @@ final folderProvider = NotifierProvider(FolderProvider.new); class FolderProvider extends Notifier { + String? _localCurrentFolderId; + String? _cloudCurrentFolderId; + + static FolderColor decodeFolderColor(String? raw) { + if (raw == null) { + return FolderColor.generic; + } + for (final value in FolderColor.values) { + if (value.name == raw) { + return value; + } + } + return FolderColor.generic; + } + + static IconData decodeFolderIcon( + CloudFolderSummary folder, { + IconData fallback = Icons.drive_folder_upload, + }) { + final codePoint = folder.iconCodePoint; + if (codePoint == null) { + return fallback; + } + return IconData( + codePoint, + fontFamily: folder.iconFontFamily, + fontPackage: folder.iconFontPackage, + ); + } + + static Folder cloudSummaryToFolder(CloudFolderSummary folder) { + return Folder( + name: folder.name, + id: folder.publicId, + dateCreated: folder.createdAt, + icon: decodeFolderIcon(folder), + color: decodeFolderColor(folder.color), + parentID: folder.parentFolderPublicId, + customColor: folder.customColorValue == null + ? null + : Color(folder.customColorValue!), + ); + } + Future createFolder({ required String name, required IconData icon, required FolderColor color, Color? customColor, String? parentID, + LibraryWorkspace? workspace, }) async { + final targetWorkspace = workspace ?? _currentWorkspace; final newFolder = Folder( icon: icon, name: name, id: const Uuid().v4(), dateCreated: DateTime.now(), - parentID: parentID ?? state, + parentID: parentID ?? _currentFolderIdForWorkspace(targetWorkspace), customColor: customColor, color: color, ); - await Hive.box(HiveBoxNames.foldersBox) - .put(newFolder.id, newFolder); + if (targetWorkspace == LibraryWorkspace.cloud) { + try { + await ref.read(convexStrategyRepositoryProvider).createFolder( + publicId: newFolder.id, + name: name, + parentFolderPublicId: newFolder.parentID, + iconCodePoint: icon.codePoint, + iconFontFamily: icon.fontFamily, + iconFontPackage: icon.fontPackage, + color: color.name, + customColorValue: customColor?.toARGB32(), + ); + ref.invalidate(cloudFoldersProvider); + return newFolder; + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'folder:create', + error: error, + stackTrace: stackTrace, + ); + } + } + + await Hive.box(HiveBoxNames.foldersBox).put(newFolder.id, newFolder); return newFolder; } void updateID(String? id) { - state = id; + updateWorkspaceFolderId(_currentWorkspace, id); } void clearID() { - state = null; + updateWorkspaceFolderId(_currentWorkspace, null); + } + + void updateWorkspaceFolderId(LibraryWorkspace workspace, String? id) { + _setFolderIdForWorkspace(workspace, id); + if (_currentWorkspace == workspace) { + state = id; + } + } + + String? currentFolderIdForWorkspace(LibraryWorkspace workspace) { + return _currentFolderIdForWorkspace(workspace); } List getFullPathIDs(Folder? folder) { @@ -151,7 +238,7 @@ class FolderProvider extends Notifier { while (currentFolder != null) { pathIDs.insert(0, currentFolder.id); if (currentFolder.parentID != null) { - currentFolder = findFolderByID(currentFolder.parentID!); + currentFolder = findLocalFolderByID(currentFolder.parentID!); } else { currentFolder = null; } @@ -160,8 +247,6 @@ class FolderProvider extends Notifier { return pathIDs; } - // I want to be able - List findFolderChildren(String id) { return Hive.box(HiveBoxNames.foldersBox) .values @@ -170,11 +255,47 @@ class FolderProvider extends Notifier { } Folder? findFolderByID(String id) { + return _currentWorkspace == LibraryWorkspace.cloud + ? null + : findLocalFolderByID(id); + } + + Folder? findLocalFolderByID(String id) { return Hive.box(HiveBoxNames.foldersBox).get(id); } - void deleteFolder(String folderID) async { - // state = state.where((folder) => folder.id != folderID).toList(); + Folder? findCloudFolderByID( + String id, + Iterable cloudFolders, + ) { + return cloudFolders + .where((folder) => folder.publicId == id) + .map(cloudSummaryToFolder) + .firstOrNull; + } + + void deleteFolder( + String folderID, { + LibraryWorkspace? workspace, + }) async { + final targetWorkspace = workspace ?? _currentWorkspace; + if (targetWorkspace == LibraryWorkspace.cloud) { + try { + await ConvexClient.instance.mutation(name: 'folders:delete', args: { + 'folderPublicId': folderID, + }); + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'folder:delete', + error: error, + stackTrace: stackTrace, + ); + } + if (_currentFolderIdForWorkspace(LibraryWorkspace.cloud) == folderID) { + updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + } + return; + } final strategyList = Hive.box(HiveBoxNames.strategiesBox).values.toList(); @@ -187,7 +308,10 @@ class FolderProvider extends Notifier { } for (final id in idsToDelete) { - await ref.read(strategyProvider.notifier).deleteStrategy(id); + await ref.read(strategyProvider.notifier).deleteStrategy( + id, + source: StrategySource.local, + ); } await Hive.box(HiveBoxNames.foldersBox).delete(folderID); @@ -199,7 +323,38 @@ class FolderProvider extends Notifier { required IconData newIcon, required FolderColor newColor, required Color? newCustomColor, + LibraryWorkspace? workspace, }) async { + final targetWorkspace = workspace ?? _currentWorkspace; + if (targetWorkspace == LibraryWorkspace.cloud) { + try { + final args = { + 'folderPublicId': folder.id, + 'name': newName, + 'iconCodePoint': newIcon.codePoint, + if (newIcon.fontFamily != null) 'iconFontFamily': newIcon.fontFamily!, + if (newIcon.fontFamily == null) 'clearIconFontFamily': true, + if (newIcon.fontPackage != null) + 'iconFontPackage': newIcon.fontPackage!, + if (newIcon.fontPackage == null) 'clearIconFontPackage': true, + 'color': newColor.name, + if (newCustomColor != null) + 'customColorValue': newCustomColor.toARGB32(), + if (newCustomColor == null) 'clearCustomColorValue': true, + }; + await ConvexClient.instance.mutation(name: 'folders:update', args: args); + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudAllFoldersProvider); + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'folder:update', + error: error, + stackTrace: stackTrace, + ); + } + return; + } + folder.name = newName; folder.icon = newIcon; folder.customColor = newCustomColor; @@ -207,8 +362,29 @@ class FolderProvider extends Notifier { await folder.save(); } - void moveToFolder({required String folderID, String? parentID}) async { - final folder = findFolderByID(folderID); + void moveToFolder({ + required String folderID, + String? parentID, + LibraryWorkspace? workspace, + }) async { + final targetWorkspace = workspace ?? _currentWorkspace; + if (targetWorkspace == LibraryWorkspace.cloud) { + try { + await ConvexClient.instance.mutation(name: 'folders:move', args: { + 'folderPublicId': folderID, + if (parentID != null) 'parentFolderPublicId': parentID, + }); + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'folder:move', + error: error, + stackTrace: stackTrace, + ); + } + return; + } + + final folder = findLocalFolderByID(folderID); if (folder != null) { folder.parentID = parentID; @@ -216,8 +392,48 @@ class FolderProvider extends Notifier { } } + LibraryWorkspace get _currentWorkspace => ref.read(libraryWorkspaceProvider); + + String? _currentFolderIdForWorkspace(LibraryWorkspace workspace) { + return switch (workspace) { + LibraryWorkspace.local => _localCurrentFolderId, + LibraryWorkspace.cloud => _cloudCurrentFolderId, + }; + } + + void _setFolderIdForWorkspace(LibraryWorkspace workspace, String? id) { + if (workspace == LibraryWorkspace.local) { + _localCurrentFolderId = id; + return; + } + _cloudCurrentFolderId = id; + } + + Future _maybeReportCloudUnauthenticated({ + required String source, + required Object error, + required StackTrace stackTrace, + }) async { + if (!isConvexUnauthenticatedError(error)) { + return; + } + + await ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ); + } + @override String? build() { - return null; + ref.listen(libraryWorkspaceProvider, (_, workspace) { + state = _currentFolderIdForWorkspace(workspace); + }); + return _currentFolderIdForWorkspace(ref.read(libraryWorkspaceProvider)); } } + +extension on Iterable { + Folder? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index ce1b00ae..c4889c10 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -351,9 +351,7 @@ class PlacedImageProvider extends Notifier { json as Map, strategyID)), ); - return images - .map(_migrateLoadedImage) - .toList(); + return images.map(_migrateLoadedImage).toList(); } void updateScale(int index, double scale) { @@ -369,7 +367,7 @@ class PlacedImageProvider extends Notifier { String imageID, String fileExtenstion, ) async { - final strategyID = ref.read(strategyProvider).id; + final strategyID = ref.read(strategyProvider).strategyId; // Get the system's application support directory. if (kIsWeb) return; final directory = await getApplicationSupportDirectory(); diff --git a/lib/providers/library_workspace_provider.dart b/lib/providers/library_workspace_provider.dart new file mode 100644 index 00000000..dd4ea91e --- /dev/null +++ b/lib/providers/library_workspace_provider.dart @@ -0,0 +1,42 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; + +enum LibraryWorkspace { + local, + cloud, +} + +final isCloudWorkspaceAvailableProvider = Provider((ref) { + final auth = ref.watch(authProvider); + return auth.isAuthenticated && auth.isConvexUserReady; +}); + +final libraryWorkspaceProvider = + NotifierProvider( + LibraryWorkspaceNotifier.new, +); + +final isCloudWorkspaceSelectedProvider = Provider((ref) { + return ref.watch(libraryWorkspaceProvider) == LibraryWorkspace.cloud; +}); + +class LibraryWorkspaceNotifier extends Notifier { + @override + LibraryWorkspace build() { + ref.listen(isCloudWorkspaceAvailableProvider, (_, isAvailable) { + if (!isAvailable && state == LibraryWorkspace.cloud) { + state = LibraryWorkspace.local; + } + }); + return LibraryWorkspace.local; + } + + void select(LibraryWorkspace workspace) { + if (workspace == LibraryWorkspace.cloud && + !ref.read(isCloudWorkspaceAvailableProvider)) { + state = LibraryWorkspace.local; + return; + } + state = workspace; + } +} diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index b33c170b..1f7533fc 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -8,6 +8,7 @@ import 'package:icarus/providers/agent_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); @@ -49,7 +50,10 @@ class MapProvider extends Notifier { return MapState(currentMap: MapValue.ascent, isAttack: true); } - void updateMap(MapValue map) => state = state.copyWith(currentMap: map); + void updateMap(MapValue map) { + state = state.copyWith(currentMap: map); + ref.read(strategyProvider.notifier).setUnsaved(); + } void fromHive(MapValue map, bool isAttack) { state = state.copyWith(currentMap: map, isAttack: isAttack); @@ -76,10 +80,12 @@ class MapProvider extends Notifier { ref.read(textProvider.notifier).switchSides(); ref.read(placedImageProvider.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() { @@ -95,3 +101,5 @@ class MapProvider extends Notifier { return mapValue; } } + + diff --git a/lib/providers/marker_sizes_sync.dart b/lib/providers/marker_sizes_sync.dart index 717316cb..a82c0cf0 100644 --- a/lib/providers/marker_sizes_sync.dart +++ b/lib/providers/marker_sizes_sync.dart @@ -1,4 +1,4 @@ -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; /// Whether [agentSize] / [abilitySize] differ across strategy pages, using diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart new file mode 100644 index 00000000..50932de6 --- /dev/null +++ b/lib/providers/strategy_page_session_provider.dart @@ -0,0 +1,619 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/map_theme_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/transition_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/strategy/strategy_page_apply.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/strategy/strategy_page_source.dart'; + +enum PageTransitionState { + idle, + animatingForward, + animatingBackward, +} + +enum PageSwitchDirection { next, previous } + +class StrategyPageSessionState { + const StrategyPageSessionState({ + required this.activePageId, + required this.availablePageIds, + required this.transitionState, + required this.isApplyingPage, + }); + + final String? activePageId; + final List availablePageIds; + final PageTransitionState transitionState; + final bool isApplyingPage; + + StrategyPageSessionState copyWith({ + String? activePageId, + bool clearActivePageId = false, + List? availablePageIds, + PageTransitionState? transitionState, + bool? isApplyingPage, + }) { + return StrategyPageSessionState( + activePageId: + clearActivePageId ? null : (activePageId ?? this.activePageId), + availablePageIds: availablePageIds ?? this.availablePageIds, + transitionState: transitionState ?? this.transitionState, + isApplyingPage: isApplyingPage ?? this.isApplyingPage, + ); + } +} + +final strategyPageSessionProvider = + NotifierProvider( + StrategyPageSessionNotifier.new, +); + +class StrategyPageSessionNotifier extends Notifier { + int? _lastHydratedRemoteSequence; + String? _lastHydratedRemoteStrategyId; + String? _lastHydratedRemotePageId; + bool _pendingRemoteReapply = false; + + @override + StrategyPageSessionState build() { + ref.listen>( + remoteStrategySnapshotProvider, + (previous, next) { + final strategyState = ref.read(strategyProvider); + if (strategyState.source != StrategySource.cloud || + !strategyState.isOpen) { + return; + } + + final snapshot = next.valueOrNull; + if (snapshot == null || snapshot.pages.isEmpty) { + return; + } + + final pageIds = [...snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final orderedIds = + pageIds.map((page) => page.publicId).toList(growable: false); + if (!listEquals(orderedIds, state.availablePageIds)) { + state = state.copyWith(availablePageIds: orderedIds); + } + + final prevSequence = previous?.valueOrNull?.header.sequence; + final sequenceChanged = + prevSequence == null || prevSequence != snapshot.header.sequence; + if (!sequenceChanged) { + return; + } + + final targetPageId = _resolveHydrationTargetPage(snapshot); + if (targetPageId == null) { + return; + } + + final alreadyHydrated = + _lastHydratedRemoteStrategyId == snapshot.header.publicId && + _lastHydratedRemoteSequence == snapshot.header.sequence && + _lastHydratedRemotePageId == targetPageId; + if (alreadyHydrated) { + return; + } + + if (_canSafelyReapplyRemotePage()) { + unawaited(_rehydrateActivePageFromSource(targetPageId)); + } else { + _pendingRemoteReapply = true; + } + }, + ); + + ref.listen(strategySaveStateProvider, (_, __) { + if (_pendingRemoteReapply && _canSafelyReapplyRemotePage()) { + _pendingRemoteReapply = false; + final pageId = state.activePageId; + if (pageId != null) { + unawaited(_rehydrateActivePageFromSource(pageId)); + } + } + }); + + ref.listen(strategyOpQueueProvider, (previous, next) { + final previousAcks = previous?.lastAcks ?? const []; + if (next.lastAcks.isEmpty || identical(previousAcks, next.lastAcks)) { + return; + } + unawaited(_reconcileAcks(next.lastAcks)); + }); + + return const StrategyPageSessionState( + activePageId: null, + availablePageIds: [], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + } + + String? get activePageId => state.activePageId; + + Future initializeForStrategy({ + required String strategyId, + required StrategySource source, + required bool selectFirstPageIfNeeded, + }) async { + final pageSource = _resolvePageSource(strategyId, source); + final pageIds = await pageSource.listPageIds(); + final initialPageId = + pageIds.contains(state.activePageId) ? state.activePageId : null; + final selected = initialPageId ?? + (selectFirstPageIfNeeded && pageIds.isNotEmpty ? pageIds.first : null); + + state = state.copyWith( + availablePageIds: pageIds, + activePageId: selected, + clearActivePageId: selected == null, + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + + if (selected != null) { + await _rehydrateActivePageFromSource(selected); + } + } + + Future setActivePage(String pageId) async { + if (pageId == state.activePageId) { + return; + } + await _switchToPage(pageId, animated: false); + } + + Future setActivePageAnimated( + String pageId, { + required PageTransitionDirection direction, + Duration duration = kPageTransitionDuration, + }) async { + if (pageId == state.activePageId) { + return; + } + + final strategyState = ref.read(strategyProvider); + if (strategyState.source == StrategySource.cloud) { + await _switchToPage(pageId, animated: false); + return; + } + + final transitionState = ref.read(transitionProvider); + final transitionNotifier = ref.read(transitionProvider.notifier); + if (transitionState.active || + transitionState.phase == PageTransitionPhase.preparing) { + transitionNotifier.complete(); + } + + state = state.copyWith( + transitionState: direction == PageTransitionDirection.forward + ? PageTransitionState.animatingForward + : PageTransitionState.animatingBackward, + ); + + final startSettings = ref.read(strategySettingsProvider); + final previous = _snapshotAllPlaced(); + transitionNotifier.prepare( + previous.values.toList(), + direction: direction, + startAgentSize: startSettings.agentSize, + startAbilitySize: startSettings.abilitySize, + ); + + await _switchToPage( + pageId, + animated: true, + direction: direction, + ); + final endSettings = ref.read(strategySettingsProvider); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final next = _snapshotAllPlaced(); + final entries = _diffToTransitions(previous, next); + if (entries.isNotEmpty) { + transitionNotifier.start( + entries, + duration: duration, + direction: direction, + startAgentSize: startSettings.agentSize, + endAgentSize: endSettings.agentSize, + startAbilitySize: startSettings.abilitySize, + endAbilitySize: endSettings.abilitySize, + ); + } else { + transitionNotifier.complete(); + } + state = state.copyWith(transitionState: PageTransitionState.idle); + }); + } + + Future switchRelativePage(PageSwitchDirection direction) async { + if (state.availablePageIds.isEmpty) { + return; + } + + final active = state.activePageId ?? state.availablePageIds.first; + final currentIndex = state.availablePageIds.indexOf(active); + if (currentIndex < 0) { + return; + } + + final nextIndex = direction == PageSwitchDirection.next + ? (currentIndex + 1) % state.availablePageIds.length + : (currentIndex - 1 + state.availablePageIds.length) % + state.availablePageIds.length; + final nextPageId = state.availablePageIds[nextIndex]; + final strategyState = ref.read(strategyProvider); + if (strategyState.source == StrategySource.cloud) { + await setActivePage(nextPageId); + return; + } + + await setActivePageAnimated( + nextPageId, + direction: direction == PageSwitchDirection.next + ? PageTransitionDirection.forward + : PageTransitionDirection.backward, + ); + } + + Future flushCurrentPage({bool flushImmediately = false}) async { + final strategyState = ref.read(strategyProvider); + if (!strategyState.isOpen || strategyState.strategyId == null) { + return; + } + + final source = _resolvePageSource( + strategyState.strategyId!, + strategyState.source ?? StrategySource.local, + ); + await source.flushCurrentPage(); + if (flushImmediately && strategyState.source == StrategySource.cloud) { + await ref.read(strategyOpQueueProvider.notifier).flushNow(); + } + } + + bool get isApplyingPage => state.isApplyingPage; + + void setStateForTest(StrategyPageSessionState newState) { + state = newState; + } + + void reset() { + state = const StrategyPageSessionState( + activePageId: null, + availablePageIds: [], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + _lastHydratedRemoteSequence = null; + _lastHydratedRemoteStrategyId = null; + _lastHydratedRemotePageId = null; + _pendingRemoteReapply = false; + } + + Future _switchToPage( + String pageId, { + required bool animated, + PageTransitionDirection? direction, + }) async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + final source = strategyState.source; + if (strategyId == null || source == null) { + return; + } + + final pageSource = _resolvePageSource(strategyId, source); + await pageSource.flushCurrentPage(); + if (source == StrategySource.cloud) { + await ref.read(strategyOpQueueProvider.notifier).flushNow(); + } + + final pageData = await pageSource.loadPage(pageId); + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: source, + ); + + if (animated && direction != null) { + _updateHydrationBookkeeping(pageData.pageId); + } + } + + Future _rehydrateActivePageFromSource(String pageId) async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + final source = strategyState.source; + if (strategyId == null || source == null) { + return; + } + + final pageData = + await _resolvePageSource(strategyId, source).loadPage(pageId); + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: source, + ); + } + + Future _applyLoadedPageData( + StrategyEditorPageData pageData, { + required String strategyId, + required StrategySource source, + }) async { + final themeProfileId = _resolveThemeProfileId(source, strategyId); + final themeOverridePalette = + _resolveThemeOverridePalette(source, strategyId); + + state = state.copyWith( + isApplyingPage: true, + activePageId: pageData.pageId, + availablePageIds: + await _resolvePageSource(strategyId, source).listPageIds(), + ); + + try { + await applyStrategyEditorPageData( + ref, + pageData, + themeProfileId: themeProfileId, + themeOverridePalette: themeOverridePalette, + ); + _updateHydrationBookkeeping(pageData.pageId); + } finally { + state = state.copyWith( + activePageId: pageData.pageId, + isApplyingPage: false, + ); + } + } + + StrategyPageSource _resolvePageSource( + String strategyId, + StrategySource source, + ) { + switch (source) { + case StrategySource.local: + return LocalStrategyPageSource( + ref, + strategyId: strategyId, + activePageId: () => state.activePageId, + ); + case StrategySource.cloud: + return CloudStrategyPageSource( + ref, + strategyId: strategyId, + activePageId: () => state.activePageId, + ); + } + } + + String _resolveThemeProfileId(StrategySource source, String strategyId) { + if (source == StrategySource.cloud) { + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + return snapshot?.header.themeProfileId ?? + MapThemeProfilesProvider.immutableDefaultProfileId; + } + + final strategy = Hive.box(HiveBoxNames.strategiesBox).get( + strategyId, + ); + return strategy?.themeProfileId ?? + MapThemeProfilesProvider.immutableDefaultProfileId; + } + + MapThemePalette? _resolveThemeOverridePalette( + StrategySource source, + String strategyId, + ) { + if (source == StrategySource.cloud) { + final payload = ref + .read(remoteStrategySnapshotProvider) + .valueOrNull + ?.header + .themeOverridePalette; + if (payload == null || payload.isEmpty) { + return null; + } + try { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return MapThemePalette.fromJson(decoded); + } + if (decoded is Map) { + return MapThemePalette.fromJson(Map.from(decoded)); + } + } catch (_) { + return null; + } + return null; + } + + return Hive.box(HiveBoxNames.strategiesBox) + .get(strategyId) + ?.themeOverridePalette; + } + + bool _canSafelyReapplyRemotePage() { + final saveState = ref.read(strategySaveStateProvider); + return !state.isApplyingPage && + state.transitionState == PageTransitionState.idle && + !saveState.isDirty && + !saveState.hasPendingCloudSync && + !saveState.isSaving; + } + + String? _resolveHydrationTargetPage(RemoteStrategySnapshot snapshot) { + if (snapshot.pages.isEmpty) { + return null; + } + + final activePageId = state.activePageId; + if (activePageId != null && + snapshot.pages.any((page) => page.publicId == activePageId)) { + return activePageId; + } + + final pages = [...snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + return pages.first.publicId; + } + + void _updateHydrationBookkeeping(String pageId) { + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null) { + return; + } + _lastHydratedRemoteStrategyId = snapshot.header.publicId; + _lastHydratedRemoteSequence = snapshot.header.sequence; + _lastHydratedRemotePageId = pageId; + } + + Future _reconcileAcks(List acks) async { + final strategyState = ref.read(strategyProvider); + if (strategyState.source != StrategySource.cloud || acks.isEmpty) { + return; + } + + var hasReject = false; + for (final ack in acks) { + if (ack.isAck) { + continue; + } + hasReject = true; + Map? serverPayload; + if (ack.latestPayload != null && ack.latestPayload!.isNotEmpty) { + try { + final decoded = jsonDecode(ack.latestPayload!); + if (decoded is Map) { + serverPayload = decoded; + } + } catch (_) { + serverPayload = null; + } + } + + ref.read(strategyConflictProvider.notifier).push( + ConflictResolution( + type: ConflictResolutionType.rebase, + opId: ack.opId, + message: ack.reason, + serverPayload: serverPayload, + serverRevision: ack.latestRevision, + serverSequence: ack.latestSequence, + ), + ); + } + + if (!hasReject) { + return; + } + + await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); + if (_canSafelyReapplyRemotePage() && state.activePageId != null) { + await _rehydrateActivePageFromSource(state.activePageId!); + } else { + _pendingRemoteReapply = true; + } + } + + Map _snapshotAllPlaced() { + final map = {}; + for (final agent in ref.read(agentProvider)) { + map[agent.id] = agent; + } + for (final ability in ref.read(abilityProvider)) { + map[ability.id] = ability; + } + for (final text in ref.read(textProvider)) { + map[text.id] = text; + } + for (final image in ref.read(placedImageProvider).images) { + map[image.id] = image; + } + for (final utility in ref.read(utilityProvider)) { + map[utility.id] = utility; + } + return map; + } + + List _diffToTransitions( + Map previous, + Map next, + ) { + final entries = []; + var order = 0; + + next.forEach((id, to) { + final from = previous[id]; + if (from != null) { + if (from.position != to.position || + PageTransitionEntry.rotationOf(from) != + PageTransitionEntry.rotationOf(to) || + PageTransitionEntry.lengthOf(from) != + PageTransitionEntry.lengthOf(to) || + !listEquals( + PageTransitionEntry.armLengthsOf(from), + PageTransitionEntry.armLengthsOf(to), + ) || + PageTransitionEntry.scaleOf(from) != + PageTransitionEntry.scaleOf(to) || + PageTransitionEntry.textSizeOf(from) != + PageTransitionEntry.textSizeOf(to) || + PageTransitionEntry.agentStateOf(from) != + PageTransitionEntry.agentStateOf(to) || + PageTransitionEntry.customDiameterOf(from) != + PageTransitionEntry.customDiameterOf(to) || + PageTransitionEntry.customWidthOf(from) != + PageTransitionEntry.customWidthOf(to) || + PageTransitionEntry.customLengthOf(from) != + PageTransitionEntry.customLengthOf(to)) { + entries + .add(PageTransitionEntry.move(from: from, to: to, order: order)); + } else { + entries.add(PageTransitionEntry.none(to: to, order: order)); + } + } else { + entries.add(PageTransitionEntry.appear(to: to, order: order)); + } + order++; + }); + + previous.forEach((id, from) { + if (!next.containsKey(id)) { + entries.add(PageTransitionEntry.disappear(from: from, order: order)); + order++; + } + }); + + return entries; + } +} diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 5c38f1f9..cbb4eeff 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -2,351 +2,58 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'dart:io'; -import 'package:archive/archive_io.dart'; -import 'package:cross_file/cross_file.dart'; -import 'package:flutter/foundation.dart' - show kIsWeb, listEquals, visibleForTesting; -import 'package:icarus/const/line_provider.dart'; +import 'package:convex_flutter/convex_flutter.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:icarus/const/transition_data.dart'; -import 'package:icarus/providers/transition_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/abilities.dart'; -import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/migrations/ability_scale_migration.dart'; -import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/auto_save_notifier.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/providers/favorite_agents_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/services/app_error_reporter.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:icarus/const/drawing_element.dart'; -import 'package:icarus/const/maps.dart'; -import 'package:icarus/const/placed_classes.dart'; -import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/providers/transition_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; -import 'package:icarus/services/archive_manifest.dart'; +import 'package:hive_ce/hive.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; - -class StrategyData extends HiveObject { - final String id; - String name; - final int versionNumber; - - @Deprecated('Use pages instead') - final List drawingData; - - @Deprecated('Use pages instead') - final List agentData; - - @Deprecated('Use pages instead') - final List abilityData; - - @Deprecated('Use pages instead') - final List textData; - - @Deprecated('Use pages instead') - final List imageData; - - @Deprecated('Use pages instead') - final List utilityData; - - @Deprecated('Use pages instead') - final bool isAttack; - - @Deprecated('Use pages instead') - final StrategySettings strategySettings; - - final List pages; - final MapValue mapData; - final DateTime lastEdited; - final DateTime createdAt; - - String? folderID; - final String? themeProfileId; - final MapThemePalette? themeOverridePalette; - - StrategyData({ - @Deprecated('Use pages instead') this.isAttack = true, - @Deprecated('Use pages instead') this.drawingData = const [], - @Deprecated('Use pages instead') this.agentData = const [], - @Deprecated('Use pages instead') this.abilityData = const [], - @Deprecated('Use pages instead') this.textData = const [], - @Deprecated('Use pages instead') this.imageData = const [], - @Deprecated('Use pages instead') this.utilityData = const [], - required this.id, - required this.name, - required this.mapData, - required this.versionNumber, - required this.lastEdited, - required this.folderID, - this.themeProfileId, - this.themeOverridePalette, - this.pages = const [], - DateTime? createdAt, - @Deprecated('Use pages instead') StrategySettings? strategySettings, - // ignore: deprecated_member_use_from_same_package - }) : strategySettings = strategySettings ?? StrategySettings(), - createdAt = createdAt ?? lastEdited; - - StrategyData copyWith({ - String? id, - String? name, - int? versionNumber, - List? drawingData, - List? agentData, - List? abilityData, - List? textData, - List? imageData, - List? utilityData, - List? pages, - MapValue? mapData, - DateTime? lastEdited, - bool? isAttack, - StrategySettings? strategySettings, - String? folderID, - DateTime? createdAt, - String? themeProfileId, - bool clearThemeProfileId = false, - MapThemePalette? themeOverridePalette, - bool clearThemeOverridePalette = false, - }) { - return StrategyData( - id: id ?? this.id, - name: name ?? this.name, - versionNumber: versionNumber ?? this.versionNumber, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - drawingData: drawingData ?? this.drawingData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - agentData: agentData ?? this.agentData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - abilityData: abilityData ?? this.abilityData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - textData: textData ?? this.textData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - imageData: imageData ?? this.imageData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - utilityData: utilityData ?? this.utilityData, - pages: pages ?? this.pages, - mapData: mapData ?? this.mapData, - lastEdited: lastEdited ?? this.lastEdited, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - isAttack: isAttack ?? this.isAttack, - // ignore: deprecated_member_use_from_same_package - strategySettings: strategySettings ?? this.strategySettings, - createdAt: createdAt ?? this.createdAt, - folderID: folderID ?? this.folderID, - themeProfileId: - clearThemeProfileId ? null : (themeProfileId ?? this.themeProfileId), - themeOverridePalette: clearThemeOverridePalette - ? null - : (themeOverridePalette ?? this.themeOverridePalette), - ); - } -} - -class StrategyState { - StrategyState({ - required this.isSaved, - required this.stratName, - required this.id, - required this.storageDirectory, - this.activePageId, - }); - - final bool isSaved; - final String? stratName; - final String id; - final String? storageDirectory; - final String? activePageId; - - StrategyState copyWith({ - bool? isSaved, - String? stratName, - String? id, - String? storageDirectory, - String? activePageId, - bool clearActivePageId = false, - }) { - return StrategyState( - isSaved: isSaved ?? this.isSaved, - stratName: stratName ?? this.stratName, - id: id ?? this.id, - storageDirectory: storageDirectory ?? this.storageDirectory, - activePageId: - clearActivePageId ? null : (activePageId ?? this.activePageId), - ); - } -} +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/auth_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/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; final strategyProvider = NotifierProvider(StrategyProvider.new); -class NewerVersionImportException implements Exception { - const NewerVersionImportException({ - required this.importedVersion, - required this.currentVersion, - }); - - final int importedVersion; - final int currentVersion; - - static const String userMessage = - 'This strategy was created in a newer version of Icarus. ' - 'Please update the app and try again.'; - - @override - String toString() { - return 'NewerVersionImportException(' - 'importedVersion: $importedVersion, ' - 'currentVersion: $currentVersion' - ')'; - } -} - -enum ImportIssueCode { - newerVersion, - invalidStrategy, - invalidArchiveMetadata, - unsupportedFile, - ioError, -} - -class ImportIssue { - const ImportIssue({ - required this.path, - required this.code, - }); - - final String path; - final ImportIssueCode code; -} - -class ImportBatchResult { - const ImportBatchResult({ - required this.strategiesImported, - required this.foldersCreated, - this.themeProfilesImported = 0, - this.globalStateRestored = false, - required this.issues, - }); - - const ImportBatchResult.empty() - : strategiesImported = 0, - foldersCreated = 0, - themeProfilesImported = 0, - globalStateRestored = false, - issues = const []; - - final int strategiesImported; - final int foldersCreated; - final int themeProfilesImported; - final bool globalStateRestored; - final List issues; - - bool get hasImports => - strategiesImported > 0 || - foldersCreated > 0 || - themeProfilesImported > 0 || - globalStateRestored; - - ImportBatchResult merge(ImportBatchResult other) { - return ImportBatchResult( - strategiesImported: strategiesImported + other.strategiesImported, - foldersCreated: foldersCreated + other.foldersCreated, - themeProfilesImported: - themeProfilesImported + other.themeProfilesImported, - globalStateRestored: globalStateRestored || other.globalStateRestored, - issues: [...issues, ...other.issues], - ); - } -} - -class _ImportEntityListing { - const _ImportEntityListing({ - required this.entities, - required this.issues, - }); - - final List entities; - final List issues; -} - -class _ArchiveExportState { - _ArchiveExportState({ - required this.rootDirectory, - }); - - final Directory rootDirectory; - final List folders = []; - final List strategies = []; -} - -class _ManifestImportData { - const _ManifestImportData({ - required this.rootDirectory, - required this.manifestFile, - required this.manifest, - }); - - final Directory rootDirectory; - final File manifestFile; - final ArchiveManifest manifest; -} - -class _GlobalImportResult { - const _GlobalImportResult({ - required this.themeProfilesImported, - required this.globalStateRestored, - required this.profileIdRemap, - }); - - final int themeProfilesImported; - final bool globalStateRestored; - final Map profileIdRemap; -} - -class _ZipManifestData { - const _ZipManifestData({ - required this.manifest, - required this.rootPrefix, - required this.filesByPath, - required this.manifestArchivePath, - }); - - final ArchiveManifest manifest; - final String rootPrefix; - final Map filesByPath; - final String manifestArchivePath; -} - class StrategyProvider extends Notifier { - String? activePageID; - @override StrategyState build() { - return StrategyState( - isSaved: false, - stratName: null, - id: "testID", + return const StrategyState( + strategyId: null, + strategyName: null, + source: null, storageDirectory: null, - activePageId: null, + isOpen: false, ); } @@ -355,24 +62,13 @@ class StrategyProvider extends Notifier { bool _saveInProgress = false; bool _pendingSave = false; - void _reportImportFailure( - String message, { - Object? error, - StackTrace? stackTrace, - required String source, - }) { - AppErrorReporter.reportError( - message, - error: error, - stackTrace: stackTrace, - source: source, - promptUser: false, - ); - } - //Used For Images void setFromState(StrategyState newState) { - state = newState; + final hasIdentity = + newState.strategyId != null || newState.strategyName != null; + state = newState.copyWith( + isOpen: newState.isOpen || hasIdentity, + ); } void cancelPendingSave() { @@ -383,26 +79,161 @@ class StrategyProvider extends Notifier { void refreshAutosaveScheduling() { cancelPendingSave(); - if (state.stratName == null || state.isSaved) { + if (!state.isOpen || !ref.read(strategySaveStateProvider).isDirty) { + return; + } + if (_currentStrategyIsCloud()) { return; } if (!ref.read(appPreferencesProvider).autosaveEnabled) { return; } + _saveTimer = Timer(Settings.autoSaveOffset, () async { - if (state.stratName == null) return; - await _performSave(state.id); + final strategyId = state.strategyId; + if (strategyId == null) { + return; + } + await _performSave(strategyId); }); } + bool _currentStrategyIsCloud() { + return state.source == StrategySource.cloud; + } + + bool _selectedWorkspaceIsCloud() { + return ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; + } + + StrategySource _resolveLibraryMutationSource() { + final currentSource = state.source; + if (currentSource != null) { + return currentSource; + } + return _selectedWorkspaceIsCloud() + ? StrategySource.cloud + : StrategySource.local; + } + + Future _reportCloudUnauthenticated({ + required String source, + required Object error, + required StackTrace stackTrace, + }) async { + if (!isConvexUnauthenticatedError(error)) { + return false; + } + + await ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ); + return true; + } + + Future openStrategy(String strategyID) async { + await openCloudStrategy(strategyID); + } + + Future openCloudStrategy(String strategyID) async { + cancelPendingSave(); + ref.read(strategySaveStateProvider.notifier).reset(); + + await ref + .read(remoteStrategySnapshotProvider.notifier) + .openStrategy(strategyID); + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null) { + return; + } + + state = state.copyWith( + strategyId: snapshot.header.publicId, + strategyName: snapshot.header.name, + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ); + + await ref.read(strategyPageSessionProvider.notifier).initializeForStrategy( + strategyId: snapshot.header.publicId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + } + + Future switchPage(String pageID) async { + if (_currentStrategyIsCloud()) { + await ref + .read(strategyPageSessionProvider.notifier) + .setActivePage(pageID); + return; + } + + await ref.read(strategyPageSessionProvider.notifier).setActivePageAnimated( + pageID, + direction: PageTransitionDirection.forward, + ); + } + + Future enqueueOps( + List ops, { + bool flushImmediately = false, + }) async { + if (!_currentStrategyIsCloud() || ops.isEmpty) { + return; + } + + ref + .read(strategyOpQueueProvider.notifier) + .enqueueAll(ops, flushImmediately: flushImmediately); + if (ref.read(strategyPageSessionProvider).isApplyingPage) { + return; + } + + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + } + + Future notifyCloudMutation({bool flushImmediately = false}) async { + if (!_currentStrategyIsCloud()) { + return; + } + + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + await ref + .read(strategyPageSessionProvider.notifier) + .flushCurrentPage(flushImmediately: flushImmediately); + } + void setUnsaved() async { - state = state.copyWith(isSaved: false); + if (ref.read(strategyPageSessionProvider).isApplyingPage) { + return; + } + + if (_currentStrategyIsCloud()) { + unawaited(notifyCloudMutation(flushImmediately: false)); + return; + } + + ref.read(strategySaveStateProvider.notifier).markDirty(); refreshAutosaveScheduling(); } - // For manual “Save now” actions Future forceSaveNow(String id) async { cancelPendingSave(); + if (_currentStrategyIsCloud()) { + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true); + } await _performSave(id); } @@ -415,9 +246,17 @@ class StrategyProvider extends Notifier { _saveInProgress = true; try { - ref.read(autoSaveProvider.notifier).ping(); // UI: “Saving…” - await saveToHive(id); + ref.read(autoSaveProvider.notifier).ping(); // UI: Saving... + ref.read(strategySaveStateProvider.notifier).markSaving(true); + if (_currentStrategyIsCloud()) { + await ref + .read(strategyPageSessionProvider.notifier) + .flushCurrentPage(flushImmediately: true); + } else { + await saveToHive(id); + } } finally { + ref.read(strategySaveStateProvider.notifier).markSaving(false); _saveInProgress = false; if (_pendingSave) { _pendingSave = false; @@ -448,2528 +287,400 @@ class StrategyProvider extends Notifier { Future clearCurrentStrategy() async { cancelPendingSave(); - activePageID = null; ref.read(strategyThemeProvider.notifier).fromStrategy(); + ref.read(strategySaveStateProvider.notifier).reset(); + ref.read(strategyPageSessionProvider.notifier).reset(); state = StrategyState( - isSaved: true, - stratName: null, - id: "testID", + strategyId: null, + strategyName: null, + source: null, storageDirectory: state.storageDirectory, - activePageId: null, + isOpen: false, ); + ref.read(remoteStrategySnapshotProvider.notifier).clear(); } - // --- MIGRATION: create a first page from legacy flat fields ---------------- - static Future migrateAllStrategies() async { - final box = Hive.box(HiveBoxNames.strategiesBox); - for (final strat in box.values) { - final legacyMigrated = await migrateLegacyData(strat); - final worldMigrated = migrateToWorld16x9(legacyMigrated); - final abilityScaleMigrated = migrateAbilityScale(worldMigrated); - final squareAoeMigrated = migrateSquareAoeCenter(abilityScaleMigrated); - final customCircleMigrated = - migrateCustomCircleWrapper(squareAoeMigrated); - if (customCircleMigrated != squareAoeMigrated) { - await box.put(customCircleMigrated.id, customCircleMigrated); - } else if (squareAoeMigrated != abilityScaleMigrated) { - await box.put(squareAoeMigrated.id, squareAoeMigrated); - } else if (abilityScaleMigrated != worldMigrated) { - await box.put(abilityScaleMigrated.id, abilityScaleMigrated); - } else if (worldMigrated != legacyMigrated) { - await box.put(worldMigrated.id, worldMigrated); - } else if (legacyMigrated != strat) { - await box.put(legacyMigrated.id, legacyMigrated); - } - } + // Switch active page: flush old page first, then hydrate new + Future setActivePage(String pageID) async { + await ref.read(strategyPageSessionProvider.notifier).setActivePage(pageID); } - static StrategyData migrateAbilityScale(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= AbilityScaleMigration.version) { - return strat; - } - - final migratedPages = AbilityScaleMigration.migratePages( - pages: strat.pages, - map: strat.mapData, - ); + Future backwardPage() async { + await ref + .read(strategyPageSessionProvider.notifier) + .switchRelativePage(PageSwitchDirection.previous); + } - final hasPageChanged = migratedPages.length == strat.pages.length && - migratedPages.asMap().entries.any((entry) { - final index = entry.key; - return entry.value != strat.pages[index]; - }); + Future forwardPage() async { + await ref + .read(strategyPageSessionProvider.notifier) + .switchRelativePage(PageSwitchDirection.next); + } - if (!hasPageChanged && !force) { - return strat; - } + Future reorderPage(int oldIndex, int newIndex) async { + if (oldIndex == newIndex) return; - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); - } + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.pages.isEmpty) return; + final ordered = [...snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + if (oldIndex < 0 || + oldIndex >= ordered.length || + newIndex < 0 || + newIndex > ordered.length) { + return; + } - static StrategyData migrateSquareAoeCenter(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= SquareAoeCenterMigration.version) { - return strat; - } + var targetIndex = newIndex; + if (targetIndex > oldIndex) targetIndex -= 1; - final migratedPages = SquareAoeCenterMigration.migratePages( - pages: strat.pages, - ); + final moved = ordered.removeAt(oldIndex); + ordered.insert(targetIndex, moved); - final hasPageChanged = migratedPages.length == strat.pages.length && - migratedPages.asMap().entries.any((entry) { - final index = entry.key; - return entry.value != strat.pages[index]; + try { + await ConvexClient.instance.mutation(name: "pages:reorder", args: { + "strategyPublicId": state.strategyId, + "orderedPagePublicIds": ordered.map((p) => p.publicId).toList(), }); - - if (!hasPageChanged && !force) { - return strat; + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:pages_reorder', + error: error, + stackTrace: stackTrace, + ); + if (!handled) rethrow; + return; + } + await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); + return; } - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); - } + final box = Hive.box(HiveBoxNames.strategiesBox); + final strategyId = state.strategyId; + if (strategyId == null) return; + final strat = box.get(strategyId); + if (strat == null || strat.pages.isEmpty) return; + + final ordered = [...strat.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - static StrategyData migrateCustomCircleWrapper(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= CustomCircleWrapperMigration.version) { - return strat; + if (oldIndex < 0 || + oldIndex >= ordered.length || + newIndex < 0 || + newIndex > ordered.length) { + return; } - final migratedPages = CustomCircleWrapperMigration.migratePages( - pages: strat.pages, - map: strat.mapData, - ); + var targetIndex = newIndex; + if (targetIndex > oldIndex) targetIndex -= 1; - final hasPageChanged = migratedPages.length == strat.pages.length && - migratedPages.asMap().entries.any((entry) { - final index = entry.key; - return entry.value != strat.pages[index]; - }); + final moved = ordered.removeAt(oldIndex); + ordered.insert(targetIndex, moved); - if (!hasPageChanged && !force) { - return strat; - } + final reindexed = [ + for (var i = 0; i < ordered.length; i++) + ordered[i].copyWith(sortIndex: i), + ]; - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); + final updated = + strat.copyWith(pages: reindexed, lastEdited: DateTime.now()); + await box.put(updated.id, updated); } - static StrategyData migrateToCurrentVersion(StrategyData strat, - {bool forceAbilityScale = false}) { - final worldMigrated = migrateToWorld16x9(strat); - final abilityScaleMigrated = - migrateAbilityScale(worldMigrated, force: forceAbilityScale); - final squareAoeMigrated = migrateSquareAoeCenter(abilityScaleMigrated); - return migrateCustomCircleWrapper(squareAoeMigrated); + // Add these inside StrategyProvider + Future setActivePageAnimated(String pageID, + {PageTransitionDirection? direction, + Duration duration = kPageTransitionDuration}) async { + await ref.read(strategyPageSessionProvider.notifier).setActivePageAnimated( + pageID, + direction: direction ?? PageTransitionDirection.forward, + duration: duration, + ); } - static Future migrateLegacyData(StrategyData strat) async { - // Already migrated - if (strat.pages.isNotEmpty) { - return migrateToCurrentVersion(strat); - } - if (strat.versionNumber > 15) { - return migrateToCurrentVersion(strat); - } - final originalVersion = strat.versionNumber; - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - final abilityData = [...strat.abilityData]; - if (strat.versionNumber < 7) { - for (final a in abilityData) { - if (a.data.abilityData! is SquareAbility) { - a.position = a.position.translate(0, -7.5); - } + Future addPage([String? name]) async { + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null) return; + final pages = [...snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final pageID = const Uuid().v4(); + final nextIndex = pages.length; + try { + await ConvexClient.instance.mutation(name: "pages:add", args: { + "strategyPublicId": state.strategyId, + "pagePublicId": pageID, + "name": name ?? "Page ${pages.length + 1}", + "sortIndex": nextIndex, + "isAttack": pages.isNotEmpty ? pages.last.isAttack : true, + "settings": ref.read(strategySettingsProvider.notifier).toJson(), + }); + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:pages_add', + error: error, + stackTrace: stackTrace, + ); + if (!handled) rethrow; + return; } + await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); + await ref + .read(strategyPageSessionProvider.notifier) + .setActivePage(pageID); + return; } - final firstPage = StrategyPage( + final box = Hive.box(HiveBoxNames.strategiesBox); + + // Flush current page so its edits are not lost + await _syncCurrentPageToHive(); + + final strategyId = state.strategyId; + if (strategyId == null) return; + final strat = box.get(strategyId); + if (strat == null) return; + + name ??= "Page ${strat.pages.length + 1}"; + //TODO Make this function of the index + final newPage = strat.pages.last.copyWith( id: const Uuid().v4(), - name: "Page 1", - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - drawingData: [...strat.drawingData], - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - agentData: [...strat.agentData], - abilityData: abilityData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - textData: [...strat.textData], - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - imageData: [...strat.imageData], - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - utilityData: [...strat.utilityData], - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - isAttack: strat.isAttack, - // ignore: deprecated_member_use_from_same_package - settings: strat.strategySettings, - sortIndex: 0, + name: name, + sortIndex: strat.pages.length, ); + // final newPage = StrategyPage( + // id: const Uuid().v4(), + // name: name, + // drawingData: , + // agentData: const [], + // abilityData: const [], + // textData: const [], + // imageData: const [], + // utilityData: const [], + // sortIndex: strat.pages.length, // corrected + // ); + final updated = strat.copyWith( - pages: [firstPage], - agentData: [], - abilityData: [], - drawingData: [], - utilityData: [], - textData: [], - versionNumber: Settings.versionNumber, + pages: [...strat.pages, newPage], lastEdited: DateTime.now(), ); + await box.put(updated.id, updated); - final worldMigrated = migrateToWorld16x9(updated, - force: originalVersion < Settings.versionNumber); - final abilityScaleMigrated = migrateAbilityScale( - worldMigrated, - force: originalVersion < AbilityScaleMigration.version, - ); - final squareAoeMigrated = migrateSquareAoeCenter( - abilityScaleMigrated, - force: originalVersion < SquareAoeCenterMigration.version, - ); - return migrateCustomCircleWrapper( - squareAoeMigrated, - force: originalVersion < CustomCircleWrapperMigration.version, - ); + await setActivePageAnimated(newPage.id); } - static StrategyData migrateToWorld16x9(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= 38) return strat; - - const double normalizedHeight = 1000.0; - const double mapAspectRatio = 1.24; - const double worldAspectRatio = 16 / 9; - const mapWidth = normalizedHeight * mapAspectRatio; - const worldWidth = normalizedHeight * worldAspectRatio; - const padding = (worldWidth - mapWidth) / 2; - - Offset shift(Offset offset) => offset.translate(padding, 0); - - List shiftAgentNodes(List agents) { - return [ - for (final agent in agents) - switch (agent) { - PlacedAgent() => agent.copyWith(position: shift(agent.position)) - ..isDeleted = agent.isDeleted, - PlacedViewConeAgent() => - agent.copyWith(position: shift(agent.position)) - ..isDeleted = agent.isDeleted, - PlacedCircleAgent() => - agent.copyWith(position: shift(agent.position)) - ..isDeleted = agent.isDeleted, - }, - ]; - } - - List shiftAbilities(List abilities) { - return [ - for (final ability in abilities) - ability.copyWith(position: shift(ability.position)) - ..isDeleted = ability.isDeleted - ]; - } - - List shiftTexts(List texts) { - return [ - for (final text in texts) - text.copyWith( - position: shift(text.position), - ) - ]; - } - - List shiftImages(List images) { - return [ - for (final image in images) - image.copyWith(position: shift(image.position)) - ..isDeleted = image.isDeleted - ]; - } - - List shiftUtilities(List utilities) { - return [ - for (final utility in utilities) - PlacedUtility( - type: utility.type, - position: shift(utility.position), - id: utility.id, - angle: utility.angle, - customDiameter: utility.customDiameter, - customWidth: utility.customWidth, - customLength: utility.customLength, - customColorValue: utility.customColorValue, - customOpacityPercent: utility.customOpacityPercent, - ) - ..rotation = utility.rotation - ..length = utility.length - ..isDeleted = utility.isDeleted - ]; - } - - List shiftLineUps(List lineUps) { - return [ - for (final lineUp in lineUps) - () { - final shiftedAgent = lineUp.agent.copyWith( - position: shift(lineUp.agent.position), - )..isDeleted = lineUp.agent.isDeleted; - final shiftedAbility = lineUp.ability.copyWith( - position: shift(lineUp.ability.position), - )..isDeleted = lineUp.ability.isDeleted; - return lineUp.copyWith( - agent: shiftedAgent, - ability: shiftedAbility, - ); - }() - ]; - } - - BoundingBox? shiftBoundingBox(BoundingBox? boundingBox) { - if (boundingBox == null) return null; - return BoundingBox( - min: shift(boundingBox.min), - max: shift(boundingBox.max), - ); + Future renamePage(String pageId, String newName) async { + final trimmed = newName.trim(); + if (trimmed.isEmpty) { + return; } - List shiftDrawings(List drawings) { - return drawings - .map((element) { - if (element is Line) { - return Line( - lineStart: shift(element.lineStart), - lineEnd: shift(element.lineEnd), - color: element.color, - thickness: element.thickness, - boundingBox: shiftBoundingBox(element.boundingBox), - isDotted: element.isDotted, - hasArrow: element.hasArrow, - id: element.id, - showTraversalTime: element.showTraversalTime, - traversalSpeedProfile: element.traversalSpeedProfile, - ); - } - if (element is FreeDrawing) { - final shiftedPoints = - element.listOfPoints.map(shift).toList(growable: false); - - return FreeDrawing( - listOfPoints: shiftedPoints, - color: element.color, - thickness: element.thickness, - boundingBox: shiftBoundingBox(element.boundingBox), - isDotted: element.isDotted, - hasArrow: element.hasArrow, - id: element.id, - showTraversalTime: element.showTraversalTime, - traversalSpeedProfile: element.traversalSpeedProfile, - ); - } - if (element is RectangleDrawing) { - return RectangleDrawing( - start: shift(element.start), - end: shift(element.end), - color: element.color, - thickness: element.thickness, - boundingBox: shiftBoundingBox(element.boundingBox), - isDotted: element.isDotted, - hasArrow: element.hasArrow, - id: element.id, - ); - } - return element; - }) - .cast() - .toList(growable: false); - } - - final updatedPages = strat.pages - .map((page) => page.copyWith( - sortIndex: page.sortIndex, - name: page.name, - id: page.id, - agentData: shiftAgentNodes(page.agentData), - abilityData: shiftAbilities(page.abilityData), - textData: shiftTexts(page.textData), - imageData: shiftImages(page.imageData), - utilityData: shiftUtilities(page.utilityData), - drawingData: shiftDrawings(page.drawingData), - lineUps: shiftLineUps(page.lineUps), - )) - .toList(growable: false); - - final migrated = strat.copyWith( - pages: updatedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); - - return migrated; - } - - // Switch active page: flush old page first, then hydrate new - Future setActivePage(String pageID) async { - if (pageID == activePageID) return; - - // Flush current before switching - await _syncCurrentPageToHive(); - - final box = Hive.box(HiveBoxNames.strategiesBox); - final doc = box.get(state.id); - if (doc == null) return; - - final page = doc.pages.firstWhere( - (p) => p.id == pageID, - orElse: () => doc.pages.first, - ); - - activePageID = page.id; - state = state.copyWith(activePageId: page.id); - - ref.read(actionProvider.notifier).resetActionState(); - final migrated = migrateToCurrentVersion(doc); - final migratedPage = migrated.pages.firstWhere( - (p) => p.id == page.id, - orElse: () => migrated.pages.first, - ); - if (migrated != doc) { - await box.put(migrated.id, migrated); - } - - ref.read(agentProvider.notifier).fromHive(migratedPage.agentData); - ref.read(abilityProvider.notifier).fromHive(migratedPage.abilityData); - ref.read(drawingProvider.notifier).fromHive(migratedPage.drawingData); - ref.read(textProvider.notifier).fromHive(migratedPage.textData); - ref.read(placedImageProvider.notifier).fromHive(migratedPage.imageData); - ref.read(utilityProvider.notifier).fromHive(migratedPage.utilityData); - ref.read(mapProvider.notifier).setAttack(migratedPage.isAttack); - ref.read(strategySettingsProvider.notifier).fromHive(migratedPage.settings); - ref.read(strategyThemeProvider.notifier).fromStrategy( - profileId: migrated.themeProfileId ?? - MapThemeProfilesProvider.immutableDefaultProfileId, - overridePalette: migrated.themeOverridePalette, - ); - ref.read(lineUpProvider.notifier).fromHive(migratedPage.lineUps); - - // Defer path rebuild until next frame (layout complete) - WidgetsBinding.instance.addPostFrameCallback((_) { - ref - .read(drawingProvider.notifier) - .rebuildAllPaths(CoordinateSystem.instance); - }); - } - - Future backwardPage() async { - if (activePageID == null) return; - - final box = Hive.box(HiveBoxNames.strategiesBox); - final doc = box.get(state.id); - if (doc == null || doc.pages.isEmpty) return; - - // Order pages by their sortIndex to find the "leading" (next) page. - final pages = [...doc.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - - final currentIndex = pages.indexWhere((p) => p.id == activePageID); - if (currentIndex == -1) return; - int nextIndex = currentIndex - 1; - if (nextIndex < 0) - nextIndex = pages.length - 1; // No forward page available. - - final nextPage = pages[nextIndex]; - await setActivePageAnimated( - nextPage.id, - direction: PageTransitionDirection.backward, - ); - } - - Future forwardPage() async { - if (activePageID == null) return; - - final box = Hive.box(HiveBoxNames.strategiesBox); - final doc = box.get(state.id); - if (doc == null || doc.pages.isEmpty) return; - - // Order pages by their sortIndex to find the "leading" (next) page. - final pages = [...doc.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - - final currentIndex = pages.indexWhere((p) => p.id == activePageID); - if (currentIndex == -1) return; - - int nextIndex = currentIndex + 1; - if (nextIndex >= pages.length) nextIndex = 0; // No forward page available. - - final nextPage = pages[nextIndex]; - await setActivePageAnimated( - nextPage.id, - direction: PageTransitionDirection.forward, - ); - } - - Future reorderPage(int oldIndex, int newIndex) async { - if (oldIndex == newIndex) return; - - final box = Hive.box(HiveBoxNames.strategiesBox); - final strat = box.get(state.id); - if (strat == null || strat.pages.isEmpty) return; - - // `oldIndex`/`newIndex` are list positions from the UI (ReorderableListView), - // not sortIndex values. We move the page and then reindex to keep a dense - // 0..N-1 ordering. - final ordered = [...strat.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - - if (oldIndex < 0 || - oldIndex >= ordered.length || - newIndex < 0 || - newIndex > ordered.length) { - return; - } - - // Flutter ReorderableListView reports `newIndex` as the target index in the - // list *after* the removal. When dragging down, we need to decrement. - var targetIndex = newIndex; - if (targetIndex > oldIndex) targetIndex -= 1; - - final moved = ordered.removeAt(oldIndex); - ordered.insert(targetIndex, moved); - - final reindexed = [ - for (var i = 0; i < ordered.length; i++) - ordered[i].copyWith(sortIndex: i), - ]; - - final updated = - strat.copyWith(pages: reindexed, lastEdited: DateTime.now()); - await box.put(updated.id, updated); - } - - PageTransitionDirection _resolveDirectionForPage( - String pageID, List orderedPages) { - if (activePageID == null) return PageTransitionDirection.forward; - - final currentIndex = orderedPages.indexWhere((p) => p.id == activePageID); - final targetIndex = orderedPages.indexWhere((p) => p.id == pageID); - if (currentIndex < 0 || targetIndex < 0) { - return PageTransitionDirection.forward; - } - - final length = orderedPages.length; - final forwardSteps = (targetIndex - currentIndex + length) % length; - final backwardSteps = (currentIndex - targetIndex + length) % length; - return forwardSteps <= backwardSteps - ? PageTransitionDirection.forward - : PageTransitionDirection.backward; - } - - // Add these inside StrategyProvider - Future setActivePageAnimated(String pageID, - {PageTransitionDirection? direction, - Duration duration = kPageTransitionDuration}) async { - if (pageID == activePageID) return; - - final transitionState = ref.read(transitionProvider); - final transitionNotifier = ref.read(transitionProvider.notifier); - if (transitionState.active || - transitionState.phase == PageTransitionPhase.preparing) { - transitionNotifier.complete(); - } - - final box = Hive.box(HiveBoxNames.strategiesBox); - final doc = box.get(state.id); - if (doc == null || doc.pages.isEmpty) return; - - final orderedPages = [...doc.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final resolvedDirection = - direction ?? _resolveDirectionForPage(pageID, orderedPages); - final startSettings = ref.read(strategySettingsProvider); - - final prev = _snapshotAllPlaced(); - transitionNotifier.prepare(prev.values.toList(), - direction: resolvedDirection, - startAgentSize: startSettings.agentSize, - startAbilitySize: startSettings.abilitySize); - - // Load target page (hydrates providers) - await setActivePage(pageID); - final endSettings = ref.read(strategySettingsProvider); - - // After layout, snapshot next and start transition - WidgetsBinding.instance.addPostFrameCallback((_) { - final next = _snapshotAllPlaced(); - final entries = _diffToTransitions(prev, next); - if (entries.isNotEmpty) { - transitionNotifier.start( - entries, - duration: duration, - direction: resolvedDirection, - startAgentSize: startSettings.agentSize, - endAgentSize: endSettings.agentSize, - startAbilitySize: startSettings.abilitySize, - endAbilitySize: endSettings.abilitySize, - ); - } else { - transitionNotifier.complete(); - } - }); - } - - Map _snapshotAllPlaced() { - final map = {}; - for (final a in ref.read(agentProvider)) map[a.id] = a; - for (final ab in ref.read(abilityProvider)) map[ab.id] = ab; - for (final t in ref.read(textProvider)) map[t.id] = t; - for (final img in ref.read(placedImageProvider).images) map[img.id] = img; - for (final u in ref.read(utilityProvider)) map[u.id] = u; - return map; - } - - List _diffToTransitions( - Map prev, - Map next, - ) { - final entries = []; - var order = 0; - - // Move / appear - next.forEach((id, to) { - final from = prev[id]; - if (from != null) { - if (from.position != to.position || - PageTransitionEntry.rotationOf(from) != - PageTransitionEntry.rotationOf(to) || - PageTransitionEntry.lengthOf(from) != - PageTransitionEntry.lengthOf(to) || - !listEquals( - PageTransitionEntry.armLengthsOf(from), - PageTransitionEntry.armLengthsOf(to), - ) || - PageTransitionEntry.scaleOf(from) != - PageTransitionEntry.scaleOf(to) || - PageTransitionEntry.textSizeOf(from) != - PageTransitionEntry.textSizeOf(to) || - PageTransitionEntry.agentStateOf(from) != - PageTransitionEntry.agentStateOf(to) || - PageTransitionEntry.customDiameterOf(from) != - PageTransitionEntry.customDiameterOf(to) || - PageTransitionEntry.customWidthOf(from) != - PageTransitionEntry.customWidthOf(to) || - PageTransitionEntry.customLengthOf(from) != - PageTransitionEntry.customLengthOf(to)) { - entries - .add(PageTransitionEntry.move(from: from, to: to, order: order)); - } else { - // Unchanged: include as 'none' so it stays visible while base view is hidden - entries.add(PageTransitionEntry.none(to: to, order: order)); - } - } else { - entries.add(PageTransitionEntry.appear(to: to, order: order)); - } - order++; - }); - - // Disappear - prev.forEach((id, from) { - if (!next.containsKey(id)) { - entries.add(PageTransitionEntry.disappear(from: from, order: order)); - order++; - } - }); - - return entries; - } - - Future addPage([String? name]) async { - final box = Hive.box(HiveBoxNames.strategiesBox); - - // Flush current page so its edits are not lost - await _syncCurrentPageToHive(); - - final strat = box.get(state.id); - if (strat == null) return; - - name ??= "Page ${strat.pages.length + 1}"; - //TODO Make this function of the index - final newPage = strat.pages.last.copyWith( - id: const Uuid().v4(), - name: name, - sortIndex: strat.pages.length, - ); - - // final newPage = StrategyPage( - // id: const Uuid().v4(), - // name: name, - // drawingData: , - // agentData: const [], - // abilityData: const [], - // textData: const [], - // imageData: const [], - // utilityData: const [], - // sortIndex: strat.pages.length, // corrected - // ); - - final updated = strat.copyWith( - pages: [...strat.pages, newPage], - lastEdited: DateTime.now(), - ); - await box.put(updated.id, updated); - - await setActivePageAnimated(newPage.id); - } - - Future loadFromHive(String id) async { - cancelPendingSave(); - final newStrat = Hive.box(HiveBoxNames.strategiesBox) - .values - .where((StrategyData strategy) { - return strategy.id == id; - }).firstOrNull; - - if (newStrat == null) { - return; - } - ref.read(actionProvider.notifier).resetActionState(); - - List pageImageData = []; - for (final page in newStrat.pages) { - pageImageData.addAll(page.imageData); - } - if (!kIsWeb) { - List allImageIds = []; - for (final page in newStrat.pages) { - allImageIds.addAll(page.imageData.map((image) => image.id)); - for (final lineUp in page.lineUps) { - List lineUpImages = []; - lineUpImages.addAll(lineUp.images.map((image) => image.id)); - allImageIds.addAll(lineUpImages); - } - } - await ref - .read(placedImageProvider.notifier) - .deleteUnusedImages(newStrat.id, allImageIds); - } - - // We clear previous data to avoid artifacts when loading a new strategy - final migratedStrategy = migrateToCurrentVersion(newStrat); - final page = migratedStrategy.pages.first; - - if (migratedStrategy != newStrat) { - await Hive.box(HiveBoxNames.strategiesBox) - .put(migratedStrategy.id, migratedStrategy); - } - - ref.read(agentProvider.notifier).fromHive(page.agentData); - ref.read(abilityProvider.notifier).fromHive(page.abilityData); - ref.read(drawingProvider.notifier).fromHive(page.drawingData); - - ref - .read(mapProvider.notifier) - .fromHive(migratedStrategy.mapData, page.isAttack); - ref.read(textProvider.notifier).fromHive(page.textData); - ref.read(placedImageProvider.notifier).fromHive(page.imageData); - ref.read(lineUpProvider.notifier).fromHive(page.lineUps); - ref.read(strategySettingsProvider.notifier).fromHive(page.settings); - ref.read(strategyThemeProvider.notifier).fromStrategy( - profileId: migratedStrategy.themeProfileId ?? - MapThemeProfilesProvider.immutableDefaultProfileId, - overridePalette: migratedStrategy.themeOverridePalette, - ); - ref.read(utilityProvider.notifier).fromHive(page.utilityData); - activePageID = page.id; - - if (kIsWeb) { - state = StrategyState( - isSaved: true, - stratName: migratedStrategy.name, - id: migratedStrategy.id, - storageDirectory: null, - activePageId: page.id, - ); - return; - } - final newDir = await setStorageDirectory(migratedStrategy.id); - - state = StrategyState( - isSaved: true, - stratName: migratedStrategy.name, - id: migratedStrategy.id, - storageDirectory: newDir.path, - activePageId: page.id, - ); - } - - Future loadFromFilePath(String filePath) async { - await _importStrategyFile( - file: XFile(filePath), - targetFolderId: null, - ); - } - - Future loadFromFilePicker() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( - allowMultiple: false, - type: FileType.custom, - allowedExtensions: ["ica"], - ); - - if (result == null) return; - - for (PlatformFile file in result.files) { - await _importStrategyFile( - file: file.xFile, - targetFolderId: null, - ); - } - } - - Future importBackupFromFilePicker() async { - final result = await FilePicker.platform.pickFiles( - allowMultiple: false, - type: FileType.custom, - allowedExtensions: ['zip'], - ); - - if (result == null || result.files.isEmpty) { - return const ImportBatchResult.empty(); - } - - final PlatformFile pickedFile = result.files.single; - final String? filePath = pickedFile.path; - - if (filePath == null || filePath.isEmpty) { - // FilePicker can return files without a valid path (e.g. in-memory bytes). - // In that case, return an empty result instead of throwing. - return const ImportBatchResult.empty(); - } - - return _importZipArchive( - zipFile: File(filePath), - parentFolderId: null, - ); - } - - Future loadFromFileDrop(List files) async { - final targetFolderId = ref.read(folderProvider); - var result = const ImportBatchResult.empty(); - - for (XFile file in files) { - result = result.merge( - await _importDroppedItem( - file: file, - targetFolderId: targetFolderId, - ), - ); - } - - return result; - } - - Future getTempDirectory(String strategyID) async { - String tempDirectoryPath; - try { - tempDirectoryPath = (await getTemporaryDirectory()).path; - } on MissingPluginException { - tempDirectoryPath = Directory.systemTemp.path; - } on MissingPlatformDirectoryException { - tempDirectoryPath = Directory.systemTemp.path; - } - - Directory tempDir = await Directory( - path.join(tempDirectoryPath, "xyz_icarus_strats", strategyID)) - .create(recursive: true); - return tempDir; - } - - Future cleanUpTempDirectory(String strategyID) async { - final tempDirectory = await getTempDirectory(strategyID); - await tempDirectory.delete(recursive: true); - } - - Future _getApplicationSupportDirectoryOrSystemTemp() async { - try { - return await getApplicationSupportDirectory(); - } on MissingPluginException { - return Directory.systemTemp; - } on MissingPlatformDirectoryException { - return Directory.systemTemp; - } - } - - Future _extractArchiveEntriesToDisk({ - required Archive archive, - required Directory destination, - }) async { - // Normalize the destination path once for comparisons. - final String destinationPath = path.normalize(destination.path); - - for (final entry in archive) { - final normalizedName = normalizeArchivePath(entry.name); - if (normalizedName.isEmpty) { - continue; - } - - // Reject absolute paths in the archive entry name. - if (path.isAbsolute(normalizedName)) { - continue; - } - - // Reject any entry that attempts directory traversal using "..". - final segments = path.posix.split(normalizedName); - if (segments.any((segment) => segment == '..')) { - continue; - } - - // Build the target path under the destination directory. - final targetPath = path.joinAll([ - destinationPath, - ...segments, - ]); - - // Normalize and verify that the target path stays within destination. - final normalizedTargetPath = path.normalize(targetPath); - final bool isWithinDestination = - path.isWithin(destinationPath, normalizedTargetPath) || - normalizedTargetPath == destinationPath; - if (!isWithinDestination) { - continue; - } - - if (entry.isFile) { - final targetFile = File(normalizedTargetPath); - await targetFile.parent.create(recursive: true); - await targetFile.writeAsBytes(entry.content as List); - } else { - await Directory(normalizedTargetPath).create(recursive: true); - } - } - } - - /// Returns true if the file is a ZIP (by checking the magic number) - Future isZipFile(File file) async { - // Read the first 4 bytes of the file - final raf = file.openSync(mode: FileMode.read); - final header = raf.readSync(4); - await raf.close(); - - // ZIP files start with 'PK\x03\x04' - return header.length == 4 && - header[0] == 0x50 && // 'P' - header[1] == 0x4B && // 'K' - header[2] == 0x03 && - header[3] == 0x04; - } - - Future _importDroppedItem({ - required XFile file, - required String? targetFolderId, - }) async { - if (file.path.isEmpty) { - return const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue(path: '', code: ImportIssueCode.ioError), - ], - ); - } - - try { - final entityType = - await FileSystemEntity.type(file.path, followLinks: false); - switch (entityType) { - case FileSystemEntityType.directory: - return await _importDirectoryTree( - sourceDir: Directory(file.path), - parentFolderId: targetFolderId, - ); - case FileSystemEntityType.file: - final extension = path.extension(file.path).toLowerCase(); - if (extension == '.ica') { - await _importStrategyFile( - file: file, - targetFolderId: targetFolderId, - ); - return const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ); - } - - if (await isZipFile(File(file.path))) { - return await _importZipArchive( - zipFile: File(file.path), - parentFolderId: targetFolderId, - ); - } - - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: file.path, - code: ImportIssueCode.unsupportedFile, - ), - ], - ); - case FileSystemEntityType.notFound: - case FileSystemEntityType.link: - case FileSystemEntityType.unixDomainSock: - case FileSystemEntityType.pipe: - default: - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: file.path, - code: ImportIssueCode.ioError, - ), - ], - ); - } - } on NewerVersionImportException { - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: file.path, - code: ImportIssueCode.newerVersion, - ), - ], - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import dropped item ${file.path}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importDroppedItem', - ); - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: file.path, - code: ImportIssueCode.ioError, - ), - ], - ); - } - } - - Future _createImportedFolder({ - required String name, - required String? parentFolderId, - }) { - return ref.read(folderProvider.notifier).createFolder( - name: name, - icon: Icons.drive_folder_upload, - color: FolderColor.generic, - parentID: parentFolderId, - ); - } - - List _sortedImportEntities( - Iterable entities, - ) { - final filtered = entities.where((entity) { - final basename = path.basename(entity.path); - return !_shouldIgnoreImportedEntityName(basename); - }).toList(); - filtered.sort((a, b) => a.path.compareTo(b.path)); - return filtered; - } - - bool _shouldIgnoreImportedEntityName(String name) { - return name.isEmpty || - name == '__MACOSX' || - name == '.DS_Store' || - name == archiveMetadataFileName || - name.startsWith('._'); - } - - bool _isIcaFileEntity(FileSystemEntity entity) { - return entity is File && - path.extension(entity.path).toLowerCase() == '.ica'; - } - - Future<_ImportEntityListing> _listImportEntities(Directory directory) async { - final issues = []; - try { - final entities = directory.listSync(followLinks: false); - return _ImportEntityListing( - entities: _sortedImportEntities(entities), - issues: issues, - ); - } on FileSystemException catch (error, stackTrace) { - final errorPath = _resolveImportErrorPath(error, directory.path); - _reportImportFailure( - 'Failed to list import directory $errorPath.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._listImportEntities', - ); - issues.add( - ImportIssue( - path: errorPath, - code: ImportIssueCode.ioError, - ), - ); - } - - return _ImportEntityListing( - entities: const [], - issues: issues, - ); - } - - String _resolveImportErrorPath(Object error, String fallbackPath) { - if (error is FileSystemException) { - return error.path ?? fallbackPath; - } - return fallbackPath; - } - - Future _importEntitiesIntoFolder({ - required Iterable entities, - required String parentFolderId, - }) async { - var result = const ImportBatchResult.empty(); - final sortedEntities = _sortedImportEntities(entities); - - for (final entity in sortedEntities) { - if (entity is Directory) { - result = result.merge( - await _importDirectoryTree( - sourceDir: entity, - parentFolderId: parentFolderId, - ), - ); - continue; - } - - if (_isIcaFileEntity(entity)) { - try { - await _importStrategyFile( - file: XFile(entity.path), - targetFolderId: parentFolderId, - ); - result = result.merge( - const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } on NewerVersionImportException { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: entity.path, - code: ImportIssueCode.newerVersion, - ), - ], - ), - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import strategy file ${entity.path}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importEntitiesIntoFolder', - ); - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: entity.path, - code: ImportIssueCode.invalidStrategy, - ), - ], - ), - ); - } - continue; - } - - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: entity.path, - code: ImportIssueCode.unsupportedFile, - ), - ], - ), - ); - } - - return result; - } - - Future _importDirectoryTree({ - required Directory sourceDir, - required String? parentFolderId, - }) async { - final manifestFile = - File(path.join(sourceDir.path, archiveMetadataFileName)); - _ManifestImportData? manifestData; - if (await manifestFile.exists()) { - try { - manifestData = await _loadManifestIfPresent(sourceDir); - if (manifestData != null) { - _validateArchiveManifest(manifestData); - } - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import manifest archive from ${sourceDir.path}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importDirectoryTree', - ); - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: manifestFile.path, - code: ImportIssueCode.invalidArchiveMetadata, - ), - ], - ).merge( - await _importDirectoryTreeLegacy( - sourceDir: sourceDir, - parentFolderId: parentFolderId, - ), - ); - } - } - - if (manifestData != null) { - return _importManifestArchive( - manifestData: manifestData, - parentFolderId: parentFolderId, - ); - } - - return _importDirectoryTreeLegacy( - sourceDir: sourceDir, - parentFolderId: parentFolderId, - ); - } - - Future _importDirectoryTreeLegacy({ - required Directory sourceDir, - required String? parentFolderId, - }) async { - final importedFolder = await _createImportedFolder( - name: path.basename(sourceDir.path), - parentFolderId: parentFolderId, - ); - - var result = const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ); - - final listing = await _listImportEntities(sourceDir); - if (listing.issues.isNotEmpty) { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: listing.issues, - ), - ); - } - - result = result.merge( - await _importEntitiesIntoFolder( - entities: listing.entities, - parentFolderId: importedFolder.id, - ), - ); - - return result; - } - - Future _importZipArchive({ - required File zipFile, - required String? parentFolderId, - }) async { - final archive = ZipDecoder().decodeBytes(await zipFile.readAsBytes()); - _ZipManifestData? manifestData; - try { - manifestData = _loadManifestFromArchive(archive); - if (manifestData != null) { - _validateArchiveManifestFromZip(manifestData); - } - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import manifest zip ${zipFile.path}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importZipArchive', - ); - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: zipFile.path, - code: ImportIssueCode.invalidArchiveMetadata, - ), - ], - ).merge( - await _importLegacyZipArchiveFromEntries( - archive: archive, - parentFolderId: parentFolderId, - zipFileName: path.basenameWithoutExtension(zipFile.path), - ), - ); - } - - if (manifestData != null) { - return _importManifestArchiveFromZip( - manifestData: manifestData, - parentFolderId: parentFolderId, - ); - } - - return _importLegacyZipArchiveFromEntries( - archive: archive, - parentFolderId: parentFolderId, - zipFileName: path.basenameWithoutExtension(zipFile.path), - ); - } - - _ZipManifestData? _loadManifestFromArchive(Archive archive) { - final filesByPath = {}; - for (final entry in archive) { - if (!entry.isFile) { - continue; - } - filesByPath[normalizeArchivePath(entry.name)] = entry; - } - - final manifestPaths = filesByPath.keys - .where((pathValue) => - path.posix.basename(pathValue) == archiveMetadataFileName) - .toList(growable: false); - if (manifestPaths.isEmpty) { - return null; - } - if (manifestPaths.length > 1) { - throw const FormatException('Archive contains multiple manifest files'); - } - - final manifestArchivePath = manifestPaths.single; - final manifestEntry = filesByPath[manifestArchivePath]!; - final decoded = jsonDecode(utf8.decode(_archiveFileBytes(manifestEntry))); - if (decoded is! Map) { - throw const FormatException('Archive manifest must decode to an object'); - } - - final rootPrefix = path.posix.dirname(manifestArchivePath); - return _ZipManifestData( - manifest: ArchiveManifest.fromJson(decoded), - rootPrefix: rootPrefix == '.' ? '' : rootPrefix, - filesByPath: filesByPath, - manifestArchivePath: manifestArchivePath, - ); - } - - List _archiveFileBytes(ArchiveFile entry) { - return entry.content as List; - } - - Future _writeArchiveEntryToTempFile({ - required ArchiveFile archiveFile, - required Directory tempDirectory, - }) async { - final baseName = path.basename(normalizeArchivePath(archiveFile.name)); - final file = File(path.join(tempDirectory.path, baseName)); - await file.parent.create(recursive: true); - await file.writeAsBytes(_archiveFileBytes(archiveFile)); - return file; - } - - Future _importManifestArchiveFromZip({ - required _ZipManifestData manifestData, - required String? parentFolderId, - }) async { - var result = const ImportBatchResult.empty(); - var profileIdRemap = const {}; - - if (manifestData.manifest.archiveType == ArchiveType.libraryBackup) { - final globals = manifestData.manifest.globals; - if (globals == null) { - throw const FormatException( - 'Library backup archive is missing globals'); - } - final globalImportResult = await _importArchiveGlobals(globals); - profileIdRemap = globalImportResult.profileIdRemap; - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: globalImportResult.themeProfilesImported, - globalStateRestored: globalImportResult.globalStateRestored, - issues: const [], - ), - ); - } - - final folderEntries = [...manifestData.manifest.folders]..sort((a, b) { - final depthCompare = _archivePathDepth(a.archivePath) - .compareTo(_archivePathDepth(b.archivePath)); - if (depthCompare != 0) { - return depthCompare; - } - return a.archivePath.compareTo(b.archivePath); - }); - - final localFolderIdsByManifestId = {}; - for (final folderEntry in folderEntries) { - final resolvedParentFolderId = folderEntry.parentManifestId == null - ? (manifestData.manifest.archiveType == ArchiveType.folderTree - ? parentFolderId - : null) - : localFolderIdsByManifestId[folderEntry.parentManifestId!]; - if (folderEntry.parentManifestId != null && - resolvedParentFolderId == null) { - throw FormatException( - 'Missing parent folder mapping for ${folderEntry.manifestId}', - ); - } - - final createdFolder = - await ref.read(folderProvider.notifier).createFolder( - name: folderEntry.name, - icon: folderEntry.icon.toIconData(), - color: folderEntry.color, - customColor: folderEntry.customColorValue == null - ? null - : Color(folderEntry.customColorValue!), - parentID: resolvedParentFolderId, - ); - localFolderIdsByManifestId[folderEntry.manifestId] = createdFolder.id; - result = result.merge( - const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } - - final materializedDirectory = - await Directory.systemTemp.createTemp('icarus-zip-manifest-import'); - try { - for (final strategyEntry in [...manifestData.manifest.strategies] - ..sort((a, b) => a.archivePath.compareTo(b.archivePath))) { - final targetFolderId = strategyEntry.folderManifestId == null - ? null - : localFolderIdsByManifestId[strategyEntry.folderManifestId!]; - final archivePath = _zipArchiveAbsolutePath( - rootPrefix: manifestData.rootPrefix, - relativePath: strategyEntry.archivePath, - ); - final archiveFile = manifestData.filesByPath[archivePath]; - if (archiveFile == null) { - throw FormatException('Missing strategy file: $archivePath'); - } - - try { - final tempFile = await _writeArchiveEntryToTempFile( - archiveFile: archiveFile, - tempDirectory: materializedDirectory, - ); - await _importStrategyFile( - file: XFile(tempFile.path), - targetFolderId: targetFolderId, - displayNameOverride: strategyEntry.name, - themeProfileIdRemap: profileIdRemap, - ); - result = result.merge( - const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } on NewerVersionImportException { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.newerVersion, - ), - ], - ), - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import manifest strategy $archivePath.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importManifestArchiveFromZip', - ); - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.invalidStrategy, - ), - ], - ), - ); - } - } - } finally { - try { - await materializedDirectory.delete(recursive: true); - } catch (_) {} - } - - final undeclaredIssues = _collectUndeclaredZipArchiveIssues(manifestData); - if (undeclaredIssues.isNotEmpty) { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: undeclaredIssues, - ), - ); - } - - return result; - } - - void _validateArchiveManifestFromZip(_ZipManifestData manifestData) { - final manifest = manifestData.manifest; - final folderIds = {}; - final folderPaths = {}; - final rootFolders = []; - - for (final folder in manifest.folders) { - if (!folderIds.add(folder.manifestId)) { - throw FormatException( - 'Duplicate folder manifest ID: ${folder.manifestId}'); - } - if (!folderPaths.add(folder.archivePath)) { - throw FormatException( - 'Duplicate folder archive path: ${folder.archivePath}'); - } - if (folder.parentManifestId == null) { - rootFolders.add(folder); - } else if (!manifest.folders.any( - (candidate) => candidate.manifestId == folder.parentManifestId)) { - throw FormatException('Missing parent folder for ${folder.manifestId}'); - } - } - - if (manifest.archiveType == ArchiveType.folderTree) { - if (rootFolders.length != 1) { - throw const FormatException( - 'Folder tree archives must contain one root'); - } - if (rootFolders.single.archivePath.isNotEmpty) { - throw const FormatException( - 'Folder tree root folder must use the manifest root path', - ); - } - } - - final knownFolderIds = - manifest.folders.map((folder) => folder.manifestId).toSet(); - final strategyPaths = {}; - for (final strategy in manifest.strategies) { - if (!strategyPaths.add(strategy.archivePath)) { - throw FormatException( - 'Duplicate strategy archive path: ${strategy.archivePath}', - ); - } - if (strategy.folderManifestId != null && - !knownFolderIds.contains(strategy.folderManifestId)) { - throw FormatException( - 'Unknown strategy folder reference: ${strategy.folderManifestId}', - ); - } - if (manifest.archiveType == ArchiveType.folderTree && - strategy.folderManifestId == null) { - throw const FormatException( - 'Folder tree strategies must reference the exported root folder', - ); - } - final archivePath = _zipArchiveAbsolutePath( - rootPrefix: manifestData.rootPrefix, - relativePath: strategy.archivePath, - ); - if (!manifestData.filesByPath.containsKey(archivePath)) { - throw FormatException('Missing strategy file: $archivePath'); - } - } - } - - String _zipArchiveAbsolutePath({ - required String rootPrefix, - required String relativePath, - }) { - return normalizeArchivePath( - rootPrefix.isEmpty - ? relativePath - : path.posix.join(rootPrefix, relativePath), - ); - } - - List _collectUndeclaredZipArchiveIssues( - _ZipManifestData manifestData, - ) { - final allowedFiles = {manifestData.manifestArchivePath}; - for (final strategy in manifestData.manifest.strategies) { - allowedFiles.add( - _zipArchiveAbsolutePath( - rootPrefix: manifestData.rootPrefix, - relativePath: strategy.archivePath, - ), - ); - } - - final issues = []; - for (final archivePath in manifestData.filesByPath.keys) { - if (!allowedFiles.contains(archivePath) && - !_shouldIgnoreImportedEntityName(path.posix.basename(archivePath))) { - issues.add( - ImportIssue( - path: archivePath, - code: ImportIssueCode.unsupportedFile, - ), - ); - } - } - return issues; - } - - Future _importLegacyZipArchiveFromEntries({ - required Archive archive, - required String? parentFolderId, - required String zipFileName, - }) async { - final filesByPath = {}; - for (final entry in archive) { - if (!entry.isFile) { - continue; - } - final normalizedPath = normalizeArchivePath(entry.name); - if (_shouldIgnoreImportedEntityName( - path.posix.basename(normalizedPath))) { - continue; - } - filesByPath[normalizedPath] = entry; - } - - final topLevelSegments = {}; - final looseTopLevelIca = []; - for (final archivePath in filesByPath.keys) { - final segments = archivePath.split('/'); - if (segments.isEmpty) { - continue; - } - topLevelSegments.add(segments.first); - if (segments.length == 1 && - path.extension(archivePath).toLowerCase() == '.ica') { - looseTopLevelIca.add(archivePath); - } - } - - if (topLevelSegments.length == 1 && looseTopLevelIca.isEmpty) { - return _importLegacyZipDirectory( - directoryPrefix: topLevelSegments.single, - filesByPath: filesByPath, - parentFolderId: parentFolderId, - ); - } - - final wrapperFolder = await _createImportedFolder( - name: zipFileName, - parentFolderId: parentFolderId, - ); - - return const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ).merge( - await _importLegacyZipEntitiesIntoFolder( - parentPrefix: '', - filesByPath: filesByPath, - parentFolderId: wrapperFolder.id, - ), - ); - } - - Future _importLegacyZipDirectory({ - required String directoryPrefix, - required Map filesByPath, - required String? parentFolderId, - }) async { - final importedFolder = await _createImportedFolder( - name: path.posix.basename(directoryPrefix), - parentFolderId: parentFolderId, - ); - - return const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ).merge( - await _importLegacyZipEntitiesIntoFolder( - parentPrefix: directoryPrefix, - filesByPath: filesByPath, - parentFolderId: importedFolder.id, - ), - ); - } - - Future _importLegacyZipEntitiesIntoFolder({ - required String parentPrefix, - required Map filesByPath, - required String parentFolderId, - }) async { - final directDirectories = {}; - final directFiles = []; - final normalizedParentPrefix = normalizeArchivePath(parentPrefix); - - for (final archivePath in filesByPath.keys) { - final parentPath = path.posix.dirname(archivePath); - if (normalizedParentPrefix.isEmpty) { - if (parentPath == '.') { - directFiles.add(archivePath); - } else if (!parentPath.contains('/')) { - directDirectories.add(parentPath); - } - continue; - } - - if (parentPath == normalizedParentPrefix) { - directFiles.add(archivePath); - continue; - } - - if (archivePath.startsWith('$normalizedParentPrefix/')) { - final remainder = - archivePath.substring(normalizedParentPrefix.length + 1); - if (remainder.isEmpty || !remainder.contains('/')) { - continue; - } - final childDirectory = remainder.substring(0, remainder.indexOf('/')); - directDirectories.add( - normalizeArchivePath( - path.posix.join(normalizedParentPrefix, childDirectory), - ), - ); - } - } - - var result = const ImportBatchResult.empty(); - - final tempDirectory = - await Directory.systemTemp.createTemp('icarus-zip-legacy-import'); - try { - final sortedDirectories = directDirectories.toList()..sort(); - for (final directoryPrefix in sortedDirectories) { - result = result.merge( - await _importLegacyZipDirectory( - directoryPrefix: directoryPrefix, - filesByPath: filesByPath, - parentFolderId: parentFolderId, - ), - ); - } - - directFiles.sort(); - for (final archivePath in directFiles) { - if (path.extension(archivePath).toLowerCase() != '.ica') { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.unsupportedFile, - ), - ], - ), - ); - continue; - } - - try { - final tempFile = await _writeArchiveEntryToTempFile( - archiveFile: filesByPath[archivePath]!, - tempDirectory: tempDirectory, - ); - await _importStrategyFile( - file: XFile(tempFile.path), - targetFolderId: parentFolderId, - ); - result = result.merge( - const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } on NewerVersionImportException { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.newerVersion, - ), - ], - ), - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import zip strategy $archivePath.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importLegacyZipEntitiesIntoFolder', - ); - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.invalidStrategy, - ), - ], - ), - ); - } - } - } finally { - try { - await tempDirectory.delete(recursive: true); - } catch (_) {} - } - - return result; - } - - Future<_ManifestImportData?> _loadManifestIfPresent( - Directory directory) async { - final manifestFile = - File(path.join(directory.path, archiveMetadataFileName)); - if (!await manifestFile.exists()) { - return null; - } - - final raw = await manifestFile.readAsString(); - final decoded = jsonDecode(raw); - if (decoded is! Map) { - throw const FormatException('Archive metadata must decode to an object'); - } - - return _ManifestImportData( - rootDirectory: directory, - manifestFile: manifestFile, - manifest: ArchiveManifest.fromJson(decoded), - ); - } - - Future _importManifestArchive({ - required _ManifestImportData manifestData, - required String? parentFolderId, - }) async { - _validateArchiveManifest(manifestData); - - var result = const ImportBatchResult.empty(); - var profileIdRemap = const {}; - - if (manifestData.manifest.archiveType == ArchiveType.libraryBackup) { - final globals = manifestData.manifest.globals; - if (globals == null) { - throw const FormatException( - 'Library backup archive is missing globals'); - } - final globalImportResult = await _importArchiveGlobals(globals); - profileIdRemap = globalImportResult.profileIdRemap; - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: globalImportResult.themeProfilesImported, - globalStateRestored: globalImportResult.globalStateRestored, - issues: const [], - ), - ); - } - - final folderEntries = [...manifestData.manifest.folders]..sort((a, b) { - final depthCompare = _archivePathDepth(a.archivePath) - .compareTo(_archivePathDepth(b.archivePath)); - if (depthCompare != 0) { - return depthCompare; - } - return a.archivePath.compareTo(b.archivePath); - }); - - final localFolderIdsByManifestId = {}; - for (final folderEntry in folderEntries) { - final resolvedParentFolderId = folderEntry.parentManifestId == null - ? (manifestData.manifest.archiveType == ArchiveType.folderTree - ? parentFolderId - : null) - : localFolderIdsByManifestId[folderEntry.parentManifestId!]; - if (folderEntry.parentManifestId != null && - resolvedParentFolderId == null) { - throw FormatException( - 'Missing parent folder mapping for ${folderEntry.manifestId}', - ); - } - - final createdFolder = - await ref.read(folderProvider.notifier).createFolder( - name: folderEntry.name, - icon: folderEntry.icon.toIconData(), - color: folderEntry.color, - customColor: folderEntry.customColorValue == null - ? null - : Color(folderEntry.customColorValue!), - parentID: resolvedParentFolderId, - ); - localFolderIdsByManifestId[folderEntry.manifestId] = createdFolder.id; - result = result.merge( - const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } - - final strategyEntries = [...manifestData.manifest.strategies] - ..sort((a, b) => a.archivePath.compareTo(b.archivePath)); - for (final strategyEntry in strategyEntries) { - final targetFolderId = strategyEntry.folderManifestId == null - ? null - : localFolderIdsByManifestId[strategyEntry.folderManifestId!]; - if (strategyEntry.folderManifestId != null && targetFolderId == null) { - throw FormatException( - 'Missing folder mapping for strategy ${strategyEntry.archivePath}', - ); - } - + if (_currentStrategyIsCloud()) { try { - await _importStrategyFile( - file: XFile( - _archivePathToFile( - manifestData.rootDirectory, strategyEntry.archivePath) - .path, - ), - targetFolderId: targetFolderId, - displayNameOverride: strategyEntry.name, - themeProfileIdRemap: profileIdRemap, - ); - result = result.merge( - const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } on NewerVersionImportException { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: strategyEntry.archivePath, - code: ImportIssueCode.newerVersion, - ), - ], - ), - ); + await ConvexClient.instance.mutation(name: "pages:rename", args: { + "strategyPublicId": state.strategyId, + "pagePublicId": pageId, + "name": trimmed, + }); } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import manifest strategy ${strategyEntry.archivePath}.', + final handled = await _reportCloudUnauthenticated( + source: 'strategy:pages_rename', error: error, stackTrace: stackTrace, - source: 'StrategyProvider._importManifestArchive', - ); - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: strategyEntry.archivePath, - code: ImportIssueCode.invalidStrategy, - ), - ], - ), - ); - } - } - - final undeclaredIssues = await _collectUndeclaredArchiveIssues( - manifestData: manifestData, - ); - if (undeclaredIssues.isNotEmpty) { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: undeclaredIssues, - ), - ); - } - - return result; - } - - void _validateArchiveManifest(_ManifestImportData manifestData) { - final manifest = manifestData.manifest; - final folderIds = {}; - final folderPaths = {}; - final rootFolders = []; - - for (final folder in manifest.folders) { - if (!folderIds.add(folder.manifestId)) { - throw FormatException( - 'Duplicate folder manifest ID: ${folder.manifestId}'); - } - if (!folderPaths.add(folder.archivePath)) { - throw FormatException( - 'Duplicate folder archive path: ${folder.archivePath}'); - } - if (folder.parentManifestId == null) { - rootFolders.add(folder); - } else if (!manifest.folders.any( - (candidate) => candidate.manifestId == folder.parentManifestId)) { - throw FormatException('Missing parent folder for ${folder.manifestId}'); - } - } - - if (manifest.archiveType == ArchiveType.folderTree) { - if (rootFolders.length != 1) { - throw const FormatException( - 'Folder tree archives must contain one root'); - } - if (rootFolders.single.archivePath.isNotEmpty) { - throw const FormatException( - 'Folder tree root folder must use the manifest root path', - ); - } - } - - final knownFolderIds = - manifest.folders.map((folder) => folder.manifestId).toSet(); - final strategyPaths = {}; - for (final strategy in manifest.strategies) { - if (!strategyPaths.add(strategy.archivePath)) { - throw FormatException( - 'Duplicate strategy archive path: ${strategy.archivePath}', - ); - } - if (strategy.folderManifestId != null && - !knownFolderIds.contains(strategy.folderManifestId)) { - throw FormatException( - 'Unknown strategy folder reference: ${strategy.folderManifestId}', ); - } - if (manifest.archiveType == ArchiveType.folderTree && - strategy.folderManifestId == null) { - throw const FormatException( - 'Folder tree strategies must reference the exported root folder', - ); - } - if (!(_archivePathToFile(manifestData.rootDirectory, strategy.archivePath) - .existsSync())) { - throw FormatException('Missing strategy file: ${strategy.archivePath}'); - } - } - } - - int _archivePathDepth(String archivePath) { - if (archivePath.isEmpty) { - return 0; - } - return archivePath.split('/').length; - } - - File _archivePathToFile(Directory rootDirectory, String archivePath) { - final normalized = normalizeArchivePath(archivePath); - final segments = - normalized.isEmpty ? const [] : normalized.split('/'); - return File(path.joinAll([rootDirectory.path, ...segments])); - } - - Future> _collectUndeclaredArchiveIssues({ - required _ManifestImportData manifestData, - }) async { - final allowedFiles = {archiveMetadataFileName}; - final allowedDirectories = {}; - - void addAllowedDirectoryAncestors(String archivePath) { - var current = normalizeArchivePath(archivePath); - if (current.isEmpty) { + if (!handled) rethrow; return; - } - while (current.isNotEmpty && current != '.') { - allowedDirectories.add(current); - final parent = path.posix.dirname(current); - if (parent == '.' || parent == current) { - break; - } - current = parent; - } - } - - for (final folder in manifestData.manifest.folders) { - addAllowedDirectoryAncestors(folder.archivePath); - } - for (final strategy in manifestData.manifest.strategies) { - final normalizedPath = normalizeArchivePath(strategy.archivePath); - allowedFiles.add(normalizedPath); - final parentDirectory = path.posix.dirname(normalizedPath); - if (parentDirectory != '.') { - addAllowedDirectoryAncestors(parentDirectory); - } - } - - final issues = []; - await for (final entity in manifestData.rootDirectory - .list(recursive: true, followLinks: false)) { - final relativePath = normalizeArchivePath( - path.relative(entity.path, from: manifestData.rootDirectory.path), - ); - if (relativePath.isEmpty) { - continue; - } - - if (entity is File) { - if (!allowedFiles.contains(relativePath)) { - issues.add( - ImportIssue( - path: entity.path, - code: ImportIssueCode.unsupportedFile, - ), - ); - } - continue; - } - - final directoryAllowed = allowedDirectories.contains(relativePath) || - allowedFiles.any((allowed) => allowed.startsWith('$relativePath/')); - if (!directoryAllowed) { - issues.add( - ImportIssue( - path: entity.path, - code: ImportIssueCode.unsupportedFile, - ), - ); - } - } - - return issues; - } - - Future<_GlobalImportResult> _importArchiveGlobals( - ArchiveGlobals globals) async { - await MapThemeProfilesProvider.bootstrap(); - - final profileBox = - Hive.box(HiveBoxNames.mapThemeProfilesBox); - final appPreferencesBox = - Hive.box(HiveBoxNames.appPreferencesBox); - final favoriteAgentsBox = Hive.box(HiveBoxNames.favoriteAgentsBox); - - final profileIdRemap = {}; - var themeProfilesImported = 0; - - final existingProfiles = profileBox.values.toList(); - for (final importedProfile in globals.themeProfiles) { - if (importedProfile.isBuiltIn) { - if (profileBox.get(importedProfile.id) != null) { - profileIdRemap[importedProfile.id] = importedProfile.id; - } - continue; - } - - final matchingExisting = existingProfiles.firstWhere( - (existing) => - !existing.isBuiltIn && - existing.name == importedProfile.name && - existing.palette == importedProfile.palette, - orElse: () => MapThemeProfile( - id: '', - name: '', - palette: MapThemeProfilesProvider.immutableDefaultPalette, - isBuiltIn: false, - ), - ); - - if (matchingExisting.id.isNotEmpty) { - profileIdRemap[importedProfile.id] = matchingExisting.id; - continue; - } - - var localProfileId = importedProfile.id; - if (profileBox.get(localProfileId) != null || - MapThemeProfilesProvider.immutableBuiltInProfiles - .any((profile) => profile.id == localProfileId)) { - localProfileId = const Uuid().v4(); - } - - final createdProfile = MapThemeProfile( - id: localProfileId, - name: importedProfile.name, - palette: importedProfile.palette, - isBuiltIn: false, - ); - await profileBox.put(createdProfile.id, createdProfile); - existingProfiles.add(createdProfile); - profileIdRemap[importedProfile.id] = createdProfile.id; - themeProfilesImported++; - } - - final resolvedDefaultProfileId = globals - .defaultThemeProfileIdForNewStrategies == - null - ? MapThemeProfilesProvider.immutableDefaultProfileId - : profileIdRemap[globals.defaultThemeProfileIdForNewStrategies!] ?? - (profileBox.get(globals.defaultThemeProfileIdForNewStrategies!) != - null - ? globals.defaultThemeProfileIdForNewStrategies! - : MapThemeProfilesProvider.immutableDefaultProfileId); - - await appPreferencesBox.put( - MapThemeProfilesProvider.appPreferencesSingletonKey, - (appPreferencesBox - .get(MapThemeProfilesProvider.appPreferencesSingletonKey) ?? - AppPreferences( - defaultThemeProfileIdForNewStrategies: - MapThemeProfilesProvider.immutableDefaultProfileId, - )) - .copyWith( - defaultThemeProfileIdForNewStrategies: resolvedDefaultProfileId, - ), - ); - - await favoriteAgentsBox.clear(); - for (final favorite in globals.favoriteAgentTypes()) { - await favoriteAgentsBox.put(favorite.name, true); + } + await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); + return; } - await ref.read(mapThemeProfilesProvider.notifier).refreshFromHive(); - await ref.read(appPreferencesProvider.notifier).refreshFromHive(); - ref.invalidate(favoriteAgentsProvider); + final strategyId = state.strategyId; + if (strategyId == null) return; + final box = Hive.box(HiveBoxNames.strategiesBox); + final strat = box.get(strategyId); + if (strat == null) return; - return _GlobalImportResult( - themeProfilesImported: themeProfilesImported, - globalStateRestored: true, - profileIdRemap: profileIdRemap, + final updatedPages = [ + for (final page in strat.pages) + if (page.id == pageId) page.copyWith(name: trimmed) else page, + ]; + await box.put( + strat.id, + strat.copyWith(pages: updatedPages, lastEdited: DateTime.now()), ); } - Future _importStrategyFile({ - required XFile file, - required String? targetFolderId, - String? displayNameOverride, - Map themeProfileIdRemap = const {}, - }) async { - final newID = const Uuid().v4(); - final bool isZip = await isZipFile(File(file.path)); - - log("Is ZIP file: $isZip"); - final bytes = await file.readAsBytes(); - String jsonData = ""; - - try { - if (isZip) { - // Decode the Zip file - final archive = ZipDecoder().decodeBytes(bytes); - - final imageFolder = await PlacedImageProvider.getImageFolder(newID); - final tempDirectory = await getTempDirectory(newID); + Future deletePage(String pageId) async { + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.pages.length <= 1) { + return; + } + final pages = [...snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final activePageId = ref.read(strategyPageSessionProvider).activePageId ?? + pages.first.publicId; + final remaining = + pages.where((page) => page.publicId != pageId).toList(growable: false); + final nextActivePageId = activePageId == pageId && remaining.isNotEmpty + ? remaining.first.publicId + : activePageId; + + if (activePageId == pageId) { + await ref.read(strategyPageSessionProvider.notifier).flushCurrentPage( + flushImmediately: true, + ); + } - await _extractArchiveEntriesToDisk( - archive: archive, - destination: tempDirectory, + try { + await ConvexClient.instance.mutation(name: "pages:delete", args: { + "strategyPublicId": state.strategyId, + "pagePublicId": pageId, + }); + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:pages_delete', + error: error, + stackTrace: stackTrace, ); - - final tempDirectoryList = tempDirectory.listSync(); - log("Temp directory list: ${tempDirectoryList.length}."); - - for (final fileEntity in tempDirectoryList) { - if (fileEntity is File) { - log(fileEntity.path); - if (path.extension(fileEntity.path) == ".json") { - log("Found JSON file"); - jsonData = await fileEntity.readAsString(); - } else if (path.extension(fileEntity.path) != ".ica") { - final fileName = path.basename(fileEntity.path); - await fileEntity.copy(path.join(imageFolder.path, fileName)); - } - } - } - if (jsonData.isEmpty) { - throw Exception("No .ica file found"); - } - } else { - jsonData = await file.readAsString(); + if (!handled) rethrow; + return; } - Map json = jsonDecode(jsonData); - final versionNumber = int.tryParse(json["versionNumber"].toString()) ?? - Settings.versionNumber; - _throwIfImportedVersionIsTooNew(versionNumber); - - //Backwards compatibility for pre-pages exported strategies - final List drawingData = - DrawingProvider.fromJson(jsonEncode(json["drawingData"] ?? [])); - - final List agentData = - AgentProvider.fromJson(jsonEncode(json["agentData"] ?? [])) - .whereType() - .toList(growable: false); - - final List abilityData = - AbilityProvider.fromJson(jsonEncode(json["abilityData"] ?? [])); - - final mapData = MapProvider.fromJson(jsonEncode(json["mapData"])); - final textData = - TextProvider.fromJson(jsonEncode(json["textData"] ?? [])); - - List imageData = []; - if (!kIsWeb) { - if (isZip) { - imageData = await PlacedImageProvider.fromJson( - jsonString: jsonEncode(json["imageData"] ?? []), - strategyID: newID); - } else { - log('Legacy image data loading'); - imageData = await PlacedImageProvider.legacyFromJson( - jsonString: jsonEncode(json["imageData"] ?? []), - strategyID: newID); - } + await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); + if (nextActivePageId != activePageId) { + await ref + .read(strategyPageSessionProvider.notifier) + .setActivePage(nextActivePageId); } + return; + } - final StrategySettings settingsData; - final bool isAttack; - final List utilityData; + final strategyId = state.strategyId; + if (strategyId == null) return; + final box = Hive.box(HiveBoxNames.strategiesBox); + final strat = box.get(strategyId); + if (strat == null || strat.pages.length <= 1) return; - if (json["settingsData"] != null) { - settingsData = ref - .read(strategySettingsProvider.notifier) - .fromJson(jsonEncode(json["settingsData"])); - } else { - settingsData = StrategySettings(); - } + final remaining = [...strat.pages]..removeWhere((page) => page.id == pageId); + final reindexed = [ + for (var i = 0; i < remaining.length; i++) + remaining[i].copyWith(sortIndex: i), + ]; + final activePageId = ref.read(strategyPageSessionProvider).activePageId; + final nextActivePageId = + activePageId == pageId ? reindexed.first.id : activePageId; - if (json["isAttack"] != null) { - isAttack = json["isAttack"] == "true" ? true : false; - } else { - isAttack = true; - } + await box.put( + strat.id, + strat.copyWith(pages: reindexed, lastEdited: DateTime.now()), + ); + if (nextActivePageId != null && nextActivePageId != activePageId) { + await ref.read(strategyProvider.notifier).setActivePageAnimated( + nextActivePageId, + ); + } + } - if (json["utilityData"] != null) { - utilityData = UtilityProvider.fromJson(jsonEncode(json["utilityData"])); - } else { - utilityData = []; - } - final MapThemePalette? importedThemeOverridePalette = - json["themePalette"] is Map - ? MapThemePalette.fromJson(json["themePalette"]) - : (json["themePalette"] is Map - ? MapThemePalette.fromJson( - Map.from(json["themePalette"])) - : null); - final rawImportedThemeProfileId = json['themeProfileId']; - final importedThemeProfileId = rawImportedThemeProfileId is String && - rawImportedThemeProfileId.isNotEmpty - ? rawImportedThemeProfileId - : null; - final String? resolvedThemeProfileId = importedThemeProfileId == null - ? null - : (themeProfileIdRemap[importedThemeProfileId] ?? - importedThemeProfileId); - - // bool needsMigration = (versionNumber < 15); - final List pages = json["pages"] != null - ? await StrategyPage.listFromJson( - json: jsonEncode(json["pages"]), - strategyID: newID, - isZip: isZip, - ) - : []; - - StrategyData newStrategy = StrategyData( - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - drawingData: drawingData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - agentData: agentData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - abilityData: abilityData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - textData: textData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - imageData: imageData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - utilityData: utilityData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - isAttack: isAttack, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - strategySettings: settingsData, - - pages: pages, - id: newID, - name: displayNameOverride ?? path.basenameWithoutExtension(file.name), - mapData: mapData, - versionNumber: versionNumber, - lastEdited: DateTime.now(), - - folderID: targetFolderId, - themeProfileId: resolvedThemeProfileId, - themeOverridePalette: resolvedThemeProfileId == null - ? importedThemeOverridePalette - : null, - ); - - newStrategy = await migrateLegacyData(newStrategy); + Future loadFromHive(String id) async { + cancelPendingSave(); + final newStrat = Hive.box(HiveBoxNames.strategiesBox) + .values + .where((StrategyData strategy) { + return strategy.id == id; + }).firstOrNull; - await Hive.box(HiveBoxNames.strategiesBox) - .put(newStrategy.id, newStrategy); - } finally { - if (isZip) { - try { - await cleanUpTempDirectory(newID); - } catch (_) {} - } + if (newStrat == null) { + return; } - } + ref.read(actionProvider.notifier).resetActionState(); - static bool isNewerVersionImportError(Object error) { - return error is NewerVersionImportException; - } + List pageImageData = []; + for (final page in newStrat.pages) { + pageImageData.addAll(page.imageData); + } + if (!kIsWeb) { + List allImageIds = []; + for (final page in newStrat.pages) { + allImageIds.addAll(page.imageData.map((image) => image.id)); + for (final lineUp in page.lineUps) { + List lineUpImages = []; + lineUpImages.addAll(lineUp.images.map((image) => image.id)); + allImageIds.addAll(lineUpImages); + } + } + await ref + .read(placedImageProvider.notifier) + .deleteUnusedImages(newStrat.id, allImageIds); + } - @visibleForTesting - static void throwIfImportedVersionIsTooNewForTest(int importedVersion) { - _throwIfImportedVersionIsTooNew(importedVersion); - } + // We clear previous data to avoid artifacts when loading a new strategy + final migratedStrategy = StrategyMigrator.migrateToCurrentVersion(newStrat); - static void _throwIfImportedVersionIsTooNew(int importedVersion) { - if (importedVersion <= Settings.versionNumber) { - return; + if (migratedStrategy != newStrat) { + await Hive.box(HiveBoxNames.strategiesBox) + .put(migratedStrategy.id, migratedStrategy); } - throw NewerVersionImportException( - importedVersion: importedVersion, - currentVersion: Settings.versionNumber, + final newDir = + kIsWeb ? null : await setStorageDirectory(migratedStrategy.id); + state = StrategyState( + strategyId: migratedStrategy.id, + strategyName: migratedStrategy.name, + source: StrategySource.local, + storageDirectory: newDir?.path, + isOpen: true, ); + ref.read(strategySaveStateProvider.notifier).reset(); + await ref.read(strategyPageSessionProvider.notifier).initializeForStrategy( + strategyId: migratedStrategy.id, + source: StrategySource.local, + selectFirstPageIfNeeded: true, + ); + ref.read(strategySaveStateProvider.notifier).markPersisted(); } Future createNewStrategy(String name) async { + if (_selectedWorkspaceIsCloud()) { + final newID = const Uuid().v4(); + final pageID = const Uuid().v4(); + final defaultThemeProfileId = + ref.read(mapThemeProfilesProvider).defaultProfileIdForNewStrategies; + try { + await ref.read(convexStrategyRepositoryProvider).createStrategy( + publicId: newID, + name: name, + mapData: Maps.mapNames[MapValue.ascent] ?? "ascent", + 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', + error: error, + stackTrace: stackTrace, + ); + if (handled) { + throw StateError('Cloud authentication required to create strategy.'); + } + rethrow; + } + ref.invalidate(cloudStrategiesProvider); + ref.invalidate(cloudFoldersProvider); + await openCloudStrategy(newID); + return newID; + } final newID = const Uuid().v4(); final pageID = const Uuid().v4(); final defaultThemeProfileId = @@ -3024,439 +735,159 @@ class StrategyProvider extends Notifier { setUnsaved(); } - Future _flushCurrentStrategyIfNeeded() async { - if (state.stratName == null || state.id == 'testID') { - return; - } - await forceSaveNow(state.id); - } - - Future exportFolder(String folderID) async { - final folder = Hive.box(HiveBoxNames.foldersBox).get(folderID); - if (folder == null) { - log("Couldn't find folder to export"); - return; - } - - await _flushCurrentStrategyIfNeeded(); - final stagingDirectory = await buildFolderExportDirectoryForTest(folderID); - - try { - final outputFile = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: "${sanitizeFileName(folder.name)}.zip", - allowedExtensions: ['zip'], - ); - - if (outputFile == null) return; - - final encoder = ZipFileEncoder(); - encoder.create(outputFile); - await encoder.addDirectory(stagingDirectory, includeDirName: false); - await encoder.close(); - } finally { - try { - await stagingDirectory.delete(recursive: true); - } catch (_) {} - } - } - - Future exportLibrary() async { - await _flushCurrentStrategyIfNeeded(); - final stagingDirectory = await buildLibraryExportDirectoryForTest(); - - try { - final outputFile = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: _buildLibraryBackupFileName(DateTime.now()), - allowedExtensions: ['zip'], - ); - - if (outputFile == null) return; - - final encoder = ZipFileEncoder(); - encoder.create(outputFile); - await encoder.addDirectory(stagingDirectory, includeDirName: false); - await encoder.close(); - } finally { - try { - await stagingDirectory.delete(recursive: true); - } catch (_) {} - } - } - - @visibleForTesting - Future buildFolderExportDirectoryForTest(String folderID) async { - final folder = Hive.box(HiveBoxNames.foldersBox).get(folderID); - if (folder == null) { - throw StateError("Couldn't find folder to export"); - } - - final stagingDirectory = - await Directory.systemTemp.createTemp('icarus-folder-export'); - final rootDirectory = await _createUniqueChildDirectory( - parentDirectory: stagingDirectory, - desiredName: folder.name, - ); - final exportState = _ArchiveExportState(rootDirectory: rootDirectory); - await _writeFolderArchive( - folderID: folderID, - exportDirectory: rootDirectory, - exportState: exportState, - parentManifestId: null, - currentArchivePath: '', - ); - await _writeArchiveManifest( - exportState: exportState, - archiveType: ArchiveType.folderTree, - ); - return stagingDirectory; - } - - @visibleForTesting - Future buildLibraryExportDirectoryForTest() async { - final stagingDirectory = - await Directory.systemTemp.createTemp('icarus-library-export'); - final rootDirectory = Directory( - path.join(stagingDirectory.path, libraryBackupRootDirectoryName), - ); - await rootDirectory.create(recursive: true); - final rootStrategiesDirectory = - Directory(path.join(rootDirectory.path, 'root_strategies')) - ..createSync(recursive: true); - final foldersDirectory = Directory(path.join(rootDirectory.path, 'folders')) - ..createSync(recursive: true); - - final exportState = _ArchiveExportState(rootDirectory: rootDirectory); - - for (final strategy in _sortedStrategiesForFolder(null)) { - final strategyArchivePath = await zipStrategy( - id: strategy.id, - saveDir: rootStrategiesDirectory, - ); - exportState.strategies.add( - ArchiveStrategyEntry( - name: strategy.name, - archivePath: normalizeArchivePath(path.posix.join( - 'root_strategies', - path.basename(strategyArchivePath), - )), - folderManifestId: null, - ), - ); - } - - for (final rootFolder in _sortedFoldersForParent(null)) { - final rootFolderDirectory = await _createUniqueChildDirectory( - parentDirectory: foldersDirectory, - desiredName: rootFolder.name, - ); - final rootArchivePath = normalizeArchivePath(path.posix.join( - 'folders', - path.basename(rootFolderDirectory.path), - )); - await _writeFolderArchive( - folderID: rootFolder.id, - exportDirectory: rootFolderDirectory, - exportState: exportState, - parentManifestId: null, - currentArchivePath: rootArchivePath, - ); - } - - await _writeArchiveManifest( - exportState: exportState, - archiveType: ArchiveType.libraryBackup, - globals: _buildLibraryGlobals(), - ); - return stagingDirectory; - } - - Future _createUniqueChildDirectory({ - required Directory parentDirectory, - required String desiredName, - }) async { - final sanitizedName = sanitizeFileName(desiredName); - var candidate = sanitizedName; - var counter = 1; - var directory = Directory(path.join(parentDirectory.path, candidate)); - while (await directory.exists()) { - candidate = '${sanitizedName}_$counter'; - counter++; - directory = Directory(path.join(parentDirectory.path, candidate)); - } - await directory.create(recursive: true); - return directory; - } - - Future _writeFolderArchive({ - required String folderID, - required Directory exportDirectory, - required _ArchiveExportState exportState, - required String? parentManifestId, - required String currentArchivePath, - }) async { - final currentFolder = - ref.read(folderProvider.notifier).findFolderByID(folderID); - if (currentFolder == null) { - return; - } - - final manifestId = const Uuid().v4(); - exportState.folders.add( - ArchiveFolderEntry( - manifestId: manifestId, - name: currentFolder.name, - parentManifestId: parentManifestId, - archivePath: normalizeArchivePath(currentArchivePath), - icon: ArchiveIconDescriptor.fromIconData(currentFolder.icon), - color: currentFolder.color, - customColorValue: currentFolder.customColor?.toARGB32(), - ), - ); - - for (final strategy in _sortedStrategiesForFolder(folderID)) { - final strategyArchivePath = await zipStrategy( - id: strategy.id, - saveDir: exportDirectory, - ); - exportState.strategies.add( - ArchiveStrategyEntry( - name: strategy.name, - archivePath: normalizeArchivePath(path.posix.join( - currentArchivePath, - path.basename(strategyArchivePath), - )), - folderManifestId: manifestId, - ), - ); - } - - for (final subFolder in _sortedFoldersForParent(folderID)) { - final childDirectory = await _createUniqueChildDirectory( - parentDirectory: exportDirectory, - desiredName: subFolder.name, - ); - final childArchivePath = normalizeArchivePath(path.posix.join( - currentArchivePath, - path.basename(childDirectory.path), - )); - await _writeFolderArchive( - folderID: subFolder.id, - exportDirectory: childDirectory, - exportState: exportState, - parentManifestId: manifestId, - currentArchivePath: childArchivePath, - ); - } - } - - static String sanitizeFileName(String input) { - final sanitized = input.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); - return sanitized.isEmpty ? 'untitled' : sanitized; - } - - String _buildLibraryBackupFileName(DateTime timestamp) { - String twoDigit(int value) => value.toString().padLeft(2, '0'); - return 'icarus-library-backup-' - '${timestamp.year}-${twoDigit(timestamp.month)}-${twoDigit(timestamp.day)}_' - '${twoDigit(timestamp.hour)}-${twoDigit(timestamp.minute)}-${twoDigit(timestamp.second)}.zip'; - } - - List _sortedStrategiesForFolder(String? folderID) { - final strategies = Hive.box(HiveBoxNames.strategiesBox) - .values - .where((strategy) => strategy.folderID == folderID) - .toList(); - strategies.sort((a, b) { - final nameCompare = a.name.compareTo(b.name); - if (nameCompare != 0) { - return nameCompare; - } - return a.id.compareTo(b.id); - }); - return strategies; - } - - List _sortedFoldersForParent(String? parentID) { - final folders = Hive.box(HiveBoxNames.foldersBox) - .values - .where((folder) => folder.parentID == parentID) - .toList(); - folders.sort((a, b) { - final nameCompare = a.name.compareTo(b.name); - if (nameCompare != 0) { - return nameCompare; - } - return a.id.compareTo(b.id); - }); - return folders; - } - - ArchiveGlobals _buildLibraryGlobals() { - final profiles = Hive.box(HiveBoxNames.mapThemeProfilesBox) - .values - .map( - (profile) => ArchiveThemeProfileEntry( - id: profile.id, - name: profile.name, - palette: profile.palette, - isBuiltIn: profile.isBuiltIn, - ), - ) - .toList(growable: false); - final appPreferences = - Hive.box(HiveBoxNames.appPreferencesBox) - .get(MapThemeProfilesProvider.appPreferencesSingletonKey); - final favoriteAgents = Hive.box(HiveBoxNames.favoriteAgentsBox) - .keys - .whereType() - .toList() - ..sort(); - - return ArchiveGlobals( - themeProfiles: profiles, - defaultThemeProfileIdForNewStrategies: - appPreferences?.defaultThemeProfileIdForNewStrategies, - favoriteAgents: favoriteAgents, - ); - } - - Future _writeArchiveManifest({ - required _ArchiveExportState exportState, - required ArchiveType archiveType, - ArchiveGlobals? globals, - }) async { - final manifest = ArchiveManifest( - schemaVersion: archiveManifestSchemaVersion, - archiveType: archiveType, - exportedAt: DateTime.now().toUtc(), - appVersionNumber: Settings.versionNumber, - folders: exportState.folders, - strategies: exportState.strategies, - globals: globals, - ); - - final manifestFile = File( - path.join(exportState.rootDirectory.path, archiveMetadataFileName)); - await manifestFile.writeAsString( - const JsonEncoder.withIndent(' ').convert(manifest.toJson()), - ); - } - - MapThemePalette _resolveThemePaletteForExport(StrategyData strategy) { - if (strategy.themeOverridePalette != null) { - return strategy.themeOverridePalette!; - } - - final profiles = - Hive.box(HiveBoxNames.mapThemeProfilesBox); - final assignedProfile = strategy.themeProfileId == null - ? null - : profiles.get(strategy.themeProfileId!); - if (assignedProfile != null) { - return assignedProfile.palette; - } - - return MapThemeProfilesProvider.immutableDefaultPalette; - } - - Future zipStrategy({ - required String id, - Directory? saveDir, - String? outputFilePath, + Future renameStrategy( + String strategyID, + String newName, { + StrategySource? source, }) async { - final strategy = Hive.box(HiveBoxNames.strategiesBox).get(id); - if (strategy == null) { - log("Couldn't find strategy to export"); - throw StateError("Couldn't find strategy to export"); - } - - final payload = { - "versionNumber": "${Settings.versionNumber}", - "mapData": "${Maps.mapNames[strategy.mapData]}", - "themePalette": _resolveThemePaletteForExport(strategy).toJson(), - if (strategy.themeProfileId != null) - "themeProfileId": strategy.themeProfileId, - "pages": strategy.pages.map((page) => page.toJson(strategy.id)).toList(), - }; - final data = jsonEncode(payload); - - final sanitizedStrategyName = sanitizeFileName(strategy.name); - - late final String outPath; - late final String archiveBase; - if (outputFilePath != null) { - outPath = outputFilePath; - archiveBase = path.basenameWithoutExtension(outPath); - } else { - final base = sanitizedStrategyName; - var candidate = base; - var index = 1; - while (File(path.join(saveDir!.path, "$candidate.ica")).existsSync()) { - candidate = "${base}_$index"; - index++; + final resolvedSource = source ?? _resolveLibraryMutationSource(); + if (resolvedSource == StrategySource.cloud) { + try { + await ConvexClient.instance.mutation(name: "strategies:update", args: { + "strategyPublicId": strategyID, + "name": newName, + }); + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:rename', + error: error, + stackTrace: stackTrace, + ); + if (!handled) rethrow; + return; } - archiveBase = candidate; - outPath = path.join(saveDir.path, "$archiveBase.ica"); - } - - final jsonArchiveFile = - ArchiveFile.bytes("$archiveBase.json", utf8.encode(data)); - - final zipEncoder = ZipFileEncoder()..create(outPath); - - final supportDirectory = - await _getApplicationSupportDirectoryOrSystemTemp(); - final customDirectory = - Directory(path.join(supportDirectory.path, strategy.id)); - final imagesDirectory = - Directory(path.join(customDirectory.path, 'images')); - await imagesDirectory.create(recursive: true); - - await for (final entity in imagesDirectory.list()) { - if (entity is File) { - await zipEncoder.addFile(entity); + if (state.strategyId == strategyID && + state.source == StrategySource.cloud) { + await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); + } else { + ref.invalidate(cloudStrategiesProvider); } + return; } - zipEncoder.addArchiveFile(jsonArchiveFile); - await zipEncoder.close(); - return outPath; - } - - Future exportFile(String id) async { - await forceSaveNow(id); - - final outputFile = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: "${sanitizeFileName(state.stratName ?? "new strategy")}.ica", - allowedExtensions: ["ica"], - ); - - if (outputFile == null) return; - await zipStrategy(id: id, outputFilePath: outputFile); - } - - Future renameStrategy(String strategyID, String newName) async { final strategyBox = Hive.box(HiveBoxNames.strategiesBox); final strategy = strategyBox.get(strategyID); if (strategy != null) { strategy.name = newName; await strategy.save(); - if (state.id == strategyID) { - state = state.copyWith(stratName: newName); + if (state.strategyId == strategyID) { + state = state.copyWith(strategyName: newName); } } else { log("Strategy with ID $strategyID not found."); } } - Future duplicateStrategy(String strategyID) async { + Future duplicateStrategy( + String strategyID, { + StrategySource? source, + }) async { + final resolvedSource = source ?? _resolveLibraryMutationSource(); + if (resolvedSource == StrategySource.cloud) { + try { + final snapshot = await ref + .read(convexStrategyRepositoryProvider) + .fetchSnapshot(strategyID); + final newStrategyID = const Uuid().v4(); + await ref.read(convexStrategyRepositoryProvider).createStrategy( + publicId: newStrategyID, + name: "${snapshot.header.name} (Copy)", + mapData: snapshot.header.mapData, + 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) { + final newPageId = const Uuid().v4(); + pageIdMap[page.publicId] = newPageId; + await ConvexClient.instance.mutation(name: "pages:add", args: { + "strategyPublicId": newStrategyID, + "pagePublicId": newPageId, + "name": page.name, + "sortIndex": page.sortIndex, + "isAttack": page.isAttack, + if (page.settings != null) "settings": page.settings, + }); + } + + final ops = []; + for (final page in pages) { + final newPageId = pageIdMap[page.publicId]; + if (newPageId == null) continue; + + final elements = snapshot.elementsByPage[page.publicId] ?? const []; + for (final element in elements) { + if (element.deleted) continue; + final payloadMap = element.decodedPayload(); + payloadMap.putIfAbsent("elementType", () => element.elementType); + final newElementId = const Uuid().v4(); + payloadMap["id"] = newElementId; + ops.add(StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.element, + entityPublicId: newElementId, + pagePublicId: newPageId, + payload: jsonEncode(payloadMap), + sortIndex: element.sortIndex, + )); + } + + final lineups = snapshot.lineupsByPage[page.publicId] ?? const []; + for (final lineup in lineups) { + if (lineup.deleted) continue; + final newLineupId = const Uuid().v4(); + String lineupPayload = lineup.payload; + try { + final decoded = jsonDecode(lineup.payload); + if (decoded is Map) { + final payload = Map.from(decoded) + ..["id"] = newLineupId; + lineupPayload = jsonEncode(payload); + } else if (decoded is Map) { + final payload = Map.from(decoded) + ..["id"] = newLineupId; + lineupPayload = jsonEncode(payload); + } + } catch (_) {} + ops.add(StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.lineup, + entityPublicId: newLineupId, + pagePublicId: newPageId, + payload: lineupPayload, + sortIndex: lineup.sortIndex, + )); + } + } + + if (ops.isNotEmpty) { + await ref.read(convexStrategyRepositoryProvider).applyBatch( + strategyPublicId: newStrategyID, + clientId: const Uuid().v4(), + ops: ops, + ); + } + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:duplicate', + error: error, + stackTrace: stackTrace, + ); + if (!handled) rethrow; + return; + } + ref.invalidate(cloudStrategiesProvider); + return; + } + final strategyBox = Hive.box(HiveBoxNames.strategiesBox); final originalStrategy = strategyBox.get(strategyID); if (originalStrategy == null) { @@ -3472,8 +903,7 @@ class StrategyProvider extends Notifier { final duplicatedStrategy = StrategyData( id: newID, name: "${originalStrategy.name} (Copy)", - mapData: originalStrategy - .mapData, // MapValue is likely an enum, so this should be safe + mapData: originalStrategy.mapData, versionNumber: originalStrategy.versionNumber, lastEdited: DateTime.now(), folderID: originalStrategy.folderID, @@ -3485,7 +915,28 @@ class StrategyProvider extends Notifier { await strategyBox.put(duplicatedStrategy.id, duplicatedStrategy); } - Future deleteStrategy(String strategyID) async { + Future deleteStrategy( + String strategyID, { + StrategySource? source, + }) async { + final resolvedSource = source ?? _resolveLibraryMutationSource(); + if (resolvedSource == StrategySource.cloud) { + try { + await ConvexClient.instance.mutation(name: "strategies:delete", args: { + "strategyPublicId": strategyID, + }); + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:delete', + error: error, + stackTrace: stackTrace, + ); + if (!handled) rethrow; + } + ref.invalidate(cloudStrategiesProvider); + return; + } + await Hive.box(HiveBoxNames.strategiesBox).delete(strategyID); final directory = await getApplicationSupportDirectory(); @@ -3498,6 +949,9 @@ class StrategyProvider extends Notifier { } Future saveToHive(String id) async { + if (_currentStrategyIsCloud()) { + return; + } // final drawingData = ref.read(drawingProvider).elements; // final agentData = ref.read(agentProvider); // final abilityData = ref.read(abilityProvider); @@ -3525,23 +979,29 @@ class StrategyProvider extends Notifier { await Hive.box(HiveBoxNames.strategiesBox) .put(currentStrategy.id, currentStrategy); - state = state.copyWith( - isSaved: true, - ); + ref.read(strategySaveStateProvider.notifier).markPersisted(); log("Save to hive was called"); } - // Flush currently active page (uses activePageID). Safe if null/missing. + // Flush currently active page into Hive. Safe if no active page is selected. Future _syncCurrentPageToHive() async { + if (_currentStrategyIsCloud()) { + return; + } final box = Hive.box(HiveBoxNames.strategiesBox); - log("Syncing current page to hive for strategy ${state.id}"); - final strat = box.get(state.id); + final strategyId = state.strategyId; + if (strategyId == null) { + return; + } + log("Syncing current page to hive for strategy $strategyId"); + final strat = box.get(strategyId); if (strat == null || strat.pages.isEmpty) { log("No strategy or pages found for syncing."); return; } - final pageId = activePageID ?? strat.pages.first.id; + final pageId = ref.read(strategyPageSessionProvider).activePageId ?? + strat.pages.first.id; final idx = strat.pages.indexWhere((p) => p.id == pageId); if (idx == -1) { log("Active page ID $pageId not found in strategy ${strat.id}"); @@ -3577,12 +1037,14 @@ class StrategyProvider extends Notifier { /// Copies current [strategySettingsProvider] marker sizes to every page in /// the open strategy (after flushing the active page to Hive). Future applyMarkerSizesToAllPages() async { - if (state.stratName == null) return; + if (state.strategyName == null) return; await _syncCurrentPageToHive(); final box = Hive.box(HiveBoxNames.strategiesBox); - final strat = box.get(state.id); + final strategyId = state.strategyId; + if (strategyId == null) return; + final strat = box.get(strategyId); if (strat == null || strat.pages.isEmpty) return; final target = ref.read(strategySettingsProvider); @@ -3610,7 +1072,30 @@ class StrategyProvider extends Notifier { setUnsaved(); } - void moveToFolder({required String strategyID, required String? parentID}) { + void moveToFolder({ + required String strategyID, + required String? parentID, + StrategySource? source, + }) { + final resolvedSource = source ?? _resolveLibraryMutationSource(); + if (resolvedSource == StrategySource.cloud) { + unawaited(() async { + try { + await ConvexClient.instance.mutation(name: "strategies:move", args: { + "strategyPublicId": strategyID, + if (parentID != null) "folderPublicId": parentID, + }); + } catch (error, stackTrace) { + await _reportCloudUnauthenticated( + source: 'strategy:move', + error: error, + stackTrace: stackTrace, + ); + } + }()); + ref.invalidate(cloudStrategiesProvider); + return; + } final strategyBox = Hive.box(HiveBoxNames.strategiesBox); final strategy = strategyBox.get(strategyID); diff --git a/lib/providers/strategy_save_state_provider.dart b/lib/providers/strategy_save_state_provider.dart new file mode 100644 index 00000000..f4212849 --- /dev/null +++ b/lib/providers/strategy_save_state_provider.dart @@ -0,0 +1,124 @@ +import 'package:flutter_riverpod/flutter_riverpod.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'; + +class StrategySaveState { + const StrategySaveState({ + required this.isDirty, + required this.isSaving, + required this.hasPendingCloudSync, + required this.cloudSyncError, + required this.lastPersistedAt, + }); + + final bool isDirty; + final bool isSaving; + final bool hasPendingCloudSync; + final String? cloudSyncError; + final DateTime? lastPersistedAt; + + bool get canLeaveSafely => + !isDirty && !isSaving && !hasPendingCloudSync && cloudSyncError == null; + + StrategySaveState copyWith({ + bool? isDirty, + bool? isSaving, + bool? hasPendingCloudSync, + String? cloudSyncError, + bool clearCloudSyncError = false, + DateTime? lastPersistedAt, + }) { + return StrategySaveState( + isDirty: isDirty ?? this.isDirty, + isSaving: isSaving ?? this.isSaving, + hasPendingCloudSync: hasPendingCloudSync ?? this.hasPendingCloudSync, + cloudSyncError: + clearCloudSyncError ? null : (cloudSyncError ?? this.cloudSyncError), + lastPersistedAt: lastPersistedAt ?? this.lastPersistedAt, + ); + } +} + +final strategySaveStateProvider = + NotifierProvider( + StrategySaveStateNotifier.new, +); + +class StrategySaveStateNotifier extends Notifier { + @override + StrategySaveState build() { + ref.listen(strategyOpQueueProvider, (previous, next) { + final source = ref.read(strategyProvider).source; + if (source != StrategySource.cloud) { + return; + } + + final hasPendingSync = next.isFlushing || next.pending.isNotEmpty; + state = state.copyWith( + isSaving: next.isFlushing, + hasPendingCloudSync: hasPendingSync, + cloudSyncError: next.lastError, + clearCloudSyncError: next.lastError == null, + isDirty: hasPendingSync ? true : state.isDirty, + ); + + if (!hasPendingSync && next.lastError == null) { + state = state.copyWith( + isDirty: false, + lastPersistedAt: DateTime.now(), + ); + } + }); + + return const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + cloudSyncError: null, + lastPersistedAt: null, + ); + } + + void reset() { + state = const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + cloudSyncError: null, + lastPersistedAt: null, + ); + } + + void markDirty() { + state = state.copyWith( + isDirty: true, + clearCloudSyncError: true, + ); + } + + void markSaving(bool value) { + state = state.copyWith(isSaving: value); + } + + void setPendingCloudSync(bool value) { + state = state.copyWith(hasPendingCloudSync: value); + } + + void setCloudSyncError(String? error) { + state = state.copyWith( + cloudSyncError: error, + clearCloudSyncError: error == null, + ); + } + + void markPersisted() { + state = state.copyWith( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + clearCloudSyncError: true, + lastPersistedAt: DateTime.now(), + ); + } +} diff --git a/lib/screenshot/screenshot_view.dart b/lib/screenshot/screenshot_view.dart index bd54d2b1..7e0971b5 100644 --- a/lib/screenshot/screenshot_view.dart +++ b/lib/screenshot/screenshot_view.dart @@ -15,6 +15,7 @@ import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; diff --git a/lib/services/app_error_reporter.dart b/lib/services/app_error_reporter.dart index 7e2d1248..994dad65 100644 --- a/lib/services/app_error_reporter.dart +++ b/lib/services/app_error_reporter.dart @@ -144,7 +144,11 @@ class AppErrorReporter { level: _developerLogLevel(level), ); - appProviderContainer.read(inAppDebugProvider.notifier).addEntry(entry); + try { + appProviderContainer.read(inAppDebugProvider.notifier).addEntry(entry); + } catch (_) { + // Tests and early startup can report logs before the global container exists. + } _queuePersistedLogWrite(entry); return entry; } diff --git a/lib/services/deep_link_registrar.dart b/lib/services/deep_link_registrar.dart new file mode 100644 index 00000000..33687c41 --- /dev/null +++ b/lib/services/deep_link_registrar.dart @@ -0,0 +1,64 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:win32_registry/win32_registry.dart'; + +Future registerDeepLinkProtocol(String scheme) async { + if (!Platform.isWindows) { + return; + } + + final appPath = Platform.resolvedExecutable; + final expectedCommand = '"$appPath" "%1"'; + + final protocolRegKey = 'Software\\Classes\\$scheme'; + const protocolRegValue = RegistryValue.string('URL Protocol', ''); + const protocolCmdRegKey = 'shell\\open\\command'; + + final currentCommand = _readCurrentProtocolCommand(scheme); + final looksLikeDevBuild = _looksLikeDevBuildPath(appPath); + final canOverwriteDevRegistration = !looksLikeDevBuild || + const bool.fromEnvironment('ICARUS_FORCE_PROTOCOL_REGISTER'); + + if (!canOverwriteDevRegistration && + currentCommand != null && + currentCommand.isNotEmpty && + currentCommand != expectedCommand) { + log( + 'Deep link registration skipped for dev executable. ' + 'existing="$currentCommand" ' + 'resolvedExecutable="$appPath"', + name: 'deep_link_registrar', + ); + return; + } + + final regKey = Registry.currentUser.createKey(protocolRegKey); + regKey.createValue(protocolRegValue); + regKey + .createKey(protocolCmdRegKey) + .createValue(RegistryValue.string('', expectedCommand)); + + log( + 'Deep link registration updated. ' + 'scheme="$scheme" command="$expectedCommand"', + name: 'deep_link_registrar', + ); +} + +String? _readCurrentProtocolCommand(String scheme) { + final path = 'Software\\Classes\\$scheme\\shell\\open\\command'; + try { + final key = Registry.openPath(RegistryHive.currentUser, path: path); + final value = key.getStringValue(''); + key.close(); + return value; + } catch (_) { + return null; + } +} + +bool _looksLikeDevBuildPath(String appPath) { + final normalized = appPath.toLowerCase().replaceAll('/', '\\'); + return normalized.contains('\\build\\windows\\x64\\runner\\debug\\'); +} diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 4b339738..9d1acd11 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; enum UnsavedStrategyDecision { @@ -10,6 +14,12 @@ enum UnsavedStrategyDecision { cancel, } +enum CloudExitDecision { + stay, + retrySync, + retryAuth, +} + Future showUnsavedStrategyDialog( BuildContext context, ) async { @@ -51,6 +61,127 @@ Future showUnsavedStrategyDialog( return result ?? UnsavedStrategyDecision.cancel; } +Future _showCloudSyncBlockedDialog( + BuildContext context, { + required String message, + required bool showRetryAuth, +}) async { + final result = await showShadDialog( + context: context, + builder: (context) { + return ShadDialog.alert( + title: const Text('Cloud sync pending'), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text(message), + ), + actions: [ + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.stay); + }, + child: const Text('Stay Here'), + ), + if (showRetryAuth) + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.retryAuth); + }, + child: const Text('Retry Convex Auth'), + ), + ShadButton( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.retrySync); + }, + child: const Text('Retry Sync'), + ), + ], + ); + }, + ); + + return result ?? CloudExitDecision.stay; +} + +Future _waitForCloudSync( + WidgetRef ref, { + Duration timeout = const Duration(seconds: 8), + Duration pollInterval = const Duration(milliseconds: 120), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final saveState = ref.read(strategySaveStateProvider); + final queueState = ref.read(strategyOpQueueProvider); + if (!saveState.hasPendingCloudSync && + queueState.pending.isEmpty && + !queueState.isFlushing && + saveState.cloudSyncError == null) { + return true; + } + await Future.delayed(pollInterval); + } + return false; +} + +Future _guardCloudStrategyExit({ + required BuildContext context, + required WidgetRef ref, + required Future Function() onContinue, +}) async { + while (true) { + final strategyState = ref.read(strategyProvider); + final saveState = ref.read(strategySaveStateProvider); + final queueState = ref.read(strategyOpQueueProvider); + final authState = ref.read(authProvider); + + final hasPendingSync = + saveState.hasPendingCloudSync || queueState.pending.isNotEmpty; + final cloudError = saveState.cloudSyncError ?? queueState.lastError; + if (!hasPendingSync && cloudError == null) { + if (!context.mounted) { + return false; + } + await onContinue(); + return true; + } + + if (queueState.isFlushing && cloudError == null) { + final synced = await _waitForCloudSync(ref); + if (synced) { + continue; + } + } + + if (!context.mounted) { + return false; + } + + final decision = await _showCloudSyncBlockedDialog( + context, + message: cloudError ?? + 'Icarus is still syncing cloud edits. Stay on this screen until sync completes.', + showRetryAuth: authState.hasActiveAuthIncident, + ); + + switch (decision) { + case CloudExitDecision.stay: + return false; + case CloudExitDecision.retryAuth: + await ref + .read(authProvider.notifier) + .reinitializeConvexAuth(source: 'cloud_exit_guard'); + break; + case CloudExitDecision.retrySync: + final strategyId = strategyState.strategyId; + if (strategyId == null) { + return false; + } + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); + break; + } + } +} + Future guardUnsavedStrategyExit({ required BuildContext context, required WidgetRef ref, @@ -58,7 +189,16 @@ Future guardUnsavedStrategyExit({ required String source, }) async { final strategyState = ref.read(strategyProvider); - if (strategyState.stratName == null || strategyState.isSaved) { + final saveState = ref.read(strategySaveStateProvider); + if (strategyState.source == StrategySource.cloud) { + return _guardCloudStrategyExit( + context: context, + ref: ref, + onContinue: onContinue, + ); + } + + if (strategyState.strategyName == null || !saveState.isDirty) { await onContinue(); return true; } @@ -67,9 +207,11 @@ Future guardUnsavedStrategyExit({ switch (decision) { case UnsavedStrategyDecision.save: try { - await ref - .read(strategyProvider.notifier) - .forceSaveNow(strategyState.id); + final strategyId = strategyState.strategyId; + if (strategyId == null) { + return false; + } + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); } catch (error, stackTrace) { AppErrorReporter.reportError( 'Failed to save strategy before leaving.', diff --git a/lib/strategy/strategy_cloud_migration.dart b/lib/strategy/strategy_cloud_migration.dart new file mode 100644 index 00000000..acd90b40 --- /dev/null +++ b/lib/strategy/strategy_cloud_migration.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:uuid/uuid.dart'; + +void appendMigratedPageOps( + List ops, + StrategyPage page, { + required Set usedElementIds, + required Set usedLineupIds, +}) { + var elementOrder = 0; + + for (final agent in page.agentData) { + final elementId = nextUniqueMigrationId(agent.id, usedElementIds); + final payload = Map.from(agent.toJson()) + ..putIfAbsent('elementType', () => 'agent') + ..['id'] = elementId; + ops.add(buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final ability in page.abilityData) { + final elementId = nextUniqueMigrationId(ability.id, usedElementIds); + final payload = Map.from(ability.toJson()) + ..putIfAbsent('elementType', () => 'ability') + ..['id'] = elementId; + ops.add(buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final drawing in page.drawingData) { + final elementId = nextUniqueMigrationId(drawing.id, usedElementIds); + final encodedList = + jsonDecode(DrawingProvider.objectToJson([drawing])) as List; + final payload = Map.from( + (encodedList.isNotEmpty ? encodedList.first : {}) as Map, + ) + ..putIfAbsent('elementType', () => 'drawing') + ..['id'] = elementId; + ops.add(buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final text in page.textData) { + final elementId = nextUniqueMigrationId(text.id, usedElementIds); + final payload = Map.from(text.toJson()) + ..putIfAbsent('elementType', () => 'text') + ..['id'] = elementId; + ops.add(buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final image in page.imageData) { + final elementId = nextUniqueMigrationId(image.id, usedElementIds); + final payload = Map.from(image.toJson()) + ..putIfAbsent('elementType', () => 'image') + ..['id'] = elementId; + ops.add(buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final utility in page.utilityData) { + final elementId = nextUniqueMigrationId(utility.id, usedElementIds); + final payload = Map.from(utility.toJson()) + ..putIfAbsent('elementType', () => 'utility') + ..['id'] = elementId; + ops.add(buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + var lineupOrder = 0; + for (final lineup in page.lineUps) { + final lineupId = nextUniqueMigrationId(lineup.id, usedLineupIds); + final lineupPayload = Map.from(lineup.toJson()) + ..['id'] = lineupId; + ops.add( + StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.lineup, + entityPublicId: lineupId, + pagePublicId: page.id, + payload: jsonEncode(lineupPayload), + sortIndex: lineupOrder++, + ), + ); + } +} + +String nextUniqueMigrationId(String preferredId, Set usedIds) { + if (usedIds.add(preferredId)) { + return preferredId; + } + + var generated = const Uuid().v4(); + while (!usedIds.add(generated)) { + generated = const Uuid().v4(); + } + return generated; +} + +StrategyOp buildMigratedElementOp( + String pagePublicId, + String elementId, + Map payload, + int sortIndex, +) { + return StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.element, + entityPublicId: elementId, + pagePublicId: pagePublicId, + payload: jsonEncode(payload), + sortIndex: sortIndex, + ); +} diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart new file mode 100644 index 00000000..764f8108 --- /dev/null +++ b/lib/strategy/strategy_import_export.dart @@ -0,0 +1,2502 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/drawing_element.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/settings.dart'; +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/favorite_agents_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/map_theme_provider.dart'; +import 'package:icarus/providers/strategy_page.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/utility_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/archive_manifest.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +String sanitizeStrategyFileName(String input) { + final sanitized = input.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); + return sanitized.isEmpty ? 'untitled' : sanitized; +} + +String buildLibraryBackupFileName(DateTime timestamp) { + String twoDigit(int value) => value.toString().padLeft(2, '0'); + return 'icarus-library-backup-' + '${timestamp.year}-${twoDigit(timestamp.month)}-${twoDigit(timestamp.day)}_' + '${twoDigit(timestamp.hour)}-${twoDigit(timestamp.minute)}-${twoDigit(timestamp.second)}.zip'; +} + +class NewerVersionImportException implements Exception { + const NewerVersionImportException({ + required this.importedVersion, + required this.currentVersion, + }); + + final int importedVersion; + final int currentVersion; + + static const String userMessage = + 'This strategy was created in a newer version of Icarus. ' + 'Please update the app and try again.'; + + @override + String toString() { + return 'NewerVersionImportException(' + 'importedVersion: $importedVersion, ' + 'currentVersion: $currentVersion' + ')'; + } +} + +enum ImportIssueCode { + newerVersion, + invalidStrategy, + invalidArchiveMetadata, + unsupportedFile, + ioError, +} + +class ImportIssue { + const ImportIssue({ + required this.path, + required this.code, + }); + + final String path; + final ImportIssueCode code; +} + +class ImportBatchResult { + const ImportBatchResult({ + required this.strategiesImported, + required this.foldersCreated, + this.themeProfilesImported = 0, + this.globalStateRestored = false, + required this.issues, + }); + + const ImportBatchResult.empty() + : strategiesImported = 0, + foldersCreated = 0, + themeProfilesImported = 0, + globalStateRestored = false, + issues = const []; + + final int strategiesImported; + final int foldersCreated; + final int themeProfilesImported; + final bool globalStateRestored; + final List issues; + + bool get hasImports => + strategiesImported > 0 || + foldersCreated > 0 || + themeProfilesImported > 0 || + globalStateRestored; + + ImportBatchResult merge(ImportBatchResult other) { + return ImportBatchResult( + strategiesImported: strategiesImported + other.strategiesImported, + foldersCreated: foldersCreated + other.foldersCreated, + themeProfilesImported: + themeProfilesImported + other.themeProfilesImported, + globalStateRestored: globalStateRestored || other.globalStateRestored, + issues: [...issues, ...other.issues], + ); + } +} + +class _ImportEntityListing { + const _ImportEntityListing({ + required this.entities, + required this.issues, + }); + + final List entities; + final List issues; +} + +class _ArchiveExportState { + _ArchiveExportState({ + required this.rootDirectory, + }); + + final Directory rootDirectory; + final List folders = []; + final List strategies = []; +} + +class _ManifestImportData { + const _ManifestImportData({ + required this.rootDirectory, + required this.manifestFile, + required this.manifest, + }); + + final Directory rootDirectory; + final File manifestFile; + final ArchiveManifest manifest; +} + +class _GlobalImportResult { + const _GlobalImportResult({ + required this.themeProfilesImported, + required this.globalStateRestored, + required this.profileIdRemap, + }); + + final int themeProfilesImported; + final bool globalStateRestored; + final Map profileIdRemap; +} + +class _ZipManifestData { + const _ZipManifestData({ + required this.manifest, + required this.rootPrefix, + required this.filesByPath, + required this.manifestArchivePath, + }); + + final ArchiveManifest manifest; + final String rootPrefix; + final Map filesByPath; + final String manifestArchivePath; +} + +class StrategyImportExportService { + StrategyImportExportService(this.ref); + + final dynamic ref; + + void _reportImportFailure( + String message, { + Object? error, + StackTrace? stackTrace, + required String source, + }) { + AppErrorReporter.reportError( + message, + error: error, + stackTrace: stackTrace, + source: source, + promptUser: false, + ); + } + + Future loadFromFilePath(String filePath) async { + await _importStrategyFile( + file: XFile(filePath), + targetFolderId: null, + ); + } + + Future loadFromFilePicker() async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.custom, + allowedExtensions: ['ica'], + ); + + if (result == null) return; + + for (final file in result.files) { + await _importStrategyFile( + file: file.xFile, + targetFolderId: null, + ); + } + } + + Future importBackupFromFilePicker() async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.custom, + allowedExtensions: ['zip'], + ); + + if (result == null || result.files.isEmpty) { + return const ImportBatchResult.empty(); + } + + final pickedFile = result.files.single; + final filePath = pickedFile.path; + if (filePath == null || filePath.isEmpty) { + return const ImportBatchResult.empty(); + } + + return _importZipArchive( + zipFile: File(filePath), + parentFolderId: null, + ); + } + + Future loadFromFileDrop(List files) async { + final targetFolderId = ref.read(folderProvider); + var result = const ImportBatchResult.empty(); + + for (final file in files) { + result = result.merge( + await _importDroppedItem( + file: file, + targetFolderId: targetFolderId, + ), + ); + } + + return result; + } + + Future getTempDirectory(String strategyID) async { + String tempDirectoryPath; + try { + tempDirectoryPath = (await getTemporaryDirectory()).path; + } on MissingPluginException { + tempDirectoryPath = Directory.systemTemp.path; + } on MissingPlatformDirectoryException { + tempDirectoryPath = Directory.systemTemp.path; + } + + return Directory( + path.join(tempDirectoryPath, 'xyz_icarus_strats', strategyID), + ).create(recursive: true); + } + + Future cleanUpTempDirectory(String strategyID) async { + final tempDirectory = await getTempDirectory(strategyID); + await tempDirectory.delete(recursive: true); + } + + Future _getApplicationSupportDirectoryOrSystemTemp() async { + try { + return await getApplicationSupportDirectory(); + } on MissingPluginException { + return Directory.systemTemp; + } on MissingPlatformDirectoryException { + return Directory.systemTemp; + } + } + + Future _extractArchiveEntriesToDisk({ + required Archive archive, + required Directory destination, + }) async { + final destinationPath = path.normalize(destination.path); + + for (final entry in archive) { + final normalizedName = normalizeArchivePath(entry.name); + if (normalizedName.isEmpty) { + continue; + } + if (path.isAbsolute(normalizedName)) { + continue; + } + + final segments = path.posix.split(normalizedName); + if (segments.any((segment) => segment == '..')) { + continue; + } + + final targetPath = path.joinAll([ + destinationPath, + ...segments, + ]); + final normalizedTargetPath = path.normalize(targetPath); + final isWithinDestination = + path.isWithin(destinationPath, normalizedTargetPath) || + normalizedTargetPath == destinationPath; + if (!isWithinDestination) { + continue; + } + + if (entry.isFile) { + final targetFile = File(normalizedTargetPath); + await targetFile.parent.create(recursive: true); + await targetFile.writeAsBytes(entry.content as List); + } else { + await Directory(normalizedTargetPath).create(recursive: true); + } + } + } + + Future isZipFile(File file) async { + final raf = file.openSync(mode: FileMode.read); + final header = raf.readSync(4); + await raf.close(); + + return header.length == 4 && + header[0] == 0x50 && + header[1] == 0x4B && + header[2] == 0x03 && + header[3] == 0x04; + } + + Future _importDroppedItem({ + required XFile file, + required String? targetFolderId, + }) async { + if (file.path.isEmpty) { + return const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue(path: '', code: ImportIssueCode.ioError), + ], + ); + } + + try { + final entityType = + await FileSystemEntity.type(file.path, followLinks: false); + switch (entityType) { + case FileSystemEntityType.directory: + return await _importDirectoryTree( + sourceDir: Directory(file.path), + parentFolderId: targetFolderId, + ); + case FileSystemEntityType.file: + final extension = path.extension(file.path).toLowerCase(); + if (extension == '.ica') { + await _importStrategyFile( + file: file, + targetFolderId: targetFolderId, + ); + return const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ); + } + + if (await isZipFile(File(file.path))) { + return await _importZipArchive( + zipFile: File(file.path), + parentFolderId: targetFolderId, + ); + } + + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: file.path, + code: ImportIssueCode.unsupportedFile, + ), + ], + ); + case FileSystemEntityType.notFound: + case FileSystemEntityType.link: + case FileSystemEntityType.unixDomainSock: + case FileSystemEntityType.pipe: + default: + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: file.path, + code: ImportIssueCode.ioError, + ), + ], + ); + } + } on NewerVersionImportException { + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: file.path, + code: ImportIssueCode.newerVersion, + ), + ], + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import dropped item ${file.path}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importDroppedItem', + ); + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: file.path, + code: ImportIssueCode.ioError, + ), + ], + ); + } + } + + Future _createImportedFolder({ + required String name, + required String? parentFolderId, + }) { + return ref.read(folderProvider.notifier).createFolder( + name: name, + icon: Icons.drive_folder_upload, + color: FolderColor.generic, + parentID: parentFolderId, + ); + } + + List _sortedImportEntities( + Iterable entities, + ) { + final filtered = entities.where((entity) { + final basename = path.basename(entity.path); + return !_shouldIgnoreImportedEntityName(basename); + }).toList(); + filtered.sort((a, b) => a.path.compareTo(b.path)); + return filtered; + } + + bool _shouldIgnoreImportedEntityName(String name) { + return name.isEmpty || + name == '__MACOSX' || + name == '.DS_Store' || + name == archiveMetadataFileName || + name.startsWith('._'); + } + + bool _isIcaFileEntity(FileSystemEntity entity) { + return entity is File && + path.extension(entity.path).toLowerCase() == '.ica'; + } + + Future<_ImportEntityListing> _listImportEntities(Directory directory) async { + final issues = []; + try { + final entities = directory.listSync(followLinks: false); + return _ImportEntityListing( + entities: _sortedImportEntities(entities), + issues: issues, + ); + } on FileSystemException catch (error, stackTrace) { + final errorPath = _resolveImportErrorPath(error, directory.path); + _reportImportFailure( + 'Failed to list import directory $errorPath.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._listImportEntities', + ); + issues.add( + ImportIssue( + path: errorPath, + code: ImportIssueCode.ioError, + ), + ); + } + + return _ImportEntityListing( + entities: const [], + issues: issues, + ); + } + + String _resolveImportErrorPath(Object error, String fallbackPath) { + if (error is FileSystemException) { + return error.path ?? fallbackPath; + } + return fallbackPath; + } + + Future _importEntitiesIntoFolder({ + required Iterable entities, + required String parentFolderId, + }) async { + var result = const ImportBatchResult.empty(); + final sortedEntities = _sortedImportEntities(entities); + + for (final entity in sortedEntities) { + if (entity is Directory) { + result = result.merge( + await _importDirectoryTree( + sourceDir: entity, + parentFolderId: parentFolderId, + ), + ); + continue; + } + + if (_isIcaFileEntity(entity)) { + try { + await _importStrategyFile( + file: XFile(entity.path), + targetFolderId: parentFolderId, + ); + result = result.merge( + const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } on NewerVersionImportException { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: entity.path, + code: ImportIssueCode.newerVersion, + ), + ], + ), + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import strategy file ${entity.path}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importEntitiesIntoFolder', + ); + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: entity.path, + code: ImportIssueCode.invalidStrategy, + ), + ], + ), + ); + } + continue; + } + + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: entity.path, + code: ImportIssueCode.unsupportedFile, + ), + ], + ), + ); + } + + return result; + } + + Future _importDirectoryTree({ + required Directory sourceDir, + required String? parentFolderId, + }) async { + final manifestFile = + File(path.join(sourceDir.path, archiveMetadataFileName)); + _ManifestImportData? manifestData; + if (await manifestFile.exists()) { + try { + manifestData = await _loadManifestIfPresent(sourceDir); + if (manifestData != null) { + _validateArchiveManifest(manifestData); + } + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import manifest archive from ${sourceDir.path}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importDirectoryTree', + ); + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: manifestFile.path, + code: ImportIssueCode.invalidArchiveMetadata, + ), + ], + ).merge( + await _importDirectoryTreeLegacy( + sourceDir: sourceDir, + parentFolderId: parentFolderId, + ), + ); + } + } + + if (manifestData != null) { + return _importManifestArchive( + manifestData: manifestData, + parentFolderId: parentFolderId, + ); + } + + return _importDirectoryTreeLegacy( + sourceDir: sourceDir, + parentFolderId: parentFolderId, + ); + } + + Future _importDirectoryTreeLegacy({ + required Directory sourceDir, + required String? parentFolderId, + }) async { + final importedFolder = await _createImportedFolder( + name: path.basename(sourceDir.path), + parentFolderId: parentFolderId, + ); + + var result = const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ); + + final listing = await _listImportEntities(sourceDir); + if (listing.issues.isNotEmpty) { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: listing.issues, + ), + ); + } + + result = result.merge( + await _importEntitiesIntoFolder( + entities: listing.entities, + parentFolderId: importedFolder.id, + ), + ); + + return result; + } + + Future _importZipArchive({ + required File zipFile, + required String? parentFolderId, + }) async { + final archive = ZipDecoder().decodeBytes(await zipFile.readAsBytes()); + _ZipManifestData? manifestData; + try { + manifestData = _loadManifestFromArchive(archive); + if (manifestData != null) { + _validateArchiveManifestFromZip(manifestData); + } + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import manifest zip ${zipFile.path}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importZipArchive', + ); + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: zipFile.path, + code: ImportIssueCode.invalidArchiveMetadata, + ), + ], + ).merge( + await _importLegacyZipArchiveFromEntries( + archive: archive, + parentFolderId: parentFolderId, + zipFileName: path.basenameWithoutExtension(zipFile.path), + ), + ); + } + + if (manifestData != null) { + return _importManifestArchiveFromZip( + manifestData: manifestData, + parentFolderId: parentFolderId, + ); + } + + return _importLegacyZipArchiveFromEntries( + archive: archive, + parentFolderId: parentFolderId, + zipFileName: path.basenameWithoutExtension(zipFile.path), + ); + } + + _ZipManifestData? _loadManifestFromArchive(Archive archive) { + final filesByPath = {}; + for (final entry in archive) { + if (!entry.isFile) { + continue; + } + filesByPath[normalizeArchivePath(entry.name)] = entry; + } + + final manifestPaths = filesByPath.keys + .where((pathValue) => + path.posix.basename(pathValue) == archiveMetadataFileName) + .toList(growable: false); + if (manifestPaths.isEmpty) { + return null; + } + if (manifestPaths.length > 1) { + throw const FormatException('Archive contains multiple manifest files'); + } + + final manifestArchivePath = manifestPaths.single; + final manifestEntry = filesByPath[manifestArchivePath]!; + final decoded = jsonDecode(utf8.decode(_archiveFileBytes(manifestEntry))); + if (decoded is! Map) { + throw const FormatException('Archive manifest must decode to an object'); + } + + final rootPrefix = path.posix.dirname(manifestArchivePath); + return _ZipManifestData( + manifest: ArchiveManifest.fromJson(decoded), + rootPrefix: rootPrefix == '.' ? '' : rootPrefix, + filesByPath: filesByPath, + manifestArchivePath: manifestArchivePath, + ); + } + + List _archiveFileBytes(ArchiveFile entry) { + return entry.content as List; + } + + Future _writeArchiveEntryToTempFile({ + required ArchiveFile archiveFile, + required Directory tempDirectory, + }) async { + final baseName = path.basename(normalizeArchivePath(archiveFile.name)); + final file = File(path.join(tempDirectory.path, baseName)); + await file.parent.create(recursive: true); + await file.writeAsBytes(_archiveFileBytes(archiveFile)); + return file; + } + + Future _importManifestArchiveFromZip({ + required _ZipManifestData manifestData, + required String? parentFolderId, + }) async { + var result = const ImportBatchResult.empty(); + var profileIdRemap = const {}; + + if (manifestData.manifest.archiveType == ArchiveType.libraryBackup) { + final globals = manifestData.manifest.globals; + if (globals == null) { + throw const FormatException( + 'Library backup archive is missing globals'); + } + final globalImportResult = await _importArchiveGlobals(globals); + profileIdRemap = globalImportResult.profileIdRemap; + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: globalImportResult.themeProfilesImported, + globalStateRestored: globalImportResult.globalStateRestored, + issues: const [], + ), + ); + } + + final folderEntries = [...manifestData.manifest.folders]..sort((a, b) { + final depthCompare = _archivePathDepth(a.archivePath) + .compareTo(_archivePathDepth(b.archivePath)); + if (depthCompare != 0) { + return depthCompare; + } + return a.archivePath.compareTo(b.archivePath); + }); + + final localFolderIdsByManifestId = {}; + for (final folderEntry in folderEntries) { + final resolvedParentFolderId = folderEntry.parentManifestId == null + ? (manifestData.manifest.archiveType == ArchiveType.folderTree + ? parentFolderId + : null) + : localFolderIdsByManifestId[folderEntry.parentManifestId!]; + if (folderEntry.parentManifestId != null && + resolvedParentFolderId == null) { + throw FormatException( + 'Missing parent folder mapping for ${folderEntry.manifestId}', + ); + } + + final createdFolder = + await ref.read(folderProvider.notifier).createFolder( + name: folderEntry.name, + icon: folderEntry.icon.toIconData(), + color: folderEntry.color, + customColor: folderEntry.customColorValue == null + ? null + : Color(folderEntry.customColorValue!), + parentID: resolvedParentFolderId, + ); + localFolderIdsByManifestId[folderEntry.manifestId] = createdFolder.id; + result = result.merge( + const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } + + final materializedDirectory = + await Directory.systemTemp.createTemp('icarus-zip-manifest-import'); + try { + for (final strategyEntry in [...manifestData.manifest.strategies] + ..sort((a, b) => a.archivePath.compareTo(b.archivePath))) { + final targetFolderId = strategyEntry.folderManifestId == null + ? null + : localFolderIdsByManifestId[strategyEntry.folderManifestId!]; + final archivePath = _zipArchiveAbsolutePath( + rootPrefix: manifestData.rootPrefix, + relativePath: strategyEntry.archivePath, + ); + final archiveFile = manifestData.filesByPath[archivePath]; + if (archiveFile == null) { + throw FormatException('Missing strategy file: $archivePath'); + } + + try { + final tempFile = await _writeArchiveEntryToTempFile( + archiveFile: archiveFile, + tempDirectory: materializedDirectory, + ); + await _importStrategyFile( + file: XFile(tempFile.path), + targetFolderId: targetFolderId, + displayNameOverride: strategyEntry.name, + themeProfileIdRemap: profileIdRemap, + ); + result = result.merge( + const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } on NewerVersionImportException { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.newerVersion, + ), + ], + ), + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import manifest strategy $archivePath.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importManifestArchiveFromZip', + ); + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.invalidStrategy, + ), + ], + ), + ); + } + } + } finally { + try { + await materializedDirectory.delete(recursive: true); + } catch (_) {} + } + + final undeclaredIssues = _collectUndeclaredZipArchiveIssues(manifestData); + if (undeclaredIssues.isNotEmpty) { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: undeclaredIssues, + ), + ); + } + + return result; + } + + void _validateArchiveManifestFromZip(_ZipManifestData manifestData) { + final manifest = manifestData.manifest; + final folderIds = {}; + final folderPaths = {}; + final rootFolders = []; + + for (final folder in manifest.folders) { + if (!folderIds.add(folder.manifestId)) { + throw FormatException( + 'Duplicate folder manifest ID: ${folder.manifestId}'); + } + if (!folderPaths.add(folder.archivePath)) { + throw FormatException( + 'Duplicate folder archive path: ${folder.archivePath}'); + } + if (folder.parentManifestId == null) { + rootFolders.add(folder); + } else if (!manifest.folders.any( + (candidate) => candidate.manifestId == folder.parentManifestId)) { + throw FormatException('Missing parent folder for ${folder.manifestId}'); + } + } + + if (manifest.archiveType == ArchiveType.folderTree) { + if (rootFolders.length != 1) { + throw const FormatException( + 'Folder tree archives must contain one root'); + } + if (rootFolders.single.archivePath.isNotEmpty) { + throw const FormatException( + 'Folder tree root folder must use the manifest root path', + ); + } + } + + final knownFolderIds = + manifest.folders.map((folder) => folder.manifestId).toSet(); + final strategyPaths = {}; + for (final strategy in manifest.strategies) { + if (!strategyPaths.add(strategy.archivePath)) { + throw FormatException( + 'Duplicate strategy archive path: ${strategy.archivePath}', + ); + } + if (strategy.folderManifestId != null && + !knownFolderIds.contains(strategy.folderManifestId)) { + throw FormatException( + 'Unknown strategy folder reference: ${strategy.folderManifestId}', + ); + } + if (manifest.archiveType == ArchiveType.folderTree && + strategy.folderManifestId == null) { + throw const FormatException( + 'Folder tree strategies must reference the exported root folder', + ); + } + final archivePath = _zipArchiveAbsolutePath( + rootPrefix: manifestData.rootPrefix, + relativePath: strategy.archivePath, + ); + if (!manifestData.filesByPath.containsKey(archivePath)) { + throw FormatException('Missing strategy file: $archivePath'); + } + } + } + + String _zipArchiveAbsolutePath({ + required String rootPrefix, + required String relativePath, + }) { + return normalizeArchivePath( + rootPrefix.isEmpty + ? relativePath + : path.posix.join(rootPrefix, relativePath), + ); + } + + List _collectUndeclaredZipArchiveIssues( + _ZipManifestData manifestData, + ) { + final allowedFiles = {manifestData.manifestArchivePath}; + for (final strategy in manifestData.manifest.strategies) { + allowedFiles.add( + _zipArchiveAbsolutePath( + rootPrefix: manifestData.rootPrefix, + relativePath: strategy.archivePath, + ), + ); + } + + final issues = []; + for (final archivePath in manifestData.filesByPath.keys) { + if (!allowedFiles.contains(archivePath) && + !_shouldIgnoreImportedEntityName(path.posix.basename(archivePath))) { + issues.add( + ImportIssue( + path: archivePath, + code: ImportIssueCode.unsupportedFile, + ), + ); + } + } + return issues; + } + + Future _importLegacyZipArchiveFromEntries({ + required Archive archive, + required String? parentFolderId, + required String zipFileName, + }) async { + final filesByPath = {}; + for (final entry in archive) { + if (!entry.isFile) { + continue; + } + final normalizedPath = normalizeArchivePath(entry.name); + if (_shouldIgnoreImportedEntityName( + path.posix.basename(normalizedPath))) { + continue; + } + filesByPath[normalizedPath] = entry; + } + + final topLevelSegments = {}; + final looseTopLevelIca = []; + for (final archivePath in filesByPath.keys) { + final segments = archivePath.split('/'); + if (segments.isEmpty) { + continue; + } + topLevelSegments.add(segments.first); + if (segments.length == 1 && + path.extension(archivePath).toLowerCase() == '.ica') { + looseTopLevelIca.add(archivePath); + } + } + + if (topLevelSegments.length == 1 && looseTopLevelIca.isEmpty) { + return _importLegacyZipDirectory( + directoryPrefix: topLevelSegments.single, + filesByPath: filesByPath, + parentFolderId: parentFolderId, + ); + } + + final wrapperFolder = await _createImportedFolder( + name: zipFileName, + parentFolderId: parentFolderId, + ); + + return const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ).merge( + await _importLegacyZipEntitiesIntoFolder( + parentPrefix: '', + filesByPath: filesByPath, + parentFolderId: wrapperFolder.id, + ), + ); + } + + Future _importLegacyZipDirectory({ + required String directoryPrefix, + required Map filesByPath, + required String? parentFolderId, + }) async { + final importedFolder = await _createImportedFolder( + name: path.posix.basename(directoryPrefix), + parentFolderId: parentFolderId, + ); + + return const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ).merge( + await _importLegacyZipEntitiesIntoFolder( + parentPrefix: directoryPrefix, + filesByPath: filesByPath, + parentFolderId: importedFolder.id, + ), + ); + } + + Future _importLegacyZipEntitiesIntoFolder({ + required String parentPrefix, + required Map filesByPath, + required String parentFolderId, + }) async { + final directDirectories = {}; + final directFiles = []; + final normalizedParentPrefix = normalizeArchivePath(parentPrefix); + + for (final archivePath in filesByPath.keys) { + final parentPath = path.posix.dirname(archivePath); + if (normalizedParentPrefix.isEmpty) { + if (parentPath == '.') { + directFiles.add(archivePath); + } else if (!parentPath.contains('/')) { + directDirectories.add(parentPath); + } + continue; + } + + if (parentPath == normalizedParentPrefix) { + directFiles.add(archivePath); + continue; + } + + if (archivePath.startsWith('$normalizedParentPrefix/')) { + final remainder = + archivePath.substring(normalizedParentPrefix.length + 1); + if (remainder.isEmpty || !remainder.contains('/')) { + continue; + } + final childDirectory = remainder.substring(0, remainder.indexOf('/')); + directDirectories.add( + normalizeArchivePath( + path.posix.join(normalizedParentPrefix, childDirectory), + ), + ); + } + } + + var result = const ImportBatchResult.empty(); + + final tempDirectory = + await Directory.systemTemp.createTemp('icarus-zip-legacy-import'); + try { + final sortedDirectories = directDirectories.toList()..sort(); + for (final directoryPrefix in sortedDirectories) { + result = result.merge( + await _importLegacyZipDirectory( + directoryPrefix: directoryPrefix, + filesByPath: filesByPath, + parentFolderId: parentFolderId, + ), + ); + } + + directFiles.sort(); + for (final archivePath in directFiles) { + if (path.extension(archivePath).toLowerCase() != '.ica') { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.unsupportedFile, + ), + ], + ), + ); + continue; + } + + try { + final tempFile = await _writeArchiveEntryToTempFile( + archiveFile: filesByPath[archivePath]!, + tempDirectory: tempDirectory, + ); + await _importStrategyFile( + file: XFile(tempFile.path), + targetFolderId: parentFolderId, + ); + result = result.merge( + const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } on NewerVersionImportException { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.newerVersion, + ), + ], + ), + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import zip strategy $archivePath.', + error: error, + stackTrace: stackTrace, + source: + 'StrategyImportExportService._importLegacyZipEntitiesIntoFolder', + ); + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.invalidStrategy, + ), + ], + ), + ); + } + } + } finally { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + + return result; + } + + Future<_ManifestImportData?> _loadManifestIfPresent( + Directory directory, + ) async { + final manifestFile = + File(path.join(directory.path, archiveMetadataFileName)); + if (!await manifestFile.exists()) { + return null; + } + + final raw = await manifestFile.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw const FormatException('Archive metadata must decode to an object'); + } + + return _ManifestImportData( + rootDirectory: directory, + manifestFile: manifestFile, + manifest: ArchiveManifest.fromJson(decoded), + ); + } + + Future _importManifestArchive({ + required _ManifestImportData manifestData, + required String? parentFolderId, + }) async { + _validateArchiveManifest(manifestData); + + var result = const ImportBatchResult.empty(); + var profileIdRemap = const {}; + + if (manifestData.manifest.archiveType == ArchiveType.libraryBackup) { + final globals = manifestData.manifest.globals; + if (globals == null) { + throw const FormatException( + 'Library backup archive is missing globals'); + } + final globalImportResult = await _importArchiveGlobals(globals); + profileIdRemap = globalImportResult.profileIdRemap; + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: globalImportResult.themeProfilesImported, + globalStateRestored: globalImportResult.globalStateRestored, + issues: const [], + ), + ); + } + + final folderEntries = [...manifestData.manifest.folders]..sort((a, b) { + final depthCompare = _archivePathDepth(a.archivePath) + .compareTo(_archivePathDepth(b.archivePath)); + if (depthCompare != 0) { + return depthCompare; + } + return a.archivePath.compareTo(b.archivePath); + }); + + final localFolderIdsByManifestId = {}; + for (final folderEntry in folderEntries) { + final resolvedParentFolderId = folderEntry.parentManifestId == null + ? (manifestData.manifest.archiveType == ArchiveType.folderTree + ? parentFolderId + : null) + : localFolderIdsByManifestId[folderEntry.parentManifestId!]; + if (folderEntry.parentManifestId != null && + resolvedParentFolderId == null) { + throw FormatException( + 'Missing parent folder mapping for ${folderEntry.manifestId}', + ); + } + + final createdFolder = + await ref.read(folderProvider.notifier).createFolder( + name: folderEntry.name, + icon: folderEntry.icon.toIconData(), + color: folderEntry.color, + customColor: folderEntry.customColorValue == null + ? null + : Color(folderEntry.customColorValue!), + parentID: resolvedParentFolderId, + ); + localFolderIdsByManifestId[folderEntry.manifestId] = createdFolder.id; + result = result.merge( + const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } + + final strategyEntries = [...manifestData.manifest.strategies] + ..sort((a, b) => a.archivePath.compareTo(b.archivePath)); + for (final strategyEntry in strategyEntries) { + final targetFolderId = strategyEntry.folderManifestId == null + ? null + : localFolderIdsByManifestId[strategyEntry.folderManifestId!]; + if (strategyEntry.folderManifestId != null && targetFolderId == null) { + throw FormatException( + 'Missing folder mapping for strategy ${strategyEntry.archivePath}', + ); + } + + try { + await _importStrategyFile( + file: XFile( + _archivePathToFile( + manifestData.rootDirectory, + strategyEntry.archivePath, + ).path, + ), + targetFolderId: targetFolderId, + displayNameOverride: strategyEntry.name, + themeProfileIdRemap: profileIdRemap, + ); + result = result.merge( + const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } on NewerVersionImportException { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: strategyEntry.archivePath, + code: ImportIssueCode.newerVersion, + ), + ], + ), + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import manifest strategy ${strategyEntry.archivePath}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importManifestArchive', + ); + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: strategyEntry.archivePath, + code: ImportIssueCode.invalidStrategy, + ), + ], + ), + ); + } + } + + final undeclaredIssues = await _collectUndeclaredArchiveIssues( + manifestData: manifestData, + ); + if (undeclaredIssues.isNotEmpty) { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: undeclaredIssues, + ), + ); + } + + return result; + } + + void _validateArchiveManifest(_ManifestImportData manifestData) { + final manifest = manifestData.manifest; + final folderIds = {}; + final folderPaths = {}; + final rootFolders = []; + + for (final folder in manifest.folders) { + if (!folderIds.add(folder.manifestId)) { + throw FormatException( + 'Duplicate folder manifest ID: ${folder.manifestId}'); + } + if (!folderPaths.add(folder.archivePath)) { + throw FormatException( + 'Duplicate folder archive path: ${folder.archivePath}'); + } + if (folder.parentManifestId == null) { + rootFolders.add(folder); + } else if (!manifest.folders.any( + (candidate) => candidate.manifestId == folder.parentManifestId)) { + throw FormatException('Missing parent folder for ${folder.manifestId}'); + } + } + + if (manifest.archiveType == ArchiveType.folderTree) { + if (rootFolders.length != 1) { + throw const FormatException( + 'Folder tree archives must contain one root'); + } + if (rootFolders.single.archivePath.isNotEmpty) { + throw const FormatException( + 'Folder tree root folder must use the manifest root path', + ); + } + } + + final knownFolderIds = + manifest.folders.map((folder) => folder.manifestId).toSet(); + final strategyPaths = {}; + for (final strategy in manifest.strategies) { + if (!strategyPaths.add(strategy.archivePath)) { + throw FormatException( + 'Duplicate strategy archive path: ${strategy.archivePath}', + ); + } + if (strategy.folderManifestId != null && + !knownFolderIds.contains(strategy.folderManifestId)) { + throw FormatException( + 'Unknown strategy folder reference: ${strategy.folderManifestId}', + ); + } + if (manifest.archiveType == ArchiveType.folderTree && + strategy.folderManifestId == null) { + throw const FormatException( + 'Folder tree strategies must reference the exported root folder', + ); + } + if (!_archivePathToFile(manifestData.rootDirectory, strategy.archivePath) + .existsSync()) { + throw FormatException('Missing strategy file: ${strategy.archivePath}'); + } + } + } + + int _archivePathDepth(String archivePath) { + if (archivePath.isEmpty) { + return 0; + } + return archivePath.split('/').length; + } + + File _archivePathToFile(Directory rootDirectory, String archivePath) { + final normalized = normalizeArchivePath(archivePath); + final segments = + normalized.isEmpty ? const [] : normalized.split('/'); + return File(path.joinAll([rootDirectory.path, ...segments])); + } + + Future> _collectUndeclaredArchiveIssues({ + required _ManifestImportData manifestData, + }) async { + final allowedFiles = {archiveMetadataFileName}; + final allowedDirectories = {}; + + void addAllowedDirectoryAncestors(String archivePath) { + var current = normalizeArchivePath(archivePath); + if (current.isEmpty) { + return; + } + while (current.isNotEmpty && current != '.') { + allowedDirectories.add(current); + final parent = path.posix.dirname(current); + if (parent == '.' || parent == current) { + break; + } + current = parent; + } + } + + for (final folder in manifestData.manifest.folders) { + addAllowedDirectoryAncestors(folder.archivePath); + } + for (final strategy in manifestData.manifest.strategies) { + final normalizedPath = normalizeArchivePath(strategy.archivePath); + allowedFiles.add(normalizedPath); + final parentDirectory = path.posix.dirname(normalizedPath); + if (parentDirectory != '.') { + addAllowedDirectoryAncestors(parentDirectory); + } + } + + final issues = []; + await for (final entity in manifestData.rootDirectory + .list(recursive: true, followLinks: false)) { + final relativePath = normalizeArchivePath( + path.relative(entity.path, from: manifestData.rootDirectory.path), + ); + if (relativePath.isEmpty) { + continue; + } + + if (entity is File) { + if (!allowedFiles.contains(relativePath)) { + issues.add( + ImportIssue( + path: entity.path, + code: ImportIssueCode.unsupportedFile, + ), + ); + } + continue; + } + + final directoryAllowed = allowedDirectories.contains(relativePath) || + allowedFiles.any((allowed) => allowed.startsWith('$relativePath/')); + if (!directoryAllowed) { + issues.add( + ImportIssue( + path: entity.path, + code: ImportIssueCode.unsupportedFile, + ), + ); + } + } + + return issues; + } + + Future<_GlobalImportResult> _importArchiveGlobals( + ArchiveGlobals globals, + ) async { + await MapThemeProfilesProvider.bootstrap(); + + final profileBox = + Hive.box(HiveBoxNames.mapThemeProfilesBox); + final appPreferencesBox = + Hive.box(HiveBoxNames.appPreferencesBox); + final favoriteAgentsBox = Hive.box(HiveBoxNames.favoriteAgentsBox); + + final profileIdRemap = {}; + var themeProfilesImported = 0; + + final existingProfiles = profileBox.values.toList(); + for (final importedProfile in globals.themeProfiles) { + if (importedProfile.isBuiltIn) { + if (profileBox.get(importedProfile.id) != null) { + profileIdRemap[importedProfile.id] = importedProfile.id; + } + continue; + } + + final matchingExisting = existingProfiles.firstWhere( + (existing) => + !existing.isBuiltIn && + existing.name == importedProfile.name && + existing.palette == importedProfile.palette, + orElse: () => MapThemeProfile( + id: '', + name: '', + palette: MapThemeProfilesProvider.immutableDefaultPalette, + isBuiltIn: false, + ), + ); + + if (matchingExisting.id.isNotEmpty) { + profileIdRemap[importedProfile.id] = matchingExisting.id; + continue; + } + + var localProfileId = importedProfile.id; + if (profileBox.get(localProfileId) != null || + MapThemeProfilesProvider.immutableBuiltInProfiles + .any((profile) => profile.id == localProfileId)) { + localProfileId = const Uuid().v4(); + } + + final createdProfile = MapThemeProfile( + id: localProfileId, + name: importedProfile.name, + palette: importedProfile.palette, + isBuiltIn: false, + ); + await profileBox.put(createdProfile.id, createdProfile); + existingProfiles.add(createdProfile); + profileIdRemap[importedProfile.id] = createdProfile.id; + themeProfilesImported++; + } + + final resolvedDefaultProfileId = globals + .defaultThemeProfileIdForNewStrategies == + null + ? MapThemeProfilesProvider.immutableDefaultProfileId + : profileIdRemap[globals.defaultThemeProfileIdForNewStrategies!] ?? + (profileBox.get(globals.defaultThemeProfileIdForNewStrategies!) != + null + ? globals.defaultThemeProfileIdForNewStrategies! + : MapThemeProfilesProvider.immutableDefaultProfileId); + + await appPreferencesBox.put( + MapThemeProfilesProvider.appPreferencesSingletonKey, + (appPreferencesBox + .get(MapThemeProfilesProvider.appPreferencesSingletonKey) ?? + AppPreferences( + defaultThemeProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfileId, + )) + .copyWith( + defaultThemeProfileIdForNewStrategies: resolvedDefaultProfileId, + ), + ); + + await favoriteAgentsBox.clear(); + for (final favorite in globals.favoriteAgentTypes()) { + await favoriteAgentsBox.put(favorite.name, true); + } + + await ref.read(mapThemeProfilesProvider.notifier).refreshFromHive(); + await ref.read(appPreferencesProvider.notifier).refreshFromHive(); + ref.invalidate(favoriteAgentsProvider); + + return _GlobalImportResult( + themeProfilesImported: themeProfilesImported, + globalStateRestored: true, + profileIdRemap: profileIdRemap, + ); + } + + Future _importStrategyFile({ + required XFile file, + required String? targetFolderId, + String? displayNameOverride, + Map themeProfileIdRemap = const {}, + }) async { + final newID = const Uuid().v4(); + final isZip = await isZipFile(File(file.path)); + + log('Is ZIP file: $isZip'); + final bytes = await file.readAsBytes(); + String jsonData = ''; + + try { + if (isZip) { + final archive = ZipDecoder().decodeBytes(bytes); + + final imageFolder = await PlacedImageProvider.getImageFolder(newID); + final tempDirectory = await getTempDirectory(newID); + + await _extractArchiveEntriesToDisk( + archive: archive, + destination: tempDirectory, + ); + + final tempDirectoryList = tempDirectory.listSync(); + log('Temp directory list: ${tempDirectoryList.length}.'); + + for (final fileEntity in tempDirectoryList) { + if (fileEntity is File) { + log(fileEntity.path); + if (path.extension(fileEntity.path) == '.json') { + log('Found JSON file'); + jsonData = await fileEntity.readAsString(); + } else if (path.extension(fileEntity.path) != '.ica') { + final fileName = path.basename(fileEntity.path); + await fileEntity.copy(path.join(imageFolder.path, fileName)); + } + } + } + if (jsonData.isEmpty) { + throw Exception('No .ica file found'); + } + } else { + jsonData = await file.readAsString(); + } + + final json = jsonDecode(jsonData) as Map; + final versionNumber = int.tryParse(json['versionNumber'].toString()) ?? + Settings.versionNumber; + _throwIfImportedVersionIsTooNew(versionNumber); + + final drawingData = + DrawingProvider.fromJson(jsonEncode(json['drawingData'] ?? [])); + final agentData = + AgentProvider.fromJson(jsonEncode(json['agentData'] ?? [])) + .whereType() + .toList(growable: false); + final abilityData = + AbilityProvider.fromJson(jsonEncode(json['abilityData'] ?? [])); + final mapData = MapProvider.fromJson(jsonEncode(json['mapData'])); + final textData = + TextProvider.fromJson(jsonEncode(json['textData'] ?? [])); + + List imageData = []; + if (!kIsWeb) { + if (isZip) { + imageData = await PlacedImageProvider.fromJson( + jsonString: jsonEncode(json['imageData'] ?? []), + strategyID: newID, + ); + } else { + log('Legacy image data loading'); + imageData = await PlacedImageProvider.legacyFromJson( + jsonString: jsonEncode(json['imageData'] ?? []), + strategyID: newID, + ); + } + } + + final StrategySettings settingsData; + final bool isAttack; + final List utilityData; + + if (json['settingsData'] != null) { + settingsData = ref + .read(strategySettingsProvider.notifier) + .fromJson(jsonEncode(json['settingsData'])); + } else { + settingsData = StrategySettings(); + } + + if (json['isAttack'] != null) { + isAttack = json['isAttack'] == 'true' ? true : false; + } else { + isAttack = true; + } + + if (json['utilityData'] != null) { + utilityData = UtilityProvider.fromJson(jsonEncode(json['utilityData'])); + } else { + utilityData = []; + } + + final importedThemeOverridePalette = + json['themePalette'] is Map + ? MapThemePalette.fromJson(json['themePalette']) + : (json['themePalette'] is Map + ? MapThemePalette.fromJson( + Map.from(json['themePalette']), + ) + : null); + final rawImportedThemeProfileId = json['themeProfileId']; + final importedThemeProfileId = rawImportedThemeProfileId is String && + rawImportedThemeProfileId.isNotEmpty + ? rawImportedThemeProfileId + : null; + final resolvedThemeProfileId = importedThemeProfileId == null + ? null + : (themeProfileIdRemap[importedThemeProfileId] ?? + importedThemeProfileId); + + final pages = json['pages'] != null + ? await StrategyPage.listFromJson( + json: jsonEncode(json['pages']), + strategyID: newID, + isZip: isZip, + ) + : []; + + var newStrategy = StrategyData( + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + drawingData: drawingData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + agentData: agentData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + abilityData: abilityData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + textData: textData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + imageData: imageData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + utilityData: utilityData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + isAttack: isAttack, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + strategySettings: settingsData, + pages: pages, + id: newID, + name: displayNameOverride ?? path.basenameWithoutExtension(file.name), + mapData: mapData, + versionNumber: versionNumber, + lastEdited: DateTime.now(), + folderID: targetFolderId, + themeProfileId: resolvedThemeProfileId, + themeOverridePalette: resolvedThemeProfileId == null + ? importedThemeOverridePalette + : null, + ); + + newStrategy = await StrategyMigrator.migrateLegacyData(newStrategy); + + await Hive.box(HiveBoxNames.strategiesBox) + .put(newStrategy.id, newStrategy); + } finally { + if (isZip) { + try { + await cleanUpTempDirectory(newID); + } catch (_) {} + } + } + } + + static bool isNewerVersionImportError(Object error) { + return error is NewerVersionImportException; + } + + @visibleForTesting + static void throwIfImportedVersionIsTooNewForTest(int importedVersion) { + _throwIfImportedVersionIsTooNew(importedVersion); + } + + static void _throwIfImportedVersionIsTooNew(int importedVersion) { + if (importedVersion <= Settings.versionNumber) { + return; + } + + throw NewerVersionImportException( + importedVersion: importedVersion, + currentVersion: Settings.versionNumber, + ); + } + + Future _flushCurrentStrategyIfNeeded() async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + if (strategyState.strategyName == null || strategyId == null) { + return; + } + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); + } + + Future exportFolder(String folderID) async { + final folder = Hive.box(HiveBoxNames.foldersBox).get(folderID); + if (folder == null) { + log("Couldn't find folder to export"); + return; + } + + await _flushCurrentStrategyIfNeeded(); + final stagingDirectory = await buildFolderExportDirectoryForTest(folderID); + + try { + final outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: '${sanitizeStrategyFileName(folder.name)}.zip', + allowedExtensions: ['zip'], + ); + + if (outputFile == null) return; + + final encoder = ZipFileEncoder(); + encoder.create(outputFile); + await encoder.addDirectory(stagingDirectory, includeDirName: false); + await encoder.close(); + } finally { + try { + await stagingDirectory.delete(recursive: true); + } catch (_) {} + } + } + + Future exportLibrary() async { + await _flushCurrentStrategyIfNeeded(); + final stagingDirectory = await buildLibraryExportDirectoryForTest(); + + try { + final outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: buildLibraryBackupFileName(DateTime.now()), + allowedExtensions: ['zip'], + ); + + if (outputFile == null) return; + + final encoder = ZipFileEncoder(); + encoder.create(outputFile); + await encoder.addDirectory(stagingDirectory, includeDirName: false); + await encoder.close(); + } finally { + try { + await stagingDirectory.delete(recursive: true); + } catch (_) {} + } + } + + @visibleForTesting + Future buildFolderExportDirectoryForTest(String folderID) async { + final folder = Hive.box(HiveBoxNames.foldersBox).get(folderID); + if (folder == null) { + throw StateError("Couldn't find folder to export"); + } + + final stagingDirectory = + await Directory.systemTemp.createTemp('icarus-folder-export'); + final rootDirectory = await _createUniqueChildDirectory( + parentDirectory: stagingDirectory, + desiredName: folder.name, + ); + final exportState = _ArchiveExportState(rootDirectory: rootDirectory); + await _writeFolderArchive( + folderID: folderID, + exportDirectory: rootDirectory, + exportState: exportState, + parentManifestId: null, + currentArchivePath: '', + ); + await _writeArchiveManifest( + exportState: exportState, + archiveType: ArchiveType.folderTree, + ); + return stagingDirectory; + } + + @visibleForTesting + Future buildLibraryExportDirectoryForTest() async { + final stagingDirectory = + await Directory.systemTemp.createTemp('icarus-library-export'); + final rootDirectory = Directory( + path.join(stagingDirectory.path, libraryBackupRootDirectoryName), + ); + await rootDirectory.create(recursive: true); + final rootStrategiesDirectory = + Directory(path.join(rootDirectory.path, 'root_strategies')) + ..createSync(recursive: true); + final foldersDirectory = Directory(path.join(rootDirectory.path, 'folders')) + ..createSync(recursive: true); + + final exportState = _ArchiveExportState(rootDirectory: rootDirectory); + + for (final strategy in _sortedStrategiesForFolder(null)) { + final strategyArchivePath = await zipStrategy( + id: strategy.id, + saveDir: rootStrategiesDirectory, + ); + exportState.strategies.add( + ArchiveStrategyEntry( + name: strategy.name, + archivePath: normalizeArchivePath(path.posix.join( + 'root_strategies', + path.basename(strategyArchivePath), + )), + folderManifestId: null, + ), + ); + } + + for (final rootFolder in _sortedFoldersForParent(null)) { + final rootFolderDirectory = await _createUniqueChildDirectory( + parentDirectory: foldersDirectory, + desiredName: rootFolder.name, + ); + final rootArchivePath = normalizeArchivePath(path.posix.join( + 'folders', + path.basename(rootFolderDirectory.path), + )); + await _writeFolderArchive( + folderID: rootFolder.id, + exportDirectory: rootFolderDirectory, + exportState: exportState, + parentManifestId: null, + currentArchivePath: rootArchivePath, + ); + } + + await _writeArchiveManifest( + exportState: exportState, + archiveType: ArchiveType.libraryBackup, + globals: _buildLibraryGlobals(), + ); + return stagingDirectory; + } + + Future _createUniqueChildDirectory({ + required Directory parentDirectory, + required String desiredName, + }) async { + final sanitizedName = sanitizeStrategyFileName(desiredName); + var candidate = sanitizedName; + var counter = 1; + var directory = Directory(path.join(parentDirectory.path, candidate)); + while (await directory.exists()) { + candidate = '${sanitizedName}_$counter'; + counter++; + directory = Directory(path.join(parentDirectory.path, candidate)); + } + await directory.create(recursive: true); + return directory; + } + + Future _writeFolderArchive({ + required String folderID, + required Directory exportDirectory, + required _ArchiveExportState exportState, + required String? parentManifestId, + required String currentArchivePath, + }) async { + final currentFolder = + ref.read(folderProvider.notifier).findFolderByID(folderID); + if (currentFolder == null) { + return; + } + + final manifestId = const Uuid().v4(); + exportState.folders.add( + ArchiveFolderEntry( + manifestId: manifestId, + name: currentFolder.name, + parentManifestId: parentManifestId, + archivePath: normalizeArchivePath(currentArchivePath), + icon: ArchiveIconDescriptor.fromIconData(currentFolder.icon), + color: currentFolder.color, + customColorValue: currentFolder.customColor?.toARGB32(), + ), + ); + + for (final strategy in _sortedStrategiesForFolder(folderID)) { + final strategyArchivePath = await zipStrategy( + id: strategy.id, + saveDir: exportDirectory, + ); + exportState.strategies.add( + ArchiveStrategyEntry( + name: strategy.name, + archivePath: normalizeArchivePath(path.posix.join( + currentArchivePath, + path.basename(strategyArchivePath), + )), + folderManifestId: manifestId, + ), + ); + } + + for (final subFolder in _sortedFoldersForParent(folderID)) { + final childDirectory = await _createUniqueChildDirectory( + parentDirectory: exportDirectory, + desiredName: subFolder.name, + ); + final childArchivePath = normalizeArchivePath(path.posix.join( + currentArchivePath, + path.basename(childDirectory.path), + )); + await _writeFolderArchive( + folderID: subFolder.id, + exportDirectory: childDirectory, + exportState: exportState, + parentManifestId: manifestId, + currentArchivePath: childArchivePath, + ); + } + } + + List _sortedStrategiesForFolder(String? folderID) { + final strategies = Hive.box(HiveBoxNames.strategiesBox) + .values + .where((strategy) => strategy.folderID == folderID) + .toList(); + strategies.sort((a, b) { + final nameCompare = a.name.compareTo(b.name); + if (nameCompare != 0) { + return nameCompare; + } + return a.id.compareTo(b.id); + }); + return strategies; + } + + List _sortedFoldersForParent(String? parentID) { + final folders = Hive.box(HiveBoxNames.foldersBox) + .values + .where((folder) => folder.parentID == parentID) + .toList(); + folders.sort((a, b) { + final nameCompare = a.name.compareTo(b.name); + if (nameCompare != 0) { + return nameCompare; + } + return a.id.compareTo(b.id); + }); + return folders; + } + + ArchiveGlobals _buildLibraryGlobals() { + final profiles = Hive.box(HiveBoxNames.mapThemeProfilesBox) + .values + .map( + (profile) => ArchiveThemeProfileEntry( + id: profile.id, + name: profile.name, + palette: profile.palette, + isBuiltIn: profile.isBuiltIn, + ), + ) + .toList(growable: false); + final appPreferences = + Hive.box(HiveBoxNames.appPreferencesBox) + .get(MapThemeProfilesProvider.appPreferencesSingletonKey); + final favoriteAgents = Hive.box(HiveBoxNames.favoriteAgentsBox) + .keys + .whereType() + .toList() + ..sort(); + + return ArchiveGlobals( + themeProfiles: profiles, + defaultThemeProfileIdForNewStrategies: + appPreferences?.defaultThemeProfileIdForNewStrategies, + favoriteAgents: favoriteAgents, + ); + } + + Future _writeArchiveManifest({ + required _ArchiveExportState exportState, + required ArchiveType archiveType, + ArchiveGlobals? globals, + }) async { + final manifest = ArchiveManifest( + schemaVersion: archiveManifestSchemaVersion, + archiveType: archiveType, + exportedAt: DateTime.now().toUtc(), + appVersionNumber: Settings.versionNumber, + folders: exportState.folders, + strategies: exportState.strategies, + globals: globals, + ); + + final manifestFile = File( + path.join(exportState.rootDirectory.path, archiveMetadataFileName), + ); + await manifestFile.writeAsString( + const JsonEncoder.withIndent(' ').convert(manifest.toJson()), + ); + } + + MapThemePalette _resolveThemePaletteForExport(StrategyData strategy) { + if (strategy.themeOverridePalette != null) { + return strategy.themeOverridePalette!; + } + + final profiles = + Hive.box(HiveBoxNames.mapThemeProfilesBox); + final assignedProfile = strategy.themeProfileId == null + ? null + : profiles.get(strategy.themeProfileId!); + if (assignedProfile != null) { + return assignedProfile.palette; + } + + return MapThemeProfilesProvider.immutableDefaultPalette; + } + + Future zipStrategy({ + required String id, + Directory? saveDir, + String? outputFilePath, + }) async { + final strategy = Hive.box(HiveBoxNames.strategiesBox).get(id); + if (strategy == null) { + log("Couldn't find strategy to export"); + throw StateError("Couldn't find strategy to export"); + } + return zipStrategyData( + strategy: strategy, + saveDir: saveDir, + outputFilePath: outputFilePath, + ); + } + + Future zipStrategyData({ + required StrategyData strategy, + Directory? saveDir, + String? outputFilePath, + }) async { + + final payload = { + 'versionNumber': '${Settings.versionNumber}', + 'mapData': '${Maps.mapNames[strategy.mapData]}', + 'themePalette': _resolveThemePaletteForExport(strategy).toJson(), + if (strategy.themeProfileId != null) + 'themeProfileId': strategy.themeProfileId, + 'pages': strategy.pages.map((page) => page.toJson(strategy.id)).toList(), + }; + final data = jsonEncode(payload); + + final sanitizedStrategyName = sanitizeStrategyFileName(strategy.name); + + late final String outPath; + late final String archiveBase; + if (outputFilePath != null) { + outPath = outputFilePath; + archiveBase = path.basenameWithoutExtension(outPath); + } else { + final base = sanitizedStrategyName; + var candidate = base; + var index = 1; + while (File(path.join(saveDir!.path, '$candidate.ica')).existsSync()) { + candidate = '${base}_$index'; + index++; + } + archiveBase = candidate; + outPath = path.join(saveDir.path, '$archiveBase.ica'); + } + + final jsonArchiveFile = + ArchiveFile.bytes('$archiveBase.json', utf8.encode(data)); + + final zipEncoder = ZipFileEncoder()..create(outPath); + + final supportDirectory = + await _getApplicationSupportDirectoryOrSystemTemp(); + final customDirectory = + Directory(path.join(supportDirectory.path, strategy.id)); + final imagesDirectory = + Directory(path.join(customDirectory.path, 'images')); + await imagesDirectory.create(recursive: true); + + await for (final entity in imagesDirectory.list()) { + if (entity is File) { + await zipEncoder.addFile(entity); + } + } + + zipEncoder.addArchiveFile(jsonArchiveFile); + await zipEncoder.close(); + return outPath; + } + + Future exportCloudStrategy(String strategyId) async { + final snapshot = + await ref.read(convexStrategyRepositoryProvider).fetchSnapshot(strategyId); + final strategy = _strategyDataFromRemoteSnapshot(snapshot); + final outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: '${sanitizeStrategyFileName(strategy.name)}.ica', + allowedExtensions: ['ica'], + ); + if (outputFile == null) return; + await zipStrategyData(strategy: strategy, outputFilePath: outputFile); + } + + Future exportFile(String id) async { + await ref.read(strategyProvider.notifier).forceSaveNow(id); + + final outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: + '${sanitizeStrategyFileName(ref.read(strategyProvider).strategyName ?? "new strategy")}.ica', + allowedExtensions: ['ica'], + ); + + if (outputFile == null) return; + await zipStrategy(id: id, outputFilePath: outputFile); + } + + StrategyData _strategyDataFromRemoteSnapshot(RemoteStrategySnapshot snapshot) { + final pages = []; + final mapValue = Maps.mapNames.entries + .where((entry) => entry.value == snapshot.header.mapData) + .map((entry) => entry.key) + .first; + + for (final remotePage in snapshot.pages..sort((a, b) => a.sortIndex.compareTo(b.sortIndex))) { + final elements = snapshot.elementsByPage[remotePage.publicId] ?? const []; + final lineups = snapshot.lineupsByPage[remotePage.publicId] ?? const []; + final drawingData = []; + final agentData = []; + final abilityData = []; + final textData = []; + final imageData = []; + final utilityData = []; + + for (final element in elements) { + if (element.deleted) continue; + final payload = element.decodedPayload(); + try { + switch (element.elementType) { + case 'drawing': + final decoded = DrawingProvider.fromJson(jsonEncode([payload])); + if (decoded.isNotEmpty) drawingData.add(decoded.first); + break; + case 'agent': + agentData.add(PlacedAgentNode.fromJson(payload)); + break; + case 'ability': + abilityData.add(PlacedAbility.fromJson(payload)); + break; + case 'text': + textData.add(PlacedText.fromJson(payload)); + break; + case 'image': + imageData.add(PlacedImage.fromJson(payload)); + break; + case 'utility': + utilityData.add(PlacedUtility.fromJson(payload)); + break; + } + } catch (_) {} + } + + final parsedLineups = []; + for (final lineup in lineups) { + if (lineup.deleted) continue; + try { + final decoded = jsonDecode(lineup.payload); + if (decoded is Map) { + parsedLineups.add(LineUp.fromJson(decoded)); + } else if (decoded is Map) { + parsedLineups.add(LineUp.fromJson(Map.from(decoded))); + } + } catch (_) {} + } + + StrategySettings settings = StrategySettings(); + if (remotePage.settings != null && remotePage.settings!.isNotEmpty) { + try { + settings = StrategySettings.fromJson(jsonDecode(remotePage.settings!)); + } catch (_) {} + } + + pages.add( + StrategyPage( + id: remotePage.publicId, + name: remotePage.name, + drawingData: drawingData, + agentData: agentData, + abilityData: abilityData, + textData: textData, + imageData: imageData, + utilityData: utilityData, + sortIndex: remotePage.sortIndex, + isAttack: remotePage.isAttack, + settings: settings, + lineUps: parsedLineups, + ), + ); + } + + MapThemePalette? overridePalette; + final rawPalette = snapshot.header.themeOverridePalette; + if (rawPalette != null && rawPalette.isNotEmpty) { + try { + final decoded = jsonDecode(rawPalette); + if (decoded is Map) { + overridePalette = MapThemePalette.fromJson(decoded); + } else if (decoded is Map) { + overridePalette = MapThemePalette.fromJson( + Map.from(decoded), + ); + } + } catch (_) {} + } + + return StrategyData( + id: snapshot.header.publicId, + name: snapshot.header.name, + mapData: mapValue, + versionNumber: Settings.versionNumber, + lastEdited: snapshot.header.updatedAt, + createdAt: snapshot.header.createdAt, + folderID: null, + themeProfileId: snapshot.header.themeProfileId, + themeOverridePalette: overridePalette, + pages: pages, + ); + } +} diff --git a/lib/strategy/strategy_migrator.dart b/lib/strategy/strategy_migrator.dart new file mode 100644 index 00000000..33f694c2 --- /dev/null +++ b/lib/strategy/strategy_migrator.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/migrations/ability_scale_migration.dart'; +import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:uuid/uuid.dart'; + +class StrategyMigrator { + static Future migrateAllStrategies() async { + final box = Hive.box(HiveBoxNames.strategiesBox); + for (final strat in box.values) { + final legacyMigrated = await migrateLegacyData(strat); + final worldMigrated = migrateToWorld16x9(legacyMigrated); + final abilityScaleMigrated = migrateAbilityScale(worldMigrated); + final squareAoeMigrated = migrateSquareAoeCenter(abilityScaleMigrated); + final customCircleMigrated = + migrateCustomCircleWrapper(squareAoeMigrated); + if (customCircleMigrated != squareAoeMigrated) { + await box.put(customCircleMigrated.id, customCircleMigrated); + } else if (squareAoeMigrated != abilityScaleMigrated) { + await box.put(squareAoeMigrated.id, squareAoeMigrated); + } else if (abilityScaleMigrated != worldMigrated) { + await box.put(abilityScaleMigrated.id, abilityScaleMigrated); + } else if (worldMigrated != legacyMigrated) { + await box.put(worldMigrated.id, worldMigrated); + } else if (legacyMigrated != strat) { + await box.put(legacyMigrated.id, legacyMigrated); + } + } + } + + static StrategyData migrateAbilityScale(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= AbilityScaleMigration.version) { + return strat; + } + + final migratedPages = AbilityScaleMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ); + + final hasPageChanged = migratedPages.length == strat.pages.length && + migratedPages.asMap().entries.any((entry) { + final index = entry.key; + return entry.value != strat.pages[index]; + }); + + if (!hasPageChanged && !force) { + return strat; + } + + return strat.copyWith( + pages: migratedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateSquareAoeCenter(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= SquareAoeCenterMigration.version) { + return strat; + } + + final migratedPages = SquareAoeCenterMigration.migratePages( + pages: strat.pages, + ); + + final hasPageChanged = migratedPages.length == strat.pages.length && + migratedPages.asMap().entries.any((entry) { + final index = entry.key; + return entry.value != strat.pages[index]; + }); + + if (!hasPageChanged && !force) { + return strat; + } + + return strat.copyWith( + pages: migratedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateCustomCircleWrapper(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= CustomCircleWrapperMigration.version) { + return strat; + } + + final migratedPages = CustomCircleWrapperMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ); + + final hasPageChanged = migratedPages.length == strat.pages.length && + migratedPages.asMap().entries.any((entry) { + final index = entry.key; + return entry.value != strat.pages[index]; + }); + + if (!hasPageChanged && !force) { + return strat; + } + + return strat.copyWith( + pages: migratedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateToCurrentVersion(StrategyData strat, + {bool forceAbilityScale = false}) { + final worldMigrated = migrateToWorld16x9(strat); + final abilityScaleMigrated = + migrateAbilityScale(worldMigrated, force: forceAbilityScale); + final squareAoeMigrated = migrateSquareAoeCenter(abilityScaleMigrated); + return migrateCustomCircleWrapper(squareAoeMigrated); + } + + static Future migrateLegacyData(StrategyData strat) async { + if (strat.pages.isNotEmpty) { + return migrateToCurrentVersion(strat); + } + if (strat.versionNumber > 15) { + return migrateToCurrentVersion(strat); + } + final originalVersion = strat.versionNumber; + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + final abilityData = [...strat.abilityData]; + if (strat.versionNumber < 7) { + for (final a in abilityData) { + if (a.data.abilityData! is SquareAbility) { + a.position = a.position.translate(0, -7.5); + } + } + } + + final firstPage = StrategyPage( + id: const Uuid().v4(), + name: 'Page 1', + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + drawingData: [...strat.drawingData], + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + agentData: [...strat.agentData], + abilityData: abilityData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + textData: [...strat.textData], + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + imageData: [...strat.imageData], + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + utilityData: [...strat.utilityData], + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + isAttack: strat.isAttack, + // ignore: deprecated_member_use_from_same_package + settings: strat.strategySettings, + sortIndex: 0, + ); + + final updated = strat.copyWith( + pages: [firstPage], + agentData: [], + abilityData: [], + drawingData: [], + utilityData: [], + textData: [], + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + + final worldMigrated = migrateToWorld16x9(updated, + force: originalVersion < Settings.versionNumber); + final abilityScaleMigrated = migrateAbilityScale( + worldMigrated, + force: originalVersion < AbilityScaleMigration.version, + ); + final squareAoeMigrated = migrateSquareAoeCenter( + abilityScaleMigrated, + force: originalVersion < SquareAoeCenterMigration.version, + ); + return migrateCustomCircleWrapper( + squareAoeMigrated, + force: originalVersion < CustomCircleWrapperMigration.version, + ); + } + + static StrategyData migrateToWorld16x9(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= 38) return strat; + + const double normalizedHeight = 1000.0; + const double mapAspectRatio = 1.24; + const double worldAspectRatio = 16 / 9; + const mapWidth = normalizedHeight * mapAspectRatio; + const worldWidth = normalizedHeight * worldAspectRatio; + const padding = (worldWidth - mapWidth) / 2; + + Offset shift(Offset offset) => offset.translate(padding, 0); + + List shiftAgentNodes(List agents) { + return [ + for (final agent in agents) + switch (agent) { + PlacedAgent() => agent.copyWith(position: shift(agent.position)) + ..isDeleted = agent.isDeleted, + PlacedViewConeAgent() => + agent.copyWith(position: shift(agent.position)) + ..isDeleted = agent.isDeleted, + PlacedCircleAgent() => + agent.copyWith(position: shift(agent.position)) + ..isDeleted = agent.isDeleted, + }, + ]; + } + + List shiftAbilities(List abilities) { + return [ + for (final ability in abilities) + ability.copyWith(position: shift(ability.position)) + ..isDeleted = ability.isDeleted + ]; + } + + List shiftTexts(List texts) { + return [ + for (final text in texts) + text.copyWith( + position: shift(text.position), + ) + ]; + } + + List shiftImages(List images) { + return [ + for (final image in images) + image.copyWith(position: shift(image.position)) + ..isDeleted = image.isDeleted + ]; + } + + List shiftUtilities(List utilities) { + return [ + for (final utility in utilities) + PlacedUtility( + type: utility.type, + position: shift(utility.position), + id: utility.id, + angle: utility.angle, + customDiameter: utility.customDiameter, + customWidth: utility.customWidth, + customLength: utility.customLength, + customColorValue: utility.customColorValue, + customOpacityPercent: utility.customOpacityPercent, + ) + ..rotation = utility.rotation + ..length = utility.length + ..isDeleted = utility.isDeleted + ]; + } + + List shiftLineUps(List lineUps) { + return [ + for (final lineUp in lineUps) + () { + final shiftedAgent = lineUp.agent.copyWith( + position: shift(lineUp.agent.position), + )..isDeleted = lineUp.agent.isDeleted; + final shiftedAbility = lineUp.ability.copyWith( + position: shift(lineUp.ability.position), + )..isDeleted = lineUp.ability.isDeleted; + return lineUp.copyWith( + agent: shiftedAgent, + ability: shiftedAbility, + ); + }() + ]; + } + + BoundingBox? shiftBoundingBox(BoundingBox? boundingBox) { + if (boundingBox == null) return null; + return BoundingBox( + min: shift(boundingBox.min), + max: shift(boundingBox.max), + ); + } + + List shiftDrawings(List drawings) { + return drawings + .map((element) { + if (element is Line) { + return Line( + lineStart: shift(element.lineStart), + lineEnd: shift(element.lineEnd), + color: element.color, + thickness: element.thickness, + boundingBox: shiftBoundingBox(element.boundingBox), + isDotted: element.isDotted, + hasArrow: element.hasArrow, + id: element.id, + showTraversalTime: element.showTraversalTime, + traversalSpeedProfile: element.traversalSpeedProfile, + ); + } + if (element is FreeDrawing) { + final shiftedPoints = + element.listOfPoints.map(shift).toList(growable: false); + + return FreeDrawing( + listOfPoints: shiftedPoints, + color: element.color, + thickness: element.thickness, + boundingBox: shiftBoundingBox(element.boundingBox), + isDotted: element.isDotted, + hasArrow: element.hasArrow, + id: element.id, + showTraversalTime: element.showTraversalTime, + traversalSpeedProfile: element.traversalSpeedProfile, + ); + } + if (element is RectangleDrawing) { + return RectangleDrawing( + start: shift(element.start), + end: shift(element.end), + color: element.color, + thickness: element.thickness, + boundingBox: shiftBoundingBox(element.boundingBox), + isDotted: element.isDotted, + hasArrow: element.hasArrow, + id: element.id, + ); + } + return element; + }) + .cast() + .toList(growable: false); + } + + final updatedPages = strat.pages + .map((page) => page.copyWith( + sortIndex: page.sortIndex, + name: page.name, + id: page.id, + agentData: shiftAgentNodes(page.agentData), + abilityData: shiftAbilities(page.abilityData), + textData: shiftTexts(page.textData), + imageData: shiftImages(page.imageData), + utilityData: shiftUtilities(page.utilityData), + drawingData: shiftDrawings(page.drawingData), + lineUps: shiftLineUps(page.lineUps), + )) + .toList(growable: false); + + final migrated = strat.copyWith( + pages: updatedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + + return migrated; + } +} diff --git a/lib/strategy/strategy_models.dart b/lib/strategy/strategy_models.dart new file mode 100644 index 00000000..e9058a18 --- /dev/null +++ b/lib/strategy/strategy_models.dart @@ -0,0 +1,161 @@ +import 'package:hive_ce/hive.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/map_theme_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class StrategyData extends HiveObject { + final String id; + String name; + final int versionNumber; + + @Deprecated('Use pages instead') + final List drawingData; + + @Deprecated('Use pages instead') + final List agentData; + + @Deprecated('Use pages instead') + final List abilityData; + + @Deprecated('Use pages instead') + final List textData; + + @Deprecated('Use pages instead') + final List imageData; + + @Deprecated('Use pages instead') + final List utilityData; + + @Deprecated('Use pages instead') + final bool isAttack; + + @Deprecated('Use pages instead') + final StrategySettings strategySettings; + + final List pages; + final MapValue mapData; + final DateTime lastEdited; + final DateTime createdAt; + + String? folderID; + final String? themeProfileId; + final MapThemePalette? themeOverridePalette; + + StrategyData({ + @Deprecated('Use pages instead') this.isAttack = true, + @Deprecated('Use pages instead') this.drawingData = const [], + @Deprecated('Use pages instead') this.agentData = const [], + @Deprecated('Use pages instead') this.abilityData = const [], + @Deprecated('Use pages instead') this.textData = const [], + @Deprecated('Use pages instead') this.imageData = const [], + @Deprecated('Use pages instead') this.utilityData = const [], + required this.id, + required this.name, + required this.mapData, + required this.versionNumber, + required this.lastEdited, + required this.folderID, + this.themeProfileId, + this.themeOverridePalette, + this.pages = const [], + DateTime? createdAt, + @Deprecated('Use pages instead') StrategySettings? strategySettings, + // ignore: deprecated_member_use_from_same_package + }) : strategySettings = strategySettings ?? StrategySettings(), + createdAt = createdAt ?? lastEdited; + + StrategyData copyWith({ + String? id, + String? name, + int? versionNumber, + List? drawingData, + List? agentData, + List? abilityData, + List? textData, + List? imageData, + List? utilityData, + List? pages, + MapValue? mapData, + DateTime? lastEdited, + bool? isAttack, + StrategySettings? strategySettings, + String? folderID, + DateTime? createdAt, + String? themeProfileId, + bool clearThemeProfileId = false, + MapThemePalette? themeOverridePalette, + bool clearThemeOverridePalette = false, + }) { + return StrategyData( + id: id ?? this.id, + name: name ?? this.name, + versionNumber: versionNumber ?? this.versionNumber, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + drawingData: drawingData ?? this.drawingData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + agentData: agentData ?? this.agentData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + abilityData: abilityData ?? this.abilityData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + textData: textData ?? this.textData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + imageData: imageData ?? this.imageData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + utilityData: utilityData ?? this.utilityData, + pages: pages ?? this.pages, + mapData: mapData ?? this.mapData, + lastEdited: lastEdited ?? this.lastEdited, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + isAttack: isAttack ?? this.isAttack, + // ignore: deprecated_member_use_from_same_package + strategySettings: strategySettings ?? this.strategySettings, + createdAt: createdAt ?? this.createdAt, + folderID: folderID ?? this.folderID, + themeProfileId: + clearThemeProfileId ? null : (themeProfileId ?? this.themeProfileId), + themeOverridePalette: clearThemeOverridePalette + ? null + : (themeOverridePalette ?? this.themeOverridePalette), + ); + } +} + +class StrategyState { + const StrategyState({ + required this.strategyId, + required this.strategyName, + required this.source, + this.storageDirectory, + this.isOpen = false, + }); + + final String? strategyId; + final String? strategyName; + final StrategySource? source; + final String? storageDirectory; + final bool isOpen; + + StrategyState copyWith({ + String? strategyId, + String? strategyName, + StrategySource? source, + String? storageDirectory, + bool? isOpen, + bool clearStrategyId = false, + bool clearStrategyName = false, + bool clearSource = false, + }) { + return StrategyState( + strategyId: clearStrategyId ? null : (strategyId ?? this.strategyId), + strategyName: + clearStrategyName ? null : (strategyName ?? this.strategyName), + source: clearSource ? null : (source ?? this.source), + storageDirectory: storageDirectory ?? this.storageDirectory, + isOpen: isOpen ?? this.isOpen, + ); + } +} diff --git a/lib/strategy/strategy_page_apply.dart b/lib/strategy/strategy_page_apply.dart new file mode 100644 index 00000000..f150921c --- /dev/null +++ b/lib/strategy/strategy_page_apply.dart @@ -0,0 +1,43 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/providers/ability_provider.dart'; +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/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/utility_provider.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +Future applyStrategyEditorPageData( + Ref ref, + StrategyEditorPageData data, { + required String themeProfileId, + required MapThemePalette? themeOverridePalette, +}) async { + ref.read(actionProvider.notifier).resetActionState(); + ref.read(agentProvider.notifier).fromHive(data.agents); + ref.read(abilityProvider.notifier).fromHive(data.abilities); + ref.read(drawingProvider.notifier).fromHive(data.drawings); + ref.read(textProvider.notifier).fromHive(data.texts); + ref.read(placedImageProvider.notifier).fromHive(data.images); + ref.read(utilityProvider.notifier).fromHive(data.utilities); + ref.read(lineUpProvider.notifier).fromHive(data.lineups); + ref.read(mapProvider.notifier).fromHive(data.map, data.isAttack); + ref.read(strategySettingsProvider.notifier).fromHive(data.settings); + ref.read(strategyThemeProvider.notifier).fromStrategy( + profileId: themeProfileId, + overridePalette: themeOverridePalette, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref + .read(drawingProvider.notifier) + .rebuildAllPaths(CoordinateSystem.instance); + }); +} diff --git a/lib/strategy/strategy_page_models.dart b/lib/strategy/strategy_page_models.dart new file mode 100644 index 00000000..6e1ac9e6 --- /dev/null +++ b/lib/strategy/strategy_page_models.dart @@ -0,0 +1,37 @@ +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +enum StrategySource { local, cloud } + +class StrategyEditorPageData { + const StrategyEditorPageData({ + required this.pageId, + required this.pageName, + required this.isAttack, + required this.map, + required this.settings, + required this.agents, + required this.abilities, + required this.drawings, + required this.texts, + required this.images, + required this.utilities, + required this.lineups, + }); + + final String pageId; + final String pageName; + final bool isAttack; + final MapValue map; + final StrategySettings settings; + final List agents; + final List abilities; + final List drawings; + final List texts; + final List images; + final List utilities; + final List lineups; +} diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart new file mode 100644 index 00000000..3cbec041 --- /dev/null +++ b/lib/strategy/strategy_page_source.dart @@ -0,0 +1,520 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/drawing_element.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/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_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'; +import 'package:icarus/providers/image_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/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(); + Future loadPage(String pageId); + Future flushCurrentPage(); +} + +class LocalStrategyPageSource implements StrategyPageSource { + LocalStrategyPageSource( + this.ref, { + required this.strategyId, + required this.activePageId, + }); + + final Ref ref; + final String strategyId; + final String? Function() activePageId; + + @override + Future> listPageIds() async { + final strategy = Hive.box(HiveBoxNames.strategiesBox).get( + strategyId, + ); + if (strategy == null) { + return const []; + } + final pages = [...strategy.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + return pages.map((page) => page.id).toList(growable: false); + } + + @override + Future loadPage(String pageId) async { + final box = Hive.box(HiveBoxNames.strategiesBox); + final current = box.get(strategyId); + if (current == null) { + throw StateError('Strategy $strategyId not found.'); + } + + final migrated = StrategyMigrator.migrateToCurrentVersion(current); + if (!identical(current, migrated)) { + await box.put(migrated.id, migrated); + } + + final orderedPages = [...migrated.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final page = orderedPages.firstWhere( + (entry) => entry.id == pageId, + orElse: () => orderedPages.first, + ); + + return StrategyEditorPageData( + pageId: page.id, + pageName: page.name, + isAttack: page.isAttack, + map: migrated.mapData, + settings: page.settings, + agents: page.agentData, + abilities: page.abilityData, + drawings: page.drawingData, + texts: page.textData, + images: page.imageData, + utilities: page.utilityData, + lineups: page.lineUps, + ); + } + + @override + Future flushCurrentPage() async { + final box = Hive.box(HiveBoxNames.strategiesBox); + final strategy = box.get(strategyId); + if (strategy == null || strategy.pages.isEmpty) { + return; + } + + final pageId = activePageId() ?? strategy.pages.first.id; + final index = strategy.pages.indexWhere((page) => page.id == pageId); + if (index < 0) { + return; + } + + final updatedPage = strategy.pages[index].copyWith( + drawingData: ref.read(drawingProvider).elements, + agentData: ref.read(agentProvider), + abilityData: ref.read(abilityProvider), + textData: ref.read(textProvider.notifier).snapshotForPersistence(), + imageData: ref.read(placedImageProvider).images, + utilityData: ref.read(utilityProvider), + isAttack: ref.read(mapProvider).isAttack, + settings: ref.read(strategySettingsProvider), + lineUps: ref.read(lineUpProvider).lineUps, + ); + + final strategyTheme = ref.read(strategyThemeProvider); + final updatedPages = [...strategy.pages]..[index] = updatedPage; + final updated = strategy.copyWith( + pages: updatedPages, + mapData: ref.read(mapProvider).currentMap, + themeProfileId: strategyTheme.profileId, + clearThemeProfileId: strategyTheme.profileId == null, + themeOverridePalette: strategyTheme.overridePalette, + clearThemeOverridePalette: strategyTheme.overridePalette == null, + lastEdited: DateTime.now(), + ); + await box.put(updated.id, updated); + } +} + +class CloudStrategyPageSource implements StrategyPageSource { + CloudStrategyPageSource( + this.ref, { + required this.strategyId, + required this.activePageId, + }); + + final Ref ref; + final String strategyId; + final String? Function() activePageId; + + RemoteStrategySnapshot get _snapshot { + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null) { + throw StateError('Remote snapshot unavailable for $strategyId.'); + } + return snapshot; + } + + @override + Future> listPageIds() async { + final pages = [..._snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + return pages.map((page) => page.publicId).toList(growable: false); + } + + @override + Future loadPage(String pageId) async { + final snapshot = _snapshot; + final pages = [...snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final page = pages.firstWhere( + (entry) => entry.publicId == pageId, + orElse: () => pages.first, + ); + + final elements = snapshot.elementsByPage[page.publicId] ?? const []; + final lineups = snapshot.lineupsByPage[page.publicId] ?? const []; + + final agents = []; + final abilities = []; + final drawings = []; + final texts = []; + final images = []; + final utilities = []; + + for (final element in elements) { + if (element.deleted) { + continue; + } + final payload = element.decodedPayload(); + try { + switch (element.elementType) { + case 'agent': + agents.add(PlacedAgentNode.fromJson(payload)); + break; + case 'ability': + abilities.add(PlacedAbility.fromJson(payload)); + break; + case 'drawing': + final decoded = DrawingProvider.fromJson(jsonEncode([payload])); + if (decoded.isNotEmpty) { + drawings.add(decoded.first); + } + break; + case 'text': + texts.add(PlacedText.fromJson(payload)); + break; + case 'image': + images.add(PlacedImage.fromJson(payload)); + break; + case 'utility': + utilities.add(PlacedUtility.fromJson(payload)); + break; + } + } catch (_) { + // Ignore malformed payloads during hydration. + } + } + + final parsedLineups = []; + for (final lineup in lineups) { + if (lineup.deleted) { + continue; + } + try { + final decoded = jsonDecode(lineup.payload); + if (decoded is Map) { + parsedLineups.add(LineUp.fromJson(decoded)); + } else if (decoded is Map) { + parsedLineups.add(LineUp.fromJson(Map.from(decoded))); + } + } catch (_) { + // Ignore malformed payloads during hydration. + } + } + + final mapValue = Maps.mapNames.entries.firstWhere( + (entry) => entry.value == snapshot.header.mapData, + orElse: () => const MapEntry(MapValue.ascent, 'ascent'), + ); + + StrategySettings pageSettings = StrategySettings(); + if (page.settings != null && page.settings!.isNotEmpty) { + try { + pageSettings = + ref.read(strategySettingsProvider.notifier).fromJson(page.settings!); + } catch (_) { + pageSettings = StrategySettings(); + } + } + + return StrategyEditorPageData( + pageId: page.publicId, + pageName: page.name, + isAttack: page.isAttack, + map: mapValue.key, + settings: pageSettings, + agents: agents, + abilities: abilities, + drawings: drawings, + texts: texts, + images: images, + utilities: utilities, + lineups: parsedLineups, + ); + } + + @override + Future flushCurrentPage() async { + final pageId = activePageId(); + if (pageId == null) { + return; + } + + final ops = _buildOpsFromCurrentPageSnapshot(pageId); + if (ops.isEmpty) { + return; + } + + ref + .read(strategyOpQueueProvider.notifier) + .enqueueAll(ops, flushImmediately: false); + } + + List<_CollabElementEnvelope> _collectLocalElementEnvelopes() { + final envelopes = <_CollabElementEnvelope>[]; + + for (final agent in ref.read(agentProvider)) { + final payload = Map.from(agent.toJson()) + ..putIfAbsent('elementType', () => 'agent'); + envelopes.add( + _CollabElementEnvelope( + publicId: agent.id, + elementType: 'agent', + payload: payload, + ), + ); + } + + for (final ability in ref.read(abilityProvider)) { + final payload = Map.from(ability.toJson()) + ..putIfAbsent('elementType', () => 'ability'); + envelopes.add( + _CollabElementEnvelope( + publicId: ability.id, + elementType: 'ability', + payload: payload, + ), + ); + } + + for (final drawing in ref.read(drawingProvider).elements) { + final encoded = jsonDecode(DrawingProvider.objectToJson([drawing])) as List; + final payload = Map.from( + (encoded.isEmpty ? {} : encoded.first) as Map, + )..putIfAbsent('elementType', () => 'drawing'); + envelopes.add( + _CollabElementEnvelope( + publicId: drawing.id, + elementType: 'drawing', + payload: payload, + ), + ); + } + + for (final text in ref.read(textProvider)) { + final payload = Map.from(text.toJson()) + ..putIfAbsent('elementType', () => 'text'); + envelopes.add( + _CollabElementEnvelope( + publicId: text.id, + elementType: 'text', + payload: payload, + ), + ); + } + + for (final image in ref.read(placedImageProvider).images) { + final payload = Map.from(image.toJson()) + ..putIfAbsent('elementType', () => 'image'); + envelopes.add( + _CollabElementEnvelope( + publicId: image.id, + elementType: 'image', + payload: payload, + ), + ); + } + + for (final utility in ref.read(utilityProvider)) { + final payload = Map.from(utility.toJson()) + ..putIfAbsent('elementType', () => 'utility'); + envelopes.add( + _CollabElementEnvelope( + publicId: utility.id, + elementType: 'utility', + payload: payload, + ), + ); + } + + return envelopes; + } + + List _buildOpsFromCurrentPageSnapshot(String pageId) { + final remoteElements = _snapshot.elementsByPage[pageId] ?? const []; + final remoteById = { + for (final element in remoteElements) element.publicId: element, + }; + + final local = _collectLocalElementEnvelopes(); + final localById = { + for (var i = 0; i < local.length; i++) local[i].publicId: (local[i], i), + }; + + final ops = []; + for (final entry in localById.entries) { + final localEnvelope = entry.value.$1; + final localIndex = entry.value.$2; + final remote = remoteById[entry.key]; + final payload = jsonEncode(localEnvelope.payload); + + if (remote == null || remote.deleted) { + ops.add( + StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.element, + entityPublicId: localEnvelope.publicId, + pagePublicId: pageId, + payload: payload, + sortIndex: localIndex, + ), + ); + continue; + } + + if (remote.payload != payload || + remote.sortIndex != localIndex || + remote.elementType != localEnvelope.elementType) { + ops.add( + StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.element, + entityPublicId: localEnvelope.publicId, + pagePublicId: pageId, + payload: payload, + sortIndex: localIndex, + ), + ); + } + } + + for (final remote in remoteElements) { + if (remote.deleted || localById.containsKey(remote.publicId)) { + continue; + } + ops.add( + StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.delete, + entityType: StrategyOpEntityType.element, + entityPublicId: remote.publicId, + pagePublicId: pageId, + ), + ); + } + + final remoteLineups = _snapshot.lineupsByPage[pageId] ?? const []; + final remoteLineupsById = { + for (final lineup in remoteLineups) lineup.publicId: lineup, + }; + final localLineups = ref.read(lineUpProvider).lineUps; + final localLineupsById = { + for (var i = 0; i < localLineups.length; i++) localLineups[i].id: (localLineups[i], i), + }; + + for (final entry in localLineupsById.entries) { + final lineup = entry.value.$1; + final localIndex = entry.value.$2; + final payload = jsonEncode(lineup.toJson()); + final remote = remoteLineupsById[entry.key]; + + if (remote == null || remote.deleted) { + ops.add( + StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.lineup, + entityPublicId: lineup.id, + pagePublicId: pageId, + payload: payload, + sortIndex: localIndex, + ), + ); + continue; + } + + if (remote.payload != payload || remote.sortIndex != localIndex) { + ops.add( + StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.lineup, + entityPublicId: lineup.id, + pagePublicId: pageId, + payload: payload, + sortIndex: localIndex, + ), + ); + } + } + + for (final remote in remoteLineups) { + if (remote.deleted || localLineupsById.containsKey(remote.publicId)) { + continue; + } + ops.add( + StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.delete, + entityType: StrategyOpEntityType.lineup, + entityPublicId: remote.publicId, + pagePublicId: pageId, + ), + ); + } + + final page = _snapshot.pages.firstWhere( + (entry) => entry.publicId == pageId, + orElse: () => _snapshot.pages.first, + ); + final latestSettings = ref.read(strategySettingsProvider.notifier).toJson(); + if (page.settings != latestSettings || page.isAttack != ref.read(mapProvider).isAttack) { + ops.add( + StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.page, + entityPublicId: page.publicId, + payload: jsonEncode( + { + 'settings': latestSettings, + 'isAttack': ref.read(mapProvider).isAttack, + }, + ), + ), + ); + } + + return ops; + } +} + +class _CollabElementEnvelope { + const _CollabElementEnvelope({ + required this.publicId, + required this.elementType, + required this.payload, + }); + + final String publicId; + final String elementType; + final Map payload; +} diff --git a/lib/widgets/current_path_bar.dart b/lib/widgets/current_path_bar.dart index 1ba667f9..6bebfb0a 100644 --- a/lib/widgets/current_path_bar.dart +++ b/lib/widgets/current_path_bar.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -11,43 +14,71 @@ class CurrentPathBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final workspace = ref.watch(libraryWorkspaceProvider); + final isCloud = workspace == LibraryWorkspace.cloud; final currentFolderId = ref.watch(folderProvider); - final currentFolder = currentFolderId != null - ? ref.read(folderProvider.notifier).findFolderByID(currentFolderId) + final cloudFolders = isCloud + ? (ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []) + .map(FolderProvider.cloudSummaryToFolder) + .toList(growable: false) : null; - - final pathIds = - ref.read(folderProvider.notifier).getFullPathIDs(currentFolder); + final currentFolder = currentFolderId == null + ? null + : isCloud + ? cloudFolders + ?.where((folder) => folder.id == currentFolderId) + .firstOrNull + : ref + .read(folderProvider.notifier) + .findLocalFolderByID(currentFolderId); + final pathFolders = isCloud + ? _cloudPathFolders(currentFolder, cloudFolders) + : ref + .read(folderProvider.notifier) + .getFullPathIDs(currentFolder) + .map((id) => ref.read(folderProvider.notifier).findFolderByID(id)) + .whereType() + .toList(growable: false); return Container( - padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), - // ignore: prefer_const_constructors - child: Row( - children: [ - Expanded( - child: ShadBreadcrumb( - lastItemTextColor: Settings.tacticalVioletTheme.foreground, - textStyle: ShadTheme.of(context).textTheme.lead, - children: [ + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), + child: Row( + children: [ + Expanded( + child: ShadBreadcrumb( + lastItemTextColor: Settings.tacticalVioletTheme.foreground, + textStyle: ShadTheme.of(context).textTheme.lead, + children: [ + FolderTab( + folder: null, + isActive: currentFolder == null, + ), + for (int i = 0; i < pathFolders.length; i++) FolderTab( - folder: null, // Represents root - isActive: currentFolder == null, + folder: pathFolders[i], + isActive: i == pathFolders.length - 1, ), - - // Path folders - for (int i = 0; i < pathIds.length; i++) ...[ - FolderTab( - folder: ref - .read(folderProvider.notifier) - .findFolderByID(pathIds[i]), - isActive: i == pathIds.length - 1, - ), - ], - ], - ), + ], ), - ], - )); + ), + ], + ), + ); + } + + List _cloudPathFolders(Folder? folder, List? cloudFolders) { + final pathFolders = []; + var current = folder; + while (current != null) { + pathFolders.insert(0, current); + final parentId = current.parentID; + if (parentId == null) { + current = null; + continue; + } + current = cloudFolders?.where((item) => item.id == parentId).firstOrNull; + } + return pathFolders; } } @@ -58,12 +89,12 @@ class FolderTab extends ConsumerWidget { this.isActive = false, }); - final Folder? folder; // null for root + final Folder? folder; final bool isActive; @override Widget build(BuildContext context, WidgetRef ref) { - final displayName = folder?.name ?? "Home"; + final displayName = folder?.name ?? 'Home'; return ShadBreadcrumbLink( textStyle: ShadTheme.of(context).textTheme.lead, @@ -78,15 +109,19 @@ class FolderTab extends ConsumerWidget { onAcceptWithDetails: (details) { final item = details.data; if (item is StrategyItem) { - // Move strategy to this folder ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategy.id, parentID: folder?.id); + strategyID: item.strategyId, + parentID: folder?.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); } else if (item is FolderItem) { - // Move folder to this folder - - ref - .read(folderProvider.notifier) - .moveToFolder(folderID: item.folder.id, parentID: folder?.id); + ref.read(folderProvider.notifier).moveToFolder( + folderID: item.folder.id, + parentID: folder?.id, + workspace: ref.read(libraryWorkspaceProvider), + ); } }, ), @@ -96,3 +131,7 @@ class FolderTab extends ConsumerWidget { ); } } + +extension on Iterable { + Folder? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/widgets/custom_text_field.dart b/lib/widgets/custom_text_field.dart index 21022622..c8dc4f44 100644 --- a/lib/widgets/custom_text_field.dart +++ b/lib/widgets/custom_text_field.dart @@ -12,7 +12,10 @@ class CustomTextField extends ConsumerWidget { this.minLines, this.maxLines, this.onSubmitted, - // required this.onEnterPressed, + this.keyboardType, + this.autofillHints, + this.obscureText = false, + this.textInputAction, }); final TextEditingController? controller; final String? hintText; @@ -20,7 +23,10 @@ class CustomTextField extends ConsumerWidget { final int? minLines; final int? maxLines; final Function(String)? onSubmitted; - // final Function(EnterTextIntent intent) onEnterPressed; + final TextInputType? keyboardType; + final Iterable? autofillHints; + final bool obscureText; + final TextInputAction? textInputAction; @override Widget build(BuildContext context, WidgetRef ref) { @@ -31,6 +37,10 @@ class CustomTextField extends ConsumerWidget { textAlign: textAlign ?? TextAlign.start, minLines: minLines, maxLines: maxLines ?? 1, + keyboardType: keyboardType, + autofillHints: autofillHints, + obscureText: obscureText, + textInputAction: textInputAction, placeholder: hintText != null ? Text(hintText!) : null, onSubmitted: onSubmitted, ), diff --git a/lib/widgets/dialogs/auth/auth_dialog.dart b/lib/widgets/dialogs/auth/auth_dialog.dart new file mode 100644 index 00000000..5e871070 --- /dev/null +++ b/lib/widgets/dialogs/auth/auth_dialog.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/widgets/custom_text_field.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +enum AuthDialogMode { signIn, signUp } + +class AuthDialog extends ConsumerStatefulWidget { + const AuthDialog({ + super.key, + this.initialMode = AuthDialogMode.signIn, + }); + + final AuthDialogMode initialMode; + + @override + ConsumerState createState() => _AuthDialogState(); +} + +class _AuthDialogState extends ConsumerState { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmController = TextEditingController(); + bool _submitting = false; + bool _isSignUp = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _isSignUp = widget.initialMode == AuthDialogMode.signUp; + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _confirmController.dispose(); + super.dispose(); + } + + Future _submit() async { + final email = _emailController.text.trim(); + final password = _passwordController.text; + final confirm = _confirmController.text; + + if (email.isEmpty || !email.contains('@')) { + setState(() { + _errorMessage = 'Enter a valid email address.'; + }); + return; + } + + if (password.length < 6) { + setState(() { + _errorMessage = 'Password must be at least 6 characters.'; + }); + return; + } + + if (_isSignUp && password != confirm) { + setState(() { + _errorMessage = 'Passwords do not match.'; + }); + return; + } + + setState(() { + _submitting = true; + _errorMessage = null; + }); + + final notifier = ref.read(authProvider.notifier); + final error = _isSignUp + ? await notifier.signUpWithEmailPassword(email: email, password: password) + : await notifier.signInWithEmailPassword(email: email, password: password); + + if (!mounted) return; + + setState(() { + _submitting = false; + _errorMessage = error; + }); + + if (error == null) { + Navigator.of(context).pop(true); + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final busy = _submitting || authState.isLoading; + + return ShadDialog( + title: Text(_isSignUp ? 'Create account' : 'Sign in'), + description: Text( + _isSignUp + ? 'Use email and password to create an account.' + : 'Sign in with email and password.', + ), + child: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomTextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + hintText: 'Email', + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 10), + CustomTextField( + controller: _passwordController, + obscureText: true, + autofillHints: const [AutofillHints.password], + hintText: 'Password', + textInputAction: + _isSignUp ? TextInputAction.next : TextInputAction.done, + onSubmitted: _isSignUp ? null : (_) => _submit(), + ), + if (_isSignUp) ...[ + const SizedBox(height: 10), + CustomTextField( + controller: _confirmController, + obscureText: true, + autofillHints: const [AutofillHints.password], + hintText: 'Confirm password', + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + ), + ], + const SizedBox(height: 12), + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + _errorMessage!, + style: TextStyle( + color: ShadTheme.of(context).colorScheme.destructive, + fontSize: 12, + ), + ), + ), + Row( + children: [ + Expanded( + child: ShadButton.secondary( + onPressed: busy + ? null + : () { + setState(() { + _isSignUp = !_isSignUp; + _errorMessage = null; + }); + }, + child: Text( + _isSignUp + ? 'Already have an account? Sign in' + : 'Need an account? Sign up', + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: ShadButton.secondary( + onPressed: busy + ? null + : () { + Navigator.of(context).pop(); + unawaited( + ref.read(authProvider.notifier).signInWithDiscord(), + ); + }, + child: const Text('Continue with Discord'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ShadButton( + onPressed: busy ? null : _submit, + child: busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isSignUp ? 'Create account' : 'Sign in'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart index ec8f521f..917d7dcb 100644 --- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -24,19 +25,30 @@ class _NameStrategyDialogState extends ConsumerState { @override Widget build(BuildContext context) { + final isCloud = + ref.watch(libraryWorkspaceProvider) == LibraryWorkspace.cloud; return ShadDialog( - title: const Text("Create Strategy"), + title: Text(isCloud ? "Create Cloud Strategy" : "Create Strategy"), actions: [ ShadButton( child: const Text("Create"), onPressed: () async { final strategyName = _textController.text; if (strategyName.isNotEmpty) { - final strategyID = await ref - .read(strategyProvider.notifier) - .createNewStrategy(strategyName); - if (!context.mounted) return; - Navigator.of(context).pop(strategyID); // Close the dialog + try { + final strategyID = await ref + .read(strategyProvider.notifier) + .createNewStrategy(strategyName); + if (!context.mounted) return; + Navigator.of(context).pop(strategyID); // Close the dialog + } catch (_) { + Settings.showToast( + message: isCloud + ? "Couldn't create cloud strategy right now. Please try logging in again." + : "Couldn't create strategy right now.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } } else { // Optionally, show an error message if the name is empty Settings.showToast( @@ -56,11 +68,20 @@ class _NameStrategyDialogState extends ConsumerState { onSubmitted: (value) async { if (value.isNotEmpty) { - final strategyID = await ref - .read(strategyProvider.notifier) - .createNewStrategy(value); - if (!context.mounted) return; - Navigator.of(context).pop(strategyID); // Close the dialog + try { + final strategyID = await ref + .read(strategyProvider.notifier) + .createNewStrategy(value); + if (!context.mounted) return; + Navigator.of(context).pop(strategyID); // Close the dialog + } catch (_) { + Settings.showToast( + message: isCloud + ? "Couldn't create cloud strategy right now. Please try logging in again." + : "Couldn't create strategy right now.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } } else { // Optionally, show an error message if the name is empty Settings.showToast( diff --git a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart index 1d9e2597..0cdfa917 100644 --- a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart +++ b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class DeleteStrategyAlertDialog extends ConsumerWidget { @@ -8,9 +9,11 @@ class DeleteStrategyAlertDialog extends ConsumerWidget { super.key, required this.strategyID, required this.name, + required this.source, }); final String strategyID; final String name; + final StrategySource source; @override Widget build(BuildContext context, WidgetRef ref) { return ShadDialog.alert( @@ -53,7 +56,7 @@ class DeleteStrategyAlertDialog extends ConsumerWidget { onPressed: () async { await ref .read(strategyProvider.notifier) - .deleteStrategy(strategyID); + .deleteStrategy(strategyID, source: source); if (!context.mounted) return; diff --git a/lib/widgets/dialogs/strategy/line_up_media_page.dart b/lib/widgets/dialogs/strategy/line_up_media_page.dart index 662478d5..ed304258 100644 --- a/lib/widgets/dialogs/strategy/line_up_media_page.dart +++ b/lib/widgets/dialogs/strategy/line_up_media_page.dart @@ -125,8 +125,12 @@ class _LineupMediaPageState extends ConsumerState { } Widget _buildImageGrid() { + final strategyId = ref.read(strategyProvider).strategyId; + if (strategyId == null) { + return const SizedBox.shrink(); + } return FutureBuilder( - future: _setImageDirectory(ref.read(strategyProvider).id), + future: _setImageDirectory(strategyId), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart index 81c59c56..971d2aba 100644 --- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart @@ -2,17 +2,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class RenameStrategyDialog extends ConsumerStatefulWidget { final String strategyId; final String currentName; + final StrategySource source; const RenameStrategyDialog({ super.key, required this.strategyId, required this.currentName, + required this.source, }); @override @@ -52,7 +55,11 @@ class _RenameStrategyDialogState extends ConsumerState { if (strategyName.isNotEmpty) { await ref .read(strategyProvider.notifier) - .renameStrategy(widget.strategyId, strategyName); + .renameStrategy( + widget.strategyId, + strategyName, + source: widget.source, + ); if (!context.mounted) return; Navigator.of(context).pop(true); // Close the dialog with success } else { @@ -79,7 +86,11 @@ class _RenameStrategyDialogState extends ConsumerState { if (value.isNotEmpty) { await ref .read(strategyProvider.notifier) - .renameStrategy(widget.strategyId, value); + .renameStrategy( + widget.strategyId, + value, + source: widget.source, + ); if (!context.mounted) return; Navigator.of(context) .pop(true); // Close the dialog with success diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index d4b23db1..b8cfb73b 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -2,39 +2,249 @@ 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/collab/collab_models.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/collab/strategy_capabilities_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/widgets/strategy_tile/strategy_tile.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/widgets/custom_search_field.dart'; -import 'package:icarus/widgets/ica_drop_target.dart'; import 'package:icarus/widgets/dot_painter.dart'; import 'package:icarus/widgets/folder_pill.dart'; +import 'package:icarus/widgets/ica_drop_target.dart'; +import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -// ... your existing imports class FolderContent extends ConsumerWidget { FolderContent({super.key, this.folder}); - final Folder? folder; // null for root - final strategiesListenable = + final Folder? folder; + final TextEditingController searchController = TextEditingController(); + + static final strategiesListenable = Provider>>((ref) { return Hive.box(HiveBoxNames.strategiesBox).listenable(); }); - final foldersListenable = Provider>>((ref) { + static final foldersListenable = Provider>>((ref) { return Hive.box(HiveBoxNames.foldersBox).listenable(); }); - final TextEditingController searchController = TextEditingController(); - @override Widget build(BuildContext context, WidgetRef ref) { - // Move all your existing grid logic here from FolderView - // Filter by folder?.id instead of folder.id + final workspace = ref.watch(libraryWorkspaceProvider); + final isCloud = workspace == LibraryWorkspace.cloud; + if (isCloud) { + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); + if (!cloudAvailable) { + return _buildCloudUnavailableState(context, ref); + } + final folders = (ref.watch(cloudFoldersProvider).valueOrNull ?? const []) + .map(FolderProvider.cloudSummaryToFolder) + .toList(growable: false); + final strategies = + ref.watch(cloudStrategiesProvider).valueOrNull ?? const []; + return _buildScaffold( + context, + ref, + folders: _filterFolders(ref, folders), + localStrategies: const [], + cloudStrategies: _filterCloudStrategies(ref, strategies), + isCloud: true, + ); + } + final strategiesBoxListenable = ref.watch(strategiesListenable); + final foldersBoxListenable = ref.watch(foldersListenable); + return ValueListenableBuilder>( + valueListenable: strategiesBoxListenable, + builder: (context, strategyBox, _) { + return ValueListenableBuilder>( + valueListenable: foldersBoxListenable, + builder: (context, folderBox, _) { + final folders = folderBox.values + .where((item) => item.parentID == folder?.id) + .toList(); + final strategies = strategyBox.values + .where((item) => item.folderID == folder?.id) + .toList(); + return _buildScaffold( + context, + ref, + folders: _filterFolders(ref, folders), + localStrategies: _filterLocalStrategies(ref, strategies), + cloudStrategies: const [], + isCloud: false, + ); + }, + ); + }, + ); + } + + List _filterFolders(WidgetRef ref, List folders) { + final search = ref.watch(strategySearchQueryProvider).trim().toLowerCase(); + final filtered = [...folders]; + if (search.isNotEmpty) { + filtered.retainWhere( + (folder) => folder.name.toLowerCase().contains(search), + ); + } + filtered.sort((a, b) => a.dateCreated.compareTo(b.dateCreated)); + return filtered; + } + + List _filterLocalStrategies( + WidgetRef ref, + List strategies, + ) { + final search = ref.watch(strategySearchQueryProvider).trim().toLowerCase(); + final filter = ref.watch(strategyFilterProvider); + final filtered = [...strategies]; + if (search.isNotEmpty) { + filtered.retainWhere( + (strategy) => strategy.name.toLowerCase().contains(search), + ); + } + + Comparator comparator = switch (filter.sortBy) { + 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), + }; + + final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; + filtered.sort((a, b) => direction * comparator(a, b)); + return filtered; + } + + List _filterCloudStrategies( + WidgetRef ref, + List strategies, + ) { + final search = ref.watch(strategySearchQueryProvider).trim().toLowerCase(); + final filter = ref.watch(strategyFilterProvider); + final filtered = [...strategies]; + if (search.isNotEmpty) { + filtered.retainWhere( + (strategy) => strategy.name.toLowerCase().contains(search), + ); + } + + Comparator comparator = switch (filter.sortBy) { + 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), + }; + + final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; + filtered.sort((a, b) => direction * comparator(a, b)); + return filtered; + } + + Widget _buildScaffold( + BuildContext context, + WidgetRef ref, { + required List folders, + required List localStrategies, + required List cloudStrategies, + required bool isCloud, + }) { + 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', + ), + ], + ), + ); + final Widget content = LayoutBuilder( + builder: (context, constraints) { + const double minTileWidth = 250; + const double spacing = 20; + const double padding = 32; + int crossAxisCount = + ((constraints.maxWidth - padding + spacing) / (minTileWidth + spacing)) + .floor(); + crossAxisCount = crossAxisCount.clamp(1, double.infinity).toInt(); + + return CustomScrollView( + slivers: [ + if (folders.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: folders + .map((folder) => FolderPill(folder: folder)) + .toList(), + ), + ), + ), + if (hasStrategies) + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisExtent: 250, + crossAxisSpacing: 20, + mainAxisSpacing: 20, + ), + delegate: SliverChildListDelegate.fixed( + [ + ...localStrategies.map( + (strategy) => StrategyTile.local( + strategyData: strategy, + ), + ), + ...cloudStrategies.map((strategy) { + final caps = + StrategyCapabilities.fromCloudRole(strategy.role); + return StrategyTile.cloud( + cloudStrategy: strategy, + canRename: caps.canRenameStrategy, + canDuplicate: caps.canDuplicateStrategy, + canDelete: caps.canDeleteStrategy, + canMove: caps.canMoveStrategy, + ); + }), + ], + ), + ), + ) + else if (folders.isNotEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 48), + child: Text( + 'No strategies in this folder', + style: TextStyle(color: Colors.grey), + ), + ), + ), + ), + ], + ); + }, + ); + final wrappedContent = + isCloud ? content : IcaDropTarget(child: content); return Stack( children: [ @@ -55,51 +265,23 @@ class FolderContent extends ConsumerWidget { Row( spacing: 8, children: [ - ShadSelect( - decoration: ShadDecoration( - color: Settings.tacticalVioletTheme.card, - shadows: const [Settings.cardForegroundBackdrop], - ), - initialValue: + _SortSelect( + currentValue: ref.watch(strategyFilterProvider).sortBy, - selectedOptionBuilder: (context, value) => - Text(StrategyFilterProvider.sortByLabels[value]!), - options: [ - for (final sb in SortBy.values) - ShadOption( - value: sb, - child: Text( - StrategyFilterProvider.sortByLabels[sb]!), - ), - ], - onChanged: (value) { - ref - .read(strategyFilterProvider.notifier) - .setSortBy(value!); - }, + labels: StrategyFilterProvider.sortByLabels, + values: SortBy.values, + onChanged: (value) => ref + .read(strategyFilterProvider.notifier) + .setSortBy(value), ), - ShadSelect( - decoration: ShadDecoration( - color: Settings.tacticalVioletTheme.card, - shadows: const [Settings.cardForegroundBackdrop], - ), - initialValue: + _SortSelect( + currentValue: ref.watch(strategyFilterProvider).sortOrder, - selectedOptionBuilder: (context, value) => Text( - StrategyFilterProvider.sortOrderLabels[value]!), - options: [ - for (final so in SortOrder.values) - ShadOption( - value: so, - child: Text(StrategyFilterProvider - .sortOrderLabels[so]!), - ), - ], - onChanged: (value) { - ref - .read(strategyFilterProvider.notifier) - .setSortOrder(value!); - }, + labels: StrategyFilterProvider.sortOrderLabels, + values: SortOrder.values, + onChanged: (value) => ref + .read(strategyFilterProvider.notifier) + .setSortOrder(value), ), ], ), @@ -117,160 +299,78 @@ class FolderContent extends ConsumerWidget { ), ), Expanded( - child: ValueListenableBuilder>( - valueListenable: strategiesBoxListenable, - builder: (context, strategyBox, _) { - final foldersBoxListenable = ref.watch(foldersListenable); - return ValueListenableBuilder>( - valueListenable: foldersBoxListenable, - builder: (context, folderBox, _) { - final folders = folderBox.values.toList(); - - final strategies = strategyBox.values.toList(); - - final search = ref - .watch(strategySearchQueryProvider) - .trim() - .toLowerCase(); - // Filter strategies and folders by the current folder - strategies.removeWhere( - (strategy) => strategy.folderID != folder?.id); - folders.removeWhere( - (listFolder) => listFolder.parentID != folder?.id); - - if (search.isNotEmpty) { - strategies.retainWhere( - (strategy) => - strategy.name.toLowerCase().contains(search), - ); - folders.retainWhere( - (listFolder) => - listFolder.name.toLowerCase().contains(search), - ); - } - final filter = ref.watch(strategyFilterProvider); - - // Pick the comparator once based on sortBy - Comparator sortByComparator = - switch (filter.sortBy) { - 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), - }; - - final direction = - filter.sortOrder == SortOrder.ascending ? 1 : -1; - - strategies.sort( - (a, b) => direction * sortByComparator(a, b), - ); - folders.sort( - (a, b) => a.dateCreated.compareTo(b.dateCreated)); - - // Check if both folders and strategies are empty - if (folders.isEmpty && strategies.isEmpty) { - return const IcaDropTarget( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('No strategies available'), - Text( - "Create a new strategy or drop strategies, folders, or .zip archives") - ], - ), - ), - ); - } + child: (folders.isEmpty && !hasStrategies) + ? (isCloud + ? emptyState + : IcaDropTarget(child: emptyState)) + : wrappedContent, + ), + ], + ), + ), + ], + ); + } - return IcaDropTarget( - child: LayoutBuilder(builder: (context, constraints) { - // Calculate how many columns can fit with minimum width - const double minTileWidth = - 250; // Your minimum width - const double spacing = 20; - const double padding = 32; // 16 * 2 + Widget _buildCloudUnavailableState(BuildContext context, WidgetRef ref) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Cloud workspace unavailable'), + const SizedBox(height: 8), + const Text( + 'Sign in again or switch back to Local to keep working.', + ), + const SizedBox(height: 16), + ShadButton.secondary( + onPressed: () { + ref + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.local); + }, + child: const Text('Back to Local'), + ), + ], + ), + ); + } +} - int crossAxisCount = - ((constraints.maxWidth - padding + spacing) / - (minTileWidth + spacing)) - .floor(); - crossAxisCount = crossAxisCount - .clamp(1, double.infinity) - .toInt(); +class _SortSelect extends StatelessWidget { + const _SortSelect({ + required this.currentValue, + required this.labels, + required this.values, + required this.onChanged, + }); - return CustomScrollView( - slivers: [ - // Folder pills section (wrap row) - if (folders.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - 16, 16, 16, 8), - child: Wrap( - spacing: 10, - runSpacing: 10, - children: folders - .map((f) => FolderPill(folder: f)) - .toList(), - ), - ), - ), + final T currentValue; + final Map labels; + final Iterable values; + final ValueChanged onChanged; - // Strategies grid - if (strategies.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverGrid( - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisExtent: 250, - crossAxisSpacing: 20, - mainAxisSpacing: 20, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - return StrategyTile( - strategyData: strategies[index]); - }, - childCount: strategies.length, - ), - ), - ) - else if (folders.isNotEmpty) - // Show placeholder when only folders exist - const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: 48), - child: Text( - 'No strategies in this folder', - style: TextStyle( - color: Colors.grey, - ), - ), - ), - ), - ), - ], - ); - }), - ); - }, - ); - }, - ), - ), - ], + @override + Widget build(BuildContext context) { + return ShadSelect( + decoration: ShadDecoration( + color: Settings.tacticalVioletTheme.card, + shadows: const [Settings.cardForegroundBackdrop], + ), + 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); + } + }, ); } } diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index e982e8e2..3b98d65e 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -50,6 +50,11 @@ class _FolderEditDialogState extends ConsumerState { @override Widget build(BuildContext context) { + final previewColor = + Folder.folderColorMap[_selectedColor] ?? + _customColor ?? + Settings.tacticalVioletTheme.primary; + return ShadDialog( title: Text(widget.folder != null ? "Edit Folder" : "Add Folder"), actions: [ @@ -161,6 +166,7 @@ class _FolderEditDialogState extends ConsumerState { onTap: () { setState(() { _selectedColor = color; + _customColor = null; }); // ref.read(penProvider.notifier).setColor(index); }, @@ -189,9 +195,7 @@ class _FolderEditDialogState extends ConsumerState { width: 300, child: ColorPicker( portraitOnly: true, - pickerColor: - Folder.folderColorMap[_selectedColor] ?? - _customColor!, + pickerColor: previewColor, onColorChanged: (color) { setState(() { _selectedColor = FolderColor.custom; diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 016d6527..201d3cd0 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:desktop_updater/desktop_updater.dart'; @@ -8,8 +9,12 @@ import 'package:icarus/const/coordinate_system.dart'; 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/folder_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'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/windows_desktop_update_controller.dart'; @@ -18,6 +23,7 @@ import 'package:icarus/widgets/current_path_bar.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; import 'package:icarus/widgets/demo_dialog.dart'; import 'package:icarus/widgets/demo_tag.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/dialogs/strategy/create_strategy_dialog.dart'; import 'package:icarus/widgets/dialogs/web_view_dialog.dart'; import 'package:icarus/widgets/folder_content.dart'; @@ -67,6 +73,8 @@ class _FolderNavigatorState extends ConsumerState { void _warnWebView() async { if (kIsWeb) return; if (!Platform.isWindows) return; + await warmUpWebViewEnvironment(); + if (!mounted) return; if (isWebViewInitialized) return; await showShadDialog( context: context, @@ -103,7 +111,7 @@ class _FolderNavigatorState extends ConsumerState { return; } try { - await ref.read(strategyProvider.notifier).loadFromFilePicker(); + await StrategyImportExportService(ref).loadFromFilePicker(); } on NewerVersionImportException catch (error, stackTrace) { AppErrorReporter.reportError( NewerVersionImportException.userMessage, @@ -127,9 +135,8 @@ class _FolderNavigatorState extends ConsumerState { return; } try { - final result = await ref - .read(strategyProvider.notifier) - .importBackupFromFilePicker(); + final result = + await StrategyImportExportService(ref).importBackupFromFilePicker(); if (result.hasImports || result.issues.isNotEmpty) { final message = buildImportSummaryMessage(result); if (result.hasImports) { @@ -166,7 +173,7 @@ class _FolderNavigatorState extends ConsumerState { return; } try { - await ref.read(strategyProvider.notifier).exportLibrary(); + await StrategyImportExportService(ref).exportLibrary(); } catch (error, stackTrace) { AppErrorReporter.reportError( 'Failed to export library.', @@ -224,10 +231,14 @@ class _FolderNavigatorState extends ConsumerState { final double height = MediaQuery.sizeOf(context).height - 90; final Size playAreaSize = Size(height * (16 / 9), height); CoordinateSystem(playAreaSize: playAreaSize); + final workspace = ref.watch(libraryWorkspaceProvider); + final isCloudWorkspace = workspace == LibraryWorkspace.cloud; + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); final currentFolderId = ref.watch(folderProvider); final currentFolder = currentFolderId != null - ? ref.read(folderProvider.notifier).findFolderByID(currentFolderId) + ? ref.read(folderProvider.notifier).findLocalFolderByID(currentFolderId) : null; + final authState = ref.watch(authProvider); Future navigateWithLoading( BuildContext context, String strategyId) async { // Show loading overlay @@ -276,7 +287,31 @@ class _FolderNavigatorState extends ConsumerState { if (strategyId != null) { if (!context.mounted) return; - await navigateWithLoading(context, strategyId); + if (isCloudWorkspace) { + await Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 200), + reverseTransitionDuration: const Duration(milliseconds: 200), + pageBuilder: (context, animation, secondaryAnimation) => + const StrategyView(), + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.9, end: 1.0) + .chain(CurveTween(curve: Curves.easeOut)) + .animate(animation), + child: child, + ), + ); + }, + ), + ); + } else { + await navigateWithLoading(context, strategyId); + } } } @@ -297,6 +332,51 @@ 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), @@ -347,7 +427,7 @@ class _FolderNavigatorState extends ConsumerState { }, child: ShadButton.secondary( key: _importExportButtonKey, - onPressed: _toggleImportExportPopover, + onPressed: isCloudWorkspace ? null : _toggleImportExportPopover, leading: const Icon(Icons.import_export), trailing: const Icon(Icons.keyboard_arrow_down), child: const Text('Import / Export'), @@ -368,7 +448,9 @@ class _FolderNavigatorState extends ConsumerState { ShadButton( onPressed: showCreateDialog, leading: const Icon(Icons.add), - child: const Text('Create Strategy'), + child: Text( + isCloudWorkspace ? 'Create Cloud Strategy' : 'Create Strategy', + ), ), ], ) @@ -395,7 +477,11 @@ class FolderItem extends GridItem { } class StrategyItem extends GridItem { - final StrategyData strategy; + final String strategyId; + final StrategyData? strategy; + + StrategyItem.local(this.strategy) + : strategyId = strategy!.id; - StrategyItem(this.strategy); + StrategyItem.cloud(this.strategyId) : strategy = null; } diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index 5afab198..edba8321 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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'; +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/folder_edit_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; @@ -79,10 +83,18 @@ class _FolderPillState extends ConsumerState final item = details.data; if (item is StrategyItem) { ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategy.id, parentID: widget.folder.id); + 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); + folderID: item.folder.id, + parentID: widget.folder.id, + workspace: ref.read(libraryWorkspaceProvider), + ); } }, builder: (context, candidateData, rejectedData) { @@ -209,9 +221,7 @@ class _FolderPillState extends ConsumerState leading: const Icon(Icons.file_upload), child: const Text('Export'), onPressed: () async { - await ref - .read(strategyProvider.notifier) - .exportFolder(widget.folder.id); + await StrategyImportExportService(ref).exportFolder(widget.folder.id); }, ), ShadContextMenuItem( @@ -229,7 +239,10 @@ class _FolderPillState extends ConsumerState ).then((confirmed) { if (confirmed) { if (widget.isDemo) return; - ref.read(folderProvider.notifier).deleteFolder(widget.folder.id); + ref.read(folderProvider.notifier).deleteFolder( + widget.folder.id, + workspace: ref.read(libraryWorkspaceProvider), + ); } }); }, @@ -275,11 +288,16 @@ class _FolderPillState extends ConsumerState } bool _isParentFolder(String folderId) { + final workspace = ref.read(libraryWorkspaceProvider); String? currentParentId = widget.folder.parentID; while (currentParentId != null) { if (currentParentId == folderId) return true; - final parentFolder = - ref.read(folderProvider.notifier).findFolderByID(currentParentId); + final parentFolder = workspace == LibraryWorkspace.local + ? ref.read(folderProvider.notifier).findLocalFolderByID(currentParentId) + : ref.read(folderProvider.notifier).findCloudFolderByID( + currentParentId, + ref.read(cloudAllFoldersProvider).valueOrNull ?? const [], + ); currentParentId = parentFolder?.parentID; } return false; diff --git a/lib/widgets/global_shortcuts.dart b/lib/widgets/global_shortcuts.dart index 1b3ae744..2200cc2d 100644 --- a/lib/widgets/global_shortcuts.dart +++ b/lib/widgets/global_shortcuts.dart @@ -221,7 +221,8 @@ class _GlobalShortcutsState extends ConsumerState SaveStrategyIntent: CallbackAction( onInvoke: (intent) async { _dismissDeleteMenu(); - final strategyId = ref.read(strategyProvider).id; + final strategyId = ref.read(strategyProvider).strategyId; + if (strategyId == null) return null; try { await ref.read(strategyProvider.notifier).forceSaveNow( strategyId, diff --git a/lib/widgets/ica_drop_target.dart b/lib/widgets/ica_drop_target.dart index 6547c355..d8cca8d2 100644 --- a/lib/widgets/ica_drop_target.dart +++ b/lib/widgets/ica_drop_target.dart @@ -2,7 +2,7 @@ import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/services/app_error_reporter.dart'; String buildImportSummaryMessage(ImportBatchResult result) { @@ -95,9 +95,10 @@ class _CustomDropTargetState extends ConsumerState { }); } try { - final result = await ref - .read(strategyProvider.notifier) - .loadFromFileDrop(details.files); + final result = + await StrategyImportExportService(ref).loadFromFileDrop( + details.files, + ); if (result.hasImports || result.issues.isNotEmpty) { final message = buildImportSummaryMessage(result); diff --git a/lib/widgets/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart index 6514be4c..3ce8ecbb 100644 --- a/lib/widgets/line_up_media_carousel.dart +++ b/lib/widgets/line_up_media_carousel.dart @@ -49,7 +49,10 @@ class _ImageCarouselState extends ConsumerState } Future _loadDirectory() async { - final strategyID = ref.read(strategyProvider).id; + final strategyID = ref.read(strategyProvider).strategyId; + if (strategyID == null) { + return; + } final dir = await PlacedImageProvider.getImageFolder(strategyID); if (mounted) { setState(() { diff --git a/lib/widgets/map_theme_settings_section.dart b/lib/widgets/map_theme_settings_section.dart index ad143650..c010561e 100644 --- a/lib/widgets/map_theme_settings_section.dart +++ b/lib/widgets/map_theme_settings_section.dart @@ -74,7 +74,7 @@ class _ActiveThemeCardState extends ConsumerState<_ActiveThemeCard> { Widget build(BuildContext context) { final strategyTheme = ref.watch(strategyThemeProvider); final effectivePalette = ref.watch(effectiveMapThemePaletteProvider); - final hasActiveStrategy = ref.watch(strategyProvider).stratName != null; + final hasActiveStrategy = ref.watch(strategyProvider).strategyName != null; final profilesState = ref.watch(mapThemeProfilesProvider); final isOverride = strategyTheme.overridePalette != null; @@ -342,7 +342,7 @@ class _ProfileLibrarySectionState Widget build(BuildContext context) { final profilesState = ref.watch(mapThemeProfilesProvider); final strategyTheme = ref.watch(strategyThemeProvider); - final hasActiveStrategy = ref.watch(strategyProvider).stratName != null; + final hasActiveStrategy = ref.watch(strategyProvider).strategyName != null; final activeProfileId = strategyTheme.overridePalette == null ? (strategyTheme.profileId ?? diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index 950457b1..7088aad3 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -3,13 +3,26 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; - -import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +class PageListItemViewModel { + const PageListItemViewModel({ + required this.id, + required this.name, + }); + + final String id; + final String name; +} + class PagesBar extends ConsumerStatefulWidget { const PagesBar({super.key}); @@ -17,159 +30,208 @@ class PagesBar extends ConsumerStatefulWidget { ConsumerState createState() => _PagesBarState(); } -class _PagesBarState extends ConsumerState - with SingleTickerProviderStateMixin { +class _PagesBarState extends ConsumerState { bool _expanded = false; - StrategyData? _strategy(Box box, String id) => box.get(id); - Future _addPage() async { + final caps = ref.read(currentStrategyCapabilitiesProvider); + if (!caps.canAddPage) return; await ref.read(strategyProvider.notifier).addPage(); } Future _selectPage(String id) async { - if (id == ref.read(strategyProvider.notifier).activePageID) return; + if (id == ref.read(strategyPageSessionProvider).activePageId) return; await ref.read(strategyProvider.notifier).setActivePageAnimated(id); } - Future _renamePage(StrategyData strat, StrategyPage page) async { + Future _renamePage(PageListItemViewModel page) async { + final caps = ref.read(currentStrategyCapabilitiesProvider); + if (!caps.canRenamePage) return; final controller = TextEditingController(text: page.name); final newName = await showShadDialog( context: context, builder: (ctx) => ShadDialog( - title: const Text("Rename page"), - description: const Text("Enter a new name for the page:"), + title: const Text('Rename page'), + description: const Text('Enter a new name for the page:'), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(ctx).pop(), backgroundColor: Settings.tacticalVioletTheme.border, - child: const Text("Cancel"), + child: const Text('Cancel'), ), ShadButton( onPressed: () => Navigator.of(ctx).pop(controller.text.trim()), leading: const Icon(Icons.text_fields), - child: const Text("Rename"), + child: const Text('Rename'), ), ], child: CustomTextField( - // autofocus: true, controller: controller, - onSubmitted: (v) => Navigator.of(ctx).pop(v.trim()), + onSubmitted: (value) => Navigator.of(ctx).pop(value.trim()), ), ), ); - if (newName == null || newName.isEmpty || newName == page.name) return; - - final box = Hive.box(HiveBoxNames.strategiesBox); - final updatedPages = [ - for (final p in strat.pages) - if (p.id == page.id) p.copyWith(name: newName) else p, - ]; - final updated = - strat.copyWith(pages: updatedPages, lastEdited: DateTime.now()); - await box.put(updated.id, updated); controller.dispose(); + if (newName == null || newName.isEmpty || newName == page.name) return; + await ref.read(strategyProvider.notifier).renamePage(page.id, newName); } - Future _deletePage(StrategyData strat, StrategyPage page) async { - if (strat.pages.length == 1) return; // cannot delete last + Future _deletePage(PageListItemViewModel page, int pageCount) async { + final caps = ref.read(currentStrategyCapabilitiesProvider); + if (!caps.canDeletePage || pageCount <= 1) return; final confirm = await ConfirmAlertDialog.show( context: context, title: "Delete '${page.name}'?", content: - "Are you sure you want to delete this page? This action cannot be undone.", - confirmText: "Delete", - cancelText: "Cancel", + 'Are you sure you want to delete this page? This action cannot be undone.', + confirmText: 'Delete', + cancelText: 'Cancel', isDestructive: true, ); - if (confirm != true) return; - - final box = Hive.box(HiveBoxNames.strategiesBox); - final remaining = [...strat.pages]..removeWhere((p) => p.id == page.id); - // Reindex sortIndex to keep dense ordering - final reindexed = [ - for (var i = 0; i < remaining.length; i++) - remaining[i].copyWith(sortIndex: i), - ]; - final activeId = ref.read(strategyProvider.notifier).activePageID; - final newActive = (activeId == page.id) ? reindexed.first.id : activeId; - - final updated = strat.copyWith( - pages: reindexed, - lastEdited: DateTime.now(), - ); - await box.put(updated.id, updated); - if (newActive != activeId) { - if (newActive != null) - await ref - .read(strategyProvider.notifier) - .setActivePageAnimated(newActive); - } + await ref.read(strategyProvider.notifier).deletePage(page.id); } @override Widget build(BuildContext context) { - final strategyId = ref.watch(strategyProvider).id; - final activePageIdFromState = - ref.watch(strategyProvider.select((state) => state.activePageId)); - final box = Hive.box(HiveBoxNames.strategiesBox); + final activePageId = ref.watch( + strategyPageSessionProvider.select((state) => state.activePageId), + ); + final caps = ref.watch(currentStrategyCapabilitiesProvider); + final isCloud = ref.watch( + strategyProvider.select((value) => value.source), + ) == + StrategySource.cloud; + if (!isCloud) { + final strategyId = ref.watch(strategyProvider).strategyId; + if (strategyId == null) { + return const SizedBox.shrink(); + } + final box = Hive.box(HiveBoxNames.strategiesBox); + return ValueListenableBuilder( + valueListenable: box.listenable(keys: [strategyId]), + builder: (context, Box _, __) { + final data = _buildLocalData(activePageId); + if (data == null || data.pages.isEmpty) { + return const SizedBox.shrink(); + } + return _buildPageBar(data, caps); + }, + ); + } + final data = _buildCloudData(activePageId); + if (data == null || data.pages.isEmpty) { + return const SizedBox.shrink(); + } + return _buildPageBar(data, caps); + } - return ValueListenableBuilder( - valueListenable: box.listenable(keys: [strategyId]), - builder: (context, Box b, _) { - final strat = _strategy(b, strategyId); - if (strat == null) return const SizedBox(); - - final pages = [...strat.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final activePageId = - activePageIdFromState ?? (pages.isNotEmpty ? pages.first.id : null); - final activeName = pages - .firstWhere( - (p) => p.id == activePageId, - orElse: () => pages.first, + Widget _buildPageBar(_PageBarData data, StrategyCapabilities caps) { + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: 2, + ), + ), + width: 224, + padding: EdgeInsets.zero, + child: _expanded + ? _ExpandedPanel( + pages: data.pages, + activePageId: data.activePageId, + canAddPage: caps.canAddPage, + canRenamePage: caps.canRenamePage, + canDeletePage: caps.canDeletePage, + canReorderPages: caps.canReorderPages, + onSelect: _selectPage, + onRename: _renamePage, + onDelete: _deletePage, + onAdd: _addPage, + onReorder: caps.canReorderPages + ? (oldIndex, newIndex) => ref + .read(strategyProvider.notifier) + .reorderPage(oldIndex, newIndex) + : null, + onCollapse: () => setState(() => _expanded = false), ) - .name; - - return AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeInOut, - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 2, + : _CollapsedPill( + activeName: data.activeName, + onAdd: caps.canAddPage ? _addPage : null, + onToggle: () => setState(() => _expanded = true), ), - ), - width: 224, - // Height grows when expanded - padding: EdgeInsets.zero, - child: _expanded - ? _ExpandedPanel( - pages: pages, - strategy: strat, - activePageId: activePageId, - onSelect: _selectPage, - onRename: (p) => _renamePage(strat, p), - onDelete: (p) => _deletePage(strat, p), - onAdd: _addPage, - onCollapse: () => setState(() => _expanded = false), - ) - : _CollapsedPill( - activeName: activeName, - onAdd: _addPage, - onToggle: () => setState(() => _expanded = true), - ), - ); - }, + ); + } + + _PageBarData? _buildCloudData(String? activePageId) { + final snapshot = ref.watch(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.pages.isEmpty) { + return null; + } + final pages = [...snapshot.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final items = pages + .map((page) => PageListItemViewModel(id: page.publicId, name: page.name)) + .toList(growable: false); + final resolvedActiveId = activePageId ?? items.first.id; + final activeName = items + .firstWhere( + (page) => page.id == resolvedActiveId, + orElse: () => items.first, + ) + .name; + return _PageBarData( + pages: items, + activePageId: resolvedActiveId, + activeName: activeName, + ); + } + + _PageBarData? _buildLocalData(String? activePageId) { + final strategyId = ref.watch(strategyProvider).strategyId; + if (strategyId == null) return null; + final box = Hive.box(HiveBoxNames.strategiesBox); + final strategy = box.get(strategyId); + if (strategy == null || strategy.pages.isEmpty) { + return null; + } + final pages = [...strategy.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final items = pages + .map((page) => PageListItemViewModel(id: page.id, name: page.name)) + .toList(growable: false); + final resolvedActiveId = activePageId ?? items.first.id; + final activeName = items + .firstWhere( + (page) => page.id == resolvedActiveId, + orElse: () => items.first, + ) + .name; + return _PageBarData( + pages: items, + activePageId: resolvedActiveId, + activeName: activeName, ); } } -/* -------- Collapsed pill -------- */ +class _PageBarData { + const _PageBarData({ + required this.pages, + required this.activePageId, + required this.activeName, + }); + + final List pages; + final String activePageId; + final String activeName; +} + class _CollapsedPill extends StatelessWidget { const _CollapsedPill({ required this.activeName, @@ -178,7 +240,7 @@ class _CollapsedPill extends StatelessWidget { }); final String activeName; - final VoidCallback onAdd; + final VoidCallback? onAdd; final VoidCallback onToggle; @override @@ -192,7 +254,7 @@ class _CollapsedPill extends StatelessWidget { _SquareIconButton( icon: Icons.add, onTap: onAdd, - tooltip: "Add page", + tooltip: 'Add page', color: Settings.tacticalVioletTheme.primary, shortcutLabel: 'C', ), @@ -202,9 +264,10 @@ class _CollapsedPill extends StatelessWidget { activeName, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 14), + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), ), ), ShadIconButton.ghost( @@ -219,57 +282,57 @@ class _CollapsedPill extends StatelessWidget { } } -/* -------- Expanded panel -------- */ -class _ExpandedPanel extends ConsumerWidget { +class _ExpandedPanel extends StatelessWidget { const _ExpandedPanel({ required this.pages, - required this.strategy, required this.activePageId, + required this.canAddPage, + required this.canRenamePage, + required this.canDeletePage, + required this.canReorderPages, required this.onSelect, required this.onRename, required this.onDelete, required this.onAdd, required this.onCollapse, + this.onReorder, }); - final List pages; - final StrategyData strategy; - final String? activePageId; + final List pages; + final String activePageId; + final bool canAddPage; + final bool canRenamePage; + final bool canDeletePage; + final bool canReorderPages; final ValueChanged onSelect; - final ValueChanged onRename; - final ValueChanged onDelete; + final ValueChanged onRename; + final Future Function(PageListItemViewModel page, int pageCount) onDelete; final VoidCallback onAdd; final VoidCallback onCollapse; + final void Function(int oldIndex, int newIndex)? onReorder; - static const double _rowHeight = 40; // each page tile height - static const double _verticalSpacing = 10; // separator height - static const double _headerFooterHeight = 48 + 1; // bottom bar + divider - static const double _topPadding = 8; // list top padding - static const double _bottomPadding = 0; // list bottom padding inside Expanded - static const double _maxPanelHeight = 310; // previous max constraint + static const double _rowHeight = 40; + static const double _verticalSpacing = 10; + static const double _headerFooterHeight = 49; + static const double _topPadding = 8; + static const double _bottomPadding = 0; + static const double _maxPanelHeight = 310; double _computeDesiredHeight(int count) { - if (count == 0) return _headerFooterHeight + 56; // fallback + if (count == 0) return _headerFooterHeight + 56; final rowsHeight = count * _rowHeight; final spacersHeight = (count - 1) * _verticalSpacing; final listSection = _topPadding + rowsHeight + spacersHeight + _bottomPadding; - final total = - listSection + _headerFooterHeight; // include footer (add/collapse) + final total = listSection + _headerFooterHeight; return total.clamp(0, _maxPanelHeight); } - Widget proxyDecorator(Widget child, int index, Animation animation) { - return child; - } - @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final desiredHeight = _computeDesiredHeight(pages.length); - final needsScroll = desiredHeight >= _maxPanelHeight - 0.5; // approximate - final activeIndex = activePageId == null - ? -1 - : pages.indexWhere((p) => p.id == activePageId); + final needsScroll = desiredHeight >= _maxPanelHeight - 0.5; + final activeIndex = pages.indexWhere((page) => page.id == activePageId); int? backwardIndex; int? forwardIndex; @@ -285,71 +348,74 @@ class _ExpandedPanel extends ConsumerWidget { duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, constraints: BoxConstraints( - // Allow it to grow with content up to max maxHeight: _maxPanelHeight, minHeight: desiredHeight, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - // List / content section Flexible( child: Padding( padding: const EdgeInsets.only(top: _topPadding), child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - ref - .read(strategyProvider.notifier) - .reorderPage(oldIndex, newIndex); - }, + onReorder: onReorder == null ? (_, __) {} : onReorder!, + buildDefaultDragHandles: canReorderPages, padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), shrinkWrap: needsScroll ? false : true, physics: needsScroll ? null : const NeverScrollableScrollPhysics(), itemCount: pages.length, - buildDefaultDragHandles: false, - proxyDecorator: proxyDecorator, - itemBuilder: (ctx, i) { + proxyDecorator: (child, _, __) => child, + itemBuilder: (context, index) { bool showForwardIndicator = false; bool showBackwardIndicator = false; - final p = pages[i]; + final page = pages[index]; if (pages.length != 1) { if (pages.length == 2) { - if (activeIndex == 0 && activeIndex != i) { + if (activeIndex == 0 && activeIndex != index) { showForwardIndicator = true; - } else if (activeIndex == 1 && activeIndex != i) { + } else if (activeIndex == 1 && activeIndex != index) { showBackwardIndicator = true; } } else { - if (forwardIndex != null && i == forwardIndex) { + if (forwardIndex != null && index == forwardIndex) { showForwardIndicator = true; } if (backwardIndex != null && - i == backwardIndex && + index == backwardIndex && forwardIndex != backwardIndex) { showBackwardIndicator = true; } } } - return ReorderableDragStartListener( - key: ValueKey(p.id), - index: i, - child: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _PageRow( - page: p, - active: p.id == activePageId, - showBackwardIndicator: showBackwardIndicator, - showForwardIndicator: showForwardIndicator, - onSelect: onSelect, - onRename: onRename, - onDelete: onDelete, - disableDelete: pages.length == 1, - ), + final row = Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _PageRow( + page: page, + active: page.id == activePageId, + showBackwardIndicator: showBackwardIndicator, + showForwardIndicator: showForwardIndicator, + onSelect: onSelect, + onRename: canRenamePage ? onRename : null, + onDelete: canDeletePage + ? () => onDelete(page, pages.length) + : null, + disableDelete: !canDeletePage || pages.length == 1, ), ); + if (!canReorderPages) { + return KeyedSubtree( + key: ValueKey(page.id), + child: row, + ); + } + return ReorderableDragStartListener( + key: ValueKey(page.id), + index: index, + child: row, + ); }, ), ), @@ -362,8 +428,8 @@ class _ExpandedPanel extends ConsumerWidget { const SizedBox(width: 8), _SquareIconButton( icon: Icons.add, - onTap: onAdd, - tooltip: "Add page", + onTap: canAddPage ? onAdd : null, + tooltip: 'Add page', color: Settings.tacticalVioletTheme.primary, shortcutLabel: 'C', ), @@ -371,8 +437,7 @@ class _ExpandedPanel extends ConsumerWidget { ShadIconButton.ghost( foregroundColor: Colors.white, onPressed: onCollapse, - icon: - const Icon(Icons.keyboard_arrow_up, color: Colors.white), + icon: const Icon(Icons.keyboard_arrow_up, color: Colors.white), ), const SizedBox(width: 4), ], @@ -396,17 +461,15 @@ class _PageRow extends StatelessWidget { required this.disableDelete, }); - final StrategyPage page; + final PageListItemViewModel page; final bool active; final bool showBackwardIndicator; final bool showForwardIndicator; final ValueChanged onSelect; - final ValueChanged onRename; - final ValueChanged onDelete; + final ValueChanged? onRename; + final VoidCallback? onDelete; final bool disableDelete; - static const double _rowHeight = 40; - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -414,7 +477,6 @@ class _PageRow extends StatelessWidget { ? Settings.tacticalVioletTheme.primary : Settings.tacticalVioletTheme.card; return Material( - // color: bg, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -431,14 +493,14 @@ class _PageRow extends StatelessWidget { ), boxShadow: [ BoxShadow( - color: - Settings.tacticalVioletTheme.card.withValues(alpha: 0.2), - blurRadius: 12, - offset: const Offset(0, 4)) + color: Settings.tacticalVioletTheme.card.withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4), + ), ], color: bg, ), - height: _rowHeight, + height: 40, child: Padding( padding: const EdgeInsets.only(left: 12), child: Row( @@ -456,34 +518,32 @@ class _PageRow extends StatelessWidget { ), if (showBackwardIndicator || showForwardIndicator) ...[ const SizedBox(width: 6), - if (showBackwardIndicator) const _KeybindBadge(label: "A"), + if (showBackwardIndicator) const _KeybindBadge(label: 'A'), if (showBackwardIndicator && showForwardIndicator) const SizedBox(width: 4), - if (showForwardIndicator) const _KeybindBadge(label: "D"), + if (showForwardIndicator) const _KeybindBadge(label: 'D'), const SizedBox(width: 2), ], ShadTooltip( - builder: (context) => const Text("Rename"), + builder: (context) => const Text('Rename'), child: ShadIconButton.ghost( hoverBackgroundColor: Colors.transparent, - foregroundColor: Colors.white, + foregroundColor: + onRename == null ? Colors.white24 : Colors.white, icon: const Icon(Icons.edit, size: 18, color: Colors.white), - onPressed: () => onRename(page), + onPressed: onRename == null ? null : () => onRename!(page), ), ), ShadTooltip( - builder: (context) => const Text("Delete"), + builder: (context) => const Text('Delete'), child: ShadIconButton.ghost( hoverForegroundColor: Settings.tacticalVioletTheme.destructive, hoverBackgroundColor: Colors.transparent, foregroundColor: disableDelete ? Colors.white24 : Colors.white, - icon: const Icon( - Icons.delete, - size: 18, - ), - onPressed: disableDelete ? null : () => onDelete(page), + icon: const Icon(Icons.delete, size: 18), + onPressed: disableDelete ? null : onDelete, ), ), ], @@ -514,8 +574,8 @@ class _KeybindBadge extends StatelessWidget { ), child: Center( child: Text( - textAlign: TextAlign.center, label, + textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, fontWeight: FontWeight.w700, @@ -528,7 +588,6 @@ class _KeybindBadge extends StatelessWidget { } } -/* -------- Square + button -------- */ class _SquareIconButton extends StatelessWidget { const _SquareIconButton({ required this.icon, @@ -539,7 +598,7 @@ class _SquareIconButton extends StatelessWidget { }); final IconData icon; - final VoidCallback onTap; + final VoidCallback? onTap; final String tooltip; final Color color; final String? shortcutLabel; diff --git a/lib/widgets/save_and_load_button.dart b/lib/widgets/save_and_load_button.dart index 13a4353f..a96e97d8 100644 --- a/lib/widgets/save_and_load_button.dart +++ b/lib/widgets/save_and_load_button.dart @@ -11,7 +11,10 @@ import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/screenshot/screenshot_view.dart'; import 'package:icarus/widgets/settings_tab.dart'; import 'package:icarus/widgets/strategy_save_icon_button.dart'; @@ -63,9 +66,8 @@ class _SaveAndLoadButtonState extends ConsumerState { return; } - await ref - .read(strategyProvider.notifier) - .exportFile(ref.read(strategyProvider).id); + await StrategyImportExportService(ref) + .exportFile(ref.read(strategyProvider).strategyId!); }, icon: const Icon(Icons.file_upload), ), @@ -89,7 +91,7 @@ class _SaveAndLoadButtonState extends ConsumerState { }); CoordinateSystem.instance.setIsScreenshot(true); - final String id = ref.read(strategyProvider).id; + final String id = ref.read(strategyProvider).strategyId!; await ref.read(strategyProvider.notifier).forceSaveNow(id); @@ -105,7 +107,7 @@ class _SaveAndLoadButtonState extends ConsumerState { } final newController = ScreenshotController(); final currentPageID = - ref.read(strategyProvider.notifier).activePageID; + ref.read(strategyPageSessionProvider).activePageId; if (currentPageID == null) return; @@ -166,7 +168,7 @@ class _SaveAndLoadButtonState extends ConsumerState { type: FileType.custom, dialogTitle: 'Please select an output file:', fileName: - "${ref.read(strategyProvider).stratName ?? "new image"}.png", + "${ref.read(strategyProvider).strategyName ?? "new image"}.png", allowedExtensions: ['png'], ); if (outputFile != null) { diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index ac86558f..94397581 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -3,11 +3,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/marker_sizes_sync.dart'; 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'; @@ -17,7 +21,8 @@ class SettingsTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final activeStrategyName = ref.watch(strategyProvider).stratName; + final activeStrategyName = ref.watch(strategyProvider).strategyName; + final authState = ref.watch(authProvider); final strategySettings = ref.watch(strategySettingsProvider); final mapState = ref.watch(mapProvider); final appPreferences = ref.watch(appPreferencesProvider); @@ -58,6 +63,94 @@ class SettingsTab extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + SettingsScopeCard( + scope: SettingsScope.workspace, + title: 'Account', + description: + 'Sign in to enable cloud sync and collaborative strategy storage.', + child: Column( + children: [ + Row( + children: [ + if (authState.avatarUrl != null) + CircleAvatar( + radius: 16, + backgroundImage: + NetworkImage(authState.avatarUrl!), + ) + else + const CircleAvatar( + radius: 16, + child: Icon(Icons.person, size: 16), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + authState.isAuthenticated + ? authState.displayName + : 'Not signed in', + style: const TextStyle(fontSize: 15), + ), + ), + ], + ), + 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( + authState.errorMessage!, + style: TextStyle( + fontSize: 12, + color: Settings.tacticalVioletTheme.destructive, + ), + ), + ], + ], + ), + ), + const SizedBox(height: 20), + const _SectionDivider(), + const SizedBox(height: 20), SettingsScopeCard( scope: SettingsScope.strategy, title: "Page object sizing", @@ -117,7 +210,7 @@ class SettingsTab extends ConsumerWidget { icon: Icons.save_outlined, title: "Autosave", description: - "Automatically save the current strategy after 15 seconds of inactivity. When off, Icarus will ask before you leave unsaved work.", + "Automatically save local strategies after 15 seconds of inactivity. Cloud strategies sync live while you edit.", value: appPreferences.autosaveEnabled, onChanged: (value) async { await ref @@ -440,13 +533,17 @@ class _PageMarkerSizesSyncBannerState @override Widget build(BuildContext context) { final stratState = ref.watch(strategyProvider); + final activePageId = ref.watch( + strategyPageSessionProvider.select((state) => state.activePageId)); final liveSettings = ref.watch(strategySettingsProvider); - final strategy = - Hive.box(HiveBoxNames.strategiesBox).get(stratState.id); - final showCta = stratState.stratName != null && + final strategyId = stratState.strategyId; + final strategy = strategyId == null + ? null + : Hive.box(HiveBoxNames.strategiesBox).get(strategyId); + final showCta = stratState.strategyName != null && markerSizesDifferAcrossPages( strategy: strategy, - activePageId: stratState.activePageId, + activePageId: activePageId, liveSettings: liveSettings, ); diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index 98112108..f14387e0 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -9,6 +9,7 @@ import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -66,7 +67,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { Future _switchStrategy(String strategyId) async { if (_isSwitching || _isEditingName) return; final currentStrategy = ref.read(strategyProvider); - if (currentStrategy.id == strategyId) return; + if (currentStrategy.strategyId == strategyId) return; _closePortal(); setState(() => _isSwitching = true); @@ -100,7 +101,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { void _startEditingName() { final currentStrategy = ref.read(strategyProvider); - final currentName = currentStrategy.stratName; + final currentName = currentStrategy.strategyName; if (_isSwitching || _isEditingName || currentName == null) return; _closePortal(); @@ -167,7 +168,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { try { await ref .read(strategyProvider.notifier) - .renameStrategy(ref.read(strategyProvider).id, nextName); + .renameStrategy(ref.read(strategyProvider).strategyId!, nextName); if (!mounted) return; _originalName = null; setState(() { @@ -244,7 +245,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { @override Widget build(BuildContext context) { final currentStrategy = ref.watch(strategyProvider); - final strategyName = currentStrategy.stratName ?? 'Untitled Strategy'; + final strategyName = currentStrategy.strategyName ?? 'Untitled Strategy'; final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); return Padding( @@ -256,7 +257,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { builder: (context, box, _) { final recents = _recentStrategies( box: box, - currentStrategyId: currentStrategy.id, + currentStrategyId: currentStrategy.strategyId!, ); return OverlayPortal( @@ -414,18 +415,19 @@ class _StrategyQuickSwitcherState extends ConsumerState { ), ) : Tooltip( - message: currentStrategy.stratName == null + message: currentStrategy.strategyName == null ? 'Load a strategy to rename it' : 'Rename strategy', child: Material( color: Colors.transparent, child: InkWell( - onTap: currentStrategy.stratName == null + onTap: currentStrategy.strategyName == null ? null : _startEditingName, - mouseCursor: currentStrategy.stratName == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, + mouseCursor: + currentStrategy.strategyName == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric( diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 5459cd5a..26f8ff3f 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -123,7 +123,7 @@ class _AutoSaveButtonState extends ConsumerState // manual save path shows a SnackBar await ref .read(strategyProvider.notifier) - .forceSaveNow(ref.read(strategyProvider).id); + .forceSaveNow(ref.read(strategyProvider).strategyId!); if (!context.mounted) return; toastification.showCustom( diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 47a64a28..2da72e8b 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -3,8 +3,12 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +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'; @@ -13,9 +17,30 @@ import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class StrategyTile extends ConsumerStatefulWidget { - const StrategyTile({super.key, required this.strategyData}); + const StrategyTile.local({ + super.key, + required this.strategyData, + }) : cloudStrategy = null, + canRename = true, + canDuplicate = true, + canDelete = true, + canMove = true; - final StrategyData strategyData; + const StrategyTile.cloud({ + super.key, + required this.cloudStrategy, + required this.canRename, + required this.canDuplicate, + required this.canDelete, + required this.canMove, + }) : strategyData = null; + + final StrategyData? strategyData; + final CloudStrategySummary? cloudStrategy; + final bool canRename; + final bool canDuplicate; + final bool canDelete; + final bool canMove; @override ConsumerState createState() => _StrategyTileState(); @@ -30,6 +55,15 @@ class _StrategyTileState extends ConsumerState { final ShadContextMenuController _rightClickMenuController = ShadContextMenuController(); + bool get _isCloud => widget.cloudStrategy != null; + String get _strategyId => + widget.strategyData?.id ?? widget.cloudStrategy!.publicId; + String get _strategyName => + widget.strategyData?.name ?? widget.cloudStrategy!.name; + StrategyTileViewData get _viewData => widget.strategyData != null + ? StrategyTileViewData.fromStrategy(widget.strategyData!) + : StrategyTileViewData.fromCloudSummary(widget.cloudStrategy!); + @override void dispose() { _menuButtonController.dispose(); @@ -39,11 +73,14 @@ class _StrategyTileState extends ConsumerState { @override Widget build(BuildContext context) { - final viewData = StrategyTileViewData(widget.strategyData); + final viewData = _viewData; return Draggable( - data: StrategyItem(widget.strategyData), + data: _isCloud + ? StrategyItem.cloud(_strategyId) + : StrategyItem.local(widget.strategyData!), dragAnchorStrategy: pointerDragAnchorStrategy, + maxSimultaneousDrags: widget.canMove ? null : 0, feedback: Opacity( opacity: 0.95, child: Material( @@ -99,9 +136,7 @@ class _StrategyTileState extends ConsumerState { onPressed: () { _menuButtonController.toggle(); }, - icon: const Icon( - Icons.more_vert_outlined, - ), + icon: const Icon(Icons.more_vert_outlined), ), ), ), @@ -119,23 +154,23 @@ class _StrategyTileState extends ConsumerState { return [ ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), + onPressed: widget.canRename ? () => _showRenameDialog() : null, child: const Text('Rename'), - onPressed: () => _showRenameDialog(), ), ShadContextMenuItem( leading: const Icon(LucideIcons.copy), + onPressed: widget.canDuplicate ? () => _duplicateStrategy() : null, child: const Text('Duplicate'), - onPressed: () => _duplicateStrategy(), ), ShadContextMenuItem( leading: const Icon(LucideIcons.upload), - child: const Text('Export'), onPressed: () => _exportStrategy(), + child: const Text('Export'), ), ShadContextMenuItem( leading: const Icon(LucideIcons.trash2, color: Colors.redAccent), + onPressed: widget.canDelete ? () => _showDeleteDialog() : null, child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), - onPressed: () => _showDeleteDialog(), ), ]; } @@ -149,9 +184,11 @@ class _StrategyTileState extends ConsumerState { _showLoadingOverlay(); try { - await ref - .read(strategyProvider.notifier) - .loadFromHive(widget.strategyData.id); + if (_isCloud) { + await ref.read(strategyProvider.notifier).openCloudStrategy(_strategyId); + } else { + await ref.read(strategyProvider.notifier).loadFromHive(_strategyId); + } if (!context.mounted) return; Navigator.pop(context); await Navigator.push( @@ -194,9 +231,10 @@ class _StrategyTileState extends ConsumerState { } Future _duplicateStrategy() async { - await ref - .read(strategyProvider.notifier) - .duplicateStrategy(widget.strategyData.id); + await ref.read(strategyProvider.notifier).duplicateStrategy( + _strategyId, + source: _isCloud ? StrategySource.cloud : StrategySource.local, + ); } Future _exportStrategy() async { @@ -208,20 +246,22 @@ class _StrategyTileState extends ConsumerState { return; } - await ref - .read(strategyProvider.notifier) - .loadFromHive(widget.strategyData.id); - await ref - .read(strategyProvider.notifier) - .exportFile(widget.strategyData.id); + if (_isCloud) { + await StrategyImportExportService(ref).exportCloudStrategy(_strategyId); + return; + } + + await ref.read(strategyProvider.notifier).loadFromHive(_strategyId); + await StrategyImportExportService(ref).exportFile(_strategyId); } Future _showRenameDialog() async { await showShadDialog( context: context, builder: (_) => RenameStrategyDialog( - strategyId: widget.strategyData.id, - currentName: widget.strategyData.name, + strategyId: _strategyId, + currentName: _strategyName, + source: _isCloud ? StrategySource.cloud : StrategySource.local, ), ); } @@ -230,8 +270,9 @@ class _StrategyTileState extends ConsumerState { showDialog( context: context, builder: (_) => DeleteStrategyAlertDialog( - strategyID: widget.strategyData.id, - name: widget.strategyData.name, + strategyID: _strategyId, + name: _strategyName, + source: _isCloud ? StrategySource.cloud : StrategySource.local, ), ); } diff --git a/lib/widgets/strategy_tile/strategy_tile_sections.dart b/lib/widgets/strategy_tile/strategy_tile_sections.dart index e0ca4ee0..8b4c4f9f 100644 --- a/lib/widgets/strategy_tile/strategy_tile_sections.dart +++ b/lib/widgets/strategy_tile/strategy_tile_sections.dart @@ -1,26 +1,60 @@ import 'package:flutter/material.dart'; +import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_page.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double _agentIconSize = 27; const double _agentRowSpacing = 5; class StrategyTileViewData { - StrategyTileViewData(this.strategy) - : name = strategy.name, - mapName = _mapName(strategy.mapData), - attackLabel = _attackLabel(strategy.pages), - attackColor = _attackColor(strategy.pages), - thumbnailAsset = - 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp', - lastEditedLabel = _timeAgo(strategy.lastEdited), - agentTypes = _collectAgentTypes(strategy.pages); - - final StrategyData strategy; + const StrategyTileViewData({ + required this.name, + required this.mapName, + required this.attackLabel, + required this.attackColor, + required this.thumbnailAsset, + required this.lastEditedLabel, + required this.agentTypes, + }); + + factory StrategyTileViewData.fromStrategy(StrategyData strategy) { + return StrategyTileViewData( + name: strategy.name, + mapName: _mapName(strategy.mapData), + attackLabel: _attackLabel(strategy.pages), + attackColor: _attackColor(_attackLabel(strategy.pages)), + thumbnailAsset: + 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp', + lastEditedLabel: _timeAgo(strategy.lastEdited), + agentTypes: _collectAgentTypes(strategy.pages), + ); + } + + factory StrategyTileViewData.fromCloudSummary(CloudStrategySummary strategy) { + MapValue? mapValue; + for (final entry in Maps.mapNames.entries) { + if (entry.value == strategy.mapData) { + mapValue = entry.key; + break; + } + } + final attackLabel = strategy.attackLabel ?? 'Unknown'; + return StrategyTileViewData( + name: strategy.name, + mapName: _mapName(mapValue), + attackLabel: attackLabel, + attackColor: _attackColor(attackLabel), + thumbnailAsset: + 'assets/maps/thumbnails/${strategy.mapData}_thumbnail.webp', + lastEditedLabel: _timeAgo(strategy.updatedAt), + agentTypes: const [], + ); + } + final String name; final String mapName; final String attackLabel; @@ -29,8 +63,8 @@ class StrategyTileViewData { final String lastEditedLabel; final List agentTypes; - static String _mapName(MapValue map) { - final raw = Maps.mapNames[map]; + static String _mapName(MapValue? map) { + final raw = map == null ? null : Maps.mapNames[map]; if (raw == null || raw.isEmpty) { return 'Unknown'; } @@ -47,8 +81,7 @@ class StrategyTileViewData { return first ? 'Attack' : 'Defend'; } - static Color _attackColor(List pages) { - final label = _attackLabel(pages); + static Color _attackColor(String label) { switch (label) { case 'Attack': return Colors.redAccent; diff --git a/lib/widgets/youtube_view.dart b/lib/widgets/youtube_view.dart index f396f4f8..f6df8a13 100644 --- a/lib/widgets/youtube_view.dart +++ b/lib/widgets/youtube_view.dart @@ -45,39 +45,52 @@ class YoutubeView extends StatefulWidget { class _YoutubeViewState extends State with AutomaticKeepAliveClientMixin { + late final Future _webViewWarmupFuture; + @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + _webViewWarmupFuture = warmUpWebViewEnvironment(); + } + @override Widget build(BuildContext context) { super.build(context); - if (!isWebViewInitialized) { - // showShadDialog( - // context: context, - // builder: (context) { - // return const WebViewDialog(); - // }, - // ); - return const WebViewDialog(); - } - return Stack( - children: [ - const Align( - alignment: Alignment.center, - child: CircularProgressIndicator(), - ), - Positioned.fill( - child: InAppWebView( - webViewEnvironment: webViewEnvironment, - initialSettings: - InAppWebViewSettings(allowBackgroundAudioPlaying: false), - initialUrlRequest: URLRequest( - url: WebUri( - "https://embed.icarus-strats.xyz/?v=${YoutubeHandler.extractYoutubeIdWithTimestamp(widget.youtubeLink)}")), - ), - ), - ], + return FutureBuilder( + future: _webViewWarmupFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done && + !isWebViewWarmupComplete) { + return const Center(child: CircularProgressIndicator()); + } + + if (!isWebViewInitialized) { + return const WebViewDialog(); + } + + return Stack( + children: [ + const Align( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ), + Positioned.fill( + child: InAppWebView( + webViewEnvironment: webViewEnvironment, + initialSettings: + InAppWebViewSettings(allowBackgroundAudioPlaying: false), + initialUrlRequest: URLRequest( + url: WebUri( + "https://embed.icarus-strats.xyz/?v=${YoutubeHandler.extractYoutubeIdWithTimestamp(widget.youtubeLink)}")), + ), + ), + ], + ); + }, ); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 1beeddac..a9fe8366 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) desktop_updater_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopUpdaterPlugin"); desktop_updater_plugin_register_with_registrar(desktop_updater_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 3a9a1f09..87c89668 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST custom_mouse_cursor desktop_drop desktop_updater + gtk pasteboard screen_retriever_linux url_launcher_linux @@ -13,6 +14,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + convex_flutter ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d68755e8..70a56fae 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import app_links import cryptography_flutter_plus import custom_mouse_cursor import desktop_drop @@ -14,10 +15,12 @@ import flutter_inappwebview_macos import pasteboard import path_provider_foundation import screen_retriever_macos +import shared_preferences_foundation import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) CryptographyFlutterPlugin.register(with: registry.registrar(forPlugin: "CryptographyFlutterPlugin")) CustomMouseCursorPlugin.register(with: registry.registrar(forPlugin: "CustomMouseCursorPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) @@ -27,6 +30,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/package.json b/package.json new file mode 100644 index 00000000..5a172be7 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "icarus", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "convex": "^1.32.0" + } +} diff --git a/pubspec.lock b/pubspec.lock index b6fb56e7..8d762ad5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "85.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" analyzer: dependency: transitive description: @@ -25,6 +33,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.4" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: "direct main" description: @@ -73,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.4" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: @@ -201,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + convex_flutter: + dependency: "direct main" + description: + name: convex_flutter + sha256: db3bca4e3e6792eadadba9a662225ccb743c287f4550879f67885cd40a2198f2 + url: "https://pub.dev" + source: hosted + version: "3.0.1" cross_file: dependency: "direct main" description: @@ -265,6 +321,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: c6ecb3bb991c459b91c5adf9e871113dcb32bbe8fe7ca2c92723f88ffc1e0b7a + url: "https://pub.dev" + source: hosted + version: "3.3.2" dart_style: dependency: transitive description: @@ -297,6 +361,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" equatable: dependency: transitive description: @@ -483,6 +555,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" flutter_shaders: dependency: transitive description: @@ -525,6 +605,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" get_it: dependency: transitive description: @@ -541,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2 + url: "https://pub.dev" + source: hosted + version: "2.18.0" graphs: dependency: transitive description: @@ -549,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" hive_ce: dependency: "direct main" description: @@ -669,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.9.5" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" leak_tracker: dependency: transitive description: @@ -869,6 +981,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: @@ -885,6 +1005,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" pub_semver: dependency: transitive description: @@ -901,6 +1029,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" riverpod: dependency: transitive description: @@ -933,6 +1077,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.5" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" screen_retriever: dependency: transitive description: @@ -989,6 +1141,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.40.6" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -1050,6 +1258,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" + url: "https://pub.dev" + source: hosted + version: "2.4.1" stream_channel: dependency: transitive description: @@ -1074,6 +1290,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + supabase: + dependency: "direct main" + description: + name: supabase + sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911 + url: "https://pub.dev" + source: hosted + version: "2.10.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "92b2416ecb6a5c3ed34cf6e382b35ce6cc8921b64f2a9299d5d28968d42b09bb" + url: "https://pub.dev" + source: hosted + version: "2.12.0" term_glyph: dependency: transitive description: @@ -1298,6 +1530,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: "direct main" + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" window_manager: dependency: "direct main" description: @@ -1346,6 +1586,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: dart: ">=3.9.0 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 33cd61b4..0f38ac8a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,11 @@ dependencies: pasteboard: ^0.4.0 desktop_updater: ^1.4.0 cryptography_plus: ^2.7.1 + convex_flutter: ^3.0.1 + supabase: ^2.10.2 + supabase_flutter: ^2.12.0 + win32_registry: ^2.1.0 + app_links: ^6.4.1 dev_dependencies: diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..1c45028c --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "skills": { + "convex-create-component": { + "source": "get-convex/agent-skills", + "sourceType": "github", + "computedHash": "84897925dd765dd58847b3b05b22ad706e65609f93a962345b48a97ff93a760f" + }, + "convex-migration-helper": { + "source": "get-convex/agent-skills", + "sourceType": "github", + "computedHash": "b99262360eb6fba714155b630537861da2d4c890365f629c75d607c8a1405c7b" + }, + "convex-performance-audit": { + "source": "get-convex/agent-skills", + "sourceType": "github", + "computedHash": "1a41a616f9615b9229928653fa22c752e86487f415c73082dd17f1073e059127" + }, + "convex-quickstart": { + "source": "get-convex/agent-skills", + "sourceType": "github", + "computedHash": "51322b7e70b0f47ec67650b2db721eb91043cba99bf8c864366b7b250d8a313f" + }, + "convex-setup-auth": { + "source": "get-convex/agent-skills", + "sourceType": "github", + "computedHash": "7cc29991c446d2ea574dc9abb5fb85c0c9a7f3ef3af4c94454eef45017bda794" + } + } +} diff --git a/skills/convex-create-component/SKILL.md b/skills/convex-create-component/SKILL.md new file mode 100644 index 00000000..a79c18e0 --- /dev/null +++ b/skills/convex-create-component/SKILL.md @@ -0,0 +1,284 @@ +--- +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..97f64c1a --- /dev/null +++ b/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,150 @@ +--- +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..219583e0 --- /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..c80522f2 --- /dev/null +++ b/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,170 @@ +# 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..9d92b33c --- /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 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 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..c71d14cb --- /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..e3e44b15 --- /dev/null +++ b/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,371 @@ +# 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 new file mode 100644 index 00000000..a96d0466 --- /dev/null +++ b/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,126 @@ +# 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 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..792bba3d --- /dev/null +++ b/skills/convex-quickstart/SKILL.md @@ -0,0 +1,337 @@ +--- +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..0fa00e2f --- /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/action_provider_bulk_clear_test.dart b/test/action_provider_bulk_clear_test.dart index 98a9678f..968e2986 100644 --- a/test/action_provider_bulk_clear_test.dart +++ b/test/action_provider_bulk_clear_test.dart @@ -18,22 +18,24 @@ 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'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( - isSaved: true, - stratName: null, - id: 'test-strategy', + return const StrategyState( + strategyId: 'test-strategy', + strategyName: null, + source: StrategySource.local, storageDirectory: null, - activePageId: null, + isOpen: true, ); } @override void setUnsaved() { - state = state.copyWith(isSaved: false); + state = state.copyWith(isOpen: true); } } diff --git a/test/cloud_ui_parity_helpers_test.dart b/test/cloud_ui_parity_helpers_test.dart new file mode 100644 index 00000000..404d1e69 --- /dev/null +++ b/test/cloud_ui_parity_helpers_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; + +void main() { + test('cloud folder summary adapts to local folder model defaults', () { + final summary = CloudFolderSummary( + publicId: 'folder-1', + name: 'Set Plays', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 2), + parentFolderPublicId: 'parent-1', + ); + + final folder = FolderProvider.cloudSummaryToFolder(summary); + + expect(folder.id, 'folder-1'); + expect(folder.name, 'Set Plays'); + expect(folder.parentID, 'parent-1'); + expect(folder.color, FolderColor.generic); + expect(folder.icon.codePoint, Icons.drive_folder_upload.codePoint); + expect(folder.customColor, isNull); + }); + + test('cloud folder summary preserves icon and color metadata', () { + final summary = CloudFolderSummary( + publicId: 'folder-2', + name: 'Execs', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 2), + iconCodePoint: 0xe318, + iconFontFamily: 'MaterialIcons', + color: 'red', + customColorValue: 0xFF123456, + ); + + final folder = FolderProvider.cloudSummaryToFolder(summary); + + expect(folder.icon.codePoint, 0xe318); + expect(folder.icon.fontFamily, 'MaterialIcons'); + expect(folder.color, FolderColor.red); + expect(folder.customColor, const Color(0xFF123456)); + }); + + test('viewer cloud capabilities disable mutations', () { + final caps = StrategyCapabilities.fromCloudRole('viewer'); + + expect(caps.canRenameStrategy, isFalse); + expect(caps.canDeleteStrategy, isFalse); + expect(caps.canAddPage, isFalse); + expect(caps.canReorderPages, isFalse); + }); + + test('owner cloud capabilities allow destructive actions', () { + final caps = StrategyCapabilities.fromCloudRole('owner'); + + expect(caps.canRenameStrategy, isTrue); + expect(caps.canDeleteStrategy, isTrue); + expect(caps.canAddPage, isTrue); + expect(caps.canReorderPages, isTrue); + }); +} diff --git a/test/collab_sync_models_test.dart b/test/collab_sync_models_test.dart new file mode 100644 index 00000000..678045a6 --- /dev/null +++ b/test/collab_sync_models_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; + +void main() { + group('CloudCollabModeState', () { + test('is enabled for authenticated, ready users', () { + const mode = CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: false, + ); + + expect( + mode.isCloudEnabled( + isAuthenticated: true, + isConvexUserReady: true, + ), + isTrue, + ); + }); + + test('is disabled when force-local fallback is enabled', () { + const mode = CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: true, + ); + + expect( + mode.isCloudEnabled( + isAuthenticated: true, + isConvexUserReady: true, + ), + isFalse, + ); + }); + }); + + group('StrategyOp model', () { + test('serializes only populated optional fields', () { + const op = StrategyOp( + opId: 'op-1', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-1', + payload: '{"foo":"bar"}', + ); + + final json = op.toConvexJson(); + + expect(json['opId'], 'op-1'); + expect(json['kind'], 'patch'); + expect(json['entityType'], 'element'); + expect(json['entityPublicId'], 'element-1'); + expect(json['payload'], '{"foo":"bar"}'); + expect(json.containsKey('pagePublicId'), isFalse); + expect(json.containsKey('sortIndex'), isFalse); + expect(json.containsKey('expectedRevision'), isFalse); + expect(json.containsKey('expectedSequence'), isFalse); + }); + + test('copyWith updates expected values while preserving identity', () { + const original = StrategyOp( + opId: 'op-2', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.lineup, + entityPublicId: 'lineup-1', + pagePublicId: 'page-1', + ); + + final updated = + original.copyWith(expectedRevision: 9, expectedSequence: 12); + + expect(updated.opId, original.opId); + expect(updated.entityPublicId, original.entityPublicId); + expect(updated.pagePublicId, original.pagePublicId); + expect(updated.expectedRevision, 9); + expect(updated.expectedSequence, 12); + }); + }); + + group('RemoteElement', () { + test('decodes valid payload json object', () { + const remote = RemoteElement( + publicId: 'el-1', + strategyPublicId: 'strat-1', + pagePublicId: 'page-1', + elementType: 'agent', + payload: '{"id":"agent-1","elementType":"agent"}', + sortIndex: 0, + revision: 1, + deleted: false, + ); + + expect(remote.decodedPayload()['id'], 'agent-1'); + expect(remote.decodedPayload()['elementType'], 'agent'); + }); + + test('returns empty map for invalid payload json', () { + const remote = RemoteElement( + publicId: 'el-2', + strategyPublicId: 'strat-1', + pagePublicId: 'page-1', + elementType: 'agent', + payload: 'not json', + sortIndex: 0, + revision: 1, + deleted: false, + ); + + expect(remote.decodedPayload(), isEmpty); + }); + }); +} diff --git a/test/drawing_provider_test.dart b/test/drawing_provider_test.dart index 7cc09efb..2a164fab 100644 --- a/test/drawing_provider_test.dart +++ b/test/drawing_provider_test.dart @@ -11,22 +11,24 @@ import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( - isSaved: true, - stratName: null, - id: 'drawing-test', + return const StrategyState( + strategyId: 'drawing-test', + strategyName: null, + source: StrategySource.local, storageDirectory: null, - activePageId: null, + isOpen: true, ); } @override void setUnsaved() { - state = state.copyWith(isSaved: false); + state = state.copyWith(isOpen: true); } } diff --git a/test/ica_drop_target_test.dart b/test/ica_drop_target_test.dart index 47a142b9..ccc15a2b 100644 --- a/test/ica_drop_target_test.dart +++ b/test/ica_drop_target_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; void main() { diff --git a/test/library_workspace_provider_test.dart b/test/library_workspace_provider_test.dart new file mode 100644 index 00000000..4460b251 --- /dev/null +++ b/test/library_workspace_provider_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +void main() { + test('workspace defaults to local', () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthProvider(_signedOutState), + ), + ], + ); + addTearDown(container.dispose); + + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.local); + }); + + test('cloud cannot be selected when unavailable', () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthProvider(_signedOutState), + ), + ], + ); + addTearDown(container.dispose); + + container + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.cloud); + + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.local); + }); + + test('workspace falls back to local when cloud becomes unavailable', () { + final fakeAuth = _FakeAuthProvider(_cloudReadyState); + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => fakeAuth), + ], + ); + addTearDown(container.dispose); + + container + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.cloud); + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.cloud); + + fakeAuth.setState(_signedOutState); + + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.local); + }); +} + +const _signedOutState = AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, +); + +const _cloudReadyState = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, +); + +class _FakeAuthProvider extends AuthProvider { + _FakeAuthProvider(this._initialState); + + final AppAuthState _initialState; + + @override + AppAuthState build() { + return _initialState; + } + + void setState(AppAuthState nextState) { + state = nextState; + } +} diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart new file mode 100644 index 00000000..9223a5cf --- /dev/null +++ b/test/providers/auth_provider_test.dart @@ -0,0 +1,458 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeSupabaseApi supabaseApi; + late FakeConvexApi convexApi; + + setUp(() { + supabaseApi = FakeSupabaseApi(); + convexApi = FakeConvexApi(); + AuthProvider.debugSupabaseApi = supabaseApi; + AuthProvider.debugConvexApi = convexApi; + AuthProvider.debugConvexAuthReadyTimeout = const Duration(milliseconds: 50); + }); + + tearDown(() async { + AuthProvider.resetTestOverrides(); + await supabaseApi.dispose(); + }); + + test('build with existing Supabase session does not throw', () async { + supabaseApi.currentSession = fakeSession(); + supabaseApi.emitInitialSessionOnListen = true; + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(() => container.read(authProvider), returnsNormally); + + final state = container.read(authProvider); + expect(state.isAuthenticated, isTrue); + await pumpMicrotasks(); + }); + + test('build queues Convex auth setup without gating auth events', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.setAuthCompleter = Completer(); + final container = ProviderContainer(); + addTearDown(container.dispose); + + final initialState = container.read(authProvider); + + expect(initialState.isAuthenticated, isTrue); + expect(initialState.convexAuthStatus, ConvexAuthStatus.configuring); + expect(convexApi.setAuthCalls, 0); + + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(convexApi.setAuthCalls, 1); + expect(state.convexAuthStatus, ConvexAuthStatus.configuring); + expect(state.activeAuthIncidentId, isNull); + expect(convexApi.reconnectCalls, 0); + + convexApi.setAuthCompleter!.complete(FakeAuthHandle()); + }); + + test('startup auth calls reconnect before waiting for readiness', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + }); + + test( + 'does not call ensureCurrentUser before Convex auth becomes authenticated', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 1); + expect(convexApi.lastMutationName, 'users:ensureCurrentUser'); + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.ready); + }); + + test( + 'callback login relies on auth-state listener and does not schedule duplicate setup', + () async { + supabaseApi.sessionFromUrlSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + final handled = + await container.read(authProvider.notifier).handleAuthCallbackUri( + Uri.parse('icarus://auth/callback#access_token=test-token'), + source: 'test', + ); + await pumpMicrotasks(); + + expect(handled, isTrue); + expect(supabaseApi.getSessionFromUrlCalls, 1); + expect(convexApi.setAuthCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.isAuthenticated, isTrue); + expect(state.convexAuthStatus, ConvexAuthStatus.ready); + }); + + test( + 'existing session on startup waits for Convex auth readiness before ensuring user', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 1); + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.ready); + expect(state.isConvexUserReady, isTrue); + }); + + test('reconnect false still waits for authState and can recover', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + convexApi.reconnectResult = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 1); + expect( + container.read(authProvider).convexAuthStatus, ConvexAuthStatus.ready); + }); + + test('null session clears Convex auth cleanly', () async { + supabaseApi.currentSession = null; + final container = ProviderContainer(); + addTearDown(container.dispose); + + final initialState = container.read(authProvider); + expect(initialState.convexAuthStatus, ConvexAuthStatus.signedOut); + + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(convexApi.clearAuthCalls, 1); + expect(state.isAuthenticated, isFalse); + expect(state.convexAuthStatus, ConvexAuthStatus.signedOut); + }); + + test('real unauthenticated error still creates auth incident', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.mutationError = Exception('{"code":"UNAUTHENTICATED"}'); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.activeAuthIncidentId, isNotNull); + expect( + state.errorMessage, + 'Cloud authentication expired. Retry Convex auth or sign out.', + ); + }); + + test('auth readiness timeout surfaces as setup incident, not unauthenticated', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await Future.delayed(const Duration(milliseconds: 80)); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.activeAuthIncidentId, isNull); + expect( + state.errorMessage, + contains('Convex auth did not become ready within'), + ); + expect(convexApi.mutationCalls, 0); + }); + + test( + 'non-auth setup error does not incorrectly become unauthenticated incident', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.mutationError = StateError('boom'); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.activeAuthIncidentId, isNull); + expect(state.errorMessage, contains('Failed to configure Convex auth')); + }); + + test( + 'reconnect throwing still surfaces setup incident if readiness never arrives', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + convexApi.reconnectError = StateError('reconnect failed'); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await Future.delayed(const Duration(milliseconds: 80)); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(convexApi.reconnectCalls, 1); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.activeAuthIncidentId, isNull); + expect( + state.errorMessage, + contains('Convex auth did not become ready within'), + ); + expect(state.errorMessage, contains('reconnectResult: unknown')); + }); +} + +Future pumpMicrotasks() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +Session fakeSession() { + final session = Session( + accessToken: 'test-token', + refreshToken: 'refresh-token', + tokenType: 'bearer', + user: const User( + id: 'user-1', + appMetadata: {}, + userMetadata: {'full_name': 'Test User'}, + aud: 'authenticated', + email: 'test@example.com', + createdAt: '2026-03-23T00:00:00.000Z', + ), + ); + session.expiresAt = DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000; + return session; +} + +class FakeAuthHandle implements AuthProviderAuthHandle { + bool isDisposed = false; + + @override + void dispose() { + isDisposed = true; + } +} + +class FakeConvexApi implements AuthProviderConvexApi { + FakeConvexApi() : _authStateController = StreamController.broadcast(); + + final StreamController _authStateController; + int clearAuthCalls = 0; + int reconnectCalls = 0; + int setAuthCalls = 0; + int mutationCalls = 0; + String? lastMutationName; + bool autoAuthenticateOnSetAuth = true; + bool _isAuthenticated = false; + Object? mutationError; + Object? reconnectError; + bool reconnectResult = true; + Completer? setAuthCompleter; + + @override + Stream get authState => _authStateController.stream; + + @override + bool get isAuthenticated => _isAuthenticated; + + @override + String? get currentConnectionStateLabel => 'connected'; + + @override + Future clearAuth() async { + clearAuthCalls += 1; + _isAuthenticated = false; + _authStateController.add(false); + } + + @override + Future reconnect() async { + reconnectCalls += 1; + if (reconnectError case final Object error?) { + throw error; + } + return reconnectResult; + } + + @override + Future mutation({ + required String name, + required Map args, + }) async { + mutationCalls += 1; + lastMutationName = name; + if (mutationError case final Object error?) { + throw error; + } + return '{}'; + } + + @override + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + setAuthCalls += 1; + final completer = setAuthCompleter; + if (completer != null) { + return completer.future; + } + if (autoAuthenticateOnSetAuth) { + emitAuthState(true); + onAuthChange?.call(true); + } + return FakeAuthHandle(); + } + + void emitAuthState(bool isAuthenticated) { + _isAuthenticated = isAuthenticated; + _authStateController.add(isAuthenticated); + } +} + +class FakeSupabaseApi implements AuthProviderSupabaseApi { + @override + Session? currentSession; + bool emitInitialSessionOnListen = false; + Session? sessionFromUrlSession; + int getSessionFromUrlCalls = 0; + final List> _controllers = + >[]; + + @override + Stream get onAuthStateChange => Stream.multi( + (controller) { + _controllers.add(controller); + if (emitInitialSessionOnListen) { + controller.add( + AuthState(AuthChangeEvent.initialSession, currentSession), + ); + } + controller.onCancel = () { + _controllers.remove(controller); + }; + }, + isBroadcast: true, + ); + + @override + Future getSessionFromUrl(Uri uri) async { + getSessionFromUrlCalls += 1; + if (sessionFromUrlSession case final Session session?) { + currentSession = session; + for (final controller in _controllers) { + controller.add(AuthState(AuthChangeEvent.signedIn, currentSession)); + } + } + } + + @override + Future refreshSession() async { + return AuthResponse(session: currentSession); + } + + @override + Future signInWithOAuth( + OAuthProvider provider, { + required String redirectTo, + required LaunchMode authScreenLaunchMode, + required String scopes, + }) async { + return true; + } + + @override + Future signInWithPassword({ + required String email, + required String password, + }) async { + return AuthResponse(session: currentSession); + } + + @override + Future signOut() async { + currentSession = null; + } + + @override + Future signUp({ + required String email, + required String password, + }) async { + return AuthResponse(session: currentSession); + } + + Future dispose() async {} +} diff --git a/test/screenshot_view_test.dart b/test/screenshot_view_test.dart index e2515141..bd575ae8 100644 --- a/test/screenshot_view_test.dart +++ b/test/screenshot_view_test.dart @@ -16,6 +16,8 @@ import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/pen_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; @@ -164,12 +166,12 @@ void main() { }); Widget buildHarness({required bool isAttack}) { - final strategyState = StrategyState( - isSaved: true, - stratName: 'test strategy', - id: 'strategy-id', + const strategyState = StrategyState( + strategyId: 'strategy-id', + strategyName: 'test strategy', + source: StrategySource.local, storageDirectory: null, - activePageId: 'page-1', + isOpen: true, ); return ProviderScope( diff --git a/test/strategy_folder_import_test.dart b/test/strategy_folder_import_test.dart index ec5ec7c9..9bc95271 100644 --- a/test/strategy_folder_import_test.dart +++ b/test/strategy_folder_import_test.dart @@ -15,8 +15,9 @@ import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/favorite_agents_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/archive_manifest.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:path/path.dart' as path; bool _adaptersRegistered = false; @@ -66,7 +67,7 @@ void main() { await _writeStrategyFile(File(path.join(childDir.path, 'a-site.ica'))); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -95,7 +96,7 @@ void main() { await Directory(path.join(emptyChild.path, 'Deep Empty')).create(); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -132,7 +133,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -172,7 +173,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -215,7 +216,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -267,7 +268,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -318,8 +319,7 @@ void main() { await _storeStrategy(name: 'default', folderID: rootFolder.id); await _storeStrategy(name: 'a-site', folderID: childFolder.id); - final exportDirectory = await container - .read(strategyProvider.notifier) + final exportDirectory = await StrategyImportExportService(container) .buildFolderExportDirectoryForTest(rootFolder.id); try { @@ -344,7 +344,7 @@ void main() { await Hive.box(HiveBoxNames.foldersBox).clear(); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -404,8 +404,7 @@ void main() { themeProfileId: customProfile.id, ); - final exportDirectory = await container - .read(strategyProvider.notifier) + final exportDirectory = await StrategyImportExportService(container) .buildLibraryExportDirectoryForTest(); try { @@ -429,7 +428,7 @@ void main() { await MapThemeProfilesProvider.bootstrap(); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -476,7 +475,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -509,7 +508,7 @@ void main() { await _writeStrategyFile(file); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(file.path)], ); @@ -545,7 +544,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -575,7 +574,7 @@ void main() { await nestedText.writeAsString('notes'); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -596,7 +595,7 @@ void main() { await file.writeAsString('not a strategy'); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(file.path)], ); diff --git a/test/strategy_import_version_guard_test.dart b/test/strategy_import_version_guard_test.dart index 7acf5cc4..5c4457d4 100644 --- a/test/strategy_import_version_guard_test.dart +++ b/test/strategy_import_version_guard_test.dart @@ -7,20 +7,21 @@ import 'package:hive_ce/hive.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/hive/hive_registration.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_models.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('version guard allows current and older versions', () { expect( - () => StrategyProvider.throwIfImportedVersionIsTooNewForTest( + () => StrategyImportExportService.throwIfImportedVersionIsTooNewForTest( Settings.versionNumber, ), returnsNormally, ); expect( - () => StrategyProvider.throwIfImportedVersionIsTooNewForTest( + () => StrategyImportExportService.throwIfImportedVersionIsTooNewForTest( Settings.versionNumber - 1, ), returnsNormally, @@ -29,7 +30,7 @@ void main() { test('version guard throws on newer version', () { expect( - () => StrategyProvider.throwIfImportedVersionIsTooNewForTest( + () => StrategyImportExportService.throwIfImportedVersionIsTooNewForTest( Settings.versionNumber + 1, ), throwsA(isA()), @@ -54,9 +55,7 @@ void main() { addTearDown(container.dispose); await expectLater( - container - .read(strategyProvider.notifier) - .loadFromFilePath(badVersionFile.path), + StrategyImportExportService(container).loadFromFilePath(badVersionFile.path), throwsA(isA()), ); diff --git a/test/strategy_integrity_test.dart b/test/strategy_integrity_test.dart index 2aac21a8..d1ea27d1 100644 --- a/test/strategy_integrity_test.dart +++ b/test/strategy_integrity_test.dart @@ -17,10 +17,11 @@ import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_page.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/utility_provider.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:path/path.dart' as path; class _IcaFixture { @@ -165,7 +166,7 @@ Future _importStrategyFromDecoded({ pages: pages, ); - strategy = await StrategyProvider.migrateLegacyData(strategy); + strategy = await StrategyMigrator.migrateLegacyData(strategy); return strategy; } @@ -619,7 +620,7 @@ void main() { ], ); - final migrated = StrategyProvider.migrateToWorld16x9(source, force: true); + final migrated = StrategyMigrator.migrateToWorld16x9(source, force: true); final page = migrated.pages.single; final migratedLine = page.drawingData.first as Line; diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart new file mode 100644 index 00000000..02312712 --- /dev/null +++ b/test/strategy_op_queue_provider_test.dart @@ -0,0 +1,141 @@ +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/strategy_op_queue_provider.dart'; + +void main() { + group('StrategyOpQueueNotifier coalescing', () { + late ProviderContainer container; + late StrategyOpQueueNotifier notifier; + + setUp(() { + container = ProviderContainer(); + notifier = container.read(strategyOpQueueProvider.notifier); + notifier.setActiveStrategy('strategy-1'); + }); + + tearDown(() { + container.dispose(); + }); + + test('coalesces add followed by patch into one add op', () { + notifier.enqueue( + const StrategyOp( + opId: 'add-1', + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-1', + pagePublicId: 'page-1', + payload: '{"value":"a"}', + sortIndex: 0, + ), + ); + notifier.enqueue( + const StrategyOp( + opId: 'patch-1', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-1', + pagePublicId: 'page-1', + payload: '{"value":"b"}', + sortIndex: 2, + ), + ); + + final pending = container.read(strategyOpQueueProvider).pending; + expect(pending, hasLength(1)); + expect(pending.single.op.kind, StrategyOpKind.add); + expect(pending.single.op.payload, '{"value":"b"}'); + expect(pending.single.op.sortIndex, 2); + }); + + test('coalesces repeated patches to the latest payload', () { + notifier.enqueue( + const StrategyOp( + opId: 'patch-1', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.lineup, + entityPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: '{"value":"a"}', + sortIndex: 0, + ), + ); + notifier.enqueue( + const StrategyOp( + opId: 'patch-2', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.lineup, + entityPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: '{"value":"b"}', + sortIndex: 1, + ), + ); + + final pending = container.read(strategyOpQueueProvider).pending; + expect(pending, hasLength(1)); + expect(pending.single.op.kind, StrategyOpKind.patch); + expect(pending.single.op.payload, '{"value":"b"}'); + expect(pending.single.op.sortIndex, 1); + }); + + test('removes add when followed by delete for same entity', () { + notifier.enqueue( + const StrategyOp( + opId: 'add-1', + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-1', + pagePublicId: 'page-1', + payload: '{"value":"a"}', + sortIndex: 0, + ), + ); + notifier.enqueue( + const StrategyOp( + opId: 'delete-1', + kind: StrategyOpKind.delete, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-1', + pagePublicId: 'page-1', + ), + ); + + final pending = container.read(strategyOpQueueProvider).pending; + expect(pending, isEmpty); + }); + + test('preserves unrelated pending ops', () { + notifier.enqueue( + const StrategyOp( + opId: 'patch-1', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-1', + pagePublicId: 'page-1', + payload: '{"value":"a"}', + sortIndex: 0, + ), + ); + notifier.enqueue( + const StrategyOp( + opId: 'patch-2', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-2', + pagePublicId: 'page-1', + payload: '{"value":"b"}', + sortIndex: 1, + ), + ); + + final pending = container.read(strategyOpQueueProvider).pending; + expect(pending, hasLength(2)); + expect( + pending.map((op) => op.op.entityPublicId), + ['element-1', 'element-2'], + ); + }); + }); +} diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart new file mode 100644 index 00000000..9fff64e6 --- /dev/null +++ b/test/strategy_page_session_provider_test.dart @@ -0,0 +1,624 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +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/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/hive/hive_registration.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/strategy_page.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _StaticStrategyProvider extends StrategyProvider { + _StaticStrategyProvider(this.initialState); + + final StrategyState initialState; + + @override + StrategyState build() => initialState; +} + +class _FakeRemoteStrategySnapshotNotifier + extends RemoteStrategySnapshotNotifier { + _FakeRemoteStrategySnapshotNotifier(this.initialSnapshot); + + RemoteStrategySnapshot? initialSnapshot; + int refreshCount = 0; + + @override + Future build() async => initialSnapshot; + + void setSnapshot(RemoteStrategySnapshot snapshot) { + initialSnapshot = snapshot; + state = AsyncData(snapshot); + } + + @override + Future refresh() async { + refreshCount++; + state = AsyncData(initialSnapshot); + } +} + +class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { + _FakeStrategyOpQueueNotifier(this.strategyPublicId); + + final String? strategyPublicId; + int enqueueAllCount = 0; + int flushNowCount = 0; + final List enqueuedOps = []; + + @override + StrategyOpQueueState build() { + return StrategyOpQueueState( + strategyPublicId: strategyPublicId, + clientId: 'test-client', + ); + } + + @override + void setActiveStrategy(String? strategyPublicId) { + state = state.copyWith( + strategyPublicId: strategyPublicId, + pending: const [], + lastAcks: const [], + clearError: true, + ); + } + + @override + void enqueueAll(Iterable ops, {bool flushImmediately = false}) { + final collected = ops.toList(growable: false); + enqueueAllCount++; + enqueuedOps.addAll(collected); + state = state.copyWith( + pending: [ + for (final op in collected) + PendingOp(op: op, clientId: state.clientId ?? 'test-client'), + ], + clearError: true, + ); + if (flushImmediately) { + flushNow(); + } + } + + @override + Future flushNow() async { + flushNowCount++; + state = state.copyWith( + pending: const [], + isFlushing: false, + lastFlushAt: DateTime.now(), + ); + } + + void emitAcks(List acks) { + state = state.copyWith(lastAcks: acks); + } +} + +Future> _openStrategyBox(String prefix) async { + const abilityInfoAdapterTypeId = 9; + final tempDir = await Directory.systemTemp.createTemp(prefix); + Hive.init(tempDir.path); + if (!Hive.isAdapterRegistered(abilityInfoAdapterTypeId)) { + registerIcarusAdapters(Hive); + } + + final strategyBox = + await Hive.openBox(HiveBoxNames.strategiesBox); + addTearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + return strategyBox; +} + +RemoteStrategySnapshot _cloudSnapshot({ + required String strategyId, + required int sequence, + required List pages, + Map> elementsByPage = const {}, +}) { + final now = DateTime.utc(2026, 1, 1); + return RemoteStrategySnapshot( + header: RemoteStrategyHeader( + publicId: strategyId, + name: 'Cloud Strategy', + mapData: Maps.mapNames[MapValue.ascent]!, + sequence: sequence, + createdAt: now, + updatedAt: now, + ), + pages: pages, + elementsByPage: elementsByPage, + lineupsByPage: const {}, + ); +} + +RemotePage _remotePage({ + required String strategyId, + required String pageId, + required int sortIndex, +}) { + return RemotePage( + publicId: pageId, + strategyPublicId: strategyId, + name: 'Page $sortIndex', + sortIndex: sortIndex, + isAttack: true, + revision: 1, + ); +} + +RemoteElement _remoteText({ + required String strategyId, + required String pageId, + required String elementId, + required String text, +}) { + final placedText = PlacedText( + id: elementId, + position: const Offset(10, 20), + )..text = text; + return RemoteElement( + publicId: elementId, + strategyPublicId: strategyId, + pagePublicId: pageId, + elementType: 'text', + payload: jsonEncode(placedText.toJson()), + sortIndex: 0, + revision: 1, + deleted: false, + ); +} + +StrategyData _localStrategy({ + required String strategyId, + required String firstText, + required String secondText, +}) { + final pageOne = StrategyPage( + id: 'page-1', + name: 'Page 1', + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: [ + PlacedText(id: 'text-1', position: const Offset(10, 20)) + ..text = firstText, + ], + imageData: const [], + utilityData: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + ); + final pageTwo = StrategyPage( + id: 'page-2', + name: 'Page 2', + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: [ + PlacedText(id: 'text-2', position: const Offset(30, 40)) + ..text = secondText, + ], + imageData: const [], + utilityData: const [], + sortIndex: 1, + isAttack: true, + settings: StrategySettings(), + ); + + return StrategyData( + id: strategyId, + name: 'Local Strategy', + mapData: MapValue.ascent, + versionNumber: 1, + lastEdited: DateTime.utc(2026, 1, 1), + folderID: null, + pages: [pageOne, pageTwo], + ); +} + +Future _settle() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +Future _cloudContainer({ + required StrategyState strategyState, + required _FakeRemoteStrategySnapshotNotifier remoteNotifier, + required _FakeStrategyOpQueueNotifier queueNotifier, +}) async { + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith( + () => _StaticStrategyProvider(strategyState), + ), + remoteStrategySnapshotProvider.overrideWith(() => remoteNotifier), + strategyOpQueueProvider.overrideWith(() => queueNotifier), + ], + ); + addTearDown(container.dispose); + container.listen(strategyPageSessionProvider, (_, __) {}); + await container.read(remoteStrategySnapshotProvider.future); + return container; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + test('remote snapshot reapply does not flush current cloud page', () async { + const strategyId = 'cloud-strategy'; + final pageOne = + _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); + final initialSnapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 1, + pages: [pageOne], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'before') + ], + }, + ); + final updatedSnapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 2, + pages: [pageOne], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'after') + ], + }, + ); + + final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(initialSnapshot); + final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final container = await _cloudContainer( + strategyState: const StrategyState( + strategyId: strategyId, + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + remoteNotifier: remoteNotifier, + queueNotifier: queueNotifier, + ); + + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'local-text', position: const Offset(50, 60)) + ..text = 'local-only', + ]); + + remoteNotifier.setSnapshot(updatedSnapshot); + await _settle(); + + expect(queueNotifier.enqueueAllCount, 0); + expect(queueNotifier.flushNowCount, 0); + expect(container.read(textProvider).single.text, 'after'); + }); + + test('reject refresh does not flush current cloud page', () async { + const strategyId = 'cloud-strategy'; + final pageOne = + _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); + final initialSnapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 1, + pages: [pageOne], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'before') + ], + }, + ); + final updatedSnapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 2, + pages: [pageOne], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'after') + ], + }, + ); + + final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(initialSnapshot); + final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final container = await _cloudContainer( + strategyState: const StrategyState( + strategyId: strategyId, + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + remoteNotifier: remoteNotifier, + queueNotifier: queueNotifier, + ); + + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'local-text', position: const Offset(50, 60)) + ..text = 'local-only', + ]); + remoteNotifier.setSnapshot(updatedSnapshot); + queueNotifier.emitAcks(const [ + OpAck( + opId: 'op-1', + status: 'reject', + latestSequence: 2, + reason: 'conflict'), + ]); + await _settle(); + + expect(remoteNotifier.refreshCount, 1); + expect(queueNotifier.enqueueAllCount, 0); + expect(queueNotifier.flushNowCount, 0); + expect(container.read(textProvider).single.text, 'after'); + }); + + test('user page switch still flushes current cloud page', () async { + const strategyId = 'cloud-strategy'; + final snapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 1, + pages: [ + _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0), + _remotePage(strategyId: strategyId, pageId: 'page-2', sortIndex: 1), + ], + elementsByPage: { + 'page-2': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-2', + elementId: 'text-2', + text: 'page-two'), + ], + }, + ); + + final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(snapshot); + final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final container = await _cloudContainer( + strategyState: const StrategyState( + strategyId: strategyId, + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + remoteNotifier: remoteNotifier, + queueNotifier: queueNotifier, + ); + + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'local-text', position: const Offset(50, 60)) + ..text = 'needs-sync', + ]); + + await container + .read(strategyPageSessionProvider.notifier) + .setActivePage('page-2'); + + expect(queueNotifier.enqueueAllCount, 1); + expect(queueNotifier.enqueuedOps, isNotEmpty); + expect(queueNotifier.flushNowCount, 1); + expect(container.read(textProvider).single.text, 'page-two'); + }); + + test('pending remote reapply resumes through non-flushing path', () async { + const strategyId = 'cloud-strategy'; + final pageOne = + _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); + final initialSnapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 1, + pages: [pageOne], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'before') + ], + }, + ); + final updatedSnapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 2, + pages: [pageOne], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'after') + ], + }, + ); + + final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(initialSnapshot); + final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final 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(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true); + remoteNotifier.setSnapshot(updatedSnapshot); + await _settle(); + + expect(container.read(textProvider).single.text, 'before'); + expect(queueNotifier.enqueueAllCount, 0); + expect(queueNotifier.flushNowCount, 0); + + container.read(strategySaveStateProvider.notifier).markPersisted(); + await _settle(); + + expect(container.read(textProvider).single.text, 'after'); + expect(queueNotifier.enqueueAllCount, 0); + expect(queueNotifier.flushNowCount, 0); + }); + + test('user page switch still flushes current local page', () async { + final box = await _openStrategyBox('icarus-page-session-local-switch-'); + final strategy = _localStrategy( + strategyId: 'local-strategy', + firstText: 'before', + secondText: 'page-two', + ); + await box.put(strategy.id, strategy); + + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'local-strategy', + strategyName: 'Local Strategy', + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.listen(strategyPageSessionProvider, (_, __) {}); + + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategy.id, + source: StrategySource.local, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'draft', + ]); + + await container + .read(strategyPageSessionProvider.notifier) + .setActivePage('page-2'); + + final saved = box.get(strategy.id)!; + expect(saved.pages.first.textData.single.text, 'draft'); + expect(container.read(textProvider).single.text, 'page-two'); + }); + + test('initializeForStrategy does not flush before initial apply', () async { + final box = await _openStrategyBox('icarus-page-session-local-init-'); + final strategy = _localStrategy( + strategyId: 'local-strategy', + firstText: 'persisted', + secondText: 'page-two', + ); + await box.put(strategy.id, strategy); + + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'local-strategy', + strategyName: 'Local Strategy', + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.listen(strategyPageSessionProvider, (_, __) {}); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'stray', position: const Offset(90, 90))..text = 'stray', + ]); + + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategy.id, + source: StrategySource.local, + selectFirstPageIfNeeded: true, + ); + + final saved = box.get(strategy.id)!; + expect(saved.pages.first.textData.single.text, 'persisted'); + expect(container.read(textProvider).single.text, 'persisted'); + }); +} diff --git a/test/text_provider_test.dart b/test/text_provider_test.dart index c6375ae3..dfef1ee0 100644 --- a/test/text_provider_test.dart +++ b/test/text_provider_test.dart @@ -13,10 +13,13 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopActionProvider extends ActionProvider { @override @@ -236,17 +239,23 @@ void main() { .setDraft('text-1', 'saved draft'); final strategyNotifier = container.read(strategyProvider.notifier); - strategyNotifier - ..setFromState( - StrategyState( - isSaved: false, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: page.id, - ), - ) - ..activePageID = page.id; + strategyNotifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategyPageSessionProvider.notifier).setStateForTest( + const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ), + ); await strategyNotifier.saveToHive(strategy.id); @@ -315,17 +324,23 @@ void main() { .setDraft('text-1', 'draft leaving page'); final strategyNotifier = container.read(strategyProvider.notifier); - strategyNotifier - ..setFromState( - StrategyState( - isSaved: false, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: pageOne.id, - ), - ) - ..activePageID = pageOne.id; + strategyNotifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategyPageSessionProvider.notifier).setStateForTest( + const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1', 'page-2'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ), + ); await strategyNotifier.setActivePage(pageTwo.id); diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index bae2e4af..b8f9f6e7 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -13,10 +13,13 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_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'; import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -266,17 +269,23 @@ void main() { container.read(textProvider.notifier).fromHive(page.textData); final strategyNotifier = container.read(strategyProvider.notifier); - strategyNotifier - ..setFromState( - StrategyState( - isSaved: false, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: page.id, - ), - ) - ..activePageID = page.id; + strategyNotifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategyPageSessionProvider.notifier).setStateForTest( + const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ), + ); await tester.pumpWidget(buildTextHarness(container)); await tester.enterText(find.byType(TextField), 'before edited'); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..bfa0fead --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 21704c28..268b13db 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -16,6 +17,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); DesktopDropPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopDropPlugin")); DesktopUpdaterPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d0d2f208..3ba041d0 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links desktop_drop desktop_updater flutter_inappwebview_windows @@ -14,6 +15,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + convex_flutter ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index dc856b09..639da5fc 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -1,12 +1,89 @@ #include #include #include - +#include "app_links/app_links_plugin_c_api.h" #include "flutter_window.h" #include "utils.h" +namespace { +void DebugLog(const std::wstring& message) { + ::OutputDebugStringW((L"[icarus] " + message + L"\n").c_str()); +} + +bool WindowTitleContains(HWND hwnd, const std::wstring& needle) { + const int titleLength = ::GetWindowTextLengthW(hwnd); + if (titleLength <= 0) { + return false; + } + + std::wstring title(titleLength, L'\0'); + ::GetWindowTextW(hwnd, title.data(), titleLength + 1); + return title.find(needle) != std::wstring::npos; +} +} // namespace + +bool SendAppLinkToInstance(const std::wstring& title) { + // Find our exact window + HWND hwnd = ::FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW", title.c_str()); + + // The app title is changed later by Dart code, so fallback to scanning + // Flutter runner windows and matching the runtime title variant. + if (!hwnd) { + HWND next = nullptr; + while ((next = ::FindWindowEx(nullptr, next, L"FLUTTER_RUNNER_WIN32_WINDOW", + nullptr)) != nullptr) { + if (WindowTitleContains(next, L"Icarus")) { + hwnd = next; + break; + } + } + } + + if (hwnd) { + DebugLog(L"Existing window found. Forwarding app link to running instance."); + + // Dispatch new link to current window + SendAppLink(hwnd); + + // (Optional) Restore our window to front in same state + WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) }; + GetWindowPlacement(hwnd, &place); + + switch(place.showCmd) { + case SW_SHOWMAXIMIZED: + ShowWindow(hwnd, SW_SHOWMAXIMIZED); + break; + case SW_SHOWMINIMIZED: + ShowWindow(hwnd, SW_RESTORE); + break; + default: + ShowWindow(hwnd, SW_NORMAL); + break; + } + + SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); + SetForegroundWindow(hwnd); + // END (Optional) Restore + + // Window has been found, don't create another one. + return true; + } + + DebugLog(L"No existing window found. Continuing cold start."); + return false; +} + int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { + DebugLog(L"wWinMain entered."); + DebugLog(std::wstring(L"Raw command line: ") + + (command_line ? command_line : L"")); + + + if (SendAppLinkToInstance(L"icarus")) { + DebugLog(L"App link forwarded to existing instance. Exiting."); + return EXIT_SUCCESS; + } // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { @@ -22,6 +99,9 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, std::vector command_line_arguments = GetCommandLineArguments(); + for (const auto& arg : command_line_arguments) { + DebugLog(std::wstring(L"CLI arg: ") + std::wstring(arg.begin(), arg.end())); + } project.set_dart_entrypoint_arguments(std::move(command_line_arguments));