@vexcms/next 0.1.0-alpha.14 → 0.1.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,6 +64,7 @@ Versioning, drafts, and live preview are not implemented yet — both are in pro
64
64
 
65
65
  - **Access provider** — `VexAccessProvider` supplies the RBAC access matrix to the admin panel via React context
66
66
  - **Collection-level permissions** — `usePermission` gates create, read, update, and delete per collection and per global
67
+ - **Field-level permissions** — a per-field access rule hides a read-denied input entirely and renders an update-denied one read-only; edit forms submit only the fields the user actually changed
67
68
  - **UI enforcement** — Buttons and actions are disabled or hidden when the current user lacks permission
68
69
 
69
70
  ### Admin Layout
@@ -1 +1 @@
1
- {"version":3,"file":"createVexRevalidateRoute.d.ts","sourceRoot":"","sources":["../../src/cache/createVexRevalidateRoute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EAEd,SAAS,EACT,WAAW,EAIX,qBAAqB,EACtB,MAAM,cAAc,CAAC;AAGtB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,kDAAkD;AAClD,MAAM,WAAW,6BAA6B;IAC5C;;;OAGG;IACH,MAAM,EAAE,SAAS,CAAC;IAClB;;;;OAIG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC;QACrB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACvC,IAAI,EAAE,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACtC,CAAC,CAAC;IACH;;;;OAIG;IACH,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;IACnD;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,cAAc,KAAK,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;CAClF;AAoBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,6BAA6B,GAAG;IAC9E,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,qBAAqB,CAAC,CAAC,CAAC;CAClG,CA8GA"}
1
+ {"version":3,"file":"createVexRevalidateRoute.d.ts","sourceRoot":"","sources":["../../src/cache/createVexRevalidateRoute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EAEd,SAAS,EACT,WAAW,EAIX,qBAAqB,EACtB,MAAM,cAAc,CAAC;AAQtB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,kDAAkD;AAClD,MAAM,WAAW,6BAA6B;IAC5C;;;OAGG;IACH,MAAM,EAAE,SAAS,CAAC;IAClB;;;;OAIG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC;QACrB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACvC,IAAI,EAAE,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACtC,CAAC,CAAC;IACH;;;;OAIG;IACH,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;IACnD;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,cAAc,KAAK,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;CAClF;AAoBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,6BAA6B,GAAG;IAC9E,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,qBAAqB,CAAC,CAAC,CAAC;CAClG,CAiHA"}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createVexRevalidateRoute,
3
3
  createVexServerClient
4
- } from "../chunk-5AGM3PEX.js";
4
+ } from "../chunk-3J7LCKFC.js";
5
5
  export {
6
6
  createVexRevalidateRoute,
7
7
  createVexServerClient
@@ -1,5 +1,10 @@
1
1
  // src/cache/createVexRevalidateRoute.ts
2
- import { hasPermission, resolveTargets, VEX_REVALIDATE_BATCH_SIZE } from "@vexcms/core";
2
+ import {
3
+ hasPermission,
4
+ PERMISSION_SCOPES,
5
+ resolveTargets,
6
+ VEX_REVALIDATE_BATCH_SIZE
7
+ } from "@vexcms/core";
3
8
  import { revalidatePath } from "next/cache";
4
9
  import { NextResponse } from "next/server";
5
10
  function toCrudAction(operation) {
@@ -28,7 +33,10 @@ function createVexRevalidateRoute(props) {
28
33
  action,
29
34
  organization,
30
35
  resource: body.collection,
31
- user
36
+ user,
37
+ // A purge writes no document fields — this asks whether the caller
38
+ // may act on the collection at all, not against a specific payload.
39
+ scope: PERMISSION_SCOPES.any
32
40
  });
33
41
  if (!allowed) {
34
42
  return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -118,4 +126,4 @@ export {
118
126
  createVexRevalidateRoute,
119
127
  createVexServerClient
120
128
  };
121
- //# sourceMappingURL=chunk-5AGM3PEX.js.map
129
+ //# sourceMappingURL=chunk-3J7LCKFC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cache/createVexRevalidateRoute.ts","../src/cache/createVexServerClient.ts"],"sourcesContent":["import type {\n CollectionSlug,\n CrudWriteAction,\n VexConfig,\n VexDocument,\n VexMutationOperation,\n VexRevalidateChange,\n VexRevalidateRequest,\n VexRevalidateResponse,\n} from \"@vexcms/core\";\nimport {\n hasPermission,\n PERMISSION_SCOPES,\n resolveTargets,\n VEX_REVALIDATE_BATCH_SIZE,\n} from \"@vexcms/core\";\nimport { revalidatePath } from \"next/cache\";\nimport type { NextRequest } from \"next/server\";\nimport { NextResponse } from \"next/server\";\n\n/** Props for {@link createVexRevalidateRoute}. */\nexport interface CreateVexRevalidateRouteProps {\n /**\n * The resolved VexCMS config. Supplies `access` for the write-permission\n * check and `routes.map` for target resolution.\n */\n config: VexConfig;\n /**\n * Resolves the authenticated user and active organization for the current\n * request. Wire to `() => fetchAuthQuery(api.auth.api.getUserOrg, {})` — the\n * same call the admin panel route already makes.\n */\n getAuth: () => Promise<{\n organization?: Record<string, unknown>;\n user: null | Record<string, unknown>;\n }>;\n /**\n * Reads the caller's session token for the current request. Wire to the host\n * app's `getToken` export (`convexBetterAuthNextJs`'s output, re-exported\n * from e.g. `~/auth/server`). A falsy return means \"no session\".\n */\n getToken: () => Promise<null | string | undefined>;\n /**\n * Lists every document in a collection, for the collection-wide purge\n * (`{ collection, all: true }`). Wire to a read through the app's own\n * `publishedSlugs`-style query — the factory cannot know an app's generated\n * Convex `api`, which is why this is injected like `getToken`/`getAuth`.\n *\n * Omit to leave the collection-wide branch unwired: it then purges nothing\n * and reports that in `errors`, rather than silently succeeding.\n */\n listCollection?: (collection: CollectionSlug) => Promise<Partial<VexDocument>[]>;\n}\n\n/**\n * Maps the wire verb to the CRUD action `hasPermission` and `resolveTargets`\n * accept.\n *\n * `VexMutationOperation` is named for the `vexConvexApi` function that produced\n * the write (`vexConvexApi.remove`), while `hasPermission` only accepts\n * `CRUD_ACTIONS`. Mapping happens exactly once per request so the same value\n * drives both the permission check and target resolution.\n *\n * @param operation - The wire operation from the request body.\n * @returns The equivalent CRUD action.\n */\nfunction toCrudAction(operation: VexMutationOperation): CrudWriteAction {\n if (operation === \"remove\") return \"delete\";\n if (operation === \"upsert\") return \"update\";\n return operation;\n}\n\n/**\n * Creates a Next.js route handler that purges the cached paths affected by a\n * document write. The admin panel calls this via `useVexMutation` after every\n * successful create/update/delete, same-origin, as a relative fetch — no URL\n * config, no shared secret.\n *\n * **Session-authorized, not secret-authorized.** The caller is a signed-in\n * admin, so this reuses `getToken`/`getAuth` exactly like the admin panel route\n * does, rather than introducing an env var or a shared secret. There is no\n * API-key fallback: a caller with no browser session cannot reach this route as\n * designed.\n *\n * **Path-based, not tag-based.** `revalidatePath` is the only purge mechanism.\n * `cacheComponents: true` — required for `cacheTag`/`revalidateTag` — is\n * incompatible with the `dynamic`/`runtime` segment configs the auth and admin\n * routes require (measured: 5 files fail to build with it enabled), so\n * `revalidateTag` is not an option in this codebase. Do not \"upgrade\" this to\n * tag-based revalidation without first removing that segment-config dependency\n * everywhere.\n *\n * **Never fails the caller past authorization.** A missing session returns 401,\n * a session without write permission 403, and an oversized `changes` batch 413\n * — all three reject before any map work runs. Past that point the handler\n * never throws or returns 5xx: an unconfigured map, a throwing map, or an\n * individual `revalidatePath` failure all resolve to 200 with the failures\n * reported in the body. A failed purge is a stale page; failing the endpoint\n * would only teach the fire-and-forget caller to retry pointlessly.\n *\n * @param props - Config and the host app's auth accessors.\n * @returns `{ POST }` — mount directly as the route module's named export.\n *\n * @example\n * ```ts\n * // app/api/vex/revalidate/route.ts\n * import { api } from \"@convex/_generated/api\";\n * import { createVexRevalidateRoute } from \"@vexcms/next/cache\";\n * import { fetchAuthQuery, getToken } from \"~/auth/server\";\n * import config from \"~/vex.config\";\n *\n * export const { POST } = createVexRevalidateRoute({\n * config,\n * getToken,\n * getAuth: () => fetchAuthQuery(api.auth.api.getUserOrg, {}),\n * });\n * ```\n */\nexport function createVexRevalidateRoute(props: CreateVexRevalidateRouteProps): {\n POST: (request: NextRequest) => Promise<NextResponse<{ error: string } | VexRevalidateResponse>>;\n} {\n return {\n async POST(\n request: NextRequest,\n ): Promise<NextResponse<{ error: string } | VexRevalidateResponse>> {\n const token = await props.getToken();\n if (token === null || token === undefined || token === \"\") {\n return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n\n let body: VexRevalidateRequest;\n try {\n body = (await request.json()) as VexRevalidateRequest;\n } catch {\n return NextResponse.json({ error: \"Bad Request\" }, { status: 400 });\n }\n\n const { organization, user } = await props.getAuth();\n\n // One mapping, used for BOTH the permission check and target resolution.\n const operation: VexMutationOperation = \"all\" in body ? \"update\" : body.operation;\n const action = toCrudAction(operation);\n\n const allowed = hasPermission({\n access: props.config.access,\n action,\n organization,\n resource: body.collection,\n user,\n // A purge writes no document fields — this asks whether the caller\n // may act on the collection at all, not against a specific payload.\n scope: PERMISSION_SCOPES.any,\n });\n if (!allowed) {\n return NextResponse.json({ error: \"Forbidden\" }, { status: 403 });\n }\n\n const errors: unknown[] = [];\n let changes: VexRevalidateChange[];\n\n if (\"all\" in body) {\n // Server-derived, so no client-supplied batch to bound. Each document\n // is mapped as its current state.\n if (props.listCollection === undefined) {\n errors.push(\n new Error(\n \"createVexRevalidateRoute: a collection-wide purge was requested but `listCollection` is not wired\",\n ),\n );\n changes = [];\n } else {\n try {\n const docs = await props.listCollection(body.collection);\n changes = docs.map((doc) => ({ after: doc }));\n } catch (error) {\n // An unreachable deployment must not 500 a purge request.\n errors.push(error);\n changes = [];\n }\n }\n } else {\n changes = body.changes;\n if (changes.length > VEX_REVALIDATE_BATCH_SIZE) {\n return NextResponse.json(\n {\n error: `Cannot revalidate more than ${VEX_REVALIDATE_BATCH_SIZE} changes in a single request`,\n },\n { status: 413 },\n );\n }\n }\n\n const routesConfig = props.config.routes;\n if (routesConfig === undefined) {\n // A project with no route map still gets a working, harmless\n // endpoint rather than a 500. Any error already accumulated (e.g. an\n // unwired collection-wide purge) is still reported.\n return NextResponse.json({ errors, revalidated: [] }, { status: 200 });\n }\n\n const pool: string[] = [];\n\n for (const change of changes) {\n // A throwing map is contained inside `resolveTargets` — its failure\n // lands in that call's `errors` and never propagates here.\n const target = resolveTargets({\n after: change.after,\n before: change.before,\n collection: body.collection,\n map: routesConfig.map,\n operation: action,\n });\n pool.push(...target.paths);\n errors.push(...target.errors);\n }\n\n // Dedupe across the whole batch, preserving first-seen order — the same\n // contract `resolveTargets` applies to one document's before/after.\n const revalidated: string[] = [];\n for (const path of new Set(pool)) {\n try {\n revalidatePath(path);\n revalidated.push(path);\n } catch (error) {\n // `revalidatePath` is a Next API called here, so it is not covered by\n // `resolveTargets`' containment.\n errors.push(error);\n }\n }\n\n return NextResponse.json({ errors, revalidated }, { status: 200 });\n },\n };\n}\n","import type { FunctionReference, OptionalRestArgs } from \"convex/server\";\n\nimport { ConvexHttpClient } from \"convex/browser\";\nimport { cache } from \"react\";\n\nimport type { VexServerClientOptions, VexServerClient } from \"./types\";\n\n/**\n * Creates a server-only Convex read client safe to use in prerenderable\n * Next.js routes.\n *\n * Wraps `ConvexHttpClient` from `convex/browser` instead of `convex/nextjs`'s\n * `fetchQuery`, which hard-codes `client.setFetchOptions({ cache: \"no-store\" })`\n * — the reason every route calling it builds dynamic (`ƒ`). This client never\n * sets that option, so a route calling only `query()` can be prerendered and\n * governed by the route's own `export const revalidate`.\n *\n * Every `query()` call is deduped via `React.cache` for the lifetime of one\n * request: two identical `(query, args)` calls — e.g. `generateMetadata` and\n * its page both reading the same document — resolve from a single Convex round\n * trip instead of two.\n *\n * `React.cache` is instantiated fresh inside every `createVexServerClient()`\n * call, so that dedupe only holds across calls sharing the SAME client\n * instance — two clients built in two different modules cannot share a round\n * trip even when they read identical `(query, args)`. Each app therefore\n * creates exactly one instance, exported from its own `src/lib/vex.ts`, and\n * every route or component imports that shared `vex` rather than calling\n * `createVexServerClient()` itself.\n *\n * @param props - Client configuration.\n * @returns A {@link VexServerClient} whose `query` method is deduped per request.\n * @throws {Error} When no `url` is given and `process.env.NEXT_PUBLIC_CONVEX_URL`\n * is unset.\n */\nexport function createVexServerClient(props: VexServerClientOptions = {}): VexServerClient {\n const url = props.url ?? process.env.NEXT_PUBLIC_CONVEX_URL;\n if (url === undefined || url === \"\") {\n throw new Error(\n \"createVexServerClient: no Convex deployment URL. Pass `url`, or set NEXT_PUBLIC_CONVEX_URL.\",\n );\n }\n\n const client = new ConvexHttpClient(url, {\n skipConvexDeploymentUrlCheck: props.skipConvexDeploymentUrlCheck,\n });\n\n // Deliberately NOT `client.setFetchOptions({ cache: \"no-store\" })` — that is\n // exactly what `convex/nextjs`'s `fetchQuery` does and what forces every\n // consuming route dynamic.\n\n // `React.cache` keys on the argument list, so the entry is (query reference,\n // args) — never the reference alone. Args are normalised to a single value so\n // `query(fn)` and `query(fn, {})` share one entry.\n const cachedQuery = cache(\n async (query: FunctionReference<\"query\">, args: unknown): Promise<unknown> =>\n client.query(query, args as OptionalRestArgs<FunctionReference<\"query\">>[0]),\n );\n\n return {\n query: async (query, ...args) => cachedQuery(query, args[0] ?? {}),\n };\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  import {
8
8
  createVexRevalidateRoute,
9
9
  createVexServerClient
10
- } from "./chunk-5AGM3PEX.js";
10
+ } from "./chunk-3J7LCKFC.js";
11
11
  import {
12
12
  createVexRobots,
13
13
  createVexSitemap,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vexcms/next",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.16",
4
4
  "description": "Next.js adapter and admin routes for VexCMS",
5
5
  "keywords": [
6
6
  "cms",
@@ -66,8 +66,8 @@
66
66
  },
67
67
  "dependencies": {
68
68
  "lucide-react": "0.577.0",
69
- "@vexcms/core": "0.1.0-alpha.14",
70
- "@vexcms/react": "0.1.0-alpha.14"
69
+ "@vexcms/react": "0.1.0-alpha.16",
70
+ "@vexcms/core": "0.1.0-alpha.16"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@convex-dev/react-query": "0.1.0",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/cache/createVexRevalidateRoute.ts","../src/cache/createVexServerClient.ts"],"sourcesContent":["import type {\n CollectionSlug,\n CrudWriteAction,\n VexConfig,\n VexDocument,\n VexMutationOperation,\n VexRevalidateChange,\n VexRevalidateRequest,\n VexRevalidateResponse,\n} from \"@vexcms/core\";\nimport { hasPermission, resolveTargets, VEX_REVALIDATE_BATCH_SIZE } from \"@vexcms/core\";\nimport { revalidatePath } from \"next/cache\";\nimport type { NextRequest } from \"next/server\";\nimport { NextResponse } from \"next/server\";\n\n/** Props for {@link createVexRevalidateRoute}. */\nexport interface CreateVexRevalidateRouteProps {\n /**\n * The resolved VexCMS config. Supplies `access` for the write-permission\n * check and `routes.map` for target resolution.\n */\n config: VexConfig;\n /**\n * Resolves the authenticated user and active organization for the current\n * request. Wire to `() => fetchAuthQuery(api.auth.api.getUserOrg, {})` — the\n * same call the admin panel route already makes.\n */\n getAuth: () => Promise<{\n organization?: Record<string, unknown>;\n user: null | Record<string, unknown>;\n }>;\n /**\n * Reads the caller's session token for the current request. Wire to the host\n * app's `getToken` export (`convexBetterAuthNextJs`'s output, re-exported\n * from e.g. `~/auth/server`). A falsy return means \"no session\".\n */\n getToken: () => Promise<null | string | undefined>;\n /**\n * Lists every document in a collection, for the collection-wide purge\n * (`{ collection, all: true }`). Wire to a read through the app's own\n * `publishedSlugs`-style query — the factory cannot know an app's generated\n * Convex `api`, which is why this is injected like `getToken`/`getAuth`.\n *\n * Omit to leave the collection-wide branch unwired: it then purges nothing\n * and reports that in `errors`, rather than silently succeeding.\n */\n listCollection?: (collection: CollectionSlug) => Promise<Partial<VexDocument>[]>;\n}\n\n/**\n * Maps the wire verb to the CRUD action `hasPermission` and `resolveTargets`\n * accept.\n *\n * `VexMutationOperation` is named for the `vexConvexApi` function that produced\n * the write (`vexConvexApi.remove`), while `hasPermission` only accepts\n * `CRUD_ACTIONS`. Mapping happens exactly once per request so the same value\n * drives both the permission check and target resolution.\n *\n * @param operation - The wire operation from the request body.\n * @returns The equivalent CRUD action.\n */\nfunction toCrudAction(operation: VexMutationOperation): CrudWriteAction {\n if (operation === \"remove\") return \"delete\";\n if (operation === \"upsert\") return \"update\";\n return operation;\n}\n\n/**\n * Creates a Next.js route handler that purges the cached paths affected by a\n * document write. The admin panel calls this via `useVexMutation` after every\n * successful create/update/delete, same-origin, as a relative fetch — no URL\n * config, no shared secret.\n *\n * **Session-authorized, not secret-authorized.** The caller is a signed-in\n * admin, so this reuses `getToken`/`getAuth` exactly like the admin panel route\n * does, rather than introducing an env var or a shared secret. There is no\n * API-key fallback: a caller with no browser session cannot reach this route as\n * designed.\n *\n * **Path-based, not tag-based.** `revalidatePath` is the only purge mechanism.\n * `cacheComponents: true` — required for `cacheTag`/`revalidateTag` — is\n * incompatible with the `dynamic`/`runtime` segment configs the auth and admin\n * routes require (measured: 5 files fail to build with it enabled), so\n * `revalidateTag` is not an option in this codebase. Do not \"upgrade\" this to\n * tag-based revalidation without first removing that segment-config dependency\n * everywhere.\n *\n * **Never fails the caller past authorization.** A missing session returns 401,\n * a session without write permission 403, and an oversized `changes` batch 413\n * — all three reject before any map work runs. Past that point the handler\n * never throws or returns 5xx: an unconfigured map, a throwing map, or an\n * individual `revalidatePath` failure all resolve to 200 with the failures\n * reported in the body. A failed purge is a stale page; failing the endpoint\n * would only teach the fire-and-forget caller to retry pointlessly.\n *\n * @param props - Config and the host app's auth accessors.\n * @returns `{ POST }` — mount directly as the route module's named export.\n *\n * @example\n * ```ts\n * // app/api/vex/revalidate/route.ts\n * import { api } from \"@convex/_generated/api\";\n * import { createVexRevalidateRoute } from \"@vexcms/next/cache\";\n * import { fetchAuthQuery, getToken } from \"~/auth/server\";\n * import config from \"~/vex.config\";\n *\n * export const { POST } = createVexRevalidateRoute({\n * config,\n * getToken,\n * getAuth: () => fetchAuthQuery(api.auth.api.getUserOrg, {}),\n * });\n * ```\n */\nexport function createVexRevalidateRoute(props: CreateVexRevalidateRouteProps): {\n POST: (request: NextRequest) => Promise<NextResponse<{ error: string } | VexRevalidateResponse>>;\n} {\n return {\n async POST(\n request: NextRequest,\n ): Promise<NextResponse<{ error: string } | VexRevalidateResponse>> {\n const token = await props.getToken();\n if (token === null || token === undefined || token === \"\") {\n return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n\n let body: VexRevalidateRequest;\n try {\n body = (await request.json()) as VexRevalidateRequest;\n } catch {\n return NextResponse.json({ error: \"Bad Request\" }, { status: 400 });\n }\n\n const { organization, user } = await props.getAuth();\n\n // One mapping, used for BOTH the permission check and target resolution.\n const operation: VexMutationOperation = \"all\" in body ? \"update\" : body.operation;\n const action = toCrudAction(operation);\n\n const allowed = hasPermission({\n access: props.config.access,\n action,\n organization,\n resource: body.collection,\n user,\n });\n if (!allowed) {\n return NextResponse.json({ error: \"Forbidden\" }, { status: 403 });\n }\n\n const errors: unknown[] = [];\n let changes: VexRevalidateChange[];\n\n if (\"all\" in body) {\n // Server-derived, so no client-supplied batch to bound. Each document\n // is mapped as its current state.\n if (props.listCollection === undefined) {\n errors.push(\n new Error(\n \"createVexRevalidateRoute: a collection-wide purge was requested but `listCollection` is not wired\",\n ),\n );\n changes = [];\n } else {\n try {\n const docs = await props.listCollection(body.collection);\n changes = docs.map((doc) => ({ after: doc }));\n } catch (error) {\n // An unreachable deployment must not 500 a purge request.\n errors.push(error);\n changes = [];\n }\n }\n } else {\n changes = body.changes;\n if (changes.length > VEX_REVALIDATE_BATCH_SIZE) {\n return NextResponse.json(\n {\n error: `Cannot revalidate more than ${VEX_REVALIDATE_BATCH_SIZE} changes in a single request`,\n },\n { status: 413 },\n );\n }\n }\n\n const routesConfig = props.config.routes;\n if (routesConfig === undefined) {\n // A project with no route map still gets a working, harmless\n // endpoint rather than a 500. Any error already accumulated (e.g. an\n // unwired collection-wide purge) is still reported.\n return NextResponse.json({ errors, revalidated: [] }, { status: 200 });\n }\n\n const pool: string[] = [];\n\n for (const change of changes) {\n // A throwing map is contained inside `resolveTargets` — its failure\n // lands in that call's `errors` and never propagates here.\n const target = resolveTargets({\n after: change.after,\n before: change.before,\n collection: body.collection,\n map: routesConfig.map,\n operation: action,\n });\n pool.push(...target.paths);\n errors.push(...target.errors);\n }\n\n // Dedupe across the whole batch, preserving first-seen order — the same\n // contract `resolveTargets` applies to one document's before/after.\n const revalidated: string[] = [];\n for (const path of new Set(pool)) {\n try {\n revalidatePath(path);\n revalidated.push(path);\n } catch (error) {\n // `revalidatePath` is a Next API called here, so it is not covered by\n // `resolveTargets`' containment.\n errors.push(error);\n }\n }\n\n return NextResponse.json({ errors, revalidated }, { status: 200 });\n },\n };\n}\n","import type { FunctionReference, OptionalRestArgs } from \"convex/server\";\n\nimport { ConvexHttpClient } from \"convex/browser\";\nimport { cache } from \"react\";\n\nimport type { VexServerClientOptions, VexServerClient } from \"./types\";\n\n/**\n * Creates a server-only Convex read client safe to use in prerenderable\n * Next.js routes.\n *\n * Wraps `ConvexHttpClient` from `convex/browser` instead of `convex/nextjs`'s\n * `fetchQuery`, which hard-codes `client.setFetchOptions({ cache: \"no-store\" })`\n * — the reason every route calling it builds dynamic (`ƒ`). This client never\n * sets that option, so a route calling only `query()` can be prerendered and\n * governed by the route's own `export const revalidate`.\n *\n * Every `query()` call is deduped via `React.cache` for the lifetime of one\n * request: two identical `(query, args)` calls — e.g. `generateMetadata` and\n * its page both reading the same document — resolve from a single Convex round\n * trip instead of two.\n *\n * `React.cache` is instantiated fresh inside every `createVexServerClient()`\n * call, so that dedupe only holds across calls sharing the SAME client\n * instance — two clients built in two different modules cannot share a round\n * trip even when they read identical `(query, args)`. Each app therefore\n * creates exactly one instance, exported from its own `src/lib/vex.ts`, and\n * every route or component imports that shared `vex` rather than calling\n * `createVexServerClient()` itself.\n *\n * @param props - Client configuration.\n * @returns A {@link VexServerClient} whose `query` method is deduped per request.\n * @throws {Error} When no `url` is given and `process.env.NEXT_PUBLIC_CONVEX_URL`\n * is unset.\n */\nexport function createVexServerClient(props: VexServerClientOptions = {}): VexServerClient {\n const url = props.url ?? process.env.NEXT_PUBLIC_CONVEX_URL;\n if (url === undefined || url === \"\") {\n throw new Error(\n \"createVexServerClient: no Convex deployment URL. Pass `url`, or set NEXT_PUBLIC_CONVEX_URL.\",\n );\n }\n\n const client = new ConvexHttpClient(url, {\n skipConvexDeploymentUrlCheck: props.skipConvexDeploymentUrlCheck,\n });\n\n // Deliberately NOT `client.setFetchOptions({ cache: \"no-store\" })` — that is\n // exactly what `convex/nextjs`'s `fetchQuery` does and what forces every\n // consuming route dynamic.\n\n // `React.cache` keys on the argument list, so the entry is (query reference,\n // args) — never the reference alone. Args are normalised to a single value so\n // `query(fn)` and `query(fn, {})` share one entry.\n const cachedQuery = cache(\n async (query: FunctionReference<\"query\">, args: unknown): Promise<unknown> =>\n client.query(query, args as OptionalRestArgs<FunctionReference<\"query\">>[0]),\n );\n\n return {\n query: async (query, ...args) => cachedQuery(query, args[0] ?? {}),\n };\n}\n"],"mappings":"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","names":[]}