|
| 1 | +import type { Request } from '@playwright/test'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Which browser requests WRITE the records of one object, named route by route. |
| 5 | + * |
| 6 | + * "Every non-GET under `/api/v1/data/:object` is a write" stopped being true |
| 7 | + * when the record page began loading through `POST /api/v1/data/:object/:id/page`: |
| 8 | + * a read with a request body, so a detector built on the HTTP method alone |
| 9 | + * counted a member's own page load as two writes. The write set is therefore |
| 10 | + * NAMED here, so a future read-shaped POST cannot be mistaken for a write, |
| 11 | + * and a real write still is. |
| 12 | + * |
| 13 | + * Source: the route ledgers, `packages/rest/src/rest-route-ledger.ts` (the |
| 14 | + * `crud`, `data-actions`, `record-shares` and `batch` families) and |
| 15 | + * `packages/runtime/src/route-ledger.ts` (the `/actions` domain). The rows were |
| 16 | + * transcribed from them once; nothing re-derives this list, so a write route |
| 17 | + * added to a ledger is not watched here until it is added below. |
| 18 | + * |
| 19 | + * - PATCH, PUT and DELETE anywhere under `/data/:object` are writes by method. |
| 20 | + * - A POST is a write only on a route named in `WRITE_POSTS`. The two |
| 21 | + * read-shaped POSTs in the ledger, `POST /data/:object/query` and |
| 22 | + * `POST /data/:object/:id/page`, are deliberately absent. |
| 23 | + * - `POST /batch`, the cross-object transaction, names its objects in the |
| 24 | + * body; it writes `object` when one of its operations names it, or when |
| 25 | + * the body cannot be read. |
| 26 | + * |
| 27 | + * Each route also matches under the environment-scoped mirror |
| 28 | + * (`/api/v1/environments/:environmentId/...`). |
| 29 | + */ |
| 30 | +const WRITE_POSTS = [ |
| 31 | + 'POST /api/v1/data/:object', |
| 32 | + 'POST /api/v1/data/:object/:id/clone', |
| 33 | + 'POST /api/v1/data/:object/:id/shares', |
| 34 | + 'POST /api/v1/data/:object/import', |
| 35 | + 'POST /api/v1/data/:object/import/jobs', |
| 36 | + 'POST /api/v1/data/:object/batch', |
| 37 | + 'POST /api/v1/data/:object/createMany', |
| 38 | + 'POST /api/v1/data/:object/updateMany', |
| 39 | + 'POST /api/v1/data/:object/deleteMany', |
| 40 | + 'POST /api/v1/actions/:object/:action', |
| 41 | + 'POST /api/v1/actions/:object/:action/:recordId', |
| 42 | + 'POST /api/v1/actions/_activation/:object/:action', |
| 43 | +] as const; |
| 44 | + |
| 45 | +const WRITE_METHODS = new Set(['PATCH', 'PUT', 'DELETE']); |
| 46 | +const ENV_MIRROR = '(?:/environments/[^/]+)?'; |
| 47 | + |
| 48 | +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 49 | + |
| 50 | +/** `/api/v1/data/:object/:id` → a pathname regex, `:object` bound to `object`. */ |
| 51 | +function routePattern(path: string, object: string): RegExp { |
| 52 | + const rest = path |
| 53 | + .replace(/^\/api\/v1/, '') |
| 54 | + .split('/') |
| 55 | + .map((seg) => (seg === ':object' ? escape(object) : seg.startsWith(':') ? '[^/]+' : escape(seg))) |
| 56 | + .join('/'); |
| 57 | + return new RegExp(`^/api/v1${ENV_MIRROR}${rest}/?$`); |
| 58 | +} |
| 59 | + |
| 60 | +function batchNames(request: Request, object: string): boolean { |
| 61 | + let body: unknown; |
| 62 | + try { |
| 63 | + body = request.postDataJSON(); |
| 64 | + } catch { |
| 65 | + return true; |
| 66 | + } |
| 67 | + const operations = (body as { operations?: unknown } | null)?.operations; |
| 68 | + if (!Array.isArray(operations)) return true; |
| 69 | + return operations.some((op) => (op as { object?: unknown } | null)?.object === object); |
| 70 | +} |
| 71 | + |
| 72 | +/** A predicate over browser requests: does this one write a record of `object`? */ |
| 73 | +export function writesRecordsOf(object: string): (request: Request) => boolean { |
| 74 | + const dataPrefix = new RegExp(`^/api/v1${ENV_MIRROR}/data/${escape(object)}(?:/|$)`); |
| 75 | + const posts = WRITE_POSTS.map((row) => routePattern(row.slice('POST '.length), object)); |
| 76 | + const crossObjectBatch = new RegExp(`^/api/v1${ENV_MIRROR}/batch/?$`); |
| 77 | + return (request) => { |
| 78 | + const method = request.method(); |
| 79 | + const path = new URL(request.url()).pathname; |
| 80 | + if (WRITE_METHODS.has(method)) return dataPrefix.test(path); |
| 81 | + if (method !== 'POST') return false; |
| 82 | + if (posts.some((p) => p.test(path))) return true; |
| 83 | + return crossObjectBatch.test(path) && batchNames(request, object); |
| 84 | + }; |
| 85 | +} |
0 commit comments