diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9aff529c..755815b4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: - name: Test run: npm test + - name: Check the docs name only paths that exist + run: npm run check:docs + # A migration that only passes on an empty database will break cert. - name: Check migrations against a populated database run: npm run check:migrations diff --git a/.gitignore b/.gitignore index d04040c46..662b38a82 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ dist-ssr # Python caches, from the scripts/ helpers __pycache__/ *.pyc + +# The shared dev tunnel's token +.tunnel-token diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 248faa29e..5332ef50d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -35,6 +35,27 @@ "panel": "dedicated", "clear": true }, + "group": { + "kind": "build" + } + }, + { + "label": "Launch tunnel", + "type": "npm", + "script": "tunnel", + "problemMatcher": [], + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "clear": true + } + }, + { + "label": "Launch servers", + "dependsOn": ["Launch dev", "Launch tunnel"], + "problemMatcher": [], "group": { "kind": "build", "isDefault": true diff --git a/AGENTS.md b/AGENTS.md index 0132cff93..693572e32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,25 +2,43 @@ ## Comments -Explain _why_, not _what_: the what is in the code. Don't restate a signature -(write "returns the access level, respecting the cache", not a paragraph -re-deriving the caching), and delete comments that narrate obvious steps. +Default to none. A good name and type say what a thing is; a comment is for the +_why_ a reader cannot get from the code: a workaround, a constraint from +Onshape or Cloudflare, a choice that looks wrong but isn't. -One or two lines is the usual size. Go longer only for something genuinely -hard — a protocol Onshape does not document, a fix whose reason is not visible -from the code — and then say the hard thing plainly rather than compressing it -into dense prose. **No comment is better than a long one, and a long one is -better than a short one that is wrong.** Brevity is not worth an inaccuracy. +Keep it to one line, two at most. If it needs more, the code probably wants +restructuring or the detail belongs in the commit message. The exception is a +genuinely obscure protocol, and even then aim for a short paragraph. -A comment is a claim, and a reader will believe it without checking. So: +Write it plainly and with confidence: -- **Hedge where you are actually unsure.** "as far as I can tell" and "Onshape - does not document this" are useful; they tell the next person where to look. - Confident phrasing on a guess is worse than no comment. +- **Say what is true now.** No history ("used to", "no longer", "was moved + here"), and no alternatives you didn't take ("rather than X"). Those go in + the commit message. +- **Don't hedge.** If you are unsure, check — read the docs, run it, write a + test — then state the result. Leave uncertainty in only where it cannot be + checked, such as undocumented Onshape behavior, and say so in a few words. +- **Don't restate the code.** No narrating steps, re-deriving a signature, or + listing every caller. -Update the comment in the same change as the code it describes, and delete it -when it stops being true. A stale comment outranks the code in a reader's head, -which is what makes it worse than none. +Update or delete a comment in the same change as the code it describes: a +stale comment is worse than none. + +## Absent values + +Prefer `undefined` to `null` for a value that is not there: an optional field +(`name?: string`), a function that finds nothing, an unset state. Where a +boundary hands us `null` — a D1 column, KV's `get`, a DOM API, an Onshape +response — convert it where it enters (`?? undefined`) rather than carrying it +inward. + +`null` stays only where it has to: + +- the boundary's own shape: a Drizzle column type, a row straight from a query; +- where `undefined` cannot go: a TanStack Query result, a Workflow step's + result, a JSON field that must be sent to say "clear this"; +- where it means something `undefined` cannot, next to it: `null` for "none at + all" beside `undefined` for "the default". ## Components @@ -42,6 +60,11 @@ function useIsDashboard(): boolean { } ``` +## Accessibility + +Not a goal. Don't add `aria-*` attributes, screen-reader labels or keyboard +handling for their own sake; the app runs in Onshape's mouse-driven panel. + ## Layout `src/` has two sides, `backend/` (the Worker) and `frontend/` (the SPA). There @@ -65,26 +88,82 @@ D1 tables live in `db/schema.ts`, except a feature's own: tracking's are in they hold no foreign key into the rest. `drizzle.config.ts` lists every schema file, so a new one has to be added there or its tables generate no migration. +KV is for what may expire or be lost: sessions, and caches that save Onshape +calls. Every key belongs to a `kvStore` (`lib/kv-store.ts`) declared beside the +code that owns it, with its prefix, value type and lifetime; don't read or +write `KV` directly. Anything that must last goes in D1. + ## Configurations A configuration takes exactly two forms, and `features/configurations/selection.ts` is the only place either is built: -- A **selection** (`Selection`) is what someone picked: every parameter - the insertable declares, each value canonically spelled (base units, trimmed, - lowercase booleans). `toSelection` makes one out of whatever arrived — a partial map - from a search hit, a stored favorite, a request body — and every boundary - calls it. Parameter defaults are canonical too, from `parse-configuration`, so - nothing has to canonicalize one to compare against it. -- A **`ConfigurationKey`** is that selection's identity: what it overrides, - encoded as `id=value;id=value`, with hidden parameters left out. It addresses - a render — R2 keys, thumbnail urls, stored records, Onshape itself — and - `ELEMENT_DEFAULT_KEY` (the empty string) is a selection that overrides - nothing. - -Raw text lives only inside the input a user is typing into. Don't add a third -form: if something needs a different view of a selection, it wants a function in -`selection.ts`, not a new shape. +- A **selection** (`Selection`) is what someone picked: every parameter the + insertable declares, each value **as it was entered**. A quantity is the + expression that was typed — `(2 + 3) in`, not `0.127 m` — and a quantity's + default is spelled in its own unit (`1 in`). `toSelection` makes one out of + whatever arrived — a search hit's values, a stored favorite, a request body, + the url — and every boundary calls it. The selection is what is stored, what + the url carries, and what Onshape is sent (`onshapeOverrides`), so a derived + feature shows the expression that was typed. +- A **`ConfigurationKey`** is derived from a selection for one purpose: naming + its thumbnail. It holds what the selection overrides, canonically spelled + (base units, hidden parameters left out), so selections rendering the same + part share a render. `DEFAULT_CONFIGURATION_KEY` (the empty string) overrides + nothing. Never store a key in place of the selection it came from, and never + send one to Onshape as a configuration outside thumbnails. + +`docs/architecture/configurations.md` covers the rest of the area. + +Anything that needs values compared or counted — analytics, "is this the +default" — goes through `canonicalValue`/`canonicalValues`, never through a key. +Don't add a third form: if something needs a different view of a selection, it +wants a function in `selection.ts`, not a new shape. + +# Architecture docs + +`docs/architecture/` holds one document per feature area, indexed in its +`README.md`: thumbnails, configurations, loading, favorites, auth (with access +levels and environment variables), search, and analytics. Each states the area's flows, storage, +**invariants**, failure modes and decisions. Read the area's document before +changing it, and check the change against its invariants. + +Update the document in the same commit when a change: + +- adds, removes or reorders a step of a flow it describes; +- adds, moves or drops storage — a table or column, a `kvStore`, an R2 prefix, + a browser store — or changes a lifetime, limit, retry policy or concurrency; +- adds, renames or removes an environment variable or binding; +- changes who may do something (a guard); +- moves or renames a file a document names (`npm run check:docs` fails on these); +- breaks or replaces an invariant. That is a design change: say so in the + commit message, rewrite the invariant, and add the reason under **Decisions**. + +A new feature area that stores data, calls Onshape or runs in the background +gets its own document, in the shape `docs/architecture/README.md` sets out, and +a row in its index. `docs/REFERENCE.md` stays a short tour that links to these +rather than repeating them. + +Write them as comments are written: what is true now, no history, no hedging. +Name code by path and symbol in backticks; describe behavior and point at the +code rather than pasting it. + +When a document and the code disagree and it isn't clear which is intended, +ask rather than quietly changing either. After reading a whole document against +the code and fixing what disagrees, bump its **Last reviewed** date. + +# Tests + +`npm test` runs two Vitest projects. A backend test that needs bindings (D1, +R2, KV, Workflows) is named `*.worker.test.ts` and runs in the Workers runtime +against a freshly migrated D1; that setup costs far more than most tests, so +everything else — pure backend logic and the frontend — runs in Node. + +Component tests are `*.test.tsx` and run in jsdom (the `dom` project). +`renderWithProviders` in `__test_utils__/render.tsx` renders the way the app +does, with a query cache that never fetches: seed what a component reads with +`setQueryData`. Test what a person does and sees — type, click, read the +screen — rather than a component's internals. # Running the app @@ -102,8 +181,8 @@ VITE_ACCESS_LEVEL_OVERRIDE=admin # granted by the server, and viewed by the clie ``` Then `npm run dev` (applies local D1 migrations, then serves -http://localhost:3000). The dev server goes https only when `localhost-key.pem` -and `localhost.pem` are present, so leave them out for a headless browser. +http://localhost:3000). A headless browser uses that url directly; the tunnel in +the README is only for Onshape. The test Worker ignores `.env` (`vitest.config.ts` turns that off), so leaving one in place does not rewrite what the auth tests assert. diff --git a/README.md b/README.md index 1c8809197..9f9698945 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,6 @@ _Other browsers, such as Brave, can have default security policies that prevent Create a new file in the root of this project named `.env` and add the following contents: ``` -# Server config -VERBOSE_LOGGING=true # Set to false to reduce logging output - # Onshape API Keys (Optional) API_ACCESS_KEY= API_SECRET_KEY= @@ -47,8 +44,8 @@ To test Onshape app changes, you will need to create an OAuth application in the - Name: (Arbitrary) FRC Design App Test - Primary format: (Arbitrary) com.frc-design-app.dev - Summary: (Arbitrary) Test for the FRC Design App. -- Redirect URLs: `https://localhost:3000/auth/callback` -- OAuth URL: `https://localhost:3000/auth/sign-in` +- Redirect URLs: `https://dev.frcdesign.org/auth/callback` +- OAuth URL: `https://dev.frcdesign.org/auth/sign-in` - Check the permissions `can read your profile information`, `can read your documents`, `can write to your documents`, and `can delete your documents and workspaces`. Click Create application, then copy your OAuth app's OAuth client secret (from the popup) and OAuth client identifier into your `.env` file. @@ -62,8 +59,8 @@ Next, add the necessary Extensions to your OAuth application so you can see it i - Location: Element right panel - Context: Inside assembly/Inside part studio - Action URL: - - Assembly: `https://localhost:3000/init?elementType=ASSEMBLY&documentId={$documentId}&instanceType={$workspaceOrVersion}&instanceId={$workspaceOrVersionId}&elementId={$elementId}` - - Part Studio: `https://localhost:3000/init?elementType=PARTSTUDIO&documentId={$documentId}&instanceType={$workspaceOrVersion}&instanceId={$workspaceOrVersionId}&elementId={$elementId}` + - Assembly: `https://dev.frcdesign.org/init?elementType=ASSEMBLY&documentId={$documentId}&instanceType={$workspaceOrVersion}&instanceId={$workspaceOrVersionId}&elementId={$elementId}` + - Part Studio: `https://dev.frcdesign.org/init?elementType=PARTSTUDIO&documentId={$documentId}&instanceType={$workspaceOrVersion}&instanceId={$workspaceOrVersionId}&elementId={$elementId}` - Icon: You'll need an icon. A good choice is the one at `/public/frc-design-app-dev.svg`. 4. Open the [Onshape App Store](https://cad.onshape.com/appstore/myapps) and go to My apps. Find your App and Subscribe to it. - If it doesn't show up, try creating a Store Entry first. @@ -83,36 +80,32 @@ Note that Onshape has an annual limit of 2,500 API calls per Onshape account. Th In particular, avoid loading large documents into your local environment and only force reload the database when necessary. -## HTTPS Setup - -Onshape requires all apps, even temporary test apps, to use https. This creates a big headache for local development. +## Tunnel Setup -You can get around this by using [mkcert](https://github.com/FiloSottile/mkcert) to create a self signed certificate which your browser will trust. +Onshape loads the app over https and delivers webhooks from its own servers, so the dev server is served at https://dev.frcdesign.org through a shared [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/). The hostname never changes, so the urls in the Onshape OAuth app are set once. -1. Install mkcert on your local machine (not in the dev container!). - If you are on Windows, this will likely mean installing [Chocolately](https://chocolatey.org/install) and running `choco install mkcert` using a Powershell terminal you run as an Administrator. -1. Create a local Certificate Authority (CA): +1. Install [cloudflared](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/) 2025.4.0 or later. +1. Get the tunnel's token from a maintainer (it is a secret; never commit it) and save it at the root of this project, where git ignores it: ``` -mkcert -install +printf '%s' '' > .tunnel-token ``` -1. Create a localhost certificate (localhost-key.pem and localhost.pem) and copy them into the root of this project: +`npm run tunnel` runs the tunnel next to `npm run dev`; the `Launch servers` VSCode task starts both. Open the app at https://dev.frcdesign.org. -``` -cd ~ # Switch to your user directory -cd Documents # Switch to the Documents folder - you can also use any other folder you recognize, like Downloads -mkcert localhost # Create a certificate which allows localhost to run -``` +Only one person can use the tunnel at a time: Cloudflare spreads requests across everyone running it, so stop yours when you're done. + +### Creating the tunnel (maintainers) + +Done once, by someone with access to the frcdesign.org Cloudflare account: -1. You can then open your Documents folder in File Explorer and copy and paste `localhost-key.pem` and `localhost.pem` into the root of this project. +1. In the Cloudflare dashboard, open Zero Trust > Networks > Tunnels and create a Cloudflared tunnel named `frc-design-app-dev`. +1. Give it the public hostname `dev.frcdesign.org` with the service `http://localhost:3000`. +1. Copy the token from the install command the dashboard shows (the long string after `--token`). To see it again later, open the tunnel's configuration, or run `cloudflared tunnel token frc-design-app-dev` after `cloudflared tunnel login`. -If you use a chromium-based browser like Google Chrome, MKCert should install the certificate automatically. -If it doesn't, you'll need to add the Certificate Authority manually. In Firefox, the procedure is: +Refreshing the token in the dashboard revokes the old one, for when it leaks or someone leaves. -1. In PowerShell, run `mkcert -CAROOT` and note down the path. -1. Open Firefox and go to `Settings > Certificates > View Certificates... > Authorities > Import...` -1. Navigate to the `CAROOT` path and select `rootCA.pem`. +To serve your own tunnel at another hostname, set `APP_URL=https://` in `.env` (it overrides the dev value in `wrangler.jsonc`), and use that hostname in your Onshape OAuth app. ## VSCode Setup @@ -126,7 +119,7 @@ npm i ## Development Servers -You should now be able to run the `Launch dev` VSCode task to launch Vite. +You should now be able to run the `Launch servers` VSCode task to launch Vite and the tunnel. You should then be able to launch the FRC Design App from the right panel of any Onshape Part Studio or Assembly and see the FRC Design App UI appear. To see documents, add one or more documents and push a new app version to rebuild the search database. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index aee72f8be..2853a1df2 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -60,7 +60,7 @@ interface ElementPath extends InstancePath { } ``` -You'll pass these around everywhere. When you need to call an Onshape endpoint, build the right path object and hand it to `apiPath()`. +You'll pass these around everywhere. When you need to call an Onshape endpoint, build the right path object and serialize it with the helpers in `src/backend/lib/onshape/path.ts`. ### How Onshape REST URLs are structured @@ -72,23 +72,18 @@ Onshape REST paths look like: The pattern is always: service → `/d/` → documentId → `/w/` or `/v/` or `/m/` → instanceId → `/e/` → elementId → endpoint action. -### `apiPath()` — building URLs +### Building URLs -The `apiPath()` function in `src/backend/onshape-api/api-path.ts` assembles these URLs for you. You give it the service name, a path object, a serializer, and any options: +Endpoint wrappers build their path with the serializers in `src/backend/lib/onshape/path.ts` (`toDocumentApiPath`, `toInstanceApiPath`, `toElementApiPath`), which turn a path object into its URL segment: ```ts -import { apiPath } from "../api-path"; -import { toInstanceApiPath, toElementApiPath } from "./path"; +// /documents/d/{did}/w/{wid}/contents +client.get(`/documents${toInstanceApiPath(instancePath)}/contents`); -// Produces: /assemblies/d/{did}/w/{wid}/e/{eid}/features -apiPath("assemblies", elementPath, toElementApiPath, { endRoute: "features" }); - -// Produces: /documents/d/{did}/w/{wid}/elements -apiPath("documents", instancePath, toInstanceApiPath, { endRoute: "elements" }); +// /assemblies/d/{did}/w/{wid}/e/{eid}/features +client.get(`/assemblies${toElementApiPath(elementPath)}/features`); ``` -The serializer functions (`toDocumentApiPath`, `toInstanceApiPath`, `toElementApiPath`) are all defined in `src/backend/lib/onshape/path.ts` and convert a path object into its URL segment string. - ### Calling the Onshape API All Onshape API calls go through the `OAuthApi` class, which is a wrapper around the Onshape API which handles authentication. For security reasons, the Onshape API is only available in the backend. The OAuthApi class can be retrieved inside any Hono route handler like this: @@ -97,15 +92,15 @@ All Onshape API calls go through the `OAuthApi` class, which is a wrapper around const onshapeApi = await c.var.getOnshapeApi(); ``` -You can then pass it to any function in `src/backend/onshape-api/endpoints/`: +You can then pass it to any function in `src/backend/lib/onshape/endpoints/`: ```ts -import { getDocumentElements } from "../onshape-api/endpoints/documents"; +import { getContents } from "../lib/onshape/endpoints/documents"; -const elements = await getDocumentElements(onshapeApi, instancePath); +const contents = await getContents(onshapeApi, instancePath); ``` -Before writing a new wrapper function, check if it already exists in one of the files under `src/backend/onshape-api/endpoints/`. +Before writing a new wrapper function, check if it already exists in one of the files under `src/backend/lib/onshape/endpoints/`. ## Adding a New Backend Route @@ -130,36 +125,44 @@ myRoutes.get("/my-thing" + libraryRoute(), async (c) => { }); ``` -**Route param helpers** (defined in `src/backend/app.ts`): +**Route param helpers** (defined in `src/backend/lib/route-params.ts`): - `libraryRoute()` — returns `"/library/:libraryId"`. Use `getLibraryParam(c)` to read it. - `insertableRoute()` — returns `"/insertable/:insertableId"`. Use `getInsertableParam(c)`. -- `groupRoute()` — returns `"/group/:groupId"`. Use `getGroupParam(c)`. +- `favoriteRoute()` — returns `"/favorite/:favoriteId"`. Use `getFavoriteParam(c)`. -**Protecting routes:** Wrap the handler with middleware if it requires elevated access: +**Protecting routes:** A route that needs elevated access names its library in the path and takes the matching middleware from `src/backend/features/auth/guards.ts`. One acting on a group or element also scopes its query to that library: ```ts -import { requireEditorMiddleware } from "../access-level-utils"; - -myRoutes.post("/my-admin-action", requireEditorMiddleware, async (c) => { - // only editors and admins reach here -}); +import { requireEditorMiddleware } from "../auth/guards"; + +myRoutes.post( + "/my-editor-action" + libraryRoute() + insertableRoute(), + requireEditorMiddleware, + async (c) => { + // only the library's editors, admins and the owner reach here + const libraryId = getLibraryParam(c); + // ...where(and(eq(insertables.id, id), eq(insertables.libraryId, libraryId))) + } +); ``` -### 2. Register the route group in `create-app.ts` +### 2. Register the route group in `app.ts` -Open `src/backend/create-app.ts`, import your new routes, and mount them: +Open `src/backend/app.ts`, import your new routes, and add them to `apiRoutes`, which mounts each under `/api`: ```ts -import { myRoutes } from "./routes/my-routes"; +import { myRoutes } from "./features/my-feature/routes"; -// Inside createApp(): -app.route("/api", myRoutes); +const apiRoutes = [ + // ... + myRoutes +]; ``` ### 3. Define the response type -If the frontend needs to consume this endpoint, define a TypeScript interface for the response in the feature's `dto.ts` (e.g. `src/backend/features/library/dto.ts`). The backend owns the contract; the frontend imports it through `@backend/features//dto`, so both sides agree on the shape. +If the frontend needs to consume this endpoint, define a TypeScript interface for the response in the feature's `contract.ts` (e.g. `src/backend/features/library/contract.ts`). The backend owns the contract; the frontend imports it through `@backend/features//contract`, so both sides agree on the shape. --- @@ -218,8 +221,8 @@ const { documentId, name } = await c.req.json<{ When calling the Onshape API from the backend, the same concept applies: -- **Path params** are handled by `apiPath()` — the `ElementPath` / `InstancePath` fields become the path segments automatically. -- **Query params** for Onshape endpoints can be passed through the endpoint wrapper functions in `src/backend/onshape-api/endpoints/`, which append them to the URL returned by `apiPath()`. +- **Path params** come from the path serializers — `toElementApiPath` and friends turn an `ElementPath` / `InstancePath` into its segments. +- **Query params** for Onshape endpoints can be passed through the endpoint wrapper functions in `src/backend/lib/onshape/endpoints/`, which pass them to the client as `query`. ## Modifying the Database Schema @@ -423,7 +426,7 @@ function useMyMutation(someId: string) { } ``` -`getQueryUpdater` (from `src/frontend/lib/utils.ts`) wraps Immer's `produce()` into a function that React Query's `setQueryData` accepts. Immer allows you to mutate query results directly rather than mutating an original cache value, which is much cleaner for nested data. +`getQueryUpdater` (from `src/frontend/lib/query-cache.ts`) wraps Immer's `produce()` into a function that React Query's `setQueryData` accepts. Immer allows you to mutate query results directly rather than mutating an original cache value, which is much cleaner for nested data. **Why cancel queries in `onMutate`?** If a background refetch lands after the optimistic update, it will overwrite the cache with stale data. Canceling outstanding queries for that key prevents this race condition. diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 05e998d85..116212f58 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -30,66 +30,39 @@ D1 is Cloudflare's managed SQLite database. It is the app's primary persistent s Queries go through **Drizzle ORM** so you write TypeScript instead of raw SQL. The schema is defined in `src/backend/db/schema.ts`. SQL migration files live in `drizzle/` and are applied automatically on deploy. -### KV — Session & Token Storage (`c.env.KV`) +### KV — Sessions and Caches (`c.env.KV`) -KV is a key-value store (like a global dictionary). The app uses it exclusively for **authentication**: +KV holds what may expire or be lost. Every key belongs to a `kvStore` (`src/backend/lib/kv-store.ts`) with its own prefix, value type and lifetime: -- During the OAuth login flow, it briefly stores the OAuth `state` and the URL to redirect back to after login. -- After login succeeds, it stores the user's access token and refresh token, keyed to their session cookie. - -KV serves as a cheap, lightweight way to persist user data across multiple Cloudflare Workers (which Cloudflare automatically scales and provisions based on the app's current traffic). Because Workers are stateless — there is no in-memory session that persists between requests — KV is the right place to stash tokens between requests. +- `session:` — a signed-in session's access and refresh tokens and user id, keyed by the opaque id in the `frc-design-app-session` cookie, for 30 days (`features/auth/session.ts`). A sign-in in flight keeps nothing here: its state and return path are the whole of the ten-minute `frc-design-app-login` cookie (`features/auth/login.ts`). +- `background-session:` — the latest session ids of the owner and each admin team member, so a load nobody is signed in behind can run as one of them. +- `unit-info:` — a workspace's units, for a week. On a miss the route asks Onshape and registers a transient `updateworkspaceunits` webhook, whose delivery drops the entry. Onshape cleans transient webhooks up after a while without events, so nothing records or removes them, and the expiry covers one it drops quietly. Onshape doesn't sign webhooks registered through the API, so the url names the workspace plainly: a forged delivery only costs a refetch (`features/webhooks/transient.ts`). ### R2 — Blob Storage (`c.env.BLOB`) -R2 is Cloudflare's blob storage, optimized for unstructured data like images and PDFs. One bucket holds everything the app stores as a blob, kept apart by key prefix: - -| Prefix | What it holds | Lifetime | -| --------------- | ------------------------------------------------- | -------------------------------- | -| `thumbnails/` | Rendered thumbnails, by element and configuration | Kept until reconciled; see below | -| `search-index/` | Each library's serialized MiniSearch index | Rewritten on every index rebuild | - -Onshape can generate preview thumbnails for parts and assemblies, but fetching them from Onshape on every page load would be slow and eat into API rate limits — a single render can require polling and take minutes. Instead, every thumbnail we ever fetch from Onshape lands in R2 and is served from there afterwards. - -Thumbnails are keyed by whether they are the element's default or a specific configuration: - -``` -thumbnails/default/{elementId}/{microversionId}/{size} -thumbnails/config/{elementId}/{microversionId}/{configKey}/{size} -``` - -`{configKey}` is the url-encoded `ConfigurationKey` — the canonical configuration with hidden and default-valued parameters dropped and quantities in meters and radians — so two equivalent selections resolve to one cached image. Encoding it keeps its `;` and `=` inside a single path segment. Including `{microversionId}` makes every object immutable, so an updated document lands on new keys rather than overwriting in place. - -Nothing expires on a timer: there is no R2 lifecycle rule, and renders are meant to last. What that costs is orphans — a tab edited into a new microversion leaves its old pair behind, and a deleted group or tab leaves everything it had. The **`reconcile-thumbnails` step** at the end of `LoadLibraryWorkflow` collects them, in `features/thumbnails/reconcile.ts`: - -- The live set is every `(elementId, microversionId)` still named by an insertable row, plus the ones a group's two stored thumbnail urls point at — a group's document thumbnail is often not one of its own insertables, and those urls are the only record of which element it is. -- It spans **every library**, because a thumbnail key names no library. A set built from the library being reloaded would read every other library's thumbnails as orphaned. -- Both prefixes are reconciled the same way: a configuration render is addressed by the same element and microversion, so it lives and dies with the element's default. -- An object younger than 24 hours is kept whatever the live set says. A group load stores thumbnails as it goes and commits its rows at the end, and a configuration render is started by a user opening the insert menu rather than by any job — so something in flight is indistinguishable from something orphaned, and only age tells them apart. -- An empty live set deletes nothing: a library really can have no elements, but so can a read that failed. -- A run scans at most 50 pages of 1,000. A bucket larger than that is finished by the next reload. +R2 is Cloudflare's blob storage. One bucket holds everything the app stores as a blob, kept apart by key prefix: -Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&configurationKey=&renderThumbnail=&insertableId=`: +| Prefix | What it holds | Lifetime | +| --------------- | ------------------------------------------------------------------------- | ------------------------------------------------- | +| `thumbnails/` | Rendered thumbnails, by element, microversion and configuration | Deleted by the load that orphans them | +| `search-index/` | Each library's serialized MiniSearch index, under a version for its shape | Rewritten on every index rebuild; built on a miss | -- **Hit** — streamed from R2 as immutable, cacheable for a year. -- **Miss** — the element's default is served instead as `no-store`, with an `X-Thumbnail-Fallback` header so the client knows to keep checking. A cacheable fallback would pin the wrong image after the real one landed. -- **Miss with `renderThumbnail=true`** — the miss also starts a `ThumbnailWorkflow` to render the configuration, resolving the element from `insertableId`. Surfaces where the user picked the configuration (the insert menu, favorites) ask for this; search rows do not, so one cold search cannot start a render per row. -- **Neither exists** — 404, and the client renders a placeholder. - -All rendering happens inside the workflow, which keeps Onshape's thumbnail id server-side. There is no HTTP path that proxies an Onshape thumbnail directly. +Every thumbnail the app shows is served from R2, never fetched from Onshape per view. How they are rendered, keyed, served and cleaned up is in [architecture/thumbnails.md](./architecture/thumbnails.md). ### Workflows — Background Jobs -Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. The two load workflows live in `src/backend/features/load/workflows.ts`; the thumbnail one lives with the feature it serves, in `src/backend/features/thumbnails/workflow.ts`: +Cloudflare Workflows run long background jobs that survive past a request and resume after a failure: + +| Binding | Class | What it does | +| --------------------------- | ------------------------- | ----------------------------------------------------------------------------- | +| `LOAD_DOCUMENT_WORKFLOW` | `LoadDocumentWorkflow` | Loads one group's document when its version moved on (or always, when forced) | +| `RENDER_THUMBNAIL_WORKFLOW` | `RenderThumbnailWorkflow` | Waits out one configuration's render and stores both sizes in R2 | -| Binding | Class | What it does | -| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | -| `LOAD_LIBRARY_WORKFLOW` | `LoadLibraryWorkflow` | Reloads every group whose document has a new version, then rebuilds the search index | -| `ADD_GROUP_WORKFLOW` | `AddGroupWorkflow` | Adds an Onshape document to a library and loads it | -| `THUMBNAIL_WORKFLOW` | `ThumbnailWorkflow` | Renders one configuration's thumbnails and stores them in R2 | +Loads, their job tracking, webhooks and version approval are in [architecture/loading.md](./architecture/loading.md); renders in [architecture/thumbnails.md](./architecture/thumbnails.md). -Loading a group means walking the document structure, downloading metadata for every part and assembly, probing each indexed configuration, generating thumbnails, and writing it all to D1 — far too long for a single HTTP request. The request kicks the workflow off and returns immediately. +### Pushes -Each workflow carries the requesting user's `sessionId`, since it calls Onshape under their tokens after the request has ended. +The server pushes to open clients over a WebSocket held by the `PushHub` Durable Object (`src/backend/features/push/`): jobs starting and finishing, a library's new version, and a configuration's render landing. Nothing polls: a client that reconnects asks again for what it may have missed. ### Assets — Static File Serving (`c.env.ASSETS`) @@ -99,46 +72,19 @@ The asset binding is configured with `single-page-application` mode, which means ## How Users Get Into the App -This app runs inside an Onshape iframe, which adds some authentication complexity. Here is the complete flow from first page load to seeing the part library. - -### Normal flow (already logged in) - -1. Onshape loads the app's iframe by navigating to `/init?documentId=...&workspaceId=...&elementId=...` with the current document's identifiers in the URL. -2. The Worker checks if the request has a valid session cookie (`frc-design-app-cookie`) and whether the stored tokens are still valid. -3. If everything checks out, the Worker serves the React app (`index.html`), which then loads and navigates to `/app/groups`. -4. The React app calls `/api/context-data` to get user info and access level, then prefetches the library data, search index, and favorites. -5. The UI renders. - -### First-time flow (OAuth) - -1. Same as above — Onshape loads `/init`. -2. The Worker finds no valid session cookie. It redirects to `/auth/sign-in?redirectUrl=`. -3. The sign-in handler generates a random `state` string (a security measure), stores `{ state, redirectUrl }` in KV under a random session ID, sets the session cookie, and redirects the user to Onshape's OAuth login page. -4. The user sees the Onshape "Authorize App" screen and clicks approve. -5. Onshape redirects back to `/auth/callback?code=...&state=...`. The sign-in names that callback as the `redirect_uri`, taken from the origin the request came in on, so Onshape returns the user to the host they signed in on rather than to whichever redirect URL it would otherwise pick — which is what lets two hosts share one OAuth app during a cutover. Every origin the app answers on has to be one of the redirect URLs registered on the OAuth app, spelled exactly. -6. The callback handler reads the session from KV, verifies the `state` matches (preventing CSRF attacks), and exchanges the `code` for real access and refresh tokens using the [Arctic](https://arcticjs.dev/) OAuth library. The exchange repeats the same `redirect_uri`, as OAuth requires. -7. The tokens are saved to KV under the session ID. The temporary login-session entry is deleted. -8. The user is redirected back to the original `/init` URL, which now succeeds because the session cookie and tokens are in place. - -### What happens on the client after auth - -Once the backend confirms authentication and serves the React app, the frontend takes over: +Onshape opens the app at `/init`, which sends a caller without a working session through Onshape sign-in and back, then hands off to the SPA at `/`. `/` resumes the last tab and group from `localStorage`. The sign-in flow, sessions and access are in [architecture/auth.md](./architecture/auth.md). -1. The `/init` route normalizes the Onshape URL parameters (document ID, workspace ID, element ID, theme, etc.) and stores them in the URL query string. TanStack Router carries these parameters forward automatically via `retainSearchParams`, so child routes can always read them. -2. `/init`'s `beforeLoad` immediately redirects to `/app/groups` (or to the last-opened group if one is saved in `localStorage`). -3. The `/app` route's `beforeLoad` calls `getContextDataQuery()` — a blocking fetch that retrieves user settings (including the `libraryId` and `cacheVersion`) and access level before any child route renders. -4. The `/app` route's `loader` uses the `cacheVersion` to kick off three prefetches in parallel: the full library data, the search index, and the user's favorites. -5. With all data already in the React Query cache, the groups page renders immediately with no loading spinners. +The `/app` route reads Onshape's parameters off the url once per page load, into `ui-state` (`src/frontend/routes/app/route.tsx`). From then on the store is what the app reads, and in-app navigation keeps only the app's own parameters (`q`, `part`, `config`, `favorite`) in the url. ## Storage at a Glance -| Store | What it holds | Lifetime | Who reads/writes it | -| ------------------ | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| **D1** | Library data, groups, parts (insertables), configurations, user preferences, favorites | Permanent (until explicitly changed) | Backend Worker on every API request | -| **KV** | OAuth session state (during login) and auth tokens (after login) | Login state: 10 minutes. Tokens: 30 days. | Backend Worker in `src/backend/features/auth/session.ts` | -| **R2** | Thumbnail images and per-library search indexes | Defaults and indexes permanent; configuration thumbnails ~90 days | Backend Worker in `src/backend/features/thumbnails/` and `src/backend/features/library/db.ts` | -| **localStorage** | UI state: open/closed panels, active search query, vendor filters, last-opened group | Persists across browser sessions | Frontend only, via `src/frontend/lib/ui-state.ts` | -| **sessionStorage** | Not used | — | — | +| Store | What it holds | Lifetime | Who reads/writes it | +| ------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **D1** | Library data, groups, parts (insertables), configurations, users, favorites | Permanent (until explicitly changed) | Backend Worker on every API request | +| **KV** | Session tokens, borrowable admin sessions, workspace units | Sessions: 30 days. Units: a week. | Backend Worker, through a `kvStore` per key prefix | +| **R2** | Thumbnail images and per-library search indexes | Until a load orphans them; indexes rewritten on rebuild | Backend Worker in `src/backend/features/thumbnails/` and `src/backend/features/library/db.ts` | +| **localStorage** | UI state: theme, last tab and group, open/closed panels, search query, vendor filters | Persists across browser sessions | Frontend only, via `src/frontend/lib/ui-state.ts` | +| **sessionStorage** | Not used | — | — | ## Codebase Map @@ -151,31 +97,33 @@ owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. ### `src/backend/` -- `index.ts` — Worker entry point; exports the default app and the three Workflow classes +- `index.ts` — Worker entry point; exports the default app, the two Workflow classes and the `PushHub` Durable Object - `app.ts` — composition root, and nothing else: binds the caller onto each request, mounts every feature's routes, and installs the error handler - `db/` — `client.ts` (the Drizzle client) and `schema.ts` (table definitions) - `lib/` — request plumbing shared by every feature: `context.ts` (bindings, typed context, and the caller binding), `cache.ts` (cache-control middleware), `api-error.ts` and `errors.ts` (the one shape every failed response takes), `validate.ts`, `route-params.ts`, `query-params.ts` -- `lib/onshape/` — everything that talks to Onshape's REST API: `client.ts` (the client class), `api-path.ts`, `path.ts` (`ElementPath`/`InstancePath` and their serializers), `endpoints/` (per-category wrappers), `objects/` (feature and query builders) +- `lib/onshape/` — everything that talks to Onshape's REST API: `client.ts` (the client class), `path.ts` (`ElementPath`/`InstancePath` and their serializers), `endpoints/` (per-category wrappers), `objects/` (feature and query builders) - `features/` — one directory per feature, each holding its own `routes.ts` plus whatever it owns: - - `auth/` — split by role: `session.ts` stores the session cookie and its KV records, `onshape-oauth.ts` runs the handshake, `caller.ts` resolves who is calling (and exports `productionCaller`, the wiring `createApp` binds), `guards.ts` holds both gates, and `routes.ts` serves the OAuth redirects plus `/access-data` - - `entry/` — `/init`, where Onshape lands: gates on auth, then resumes the caller in the library and theme they last used - - `settings/` — the caller's stored preferences and the `Settings` model + - `auth/` — split by role: `session.ts` stores the session cookie and its KV records, `login.ts` holds a sign-in in flight, `onshape-oauth.ts` runs the handshake, `request-auth.ts` resolves who is calling (and exports `productionAuth`, the wiring `createApp` binds), `guards.ts` holds the route gates, and `routes.ts` serves the OAuth redirects plus `/access-data` + - `entry/` — `/init`, where Onshape lands: gates on auth, then hands the launch to the app - `library/` — the library response (`db.ts`), its DTOs, and the groups and insertables endpoints - `load/` — everything that turns Onshape into what we store: the `parse-*` modules (document contents, configurations, configuration records, vendors, fasten info), the per-group and per-insertable loaders, the Workflows that drive them, their retry policies, and the job tracker - `configurations/` — the configuration domain the frontend shares: models, canonicalization, combination enumeration, and the input parser - An element's own part number and material live on `insertables.part_data`; a `configurations` row exists exactly when the element has parameters to configure. + An element's own part number and material live on `insertables.part_metadata`; a `configurations` row exists exactly when the element has parameters to configure. - `thumbnails/` — rendering and R2 storage (`store.ts`), its Workflow, the routes, and the key and URL scheme the client shares - `build-checker/` — build issues, the checks that raise them, and the build-status endpoint - - `favorites/`, `search/` + - `webhooks/` — the per-document webhook that loads new versions, and transient ones for caches + - `push/` — the `PushHub` Durable Object and what it pushes + - `admin-team/` — each library's admin team, set by the owner and synced from Onshape + - `favorites/`, `search/`, `analytics/`, `insert-location/` ### `src/frontend/` - `main.tsx` — React root; wraps the app in `QueryClientProvider` and `MantineProvider` - `routes/` — file-based TanStack Router routes -- `lib/` — cross-cutting helpers: `api-client.ts` (fetch wrappers), `query-keys.ts` (every query key in one place), `query-client.ts`, `ui-state.ts` (localStorage state), `refresh.ts`, `notifications.tsx` +- `lib/` — cross-cutting helpers: `api-client.ts` (fetch wrappers), `query-keys.ts` (every query key in one place), `query-client.ts`, `ui-state.ts` (the Zustand store kept in localStorage), `onshape-params.ts` (the tab's Onshape launch, in sessionStorage), `refresh.ts`, `notifications.tsx` - `components/` — UI used by more than one feature, plus the app shell (`app-navbar.tsx`, `alerts.tsx`, `root-error.tsx`) -- `features/` — `library/`, `favorites/`, `insert/`, `search/`, `settings/`, `thumbnails/`, `build-status/`, `auth/`, each with a `queries.ts` and a `components/` directory +- `features/` — `library/`, `favorites/`, `insert/`, `insert-location/`, `search/`, `settings/`, `thumbnails/`, `build-status/`, `admin-team/`, `dashboard/`, `auth/`, each with its queries and a `components/` directory Other top-level files: @@ -184,8 +132,8 @@ Other top-level files: ## Access Levels -The app has three access levels, checked on every protected API call: **ADMIN**, **EDITOR**, and **USER**. Admin and editor access currently grant the same permissions (adding, removing, and renaming groups, toggling insertable visibility), but they are kept separate so permissions can be tightened in the future if needed. USER access allows anyone who logs in via OAuth to browse the library, insert parts, and manage their own favorites. +Four levels, per library: **owner** (the user `OWNER_USER_ID` names), **admin** and **editor** (team admins and members of the library's Onshape admin team), and **user** (anyone signed in). What each may do, how the team is synced, and the environment variables that affect access are in [architecture/auth.md](./architecture/auth.md). -The Worker determines a user's access level in `src/backend/features/auth/caller.ts` by calling the Onshape API to check team membership against the `ADMIN_TEAM` binding. Backend routes that require elevated access are wrapped with `requireEditorMiddleware` or `requireAdminMiddleware` from `src/backend/features/auth/guards.ts`. +## Architecture -During local development, you can bypass the team membership check by setting `ACCESS_LEVEL_OVERRIDE=admin` (or `editor`/`user`) in your `.env` file. +Each area's design, invariants and failure modes live in [architecture/](./architecture/README.md): thumbnails, configurations, loading, favorites, and auth. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 000000000..f9fa1d3a1 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,54 @@ +# Architecture + +One document per feature area, describing how it works **now**: what it owns, +where it stores things, how its flows run, and the rules that must keep holding. +They are the reference for reviewing a change against the design, and for +noticing when the code has drifted from it. + +| Document | Covers | +| ---------------------------------------- | ---------------------------------------------------------------------------------- | +| [thumbnails.md](./thumbnails.md) | Element, group and configuration thumbnails: rendering, R2 storage, cleanup | +| [configurations.md](./configurations.md) | Parameters, selections, configuration keys, indexing and enumeration | +| [loading.md](./loading.md) | Loading a document into the library: jobs, the load workflow, webhooks | +| [favorites.md](./favorites.md) | Per-user favorites and the configuration each opens with | +| [auth.md](./auth.md) | Onshape OAuth, sessions, access levels, and the environment variables | +| [search.md](./search.md) | Building, serving and querying each library's search index | +| [analytics.md](./analytics.md) | Usage tracking, rollups, and the public dashboard | +| [platform.md](./platform.md) | Onshape client, errors, retries and timeouts, concurrency, toasts, pushes, caching | + +[REFERENCE.md](../REFERENCE.md) is the tour of the whole system and links here +for depth; [GUIDE.md](../GUIDE.md) holds recipes. + +## Shape of a document + +Every document uses the same sections, in this order, leaving out any that +would be empty: + +1. **Purpose** — what the area is for, and what it deliberately is not. +2. **Code map** — the files that own it, each with one line on its role. +3. **Storage** — every table, KV store, R2 prefix and browser store it touches, + with lifetimes. +4. **Flows** — the paths through the code, as numbered steps. A diagram only + where the ordering is the hard part. +5. **Invariants** — rules that must hold, stated so a reviewer can check a diff + against them. This is the section drift is measured by. +6. **Failure and recovery** — what goes wrong, and what puts it right. +7. **Decisions** — choices that look wrong or have a tempting alternative, each + with its reason. New decisions are added here rather than in a separate log. + +Write the way `AGENTS.md` asks of comments: what is true now, plainly, without +history or hedging. Name code by path and symbol, in backticks, so it can be +searched for. + +## Keeping them current + +A change that alters a flow, an invariant, a storage location, a limit, or an +environment variable updates the document in the same commit. `AGENTS.md` has +the full rules. + +`npm run check:docs` fails when a document names a path under `src/`, `docs/`, +`drizzle/` or `scripts/` that no longer exists, which catches the most common +drift, a move or rename. It runs in CI. + +Each document ends with a **Last reviewed** date. Bump it when you read the +whole document against the code and fix what disagrees, not for a one-line edit. diff --git a/docs/architecture/analytics.md b/docs/architecture/analytics.md new file mode 100644 index 000000000..6253c9e64 --- /dev/null +++ b/docs/architecture/analytics.md @@ -0,0 +1,115 @@ +# Analytics and the dashboard + +## Purpose + +Records how the libraries are used — each insert and each launch from Onshape — +and reports it on a public dashboard: totals and trends, per-library health, +which parts and configuration options are used, and which aren't. It exists so +the library team can see what to maintain and what to cut. + +It records nothing a user sees back about themselves, and reports only +aggregates. + +## Code map + +| Path | Role | +| --------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `src/backend/features/analytics/tracking.ts` | `trackInsert`, `trackAppOpen`: record an event after the response | +| `src/backend/features/analytics/usage.ts` | `EventType`, `InsertSource`, `EVENT_SCHEMA_VERSION` | +| `src/backend/features/analytics/logged-event.ts` | Which columns each kind of event sets | +| `src/backend/features/analytics/schema.ts` | The event log and every rollup table | +| `src/backend/features/analytics/rollups.ts` | `rollupWrites`: the rollup rows one event adds to | +| `src/backend/features/analytics/day.ts` | Day keys in the reporting time zone | +| `src/backend/features/analytics/seasons.ts` | FRC and FTC seasons, for season-over-season comparison | +| `src/backend/features/analytics/range.ts`, `growth.ts` | Date ranges, clamped to when tracking began; growth windows | +| `src/backend/features/analytics/metric-queries.ts`, `part-queries.ts` | The dashboard's reads over the rollups | +| `src/backend/features/analytics/health.ts` | Build-issue counts per library | +| `src/backend/features/analytics/parameter-usage.ts` | Option usage, per way a parameter is shown | +| `src/backend/features/analytics/routes.ts` | `GET /api/analytics/...` | +| `src/backend/features/entry/routes.ts` | `POST /api/app-open/...`, sent on a launch from Onshape | +| `src/frontend/routes/dashboard/` | The dashboard pages: overview, library, part, unused | +| `src/frontend/features/dashboard/` | The dashboard's queries, charts and tables | + +## Storage + +**D1**, in `features/analytics/schema.ts` (kept apart from `db/schema.ts`, since +no foreign key crosses between them): + +- `events` — the append-only log. Every event has an id, type, time, day key, + library, user and schema version; an insert adds the element path, + insertable, target tab type, canonical selection, source, and the favorite, + quick-insert and fasten flags. Keyed on Onshape's element id with no foreign + keys, so history survives a reload or a re-added tab. +- Rollups, each derived from the log alone: `daily_metrics`, + `daily_target_metrics`, `daily_source_metrics`, `insertable_stats`, + `daily_insertable_metrics`, `daily_insertable_users`, + `daily_configuration_metrics`, `daily_user_activity`, `user_stats`. + +## Flows + +### Recording + +1. An insert route calls `trackInsert` once the insert succeeded; `/init`'s + handoff calls `POST /api/app-open/...`, which calls `trackAppOpen`. +2. Both run in the background (`runInBackground`), after the response, so + tracking never slows or fails what the user asked for. +3. The selection is stored canonically (`canonicalValues`), so `5 in` and + `(2 + 3) in` count as one value. +4. `record` writes the event and its rollup rows in one D1 batch, so neither + lands without the other. + +### Days and seasons + +A day key is the date in `America/New_York`, since US teams work evenings that +UTC midnight would split. Reports end yesterday: today is still filling, and +would dip every chart. Growth compares a season's stretch with the same stretch +of the previous season (`seasons.ts`), since a January–April competition year +is nothing like a calendar year; between seasons it shows the last complete one. + +### Reporting + +The dashboard is a sibling of `/app`, a full-screen page outside the Onshape +panel. Its routes read only rollups and return only aggregates: + +- **Overview** — totals, per-library summaries, insert and metric series, insert + sources, and growth. +- **Library** — its summary, health (build issues by severity, hidden elements + left out), and its parts table. +- **Part** — one element's history, and usage of each configuration option, + split per way the option is shown (`toParameterInstances`). +- **Unused** — parts and options at or below a usage threshold. + +The range, threshold and preset live in the url, so a view can be shared. + +## Invariants + +- Tracking never fails or delays the request it records. +- Analytics routes are public: they never call `getUserId()` or + `getOnshapeApi()`, and never return anything about one user. +- Every rollup is derivable from `events` alone, so the rollups can be rebuilt by + replaying the log. +- An event's columns are decided in `logged-event.ts`; a new column fails to + compile until each kind of event says what it holds. +- Values are compared and counted canonically, never by configuration key. +- Changing what a column means bumps `EVENT_SCHEMA_VERSION`. + +## Failure and recovery + +| Failure | Result | Recovery | +| ------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------- | +| The tracking write fails | That event is lost; the insert is unaffected | None needed | +| A rollup is wrong after a code change | Dashboard numbers off | Replay `events` through `rollupWrites`; no script does this yet | +| A range starts before tracking began | Clamped; growth withheld when its baseline predates tracking | Automatic | + +## Decisions + +- **A log plus rollups.** The log keeps everything for replay; rollups keep every + dashboard read a small indexed query. +- **Keyed by element, not insertable.** An insertable row is replaced when a tab + is removed and re-added; its element id is not. +- **Public aggregates.** The numbers are useful to the community, and nothing + per-user is exposed. +- **The module isn't called `events.ts`.** Ad blockers match an `events-.js` + chunk, and the frontend imports its enums. + +_Last reviewed: 2026-09-27_ diff --git a/docs/architecture/auth.md b/docs/architecture/auth.md new file mode 100644 index 000000000..080ee200e --- /dev/null +++ b/docs/architecture/auth.md @@ -0,0 +1,154 @@ +# Auth, access levels and environment + +## Purpose + +Who is calling, what they may do, and how the server reaches Onshape on their +behalf. Everyone signs in with their own Onshape account through OAuth; there +are no app accounts. Access beyond browsing comes from a library's Onshape admin +team, plus one owner set by configuration. + +## Code map + +| Path | Role | +| ----------------------------------------------------------- | ----------------------------------------------------------------------- | +| `src/backend/features/auth/onshape-oauth.ts` | The OAuth handshake: authorize url, code exchange, token shape | +| `src/backend/features/auth/login.ts` | A sign-in in flight, held in its own short cookie | +| `src/backend/features/auth/session.ts` | The session cookie and its KV record | +| `src/backend/features/auth/request-auth.ts` | `productionAuth`: resolves the caller, their Onshape client and level | +| `src/backend/features/auth/access-level.ts` | `AccessLevel` and its ordering; shared with the client | +| `src/backend/features/auth/guards.ts` | Route middleware: sign-in, editor, admin, owner | +| `src/backend/features/auth/background-sessions.ts` | Sessions a load with no requester can borrow | +| `src/backend/features/auth/company.ts`, `cookie-options.ts` | Enterprise company matching; cross-site cookie settings | +| `src/backend/features/auth/routes.ts` | `/auth/sign-in`, `/auth/callback`, `/auth/sign-out`, `/api/access-data` | +| `src/backend/features/admin-team/` | The owner sets a library's admin team; members are synced from Onshape | +| `src/backend/features/entry/routes.ts` | `/init`, where Onshape launches the app | +| `src/backend/lib/context.ts` | `AppBindings` (every binding and variable) and `bindAuth` | +| `src/frontend/features/auth/access-level.tsx` | `useAccessData`: the server's level and the level the app is viewed as | +| `wrangler.jsonc` | Per-environment vars and bindings | + +## Storage + +- **Cookies** (`SameSite=None; Secure`, since the app runs in Onshape's iframe): + `frc-design-app-session` holds an opaque session id; `frc-design-app-login` + holds a sign-in's OAuth state and return path for ten minutes and is read once. +- **KV** `session:` — the session's access and refresh tokens, expiry, and the + user id once resolved, for 30 days. +- **KV** `background-session:` — the latest session id of the owner and each + admin team member (admins and editors), by user id, plus one reserved id for + the dev override's user. + chose and its synced members (`isTeamAdmin` per member). +- **localStorage** (`ui-state`): `accessLevel`, the level the app is viewed as. + +## Flows + +### Launch and sign-in + +1. Onshape opens `/init?documentId=…&instanceType=…&elementId=…&sessionCompanyId=…`. + A version or microversion goes to `/version-error`. +2. With no session, or one for another company than the document's, `/init` + sends the caller through sign-in and back, marked so it never loops. +3. `/auth/sign-in` stores `{ state, redirectUrl }` in the login cookie and + redirects to Onshape's authorize page. The redirect url must be a local path. +4. The authorize request names `APP_URL/auth/callback` as its redirect uri, + which must be registered on the OAuth app. Onshape returns there; the callback checks + the state, exchanges the code, starts a session (a new id), and redirects back. + +### Sign-out + +Standalone only: inside Onshape the session is Onshape's to end. **Sign out** +sends the browser to `/auth/sign-out?redirectUrl=`, which deletes +the session's KV record and its cookie, then returns to the page, where access +is refetched signed out. The Onshape tokens themselves are not revoked; they +expire on their own. + +### A request + +`bindAuth` puts lazy answers on `c.var`, so a route that asks nothing never calls +Onshape: + +- `getOnshapeApi` — an Onshape client from the session's tokens, refreshing + when expired and on a 401; the refreshed tokens are saved before use. +- `getUserId` — resolved from Onshape once and kept on the session. +- `getAccessLevel(libraryId)` — see below. +- `isAuthenticated` — the session works **and** its company matches the + launching document's. + +### Access levels + +| Level | Who | May | +| ---------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------ | +| **Owner** | The user `OWNER_USER_ID` names | Everything, in every library; set admin teams; **Reload all** | +| **Admin** | A team admin of the library's team | Editor rights, plus **Reload**, version approval, refreshing the team | +| **Editor** | A member of the library's team | Groups (add, delete, order, sort), element settings and visibility, build status, **Reload thumbnail** | +| **User** | Anyone signed in | Browse, insert, favorites | + +`getAccessLevel` returns owner for `OWNER_USER_ID`, otherwise looks the caller +up in the library's stored `admin_team`. It is a database read, never an Onshape +call; the team is synced when the owner sets it or an admin presses **Refresh**, +because Onshape's team webhooks need a company, which a personal account lacks. + +Routes gate with `requireSignInMiddleware`, `requireEditorMiddleware`, +`requireAdminMiddleware` and `requireOwnerMiddleware`. A library-gated route +names its library in the path (`libraryRoute()`), and the middleware checks the +caller's level there; a route acting on a group or element also scopes its +query to that library, so naming your own library with another's element finds +nothing. The owner's level is the same everywhere, so the owner check always +asks about the default library. + +Whenever the owner's or an admin team member's level is checked, their session +id is saved under `background-session:` so background work can borrow it (see +[loading.md](./loading.md)). + +On the client, `useAccessData` combines the server's level with the level the +user chose to view the app as, clamped to what the server grants. + +## Environment variables + +| Name | Where it is set | Read by | Effect | +| ---------------------------------------- | ------------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET` | Secrets per environment; `.env` locally | `onshape-oauth.ts` | The Onshape OAuth app | +| `OWNER_USER_ID` | `wrangler.jsonc` vars | `request-auth.ts`, `background-sessions.ts` | The owner; unset grants nobody | +| `NODE_ENV` | `wrangler.jsonc` vars | `request-auth.ts` | Anything but `production` arms the two dev overrides below | +| `VITE_ACCESS_LEVEL_OVERRIDE` | `.env` | Server and client | Server grants it (dev only); client views the app at it by default | +| `FORCE_SIGNED_IN` | `.env` | `request-auth.ts` | Dev only: signed in as a fake user with no Onshape session | +| `APP_URL` | `wrangler.jsonc` vars; `.env` to override in dev | `onshape-oauth.ts`, webhooks, `vite.config.ts` | Where the app is served: the OAuth redirect uri, every webhook url, and the host the dev server accepts | + +`README.md` also lists `API_ACCESS_KEY` and `API_SECRET_KEY`, which no code +reads. + +Bindings (`DB`, `KV`, `BLOB`, `ASSETS`, the two workflows, `PUSH_HUB`) are +declared in `wrangler.jsonc` per environment and typed in `AppBindings`. Run +`npx wrangler types` after changing them. + +## Invariants + +- The cookie holds only an opaque id; tokens never leave KV. +- A session is only created by a completed sign-in, with a fresh id. +- Access is decided on the server per library; the client's viewed level only + hides UI and can never exceed the server's. +- Every elevated route checks the library the resource belongs to, not one the + request claims. +- The dev overrides do nothing when `NODE_ENV` is `production`, and an override + still requires a real session to pass a guard. +- The access level is a D1 read; no request calls Onshape to decide access. + +## Failure and recovery + +| Failure | Result | Recovery | +| ----------------------------------------- | ---------------------------------------- | ---------------------------------------- | +| Access token expired | Refreshed on the next call | Automatic | +| Refresh token revoked or expired | Treated as signed out | Sign in again | +| Enterprise session opening a personal doc | `/init` sends the caller through sign-in | Automatic, once | +| Team membership changed in Onshape | Stale access until synced | **Refresh** beside the admin team | +| No saved background session | Webhook loads fail | The owner or a team member opens the app | + +## Decisions + +- **Onshape teams as the permission source.** Library admins already manage the + documents in Onshape; the app mirrors that rather than keeping its own roles. +- **Synced, not live.** Asking Onshape on every request would spend the + allocation and add latency; membership rarely changes. +- **Login state in a cookie, not KV.** A caller who abandons sign-in keeps the + session they had, and nothing needs cleaning up. + +_Last reviewed: 2026-09-27_ diff --git a/docs/architecture/configurations.md b/docs/architecture/configurations.md new file mode 100644 index 000000000..aede6d7ab --- /dev/null +++ b/docs/architecture/configurations.md @@ -0,0 +1,166 @@ +# Configurations + +## Purpose + +Onshape parts are configurable: a part studio or assembly declares parameters +(lists, checkboxes, quantities, text) and each combination of values is a +different part. This area parses those parameters, represents what someone +picked, sends it back to Onshape on insert, and enumerates combinations so +search can find a part by any configuration's part number. + +It does not own rendering a configuration's picture ([thumbnails.md](./thumbnails.md)) +or the load that probes Onshape ([loading.md](./loading.md)); both consume it. + +## Code map + +| Path | Role | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `src/backend/features/configurations/contract.ts` | Parameter, `Selection`, `ConfigurationKey`, record and result types | +| `src/backend/features/configurations/selection.ts` | The only place a selection or key is built; see the invariants | +| `src/backend/features/configurations/utils.ts` | Visibility conditions, option resolution, configuration text encodings | +| `src/backend/features/configurations/input-parser.ts` | Parses and evaluates typed quantity expressions (`(2 + 3) in`) | +| `src/backend/features/configurations/combinations.ts` | Indexing limits and bands, enumeration and counting of combinations | +| `src/backend/features/configurations/roles.ts` | Recognizes parameter roles by name (derivation variable, color, ...) | +| `src/backend/features/configurations/instances.ts` | Splits a parameter by the choices that filter it, for reporting | +| `src/backend/features/configurations/units.ts` | A workspace's display units, cached in KV | +| `src/backend/features/configurations/part-number.ts` | Placeholder and meaningless part numbers | +| `src/backend/features/configurations/routes.ts` | `GET /api/configuration/...`, `GET /api/unit-info` | +| `src/backend/features/load/parse-configuration.ts` | Onshape's configuration response to our parameters, roles attached | +| `src/backend/features/load/parse-configuration-records.ts` | `decideIndexing`, probing each combination into a record | +| `src/backend/features/search/records.ts` | Records to search records, each with its thumbnail key | +| `src/frontend/features/insert/components/configurations.tsx` | The parameter panel in the insert menu | +| `src/frontend/features/build-status/components/indexing-section.tsx` | Admin indexing controls | + +## Storage + +**D1** + +- `configurations` — one row per insertable that has parameters: `parameters` + (the parsed declarations) and `records` (one per probed combination: its + values and the part number, name, material and vendor Onshape reported). Its + own table, since both columns are large. +- `insertables.part_metadata` — the default configuration's part data, also the + fallback record. +- `insertables.index_configurations` — an admin enabled indexing past the + automatic threshold. +- `insertables.excluded_parameter_ids` — parameters an admin left out of + indexing. + +**KV**: `unit-info:` — a workspace's display units, for a week; a transient +Onshape webhook drops the entry when the units change. + +**URL**: the `config` app parameter carries the selection being edited, so a +link reopens it. + +## The two forms + +`AGENTS.md` states the rule; this is the reasoning. + +- **Selection** (`Selection`): every declared parameter, each value **as + entered**. A quantity keeps the expression typed, `(2 + 3) in`, and a + quantity's default is spelled in its own unit, `1 in`. It is what is stored, + what the url carries, and what Onshape is sent, so a derived feature in + Onshape shows what the person typed. `toSelection` makes one from anything: a + search hit, a favorite, a request body, the url. +- **Configuration key** (`ConfigurationKey`): derived from a selection only to + name its thumbnail. It holds what the selection overrides, canonically spelled + (base units, hidden parameters and derivation variables left out), so + selections that render the same part share a render. + `DEFAULT_CONFIGURATION_KEY` is the empty string. + +Comparing or counting values (analytics, "is this the default") goes through +`canonicalValue` / `canonicalValues`. + +## Flows + +### Parsing, during a load + +1. The load fetches the element's configuration and `parse-configuration.ts` + turns it into `ConfigurationParameter`s, visibility conditions included. +2. `withRoles` marks parameters whose names say they are a derivation variable, + a color or its channels, or tessellation. A parameter with a role is never + indexed; no list or checkbox in the libraries has one yet, so today that + excludes nothing. +3. The parameters are stored on the `configurations` row. + +### Indexing + +1. `countConfigurations` enumerates the combinations of the **indexed** + parameters: enums and booleans without a role, minus + `excluded_parameter_ids`. Visibility is honored, so a hidden parameter keeps + its default. +2. The count sets the band: under `AUTO_INDEX_THRESHOLD` (128) indexes + automatically; at or above it indexes only with `index_configurations` + (`MANUAL_INDEXING_REQUIRED` otherwise); past + `MAX_PART_NUMBER_CONFIGURATIONS` (512) never (`CONFIGURATION_LIMIT_EXCEEDED`). +3. `decideIndexing` returns the combinations; `parseConfigurationRecords` probes + each in batches of 20, with only the values that differ from the defaults + sent, and stores a record per combination. +4. Changing the exclusions or the indexing switch re-runs this for one element + immediately (`POST /api/excluded-parameters/...`, + `POST /api/index-configurations/...`). + +The admin card counts with the same functions, so it cannot disagree with the +load about the band. + +### Configuring and inserting + +1. The insert menu fetches `GET /api/configuration/insertable/:id?v={microversion}`, + pinned to the microversion so it never refetches mid-edit. +2. The panel keeps a selection. `normalizeSelection` settles it after each edit: + hidden parameters take their default and an enum lands on a visible option, + repeated because settling one parameter can change another's options. +3. The panel gives each derivation variable still at its default a unique + value (`fillDerivationValues`), so each derive is its own configuration. + An insert that skips the menu leaves it at its default. +4. The panel reports the selection and its key; the key drives the preview + thumbnail. +5. On insert, the server makes the selection whole (`toSelection`) and sends + only the overrides (`onshapeOverrides`). Onshape refuses a part studio's + empty configuration (an assembly's is fine), so a part studio left on its + defaults is sent its first parameter at its default. + +### Search + +Each record becomes a search record carrying its values and key +(`search/records.ts`), so a hit on a configuration's part number opens that +configuration and shows its thumbnail. `findRecord` picks the most specific +record for a selection, since records name only what enumeration varied. + +## Invariants + +- `selection.ts` is the only place a `Selection` or `ConfigurationKey` is built, + and there are only these two forms. +- A selection is never replaced by its key in storage, and a key is never sent + to Onshape as a configuration outside thumbnails. +- Every boundary that receives a configuration (request body, url, stored + favorite, search hit) passes it through `toSelection`. +- Stored and shared selections omit derivation variables (`toStoredSelection`). +- Parameters with a role are never indexed. +- The load and the admin UI decide the indexing band with the same + `combinations.ts` functions. + +## Failure and recovery + +| Failure | Result | Recovery | +| ----------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------- | +| A combination doesn't regenerate in Onshape | That probe fails; the batch retries, then the load flags the element | Fix the part in Onshape; the next version reloads it | +| Too many combinations | Not indexed; build issue | Exclude parameters in the Indexing section | +| Units change in a workspace | Cached units drop via webhook, or expire in a week | Automatic | +| A parameter is added after a favorite was saved | Favorite's selection is short one parameter | `toSelection` defaults it on read | + +## Decisions + +- **Selections keep what was typed.** Sending Onshape the canonical value would + turn a derived feature's `(2 + 3) in` into `0.127 m`. +- **Keys exist only for thumbnails.** Anything else built on a key would inherit + its lossy canonicalization; comparisons use `canonicalValues`. +- **Roles by name.** Onshape doesn't mark a parameter's purpose, so recognized + names are stored as `role` at load. +- **Derivation variables are filled in the menu.** Onshape takes a repeated + derive of the same configuration; the unique value only keeps each derive + distinct, which is the menu's concern, not the server's. +- **Assemblies index like part studios.** Their probes send overrides the same + way, so exclusions apply to both. + +_Last reviewed: 2026-09-27_ diff --git a/docs/architecture/favorites.md b/docs/architecture/favorites.md new file mode 100644 index 000000000..9fb2bb54a --- /dev/null +++ b/docs/architecture/favorites.md @@ -0,0 +1,81 @@ +# Favorites + +## Purpose + +A signed-in user can favorite insertables in a library, order them, and save the +configuration each one opens with. Favorites are per user and per library. + +## Code map + +| Path | Role | +| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `src/backend/features/favorites/contract.ts` | `Favorite`, `FavoritesData`, `MAX_FAVORITES` | +| `src/backend/features/favorites/routes.ts` | List, add, delete, reorder, and set the saved configuration | +| `src/frontend/features/favorites/queries.ts` | Query and optimistic mutations | +| `src/frontend/features/favorites/components/favorites-list.tsx` | The Favorites tab, searchable through the library index | +| `src/frontend/features/favorites/components/favorite-button.tsx` | Toggling a favorite | +| `src/frontend/features/favorites/components/save-favorite-configuration-button.tsx` | Saving the configuration on screen | +| `src/frontend/features/insert/components/reset-configuration-items.tsx` | Reset to default / to the favorite's configuration | + +## Storage + +**D1** `favorites`: `id`, `user_id`, `library_id`, `insertable_id` (cascade on +delete), `default_selection` (a stored selection, or null for the defaults), +`sort_order`, `created_at`. Unique on user, library and insertable. + +`users` holds one row per user who has favorited anything; a favorite references +it, so adding one inserts the user first. + +## Flows + +### Reading + +`GET /api/favorites/library/:libraryId` returns the caller's favorites in order. +For each it makes the stored selection whole against the insertable's current +parameters (`toSelection`), and **derives** its configuration key and matching +record, rather than storing them: a reload can move the defaults a key is +measured against, and the record must match the saved configuration's part +number. + +### Writing + +- **Add**: the client picks the favorite's id, so the optimistic entry and the + server row agree. The selection, if any, is stored with `toStoredSelection` + (whole, derivation variables removed). Past `MAX_FAVORITES` (250) answers 409. +- **Delete**, **reorder** and **set configuration**: every write is scoped to the + caller's user id, so another user's favorite id matches nothing. A reorder is + one D1 batch. +- The frontend applies each change optimistically and refetches on settle. + +### Opening a favorite + +The insert menu opens on the favorite's saved selection, and its thumbnail row +asks for that configuration's render (see [thumbnails.md](./thumbnails.md)). +**Reset to favorite default** returns to it; **Reset to default** to the +element's own defaults. + +## Invariants + +- A favorite is only ever read or written by its owner: every query filters by + the caller's user id. +- What is stored is a selection, never a key; the key and record are derived per + response. +- A stored selection never holds a derivation variable. +- Deleting an insertable deletes its favorites. + +## Failure and recovery + +| Failure | Result | Recovery | +| ------------------------------------- | --------------------------------------- | ---------------------------- | +| The insertable gains a parameter | Stored selection lacks it | Defaulted on read | +| The insertable loses the saved option | `toSelection` falls back to the default | Save the configuration again | +| Signed out | No favorites shown; prompt to sign in | Sign in | + +## Decisions + +- **Client-chosen ids.** The optimistic row and the stored one share an id, so + a follow-up edit before the refetch hits the right row. +- **Derived keys.** Storing a key would go stale the moment a reload changed the + element's defaults. + +_Last reviewed: 2026-09-27_ diff --git a/docs/architecture/loading.md b/docs/architecture/loading.md new file mode 100644 index 000000000..a9c13bafa --- /dev/null +++ b/docs/architecture/loading.md @@ -0,0 +1,223 @@ +# Loading + +## Purpose + +A library is a set of groups, each pinned to a version of one Onshape document, +whose tabs are its insertables. Loading reads a document's latest version from +Onshape and writes everything the app shows about it: the group, each +insertable's metadata, configurations and records, thumbnails, build issues, and +the library's search index. + +Every load is one group's document, run as a Cloudflare Workflow so it can take +minutes, survive retries, and resume where it stopped. + +## Code map + +| Path | Role | +| --------------------------------------------------------------- | -------------------------------------------------------------------- | +| `src/backend/features/load/jobs.ts` | `requestLoads`, `finishLoad`, approval; one load per group at a time | +| `src/backend/features/load/workflows.ts` | `LoadDocumentWorkflow`: resolve the version, skip or load, webhook | +| `src/backend/features/load/load-group.ts` | `loadGroup`: contents, which tabs to load, save, cleanup | +| `src/backend/features/load/load-insertable.ts` | `loadInsertable`: probe one tab, its thumbnail, save its row | +| `src/backend/features/load/context.ts` | `LoadContext`, limiters, `getOnshapeApiFromContext` | +| `src/backend/features/load/steps.ts` | Retry policies for Onshape steps and thumbnail steps | +| `src/backend/features/load/flag.ts` | `flagFailedLoads`, for loads that died without saying so | +| `src/backend/features/load/routes.ts` | Reload and version-approval endpoints | +| `src/backend/features/load/parse-*.ts` | Onshape responses to what we store | +| `src/backend/features/library/groups/routes.ts` | Adding a document (shell group + load) and deleting a group | +| `src/backend/features/webhooks/` | The per-document webhook that loads new versions | +| `src/backend/features/push/` | Pushes job status and library changes to open clients | +| `src/frontend/features/library/components/reload-button.tsx` | **Reload** (outdated) and the owner's **Reload all** | +| `src/frontend/features/library/components/version-approval.tsx` | **Approve new versions** and held versions | + +## Storage + +**D1** + +- `load_jobs` — one row per group with a load in flight: its workflow + `instance_id`, `started_at`, `force_reload`, `awaiting_approval`. +- `groups`, `insertables`, `configurations` — what a load writes. + `groups.version_id` is the version loaded; a shell group holds + `PLACEHOLDER_VERSION_ID` until its first load. +- `libraries.cache_version` — bumped by every load that wrote; every library + response is cached under it. +- `libraries.approve_versions` — hold webhook loads for an admin. +- `onshape_webhooks` — one row per document: its webhook id and delivery token. + +**R2**: `search-index/` — each library's serialized search index, rebuilt by +every load that wrote. Thumbnails are in [thumbnails.md](./thumbnails.md). + +**KV**: `background-session:` — sessions a load with no requester can borrow; see +[auth.md](./auth.md). + +## Flows + +### Starting loads + +Three things call `requestLoads`, each with one request per group: + +- **Adding a document** writes a shell group first, so a failed first load still + leaves something to retry or delete, then loads it. +- **Reload** (admin): every group of the library; each load skips itself when + its version is unchanged and it has no failure. **Reload all** (owner) forces + every group, which spends a lot of the Onshape allocation. +- **A webhook** for a new version loads every group of that document, holding + for approval when the library asks for it. + +`requestLoads`: + +1. Reads the groups' `load_jobs` rows and clears dead ones (an instance that + finished or never started within a minute), flagging those groups + `LOAD_FAILED`. +2. Terminates any load still running for a group: the new load reads the latest + version itself. A forced load replaced by a plain one stays forced. +3. Writes the rows and creates every instance at once, in batches of 100. Loads + are independent, so they run concurrently. +4. Pushes the library's job status. + +### One load + +```mermaid +flowchart TD + A[read group row] --> B[resolve document + latest version] + B --> C{new version, forced,
or failed last time?} + C -- no --> W + C -- yes --> D{held for approval?} + D -- yes --> E[wait for approve-version
up to 2 days] + E --> F + D -- no --> F[loadGroup] + F --> W[ensure document webhook] + W --> Z[finishLoad] +``` + +`loadGroup`: + +1. Syncs the version's thumbnail workspace. +2. Reads the document's contents and the group's stored insertables. +3. Selects tabs to load: new ones, changed ones (microversion differs), ones + whose last load failed, or all when forced. Removed and reordered tabs are + computed from the same lists. +4. Loads each selected tab (`loadInsertable`) in parallel under the load limiter + (`LOAD_CONCURRENCY`, 15). A tab that fails is recorded and the rest carry on. +5. Stores the group's thumbnail, then saves the group in one step: its fields, + removals, new order, and `INSERTABLES_FAILED` if any tab failed. +6. Deletes stale thumbnails, and stale thumbnail workspaces when nothing + failed. Neither is fatal. + +`loadInsertable`: probes the tab under the limiter (configuration, parts, fasten +info, vendors, and a record per indexed combination), fetches its thumbnail +under the thumbnail limiter, runs the insertable checks, and saves the row. + +`finishLoad` always runs, success or not: when anything was written it rebuilds +the search index, then bumps the library version and pushes; then it deletes the +`load_jobs` row **only if it still holds this instance**, since a replacement +owns it otherwise. + +### Steps, Onshape calls and retries + +Each row is one durable workflow step, named as in the dashboard; a step's +result is stored, so a retried or resumed load skips steps that finished. +`ONSHAPE` is `ONSHAPE_STEP_RETRIES`, `THUMBNAIL` is `THUMBNAIL_RETRIES` (see +[platform.md](./platform.md#retries-and-timeouts)). + +| Step | Onshape call | Retries | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------- | +| `read-group` | — | default | +| `document` | `GET /documents/{did}` | `ONSHAPE` | +| `version` | `GET /documents/d/{did}/versions` (the latest) | `ONSHAPE` | +| `hold-for-approval`, then a wait for `approve-version` | — | default | +| `thumbnail-workspace-{group}` | `GET /documents/d/{did}/workspaces`, `POST` one if missing | `ONSHAPE` | +| `document-contents-{group}` | `GET /documents/d/{did}/v/{vid}/contents` | `ONSHAPE` | +| `stored-insertables-{group}`, `select-insertables-{group}` | — | default | +| `flags-{insertable}` | — | default | +| `config-{insertable}` | `GET /elements/d/{did}/v/{vid}/e/{eid}/configuration` | `ONSHAPE` | +| `parts-{insertable}` (part studios) | `GET /parts/d/{did}/v/{vid}/e/{eid}` | `ONSHAPE` | +| `fasten-{insertable}` (if enabled) | Assembly definition, or part studio features | `ONSHAPE` | +| `records-{insertable}-{batch}` | Per configuration: `GET /parts/...` (part studio) or `GET /metadata/...` (assembly), with the overrides | `ONSHAPE` | +| `thumbnail-{insertable}` | `GET /thumbnails/d/{did}/w/{thumbnail wid}/e/{eid}/s/{size}`, both sizes | `THUMBNAIL` | +| `save-{insertable}` | — | default | +| `document-thumbnail-{group}` | As `thumbnail-`, for the thumbnail tab | `THUMBNAIL` | +| `save-group-{group}` | — | default | +| `delete-stale-thumbnails-{group}` | — (R2 only) | default | +| `delete-stale-workspaces-{group}` | `DELETE /documents/d/{did}/workspaces/{wid}` per stale one | default | +| `register-webhook` | `GET /webhooks/{id}`, `POST /webhooks` if gone | `ONSHAPE` | +| `flag-failed` (on a failure) | — | default | +| `finish` | — | default | + +"default" is Workflows' own policy for a step given none. A step that exhausts +its retries throws; a failed insertable step flags that insertable and the rest +of the group carries on, and a failed group-level step flags the group +`LOAD_FAILED`. + +### Whose session a load uses + +Every Onshape call in a load goes through `getOnshapeApiFromContext`, which +picks the first of these that works, per call: + +| Load started by | 1st: the requester's session | Then, borrowed (`getBackgroundOnshapeApi`) | +| --------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Adding a document, **Reload**, **Reload all** | Yes, while it works | The owner's, then the library's team admins', then its editors', then (dev only) the access-level override's | +| A webhook for a new version | None: nobody asked | Same order | + +A borrowed session is one saved under `background-session:` in KV, which +happens whenever the owner's or an admin team member's access is checked (they +open the app). +"Works" means its tokens refresh and Onshape accepts one call; a failing one is +skipped for the next. The requester's session is tried again on every call, so +one that expires mid-load falls through to a borrowed one and the load carries +on. No working session at all fails the step, which retries, then fails the +load (`LOAD_FAILED`). + +### Webhooks + +Each load ends by making sure Onshape has the document's webhook for +`onshape.model.lifecycle.createversion` (`ensureWebhook`). It checks the stored +webhook still exists and registers a new one if not: Onshape cancels a webhook +whose registration check fails, and deactivates one whose deliveries error, +without telling us. The delivery url is on `APP_URL` and carries a random token that identifies the +row; the route answers 200 at once and does the work after, so a slow load never +costs the webhook. Deleting the last group of a document removes its webhook. + +### Version approval + +With **Approve new versions** on, a webhook's load of a new version marks its +row `awaiting_approval` and waits on the workflow event `approve-version` for up +to two days, then loads anyway. **Approve** sends the event to every held load; +turning approval off does too. An admin's reload replaces a held load with one +that doesn't wait. + +## Invariants + +- At most one load per group runs; a newer request replaces the running one. +- A load that wrote anything rebuilds the search index **before** bumping the + library version, since the bump makes the old index url immutable. +- Only the load holding a group's `load_jobs` row releases it. +- A failure writes no microversion, so a failed tab or group is always retried + by the next plain reload (`LOAD_FAILED`, `INSERTABLES_FAILED`). +- Steps are deterministic on retry: anything random (new insertable ids) is + generated inside a step so the persisted result is reused. +- A load reads Onshape as its requester first, and borrows an admin's session + only when there is none. + +## Failure and recovery + +| Failure | Result | Recovery | +| ------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------- | +| An Onshape step fails | Retried with backoff, honoring `Retry-After` | Automatic; exhausted, the group or tab is flagged | +| A tab fails | `LOAD_FAILED` on it, `INSERTABLES_FAILED` on the group | **Reload** | +| The workflow crashes | Row left behind | Next job-status read or request clears it, flags `LOAD_FAILED` | +| Webhook cancelled or deactivated by Onshape | No automatic loads | Next load of the document registers it again | +| No session to borrow | Webhook loads fail | The owner or a team member opens the app | + +## Decisions + +- **One workflow per group, not per library.** Groups are independent, so a + failure or a slow document holds up nothing else, and a reload is just many + loads started at once. +- **Replace rather than queue.** A newer request always wants the latest + version, which a fresh load reads anyway. +- **Job rows in D1, not KV.** Concurrent KV writes lose updates. +- **Loads skip unchanged versions.** Reloading spends the Onshape allocation; + only the owner can force it. + +_Last reviewed: 2026-09-27_ diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md new file mode 100644 index 000000000..512f7ecf0 --- /dev/null +++ b/docs/architecture/platform.md @@ -0,0 +1,185 @@ +# Platform + +## Purpose + +The plumbing every feature shares: how the server calls Onshape, how failures +travel from Onshape to the user, every retry and timeout in one place, the +concurrency limits, toasts, pushes, and HTTP caching. Feature documents name +their Onshape calls and link here for how those calls behave. + +## Code map + +| Path | Role | +| ------------------------------------------------- | --------------------------------------------------------------------------------- | +| `src/backend/lib/onshape/client.ts` | `OnshapeApi`, `OAuthApi`, `ApiKeyApi`; `OnshapeApiError`, `OnshapeRateLimitError` | +| `src/backend/lib/onshape/endpoints/` | One wrapper per Onshape endpoint, grouped by Onshape's API category | +| `src/backend/lib/api-error.ts` | `ApiError` and its four kinds; the body every failed `/api` response carries | +| `src/backend/lib/errors.ts` | `errorHandler`: turns any thrown error into that body and a status | +| `src/backend/features/load/steps.ts` | Workflow retry policies for Onshape and thumbnail steps | +| `src/backend/features/load/context.ts` | The load's limiters | +| `src/backend/lib/cache.ts` | `CachePolicy`, `cacheMiddleware`, `setCache` | +| `src/backend/features/push/` | `PushHub` Durable Object, message contract, `notify.ts` senders | +| `src/frontend/lib/api-client.ts` | `apiGet`, `apiPost`, `apiDelete`, `loadImage`; appends `?v=` for cached reads | +| `src/frontend/lib/errors.ts` | `AppError`, `handleAppError`, `getAppErrorHandler` | +| `src/frontend/lib/notifications.tsx` | `showLoadingToast`, `showSuccessToast`, `showInfoToast`, `showErrorToast` | +| `src/frontend/lib/query-client.ts` | TanStack Query defaults, including retry | +| `src/frontend/lib/push-socket.ts`, `push-sync.ts` | The client's WebSocket, and applying pushes to the cache | + +## Calling Onshape + +Every call goes through `OnshapeApi._call` to +`https://cad.onshape.com/api/v16{path}`: + +- **Auth.** `OAuthApi` sends `Authorization: Bearer `. On a 401 it + calls its refresh callback once, which exchanges the refresh token, saves the + new tokens to the session before returning, and repeats the request once. A + second 401 is thrown. `ApiKeyApi` signs with an HMAC for scripts. +- **Timeout.** Each request aborts after 60 s (`REQUEST_TIMEOUT_MS`), well under + a workflow step's 10-minute limit, so a hung call fails the step and it + retries. +- **Headers.** JSON in and out; images are fetched with `Accept: */*`, since + under `image/*` a thumbnail still rendering answers 406 rather than 404. +- **Errors.** Any non-2xx throws `OnshapeApiError` with the status and the + response text. A 429 throws `OnshapeRateLimitError` carrying `Retry-After` + (60 s when absent), and spells it into the message, since a Workflow rebuilds + errors and only the message survives; `readRetryAfterSeconds` reads it back. +- **No retry in the client.** A request is tried once (plus the 401 refresh). Retrying + belongs to the caller: a workflow step's policy, or the user. + +Which calls each flow makes is listed in that flow's document. + +## Errors, end to end + +A route throws; `errorHandler` answers with `{ kind, message }` and a status: + +| Thrown | Kind | Status | Message the user sees | +| -------------------------------- | ------------------ | -------- | ----------------------------------------------------------- | +| `handledError(message, status)` | `handled` | as given | `message` | +| `signInRequiredError(message)` | `sign-in-required` | 401 | `message`, with a **Sign in** button | +| `forbiddenError(message)` | `forbidden` | 403 | `message` | +| `internalError(message, status)` | `internal` | as given | The caller's default wording; `message` is logged | +| `OnshapeRateLimitError` | `handled` | 429 | "Onshape rate limit reached. Please try again later." | +| `OnshapeApiError` 401 | `sign-in-required` | 401 | "Onshape did not accept the session. Try signing in again." | +| `OnshapeApiError` 403 | `forbidden` | 403 | "Onshape rejected the operation. …" | +| Any other `OnshapeApiError` | `internal` | 502 | Default wording; logged | +| A validator's `HTTPException` | `internal` | its own | Default wording; logged | +| Anything else | `internal` | 500 | Default wording; logged | + +On the client, `apiGet`/`apiPost` throw an `AppError` holding that body. A +mutation's `onError` is `getAppErrorHandler(defaultMessage, toastId)`, which +shows the body's message for `handled` and `forbidden`, adds a **Sign in** +action for `sign-in-required`, and shows `defaultMessage` for `internal` or any +non-`AppError`. Images use `loadImage`, which throws `ImageLoadError` with only +the status. + +## Retries and timeouts + +| Where | Setting | Policy | +| ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------ | +| Any Onshape request | `REQUEST_TIMEOUT_MS` | Aborts after 60 s | +| Load steps calling Onshape | `ONSHAPE_STEP_RETRIES` | 5 retries after 10, 20, 40, 80, 160 s; a 429 waits its `Retry-After` plus 0–20 s of jitter | +| Load thumbnail steps | `THUMBNAIL_RETRIES` | 6 retries after 30 s, 1, 2, 4, 4, 4 min; a 429 as above | +| Configuration renders | `RENDER_RETRIES` | 12 retries every 5 s; a 429 waits its `Retry-After` plus jitter | +| A held load | `APPROVAL_TIMEOUT` | Waits 2 days for approval, then loads | +| A claimed job with no instance | `CLAIM_GRACE_MS` | Treated as failed after 60 s | +| Client queries | `queryClient` default | 2 retries, none for an `AppError` that isn't `internal` | +| Stored thumbnails on the client | `STORED_RETRIES` | 1 retry | +| Waiting on a render | `RENDER_TIMEOUT_MS` | Gives up after 60 s without the image | +| Push socket | `FIRST_RETRY_MS` → `LAST_RETRY_MS` | Reconnects after 1 s, doubling to 30 s | + +Workflows multiply a retry's delay by its backoff curve, so every policy returns +its own delay from a function and sets `backoff: "constant"`. + +## Concurrency + +| Limit | Value | Bounds | +| ----------------------- | ------------- | ---------------------------------------------------------------- | +| `LOAD_CONCURRENCY` | 15 | A load's insertables probed at once | +| `THUMBNAIL_CONCURRENCY` | 10 | A load's thumbnail steps at once; a slot is held through retries | +| `BATCH_SIZE` (records) | 20 | Configurations probed per workflow step | +| Loads | one per group | `load_jobs`; a new request replaces the running one | +| Renders | one per key | The instance id is derived from the render's key | + +## Toasts + +Mantine notifications, bottom-centre, at most 3 on screen, closing after 4 s +unless told otherwise (`__root.tsx`). `showToast` keeps the ids of the toasts +it has open, and a second call with a live id **updates** that toast in place, +which is how a loading toast turns into its result. + +| Kind | Colour | Closes | Used for | +| ------- | ------------- | ---------------------- | --------------------------------------------------- | +| Loading | blue, spinner | Never, no close button | Work the user is waiting on; always given an id | +| Success | green | 4 s | The same id, when the work lands | +| Info | blue | 4 s, or as given | Background work started; tips | +| Error | red | 4 s | Through `handleAppError`, on the loading toast's id | + +The flows that use them: + +| Action | Toast id | While | Then | +| ----------------------------- | --------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------- | +| Insert | per insertable | "Inserting {name}..." | "Successfully inserted {name}." (and "…created a Fasten mate.") | +| Add document | `add-group` | "Adding document..." | "Added {name}." | +| Reload thumbnail | `reload-thumbnail` | "Reloading thumbnail..." | "Thumbnail reloaded." | +| Show or hide elements | `set-visibility` | "Showing/Hiding insertables..." | "Insertables shown/hidden." | +| Toggle insert and fasten | per insertable | loading | success | +| Enable or disable indexing | per insertable | info: "Enabling/Disabling indexing" | "Indexing enabled/disabled." | +| Exclude parameters | per insertable | info: "Reindexing part" | "Part reindexed." | +| Set or refresh the admin team | none | — | "Admin team set/refreshed: {n} members." | +| Reload documents / approve | none | — | info: "Reloading {n} documents..." | +| Save favorite configuration | none | — | "Favorite default configuration set." | +| Tips | `quick-insert-tip`, `thumbnail-wait-tip`, sign-in preview | — | info, 8 s | + +Loading toasts are for work the user watches; background work gets an info +toast and then shows its progress where it happens (a group's spinner, a job +status). A mutation that fails shows its error on the same id, replacing the +loading toast. + +## Pushes + +One `PushHub` Durable Object holds every client's WebSocket +(`GET /api/push?library=`), hibernating so idle sockets cost nothing, and +tags each with the library it shows. + +| Message | Sent by | To | The client | +| ----------- | ---------------------------------------------------- | ---------------------- | -------------------------------------------------------------- | +| `jobs` | `requestLoads`, `finishLoad`, approval changes | That library's sockets | Sets the job status query (editors only) | +| `library` | A load that wrote, a shell group, an admin team sync | That library's sockets | `refreshLibrary()`, which moves to the new cache version | +| `thumbnail` | Each stored render size | Every socket | Refetches rows that missed that render; wakes a waiting render | + +A push says only what changed; the client refetches under its own access, so +nothing private travels over it. A failed send is logged, not thrown. A client +that reconnects refreshes the library, since pushes during the outage are lost. + +## Caching + +- **Versioned reads** (`library-data`, `search-db`, `build-status`, + `configuration`, health) carry `?v=`, and `cacheMiddleware(PUBLIC_CACHE)` + answers `public, max-age=31536000, immutable`, refusing a request without `v`. + Only a success is cached; an error answers `private, no-store`. +- **Stored thumbnails** are immutable by their url (`v` is the microversion); a + miss is `no-store`. +- **Everything else** is `private, no-store`. +- On the client, `useRefreshLibrary` invalidates the library's unversioned + queries and the version itself; a versioned query's old url is left alone, + since refetching it would restore the old state. + +## Invariants + +- The Onshape client never retries on its own; the caller's policy decides. +- Every retry policy honours a 429's `Retry-After`. +- A failed `/api` response always carries `{ kind, message }`, and only + `handled`, `forbidden` and `sign-in-required` messages reach the user verbatim. +- A cached `/api` url always includes its version. +- A push carries no data a viewer isn't already allowed to fetch. + +## Decisions + +- **Retries at the step, not the request.** A workflow step's retry is durable + and visible; a retry inside `fetch` would hide attempts inside one step's + timeout. +- **One push hub.** Pushes are few and small; one instance keeps fan-out simple. +- **Errors by kind, not status.** The client decides wording from `kind`, so a + status can change without changing what the user reads. + +_Last reviewed: 2026-09-28_ diff --git a/docs/architecture/search.md b/docs/architecture/search.md new file mode 100644 index 000000000..da6f604b6 --- /dev/null +++ b/docs/architecture/search.md @@ -0,0 +1,138 @@ +# Search + +## Purpose + +Finds parts in a library by name, group, part number or part name, including the +part number of any indexed configuration, and opens a hit on the configuration +it matched. The index is built on the server and searched entirely in the +browser, so typing never waits on the network. + +## Code map + +| Path | Role | +| ----------------------------------------- | ------------------------------------------------------------------ | +| `src/backend/features/search/contract.ts` | `SearchDocument` and `SEARCH_OPTIONS`, shared by both sides | +| `src/backend/features/search/fields.ts` | The indexed field names | +| `src/backend/features/search/tokenize.ts` | How names and part numbers become terms, with their spans | +| `src/backend/features/search/records.ts` | Configuration records to search records, and matching a hit to one | +| `src/backend/features/search/build.ts` | `buildSearchDb`: one document per insertable | +| `src/backend/features/library/db.ts` | `rebuildSearchDb`, `searchIndexKey` | +| `src/backend/features/library/routes.ts` | `GET /api/search-db/...` | +| `src/frontend/features/search/queries.ts` | Loads the index, keyed by the library's cache version | +| `src/frontend/features/search/search.ts` | `doSearch`: the query, filters, ranking and highlighting | +| `src/frontend/features/search/filter.ts` | Browsing and searching produce the same list shape | + +## Storage + +**R2** `search-index/v2/{libraryId}.json` — the serialized MiniSearch index. The +`v2` names the index's shape: a deploy that changes it bumps the version, and the +route builds the new shape on its first miss. + +**D1** `libraries.cache_version` — the `?v=` of every request for the index; see +[loading.md](./loading.md). + +## Flows + +### Building + +1. Every load that wrote, and every reindex of one element, calls + `rebuildSearchDb` **before** bumping the library version. +2. It reads the library and each insertable's search records, and builds one + document per insertable: its name, group name, vendors, visibility, and the + space-joined part numbers and part names of its records. The records + themselves are stored, not indexed, so a hit can say which configuration it + matched. +3. The index is written to R2 uncompressed; the runtime compresses responses. + +### Serving + +`GET /api/search-db/library/:libraryId?v={cacheVersion}` streams the stored index, +cacheable for a year, since the version in the url changes whenever the index +does. A missing index (a new shape after a deploy) is built on the spot. + +### How text becomes terms + +`tokenize` reads each field into lowercase terms, keeping where each came from +so what matched is what gets underlined. Names and part numbers are read +differently: a name describes the part, a part number identifies it. + +| Field | Text | Terms | +| ---------------------------- | ---------------------------- | --------------------------------------------------- | +| name, group name, part names | `1/2" Hex Shaft (MAXSpline)` | `0.5`, `hex`, `shaft`, `maxspline`, `max`, `spline` | +| part numbers | `WCP-0016 am-3749` | `wcp-0016`, `wcp`, `0016`, `am-3749`, `am`, `3749` | + +In a name, sizes become their decimal value (`1/2` and `.5` both read `0.5`; a +mixed number like `1-1/2` reads `1.5`), leading zeros drop, and camelCase splits. +A part number is kept whole and also split at `-` and `/`, spelled as written. + +### How a query is read + +The query is split at spaces into **words**, and each word into its +**readings**: how a name would read it, plus, for a word with a letter in it, +how a part number would. From `queryWords`: + +| Query | Words and their readings | +| ------------------ | --------------------------------------------------- | +| `1/2 hex shaft` | [`0.5`, `1/2`] · [`hex`] · [`shaft`] | +| `WCP-0016` | [`wcp`, `16`, `wcp-0016`, `0016`] | +| `am-3749 .196` | [`am`, `3749`, `am-3749`] · [`0.2`, `0.19`, `.196`] | +| `rev spacer 1-1/2` | [`rev`] · [`spacer`] · [`1.5`, `1-1/2`] | + +A result must match **every word** (AND), and a word matches if **any** of its +readings does (OR), in any field. So `1/2 hex shaft` finds a part only if some +field has `0.5` or `1/2`, some field has `hex`, and some field has `shaft`; +`1/2 hex` alone finds more parts than `1/2 hex shaft`, never fewer. Each reading +matches by prefix, so `spa` finds `spacer` and `374` finds `3749`. + +### How results are scored + +MiniSearch scores each matching term with BM25 (a term rare in the library and +frequent in the field scores higher, and short fields beat long ones), then: + +- **Field weight**: the title and part numbers count fully, part names at 0.7, + the group name at 0.5. `hex` in a part's own name outranks `hex` in the name + of the group it sits in. +- **Prefix matches** count for less than whole ones, so a query `16` ranks a + part numbered `am-16` above one numbered `am-160`. +- A result's score is the sum over the words it matched, so a part matching a + word in two fields ranks above one matching it in one. + +Results come back highest score first, capped at a list's worth. + +### Filters and the matched configuration + +1. Filters apply during the search: hidden parts (unless showing them), the + favorites tab, the group being browsed, and vendors. Hits a group or vendor + filter removed are counted, so the UI can say how many it hid. +2. For each hit, `matchedRecord` picks the configuration record whose part + number or name the query matched, preferring a whole term to a prefix: `1` + names a `1"` shaft but only starts `16`. The row opens on that configuration + and shows its thumbnail. + +## Invariants + +- The backend and frontend use the same `SEARCH_OPTIONS` and `tokenize`, so a + term is spelled the same when indexed and when searched. +- The index is rebuilt before the version bump that publishes it. +- The index's url includes the library version, so no cache ever serves an + index older than the library it describes; nothing invalidates it otherwise. +- Hidden elements are in the index and filtered at search time, so admins can + search them. + +## Failure and recovery + +| Failure | Result | Recovery | +| ----------------------------- | ----------------------------- | ------------------------- | +| The index object is missing | Built on the next request | Automatic | +| A rebuild fails during a load | The load fails before bumping | The next load rebuilds it | + +## Decisions + +- **Client-side search.** A library's index is small enough to download once per + version, and searching locally makes every keystroke instant. +- **Words ANDed, readings ORed.** A word like a size has several readings; any + will do, but a query of several words should narrow, not widen. +- **Records stored with the index.** A part-number hit opens the configuration + it names without another request. + +_Last reviewed: 2026-09-27_ diff --git a/docs/architecture/thumbnails.md b/docs/architecture/thumbnails.md new file mode 100644 index 000000000..a5f51732b --- /dev/null +++ b/docs/architecture/thumbnails.md @@ -0,0 +1,198 @@ +# Thumbnails + +## Purpose + +Every picture of a part the app shows is served from our own R2 bucket, never +from Onshape per page view: Onshape renders slowly, and each fetch spends the +account's API allocation. There are three kinds: + +- **Insertable thumbnails** — an insertable in its default configuration, + stored by the load. +- **Group thumbnails** — the document's thumbnail tab, stored by the load. +- **Configuration thumbnails** — one configuration of an insertable, rendered on + demand the first time someone picks it. + +## Code map + +| Path | Role | +| ----------------------------------------------------------- | --------------------------------------------------------------------------- | +| `src/backend/features/thumbnails/keys.ts` | R2 keys and app urls; shared with the client so both build the same url | +| `src/backend/features/thumbnails/store.ts` | `putThumbnail`, `uploadThumbnails` (both sizes of an insertable's default) | +| `src/backend/features/thumbnails/workspace.ts` | The per-version thumbnail workspace: `syncThumbnailWorkspace`, cleanup | +| `src/backend/features/thumbnails/routes.ts` | Serving stored thumbnails, starting a render, **Reload thumbnail** | +| `src/backend/features/thumbnails/render.ts` | `requestRender`: starts one configuration's render, at most once | +| `src/backend/features/thumbnails/render-workflow.ts` | `RenderThumbnailWorkflow`: waits out Onshape's render and stores both sizes | +| `src/backend/features/thumbnails/reload.ts` | The **Reload thumbnail** action for one insertable or group | +| `src/backend/features/thumbnails/reconcile.ts` | `deleteStaleThumbnails`: removes objects nothing points at | +| `src/backend/lib/onshape/endpoints/thumbnails.ts` | Onshape calls: element thumbnail, thumbnail id, thumbnail by id | +| `src/frontend/features/thumbnails/components/thumbnail.tsx` | `CardThumbnail` (rows) and `PreviewImageCard` (insert menu) | +| `src/frontend/features/thumbnails/render-wait.ts` | `loadRenderedImage`: starts a render on a miss, waits for its push | + +## Storage + +**R2 (`BLOB`)**, under `thumbnails/`: + +``` +thumbnails/default/{elementId}/{microversionId}/{size} +thumbnails/config/{elementId}/{microversionId}/{encodeURIComponent(configurationKey)}/{size} +``` + +Each object carries `microversionId` and `configurationKey` as custom metadata, +and is stored with an immutable one-year cache header. Nothing expires on a +timer; see cleanup below. + +**D1**: `insertables.small_thumbnail_url` / `large_thumbnail_url` and the same on +`groups` hold the app urls of the default thumbnails. `groups.thumbnail_workspace_id` +records the group's thumbnail workspace. + +**Onshape**: one workspace per document version named +`FRCDesignApp Thumbnails (DO NOT EDIT)`, whose description names the version. + +## Flows + +### The thumbnail workspace + +Onshape sometimes never renders thumbnails in a version, and the document's own +workspace moves on from the version we loaded. So every thumbnail is read from a +workspace of ours branched off the loaded version. + +1. A load of a new version calls `syncThumbnailWorkspace`, which finds our + workspace whose description names that version, or branches one. Every group + of the document and every retried step find the same one. +2. The group row records it (`thumbnail_workspace_id`). +3. After the group saves, `deleteStaleThumbnailWorkspaces` deletes every + workspace of ours that no group of the document, in any library, names. A + group elsewhere can be pinned to an older version, held for approval, and + still read from its own. + +Deleting waits for the save because until then the group row still names the +old workspace and the old version's microversions: configuration renders asked +for meanwhile read from it, and a load that fails part-way keeps the old +version, so its workspace is kept too. Onshape can't move a workspace to +another version, so a new version always means a new branch. + +A fresh branch has no rendered thumbnails for a few minutes. + +### Insertable and group thumbnails, during a load + +1. `loadInsertable` (see [loading.md](./loading.md)) calls `uploadThumbnails` for + the insertable's tab in the thumbnail workspace, keyed by the **version's** + microversion. A part studio with no parts is skipped. +2. `uploadThumbnails` fetches each size that is not already stored and throws + while Onshape has not rendered it. +3. The step runs under its own limiter (`THUMBNAIL_CONCURRENCY`) with + `THUMBNAIL_RETRIES` (`src/backend/features/load/steps.ts`): retries after 30 + seconds, 1, 2, 4, 4 and 4 minutes, about 16 minutes in all. Exhausted, it + records `THUMBNAIL_FAILED` and the load carries on. +4. The group's thumbnail comes from the document's designated thumbnail tab, or + its first tab without one (`NO_THUMBNAIL_TAB`). + +### Serving + +`GET /api/thumbnail/:size/:elementId?v={microversionId}&configurationKey=` only +serves what is stored: + +- **Hit**: streamed from R2, cacheable for a year. +- **Miss**: 404, uncached, so a render landing later is not shadowed. A + configuration miss is never answered with the insertable's default. + +### Rendering a configuration + +A render is started explicitly, by a client that got a configuration miss and +can wait for it: the insert menu's preview and favorite rows. Search rows never +start one, so a cold search cannot start a render per row. + +1. The client calls `POST /api/render-thumbnail/insertable/:insertableId` with + the configuration key. It must be signed in: the render calls Onshape as the + caller. +2. `requestRender` reads the insertable's element, current microversion and its + group's thumbnail workspace. A group without one starts nothing (it gets one + on its next load). +3. It asks Onshape for the configuration's thumbnail id through the insertables + endpoint, in the workspace. None answers `no-part`, which the client shows as + a configuration that failed to regenerate; otherwise it answers `rendering`. +4. The instance id is `render-` plus a SHA-256 of + `elementId/microversionId/configurationKey`, so every request for one render + finds the same instance. A running instance is left alone; a finished one is + restarted, and returns at once if its bytes are already stored. +5. `RenderThumbnailWorkflow` fetches both sizes by thumbnail id, each retried + every five seconds for about a minute (`RENDER_RETRIES`), and stores each as + it lands. +6. Each stored size pushes a `thumbnail` message (`src/backend/features/push/`). + +On the client, `loadRenderedImage` fetches; on the first miss it starts the +render and fetches again at once, then waits for the push (or its 60-second +deadline) before fetching once more. The row shows the insertable's default +meanwhile. + +### Reload thumbnail + +Editors can refetch one insertable's or group's thumbnail from its menu +(`POST /api/reload-thumbnail/library/:libraryId`, naming the group or insertable +in the body). It deletes the stored pair, fetches again from +the thumbnail workspace (syncing it first), clears `THUMBNAIL_FAILED` and bumps +the library version. Onshape still rendering answers 503. + +### Cleanup + +`deleteStaleThumbnails` runs after each load saves its group, and when a group +is deleted. For each element of the document, past and present, it lists both +prefixes and deletes objects whose `(elementId, microversionId)` no insertable +row in **any** library names and no group thumbnail url points at. A forced +reload also deletes every configuration render of the document +(`dropRenders`), so bad ones are redone. + +### Onshape calls + +| Flow | Call | Retries | +| ----------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| Load, reload | `GET /thumbnails/d/{did}/w/{thumbnail wid}/e/{eid}/s/{size}`, per size | `THUMBNAIL_RETRIES`; none on reload | +| Render request | `GET /documents/d/{did}/w/{thumbnail wid}/insertables?elementId=&configuration=` for `predictableThumbnailId` | none: the route answers | +| Render workflow | `GET /thumbnails/{thumbnailId}/s/{size}`, per size | `RENDER_RETRIES` | +| Workspace sync | `GET`/`POST /documents/d/{did}/workspaces` | `ONSHAPE_STEP_RETRIES` in a load | +| Workspace cleanup | `DELETE /documents/d/{did}/workspaces/{wid}` | none; failure is logged | + +A render workflow calls Onshape as the session that requested it +(`getOnshapeApiFromSessionId`), so a render outlives the request but not a +revoked session. + +## Invariants + +- A stored object is never overwritten: its key contains the microversion, so a + changed element lands on new keys. +- A configuration url never serves the insertable's default, and a miss is never + cached. +- Serving never starts a render; only `POST /api/render-thumbnail/...` does, for + a signed-in caller, and at most one instance runs per key. +- Thumbnails are read from the thumbnail workspace only, never from the version + or the document's own workspace. +- Configuration thumbnails are named by `ConfigurationKey`, and keys are used for + nothing else (see [configurations.md](./configurations.md)). +- Cleanup keeps anything younger than `STALE_THUMBNAIL_GRACE_MS` (one hour): a + load stores thumbnails before the rows naming them. + +## Failure and recovery + +| Failure | Result | Recovery | +| -------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------- | +| Onshape never renders an insertable within ~16 min | `THUMBNAIL_FAILED` on the insertable or group | **Reload thumbnail**, or the next version's load | +| A configuration never renders within ~1 min | Client keeps showing the default | Picking it again restarts the finished instance | +| A bad configuration render was stored | Wrong picture, immutably cached | Owner's **Reload all documents** drops the document's renders | +| A load crashes between storing and saving | Orphaned objects | Next cleanup, after the grace period | + +## Decisions + +- **A workspace per version, not the version.** Onshape does not reliably render + thumbnails in versions. Branching per version, rather than reusing one + workspace, keeps the rendered content identical to what was loaded. +- **Keyed by the version's microversion though read from the workspace.** The + workspace is branched from that version, so its content renders the same, and + the version's microversion is what the rows already hold. +- **Renders by thumbnail id.** The id Onshape reports for an insertable in a + configuration is fetched through `/thumbnails/{id}`; the documented + configured-thumbnail endpoint and its strictness flags returned wrong or + default images. +- **No R2 lifecycle rule.** Renders are meant to last; cleanup deletes what is + orphaned instead of expiring what might still be shown. + +_Last reviewed: 2026-09-27_ diff --git a/drizzle/0005_library_loading.sql b/drizzle/0005_library_loading.sql new file mode 100644 index 000000000..0a4185253 --- /dev/null +++ b/drizzle/0005_library_loading.sql @@ -0,0 +1,28 @@ +CREATE TABLE `load_jobs` ( + `group_id` text PRIMARY KEY NOT NULL, + `library_id` text NOT NULL, + `instance_id` text, + `started_at` integer NOT NULL, + `force_reload` integer DEFAULT false NOT NULL, + `awaiting_approval` integer DEFAULT false NOT NULL, + FOREIGN KEY (`group_id`) REFERENCES `groups`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`library_id`) REFERENCES `libraries`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `onshape_webhooks` ( + `subject` text NOT NULL, + `subject_id` text NOT NULL, + `webhook_id` text, + `token` text NOT NULL, + PRIMARY KEY(`subject`, `subject_id`) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `onshape_webhooks_token_unique` ON `onshape_webhooks` (`token`);--> statement-breakpoint +ALTER TABLE `groups` ADD `thumbnail_workspace_id` text;--> statement-breakpoint +ALTER TABLE `insertables` ADD `excluded_parameter_ids` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +ALTER TABLE `libraries` ADD `admin_team_id` text;--> statement-breakpoint +ALTER TABLE `libraries` ADD `admin_team` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +ALTER TABLE `libraries` ADD `approve_versions` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `theme`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `tab_id`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `group_id`; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 000000000..500272d26 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1327 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fcb81b2b-1673-4853-a0c7-9abd722c236f", + "prevId": "ed7074d1-6acd-452d-bdd7-662e8a205082", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_insertable_id_insertables_id_fk": { + "name": "configurations_insertable_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_selection": { + "name": "default_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "favorites_user_id_library_id_insertable_id_unique": { + "name": "favorites_user_id_library_id_insertable_id_unique", + "columns": ["user_id", "library_id", "insertable_id"], + "isUnique": true + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_library_id_libraries_id_fk": { + "name": "favorites_library_id_libraries_id_fk", + "tableFrom": "favorites", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_insertable_id_insertables_id_fk": { + "name": "favorites_insertable_id_insertables_id_fk", + "tableFrom": "favorites", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_workspace_id": { + "name": "thumbnail_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_alphabetically": { + "name": "sort_alphabetically", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groups_document_id_library_id_unique": { + "name": "groups_document_id_library_id_unique", + "columns": ["document_id", "library_id"], + "isUnique": true + } + }, + "foreignKeys": { + "groups_library_id_libraries_id_fk": { + "name": "groups_library_id_libraries_id_fk", + "tableFrom": "groups", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertables": { + "name": "insertables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_type": { + "name": "element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "microversion_id": { + "name": "microversion_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_open_composite": { + "name": "is_open_composite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "supports_fasten": { + "name": "supports_fasten", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "excluded_parameter_ids": { + "name": "excluded_parameter_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vendors": { + "name": "vendors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_metadata": { + "name": "part_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "insertables_group_id_groups_id_fk": { + "name": "insertables_group_id_groups_id_fk", + "tableFrom": "insertables", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "insertables_library_id_libraries_id_fk": { + "name": "insertables_library_id_libraries_id_fk", + "tableFrom": "insertables", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "libraries": { + "name": "libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "admin_team_id": { + "name": "admin_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_team": { + "name": "admin_team", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "approve_versions": { + "name": "approve_versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "load_jobs": { + "name": "load_jobs", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "force_reload": { + "name": "force_reload", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awaiting_approval": { + "name": "awaiting_approval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "load_jobs_group_id_groups_id_fk": { + "name": "load_jobs_group_id_groups_id_fk", + "tableFrom": "load_jobs", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "load_jobs_library_id_libraries_id_fk": { + "name": "load_jobs_library_id_libraries_id_fk", + "tableFrom": "load_jobs", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onshape_webhooks": { + "name": "onshape_webhooks", + "columns": { + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "onshape_webhooks_token_unique": { + "name": "onshape_webhooks_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onshape_webhooks_subject_subject_id_pk": { + "columns": ["subject", "subject_id"], + "name": "onshape_webhooks_subject_subject_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'frc-design-lib'" + } + }, + "indexes": {}, + "foreignKeys": { + "users_library_id_libraries_id_fk": { + "name": "users_library_id_libraries_id_fk", + "tableFrom": "users", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_configuration_metrics": { + "name": "daily_configuration_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parameter_id": { + "name": "parameter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_key": { + "name": "instance_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_configuration_metrics_day_idx": { + "name": "daily_configuration_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_configuration_metrics_library_id_element_id_parameter_id_value_instance_key_day_pk": { + "columns": [ + "library_id", + "element_id", + "parameter_id", + "value", + "instance_key", + "day" + ], + "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_instance_key_day_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_metrics": { + "name": "daily_insertable_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_insertable_metrics_day_idx": { + "name": "daily_insertable_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { + "columns": [ + "library_id", + "element_id", + "day", + "target_element_type" + ], + "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_users": { + "name": "daily_insertable_users", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_users_library_id_element_id_day_user_id_pk": { + "columns": ["library_id", "element_id", "day", "user_id"], + "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_metrics": { + "name": "daily_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fasten_count": { + "name": "fasten_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_metrics_day_library_id_type_pk": { + "columns": ["day", "library_id", "type"], + "name": "daily_metrics_day_library_id_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_source_metrics": { + "name": "daily_source_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_source_metrics_day_library_id_source_pk": { + "columns": ["day", "library_id", "source"], + "name": "daily_source_metrics_day_library_id_source_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_target_metrics": { + "name": "daily_target_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_target_metrics_day_library_id_target_element_type_pk": { + "columns": ["day", "library_id", "target_element_type"], + "name": "daily_target_metrics_day_library_id_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_user_activity": { + "name": "daily_user_activity", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "daily_user_activity_day_idx": { + "name": "daily_user_activity_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_user_activity_day_library_id_user_id_pk": { + "columns": ["day", "library_id", "user_id"], + "name": "daily_user_activity_day_library_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_quick_insert": { + "name": "is_quick_insert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten": { + "name": "fasten", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "events_day_idx": { + "name": "events_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertable_stats": { + "name": "insertable_stats", + "columns": { + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_inserted_at": { + "name": "first_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_inserted_at": { + "name": "last_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "insertable_stats_count_idx": { + "name": "insertable_stats_count_idx", + "columns": ["library_id", "insert_count"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "insertable_stats_library_id_element_id_pk": { + "columns": ["library_id", "element_id"], + "name": "insertable_stats_library_id_element_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_stats_user_id_library_id_pk": { + "columns": ["user_id", "library_id"], + "name": "user_stats_user_id_library_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 79125ad46..6a1f1561c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1790116094276, "tag": "0004_configuration_instance_key", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1790601046226, + "tag": "0005_library_loading", + "breakpoints": true } ] } diff --git a/package-lock.json b/package-lock.json index 760346b05..bc2f2b4b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "react-dom": "^19.2.7", "recharts": "^3.10.1", "typescript-parsec": "^0.3.4", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.15" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.45.1", @@ -36,6 +37,9 @@ "@hey-api/openapi-ts": "^0.99.0", "@tanstack/react-router-devtools": "^1.166.13", "@tanstack/router-plugin": "^1.168.2", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/node": "^24.12.4", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", @@ -48,6 +52,7 @@ "eslint-plugin-react-x": "^5.7.8", "globals": "^17.5.0", "husky": "^9.1.7", + "jsdom": "^30.1.1", "lint-staged": "^17.0.5", "postcss": "^8.5.15", "postcss-preset-mantine": "^1.18.0", @@ -60,6 +65,59 @@ "wrangler": "^4.123.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -351,6 +409,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -378,17 +449,17 @@ } }, "node_modules/@cloudflare/vite-plugin": { - "version": "1.54.6", - "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.54.6.tgz", - "integrity": "sha512-7G85rvY9gaLYOLA/0Q1rQPx6l7wrY763b67FEVXyWoFUriiLljemBkbsFsnVYjrJyZ+yqHQ6gNCRX//hAzSc0g==", + "version": "1.60.2", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.60.2.tgz", + "integrity": "sha512-scyKSUBOAXNJDLDD5y1qDUPBgbmGOE8v7zgTHjeYXE1VLxYGTHewhu4rUXXctJg0t2Lsp2ciW5feM858UZ5qTQ==", "dev": true, "license": "MIT", "dependencies": { - "@cloudflare/unenv-preset": "2.16.1", - "miniflare": "5.20260908.0-alpha", + "@cloudflare/unenv-preset": "2.16.2", + "miniflare": "5.20260925.0-alpha", "unenv": "2.0.0-rc.24", - "workerd": "1.20260908.1", - "wrangler": "4.130.0", + "workerd": "1.20260925.1", + "wrangler": "4.141.0", "ws": "8.21.0" }, "bin": { @@ -396,105 +467,697 @@ }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", - "wrangler": "^4.130.0" + "wrangler": "^4.141.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260925.1.tgz", + "integrity": "sha512-GFDjFo5jfmg98iiy73DOZjHRV+lPWaeGeJ6MbtUIVyostkh9L69KliaAWVytBHCSp36o3NNZOQ0CN8VrqO3+8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260925.1.tgz", + "integrity": "sha512-b3BLoP9NkF6b2QOQFs8wFfOt9XSH3jVtacdGn9lcvkUCdPq7F6CfDlleHqyQEnDfZ32lH5jgMRTdaMHUPf/XbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260925.1.tgz", + "integrity": "sha512-LczXd6uEMqEmBxBJPFwplRIwKmUHqj5t8hilIWOtbX5i0TFp7/iMYBXbAiIhvZnev+8yiTyA7uWBBjDvfPh5Ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260925.1.tgz", + "integrity": "sha512-hziOugQbLuva85w8z9bGJbEtABKzSip653x0NgvxgP/Q3n1TpRqRyIJRBufbSUZRkJlfH2se2nQaTx2OU0TT3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260925.1.tgz", + "integrity": "sha512-mG0JYuF6HVCgXSsNeB5os8Vtdprk5jHPysviKNNMHJSLA3Q1djFjSmK4+pnQR/zOAgzNo0HJ+Tpcz+tZj1Q7fQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" } }, - "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260908.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260908.1.tgz", - "integrity": "sha512-t3juyCXFn12OklBL0S7UC98py3nLEmuioBOUOazeyeVsLUu8fv+pfJrR+XzwSH2Uh6rCXZNogRh+LtV0YpzMcQ==", + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ - "x64" + "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=16" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, - "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260908.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260908.1.tgz", - "integrity": "sha512-I1nwA4qm/fUNSKhPSO76YA6JAhcA0KU63yFcYHN6AiDowGbDyfjDPH9KCVopT5rI1LY4GfbdAi5b9gODqZsAkQ==", + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ - "arm64" + "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=16" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, - "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260908.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260908.1.tgz", - "integrity": "sha512-s/h5uSW1UC6dGeVKSqrPLpTu+vo0fKJZCNEokW1Ol1qcCB2oN6WCRHhoyzo4Y69oONAXRgLfLBYaHWe7z51Vnw==", + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ - "x64" + "wasm32" ], "dev": true, "license": "Apache-2.0", "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=16" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260908.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260908.1.tgz", - "integrity": "sha512-PP/nUKl0R6colwfocggL8dctO/dFMqMft12unzFUkoAJr0aXQeT+ia0JuNmOUWXe6EfvyCSmAbijEsTKQ5t/ew==", + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ - "arm64" + "ia32" ], "dev": true, - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=16" + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260908.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260908.1.tgz", - "integrity": "sha512-jkaS5EKKTvzAdIlbMfOYrHoTucWVRwYBwIig+xRsjXQv5BXTLA2Llg+iM8djlKtk0CjkztZhXmuxuqoqWKZmFw==", + "node_modules/@cloudflare/vite-plugin/node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=16" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@cloudflare/vite-plugin/node_modules/miniflare": { - "version": "5.20260908.0-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260908.0-alpha.tgz", - "integrity": "sha512-BHIknb0u6vLIvb+R8PsUAzgoWWk3OlW85DrV2SG0EydfSpIba28YT0rH6xZThjnSwDxzNuW3FDRNSWvbiI/DVw==", + "version": "5.20260925.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260925.0-alpha.tgz", + "integrity": "sha512-ly2ygNOfuouMiPcyTNEUW6f3+zgndfC45e1nQZ2LSciOSFfrih8V7ciaZ01I/64Ccp3F6fWraxh5VuHPsP3AAw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.35.2", + "sharp": "0.35.4", "undici": "7.29.0", - "workerd": "1.20260908.1", + "workerd": "1.20260925.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -502,10 +1165,73 @@ "node": ">=22.0.0" } }, + "node_modules/@cloudflare/vite-plugin/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@cloudflare/vite-plugin/node_modules/workerd": { - "version": "1.20260908.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260908.1.tgz", - "integrity": "sha512-rYhpW6NWHD++p34ej+VXWzi5Pdnmd7ncdbB/ux4s+i3mf7IeOpW3O4roqClQ+Z8ernvyMCknBLCb76z1rA+a7Q==", + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260925.1.tgz", + "integrity": "sha512-cl7Mrvhm+xEC9brMBIWIHyfYl7y5dGH3CjQog6CglWhgRwAq+/b+2gHBXNQb4hsjLXQVCuCYExJRYCjEaZmAlA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -516,28 +1242,28 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260908.1", - "@cloudflare/workerd-darwin-arm64": "1.20260908.1", - "@cloudflare/workerd-linux-64": "1.20260908.1", - "@cloudflare/workerd-linux-arm64": "1.20260908.1", - "@cloudflare/workerd-windows-64": "1.20260908.1" + "@cloudflare/workerd-darwin-64": "1.20260925.1", + "@cloudflare/workerd-darwin-arm64": "1.20260925.1", + "@cloudflare/workerd-linux-64": "1.20260925.1", + "@cloudflare/workerd-linux-arm64": "1.20260925.1", + "@cloudflare/workerd-windows-64": "1.20260925.1" } }, "node_modules/@cloudflare/vite-plugin/node_modules/wrangler": { - "version": "4.130.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.130.0.tgz", - "integrity": "sha512-fzNjnTzyZl31PGJOFXbiLeZqEIToQ9KOzkkvGdQz4wlB+BoHnl3BRwCR5xg8AqYyzVunvvMHlMzvlbThQ7rrAA==", + "version": "4.141.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.141.0.tgz", + "integrity": "sha512-8+LRZEcynBMfVYgO4N378z5XyuajaILt2IzsdWRLCHrRWz7mmGWv4igdVjqbRQMJtE0S5bSmyz0W2XpA6UM/ow==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", + "@cloudflare/unenv-preset": "2.16.2", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "5.20260908.0-alpha", + "miniflare": "5.20260925.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260908.1" + "workerd": "1.20260925.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -551,7 +1277,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260908.1" + "@cloudflare/workers-types": "^5.20260925.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -694,6 +1420,146 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -1868,6 +2734,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -3821,18 +4705,80 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/virtual-file-routes": { - "version": "1.162.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", - "integrity": "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==", + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", + "integrity": "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.19" + "node": ">=12", + "npm": ">=6" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" } }, "node_modules/@tybys/wasm-util": { @@ -3846,6 +4792,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4429,6 +5382,19 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ansis": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", @@ -4457,6 +5423,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4503,6 +5479,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -4801,6 +5787,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4941,6 +5941,35 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4959,6 +5988,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -5022,6 +6058,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -5055,6 +6101,13 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -5079,9 +6132,9 @@ } }, "node_modules/drizzle-kit": { - "version": "0.31.10", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", - "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "version": "0.31.11", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", + "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", "dev": true, "license": "MIT", "dependencies": { @@ -5710,6 +6763,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -6308,6 +7374,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/http-status-ts": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-status-ts/-/http-status-ts-2.0.1.tgz", @@ -6463,6 +7542,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -6534,6 +7620,66 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz", + "integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7113,6 +8259,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7123,6 +8279,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -7328,6 +8491,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7595,6 +8771,38 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7831,6 +9039,16 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -7955,6 +9173,19 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8305,6 +9536,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz", + "integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.14" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz", + "integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8754,6 +10031,29 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -8761,6 +10061,31 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8900,6 +10225,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8983,6 +10325,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 796549fee..b657a9dec 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "dev": "wrangler d1 migrations apply DB --local && vite dev", + "tunnel": "cloudflared tunnel run --token-file .tunnel-token", "tsc": "tsc -b", "build": "tsc -b && vite build", "lint": "eslint .", @@ -18,6 +19,7 @@ "gen:onshape-types": "openapi-ts", "test": "vitest run", "check:migrations": "node --disable-warning=ExperimentalWarning scripts/check-migrations.js", + "check:docs": "node scripts/check-docs.js", "test:watch": "vitest", "prepare": "husky" }, @@ -41,7 +43,8 @@ "react-dom": "^19.2.7", "recharts": "^3.10.1", "typescript-parsec": "^0.3.4", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.15" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.45.1", @@ -50,6 +53,9 @@ "@hey-api/openapi-ts": "^0.99.0", "@tanstack/react-router-devtools": "^1.166.13", "@tanstack/router-plugin": "^1.168.2", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/node": "^24.12.4", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", @@ -62,6 +68,7 @@ "eslint-plugin-react-x": "^5.7.8", "globals": "^17.5.0", "husky": "^9.1.7", + "jsdom": "^30.1.1", "lint-staged": "^17.0.5", "postcss": "^8.5.15", "postcss-preset-mantine": "^1.18.0", @@ -83,6 +90,6 @@ "esbuild@0.28.1": true, "@parcel/watcher@2.5.6": true, "sharp@0.35.2": true, - "workerd@1.20260908.1": true + "workerd@1.20260925.1": true } } diff --git a/scripts/check-docs.js b/scripts/check-docs.js new file mode 100644 index 000000000..281a0c254 --- /dev/null +++ b/scripts/check-docs.js @@ -0,0 +1,66 @@ +/** + * Fails when a doc names a file that no longer exists: a path in backticks + * under one of the repo's top-level directories, or a relative markdown link. + * A moved or renamed file is the most common way the docs drift from the code. + * + * npm run check:docs + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +const DOC_DIRS = ["docs", "docs/architecture"]; +const ROOT_FILES = ["AGENTS.md", "README.md"]; +const CHECKED_ROOTS = ["src/", "docs/", "drizzle/", "scripts/"]; + +function docFiles() { + const files = [...ROOT_FILES]; + for (const dir of DOC_DIRS) { + for (const name of readdirSync(dir)) { + if (name.endsWith(".md")) files.push(join(dir, name)); + } + } + return files; +} + +/** Backticked paths under a checked root; globs and placeholders are skipped. */ +function namedPaths(text) { + const paths = []; + for (const [, code] of text.matchAll(/`([^`\n]+)`/g)) { + for (const token of code.split(/[\s,]+/)) { + if (!CHECKED_ROOTS.some((root) => token.startsWith(root))) continue; + if (/[*{}<>]/.test(token)) continue; + paths.push(token.replace(/[.:;)]+$/, "")); + } + } + return paths; +} + +/** Relative link targets, without their anchors. */ +function linkedPaths(text) { + const paths = []; + for (const [, target] of text.matchAll(/\]\(([^)\s]+)\)/g)) { + if (/^[a-z]+:/i.test(target) || target.startsWith("#")) continue; + paths.push(target.split("#")[0]); + } + return paths; +} + +const missing = []; +for (const file of docFiles()) { + const text = readFileSync(file, "utf8"); + for (const path of namedPaths(text)) { + if (!existsSync(path)) missing.push(`${file}: \`${path}\``); + } + for (const path of linkedPaths(text)) { + if (!existsSync(resolve(dirname(file), path))) { + missing.push(`${file}: link ${path}`); + } + } +} + +if (missing.length > 0) { + console.error("Docs name paths that don't exist:\n"); + for (const line of missing) console.error(` ${line}`); + process.exit(1); +} +console.log("Every path the docs name exists."); diff --git a/src/__test_utils__/apply-migrations.ts b/src/__test_utils__/apply-migrations.ts index 81728d641..de3e2eb0e 100644 --- a/src/__test_utils__/apply-migrations.ts +++ b/src/__test_utils__/apply-migrations.ts @@ -1,6 +1,5 @@ import { applyD1Migrations } from "cloudflare:test"; import { env } from "cloudflare:workers"; -// Setup files may run more than once; applyD1Migrations only applies what is -// missing, so calling it here is safe. +// Only applies what's missing, so repeated setup is safe. await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index f08e172ef..2381dd8cf 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -1,17 +1,16 @@ -/** - * Import directly, not through `__test_utils__/index.ts`: the barrel reaches - * `cloudflare:workers`, which `src/shared`'s node-project tests cannot resolve. - */ +// Import directly: the barrel reaches `cloudflare:workers`, which node tests can't resolve. import { ParameterType, type BooleanParameter, type ConfigurationRecord, type EnumParameter, type QuantityParameter, - type UnitInfo + type StringParameter, + type UnitInfo, + ParameterRole } from "@backend/features/configurations/contract"; import { QuantityType, Unit } from "@backend/features/configurations/enums"; -import { canonicalizeValue } from "@backend/features/configurations/selection"; +import { quantityDefault } from "@backend/features/configurations/selection"; /** Builds an enum parameter whose options are named after their ids. */ export function enumParam( @@ -23,7 +22,6 @@ export function enumParam( id, name: id, default: optionIds[0], - isCosmetic: false, type: ParameterType.ENUM, options: optionIds.map((optionId) => ({ id: optionId, @@ -49,25 +47,32 @@ export function boolParam(id: string): BooleanParameter { id, name: id, default: "false", - isCosmetic: false, type: ParameterType.BOOLEAN }; } -/** - * A length quantity parameter defaulting to 1 inch, canonically spelled — the - * form `parseOnshapeConfiguration` stores, so tests compare like for like. - */ +export function stringParam(id: string): StringParameter { + return { id, name: id, default: "", type: ParameterType.STRING }; +} + +/** A text parameter recognized, as a load would, as a derivation variable. */ +export function derivationParam(id: string): StringParameter { + return { + ...stringParam(id), + name: "Derivation Variable", + role: ParameterRole.DERIVATION_VARIABLE + }; +} + +/** Defaults to 1 inch, spelled as `parseOnshapeConfiguration` stores it. */ export function quantityParam( id: string, - extra: Partial = {} + extra: Omit, "default"> = {} ): QuantityParameter { - const parameter: QuantityParameter = { + const parameter = { id, name: id, - default: "1 in", - isCosmetic: false, - type: ParameterType.QUANTITY, + type: ParameterType.QUANTITY as const, quantityType: QuantityType.LENGTH, defaultValue: 1, min: 0, @@ -75,10 +80,7 @@ export function quantityParam( unit: Unit.INCH, ...extra }; - return { - ...parameter, - default: canonicalizeValue(parameter, parameter.default) - }; + return { ...parameter, default: quantityDefault(parameter) }; } /** Document units: inches to 4 decimals, degrees to 3. */ @@ -95,7 +97,7 @@ export function configurationRecord( overrides: Partial = {} ): ConfigurationRecord { return { - configurationKey: "", + values: {}, hasMultipleParts: false, isOpenComposite: false, ...overrides diff --git a/src/__test_utils__/dom-setup.ts b/src/__test_utils__/dom-setup.ts new file mode 100644 index 000000000..def0ed771 --- /dev/null +++ b/src/__test_utils__/dom-setup.ts @@ -0,0 +1,33 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// jsdom lacks what Mantine measures and positions with. +window.matchMedia ??= (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false + }) as MediaQueryList; + +const ignore = () => undefined; + +globalThis.ResizeObserver ??= class { + observe = ignore; + unobserve = ignore; + disconnect = ignore; +}; + +if (!("scrollIntoView" in Element.prototype)) { + Object.assign(Element.prototype, { scrollIntoView: ignore }); +} + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + window.sessionStorage.clear(); +}); diff --git a/src/__test_utils__/fake-step.ts b/src/__test_utils__/fake-step.ts index 50e494ead..c8d3521c1 100644 --- a/src/__test_utils__/fake-step.ts +++ b/src/__test_utils__/fake-step.ts @@ -1,9 +1,6 @@ import type { WorkflowStep } from "cloudflare:workers"; -/** - * Runs each step inline, so the load functions can be exercised without a real - * workflow. Durability and retries are Cloudflare's concern, not these tests'. - */ +/** Runs each step inline; durability and retries are Cloudflare's concern. */ export const FAKE_STEP = { do: (_name: string, optionsOrFn: unknown, maybeFn?: unknown) => { const run = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn; diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index 262bd5291..7942088d9 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -1,7 +1,4 @@ -/** - * Factories for the load pipeline's insertable shapes. Import directly: the - * barrel re-exports Workers-only helpers these tests cannot resolve. - */ +// Import directly: the barrel re-exports Workers-only helpers. import type { InsertableTarget } from "@backend/features/load/context"; import type { ParsedInsertable } from "@backend/features/load/load-insertable"; import { ElementType } from "@backend/lib/onshape/element-type"; @@ -22,8 +19,9 @@ export function insertableTarget( libraryId: TEST_LIBRARY_ID, groupId: TEST_GROUP_ID, elementPath: TEST_PART_STUDIO_PATH, - elementWorkspacePath: { + thumbnailPath: { ...TEST_PART_STUDIO_PATH, + instanceId: "w-thumbnails", instanceType: "w" }, versionCreatedAt: TEST_VERSION_CREATED_AT, diff --git a/src/__test_utils__/mock-onshape-api.ts b/src/__test_utils__/mock-onshape-api.ts index 92008ced9..069694065 100644 --- a/src/__test_utils__/mock-onshape-api.ts +++ b/src/__test_utils__/mock-onshape-api.ts @@ -1,8 +1,5 @@ import { OAuthApi } from "@backend/lib/onshape/client"; -/** - * A thin shell client extending OAuthApi. - */ export class MockOnshapeApi extends OAuthApi { constructor() { // The token/refresh callback are unused — requests never reach the network. diff --git a/src/__test_utils__/render.tsx b/src/__test_utils__/render.tsx new file mode 100644 index 000000000..f24d5d8e8 --- /dev/null +++ b/src/__test_utils__/render.tsx @@ -0,0 +1,35 @@ +/** Themed, with a query cache that never fetches: seed what a component reads. */ +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { createAppTheme } from "@frontend/theme"; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity } + } + }); +} + +export function renderWithProviders( + ui: ReactNode, + queryClient: QueryClient = createTestQueryClient() +) { + return { + queryClient, + ...render( + + {/* `test` turns off transitions and portals, which jsdom + cannot run, and which would hide a dropdown's options. */} + + {ui} + + + ) + }; +} diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 30d603575..ad95634ed 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -1,4 +1,3 @@ -import { type AppTab, isLibraryTab } from "@backend/features/settings/app-tab"; import { type Db } from "@backend/db/client"; import { configurations, @@ -6,7 +5,9 @@ import { groups, insertables, libraries, - users + users, + loadJobs, + onshapeWebhooks } from "@backend/db/schema"; import { dailyConfigurationMetrics, @@ -60,33 +61,34 @@ export const TEST_PARAMETERS: ConfigurationParameter[] = [ type: ParameterType.BOOLEAN, id: "boolean", name: "Test boolean", - isCosmetic: false, default: "true" } ]; -/** - * Truncates every table these helpers touch, in FK-safe order. D1 storage is - * isolated per test *file*, so call this in `beforeEach` to isolate tests. - */ +/** D1 storage is only isolated per file, so call in `beforeEach`. */ export async function resetDb(db: Db): Promise { - await db.delete(favorites); - await db.delete(configurations); - await db.delete(insertables); - await db.delete(groups); - await db.delete(users); - await db.delete(libraries); - // Analytics has no foreign keys, so nothing cascades these away. - await db.delete(events); - await db.delete(dailyMetrics); - await db.delete(dailySourceMetrics); - await db.delete(dailyTargetMetrics); - await db.delete(dailyUserActivity); - await db.delete(insertableStats); - await db.delete(dailyInsertableMetrics); - await db.delete(dailyInsertableUsers); - await db.delete(dailyConfigurationMetrics); - await db.delete(userStats); + // One batch, one round trip: this runs before nearly every test. + await db.batch([ + db.delete(favorites), + db.delete(configurations), + db.delete(loadJobs), + db.delete(insertables), + db.delete(groups), + db.delete(users), + db.delete(libraries), + db.delete(onshapeWebhooks), + // Analytics has no foreign keys, so nothing cascades these away. + db.delete(events), + db.delete(dailyMetrics), + db.delete(dailySourceMetrics), + db.delete(dailyTargetMetrics), + db.delete(dailyUserActivity), + db.delete(insertableStats), + db.delete(dailyInsertableMetrics), + db.delete(dailyInsertableUsers), + db.delete(dailyConfigurationMetrics), + db.delete(userStats) + ]); } export async function seedLibrary( @@ -97,20 +99,13 @@ export async function seedLibrary( return id; } -/** - * Seeds the libraries a user row needs: the tab's, so a group seeded in it has - * one to belong to, and the default its dead `library_id` falls back to. - */ +/** Also seeds the default library its dead `library_id` column falls back to. */ export async function seedUser( db: Db, - id: string = TEST_USER_ID, - tabId: AppTab = DEFAULT_LIBRARY + id: string = TEST_USER_ID ): Promise { await seedLibrary(db, DEFAULT_LIBRARY); - if (isLibraryTab(tabId)) { - await seedLibrary(db, tabId); - } - await db.insert(users).values({ id, tabId }).onConflictDoNothing(); + await db.insert(users).values({ id }).onConflictDoNothing(); return id; } @@ -138,10 +133,7 @@ export async function seedGroup( return id; } -/** - * Inserts an insertable row, defaulting to the standard part studio. Pass - * overrides to seed a row with the specific columns a test wants to manipulate. - */ +/** Defaults to the standard part studio. */ export async function seedInsertable( db: Db, overrides: Partial = {} @@ -203,10 +195,7 @@ export async function seedFavorite( return id; } -/** - * Seeds a configuration for a given insertable. - * Note configurations are always 1:1 with insertables so the configuration id is also the insertable id. - */ +/** Configurations are 1:1 with insertables and share their id. */ export async function seedConfiguration( db: Db, insertableId: string = TEST_PART_STUDIO_ID @@ -220,10 +209,7 @@ export async function seedConfiguration( .onConflictDoNothing(); } -/** - * Seeds the canonical dataset: a library, a user, a groups, a part studio, an - * assembly, and two favorites (the user's, on the part studio and the assembly). - */ +/** A library, a user, a group, a part studio, an assembly, and a favorite on each. */ export async function seedTestData(db: Db): Promise { await seedLibrary(db); await seedGroup(db); diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index 4b886d034..bf5559cd4 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -1,27 +1,22 @@ import { createApp } from "@backend/app"; import { AccessLevel } from "@backend/features/auth/access-level"; +import type { LibraryId } from "@backend/features/library/library-id"; import { MOCK_ONSHAPE_API, MockOnshapeApi } from "./mock-onshape-api"; export interface TestAppOptions { /** Current user id, returned by `c.var.getUserId()` (default `"test-user"`). */ userId?: string; - /** Access level returned by `c.var.getAccessLevel()` (default `ADMIN`). */ - accessLevel?: AccessLevel; + /** Default `ADMIN`; a function sets it per library. */ + accessLevel?: AccessLevel | ((libraryId: LibraryId) => AccessLevel); /** Onshape mock returned by `c.var.getOnshapeApi()` (default a fresh mock). */ onshapeApi?: MockOnshapeApi; - /** - * When false, `getOnshapeApi` rejects so `isSignedIn()` is false (simulating - * a not-signed-in caller). Default true. - */ + /** When false, `getOnshapeApi` rejects, so `isSignedIn()` is false. Default true. */ signedIn?: boolean; /** Whether the caller passes the auth gate (default true). */ isAuthenticated?: boolean; } -/** - * The real app from `createApp`, answering its auth questions from `options` - * instead of `productionAuth`. Drive it with `app.request(path, init, env)`. - */ +/** The real app, with auth from `options` instead of `productionAuth`. */ export function createTestApp(options: TestAppOptions = {}) { const signedIn = options.signedIn ?? true; return createApp(() => ({ @@ -30,16 +25,16 @@ export function createTestApp(options: TestAppOptions = {}) { ? Promise.resolve(options.onshapeApi ?? MOCK_ONSHAPE_API) : Promise.reject(new Error("Not signed in")), getUserId: () => Promise.resolve(options.userId ?? "test-user"), - getAccessLevel: () => - Promise.resolve(options.accessLevel ?? AccessLevel.ADMIN), + getAccessLevel: (libraryId) => { + const level = options.accessLevel ?? AccessLevel.ADMIN; + return Promise.resolve( + typeof level === "function" ? level(libraryId) : level + ); + }, isAuthenticated: () => Promise.resolve(options.isAuthenticated ?? true) })); } -/** - * Builds a `RequestInit` for a JSON request, serializing `body` and setting the - * content-type header. Use with `app.request(path, jsonRequest(...), env)`. - */ export function jsonRequest(method: string, body?: unknown): RequestInit { if (body === undefined) return { method }; return { diff --git a/src/backend/app.ts b/src/backend/app.ts index 967edef21..91e98634c 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,19 +1,18 @@ -/** - * Composition root: binds the request's auth onto every request and mounts each - * feature's routes. Everything it wires lives in a feature or in lib. - */ import { analyticsRoutes } from "./features/analytics/routes"; import { accessRoutes, authRoutes } from "./features/auth/routes"; import { buildStatusRoutes } from "./features/build-checker/routes"; import { configurationRoutes } from "./features/configurations/routes"; -import { entryRoutes } from "./features/entry/routes"; +import { appOpenRoutes, entryRoutes } from "./features/entry/routes"; import { favoriteRoutes } from "./features/favorites/routes"; import { groupRoutes } from "./features/library/groups/routes"; import { insertLocationRoutes } from "./features/insert-location/routes"; import { insertableRoutes } from "./features/library/insertables/routes"; import { libraryRoutes } from "./features/library/routes"; -import { settingsRoutes } from "./features/settings/routes"; import { thumbnailRoutes } from "./features/thumbnails/routes"; +import { webhookRoutes } from "./features/webhooks/routes"; +import { pushRoutes } from "./features/push/routes"; +import { adminTeamRoutes } from "./features/admin-team/routes"; +import { loadRoutes } from "./features/load/routes"; import { logger } from "hono/logger"; import { cacheMiddleware } from "./lib/cache"; import { bindAuth, getApp, type AuthResolver } from "./lib/context"; @@ -21,7 +20,7 @@ import { errorHandler } from "./lib/errors"; const apiRoutes = [ accessRoutes, - settingsRoutes, + appOpenRoutes, libraryRoutes, groupRoutes, insertableRoutes, @@ -30,14 +29,17 @@ const apiRoutes = [ thumbnailRoutes, favoriteRoutes, buildStatusRoutes, - analyticsRoutes + analyticsRoutes, + webhookRoutes, + pushRoutes, + adminTeamRoutes, + loadRoutes ]; export function createApp(resolveAuth: AuthResolver) { const app = getApp(); - // Reaches Workers Logs, which wrangler.jsonc enables. Only /init, /api/* - // and /auth/* run the Worker, so static assets are not logged. + // To Workers Logs. Static assets don't run the Worker, so they aren't logged. app.use("*", logger()); app.use("*", bindAuth(resolveAuth)); diff --git a/src/backend/db/chunk.ts b/src/backend/db/chunk.ts index 18dbeb8b7..4f83b0e80 100644 --- a/src/backend/db/chunk.ts +++ b/src/backend/db/chunk.ts @@ -1,15 +1,7 @@ -/** - * D1 rejects a statement carrying more than 100 bound parameters, and drizzle's - * `inArray` binds one per value. The ten left spare cover the rest of a - * statement's conditions; the widest here pairs a library id with the list. - */ +/** D1 allows 100 bound parameters per statement; the spare ten cover the other conditions. */ const MAX_IN_ARRAY_VALUES = 90; -/** - * Splits values into runs an `inArray` can carry in a single statement. An - * empty list yields no chunks, which leaves a caller's loop doing nothing — - * the same as the `false` drizzle builds for an empty `inArray`. - */ +/** Empty input yields no chunks. */ export function chunkForInArray(values: T[]): T[][] { const chunks: T[][] = []; for (let i = 0; i < values.length; i += MAX_IN_ARRAY_VALUES) { diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 345b54a6d..5fcc907f3 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -3,41 +3,39 @@ import { text, integer, unique, - customType + customType, + primaryKey } from "drizzle-orm/sqlite-core"; import { ElementType } from "../lib/onshape/element-type"; import { FastenInfo } from "../features/library/insertables/fasten"; import { DEFAULT_LIBRARY, LibraryId } from "../features/library/library-id"; -import { AppTab } from "../features/settings/app-tab"; -import { DEFAULT_SETTINGS, Theme } from "../features/settings/settings"; +import type { AdminTeamMember } from "../features/admin-team/contract"; import { Vendor } from "../features/library/vendors"; import { ConfigurationParameter, ConfigurationRecord, - Selection, + PartialSelection, PartMetadata } from "../features/configurations/contract"; import { BuildIssue, knownBuildIssues } from "../features/build-checker/issues"; +/** A JSON column whose stored rows may predate its current shape. */ +function upgradedJson(upgrade: (stored: T) => T) { + return customType<{ data: T; driverData: string }>({ + dataType: () => "text", + toDriver: (value) => JSON.stringify(value), + fromDriver: (value) => upgrade(JSON.parse(value) as T) + }); +} + /** - * Build-time issues flagged by the build checker, recomputed on reload. Declared - * once because both tables carry exactly this column. - * - * Filtered on read rather than trusted: a stored array was written by whichever - * deploy last loaded the row, so it can still name a check that has since been - * removed. The next write of the row drops it for good. + * Filtered on read: a row keeps whatever checks the deploy that wrote it knew, + * so it can name one since removed. */ -const buildIssuesColumn = customType<{ - data: BuildIssue[]; - driverData: string; -}>({ - dataType: () => "text", - toDriver: (issues) => JSON.stringify(issues), - fromDriver: (value) => knownBuildIssues(JSON.parse(value) as BuildIssue[]) -}); - const buildIssues = () => - buildIssuesColumn("build_issues").notNull().default([]); + upgradedJson(knownBuildIssues)("build_issues") + .notNull() + .default([]); /** The pair Onshape renders for a group or an insertable; null until rendered. */ const thumbnailUrls = () => ({ @@ -45,33 +43,33 @@ const thumbnailUrls = () => ({ largeThumbnailUrl: text("large_thumbnail_url") }); -/** - * Ordered `$type` then constraints, so the column reads as what it holds before - * what is true of it; the callers add their own `.references`. - */ const libraryId = () => text("library_id").$type().notNull(); /** Null before the first successful load. Failures are conveyed by build issues. */ const lastLoadedAt = () => integer("last_loaded_at", { mode: "timestamp_ms" }); -/** - * When Onshape cut the version this row is pinned to — what the row depicts, - * rather than when we last asked. Null until the row has a real version. - */ +/** When Onshape cut the pinned version. Null until there is one. */ const versionCreatedAt = () => integer("version_created_at", { mode: "timestamp_ms" }); export const libraries = sqliteTable("libraries", { - id: text("id").primaryKey(), - cacheVersion: integer("cache_version").notNull().default(0) - // The serialized MiniSearch index now lives in R2 (see rebuildSearchDb), - // keyed by library id, rather than in a D1 column. + id: text("id").$type().primaryKey(), + // The search index is in R2, keyed by library id; see rebuildSearchDb. + cacheVersion: integer("cache_version").notNull().default(0), + // Null until the owner sets one; until then only the owner can edit. + adminTeamId: text("admin_team_id"), + // As of the last sync, replaced whole each time. + adminTeam: text("admin_team", { mode: "json" }) + .$type() + .notNull() + .default([]), + // Holds each new version's load until an admin approves it. + approveVersions: integer("approve_versions", { mode: "boolean" }) + .notNull() + .default(false) }); -/** - * The `versionId` a group carries before a load pins a real one, so a group - * whose load failed still has a row that can be seen, deleted, and retried. - */ +/** Before a load pins a real version, so a failed group can still be retried. */ export const PLACEHOLDER_VERSION_ID = "placeholder"; export const groups = sqliteTable( @@ -86,6 +84,8 @@ export const groups = sqliteTable( documentId: text("document_id").notNull(), versionId: text("version_id").notNull(), versionCreatedAt: versionCreatedAt(), + // See `thumbnails/workspace.ts`. Null until a load has made one. + thumbnailWorkspaceId: text("thumbnail_workspace_id"), sortAlphabetically: integer("sort_alphabetically", { mode: "boolean" }) .notNull() .default(false), @@ -121,11 +121,15 @@ export const insertables = sqliteTable("insertables", { supportsFasten: integer("supports_fasten", { mode: "boolean" }) .notNull() .default(false), - // Indexes this insertable's configurations even above the auto threshold. - // User-owned; preserved across reloads. + // Set by an admin; kept across reloads. indexConfigurations: integer("index_configurations", { mode: "boolean" }) .notNull() .default(false), + // Set by an admin; kept across reloads. + excludedParameterIds: text("excluded_parameter_ids", { mode: "json" }) + .$type() + .notNull() + .default([]), versionId: text("version_id").notNull(), versionCreatedAt: versionCreatedAt(), sortOrder: integer("sort_order").notNull().default(0), @@ -137,8 +141,7 @@ export const insertables = sqliteTable("insertables", { fastenInfo: text("fasten_info", { mode: "json" }).$type(), - // The element's own part identity, probed from its defaults. Null until a - // probe succeeds; a configurable insertable left unindexed never gets one. + // Null until probed; a configurable insertable left unindexed never is. partMetadata: text("part_metadata", { mode: "json" }).$type(), @@ -146,10 +149,8 @@ export const insertables = sqliteTable("insertables", { lastLoadedAt: lastLoadedAt() }); -/** - * Split off rather than folded into `insertables` because `parameters` and - * `records` are large: inline, they would slow every scan of the library. - */ +// Its own table because `parameters` and `records` are large and would slow +// every scan of `insertables`. export const configurations = sqliteTable("configurations", { insertableId: text("insertable_id") .primaryKey() @@ -158,8 +159,7 @@ export const configurations = sqliteTable("configurations", { .$type() .notNull() .default([]), - // One record per indexed configuration. Empty unless the insertable is - // indexed; the element's own metadata lives on `insertables.partMetadata`. + // Empty unless indexed; the default part is `insertables.partMetadata`. records: text("records", { mode: "json" }) .$type() .notNull() @@ -168,23 +168,11 @@ export const configurations = sqliteTable("configurations", { export const users = sqliteTable("users", { id: text("id").primaryKey(), - theme: text("theme") - .$type() - .notNull() - .default(DEFAULT_SETTINGS.theme), - // Dead, and not droppable: SQLite cannot drop a column named in a foreign - // key, and rebuilding the table means dropping it, which D1 refuses while - // favorites point at these rows. Its default is why a user row still needs - // the default library to exist. + // Unused, but SQLite can't drop a column in a foreign key, and D1 won't + // rebuild the table while favorites reference it. libraryId: libraryId() .default(DEFAULT_LIBRARY) - .references(() => libraries.id), - // The tab last opened, which entry resumes in. Null until one is picked. - // No foreign key: only some tabs name a library row. - tabId: text("tab_id").$type(), - // The group last opened in that tab, which entry resumes in. Null for the - // tab itself; a stale one resolves to that, so it is never cleaned. - groupId: text("group_id") + .references(() => libraries.id) }); export const favorites = sqliteTable( @@ -200,15 +188,55 @@ export const favorites = sqliteTable( insertableId: text("insertable_id") .notNull() .references(() => insertables.id, { onDelete: "cascade" }), - // The selection the favorite opens with, whole and canonical like - // every stored one. Null for an insertable with nothing to configure. + // Null for an insertable with nothing to configure. defaultSelection: text("default_selection", { mode: "json" - }).$type(), + }).$type(), sortOrder: integer("sort_order").notNull().default(0), - // Null on rows predating the column: backfilling would draw a cliff - // of favorites on a day nobody favorited anything. + // Null on rows older than the column. createdAt: integer("created_at", { mode: "timestamp_ms" }) }, (t) => [unique().on(t.userId, t.libraryId, t.insertableId)] ); + +export enum WebhookSubject { + DOCUMENT = "document" +} + +/** + * One per subject. The token is stored before Onshape answers the create, + * since Onshape calls the url before then. + */ +export const onshapeWebhooks = sqliteTable( + "onshape_webhooks", + { + subject: text("subject").$type().notNull(), + // The document id. + subjectId: text("subject_id").notNull(), + webhookId: text("webhook_id"), + token: text("token").notNull().unique() + }, + (t) => [primaryKey({ columns: [t.subject, t.subjectId] })] +); + +/** + * At most one running load per group; a request meanwhile sets `rerun`. In D1 + * because concurrent writes to a KV list lose updates. + */ +export const loadJobs = sqliteTable("load_jobs", { + groupId: text("group_id") + .primaryKey() + .references(() => groups.id, { onDelete: "cascade" }), + libraryId: libraryId().references(() => libraries.id), + // Null for the moment between claiming the row and the instance existing. + instanceId: text("instance_id"), + startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), + // A plain load replacing this one keeps it forced. + forceReload: integer("force_reload", { mode: "boolean" }) + .notNull() + .default(false), + // Its load is waiting for an admin to approve the version. + awaitingApproval: integer("awaiting_approval", { mode: "boolean" }) + .notNull() + .default(false) +}); diff --git a/src/backend/db/updates.ts b/src/backend/db/updates.ts index a18f66cb3..9fa955e41 100644 --- a/src/backend/db/updates.ts +++ b/src/backend/db/updates.ts @@ -1,20 +1,13 @@ import { sql, type SQL } from "drizzle-orm"; import { type SQLiteColumn } from "drizzle-orm/sqlite-core"; -/** - * Expressions for the `set` of an update or upsert, where the new value is - * built from the stored one. Drizzle ships no equivalent, so they live here. - */ +// Updates computed from the stored value, which Drizzle has no helper for. -/** `column + by`, for a row that counts rather than replaces. */ export function increment(column: SQLiteColumn, by: number | SQL = 1): SQL { return sql`${column} + ${by}`; } -/** - * Bounds rather than assignment, so a write arriving out of order still leaves - * the true first and last. Spelled in ms: a raw `sql` fragment has no codec. - */ +/** Bounds, so out-of-order writes keep the true first and last. In ms, since raw `sql` has no codec. */ export function earliest(column: SQLiteColumn, value: Date): SQL { return sql`min(${column}, ${value.getTime()})`; } diff --git a/src/backend/features/admin-team/contract.ts b/src/backend/features/admin-team/contract.ts new file mode 100644 index 000000000..2fc7a9ca7 --- /dev/null +++ b/src/backend/features/admin-team/contract.ts @@ -0,0 +1,12 @@ +/** What the owner sees of a library's admin team. */ +export interface AdminTeamOut { + /** Absent until the owner sets one. */ + teamId?: string; + memberCount: number; +} + +export interface AdminTeamMember { + userId: string; + /** An admin of the Onshape team, which makes them an admin of the library. */ + isTeamAdmin: boolean; +} diff --git a/src/backend/features/admin-team/routes.ts b/src/backend/features/admin-team/routes.ts new file mode 100644 index 000000000..8734e64dd --- /dev/null +++ b/src/backend/features/admin-team/routes.ts @@ -0,0 +1,102 @@ +import { eq } from "drizzle-orm"; +import { HttpStatus } from "http-status-ts"; +import { z } from "zod"; +import { getApp } from "../../lib/context"; +import { handledError } from "../../lib/api-error"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { validate } from "../../lib/validate"; +import { type Db, getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import { + requireAdminMiddleware, + requireEditorMiddleware, + requireOwnerMiddleware +} from "../auth/guards"; +import { ensureLibrary } from "../library/db"; +import type { LibraryId } from "../library/library-id"; +import type { AdminTeamOut } from "./contract"; +import { syncAdminTeam } from "./sync"; + +export const adminTeamRoutes = getApp(); + +const setAdminTeamBody = z.object({ + /** Null takes the team away, leaving only the owner able to edit. */ + teamId: z.string().trim().min(1).nullable() +}); + +async function getAdminTeam( + db: Db, + libraryId: LibraryId +): Promise { + const library = await db + .select({ + teamId: libraries.adminTeamId, + members: libraries.adminTeam + }) + .from(libraries) + .where(eq(libraries.id, libraryId)) + .get(); + return { + teamId: library?.teamId ?? undefined, + memberCount: library?.members.length ?? 0 + }; +} + +/** GET /api/admin-team/library/:libraryId */ +adminTeamRoutes.get( + "/admin-team" + libraryRoute(), + requireEditorMiddleware, + async (c) => c.json(await getAdminTeam(getDb(c.env.DB), getLibraryParam(c))) +); + +/** POST /api/admin-team/library/:libraryId: sets the team and pulls its members. */ +adminTeamRoutes.post( + "/admin-team" + libraryRoute(), + requireOwnerMiddleware, + validate("json", setAdminTeamBody), + async (c) => { + const libraryId = getLibraryParam(c); + const { teamId } = c.req.valid("json"); + const db = getDb(c.env.DB); + const onshapeApi = await c.var.getOnshapeApi(); + + await ensureLibrary(db, libraryId); + const previous = (await getAdminTeam(db, libraryId)).teamId; + const setTeam = (adminTeamId: string | undefined) => + db + .update(libraries) + // Drizzle skips an undefined field, so clearing takes null. + .set({ adminTeamId: adminTeamId ?? null }) + .where(eq(libraries.id, libraryId)); + + await setTeam(teamId ?? undefined); + try { + await syncAdminTeam(c.env, onshapeApi, libraryId); + } catch (error) { + // Usually a typo, so keep the team that worked. + await setTeam(previous); + console.error(`Failed to read team ${teamId}`, error); + throw handledError( + "Couldn't read that team's members from Onshape. Check the team id.", + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + + return c.json(await getAdminTeam(db, libraryId)); + } +); + +/** + * POST /api/admin-team/refresh/library/:libraryId: pulls the team's members + * again. Onshape's team webhooks need a company, which a personal account + * lacks, so a change in Onshape waits for this. + */ +adminTeamRoutes.post( + "/admin-team/refresh" + libraryRoute(), + requireAdminMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + await syncAdminTeam(c.env, await c.var.getOnshapeApi(), libraryId); + return c.json(await getAdminTeam(getDb(c.env.DB), libraryId)); + } +); diff --git a/src/backend/features/admin-team/routes.worker.test.ts b/src/backend/features/admin-team/routes.worker.test.ts new file mode 100644 index 000000000..4b393b4da --- /dev/null +++ b/src/backend/features/admin-team/routes.worker.test.ts @@ -0,0 +1,132 @@ +import { env } from "cloudflare:workers"; +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TEST_LIBRARY_ID, + createTestApp, + jsonRequest, + resetDb, + seedLibrary +} from "../../../__test_utils__"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import { OnshapeApiError } from "../../lib/onshape/client"; +import { AccessLevel } from "../auth/access-level"; + +const db = getDb(env.DB); +const PATH = `/api/admin-team/library/${TEST_LIBRARY_ID}`; + +/** Onshape, with a team of one member and one admin. */ +function mockOnshape() { + const onshapeApi = new MockOnshapeApi(); + vi.spyOn(onshapeApi, "get").mockImplementation((path: string) => { + if (path === "/users/sessioninfo") { + return Promise.resolve({ id: "owner", company: { id: "company" } }); + } + if (path === "/teams/team/members") { + return Promise.resolve({ + items: [ + { admin: false, member: { id: "member" } }, + { admin: true, member: { id: "team-admin" } } + ] + }); + } + return Promise.reject(new OnshapeApiError("no such team", 404)); + }); + return { onshapeApi }; +} + +function setTeam(teamId: string | null, onshapeApi: MockOnshapeApi) { + return createTestApp({ + accessLevel: AccessLevel.OWNER, + onshapeApi + }).request(PATH, jsonRequest("POST", { teamId }), env); +} + +describe("setting a library's admin team", () => { + beforeEach(async () => { + await resetDb(db); + await seedLibrary(db); + }); + afterEach(() => vi.restoreAllMocks()); + + it("is the owner's alone", async () => { + const res = await createTestApp({ + accessLevel: AccessLevel.ADMIN + }).request(PATH, jsonRequest("POST", { teamId: "team" }), env); + expect(res.status).toBe(403); + }); + + it("stores the team's members", async () => { + const { onshapeApi } = mockOnshape(); + + const res = await setTeam("team", onshapeApi); + + expect(await res.json()).toEqual({ teamId: "team", memberCount: 2 }); + const library = await db + .select({ adminTeam: libraries.adminTeam }) + .from(libraries) + .where(eq(libraries.id, TEST_LIBRARY_ID)) + .get(); + expect(library?.adminTeam).toEqual([ + { userId: "member", isTeamAdmin: false }, + { userId: "team-admin", isTeamAdmin: true } + ]); + }); + + // A mistyped id should not lock everyone but the owner out. + it("keeps the team that was working when the new one cannot be read", async () => { + const { onshapeApi } = mockOnshape(); + await setTeam("team", onshapeApi); + + const res = await setTeam("typo", onshapeApi); + + expect(res.status).toBe(422); + const library = await db + .select({ adminTeamId: libraries.adminTeamId }) + .from(libraries) + .where(eq(libraries.id, TEST_LIBRARY_ID)) + .get(); + expect(library?.adminTeamId).toBe("team"); + }); + + it("takes the team away", async () => { + const { onshapeApi } = mockOnshape(); + await setTeam("team", onshapeApi); + + const res = await setTeam(null, onshapeApi); + + expect(await res.json()).toEqual({ memberCount: 0 }); + }); +}); + +describe("refreshing a library's admin team", () => { + const REFRESH_PATH = `/api/admin-team/refresh/library/${TEST_LIBRARY_ID}`; + + beforeEach(async () => { + await resetDb(db); + await seedLibrary(db); + await db + .update(libraries) + .set({ adminTeamId: "team" }) + .where(eq(libraries.id, TEST_LIBRARY_ID)); + }); + afterEach(() => vi.restoreAllMocks()); + + const refresh = (accessLevel: AccessLevel) => + createTestApp({ + accessLevel, + onshapeApi: mockOnshape().onshapeApi + }).request(REFRESH_PATH, jsonRequest("POST"), env); + + it("pulls the members again for an admin", async () => { + const res = await refresh(AccessLevel.ADMIN); + + expect(await res.json()).toEqual({ teamId: "team", memberCount: 2 }); + }); + + it("turns away an editor", async () => { + expect((await refresh(AccessLevel.EDITOR)).status).toBe(403); + }); +}); diff --git a/src/backend/features/admin-team/sync.ts b/src/backend/features/admin-team/sync.ts new file mode 100644 index 000000000..3fc13d35f --- /dev/null +++ b/src/backend/features/admin-team/sync.ts @@ -0,0 +1,38 @@ +/** Access is read from the stored team, and a sync bumps the library version so clients refresh. */ +import { eq } from "drizzle-orm"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import type { OnshapeApi } from "../../lib/onshape/client"; +import { getTeamMembers } from "../../lib/onshape/endpoints/teams"; +import { bumpLibraryVersion } from "../library/db"; +import type { LibraryId } from "../library/library-id"; +import { pushLibraryChanged } from "../push/notify"; + +export async function syncAdminTeam( + env: AppBindings, + onshapeApi: OnshapeApi, + libraryId: LibraryId +): Promise { + const db = getDb(env.DB); + const library = await db + .select({ adminTeamId: libraries.adminTeamId }) + .from(libraries) + .where(eq(libraries.id, libraryId)) + .get(); + const teamId = library?.adminTeamId; + const members = teamId ? await getTeamMembers(onshapeApi, teamId) : []; + + await db + .update(libraries) + .set({ + adminTeam: members.map((member) => ({ + userId: member.member.id, + isTeamAdmin: member.admin + })) + }) + .where(eq(libraries.id, libraryId)); + + await bumpLibraryVersion(db, libraryId); + await pushLibraryChanged(env, libraryId); +} diff --git a/src/backend/features/analytics/contract.ts b/src/backend/features/analytics/contract.ts index 4880d9b0f..4be92f73d 100644 --- a/src/backend/features/analytics/contract.ts +++ b/src/backend/features/analytics/contract.ts @@ -3,10 +3,7 @@ import { ElementType } from "../../lib/onshape/element-type"; import type { ElementPath } from "../../lib/onshape/path"; import { InsertSource } from "./usage"; -/** - * Inserts by the kind of tab they landed in. Every type is listed, so a tab - * nobody inserts into reads as a zero rather than a missing key. - */ +/** Every type is listed, so an unused one reads as zero. */ export type InsertTargets = Record; export function emptyTargets(): InsertTargets { @@ -23,16 +20,10 @@ export interface AnalyticsTotals { /** Subsets of `inserts`; divide by it for the percentages. */ favoriteInserts: number; quickInserts: number; - /** - * Insert-and-fasten, which Onshape only offers on an assembly target — so - * its denominator is the assembly entry of `targets`, not `inserts`. - */ + /** Onshape only offers fasten on assemblies, so compare to that entry of `targets`. */ fastenInserts: number; targets: InsertTargets; - /** - * Favorites standing right now, not over the range: a favorite is state a - * user keeps, not an event, so it has no day to be windowed by. - */ + /** Current, not over the range: a favorite is state, not an event. */ favorites: number; } @@ -42,10 +33,7 @@ export interface DailyInsertPoint { counts: Partial>; } -/** - * One day of every tracked metric, as raw counts. One series backs every trend, - * so a tile and the chart behind it cannot disagree. - */ +/** One series backs every trend, so a tile and its chart can't disagree. */ export interface DailyMetricPoint { day: string; inserts: number; @@ -67,10 +55,7 @@ export interface InsertSourceUsage { quickInsertCount: number; } -/** - * Severity counts are of issues, not items — one part with three warnings is - * three — while `healthyItems` counts items, as the two counts above it do. - */ +/** Severity counts are of issues; `healthyItems` counts items. */ export interface LibraryHealthCounts { groupCount: number; insertableCount: number; @@ -85,10 +70,7 @@ export interface LibrarySummary { health: LibraryHealthCounts; } -/** - * Why a change cannot be stated: an unmeasured baseline, two empty windows, or - * an empty baseline, which reads as new rather than infinite. - */ +/** An empty baseline reads as new rather than infinite. */ export enum ChangeUnavailable { NO_PRIOR_DATA = "no-prior-data", PARTIAL_PRIOR_DATA = "partial-prior-data", @@ -125,14 +107,10 @@ export interface GrowthOut { season: Record; } -/** - * What one library's page reads. Narrower than the app overview it used to - * share a type with, which had every library page paying for unopened fields. - */ export interface LibrarySummaryOut { - /** Lifetime, for the headline cards. */ + /** Over the requested range, for the headline cards. */ totals: AnalyticsTotals; - /** Scoped to the requested range, for the chart and the sparklines. */ + /** Also over the range, for the chart and the sparklines. */ metricSeries: DailyMetricPoint[]; growth: GrowthOut; from: string; @@ -154,6 +132,7 @@ export interface AnalyticsOverviewOut { /** A row of the parts table. Only parts still in the library are listed. */ export interface PartUsageOut { + libraryId: LibraryId; /** The version-pinned tab, which is both the analytics key and the link. */ path: ElementPath; name: string; @@ -164,8 +143,7 @@ export interface PartUsageOut { insertCount: number; /** The window's inserts scaled to a month; see {@link usesPerMonth}. */ usesPerMonth: number; - /** Daily inserts over a trailing {@link MONTH_DAYS}, oldest first: a - * shape rather than the reported window, which can be years of smear. */ + /** Daily inserts over the last {@link MONTH_DAYS}, oldest first. */ recent: number[]; } @@ -176,49 +154,33 @@ export interface ConfigurationValueUsage { label: string; count: number; isDefault: boolean; - /** - * The option the app lands on here, the declared default not being offered - * in this instance; see `resolveSelectedOption`. - */ + /** The declared default isn't offered here; see `resolveSelectedOption`. */ isImplicitDefault?: boolean; } -/** - * One parameter as it is shown under one set of controlling choices. A list - * another choice filters has an entry per branch, so the options in each are - * only the ones that branch offers. - */ +/** One parameter under one set of controlling choices; see `instances.ts`. */ export interface ConfigurationParameterUsage { parameterId: string; name: string; type: string; defaultValue?: string; - /** - * The controlling choices this instance is shown under, outermost first — - * ["Generic"] for the list a Generic vendor offers. Empty when nothing - * conditions the parameter. - */ + /** Outermost first, e.g. ["Generic"]; empty when unconditioned. */ path: string[]; /** Recorded values counted here, the base for percentages. */ total: number; values: ConfigurationValueUsage[]; } -/** - * One declared enum option and how often it was chosen. Only an enum declares - * its options, so only an enum can have one nobody picked. - */ +/** Only an enum declares its options, so only an enum can have an unused one. */ export interface UnusedOptionOut { path: ElementPath; partName: string; parameterId: string; parameterName: string; - /** The choices the parameter is shown under; see - * {@link ConfigurationParameterUsage.path}. */ + /** See {@link ConfigurationParameterUsage.path}. */ parameterPath: string[]; value: ConfigurationValueUsage; - /** Every recorded value for this parameter, which the count is a - * fraction of. */ + /** Every recorded value for this parameter. */ parameterTotal: number; } diff --git a/src/backend/features/analytics/day.test.ts b/src/backend/features/analytics/day.test.ts index e364a5cd7..4b554695a 100644 --- a/src/backend/features/analytics/day.test.ts +++ b/src/backend/features/analytics/day.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; import { addDays, toDayKey, toReportingDay } from "./day"; describe("toDayKey", () => { - // The whole point of reporting in a fixed US zone: an evening build session - // is one day's work, and UTC would split it across two. it("keeps a US evening on the day it was worked", () => { // 10pm Eastern on 10 Sep, which UTC already calls the 11th. expect(toDayKey(Date.parse("2026-09-11T02:00:00Z"))).toBe("2026-09-10"); @@ -32,8 +30,6 @@ describe("addDays", () => { expect(addDays("2026-12-31", 1)).toBe("2027-01-01"); }); - // A day key is a calendar date, so the spring-forward day is still one day - // wide even though the zone's day is 23 hours long. it("steps one day across a DST transition", () => { expect(addDays("2026-03-07", 1)).toBe("2026-03-08"); expect(addDays("2026-03-08", 1)).toBe("2026-03-09"); diff --git a/src/backend/features/analytics/day.ts b/src/backend/features/analytics/day.ts index 3bcbaa55a..39e9bfda8 100644 --- a/src/backend/features/analytics/day.ts +++ b/src/backend/features/analytics/day.ts @@ -1,17 +1,9 @@ -/** - * The day key every rollup is keyed on, and the window a read covers. - * - * Imported by both sides, so it stays free of anything Worker-only. - */ +/** Imported by both sides, so nothing Worker-only. */ -/** - * US teams work evenings, and a UTC midnight cuts that session in half: 8pm - * Eastern is already tomorrow. Fixed, so an insert lands on one day for everyone. - */ +/** US teams work evenings, which UTC midnight would split. */ const REPORTING_TIME_ZONE = "America/New_York"; -// en-CA formats as YYYY-MM-DD, which is the shape day keys are compared as. -// Built once: constructing a formatter per call is the expensive part. +// en-CA gives YYYY-MM-DD. Built once, since construction is the expensive part. const dayFormat = new Intl.DateTimeFormat("en-CA", { timeZone: REPORTING_TIME_ZONE, year: "numeric", @@ -24,20 +16,13 @@ export function toDayKey(timestamp: number): string { return dayFormat.format(timestamp); } -/** - * Steps a day key by whole days. A key is a calendar date, not an instant, so - * parsing at UTC midnight keeps every step 24 hours — a DST zone would not. - */ +/** Parsed at UTC midnight so every step is 24 hours. */ export function addDays(day: string, count: number): string { const at = Date.parse(`${day}T00:00:00Z`) + count * 24 * 3600 * 1000; return new Date(at).toISOString().slice(0, 10); } -/** - * The last day a report covers: yesterday, since today is still filling. Ending - * a series on a part-finished day puts a dip at the right of every chart that - * recovers by the next morning. - */ +/** Yesterday: today is still filling, and would dip every chart. */ export function toReportingDay(timestamp: number): string { return addDays(toDayKey(timestamp), -1); } diff --git a/src/backend/features/analytics/growth.ts b/src/backend/features/analytics/growth.ts index bbaf15366..900dd250d 100644 --- a/src/backend/features/analytics/growth.ts +++ b/src/backend/features/analytics/growth.ts @@ -24,22 +24,14 @@ interface Window { to: string; } -/** - * The two trailing windows to compare, ending on the last reported day: a - * part-finished today would manufacture a decline every morning that recovers - * by evening. - */ +/** Ends on the last reported day, since a partial today reads as a decline. */ export function recentWindows(through: string): { current: Window; previous: Window; } { - // A month back from it, inclusive: the -1 is what makes the span the count - // of days rather than one more than it. const to = through; const from = addDays(to, -(MONTH_DAYS - 1)); - // The equal window immediately before, ending the day before `from`, so the - // two are the same length and share no day. return { current: { from, to }, previous: { @@ -49,10 +41,7 @@ export function recentWindows(through: string): { }; } -/** - * Withholds the percentage when the baseline reaches back past the day tracking - * started: that window is empty for want of recording, not of activity. - */ +/** Withheld when the baseline predates tracking, since it's empty for want of data. */ export function toComparison( current: number, previous: number, @@ -80,8 +69,7 @@ export function toComparison( return { ...base, unavailable: ChangeUnavailable.PARTIAL_PRIOR_DATA }; } if (previous === 0) { - // Both empty is a quiet stretch, not a gap in what was recorded — the - // difference decides whether the UI blames tracking or the period. + // Lets the UI tell a quiet stretch from missing data. return { ...base, unavailable: @@ -126,10 +114,7 @@ async function countEvents( }; } -/** - * Two queries rather than one: a COUNT(DISTINCT) cannot be split by a CASE, and - * someone active in both windows counts once in each. - */ +/** Two queries: COUNT(DISTINCT) can't be split by a CASE. */ async function countPeople( db: Db, windows: { current: Window; previous: Window }, @@ -157,10 +142,6 @@ async function countPeople( return { current: current?.value ?? 0, previous: previous?.value ?? 0 }; } -/** - * Measures one window pair three ways, so a set of comparisons is built from a - * single description of what to compare. - */ async function measure( db: Db, windows: { current: Window; previous: Window }, @@ -191,11 +172,8 @@ async function measure( } /** - * Growth for a library, or for the app when no library is given. The app spans - * Sept–Apr, which covers FRC's Jan–Apr, so its season is named without a program. - * - * `through` is the last complete day, not today: every window here ends on it, - * so a season to date is not half a day short of the stretch it is compared to. + * For the app when no library is given. The app's season spans Sept–Apr, which + * covers FRC's Jan–Apr, so it's named without a program. */ export async function getGrowth( db: Db, diff --git a/src/backend/features/analytics/growth.test.ts b/src/backend/features/analytics/growth.worker.test.ts similarity index 91% rename from src/backend/features/analytics/growth.test.ts rename to src/backend/features/analytics/growth.worker.test.ts index 53bf10d6b..4ebd38d5a 100644 --- a/src/backend/features/analytics/growth.test.ts +++ b/src/backend/features/analytics/growth.worker.test.ts @@ -10,10 +10,7 @@ import { getGrowth, recentWindows, toComparison } from "./growth"; const db = getDb(env.DB); -/** - * The last complete day, which is what the routes report through. Late August: - * outside both seasons, so season comparisons use whole ones. - */ +/** Late August, outside both seasons. */ const THROUGH = "2026-08-26"; async function seedInserts( @@ -64,8 +61,7 @@ describe("toComparison", () => { }); it("withholds a change when the baseline predates tracking", () => { - // The whole prior window is before anything was recorded, so its zero - // means "not measured", not "nothing happened". + // The prior window predates tracking, so its zero means "not measured". const out = toComparison(120, 0, WINDOWS, LABELS, "2026-08-01"); expect(out.changeRatio).toBeUndefined(); expect(out.unavailable).toBe(ChangeUnavailable.NO_PRIOR_DATA); @@ -124,8 +120,6 @@ describe("getGrowth", () => { }); it("compares whole seasons when the window falls between them", async () => { - // August is off-season, and app-wide seasons run Sept-Apr so both - // competitions are covered by one window. await seedInserts("2026-03-01", 100); await seedInserts("2025-03-01", 50); @@ -141,8 +135,7 @@ describe("getGrowth", () => { }); it("counts an FTC-only autumn the app-wide season would miss on FRC", async () => { - // October is inside Sept-Apr but outside FRC's Jan-Apr, so measuring - // the app on FRC's span would drop this entirely. + // October is outside FRC's Jan–Apr, so measuring on FRC's span would drop it. await seedInserts("2025-10-15", 40); const growth = await getGrowth(db, THROUGH, "2024-09-01"); @@ -168,8 +161,7 @@ describe("getGrowth", () => { }); it("clips an in-season baseline to the same elapsed stretch", async () => { - // 1 Feb is 154 days into a Sept-Apr season. Last season ran on past - // that point, and only the part inside it may be compared against. + // Only the part of last season up to the same point may be compared. await seedInserts("2027-01-15", 25); await seedInserts("2026-01-15", 40); await seedInserts("2026-03-15", 20); diff --git a/src/backend/features/analytics/health.test.ts b/src/backend/features/analytics/health.test.ts index 4201a42cc..b6f043379 100644 --- a/src/backend/features/analytics/health.test.ts +++ b/src/backend/features/analytics/health.test.ts @@ -18,8 +18,7 @@ describe("summarizeHealth", () => { ] }, { - // Info-only, so it leaves the item unhealthy without - // landing on either tile. + // Info-only: unhealthy, but on neither tile. buildIssues: [{ type: BuildIssueType.NO_THUMBNAIL_TAB }] } ] @@ -52,8 +51,6 @@ describe("summarizeHealth", () => { }); it("reports a group's stored issues without recomputing any", () => { - // Visibility checks now run in the workflow and on the visibility - // toggle, so this only reads what they wrote. const counts = summarizeHealth( [ { diff --git a/src/backend/features/analytics/health.ts b/src/backend/features/analytics/health.ts index b2ddd0189..3b45e9de9 100644 --- a/src/backend/features/analytics/health.ts +++ b/src/backend/features/analytics/health.ts @@ -1,7 +1,3 @@ -/** - * The library's build health, counted rather than listed: which items carry an - * issue, and how severe the worst one is. - */ import { and, eq } from "drizzle-orm"; import { type Db } from "../../db/client"; import { groups, insertables } from "../../db/schema"; @@ -14,10 +10,7 @@ import { type BuildIssue } from "../build-checker/issues"; -/** - * Hidden insertables are exempt from the build checks, so the health report - * leaves them out entirely rather than counting them as healthy. - */ +/** Hidden insertables skip the build checks, so they aren't counted at all. */ function visibleIn(libraryId: LibraryId) { return and( eq(insertables.libraryId, libraryId), @@ -49,10 +42,7 @@ export async function getHealthCounts( return summarizeHealth(allGroups, allInsertables); } -/** - * Hidden insertables are filtered upstream: exempt from the checks, so never - * healthy. - */ +/** Expects hidden insertables already filtered out. */ export function summarizeHealth( groups: { buildIssues: BuildIssue[] }[], insertables: { buildIssues: BuildIssue[] }[] @@ -66,7 +56,7 @@ export function summarizeHealth( }; const record = (issues: BuildIssue[]) => { - if (getMaxSeverity(issues) === null) { + if (getMaxSeverity(issues) === undefined) { counts.healthyItems++; return; } @@ -78,8 +68,7 @@ export function summarizeHealth( case BuildIssueSeverity.WARNING: counts.warningCount++; break; - // Info issues are counted by neither tile, so they only have - // to leave the item healthy-or-not, which `record` already did. + // Counted by neither tile. case BuildIssueSeverity.INFO: break; } diff --git a/src/backend/features/analytics/logged-event.ts b/src/backend/features/analytics/logged-event.ts index 1611f1284..931c0271a 100644 --- a/src/backend/features/analytics/logged-event.ts +++ b/src/backend/features/analytics/logged-event.ts @@ -1,7 +1,4 @@ -/** - * The log's rows, read as the events they are. `events` is one wide table, so - * this is the one place that knows which columns a given kind sets. - */ +/** The one place that knows which columns each kind of event sets. */ import { type ElementType } from "../../lib/onshape/element-type"; import { EventType, type InsertSource } from "./usage"; import { type LoggedEvent } from "./schema"; @@ -21,10 +18,7 @@ export type EventCore = Pick< /** The rest, which only an insert fills in. */ type InsertColumns = Omit; -/** - * Spelled out rather than defaulted: a column added to the log stops compiling - * here until someone says what a non-insert records for it. - */ +/** Spelled out, so a new column fails to compile until someone decides its value here. */ export const NOT_AN_INSERT: InsertColumns = { elementId: null, documentId: null, @@ -46,15 +40,12 @@ export type LoggedInsert = LoggedEvent & { source: InsertSource; }; -/** - * Null for another kind, and for an insert whose columns disagree with its type — - * a row from a version that did not set them, worth reading past not crashing on. - */ -export function asInsert(event: LoggedEvent): LoggedInsert | null { +/** Undefined for another kind, or an insert from a version that didn't set these columns. */ +export function asInsert(event: LoggedEvent): LoggedInsert | undefined { const isInsert = event.type === EventType.INSERT && event.elementId !== null && event.targetElementType !== null && event.source !== null; - return isInsert ? (event as LoggedInsert) : null; + return isInsert ? (event as LoggedInsert) : undefined; } diff --git a/src/backend/features/analytics/measures.ts b/src/backend/features/analytics/measures.ts index f8b491c4f..33b9fed67 100644 --- a/src/backend/features/analytics/measures.ts +++ b/src/backend/features/analytics/measures.ts @@ -1,19 +1,9 @@ -/** - * The windows the dashboard reports over, and the one rate it derives. - * - * Imported by both sides, so it stays free of anything Worker-only. - */ +/** Imported by both sides, so nothing Worker-only. */ -/** - * What "a month" means throughout: the recent comparison window, a sparkline's - * days, and the span a rate scales to. The dashboard titles itself from it. - */ +/** The comparison window, a sparkline's length, and the span rates scale to. */ export const MONTH_DAYS = 30; -/** - * Inserts per month, so a new part is not buried under an old one. The span is - * floored at a month, or a first week of 2 extrapolates to 60. - */ +/** Floored at a month, or a first week of 2 extrapolates to 60. */ export function usesPerMonth( insertCount: number, firstInsertedAt: number | undefined, diff --git a/src/backend/features/analytics/metric-queries.ts b/src/backend/features/analytics/metric-queries.ts index 641cfa645..5c7043383 100644 --- a/src/backend/features/analytics/metric-queries.ts +++ b/src/backend/features/analytics/metric-queries.ts @@ -1,7 +1,3 @@ -/** - * Reads of the library-wide rollups: totals, the day series behind the charts, - * and where inserts started from. - */ import { and, asc, @@ -39,7 +35,7 @@ import { type DayRange } from "./day"; import { eachDay } from "./range"; import { getHealthCounts } from "./health"; -/** Lifetime totals, optionally scoped to one library and to a window. */ +/** Lifetime totals, or scoped to one library or a window. */ export async function getTotals( db: Db, libraryId?: LibraryId, @@ -67,10 +63,6 @@ export async function getTotals( }; } -/** - * The scope every rollup read takes: one library or all of them, one window or - * all of time. The tables differ only in which columns carry the two. - */ function scopeFilters( columns: { day: SQLiteColumn; libraryId: SQLiteColumn }, libraryId?: LibraryId, @@ -129,10 +121,7 @@ export function toTargets( return targets; } -/** - * `user_stats` holds one row per user for all time and so cannot be windowed; - * a range counts the per-day activity rollup instead. - */ +/** `user_stats` is all-time, so a range counts the daily rollup. */ function countUsers(db: Db, libraryId?: LibraryId, range?: DayRange) { if (range) { return db @@ -214,11 +203,7 @@ function countTargetsByDay(db: Db, range: DayRange, libraryId?: LibraryId) { .all(); } -/** - * DISTINCT, not COUNT: the table holds one row per user *per library* per day, - * so an unscoped read counts someone active in two libraries twice — and would - * then disagree with getTotals, which counts distinct. - */ +/** DISTINCT: rows are per library, so COUNT would count a user in two libraries twice. */ function countUsersByDay(db: Db, range: DayRange, libraryId?: LibraryId) { return db .select({ @@ -231,10 +216,7 @@ function countUsersByDay(db: Db, range: DayRange, libraryId?: LibraryId) { .all(); } -/** - * Every metric's daily values as one series, read from rollups: the event log - * grows with every insert rather than with the range. - */ +/** From rollups, since the event log grows with every insert. */ export async function getMetricSeries( db: Db, range: DayRange, @@ -284,8 +266,7 @@ export async function getMetricSeries( pointFor(row.day).activeUsers = row.activeUsers; } - // A quiet day is a zero, not a missing point: anything dividing by the - // number of points would average over active days instead of calendar ones. + // A quiet day is a zero, so averages are over calendar days. for (const day of eachDay(range)) pointFor(day); return [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)); @@ -365,8 +346,7 @@ export async function getSeries( point.counts[row.libraryId] = row.count; byDay.set(row.day, point); } - // Same reason as the metric series: a missing day is a zero, and a line - // that jumps across it reads as activity that never happened. + // A missing day is a zero, or the line jumps across it. for (const day of eachDay(range)) { if (!byDay.has(day)) byDay.set(day, { day, counts: {} }); } diff --git a/src/backend/features/analytics/parameter-usage.test.ts b/src/backend/features/analytics/parameter-usage.test.ts index 10e251605..e400b0462 100644 --- a/src/backend/features/analytics/parameter-usage.test.ts +++ b/src/backend/features/analytics/parameter-usage.test.ts @@ -27,7 +27,6 @@ describe("buildParameterUsage", () => { id: "size", name: "Size", default: "medium", - isCosmetic: false, options: [ { id: "small", name: "Small" }, { id: "medium", name: "Medium" }, @@ -46,8 +45,6 @@ describe("buildParameterUsage", () => { const small = usage.values.find((value) => value.value === "small"); expect(small).toMatchObject({ count: 0, label: "Small" }); - // The default is flagged even though it isn't the popular choice — - // which is the whole point of the report. const medium = usage.values.find((value) => value.value === "medium"); expect(medium?.isDefault).toBe(true); expect(usage.values[0].value).toBe("large"); @@ -247,8 +244,7 @@ describe("buildParameterUsage", () => { type: ParameterType.STRING, id: "label", name: "Label", - default: "none", - isCosmetic: false + default: "none" } ], [count("label", "custom", 2)] diff --git a/src/backend/features/analytics/parameter-usage.ts b/src/backend/features/analytics/parameter-usage.ts index 04a8cbc86..b559f736c 100644 --- a/src/backend/features/analytics/parameter-usage.ts +++ b/src/backend/features/analytics/parameter-usage.ts @@ -1,13 +1,9 @@ -/** - * What a part's recorded configuration values say, merged with the parameters - * it declares today — once per way each parameter is shown. - */ import { ParameterType, type ConfigurationParameter } from "../configurations/contract"; import { toParameterInstances } from "../configurations/instances"; -import { formatValue } from "../configurations/selection"; +import { canonicalValue, formatValue } from "../configurations/selection"; import type { ConfigurationParameterUsage, ConfigurationValueUsage @@ -26,14 +22,9 @@ export interface ValueCount { } /** - * Merged with the parameters declared today, so an unused option still surfaces - * and a retired one is dropped: nobody can pick it any more. - * - * One entry per instance rather than per parameter — a list another choice - * filters is a different list under each — and each counts only the rows keyed - * to a branch it covers. A row recorded before those keys were written belongs - * to no branch, so it counts only where the parameter is reported whole; a - * rebuild from the event log is what attributes the rest. + * Against today's parameters, so unused options show and retired ones drop. + * One entry per instance, counting only the rows keyed to a branch it covers. + * Rows from before keys were written count only where it is reported whole. */ export function buildParameterUsage( parameters: ConfigurationParameter[], @@ -61,7 +52,12 @@ export function buildParameterUsage( isImplicitDefault: true }) })) - : toFreeFormValues(counts, parameter, parameter.default); + : // Counted canonically, so the default is looked up that way too. + toFreeFormValues( + counts, + parameter, + canonicalValue(parameter, parameter.default) + ); return { parameterId: parameter.id, @@ -69,19 +65,14 @@ export function buildParameterUsage( type: parameter.type, defaultValue: parameter.default, path: instance.path.map((step) => step.label), - // An instance's own options, so its percentages add to a hundred; - // a free-form list is truncated, so its total stays the true one. + // An enum's percentages add to 100; a free-form list is truncated, so it keeps the true total. total: isEnum ? sumValues(values) : sumCounts(counts), values: values.sort((a, b) => b.count - a.count) }; }); } -/** - * One parameter's counts by value, narrowed to the branches `keys` names. An - * absent `keys` is a parameter reported whole, which every branch counts - * towards — including the empty one older rows carry. - */ +/** Narrowed to the branches `keys` names; absent keys count every branch. */ function countsFor( rows: ValueCount[] | undefined, keys: string[] | undefined @@ -95,10 +86,7 @@ function countsFor( return counts; } -/** - * Unbounded distinct values, so only the most-used are returned, plus the - * default. Labelled in the parameter's unit; nobody reads a tube length in metres. - */ +/** The most-used values plus the default, in the parameter's unit. */ function toFreeFormValues( counts: Map, parameter: ConfigurationParameter, diff --git a/src/backend/features/analytics/part-queries.ts b/src/backend/features/analytics/part-queries.ts index c88080d42..82fd8ca35 100644 --- a/src/backend/features/analytics/part-queries.ts +++ b/src/backend/features/analytics/part-queries.ts @@ -1,7 +1,3 @@ -/** - * Reads of the per-part rollups: what a part was used for inside a window, and - * how that lands day by day. - */ import { and, count, countDistinct, eq, gte, lte, sum } from "drizzle-orm"; import { type Db } from "../../db/client"; import { @@ -24,6 +20,7 @@ import { toElementPath } from "../../lib/onshape/path"; import { type ConfigurationParameter } from "../configurations/contract"; export interface PartRow { + libraryId: LibraryId; elementId: string; name: string; groupName: string; @@ -33,11 +30,7 @@ export interface PartRow { firstInsertedAt: Date | null; } -/** - * Every part the library still lists, with the date it was first inserted. - * Driven off `insertables` rather than the stats table, so a part nobody has - * used lists at zero and one that has left the library does not list at all. - */ +/** From `insertables`, so unused parts list at zero and removed ones don't list. */ export function getPartRows( db: Db, libraryId: LibraryId, @@ -50,6 +43,7 @@ export function getPartRows( return ( db .select({ + libraryId: insertables.libraryId, elementId: insertables.elementId, name: insertables.name, groupName: groups.name, @@ -73,7 +67,6 @@ export function getPartRows( ); } -/** One part counted over the window rather than over its whole history. */ export function toWindowedPart( row: PartRow, windowed: Map, @@ -83,11 +76,11 @@ export function toWindowedPart( const from = Date.parse(`${range.from}T00:00:00Z`); const to = Math.min(Date.now(), Date.parse(`${range.to}T23:59:59Z`)); const insertCount = windowed.get(row.elementId) ?? 0; - // Rated over the days the part has existed, so arriving late in the window - // does not read as unpopular. + // Over the days it existed, so arriving late doesn't read as unpopular. const firstUsed = Math.max(row.firstInsertedAt?.getTime() ?? from, from); return { + libraryId: row.libraryId, path: toElementPath(row), name: row.name, groupName: row.groupName, @@ -102,10 +95,7 @@ export function toWindowedPart( }; } -/** - * Inserts per element inside the window, folded out of the daily rollup — - * a range scan, thanks to `daily_insertable_metrics_day_idx`. - */ +/** A range scan on `daily_insertable_metrics_day_idx`. */ export async function getWindowedInsertCounts( db: Db, libraryId: LibraryId, @@ -186,11 +176,7 @@ function emptySparkline(): number[] { return Array.from({ length: MONTH_DAYS }, () => 0); } -/** - * Daily insert counts per part over the trailing window, as dense arrays the - * table can plot directly. Ends on the last complete day, as every other - * series does, so no row trails off into a half-recorded today. - */ +/** Ends on the last complete day, like every other series. */ export async function getPartSparklines( db: Db, libraryId: LibraryId @@ -201,8 +187,7 @@ export async function getPartSparklines( ); const dayIndex = new Map(days.map((day, i) => [day, i])); - // Summed over targets: a part inserted into both kinds of tab on one day - // has a row apiece, and the sparkline plots the day. + // Summed over targets, which each have a row. const rows = await db .select({ elementId: dailyInsertableMetrics.elementId, @@ -311,10 +296,6 @@ export function sumPartTargets( .all(); } -/** - * Favorites are keyed by insertable id, so a part that has left the library has - * none to count. - */ export function countPartFavorites( db: Db, libraryId: LibraryId, @@ -333,10 +314,7 @@ export function countPartFavorites( .get(); } -/** - * The parameters the part declares today. The 1:1 configurations table is keyed - * by insertable id, so they are only reachable through a live row. - */ +/** Only reachable through a live insertable row. */ export async function getPartParameters( db: Db, insertableId: string | undefined diff --git a/src/backend/features/analytics/range.test.ts b/src/backend/features/analytics/range.test.ts index eabf21e0f..6e43add56 100644 --- a/src/backend/features/analytics/range.test.ts +++ b/src/backend/features/analytics/range.test.ts @@ -55,8 +55,6 @@ describe("eachDay", () => { }); it("caps a far-future end rather than allocating a point per day to it", () => { - // Unclamped this is 2.9 million days, which exhausts the Worker before - // the caller ever gets to build a point for each one. const days = eachDay({ from: "2026-01-01", to: "9999-12-31" }); expect(days.length).toBeLessThanOrEqual(10 * 366); expect(days[0]).toBe("2026-01-01"); diff --git a/src/backend/features/analytics/range.ts b/src/backend/features/analytics/range.ts index bfd7aaadd..5e13e8308 100644 --- a/src/backend/features/analytics/range.ts +++ b/src/backend/features/analytics/range.ts @@ -1,6 +1,3 @@ -/** - * The window a dashboard read covers, and the days it spans. - */ import { min } from "drizzle-orm"; import z from "zod"; import { type Db } from "../../db/client"; @@ -10,18 +7,11 @@ import { addDays, toReportingDay, type DayRange } from "./day"; /** The uses a part must be at or below for the low-usage reports to list it. */ const DEFAULT_UNUSED_THRESHOLD = 5; -/** - * The most days one densified series may cover. Only a hand-edited url reaches - * it: the app asks for at most the days since tracking began. - */ +/** Only a hand-edited url reaches this. */ const MAX_SERIES_DAYS = 10 * 366; const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); -/** - * Both bounds are required: every page states the window it reports, so a - * missing one is the caller's bug. - */ export const rangeQuery = z.object({ from: day, to: day }); /** A range, plus the cutoff the low-usage reports list at or below. */ @@ -33,10 +23,7 @@ export const thresholdQuery = rangeQuery.extend({ .default(DEFAULT_UNUSED_THRESHOLD) }); -/** - * Every day in the range. Clamp `from` first, or "all time" fills two decades of - * zeroes; capped as well, since the allocation happens here. - */ +/** Clamp `from` first, or "all time" fills two decades. */ export function eachDay(range: DayRange): string[] { const days: string[] = []; let day = range.from; @@ -47,10 +34,7 @@ export function eachDay(range: DayRange): string[] { return days; } -/** - * The first day anything was recorded, which is what tells "nothing happened" - * from "we were not tracking yet". - */ +/** Tells "nothing happened" from "not tracking yet". */ export async function getTrackingSince(db: Db): Promise { const row = await db .select({ day: min(dailyMetrics.day) }) @@ -59,11 +43,7 @@ export async function getTrackingSince(db: Db): Promise { return row?.day ?? undefined; } -/** - * Narrows a range to the days tracking covers. `to` is held to the last - * reported day as well: today is still filling, nothing was recorded tomorrow, - * and an unclamped end runs to any year asked for. - */ +/** `to` is held to the last reported day, since today is still filling. */ export function clampRange( range: DayRange, since: string | undefined, diff --git a/src/backend/features/analytics/rollups.ts b/src/backend/features/analytics/rollups.ts index 4cea155ac..dbb0bd130 100644 --- a/src/backend/features/analytics/rollups.ts +++ b/src/backend/features/analytics/rollups.ts @@ -19,13 +19,8 @@ import { } from "./schema"; /** - * Every counter one event feeds, derived from the row and the parameters the - * part declares — which is what lets a replay rebuild the rollups exactly, or - * move them to a batch job. - * - * `parameters` is only read to key a configuration count by the branch it was - * chosen in; everything else comes from the row. Pass the part's declared - * parameters, or none for an event that configured nothing. + * Derived from the row, and the part's parameters to key a configuration count + * by its branch, so a replay rebuilds the rollups exactly. */ export function rollupWrites( db: Db, @@ -86,10 +81,7 @@ function countDay(db: Db, event: LoggedEvent) { }); } -/** - * Records that this user was active that day. Idempotent, so the row is written - * once per user per library per day no matter how much they do. - */ +/** Idempotent: one row per user per library per day. */ function markUserActive(db: Db, event: LoggedEvent) { return db .insert(dailyUserActivity) @@ -176,7 +168,7 @@ function countTarget(db: Db, event: LoggedInsert) { }); } -/** As {@link countTarget}, but for one part rather than the library. */ +/** {@link countTarget} for one part. */ function countPartDay(db: Db, event: LoggedInsert) { return db .insert(dailyInsertableMetrics) @@ -198,7 +190,7 @@ function countPartDay(db: Db, event: LoggedInsert) { }); } -/** As {@link markUserActive}, but for one part rather than the library. */ +/** {@link markUserActive} for one part. */ function markPartUser(db: Db, event: LoggedInsert) { return db .insert(dailyInsertableUsers) diff --git a/src/backend/features/analytics/rollups.test.ts b/src/backend/features/analytics/rollups.worker.test.ts similarity index 87% rename from src/backend/features/analytics/rollups.test.ts rename to src/backend/features/analytics/rollups.worker.test.ts index a6b60241c..548d25a5a 100644 --- a/src/backend/features/analytics/rollups.test.ts +++ b/src/backend/features/analytics/rollups.worker.test.ts @@ -47,14 +47,16 @@ const ROLLUPS = [ userStats ]; -function fakeContext(): AppContext { - return { env } as unknown as AppContext; +function fakeContext(userId = TEST_USER_ID): AppContext { + return { + env, + var: { getUserId: () => Promise.resolve(userId) } + } as unknown as AppContext; } function insertEvent(overrides: Partial = {}): InsertEvent { return { libraryId: TEST_LIBRARY_ID, - userId: TEST_USER_ID, path: TEST_PART_STUDIO_PATH, insertableId: TEST_PART_STUDIO_ID, targetElementType: ElementType.PART_STUDIO, @@ -88,16 +90,12 @@ describe("rollupWrites", () => { }); it("rebuilds every rollup from the log alone", async () => { - // Two days, so the day keys have to come from the rows rather than now. const day = 24 * 3600 * 1000; const start = Date.parse("2026-03-01T09:00:00Z"); const clock = vi.spyOn(Date, "now"); clock.mockReturnValue(start); - await trackAppOpen(fakeContext(), { - libraryId: TEST_LIBRARY_ID, - userId: TEST_USER_ID - }); + await trackAppOpen(fakeContext(), TEST_LIBRARY_ID); await trackInsert( fakeContext(), insertEvent({ @@ -119,14 +117,13 @@ describe("rollupWrites", () => { fasten: true }) ); - await trackInsert(fakeContext(), insertEvent({ userId: "someone-2" })); + await trackInsert(fakeContext("someone-2"), insertEvent()); clock.mockRestore(); const live = await readRollups(); expect(live.every((rows) => rows.length > 0)).toBe(true); - // What a batch job would do: drop the rollups and derive them from the log. - // Newest first, since nothing promises a job reads rows in the order written. + // Newest first, since a job needn't read rows in order. for (const table of ROLLUPS) { await db.delete(table); } diff --git a/src/backend/features/analytics/routes.ts b/src/backend/features/analytics/routes.ts index c6972cb4a..aa2e3add9 100644 --- a/src/backend/features/analytics/routes.ts +++ b/src/backend/features/analytics/routes.ts @@ -51,10 +51,7 @@ import { export const analyticsRoutes = getApp(); -/** - * Every handler here is public, which in this app means never touching - * `getUserId()` or `getOnshapeApi()`. Only aggregates leave — never a user id. - */ +/** Public: never call `getUserId()` or `getOnshapeApi()`, and return only aggregates. */ /** GET /api/analytics/overview */ analyticsRoutes.get( @@ -64,8 +61,7 @@ analyticsRoutes.get( const db = getDb(c.env.DB); const requested = c.req.valid("query"); const trackingSince = await getTrackingSince(db); - // Series are densified, so they read the clamped range; the totals still - // read what was asked for, where extra empty days cost nothing. + // Series are densified, so they need the clamped range. const range = clampRange(requested, trackingSince); const [totals, perLibrary, series, metricSeries, sources, growth] = @@ -103,7 +99,7 @@ analyticsRoutes.get( const range = clampRange(requested, trackingSince); const [totals, metricSeries, growth] = await Promise.all([ - getTotals(db, libraryId), + getTotals(db, libraryId, range), getMetricSeries(db, range, libraryId), getGrowth(db, toReportingDay(Date.now()), trackingSince, libraryId) ]); @@ -121,8 +117,7 @@ analyticsRoutes.get( /** GET /api/analytics/health/library/:libraryId?v=:cacheVersion */ analyticsRoutes.get( "/analytics/health" + libraryRoute(), - // Off the same build issues `/build-status` reads, so keyed the same way. - // Public rather than private: this answer is the same for whoever asks. + // Public cache: the answer is the same for everyone. cacheMiddleware(CachePolicy.PUBLIC_CACHE), async (c) => { const libraryId = getLibraryParam(c); @@ -228,8 +223,7 @@ analyticsRoutes.get( byElement.get(part.elementId) ?? [] ); for (const parameter of usage) { - // Only an enum declares the options it could have been given, so - // only an enum can have one that was never picked. + // Only an enum declares its options. if (parameter.type !== ParameterType.ENUM) continue; for (const value of parameter.values) { if (value.count > threshold) continue; @@ -246,8 +240,7 @@ analyticsRoutes.get( } } - // Never-picked first, then by how much of the parameter went elsewhere: - // an option skipped on a heavily configured part is the stronger signal. + // Never-picked first, then by how much of the parameter went elsewhere. out.sort( (a, b) => a.value.count - b.value.count || @@ -289,8 +282,7 @@ analyticsRoutes.get( (total, count) => total + count, 0 ); - // Rated over the days the part has existed inside the window, as the - // parts table rates it. + // Over the days the part existed in the window, as the parts table rates it. const windowStart = Date.parse(`${range.from}T00:00:00Z`); const windowEnd = Math.min( Date.now(), @@ -301,8 +293,7 @@ analyticsRoutes.get( windowStart ); - // Only a part still in the library has a report: its name, its path - // and its parameters all come from the row that is no longer there. + // Its name, path and parameters all come from the row. if (!insertable) { throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } diff --git a/src/backend/features/analytics/routes.test.ts b/src/backend/features/analytics/routes.worker.test.ts similarity index 93% rename from src/backend/features/analytics/routes.test.ts rename to src/backend/features/analytics/routes.worker.test.ts index f10aa2985..82b5dd6a7 100644 --- a/src/backend/features/analytics/routes.test.ts +++ b/src/backend/features/analytics/routes.worker.test.ts @@ -37,6 +37,7 @@ import { import { ElementType } from "../../lib/onshape/element-type"; import { type AnalyticsOverviewOut, + type LibrarySummaryOut, type UnusedOptionOut, type InsertableReportOut, type LibraryHealthCounts, @@ -50,10 +51,7 @@ import { BuildIssueType } from "../build-checker/issues"; const db = getDb(env.DB); const elementId = TEST_PART_STUDIO_PATH.elementId; -/** - * The real app with services that throw, as they do without a session: an - * endpoint that touched one would fail here, which keeps the dashboard public. - */ +/** Services throw, as without a session, which keeps the dashboard public. */ function anonymousGet(path: string) { const app = createApp(() => ({ getOnshapeApi: () => Promise.reject(new Error("no session")), @@ -87,10 +85,7 @@ interface SeedInsertOptions { target?: ElementType; } -/** - * The rollups `count` inserts of one part on one day would leave behind, which - * is all any endpoint reads. - */ +/** The rollups these inserts would leave, which is all any endpoint reads. */ async function seedInserts( count: number, options: SeedInsertOptions = {} @@ -272,7 +267,6 @@ describe("analytics routes", () => { favoriteInserts: 4, fastenInserts: 3, quickInserts: 6, - // The fasten denominator, so the UI reads 3/5 rather than 3/10. targets: { [ElementType.ASSEMBLY]: 5, [ElementType.PART_STUDIO]: 5 @@ -296,8 +290,7 @@ describe("analytics routes", () => { it("scopes rangeTotals to the range while totals stay lifetime", async () => { await seedMetric("2026-01-01", 5); await seedMetric("2026-06-15", 3); - // user_stats holds one all-time row per user, so a range reads - // the per-day activity rollup instead. + // user_stats is all-time, so a range reads the daily rollup. await db.insert(dailyUserActivity).values([ { day: "2026-01-01", @@ -325,8 +318,7 @@ describe("analytics routes", () => { expect(body.totals.inserts).toBe(8); }); - // The rollup holds one row per user *per library* per day, so counting - // rows would report one person active in two libraries as two. + // The rollup is per library, so counting rows would count this user twice. it("counts a user active in two libraries once in the day series", async () => { await seedMetric("2026-06-15", 4); await db.insert(dailyUserActivity).values([ @@ -350,14 +342,11 @@ describe("analytics routes", () => { const day = body.metricSeries.find( (point) => point.day === "2026-06-15" ); - // One, not two — which is also how getTotals counts a windowed - // read of this table, so the tile and the series agree. expect(day?.activeUsers).toBe(1); }); it("fills quiet days in, so an average is per calendar day", async () => { - // Two active days in a wider window: left sparse, a mean over the - // points would report the two-day average as the month's. + // Sparse points would make a mean report the two-day average as the month's. await seedMetric("2026-06-15", 2); await seedMetric("2026-06-18", 4); @@ -366,8 +355,7 @@ describe("analytics routes", () => { ); const body: AnalyticsOverviewOut = await res.json(); - // Clamped to the first recorded day, not back to the requested - // one: "all time" reaches to 2000 and would fill two decades. + // "All time" reaches to 2000, so it clamps to the first recorded day. expect(body.metricSeries.map((point) => point.day)).toEqual([ "2026-06-15", "2026-06-16", @@ -422,8 +410,7 @@ describe("analytics routes", () => { count: 5, quickInsertCount: 5 }); - // Present as a zero rather than missing, so the UI shows every - // source — a new one included, from the day it is added. + // So the UI shows every source, including a new one. expect(bySource[InsertSource.BROWSE]).toMatchObject({ count: 0 }); expect(bySource[InsertSource.GROUP_SEARCH]).toMatchObject({ count: 0 @@ -469,8 +456,6 @@ describe("analytics routes", () => { ); const body: AnalyticsOverviewOut = await res.json(); - // Every day in the window is present, but only the one inside it - // carries a count — days outside are excluded, not zeroed. expect(body.series).toHaveLength(62); expect(body.series[0].day).toBe("2026-05-01"); expect(body.series.at(-1)?.day).toBe("2026-07-01"); @@ -485,6 +470,20 @@ describe("analytics routes", () => { }); }); + describe("GET /analytics/summary/library/:libraryId", () => { + it("totals over the requested range", async () => { + await seedMetric("2026-01-01", 1); + await seedMetric("2026-06-01", 2); + + const res = await anonymousGet( + `/api/analytics/summary/library/${TEST_LIBRARY_ID}?from=2026-05-01&to=2026-07-01` + ); + const body: LibrarySummaryOut = await res.json(); + + expect(body.totals.inserts).toBe(2); + }); + }); + describe("GET /analytics/parts/library/:libraryId", () => { function partsUrl(query = `?${ALL_TIME}`) { return `/api/analytics/parts/library/${TEST_LIBRARY_ID}${query}`; @@ -529,8 +528,7 @@ describe("analytics routes", () => { }); it("still lists a part that went unused in the window", async () => { - // Zero here means "not used lately", which is the interesting - // reading — dropping the row would hide it. + // Zero means "not used lately", which is worth showing. await seedPartStudio(db); await seedInserts(4, { day: "2025-03-01" }); @@ -545,8 +543,6 @@ describe("analytics routes", () => { }); it("keeps history for a part that is no longer visible", async () => { - // A hidden part is still in the library, so it stays listed with - // whatever usage it accumulated while it was insertable. await seedPartStudio(db); await seedInserts(9); @@ -573,8 +569,7 @@ describe("analytics routes", () => { it("leaves out a part that is no longer in the library", async () => { await seedPartStudio(db); await seedInserts(9); - // Usage with no insertable behind it: the tab was deleted. It would - // otherwise top the table with a part nobody can open. + // The tab was deleted, so nobody could open it. await seedInserts(30, { element: "e-gone" }); const res = await anonymousGet(partsUrl()); @@ -594,8 +589,7 @@ describe("analytics routes", () => { await seedPartStudio(db); await seedAssembly(db); await db.update(insertables).set({ isVisible: true }); - // The part studio earned 60 over two years; the assembly earned 20 - // in the last two months and is the one in active use. + // The assembly is the one in active use. await db.insert(insertableStats).values([ { libraryId: TEST_LIBRARY_ID, @@ -628,8 +622,7 @@ describe("analytics routes", () => { }); it("rates a part over its own days, not the whole window", async () => { - // Both parts were used 10 times, but one only existed for the last - // stretch of the window and must not be marked down for it. + // One part only existed for the last stretch and mustn't be marked down for it. const day = 24 * 3600 * 1000; const ago = (days: number) => toDayKey(Date.now() - days * day); await seedPartStudio(db); @@ -687,8 +680,7 @@ describe("analytics routes", () => { }); it("adds up a day's targets in the sparkline and the count", async () => { - // One row per target, so a part used both ways in a day would - // otherwise plot whichever row came back last. + // One row per target, so both must be summed. const last = toReportingDay(Date.now()); await seedPartStudio(db); await seedInserts(2, { @@ -710,8 +702,6 @@ describe("analytics routes", () => { }); it("keeps the sparkline at 30 days whatever the range", async () => { - // It is a shape, not a window: two years of points in a sparkline - // is a smear. await seedPartStudio(db); await seedInserts(2); @@ -767,7 +757,6 @@ describe("analytics routes", () => { type: ParameterType.ENUM, id: "stages", name: "Stages", - isCosmetic: false, default: "one", options: [ { id: "one", name: "1 Stage" }, @@ -838,8 +827,7 @@ describe("analytics routes", () => { }); it("ignores a parameter with no declared options", async () => { - // A boolean or quantity has no option list, so "never used" is not - // a question that can be asked of it. + // Only an enum has an option list to go unused. await seedPartStudio(db); await db.update(insertables).set({ isVisible: true }); await seedConfiguration(db); diff --git a/src/backend/features/analytics/schema.ts b/src/backend/features/analytics/schema.ts index 3f7f0bf6f..0b14331dd 100644 --- a/src/backend/features/analytics/schema.ts +++ b/src/backend/features/analytics/schema.ts @@ -1,7 +1,4 @@ -/** - * Tracking's own tables, kept out of `db/schema.ts` because nothing here points - * at the app's data: no foreign key crosses between the two sides. - */ +/** Kept out of `db/schema.ts`: no foreign key crosses between the two. */ import { sqliteTable, @@ -17,8 +14,8 @@ import { Selection } from "../configurations/contract"; import { EventType, InsertSource } from "./usage"; /** - * Append-only usage log, keyed on the Onshape `elementId` and free of foreign - * keys: a re-added tab gets a fresh app id, and a reload must not drop history. + * Append-only. Keyed on Onshape's `elementId` with no foreign keys, so a reload + * or re-added tab keeps history. */ export const events = sqliteTable( "events", @@ -32,13 +29,9 @@ export const events = sqliteTable( day: text("day").notNull(), libraryId: text("library_id").$type().notNull(), userId: text("user_id").notNull(), - /** - * What the columns meant when the row was written. 1 is the backfill - * for rows predating the column, which is what they were. - */ + /** Rows predating the column were version 1. */ schemaVersion: integer("schema_version").notNull().default(1), - // The whole path inserted from, version included: what the part was - // when it was used, which the library row no longer says after a reload. + // The versioned path, since the library row moves on after a reload. elementId: text("element_id"), documentId: text("document_id"), instanceId: text("instance_id"), @@ -51,26 +44,21 @@ export const events = sqliteTable( selection: text("selection", { mode: "json" }).$type(), - // Whether the part was favorited at insert time — not where the insert - // came from; `source` carries that. + // Favorited at insert time; `source` says where the insert came from. isFavorite: integer("is_favorite", { mode: "boolean" }), isQuickInsert: integer("is_quick_insert", { mode: "boolean" }), source: text("source").$type(), // Insert-and-fasten, which Onshape only offers for assembly targets. fasten: integer("fasten", { mode: "boolean" }) }, - // Indexed by day alone: nothing reads the log to report a metric, so this - // exists to rebuild the rollups below, which is a walk through time. + // Only used to rebuild the rollups, which walk by day. (t) => [index("events_day_idx").on(t.day)] ); /** One row of the log: everything the rollups are derived from. */ export type LoggedEvent = typeof events.$inferSelect; -/** - * Per-day counts, each flag counter a subset of `count`. Fasten's denominator is - * the assembly row of {@link dailyTargetMetrics}, Onshape offering it only there. - */ +/** Each flag counter is a subset of `count`. */ export const dailyMetrics = sqliteTable( "daily_metrics", { @@ -85,10 +73,7 @@ export const dailyMetrics = sqliteTable( (t) => [primaryKey({ columns: [t.day, t.libraryId, t.type] })] ); -/** - * Per-day inserts split by the kind of tab they landed in. A dimension rather - * than a counter per type, so a new kind of target needs no column. - */ +/** A row per type, so a new target type needs no column. */ export const dailyTargetMetrics = sqliteTable( "daily_target_metrics", { @@ -135,10 +120,7 @@ export const insertableStats = sqliteTable( ] ); -/** - * Per-day counts for one part, split by target as {@link dailyTargetMetrics} is. - * Keyed part-first for one part's history, indexed by day for a whole library's. - */ +/** Keyed part-first for one part's history; indexed by day for a library's. */ export const dailyInsertableMetrics = sqliteTable( "daily_insertable_metrics", { @@ -158,10 +140,7 @@ export const dailyInsertableMetrics = sqliteTable( ] ); -/** - * {@link dailyUserActivity} narrowed to one part: a distinct-user count is the - * one measure a counter cannot accumulate, so the identities are kept per day. - */ +/** Distinct users can't be summed, so identities are kept per day. */ export const dailyInsertableUsers = sqliteTable( "daily_insertable_users", { @@ -177,10 +156,6 @@ export const dailyInsertableUsers = sqliteTable( ] ); -/** - * How often each configuration value was chosen, per day, so a default that - * nobody wants (or an option nobody picks) is visible over any window. - */ export const dailyConfigurationMetrics = sqliteTable( "daily_configuration_metrics", { @@ -213,10 +188,7 @@ export const dailyConfigurationMetrics = sqliteTable( ] ); -/** - * One row per user per library per day, so a distinct-user count is a DISTINCT - * over users x days rather than over every insert ever made. - */ +/** So distinct users is a count over days, not over every insert. */ export const dailyUserActivity = sqliteTable( "daily_user_activity", { diff --git a/src/backend/features/analytics/seasons.test.ts b/src/backend/features/analytics/seasons.test.ts index 86a187860..6a698f368 100644 --- a/src/backend/features/analytics/seasons.test.ts +++ b/src/backend/features/analytics/seasons.test.ts @@ -42,8 +42,7 @@ describe("currentSeason", () => { it.each([ ["2026-08-31", undefined], ["2026-09-01", "FTC 2026–27"], - // The one that a naive year lookup gets wrong: January belongs to a - // season that opened the previous September. + // January belongs to the season that opened the previous September. ["2027-01-15", "FTC 2026–27"], ["2027-04-30", "FTC 2026–27"], ["2027-05-01", undefined] @@ -77,8 +76,6 @@ describe("seasonWindow", () => { describe("baselineWindow", () => { it("clips the baseline to the same elapsed stretch", () => { - // 32 days into 2027 compares against the first 32 days of 2026, not - // against the whole of it. const baseline = baselineWindow( seasonWindow(Program.FRC, "2027-02-01") ); diff --git a/src/backend/features/analytics/seasons.ts b/src/backend/features/analytics/seasons.ts index 0787981c9..35d84a0b1 100644 --- a/src/backend/features/analytics/seasons.ts +++ b/src/backend/features/analytics/seasons.ts @@ -1,10 +1,7 @@ import { LibraryId } from "../library/library-id"; import { addDays } from "./day"; -/** - * Competition seasons, which is what makes usage comparable: a library built - * for a Jan–Apr competition has a year nothing like a calendar one. - */ +/** Usage is compared by season: a Jan–Apr competition's year is nothing like a calendar one. */ export enum Program { FRC = "FRC", FTC = "FTC" @@ -17,10 +14,7 @@ export const LIBRARY_PROGRAM: Record = { [LibraryId.FTC_DESIGN_LIB]: Program.FTC }; -/** - * Month a season opens and the month it closes, both inclusive. FTC opens - * before New Year and closes after it, so its span covers two calendar years. - */ +/** Inclusive. FTC's season spans New Year. */ const SPANS: Record = { [Program.FRC]: { startMonth: 1, endMonth: 4 }, [Program.FTC]: { startMonth: 9, endMonth: 4 } @@ -34,10 +28,7 @@ export interface Season { /** Those months as `YYYY-MM`, which is all a chart marker needs. */ startMonth: string; endMonth: string; - /** - * The year the season ends in, which is how both programs name themselves: - * FTC's Sept 2026 – Apr 2027 is the 2027 season, as is FRC's Jan–Apr 2027. - */ + /** The year it ends in: FTC's Sept 2026 – Apr 2027 is the 2027 season. */ year: number; /** "2027" or "2026–27" — the season named without naming a program. */ years: string; @@ -82,8 +73,7 @@ export function currentSeason( day: string ): Season | undefined { const year = Number(day.slice(0, 4)); - // A day in January belongs to a season that opened the previous year, so - // both candidates have to be tried. + // A January day belongs to a season opened the year before. for (const candidate of [ seasonOf(program, year), seasonOf(program, year + 1) @@ -126,10 +116,7 @@ function daysBetween(from: string, to: string): number { return Math.round(ms / (24 * 3600 * 1000)) + 1; } -/** - * In season, the season so far; between seasons, the last complete one — an - * off-season week against a full season would show a collapse every May. - */ +/** Between seasons, the last complete one, or every May would show a collapse. */ export function seasonWindow(program: Program, day: string): SeasonWindow { const current = currentSeason(program, day); if (current === undefined) { @@ -151,10 +138,7 @@ export function seasonWindow(program: Program, day: string): SeasonWindow { }; } -/** - * The same stretch of the previous season, so a half-finished season is - * compared against half of the one before rather than all of it. - */ +/** The same stretch of the previous season, so a half season compares to a half. */ export function baselineWindow(window: SeasonWindow): { from: string; to: string; diff --git a/src/backend/features/analytics/tracking.ts b/src/backend/features/analytics/tracking.ts index cd5cacce5..51ee1ec4c 100644 --- a/src/backend/features/analytics/tracking.ts +++ b/src/backend/features/analytics/tracking.ts @@ -1,6 +1,7 @@ import type { BatchItem } from "drizzle-orm/batch"; +import { runInBackground } from "../../lib/background"; import { type AppContext } from "../../lib/context"; -import { getDb, type Db } from "../../db/client"; +import { getDb } from "../../db/client"; import { events, type LoggedEvent } from "./schema"; import { NOT_AN_INSERT, type EventCore } from "./logged-event"; import { rollupWrites } from "./rollups"; @@ -12,24 +13,20 @@ import { type ConfigurationParameter, type Selection } from "../configurations/contract"; -import { appliedValues } from "../configurations/selection"; +import { canonicalValues } from "../configurations/selection"; import { toDayKey } from "./day"; +/** The caller is the one who inserted; see `trackInsert`. */ export interface InsertEvent { libraryId: LibraryId; - userId: string; - /** The version-pinned tab inserted from, logged whole: the rollups key on - * its element id, and the rest says which version was used. */ + /** The rollups key on the element id; the rest records the version. */ path: ElementPath; insertableId: string; /** The type of tab the user inserted into. */ targetElementType: ElementType; /** The whole selection the insert applied; undefined when it has none. */ selection: Selection | undefined; - /** - * The parameters that selection was made whole against, carried rather than - * read back: applying the insert already had to load them. - */ + /** Passed in, since the insert already loaded them. */ parameters: ConfigurationParameter[]; /** Whether the part was favorited, not where the insert came from. */ isFavorite: boolean; @@ -38,107 +35,78 @@ export interface InsertEvent { fasten: boolean; } -interface AppOpenEvent { - libraryId: LibraryId; - userId: string; -} - -/** - * Usage data is never worth failing a user's insert over, so errors are logged - * and dropped. Awaits when no execution context is available. - */ -export async function trackInBackground( - c: AppContext, - work: () => Promise -): Promise { - const guarded = work().catch((error) => { - console.error("Failed to record usage event", error); - }); - - try { - c.executionCtx.waitUntil(guarded); - } catch { - await guarded; - } -} - -export async function trackInsert( - c: AppContext, - event: InsertEvent -): Promise { - const db = getDb(c.env.DB); - const now = Date.now(); - - await record( - db, - { - ...core(EventType.INSERT, now, event), - ...event.path, - insertableId: event.insertableId, - targetElementType: event.targetElementType, - selection: appliedSelection(event.selection, event.parameters), - isFavorite: event.isFavorite, - isQuickInsert: event.isQuickInsert, - source: event.source, - fasten: event.fasten - }, - event.parameters +/** After the response, and never failing it: tracking is not what was asked for. */ +export function trackInsert(c: AppContext, event: InsertEvent): Promise { + return runInBackground(c, "record an insert", async () => + record( + c, + { + ...(await core(c, EventType.INSERT, event.libraryId)), + ...event.path, + insertableId: event.insertableId, + targetElementType: event.targetElementType, + selection: appliedSelection(event.selection, event.parameters), + isFavorite: event.isFavorite, + isQuickInsert: event.isQuickInsert, + source: event.source, + fasten: event.fasten + }, + event.parameters + ) ); } -export async function trackAppOpen( +/** As `trackInsert`. */ +export function trackAppOpen( c: AppContext, - event: AppOpenEvent + libraryId: LibraryId ): Promise { - const db = getDb(c.env.DB); - const now = Date.now(); - - // An app open configures nothing, so it has no parameters to key by. - await record( - db, - { ...core(EventType.APP_OPEN, now, event), ...NOT_AN_INSERT }, - [] + return runInBackground(c, "record an app open", async () => + record( + c, + { + ...(await core(c, EventType.APP_OPEN, libraryId)), + ...NOT_AN_INSERT + }, + [] + ) ); } -/** - * What Onshape applied for a selection: the values no condition hid. Null when - * the insertable has nothing to configure, which is what the log records. - */ +/** Canonical, so "5 in" and "(2 + 3) in" count as one. Null when there's nothing to configure. */ function appliedSelection( selection: Selection | undefined, parameters: ConfigurationParameter[] ): Selection | null { if (!selection || parameters.length === 0) return null; - return appliedValues(selection, parameters); + return canonicalValues(selection, parameters); } /** What every logged event carries; its kind fills in the rest. */ -function core( +async function core( + c: AppContext, type: EventType, - now: number, - event: { libraryId: LibraryId; userId: string } -): EventCore { + libraryId: LibraryId +): Promise { + const now = Date.now(); return { id: crypto.randomUUID(), type, createdAt: new Date(now), day: toDayKey(now), - libraryId: event.libraryId, - userId: event.userId, + libraryId, + userId: await c.var.getUserId(), schemaVersion: EVENT_SCHEMA_VERSION }; } -/** - * The two halves of a write, batched so neither lands without the other. Kept - * apart so the counting can move to a batch job without touching the recording. - */ +/** Batched so neither half lands without the other. */ async function record( - db: Db, + c: AppContext, event: LoggedEvent, parameters: ConfigurationParameter[] ): Promise { + const db = getDb(c.env.DB); const writes: BatchItem<"sqlite">[] = [ db.insert(events).values(event), ...rollupWrites(db, event, parameters) diff --git a/src/backend/features/analytics/tracking.test.ts b/src/backend/features/analytics/tracking.worker.test.ts similarity index 88% rename from src/backend/features/analytics/tracking.test.ts rename to src/backend/features/analytics/tracking.worker.test.ts index 0bf849079..3c4026db7 100644 --- a/src/backend/features/analytics/tracking.test.ts +++ b/src/backend/features/analytics/tracking.worker.test.ts @@ -25,12 +25,7 @@ import { } from "../../../__test_utils__"; import { getDb } from "../../db/client"; import { type AppContext } from "../../lib/context"; -import { - trackAppOpen, - trackInBackground, - trackInsert, - type InsertEvent -} from "./tracking"; +import { trackAppOpen, trackInsert, type InsertEvent } from "./tracking"; import { toDayKey } from "./day"; import { EVENT_SCHEMA_VERSION, InsertSource } from "./usage"; import { @@ -54,15 +49,17 @@ function noop(): void { const elementId = TEST_PART_STUDIO_PATH.elementId; /** A context stub carrying only what the tracking functions touch. */ -function fakeContext(): AppContext { - return { env } as unknown as AppContext; +function fakeContext(userId = TEST_USER_ID): AppContext { + return { + env, + var: { getUserId: () => Promise.resolve(userId) } + } as unknown as AppContext; } /** An insert of an unconfigurable part. */ function insertEvent(overrides: Partial = {}): InsertEvent { return { libraryId: TEST_LIBRARY_ID, - userId: TEST_USER_ID, path: TEST_PART_STUDIO_PATH, insertableId: TEST_PART_STUDIO_ID, targetElementType: ElementType.PART_STUDIO, @@ -90,10 +87,7 @@ const SIZE_PARAMETERS: ConfigurationParameter[] = [ } ]; -/** - * An insert of a configured part, whole against its parameters — which is what - * the insert routes hand tracking. - */ +/** Whole, as the insert routes hand it over. */ function configuredEvent( values: Record, overrides: Partial = {} @@ -130,8 +124,6 @@ describe("tracking", () => { libraryId: TEST_LIBRARY_ID, userId: TEST_USER_ID, schemaVersion: EVENT_SCHEMA_VERSION, - // The whole path, so the version used is still known after a - // reload moves the library on. ...TEST_PART_STUDIO_PATH, selection: { size: "large" } }); @@ -170,8 +162,6 @@ describe("tracking", () => { expect(daily).toHaveLength(1); expect(daily[0].count).toBe(3); - // The user is unique, so still one row — this is what makes the - // unique-user count a cheap COUNT. const users = await db.select().from(userStats).all(); expect(users).toHaveLength(1); expect(users[0].insertCount).toBe(3); @@ -203,10 +193,7 @@ describe("tracking", () => { it("records a part's user once a day, however often they insert it", async () => { await trackInsert(fakeContext(), insertEvent()); await trackInsert(fakeContext(), insertEvent()); - await trackInsert( - fakeContext(), - insertEvent({ userId: "someone-else" }) - ); + await trackInsert(fakeContext("someone-else"), insertEvent()); const rows = await db.select().from(dailyInsertableUsers).all(); expect(rows).toHaveLength(2); @@ -270,8 +257,7 @@ describe("tracking", () => { it("leaves out a parameter the selection hides", async () => { await seedSizeConfiguration(); - // "reinforced" is only shown for the large size, and Onshape - // applies nothing it does not show. + // "reinforced" is hidden for small, so it isn't applied. await trackInsert( fakeContext(), configuredEvent({ size: "small", reinforced: "true" }) @@ -310,8 +296,7 @@ describe("tracking", () => { ]); }); - // A quick insert chooses nothing, and the route hands over the whole - // selection anyway — every parameter at its default. + // A quick insert chooses nothing, but every parameter's default applies. it("counts the defaults of a selection nobody touched", async () => { await seedSizeConfiguration(); @@ -325,8 +310,6 @@ describe("tracking", () => { Object.fromEntries( values.map((row) => [row.parameterId, row.value]) ) - // No "reinforced": the small default hides it, so it applied - // nothing. ).toEqual({ size: "small", length: "0.0254 m" }); }); @@ -367,8 +350,7 @@ describe("tracking", () => { targetElementType: ElementType.ASSEMBLY }) ); - // A part-studio insert is never fasten-eligible, so it must not - // dilute the denominator. + // Part-studio inserts can't fasten, so they mustn't dilute the denominator. await trackInsert( fakeContext(), insertEvent({ @@ -420,8 +402,6 @@ describe("tracking", () => { }); }); - // The point of the two: a search filtered to one group is a different - // search from one across the library, and reads as one. it("counts a search inside a group apart from one across the library", async () => { await trackInsert( fakeContext(), @@ -446,8 +426,7 @@ describe("tracking", () => { }); it("keeps a favorited part inserted from search attributed to search", async () => { - // isFavorite is a property of the part; source is where the insert - // began. Conflating them would misreport the favorites list. + // isFavorite is about the part; source is where the insert began. await trackInsert( fakeContext(), insertEvent({ @@ -482,10 +461,7 @@ describe("tracking", () => { it("writes one row per user per day however much they do", async () => { await trackInsert(fakeContext(), insertEvent()); await trackInsert(fakeContext(), insertEvent()); - await trackAppOpen(fakeContext(), { - libraryId: TEST_LIBRARY_ID, - userId: TEST_USER_ID - }); + await trackAppOpen(fakeContext(), TEST_LIBRARY_ID); const rows = await db.select().from(dailyUserActivity).all(); expect(rows).toHaveLength(1); @@ -498,10 +474,7 @@ describe("tracking", () => { it("keeps a row per user", async () => { await trackInsert(fakeContext(), insertEvent()); - await trackInsert( - fakeContext(), - insertEvent({ userId: "someone-else" }) - ); + await trackInsert(fakeContext("someone-else"), insertEvent()); const rows = await db.select().from(dailyUserActivity).all(); expect(rows.map((row) => row.userId).sort()).toEqual([ @@ -513,10 +486,7 @@ describe("tracking", () => { describe("trackAppOpen", () => { it("counts opens separately from inserts", async () => { - await trackAppOpen(fakeContext(), { - libraryId: TEST_LIBRARY_ID, - userId: TEST_USER_ID - }); + await trackAppOpen(fakeContext(), TEST_LIBRARY_ID); const daily = await db.select().from(dailyMetrics).get(); expect(daily).toMatchObject({ type: "app_open", count: 1 }); @@ -529,16 +499,18 @@ describe("tracking", () => { }); }); - describe("trackInBackground", () => { - it("swallows failures so an insert is never lost to tracking", async () => { + describe("a failure", () => { + it("is logged, so an insert is never lost to tracking", async () => { const consoleError = vi .spyOn(console, "error") .mockImplementation(noop); + const failing = { + env, + var: { getUserId: () => Promise.reject(new Error("no user")) } + } as unknown as AppContext; await expect( - trackInBackground(fakeContext(), () => - Promise.reject(new Error("d1 exploded")) - ) + trackInsert(failing, insertEvent()) ).resolves.toBeUndefined(); expect(consoleError).toHaveBeenCalled(); diff --git a/src/backend/features/analytics/usage.ts b/src/backend/features/analytics/usage.ts index 62099b1e8..a2e965ff6 100644 --- a/src/backend/features/analytics/usage.ts +++ b/src/backend/features/analytics/usage.ts @@ -1,29 +1,17 @@ /** - * The vocabulary of what gets recorded. Named for what it describes rather than - * for the events themselves: as `events.ts` it built to an `events-.js` - * chunk, and the frontend imports these enums, so an ad blocker that matches - * that name takes the main entry and the library route down with it. That is - * what blocked it in dev, where the url carries the path as written. + * Not named `events.ts`: ad blockers match the `events-.js` chunk, and + * the frontend imports these enums. */ -/** - * What a logged event's columns mean. Bump it when that changes, so a reader can - * tell rows written under the old reading from rows written under the new. - */ +/** Bump when the columns' meaning changes. */ export const EVENT_SCHEMA_VERSION = 1; -/** - * The kinds of usage events recorded for the analytics dashboard. - */ export enum EventType { INSERT = "insert", APP_OPEN = "app_open" } -/** - * Where an insert started, not whether the part is favorited. One value per place - * rather than a source crossed with a location: only search has two forms. - */ +/** Not whether the part is favorited. */ export enum InsertSource { /** Searching the whole library, from its home list. */ SEARCH = "search", diff --git a/src/backend/features/auth/access-level.ts b/src/backend/features/auth/access-level.ts index cb7f5bc2f..c137304e6 100644 --- a/src/backend/features/auth/access-level.ts +++ b/src/backend/features/auth/access-level.ts @@ -1,22 +1,23 @@ /** The permission tiers the app grants, and the predicates routes gate on. */ export enum AccessLevel { + /** `OWNER_USER_ID`: an admin whose session the server borrows for its own work. */ + OWNER = "owner", ADMIN = "admin", EDITOR = "editor", USER = "user" } -export function hasEditorAccess(accessLevel: AccessLevel) { - return ( - accessLevel === AccessLevel.ADMIN || accessLevel === AccessLevel.EDITOR - ); -} - const ACCESS_LEVEL_RANK: Record = { [AccessLevel.USER]: 0, [AccessLevel.EDITOR]: 1, - [AccessLevel.ADMIN]: 2 + [AccessLevel.ADMIN]: 2, + [AccessLevel.OWNER]: 3 }; +export function hasEditorAccess(accessLevel: AccessLevel) { + return isWithinAccessLevel(AccessLevel.EDITOR, accessLevel); +} + /** Whether `accessLevel` grants no more than `maxAccessLevel` does. */ export function isWithinAccessLevel( accessLevel: AccessLevel, @@ -25,10 +26,7 @@ export function isWithinAccessLevel( return ACCESS_LEVEL_RANK[accessLevel] <= ACCESS_LEVEL_RANK[maxAccessLevel]; } -/** - * Server-provided access: the highest level granted plus sign-in state. The - * level the app is currently viewed as is client-side (see useAccessData). - */ +/** The level currently viewed is client-side; see useAccessData. */ export interface AccessData { maxAccessLevel: AccessLevel; signedIn: boolean; diff --git a/src/backend/features/auth/background-sessions.ts b/src/backend/features/auth/background-sessions.ts new file mode 100644 index 000000000..5aa696ae1 --- /dev/null +++ b/src/backend/features/auth/background-sessions.ts @@ -0,0 +1,102 @@ +/** + * Work nobody is signed in behind, like a webhook's load, still calls Onshape + * as someone: the owner or a member of the library's admin team, whoever still + * has a session Onshape takes. Only their sessions are kept by user id. + */ +import { type OAuthApi } from "../../lib/onshape/client"; +import { getSessionInfo } from "../../lib/onshape/endpoints/users"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import { inArray } from "drizzle-orm"; +import type { LibraryId } from "../library/library-id"; +import { getOnshapeApiFromSessionId } from "./request-auth"; +import { kvStore } from "../../lib/kv-store"; + +/** Each eligible user's latest session id, by user id. */ +const backgroundSessions = kvStore("background-session"); + +/** + * The dev access-level override grants access to someone on no team, so no + * user id finds them; their session is held under this instead. Only dev writes it. + */ +const OVERRIDDEN_USER = "access-level-override"; + +/** Only writes when the session changed. */ +export async function rememberBackgroundSession( + kv: KVNamespace, + userId: string, + sessionId: string +): Promise { + if ((await backgroundSessions.get(kv, userId)) !== sessionId) { + await backgroundSessions.put(kv, userId, sessionId); + } +} + +export function rememberOverriddenSession( + kv: KVNamespace, + sessionId: string +): Promise { + return rememberBackgroundSession(kv, OVERRIDDEN_USER, sessionId); +} + +async function getLiveApi( + kv: KVNamespace, + userId: string +): Promise { + const sessionId = await backgroundSessions.get(kv, userId); + if (!sessionId) { + return undefined; + } + try { + const onshapeApi = await getOnshapeApiFromSessionId(kv, sessionId); + // Refreshing proves the refresh token; this proves the access token. + await getSessionInfo(onshapeApi); + return onshapeApi; + } catch { + return undefined; + } +} + +/** + * The owner's session, else the first working one of the libraries' team + * admins, then team members, else an overridden user's. + */ +export async function getBackgroundOnshapeApi( + env: AppBindings, + libraryIds: LibraryId[] +): Promise { + const owner = env.OWNER_USER_ID + ? await getLiveApi(env.KV, env.OWNER_USER_ID) + : undefined; + return ( + owner ?? + (await getTeamApi(env, libraryIds)) ?? + (await getLiveApi(env.KV, OVERRIDDEN_USER)) + ); +} + +async function getTeamApi( + env: AppBindings, + libraryIds: LibraryId[] +): Promise { + if (libraryIds.length === 0) { + return undefined; + } + const rows = await getDb(env.DB) + .select({ adminTeam: libraries.adminTeam }) + .from(libraries) + .where(inArray(libraries.id, libraryIds)); + const members = rows.flatMap((row) => row.adminTeam); + const userIds = new Set([ + ...members.filter((m) => m.isTeamAdmin).map((m) => m.userId), + ...members.filter((m) => !m.isTeamAdmin).map((m) => m.userId) + ]); + for (const userId of userIds) { + const api = await getLiveApi(env.KV, userId); + if (api) { + return api; + } + } + return undefined; +} diff --git a/src/backend/features/auth/background-sessions.worker.test.ts b/src/backend/features/auth/background-sessions.worker.test.ts new file mode 100644 index 000000000..8c365140b --- /dev/null +++ b/src/backend/features/auth/background-sessions.worker.test.ts @@ -0,0 +1,92 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TEST_LIBRARY_ID, resetDb, seedLibrary } from "../../../__test_utils__"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { + getBackgroundOnshapeApi, + rememberBackgroundSession, + rememberOverriddenSession +} from "./background-sessions"; +import * as RequestAuth from "./request-auth"; + +const db = getDb(env.DB); +const OWNER = "owner"; + +/** Each session id answers with its own api, unless it is in `dead`. */ +function sessions(dead: string[] = []) { + const apis = new Map(); + vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockImplementation( + (_kv, sessionId) => { + if (dead.includes(sessionId)) { + return Promise.reject(new Error("expired")); + } + const api = new MockOnshapeApi(); + vi.spyOn(api, "get").mockResolvedValue({ id: sessionId }); + apis.set(sessionId, api); + return Promise.resolve(api); + } + ); + return apis; +} + +describe("finding a session to work in the background with", () => { + beforeEach(async () => { + await resetDb(db); + await seedLibrary(db); + await env.KV.delete(`background-session:${OWNER}`); + await env.KV.delete("background-session:access-level-override"); + await db + .update(libraries) + .set({ + adminTeam: [ + { userId: "member", isTeamAdmin: false }, + { userId: "admin", isTeamAdmin: true } + ] + }) + .where(eq(libraries.id, TEST_LIBRARY_ID)); + await rememberBackgroundSession(env.KV, "member", "member-session"); + await rememberBackgroundSession(env.KV, "admin", "admin-session"); + }); + afterEach(() => vi.restoreAllMocks()); + + const find = () => + getBackgroundOnshapeApi({ ...env, OWNER_USER_ID: OWNER }, [ + TEST_LIBRARY_ID + ]); + + it("prefers the owner's", async () => { + await rememberBackgroundSession(env.KV, OWNER, "owner-session"); + const apis = sessions(); + + expect(await find()).toBe(apis.get("owner-session")); + }); + + it("falls back to a team admin's before a member's", async () => { + await rememberBackgroundSession(env.KV, OWNER, "owner-session"); + const apis = sessions(["owner-session"]); + + expect(await find()).toBe(apis.get("admin-session")); + }); + + it("falls back to a team member's when no admin's works", async () => { + const apis = sessions(["admin-session"]); + + expect(await find()).toBe(apis.get("member-session")); + }); + + // The dev override's user is on no team, so nothing else finds them. + it("falls back last to the overridden user's", async () => { + await rememberOverriddenSession(env.KV, "override-session"); + const apis = sessions(["admin-session", "member-session"]); + + expect(await find()).toBe(apis.get("override-session")); + }); + + it("finds nothing when no session works", async () => { + sessions(["admin-session", "member-session"]); + expect(await find()).toBeUndefined(); + }); +}); diff --git a/src/backend/features/auth/company.ts b/src/backend/features/auth/company.ts new file mode 100644 index 000000000..1ef7f39a9 --- /dev/null +++ b/src/backend/features/auth/company.ts @@ -0,0 +1,9 @@ +import { type AppContext } from "../../lib/context"; + +/** What `/init` carries outside an enterprise. OAuth won't accept it as a company. */ +export const PERSONAL_COMPANY_ID = "cad"; + +/** The company the document Onshape launched in belongs to. */ +export function getSessionCompanyId(c: AppContext): string { + return c.req.query("sessionCompanyId") ?? PERSONAL_COMPANY_ID; +} diff --git a/src/backend/features/auth/cookie-options.ts b/src/backend/features/auth/cookie-options.ts new file mode 100644 index 000000000..472c3a65e --- /dev/null +++ b/src/backend/features/auth/cookie-options.ts @@ -0,0 +1,7 @@ +/** SameSite=None and secure, since the app runs embedded in Onshape's iframe. */ +export const COOKIE_OPTIONS = { + httpOnly: true, + secure: true, + sameSite: "None", + path: "/" +} as const; diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index 5a117104f..9aecf03c3 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -1,8 +1,9 @@ -/** The two gates routes mount: signed in to Onshape at all, and on the admin team. */ import type { MiddlewareHandler } from "hono"; import { forbiddenError, signInRequiredError } from "../../lib/api-error"; import type { AppContext, AppContextEnv } from "../../lib/context"; -import { hasEditorAccess } from "./access-level"; +import { getLibraryParam } from "../../lib/route-params"; +import { DEFAULT_LIBRARY } from "../library/library-id"; +import { AccessLevel, isWithinAccessLevel } from "./access-level"; import { isSignedIn } from "./request-auth"; async function requireSignIn(c: AppContext): Promise { @@ -22,18 +23,44 @@ export const requireSignInMiddleware: MiddlewareHandler = async ( }; /** - * Editing implies a session: access level alone would admit a signed-out caller - * under a dev access-level override, and answer 403 rather than 401 otherwise. + * For a route under `libraryRoute()`. Requires a session, or a dev override + * would let a signed-out caller through. A route acting on a group or + * insertable scopes its query to this library, so it can't reach another's. */ -export const requireEditorMiddleware: MiddlewareHandler = async ( +function requireLibraryAccess( + level: AccessLevel.EDITOR | AccessLevel.ADMIN +): MiddlewareHandler { + return async (c, next) => { + await requireSignIn(c); + const accessLevel = await c.var.getAccessLevel(getLibraryParam(c)); + if (!isWithinAccessLevel(level, accessLevel)) { + throw forbiddenError( + level === AccessLevel.ADMIN + ? "You must be an admin of the library's admin team to use this functionality" + : "You must be on the library's admin team to use this functionality" + ); + } + await next(); + }; +} + +export const requireEditorMiddleware = requireLibraryAccess(AccessLevel.EDITOR); + +export const requireAdminMiddleware = requireLibraryAccess(AccessLevel.ADMIN); + +/** The owner's access is the same everywhere, so any library answers. */ +export async function requireOwner(c: AppContext): Promise { + await requireSignIn(c); + const level = await c.var.getAccessLevel(DEFAULT_LIBRARY); + if (level !== AccessLevel.OWNER) { + throw forbiddenError("Only the owner can use this functionality"); + } +} + +export const requireOwnerMiddleware: MiddlewareHandler = async ( c, next ) => { - await requireSignIn(c); - if (!hasEditorAccess(await c.var.getAccessLevel())) { - throw forbiddenError( - "You must be on the admin team to use this functionality" - ); - } + await requireOwner(c); await next(); }; diff --git a/src/backend/features/auth/guards.test.ts b/src/backend/features/auth/guards.worker.test.ts similarity index 57% rename from src/backend/features/auth/guards.test.ts rename to src/backend/features/auth/guards.worker.test.ts index 3c2e9c6d0..060059eb7 100644 --- a/src/backend/features/auth/guards.test.ts +++ b/src/backend/features/auth/guards.worker.test.ts @@ -2,11 +2,12 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; import { AccessLevel } from "./access-level"; import { LibraryId } from "../library/library-id"; -import { Theme } from "../settings/settings"; import { createTestApp, jsonRequest, resetDb, + seedGroup, + seedInsertable, seedLibrary } from "../../../__test_utils__"; import { getDb } from "../../db/client"; @@ -28,12 +29,12 @@ describe("requireSignInMiddleware", () => { ); expect(favorites.status).toBe(401); - const userData = await app.request( - "/api/settings", - jsonRequest("POST", { theme: Theme.DARK }), + const appOpen = await app.request( + "/api/app-open/library/" + LibraryId.FRC_DESIGN_LIB, + jsonRequest("POST"), env ); - expect(userData.status).toBe(401); + expect(appOpen.status).toBe(401); }); it("allows sign-in-only routes when signed in", async () => { @@ -54,8 +55,7 @@ describe("requireEditorMiddleware", () => { await resetDb(db); }); - // Access level alone would admit a signed-out caller wherever it is - // granted without a session, e.g. behind a dev access-level override. + // A dev access-level override grants the level without a session. it("401s an editor-level caller who is not signed in", async () => { const app = createTestApp({ signedIn: false, @@ -63,7 +63,7 @@ describe("requireEditorMiddleware", () => { }); const res = await app.request( - `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, jsonRequest("POST"), env ); @@ -77,10 +77,39 @@ describe("requireEditorMiddleware", () => { }); const res = await app.request( - `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, jsonRequest("POST"), env ); expect(res.status).toBe(403); }); }); + +describe("editing something inside a library", () => { + beforeEach(() => resetDb(db)); + + // So an editor of one library can't reach into another. + it("finds only what is in the library the path names", async () => { + await seedGroup(db, "ftc-group", LibraryId.FTC_DESIGN_LIB); + await seedInsertable(db, { + id: "ftc-part", + groupId: "ftc-group", + libraryId: LibraryId.FTC_DESIGN_LIB + }); + const app = createTestApp({ + accessLevel: (libraryId) => + libraryId === LibraryId.FRC_DESIGN_LIB + ? AccessLevel.ADMIN + : AccessLevel.USER + }); + const reindex = (libraryId: LibraryId) => + app.request( + `/api/index-configurations/library/${libraryId}/insertable/ftc-part`, + jsonRequest("POST", { indexConfigurations: true }), + env + ); + + expect((await reindex(LibraryId.FRC_DESIGN_LIB)).status).toBe(404); + expect((await reindex(LibraryId.FTC_DESIGN_LIB)).status).toBe(403); + }); +}); diff --git a/src/backend/features/auth/login.ts b/src/backend/features/auth/login.ts new file mode 100644 index 000000000..8f6f92af1 --- /dev/null +++ b/src/backend/features/auth/login.ts @@ -0,0 +1,40 @@ +/** + * A sign-in in flight: the OAuth state and where to return, held whole in a + * ten-minute cookie of its own. Nothing of it is in KV. + */ +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; +import { type AppContext } from "../../lib/context"; +import { COOKIE_OPTIONS } from "./cookie-options"; + +const LOGIN_COOKIE = "frc-design-app-login"; +const LOGIN_TTL = 600; // 10 minutes + +/** What the callback needs to finish a sign-in it did not start. */ +export interface Login { + state: string; + redirectUrl: string; +} + +/** + * Held in its own cookie, so a caller who never returns through the callback + * keeps the session they had. Only the caller's own browser can set it, and the + * callback checks its state against Onshape's. + */ +export function startLogin(c: AppContext, login: Login): void { + setCookie(c, LOGIN_COOKIE, JSON.stringify(login), { + ...COOKIE_OPTIONS, + maxAge: LOGIN_TTL + }); +} + +/** Single-use: reading it also clears it, so a state cannot be replayed. */ +export function takeLogin(c: AppContext): Login | undefined { + const raw = getCookie(c, LOGIN_COOKIE); + if (!raw) return undefined; + deleteCookie(c, LOGIN_COOKIE, COOKIE_OPTIONS); + try { + return JSON.parse(raw) as Login; + } catch { + return undefined; + } +} diff --git a/src/backend/features/auth/onshape-oauth.ts b/src/backend/features/auth/onshape-oauth.ts index 4895b1881..0110f169d 100644 --- a/src/backend/features/auth/onshape-oauth.ts +++ b/src/backend/features/auth/onshape-oauth.ts @@ -4,24 +4,20 @@ import { generateState, OAuth2Client, OAuth2Tokens } from "arctic"; import { internalError } from "../../lib/api-error"; import { env } from "cloudflare:workers"; import { type AppContext } from "../../lib/context"; -import { - type AuthTokens, - beginSession, - PERSONAL_COMPANY_ID, - startLoginSession, - takeLoginSession -} from "./session"; +import { type AuthTokens, beginSession } from "./session"; +import { PERSONAL_COMPANY_ID } from "./company"; +import { startLogin, takeLogin } from "./login"; const AUTH_ENDPOINT = "https://oauth.onshape.com/oauth/authorize"; export const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; -/** - * No redirect uri, so neither the authorization request nor the code exchange - * names one and Onshape returns the caller to whichever its OAuth app - * registers — so a host wants an app registering its own callback. - */ +/** The redirect uri must match one registered on the Onshape OAuth app. */ export function getOauthClient(): OAuth2Client { - return new OAuth2Client(env.OAUTH_CLIENT_ID, env.OAUTH_CLIENT_SECRET, null); + return new OAuth2Client( + env.OAUTH_CLIENT_ID, + env.OAUTH_CLIENT_SECRET, + `${env.APP_URL}/auth/callback` + ); } export function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { @@ -32,32 +28,24 @@ export function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { }; } -/** - * Stores the redirectUrl and state. - * - * Returns the URL the user should be redirected to. - */ -export async function doSignIn( +/** Stores the redirect url and state; returns the url to send the user to. */ +export function doSignIn( c: AppContext, redirectUrl: string, companyId?: string -): Promise { +): string { const oauthClient = getOauthClient(); const state = generateState(); - // Store the state and redirectUrl so the callback can complete sign-in. - await startLoginSession(c, { state, redirectUrl }); + startLogin(c, { state, redirectUrl }); const authorizationUrl = oauthClient.createAuthorizationURL( AUTH_ENDPOINT, state, [] ); - // company_id scopes the sign-in to an enterprise, and Onshape only accepts - // a real one: sign-in worked in an enterprise and failed for plain - // cad.onshape.com users, whose id is PERSONAL_COMPANY_ID. So that id is - // left off, as a standalone sign-in's missing company already is. + // Onshape only accepts a real enterprise's company_id. if (companyId && companyId !== PERSONAL_COMPANY_ID) { authorizationUrl.searchParams.set("company_id", companyId); } @@ -72,17 +60,17 @@ export async function doCallback(c: AppContext): Promise { return c.redirect("/grant-denied"); } - const session = await takeLoginSession(c); + const login = takeLogin(c); - // There was a problem with the cookie used to store redirect information - if (!session) { + // The redirect cookie was missing. + if (!login) { if (isSafari(c.req.raw)) { return c.redirect("/safari-error"); } return c.redirect("/cookie-error"); } - if (!search.code || session.state !== search.state) { + if (!search.code || login.state !== search.state) { throw internalError( "Invalid response from Onshape", HttpStatus.UNAUTHORIZED @@ -95,7 +83,7 @@ export async function doCallback(c: AppContext): Promise { .then((tokens) => makeAuthTokens(tokens)) .then((tokens) => beginSession(c, tokens)); - return c.redirect(session.redirectUrl); + return c.redirect(login.redirectUrl); } function isSafari(request: Request): boolean { diff --git a/src/backend/features/auth/request-auth.test.ts b/src/backend/features/auth/request-auth.test.ts deleted file mode 100644 index 4483d54c4..000000000 --- a/src/backend/features/auth/request-auth.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { env } from "cloudflare:workers"; -import { env as processEnv } from "process"; -import { afterEach, describe, expect, it } from "vitest"; -import { AccessLevel } from "./access-level"; -import { productionAuth } from "./request-auth"; -import { createApp } from "../../app"; -import { jsonRequest } from "../../../__test_utils__"; - -const app = createApp(productionAuth); - -/** What the real caller resolves for a request carrying no Onshape session. */ -async function getMaxAccessLevel(override?: AccessLevel): Promise { - const res = await app.request("/api/access-data", jsonRequest("GET"), { - ...env, - VITE_ACCESS_LEVEL_OVERRIDE: override - }); - const body: { maxAccessLevel: AccessLevel } = await res.json(); - return body.maxAccessLevel; -} - -describe("the dev access-level override", () => { - const nodeEnv = processEnv.NODE_ENV; - afterEach(() => { - processEnv.NODE_ENV = nodeEnv; - }); - - it("grants the level it names", async () => { - expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( - AccessLevel.ADMIN - ); - }); - - // It is the one thing standing between a stray env var and admin, so it - // must not survive a production build. - it("is ignored in production", async () => { - processEnv.NODE_ENV = "production"; - expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( - AccessLevel.USER - ); - }); - - it("leaves an unset override to the caller's own session", async () => { - expect(await getMaxAccessLevel()).toBe(AccessLevel.USER); - }); -}); diff --git a/src/backend/features/auth/request-auth.ts b/src/backend/features/auth/request-auth.ts index 78ed707de..d53dd87f8 100644 --- a/src/backend/features/auth/request-auth.ts +++ b/src/backend/features/auth/request-auth.ts @@ -1,32 +1,24 @@ -/** - * Answers a request's auth questions from its session, memoized in KV. `createApp` - * binds `productionAuth` onto every request; guards and routes ask through `c.var`. - */ +/** `createApp` binds `productionAuth` onto every request; routes ask through `c.var`. */ import { env as processEnv } from "process"; import { OAuthApi } from "../../lib/onshape/client"; -import { - getAccessLevel, - getSessionInfo, - getUserId -} from "../../lib/onshape/endpoints/users"; +import { getSessionInfo, getUserId } from "../../lib/onshape/endpoints/users"; import { type AppContext, type AuthResolver } from "../../lib/context"; -import { AccessLevel } from "./access-level"; +import { eq } from "drizzle-orm"; +import { getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import type { LibraryId } from "../library/library-id"; +import { AccessLevel, hasEditorAccess } from "./access-level"; +import { + rememberBackgroundSession, + rememberOverriddenSession +} from "./background-sessions"; import { getOauthClient, makeAuthTokens, TOKEN_ENDPOINT } from "./onshape-oauth"; -import { - accessLevelKey, - getSession, - getSessionCompanyId, - getSessionId, - PERSONAL_COMPANY_ID, - saveSession -} from "./session"; - -/** How long a resolved access level is cached in KV. */ -const ACCESS_LEVEL_TTL_SECONDS = 60 * 60; +import { getSession, getSessionId, saveSession } from "./session"; +import { getSessionCompanyId, PERSONAL_COMPANY_ID } from "./company"; /** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; @@ -43,9 +35,7 @@ export async function getOnshapeApiFromSessionId( .refreshAccessToken(TOKEN_ENDPOINT, session.refreshToken, []) .then((refreshed) => makeAuthTokens(refreshed)); - // Awaited, not floated: a cancelled write leaves the old token in KV - // and every later request refreshes again. Spread, so the refresh keeps - // the userId the session already resolved. + // Awaited: a cancelled write leaves the old token, so every request refreshes. await saveSession(kv, sessionId, { ...session, ...newTokens }); return newTokens.accessToken; @@ -60,11 +50,7 @@ export async function getOnshapeApiFromSessionId( return new OAuthApi(accessToken, refreshCallback); } -/** - * Creates/caches an Onshape API instance from the AppContext. - * - * Note this function should not be called directly, as it is bound to the context directly. - */ +/** Cached on the context; call it through `c.var`. */ export async function getOnshapeApi(c: AppContext): Promise { const cached = c.get("onshapeApi"); if (cached) return cached; @@ -73,12 +59,8 @@ export async function getOnshapeApi(c: AppContext): Promise { return api; } -/** - * The Onshape user a session belongs to, resolved once and kept on it. Taken by - * session id rather than request because work started by one outlives it — a - * render queued under the user who asked for it, which the renderer is keyed by. - */ -export async function getUserIdFromSessionId( +/** Takes a session id, since work a request starts can outlive it. */ +async function getUserIdFromSessionId( kv: KVNamespace, sessionId: string ): Promise { @@ -101,9 +83,7 @@ export async function isAuthenticated(c: AppContext): Promise { try { const onshapeApi = await c.var.getOnshapeApi(); const sessionInfo = await getSessionInfo(onshapeApi); - // Onshape reports no company for a session outside an enterprise; - // PERSONAL_COMPANY_ID is the id it uses for those, and what we store - // for them. + // Onshape reports no company outside an enterprise. const tokenCompanyId = sessionInfo.company?.id ?? PERSONAL_COMPANY_ID; return getSessionCompanyId(c) === tokenCompanyId; } catch { @@ -138,10 +118,7 @@ async function hasOnshapeSession(c: AppContext): Promise { } } -/** - * Whether the caller has a valid Onshape session, memoized on the request. - * `FORCE_SIGNED_IN` stands in for the session it cannot have in development. - */ +/** Memoized on the request. */ export async function isSignedIn(c: AppContext): Promise { const cached = c.get("signedIn"); if (cached !== undefined) return cached; @@ -151,27 +128,54 @@ export async function isSignedIn(c: AppContext): Promise { return signedIn; } -/** Returns the caller's access level, memoized in KV by session. */ -async function getCachedAccessLevel(c: AppContext): Promise { - const key = accessLevelKey(getSessionId(c)); +/** The owner's anywhere; otherwise the library's admin team as last synced. */ +async function getLibraryAccessLevel( + c: AppContext, + libraryId: LibraryId +): Promise { + const userId = await getCachedUserId(c); + const level = await lookUpAccessLevel(c, libraryId, userId); + if (hasEditorAccess(level)) { + await rememberBackgroundSession(c.env.KV, userId, getSessionId(c)); + } + return level; +} - const cached = await c.env.KV.get(key); - if (cached) return cached as AccessLevel; +async function lookUpAccessLevel( + c: AppContext, + libraryId: LibraryId, + userId: string +): Promise { + if (c.env.OWNER_USER_ID && userId === c.env.OWNER_USER_ID) { + return AccessLevel.OWNER; + } + const library = await getDb(c.env.DB) + .select({ adminTeam: libraries.adminTeam }) + .from(libraries) + .where(eq(libraries.id, libraryId)) + .get(); + const member = library?.adminTeam.find((entry) => entry.userId === userId); + if (!member) { + return AccessLevel.USER; + } + return member.isTeamAdmin ? AccessLevel.ADMIN : AccessLevel.EDITOR; +} - const level = await getAccessLevel( - await getOnshapeApi(c), - c.env.ADMIN_TEAM - ); - await c.env.KV.put(key, level, { - expirationTtl: ACCESS_LEVEL_TTL_SECONDS - }); - return level; +/** So a load nobody is signed in behind, like a webhook's, can borrow the session in dev. */ +async function rememberOverriddenUser( + c: AppContext, + override: AccessLevel +): Promise { + if ( + hasEditorAccess(override) && + !isForceSignedIn(c) && + (await isSignedIn(c)) + ) { + await rememberOverriddenSession(c.env.KV, getSessionId(c)); + } } -/** - * The real answers. getUserId only runs behind requireSignInMiddleware; - * getAccessLevel falls back to USER for anyone without a real Onshape session. - */ +/** getAccessLevel falls back to USER without a real session. */ export const productionAuth: AuthResolver = (c) => ({ getOnshapeApi: () => getOnshapeApi(c), getUserId: () => { @@ -181,13 +185,15 @@ export const productionAuth: AuthResolver = (c) => ({ } return getCachedUserId(c); }, - getAccessLevel: async () => { + getAccessLevel: async (libraryId) => { const override = getAccessLevelOverride(c); - if (override) return override; - // getCachedAccessLevel needs a real Onshape session, so only call it - // for a genuinely signed-in caller (not FORCE_SIGNED_IN). + if (override) { + await rememberOverriddenUser(c, override); + return override; + } + // FORCE_SIGNED_IN has no real session to identify the caller. if (!isForceSignedIn(c) && (await isSignedIn(c))) { - return getCachedAccessLevel(c); + return getLibraryAccessLevel(c, libraryId); } return AccessLevel.USER; }, diff --git a/src/backend/features/auth/request-auth.worker.test.ts b/src/backend/features/auth/request-auth.worker.test.ts new file mode 100644 index 000000000..5b955b98f --- /dev/null +++ b/src/backend/features/auth/request-auth.worker.test.ts @@ -0,0 +1,155 @@ +import { env } from "cloudflare:workers"; +import { env as processEnv } from "process"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AccessLevel } from "./access-level"; +import { productionAuth } from "./request-auth"; +import { createApp } from "../../app"; +import { jsonRequest, resetDb, seedLibrary } from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { LibraryId } from "../library/library-id"; +import { saveSession } from "./session"; + +const app = createApp(productionAuth); + +/** What the real caller resolves for a request carrying no Onshape session. */ +async function getMaxAccessLevel(override?: AccessLevel): Promise { + const res = await app.request( + "/api/access-data/library/frc-design-lib", + jsonRequest("GET"), + { + ...env, + VITE_ACCESS_LEVEL_OVERRIDE: override + } + ); + const body: { maxAccessLevel: AccessLevel } = await res.json(); + return body.maxAccessLevel; +} + +describe("the dev access-level override", () => { + const nodeEnv = processEnv.NODE_ENV; + afterEach(() => { + processEnv.NODE_ENV = nodeEnv; + }); + + it("grants the level it names", async () => { + expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( + AccessLevel.ADMIN + ); + }); + + it("is ignored in production", async () => { + processEnv.NODE_ENV = "production"; + expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( + AccessLevel.USER + ); + }); + + it("leaves an unset override to the caller's own session", async () => { + expect(await getMaxAccessLevel()).toBe(AccessLevel.USER); + }); + + // A webhook's load in dev has no other admin session to borrow. + it("keeps the session of an editor or admin it grants", async () => { + const kept = async (override: AccessLevel) => { + await env.KV.delete("background-session:access-level-override"); + const sessionId = crypto.randomUUID(); + await saveSession(env.KV, sessionId, { + accessToken: "token", + refreshToken: "refresh", + expiresAt: Date.now() + 60_000, + userId: "developer" + }); + await app.request( + "/api/access-data/library/frc-design-lib", + { + method: "GET", + headers: { Cookie: `frc-design-app-session=${sessionId}` } + }, + { ...env, VITE_ACCESS_LEVEL_OVERRIDE: override } + ); + return env.KV.get("background-session:access-level-override"); + }; + + expect(await kept(AccessLevel.EDITOR)).toBeTruthy(); + expect(await kept(AccessLevel.USER)).toBeNull(); + }); +}); + +describe("access from a library's admin team", () => { + const OWNER = "owner-user-id"; + + beforeEach(async () => { + const db = getDb(env.DB); + await resetDb(db); + await seedLibrary(db, LibraryId.FRC_DESIGN_LIB); + await seedLibrary(db, LibraryId.FTC_DESIGN_LIB); + await db + .update(libraries) + .set({ + adminTeam: [ + { userId: "member", isTeamAdmin: false }, + { userId: "team-admin", isTeamAdmin: true } + ] + }) + .where(eq(libraries.id, LibraryId.FRC_DESIGN_LIB)); + }); + + /** A signed-in session whose user is already resolved, so Onshape is not asked. */ + async function accessLevelOf( + userId: string, + libraryId: LibraryId = LibraryId.FRC_DESIGN_LIB + ): Promise { + const sessionId = crypto.randomUUID(); + await saveSession(env.KV, sessionId, { + accessToken: "token", + refreshToken: "refresh", + expiresAt: Date.now() + 60_000, + userId + }); + const res = await app.request( + `/api/access-data/library/${libraryId}`, + { + method: "GET", + headers: { Cookie: `frc-design-app-session=${sessionId}` } + }, + { ...env, OWNER_USER_ID: OWNER } + ); + const body: { maxAccessLevel: AccessLevel } = await res.json(); + return body.maxAccessLevel; + } + + it("is the user OWNER_USER_ID names", async () => { + expect(await accessLevelOf(OWNER)).toBe(AccessLevel.OWNER); + }); + + // So a load nobody is signed in behind can run as them. + it("keeps the admin team's sessions, and nobody else's", async () => { + await accessLevelOf("team-admin"); + await accessLevelOf("member"); + await accessLevelOf("stranger"); + expect(await env.KV.get("background-session:team-admin")).toBeTruthy(); + expect(await env.KV.get("background-session:member")).toBeTruthy(); + expect(await env.KV.get("background-session:stranger")).toBeNull(); + }); + + it("makes a member an editor, and a team admin an admin", async () => { + expect(await accessLevelOf("member")).toBe(AccessLevel.EDITOR); + expect(await accessLevelOf("team-admin")).toBe(AccessLevel.ADMIN); + expect(await accessLevelOf("stranger")).toBe(AccessLevel.USER); + }); + + // Access is per library now: one library's team edits that library alone. + it("grants nothing in a library whose team the user is not on", async () => { + expect( + await accessLevelOf("team-admin", LibraryId.FTC_DESIGN_LIB) + ).toBe(AccessLevel.USER); + }); + + it("gives the owner every library", async () => { + expect(await accessLevelOf(OWNER, LibraryId.FTC_DESIGN_LIB)).toBe( + AccessLevel.OWNER + ); + }); +}); diff --git a/src/backend/features/auth/routes.ts b/src/backend/features/auth/routes.ts index 5aad8dc97..3a7b3cbbb 100644 --- a/src/backend/features/auth/routes.ts +++ b/src/backend/features/auth/routes.ts @@ -1,7 +1,6 @@ -import { HttpStatus } from "http-status-ts"; -import { internalError } from "../../lib/api-error"; import { getApp } from "../../lib/context"; import { cacheMiddleware } from "../../lib/cache"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { type AccessData } from "./access-level"; import { isSignedIn } from "./request-auth"; import { doCallback, doSignIn } from "./onshape-oauth"; @@ -13,99 +12,40 @@ export const authRoutes = getApp(); /** What the app needs to know about the caller, mounted at /api. */ export const accessRoutes = getApp(); -/** GET /api/access-data */ -accessRoutes.get("/access-data", cacheMiddleware(), async (c) => { - return c.json({ - maxAccessLevel: await c.var.getAccessLevel(), - signedIn: await isSignedIn(c) - } satisfies AccessData); -}); - -/** The app's own entry, which re-runs the gate and opens wherever it lands. */ -const ENTRY_PATH = "/init"; - -/** Onshape's own hosts. An enterprise is a subdomain, so the zone is allowed. */ -function isOnshapeUrl(url: URL): boolean { - return ( - url.protocol === "https:" && - (url.hostname === "onshape.com" || - url.hostname.endsWith(".onshape.com")) - ); -} - -/** - * Where a finished sign-in may land the caller. - * - * `redirectUrl` is ours: `/init` builds it from the launch it was called with, - * so it is the only target that returns the caller to the element they opened - * the panel on. It wins wherever there is one. - * - * `redirectOnshapeUri` is Onshape's, set when Onshape sends a caller here - * itself, and the callback hands whatever was stored to `c.redirect` unread. So - * it is only taken as an absolute url on Onshape. Onshape does not document - * whether it can be a bare path, and one would be the more damaging case: it - * resolves against this origin instead, landing the caller on a url the app has - * no route for, with none of the launch parameters the panel needs. - * - * Anything else falls back to the entry rather than refusing the sign-in. What - * Onshape actually sends here is not something we can see from the outside, so - * a value this does not recognize is as likely to be our own reading being too - * narrow as it is to be hostile, and opening the app without the caller's - * element beats leaving them unable to sign in at all. - */ -function getSignInRedirect(query: Record): string | undefined { - const { redirectUrl, redirectOnshapeUri } = query; - if (redirectUrl?.startsWith("/") && !redirectUrl.startsWith("//")) { - return redirectUrl; - } - if (!redirectOnshapeUri) { - return undefined; +/** GET /api/access-data/library/:libraryId */ +accessRoutes.get( + "/access-data" + libraryRoute(), + cacheMiddleware(), + async (c) => { + return c.json({ + maxAccessLevel: await c.var.getAccessLevel(getLibraryParam(c)), + signedIn: await isSignedIn(c) + } satisfies AccessData); } - try { - if (isOnshapeUrl(new URL(redirectOnshapeUri))) { - return redirectOnshapeUri; - } - } catch { - // Not a url at all, which the fallback covers along with a bad one. - } - return ENTRY_PATH; -} - -authRoutes.get("/sign-in", async (c) => { - const query = c.req.query(); +); - const redirectUrl = getSignInRedirect(query); - if (!redirectUrl) { - throw internalError( - "Failed to find valid redirectUrl", - HttpStatus.BAD_REQUEST - ); +/** A path within the app, so the parameter cannot forward a caller offsite. */ +function toLocalPath(redirectUrl: string | undefined): string { + if (!redirectUrl?.startsWith("/") || redirectUrl.startsWith("//")) { + return "/"; } + return redirectUrl; +} - // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the - // user can pick their account on Onshape. - const companyId = query.sessionCompanyId; - const authorizationUrl = await doSignIn(c, redirectUrl, companyId); - return c.redirect(authorizationUrl); +/** GET /auth/sign-in?redirectUrl=&sessionCompanyId= */ +authRoutes.get("/sign-in", (c) => { + const redirectUrl = toLocalPath(c.req.query("redirectUrl")); + // Absent standalone, so the user can pick their account on Onshape. + const companyId = c.req.query("sessionCompanyId"); + return c.redirect(doSignIn(c, redirectUrl, companyId)); }); -/** - * Standalone only: inside Onshape the panel's session is Onshape's to end. - * Where the caller lands is theirs to say, as long as it is this app. - */ +/** Standalone only: inside Onshape, the session is Onshape's to end. */ authRoutes.get("/sign-out", async (c) => { await endSession(c); - return c.redirect(getLocalRedirect(c.req.query("redirectUrl"))); + return c.redirect(toLocalPath(c.req.query("redirectUrl"))); }); -/** A path within the app, so the parameter cannot forward a caller offsite. */ -function getLocalRedirect(redirectUrl: string | undefined): string { - if (!redirectUrl?.startsWith("/") || redirectUrl.startsWith("//")) { - return "/"; - } - return redirectUrl; -} - authRoutes.get("/callback", async (c) => { return doCallback(c); }); diff --git a/src/backend/features/auth/routes.test.ts b/src/backend/features/auth/routes.worker.test.ts similarity index 58% rename from src/backend/features/auth/routes.test.ts rename to src/backend/features/auth/routes.worker.test.ts index 6f5b90dfd..d70d0a34e 100644 --- a/src/backend/features/auth/routes.test.ts +++ b/src/backend/features/auth/routes.worker.test.ts @@ -15,7 +15,7 @@ describe("GET /access-data", () => { const app = createTestApp({ accessLevel: AccessLevel.EDITOR }); const res = await app.request( - "/api/access-data", + "/api/access-data/library/frc-design-lib", jsonRequest("GET"), env ); @@ -34,7 +34,7 @@ describe("GET /access-data", () => { }); const res = await app.request( - "/api/access-data", + "/api/access-data/library/frc-design-lib", jsonRequest("GET"), env ); @@ -47,19 +47,18 @@ describe("GET /access-data", () => { }); }); -const SESSION_COOKIE = "frc-design-app-cookie"; +const SESSION_COOKIE = "frc-design-app-session"; /** A signed-in session, as the OAuth callback would have left it. */ async function seedSession(sessionId: string) { await env.KV.put( - `tokens:${sessionId}`, + `session:${sessionId}`, JSON.stringify({ accessToken: "a", refreshToken: "r", expiresAt: Date.now() + 10000 }) ); - await env.KV.put(`access-level:${sessionId}`, AccessLevel.ADMIN); } describe("GET /auth/sign-out", () => { @@ -77,15 +76,14 @@ describe("GET /auth/sign-out", () => { ); } - it("drops the session and everything keyed to it", async () => { + it("drops the session", async () => { await seedSession("session-1"); const res = await signOut("/app/library/frc-design-lib", "session-1"); expect(res.status).toBe(302); expect(res.headers.get("Location")).toBe("/app/library/frc-design-lib"); - expect(await env.KV.get("tokens:session-1")).toBeNull(); - expect(await env.KV.get("access-level:session-1")).toBeNull(); + expect(await env.KV.get("session:session-1")).toBeNull(); expect(res.headers.get("Set-Cookie")).toContain(`${SESSION_COOKIE}=;`); }); @@ -125,12 +123,12 @@ describe("GET /auth/sign-in", () => { return new URL(res.headers.get("Location")!); } - // Named by neither half of the flow, so Onshape returns the caller to - // whichever redirect url its OAuth app registers — which is why a host - // wants an app of its own. - it("names no callback, leaving it to the OAuth app", async () => { + // It has to match one registered on the Onshape OAuth app. + it("names the callback on the app's own url", async () => { const url = await authorizationUrl(""); - expect(url.searchParams.get("redirect_uri")).toBeNull(); + expect(url.searchParams.get("redirect_uri")).toBe( + `${env.APP_URL}/auth/callback` + ); }); it("scopes the sign-in to the enterprise that launched it", async () => { @@ -138,8 +136,7 @@ describe("GET /auth/sign-in", () => { expect(url.searchParams.get("company_id")).toBe("company-1"); }); - // Onshape rejects "cad" as a company, which is what a non-enterprise user - // arrives with. + // Onshape rejects "cad", which a non-enterprise user arrives with. it("names no company for a personal account", async () => { const url = await authorizationUrl("sessionCompanyId=cad"); expect(url.searchParams.has("company_id")).toBe(false); @@ -150,9 +147,7 @@ describe("GET /auth/sign-in", () => { expect(url.searchParams.has("company_id")).toBe(false); }); - // The gate sends a caller here whenever Onshape will not take their - // session, so a sign-in they never finish must not sign them out of the one - // they arrived with. + // An unfinished sign-in mustn't end the session the caller had. it("leaves the session the caller arrived with alone", async () => { await seedSession("session-1"); @@ -160,16 +155,12 @@ describe("GET /auth/sign-in", () => { const setCookie = res.headers.get("Set-Cookie") ?? ""; expect(setCookie).not.toContain(`${SESSION_COOKIE}=`); - expect(await env.KV.get("tokens:session-1")).not.toBeNull(); + expect(await env.KV.get("session:session-1")).not.toBeNull(); }); }); const LOGIN_COOKIE = "frc-design-app-login"; -/** - * Where the callback would send the caller: what the sign-in stored, which - * `doCallback` redirects to unread. - */ describe("GET /auth/sign-in redirect target", () => { async function storedRedirect(query: string): Promise { const res = await createTestApp().request( @@ -179,74 +170,61 @@ describe("GET /auth/sign-in redirect target", () => { ); if (res.status !== 302) return undefined; - const loginId = new RegExp(`${LOGIN_COOKIE}=([^;]+)`).exec( + const login = new RegExp(`${LOGIN_COOKIE}=([^;]+)`).exec( res.headers.get("Set-Cookie") ?? "" )?.[1]; - expect(loginId).toBeDefined(); - const raw = await env.KV.get(`login-session:${loginId!}`); - return (JSON.parse(raw!) as { redirectUrl: string }).redirectUrl; + expect(login).toBeDefined(); + return ( + JSON.parse(decodeURIComponent(login!)) as { redirectUrl: string } + ).redirectUrl; } - it("returns the caller to the launch /init named", async () => { + it("returns the caller to the path it was given", async () => { expect( await storedRedirect("redirectUrl=%2Finit%3FdocumentId%3Ddoc-1") ).toBe("/init?documentId=doc-1"); }); - // Onshape's own value names Onshape, and an enterprise is a subdomain. - it("takes an Onshape url from Onshape", async () => { - expect( - await storedRedirect( - "redirectOnshapeUri=https%3A%2F%2Fcompany.onshape.com%2Fdocuments%2Fdoc-1" - ) - ).toBe("https://company.onshape.com/documents/doc-1"); - }); - - // Ours carries the element the panel was opened on; Onshape's does not. - it("prefers the launch over Onshape's own value", async () => { - expect( - await storedRedirect( - "redirectUrl=%2Finit%3FdocumentId%3Ddoc-1&redirectOnshapeUri=https%3A%2F%2Fcad.onshape.com%2Fdocuments" - ) - ).toBe("/init?documentId=doc-1"); - }); - - // The reported bug: resolved against this origin it is a url with no route, - // and the caller lands on the app's not-found page with no way back into - // the document they came from. - it("refuses a bare path, which would resolve against this app", async () => { - expect( - await storedRedirect( - "redirectOnshapeUri=%2Fdocuments%2Fdoc-1%2Fw%2Fws-1%2Fe%2Fel-1" - ) - ).toBe("/init"); - }); - - it("refuses an origin that is not Onshape", async () => { - expect( - await storedRedirect( - "redirectOnshapeUri=https%3A%2F%2Fevil.com%2Fx" - ) - ).toBe("/init"); - // A host that merely ends in the zone's spelling is not in it. - expect( - await storedRedirect( - "redirectOnshapeUri=https%3A%2F%2Fnotonshape.com%2Fx" - ) - ).toBe("/init"); + it.each([ + ["nothing", ""], + ["an absolute url", "redirectUrl=https%3A%2F%2Fevil.com%2Fx"], + ["a protocol-relative url", "redirectUrl=%2F%2Fevil.com"], + [ + "Onshape's own redirect", + "redirectOnshapeUri=https%3A%2F%2Fcad.onshape.com%2Fdocuments" + ] + ])("sends the caller home given %s", async (_, query) => { + expect(await storedRedirect(query)).toBe("/"); }); +}); - // Refusing outright would leave a caller unable to sign in at all if this - // reading of what Onshape sends turns out to be too narrow. - it("opens the app rather than refusing a value it cannot place", async () => { - expect(await storedRedirect("redirectOnshapeUri=not-a-url")).toBe( - "/init" +describe("GET /auth/callback", () => { + function callback(query: string, login?: object) { + return createTestApp().request( + `/auth/callback?${query}`, + { + method: "GET", + headers: login + ? { + Cookie: `${LOGIN_COOKIE}=${encodeURIComponent(JSON.stringify(login))}` + } + : {}, + redirect: "manual" + }, + env ); + } + + it("sends a caller whose browser dropped the login cookie to the cookie error", async () => { + const res = await callback("code=c&state=s"); + expect(res.headers.get("Location")).toBe("/cookie-error"); }); - it("refuses a protocol-relative url, which names another host", async () => { - expect( - await storedRedirect("redirectUrl=%2F%2Fevil.com") - ).toBeUndefined(); + it("refuses a state it did not hand out", async () => { + const res = await callback("code=c&state=forged", { + state: "s", + redirectUrl: "/" + }); + expect(res.status).toBe(401); }); }); diff --git a/src/backend/features/auth/session.ts b/src/backend/features/auth/session.ts index 90cf94ce2..e7d6da6c6 100644 --- a/src/backend/features/auth/session.ts +++ b/src/backend/features/auth/session.ts @@ -1,23 +1,18 @@ -/** Session cookie plus the KV records it keys: the session and login state. */ +/** + * A signed-in session. Its cookie holds only an opaque id; the tokens and user + * it keys stay in KV. The sign-in in flight is `login.ts`'s, and holds nothing + * here. + */ import { HttpStatus } from "http-status-ts"; import { internalError } from "../../lib/api-error"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { type AppContext } from "../../lib/context"; +import { kvStore } from "../../lib/kv-store"; +import { COOKIE_OPTIONS } from "./cookie-options"; -const SESSION_COOKIE = "frc-design-app-cookie"; -/** Held only for the OAuth round trip, so an abandoned one costs the session nothing. */ -const LOGIN_COOKIE = "frc-design-app-login"; -const LOGIN_TTL = 600; // 10 minutes +const SESSION_COOKIE = "frc-design-app-session"; const SESSION_TTL = 30 * 24 * 3600; // 30 days -/** SameSite=None + secure required because the app runs embedded in an Onshape iframe. */ -const COOKIE_OPTIONS = { - httpOnly: true, - secure: true, - sameSite: "None", - path: "/" -} as const; - export function getSessionId(c: AppContext): string { const sessionId = getCookie(c, SESSION_COOKIE); if (!sessionId) { @@ -29,17 +24,6 @@ export function getSessionId(c: AppContext): string { return sessionId; } -/** - * Onshape's company id for a session outside an enterprise. It is what - * `/init` carries for a plain cad.onshape.com user, and it is not a company - * OAuth will accept. - */ -export const PERSONAL_COMPANY_ID = "cad"; - -export function getSessionCompanyId(c: AppContext) { - return c.req.query("sessionCompanyId") ?? PERSONAL_COMPANY_ID; -} - export interface AuthTokens { accessToken: string; refreshToken: string; @@ -52,46 +36,18 @@ interface Session extends AuthTokens { userId?: string; } -/** Still `tokens:`, so sessions signed in before this held a userId survive. */ -function sessionKey(sessionId: string): string { - return `tokens:${sessionId}`; -} - -/** Keyed by session, so it is dropped along with one. */ -export function accessLevelKey(sessionId: string): string { - return `access-level:${sessionId}`; -} +const sessions = kvStore("session", { ttlSeconds: SESSION_TTL }); -function loginKey(loginId: string): string { - return `login-session:${loginId}`; -} - -/** Drops what a session id keys; the cookie is the caller's to clear. */ -async function dropSession(kv: KVNamespace, sessionId: string): Promise { - await Promise.all([ - kv.delete(sessionKey(sessionId)), - kv.delete(accessLevelKey(sessionId)) - ]); -} - -/** - * Signs the caller out: the tokens and what was resolved from them go, and the - * cookie with them, so the next request is simply a stranger's. - */ export async function endSession(c: AppContext): Promise { const sessionId = getCookie(c, SESSION_COOKIE); if (sessionId) { - await dropSession(c.env.KV, sessionId); + await sessions.delete(c.env.KV, sessionId); } // Matched to how it was set, or the browser keeps the cookie. deleteCookie(c, SESSION_COOKIE, COOKIE_OPTIONS); } -/** - * Puts the caller in a newly signed-in session, and drops the one they came - * with. The id is minted here rather than at sign-in, so it is only ever - * replaced by a sign-in that finished, and never carries over one that did not. - */ +/** Minted here, so a session is only replaced by a sign-in that finished. */ export async function beginSession( c: AppContext, tokens: AuthTokens @@ -104,7 +60,7 @@ export async function beginSession( }); await saveSession(c.env.KV, sessionId, tokens); if (previousSessionId) { - await dropSession(c.env.KV, previousSessionId); + await sessions.delete(c.env.KV, previousSessionId); } } @@ -113,64 +69,19 @@ export async function saveSession( sessionId: string, session: Session ) { - await kv.put(sessionKey(sessionId), JSON.stringify(session), { - expirationTtl: SESSION_TTL - }); + await sessions.put(kv, sessionId, session); } export async function getSession( kv: KVNamespace, sessionId: string ): Promise { - const raw = await kv.get(sessionKey(sessionId)); - if (!raw) { + const session = await sessions.get(kv, sessionId); + if (!session) { throw internalError( "Failed to find valid auth tokens to use", HttpStatus.UNAUTHORIZED ); } - return JSON.parse(raw) as Session; -} - -/** What the callback needs to finish a sign-in it did not start. */ -interface LoginSession { - state: string; - redirectUrl: string; -} - -/** Single-use: reading it also clears it, so a state cannot be replayed. */ -export async function takeLoginSession( - c: AppContext -): Promise { - const loginId = getCookie(c, LOGIN_COOKIE); - if (!loginId) return null; - const raw = await c.env.KV.get(loginKey(loginId)); - if (!raw) return null; - - const session = JSON.parse(raw) as LoginSession; - - deleteCookie(c, LOGIN_COOKIE, COOKIE_OPTIONS); - void c.env.KV.delete(loginKey(loginId)); return session; } - -/** - * Starts an OAuth round trip. It rides its own cookie: `/init` sends a caller - * here whenever Onshape will not take their session, and one who never comes - * back through the callback — the sign-in failed, or they closed the panel — - * keeps the session they arrived with. - */ -export async function startLoginSession( - c: AppContext, - data: LoginSession -): Promise { - const loginId = crypto.randomUUID(); - setCookie(c, LOGIN_COOKIE, loginId, { - ...COOKIE_OPTIONS, - maxAge: LOGIN_TTL - }); - - await c.env.KV.put(loginKey(loginId), JSON.stringify(data), { - expirationTtl: LOGIN_TTL - }); -} diff --git a/src/backend/features/build-checker/checks.ts b/src/backend/features/build-checker/checks.ts index 8d615a920..7a189e13e 100644 --- a/src/backend/features/build-checker/checks.ts +++ b/src/backend/features/build-checker/checks.ts @@ -11,10 +11,7 @@ interface GroupCheckInput { hasFailedInsertables: boolean; } -/** - * Computes build-time issues for a group. Pure: takes already-resolved signals - * from the load-document workflow rather than fetching anything itself. - */ +/** Pure: the load resolves the signals. */ export function checkGroup(input: GroupCheckInput): BuildIssue[] { let issues: BuildIssue[] = []; @@ -43,10 +40,7 @@ interface InsertableCheckInput { thumbnailUrls: ThumbnailUrls | null; } -/** - * Computes build-time issues for an insertable. Pure: takes already-resolved - * signals from the load-document workflow rather than fetching anything itself. - */ +/** Pure: the load resolves the signals. */ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { let issues: BuildIssue[] = []; diff --git a/src/backend/features/build-checker/contract.ts b/src/backend/features/build-checker/contract.ts index 533911228..d0e8ad256 100644 --- a/src/backend/features/build-checker/contract.ts +++ b/src/backend/features/build-checker/contract.ts @@ -13,7 +13,7 @@ export interface GroupBuildStatus { sortAlphabetically: boolean; insertableOrder: string[]; /** When Onshape cut the version this group is pinned to (epoch ms). */ - versionCreatedAt: number | null; + versionCreatedAt?: number; } export interface InsertableBuildStatus { @@ -24,10 +24,12 @@ export interface InsertableBuildStatus { isVisible: boolean; supportsFasten: boolean; indexConfigurations: boolean; + /** Parameters an admin left out of indexing. */ + excludedParameterIds: string[]; vendors: Vendor[]; configuration?: ConfigurationBuildStatus; /** When Onshape cut the version this insertable is pinned to (epoch ms). */ - versionCreatedAt: number | null; + versionCreatedAt?: number; } export interface LibraryBuildStatus { diff --git a/src/backend/features/build-checker/issues.test.ts b/src/backend/features/build-checker/issues.test.ts index a6a1864b4..56c6fbe4a 100644 --- a/src/backend/features/build-checker/issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -6,8 +6,8 @@ import { BuildIssueSeverity, BuildIssueType, clearBuildIssue, - getIssueConfigurationKey, - getIssueDescription, + getIssueConfiguration, + getIssueTitle, getMaxSeverity, knownBuildIssues } from "./issues"; @@ -23,34 +23,34 @@ const issue = (severity: BuildIssueSeverity): BuildIssue => ({ type: TYPE_BY_SEVERITY[severity] }); -describe("getIssueConfigurationKey", () => { +describe("getIssueConfiguration", () => { it("names the configuration an issue blames", () => { expect( - getIssueConfigurationKey({ + getIssueConfiguration({ type: BuildIssueType.UNSTABLE_COMPOSITE, - configurationKey: "size=large", + values: { size: "large" }, configurationCount: 1 }) - ).toBe("size=large"); + ).toEqual({ size: "large" }); }); it("names none where the element itself is at fault", () => { expect( - getIssueConfigurationKey({ type: BuildIssueType.MULTIPLE_PARTS }) + getIssueConfiguration({ type: BuildIssueType.MULTIPLE_PARTS }) ).toBeUndefined(); }); }); -describe("getIssueDescription", () => { +describe("getIssueTitle", () => { // The count is the difference between "go fix this one" and "go fix forty". it.each([ - [1, "A configuration resolves to more than one part"], - [4, "4 configurations resolve to more than one part"] + [1, "A configuration has more than one part"], + [4, "4 configurations have more than one part"] ])("counts %i offending configurations", (count, expected) => { expect( - getIssueDescription({ + getIssueTitle({ type: BuildIssueType.CONFIGURATION_MULTIPLE_PARTS, - configurationKey: "size=large", + values: { size: "large" }, configurationCount: count }) ).toBe(expected); @@ -58,7 +58,6 @@ describe("getIssueDescription", () => { }); describe("knownBuildIssues", () => { - /** A type an older deploy stored, cast because this build no longer has it. */ const retired = { type: "thumbnail-pending" } as unknown as BuildIssue; it("drops a type this build has no check for", () => { @@ -67,9 +66,18 @@ describe("knownBuildIssues", () => { ).toEqual([{ type: BuildIssueType.LOAD_FAILED }]); }); + it("keeps an issue stored before issues carried values, unlinked", () => { + const stored = { + type: BuildIssueType.UNSTABLE_COMPOSITE, + configurationKey: "size=large", + configurationCount: 2 + } as unknown as BuildIssue; + const [kept] = knownBuildIssues([stored]); + expect(kept.type).toBe(BuildIssueType.UNSTABLE_COMPOSITE); + expect(getIssueConfiguration(kept)).toBeUndefined(); + }); + it("keeps every type it knows", () => { - // Only the type is read, so the ones carrying a configuration stand up - // bare here rather than being built twice. const issues = Object.values(BuildIssueType).map( (type) => ({ type }) as BuildIssue ); @@ -79,7 +87,7 @@ describe("knownBuildIssues", () => { describe("getMaxSeverity", () => { it("returns null when there are no issues", () => { - expect(getMaxSeverity([])).toBeNull(); + expect(getMaxSeverity([])).toBeUndefined(); }); const { INFO, WARNING, ERROR } = BuildIssueSeverity; @@ -108,8 +116,7 @@ describe("addBuildIssue", () => { expect(result).toEqual(existing); }); - // Callers hold onto the array they passed in, so it must never be the one - // that comes back, even when there was nothing to add. + // Callers keep the array they passed in. it("returns a new array even when nothing is added", () => { const existing: BuildIssue[] = [{ type: BuildIssueType.NO_VENDORS }]; expect( diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index 0d625d82d..0fcd620eb 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -1,12 +1,9 @@ -/** - * Data-quality issues for groups and insertables. Most are stored at load time; - * a few are computed live where they depend on per-user state. - */ +/** Mostly stored at load time; a few are computed live from per-user state. */ import { AUTO_INDEX_THRESHOLD, MAX_PART_NUMBER_CONFIGURATIONS } from "../configurations/combinations"; -import type { ConfigurationKey } from "../configurations/contract"; +import type { PartialSelection } from "../configurations/contract"; export enum BuildIssueSeverity { /** A potential issue that is usually fine, e.g. no vendors parsed. */ @@ -33,28 +30,21 @@ export enum BuildIssueType { LOAD_FAILED = "load-failed" } -/** - * Base shape for a build issue, discriminated on type. - */ interface BuildIssueOf { type: T; } -/** - * An issue particular configurations raise, which the element's own defaults do - * not — a problem with those configurations rather than with the part itself. - */ +/** Raised by some configurations but not the defaults. */ interface ConfigurationBuildIssueOf< T extends ConfigurationIssueType > extends BuildIssueOf { - /** The first offender, which the build card links out to. */ - configurationKey: ConfigurationKey; + /** The first offender's values, which the build card links out to. */ + values: PartialSelection; /** How many configurations raise it, that first one included. */ configurationCount: number; } -/** The issue types a configuration raises, rather than the element itself. */ -export type ConfigurationIssueType = +type ConfigurationIssueType = | BuildIssueType.CONFIGURATION_MULTIPLE_PARTS | BuildIssueType.UNSTABLE_COMPOSITE; @@ -72,73 +62,81 @@ export type BuildIssue = | BuildIssueOf | BuildIssueOf; -/** - * Builds the issue a set of offending configurations raises. The first is the - * one the card links out to; the rest are only counted. - */ +/** The card links to the first; the rest are counted. */ export function toConfigurationIssue( type: ConfigurationIssueType, - offenders: { configurationKey: ConfigurationKey }[] + offenders: { values: PartialSelection }[] ): BuildIssue { return { type, - configurationKey: offenders[0].configurationKey, + values: offenders[0].values, configurationCount: offenders.length }; } -/** - * The configuration an issue blames, or undefined where the element itself is - * at fault and there is nothing narrower to open. - */ -export function getIssueConfigurationKey( +/** Undefined when the element itself is at fault. */ +export function getIssueConfiguration( issue: BuildIssue -): ConfigurationKey | undefined { - return "configurationKey" in issue ? issue.configurationKey : undefined; +): PartialSelection | undefined { + return "values" in issue ? issue.values : undefined; } const BUILD_ISSUE_TYPES = new Set(Object.values(BuildIssueType)); -/** - * Drops issues this deploy has no check for. A stored array was written by - * whichever deploy last loaded the row, so it can name a type since removed from - * `BuildIssueType`, which has no severity or description to render. - */ +/** Drops types a later deploy removed. */ export function knownBuildIssues(issues: BuildIssue[]): BuildIssue[] { return issues.filter((issue) => BUILD_ISSUE_TYPES.has(issue.type)); } -/** A human-readable description of a build issue, shown to editors. */ -export function getIssueDescription(issue: BuildIssue): string { +/** What is wrong, in a line. */ +export function getIssueTitle(issue: BuildIssue): string { switch (issue.type) { case BuildIssueType.THUMBNAIL_FAILED: - return "Thumbnail failed to generate"; + return "Thumbnail failed to render"; case BuildIssueType.NO_THUMBNAIL_TAB: - return "No thumbnail tab set"; + return "No document thumbnail set"; case BuildIssueType.NO_VENDORS: - return "No vendors could be parsed"; + return "No vendors found"; case BuildIssueType.NO_PARTS: - return "This part studio has no parts"; + return "Part studio has no parts"; case BuildIssueType.NO_UNHIDDEN_INSERTABLES: - return "No unhidden insertables"; + return "Every element is hidden"; case BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED: - return `Over the ${MAX_PART_NUMBER_CONFIGURATIONS} configuration limit, so its configurations cannot be indexed`; + return "Too many configurations to index"; case BuildIssueType.MANUAL_INDEXING_REQUIRED: - return `Over ${AUTO_INDEX_THRESHOLD} configurations, so indexing must be enabled manually`; + return "Indexing is off"; case BuildIssueType.MULTIPLE_PARTS: - return "This part studio has more than one part"; + return "Part studio has more than one part"; case BuildIssueType.CONFIGURATION_MULTIPLE_PARTS: return issue.configurationCount === 1 - ? "A configuration resolves to more than one part" - : `${issue.configurationCount} configurations resolve to more than one part`; + ? "A configuration has more than one part" + : `${issue.configurationCount} configurations have more than one part`; case BuildIssueType.UNSTABLE_COMPOSITE: return issue.configurationCount === 1 - ? "A configuration does not use the part studio's open composite" - : `${issue.configurationCount} configurations do not use the part studio's open composite`; + ? "A configuration has no open composite part" + : `${issue.configurationCount} configurations have no open composite part`; case BuildIssueType.INSERTABLES_FAILED: - return "Some child insertables failed to load"; + return "Some elements failed to load"; case BuildIssueType.LOAD_FAILED: - return "Failed to load from Onshape"; + return "Document failed to load"; + } +} + +/** The fix or reason, only where knowing Onshape doesn't make it plain. */ +export function getIssueDescription(issue: BuildIssue): string | undefined { + switch (issue.type) { + case BuildIssueType.THUMBNAIL_FAILED: + return "Use Reload thumbnail to try again."; + case BuildIssueType.NO_VENDORS: + return "Vendors are parsed from names and codes in the name or configuration options."; + case BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED: + return `Stop indexing parameters to get under ${MAX_PART_NUMBER_CONFIGURATIONS}.`; + case BuildIssueType.MANUAL_INDEXING_REQUIRED: + return `Elements with ${AUTO_INDEX_THRESHOLD}+ configurations must be enabled manually.`; + case BuildIssueType.UNSTABLE_COMPOSITE: + return "The default configuration has one, so every configuration should."; + default: + return undefined; } } @@ -163,10 +161,7 @@ export function getIssueSeverity(issue: BuildIssue): BuildIssueSeverity { } } -/** - * Adds each of `newIssues` to `issues`, skipping any whose type is already - * present, and returning a new array only when something was added. - */ +/** Skips types already present; returns the same array if nothing was added. */ export function addBuildIssue( issues: BuildIssue[], ...newIssues: BuildIssue[] @@ -188,9 +183,6 @@ export function hasBuildIssue( return issues.some((issue) => types.includes(issue.type)); } -/** - * Removes any issue whose type is one of `types`. - */ export function clearBuildIssue( issues: BuildIssue[], ...types: BuildIssueType[] @@ -205,17 +197,14 @@ const SEVERITY_ORDER: BuildIssueSeverity[] = [ BuildIssueSeverity.ERROR ]; -/** - * Returns the worst severity present in `issues`, or `null` when there are no issues. - */ export function getMaxSeverity( issues: BuildIssue[] -): BuildIssueSeverity | null { - let max: BuildIssueSeverity | null = null; +): BuildIssueSeverity | undefined { + let max: BuildIssueSeverity | undefined; for (const issue of issues) { const severity = getIssueSeverity(issue); if ( - max === null || + max === undefined || SEVERITY_ORDER.indexOf(severity) > SEVERITY_ORDER.indexOf(max) ) { max = severity; diff --git a/src/backend/features/build-checker/routes.ts b/src/backend/features/build-checker/routes.ts index bbf4dc0b3..28a1b1362 100644 --- a/src/backend/features/build-checker/routes.ts +++ b/src/backend/features/build-checker/routes.ts @@ -18,8 +18,7 @@ export const buildStatusRoutes = getApp(); buildStatusRoutes.get( "/build-status" + libraryRoute(), requireEditorMiddleware, - // The same for every editor, but only for an editor: a shared cache would - // hand it to whoever asked for the url next. + // Private: only editors may see it. cacheMiddleware(CachePolicy.PRIVATE_CACHE), async (c) => { const libraryId = getLibraryParam(c); @@ -50,6 +49,7 @@ buildStatusRoutes.get( isVisible: insertables.isVisible, supportsFasten: insertables.supportsFasten, indexConfigurations: insertables.indexConfigurations, + excludedParameterIds: insertables.excludedParameterIds, vendors: insertables.vendors, sortOrder: insertables.sortOrder, versionCreatedAt: insertables.versionCreatedAt @@ -60,9 +60,7 @@ buildStatusRoutes.get( .all() ]); - // Joined to the library rather than filtered by the ids just read: D1 - // takes at most 100 bound parameters in a statement, and an `inArray` - // binds one per id, so listing them fails on any real library. + // Joined rather than filtered by id: D1 binds at most 100 parameters. const allConfigurations = await db .select({ insertableId: configurations.insertableId, @@ -92,7 +90,7 @@ buildStatusRoutes.get( buildIssues: group.buildIssues, sortAlphabetically: group.sortAlphabetically, insertableOrder: groupInsertables.map((ins) => ins.id), - versionCreatedAt: group.versionCreatedAt?.getTime() ?? null + versionCreatedAt: group.versionCreatedAt?.getTime() }; } @@ -105,9 +103,10 @@ buildStatusRoutes.get( isVisible: ins.isVisible, supportsFasten: ins.supportsFasten, indexConfigurations: ins.indexConfigurations, + excludedParameterIds: ins.excludedParameterIds, vendors: ins.vendors, configuration: configMap.get(ins.id), - versionCreatedAt: ins.versionCreatedAt?.getTime() ?? null + versionCreatedAt: ins.versionCreatedAt?.getTime() }; } diff --git a/src/backend/features/build-checker/routes.test.ts b/src/backend/features/build-checker/routes.worker.test.ts similarity index 91% rename from src/backend/features/build-checker/routes.test.ts rename to src/backend/features/build-checker/routes.worker.test.ts index 07b5f1738..986b66bd3 100644 --- a/src/backend/features/build-checker/routes.test.ts +++ b/src/backend/features/build-checker/routes.worker.test.ts @@ -25,8 +25,7 @@ describe("GET /build-status", () => { beforeEach(() => resetDb(db)); afterEach(() => vi.restoreAllMocks()); - // The date the card shows is the version's, not the sync's: a library - // reloaded today off a year-old version is a year-old version. + // The version's date, not the sync's. it("returns each group's and insertable's version date", async () => { await seedPartStudio(db); await db @@ -88,12 +87,10 @@ describe("GET /build-status", () => { expect(res.status).toBe(200); const body: LibraryBuildStatus = await res.json(); - expect(body.groups[TEST_GROUP_ID].versionCreatedAt).toBeNull(); + expect(body.groups[TEST_GROUP_ID].versionCreatedAt).toBeUndefined(); }); - // D1 takes at most 100 bound parameters in a statement and an `inArray` - // binds one per value, so listing every insertable id failed 500 here on - // any library past that — every real one. + // D1 binds at most 100 parameters, and `inArray` binds one per value. it("serves a library with more insertables than a statement can bind", async () => { const count = 120; await seedGroup(db); @@ -122,8 +119,7 @@ describe("GET /build-status", () => { ); }); - // A removed check has no severity or description, so left in it renders as a - // blank callout and steals the row's status color. + // A removed check would render as a blank callout. it("drops a stored issue whose type this build no longer has", async () => { await seedPartStudio(db); // Written as raw JSON, the way the deploy that still had the check did. diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index 7b07c85f6..0c30e0dde 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -12,8 +12,7 @@ import { import { OptionVisibilityType, ConfigurationParameter, - ParameterType, - StringParameter, + ParameterRole, VisibilityCondition, VisibilityType } from "./contract"; @@ -21,19 +20,10 @@ import { boolParam, enumParam, paramsWithConfigs, - quantityParam + quantityParam, + stringParam } from "../../../__test_utils__/configuration-fixtures"; -function stringParam(id: string): StringParameter { - return { - id, - name: id, - default: "", - isCosmetic: false, - type: ParameterType.STRING - }; -} - const equals = (id: string, value: string): VisibilityCondition => ({ type: VisibilityType.EQUAL, id, @@ -70,14 +60,13 @@ describe("enumerateConfigurations", () => { expect(configurations).toEqual([{ A: "a1" }, { A: "a2" }]); }); - it("ignores cosmetic parameters", () => { + it("leaves an excluded parameter at its default", () => { const params: ConfigurationParameter[] = [ enumParam("A", ["a1", "a2"]), - enumParam("C", ["c1", "c2"], { isCosmetic: true }), + enumParam("C", ["c1", "c2"]), boolParam("B") ]; - const { configurations } = enumerateConfigurations(params); - // C ("exclude from properties") rides its default, so only A and B vary. + const { configurations } = enumerateConfigurations(params, ["C"]); expect(configurations).toHaveLength(4); expect(configurations.every((c) => !("C" in c))).toBe(true); }); @@ -127,7 +116,11 @@ describe("enumerateConfigurations", () => { boolParam("B"), boolParam("C") ]; - const { configurations, capped } = enumerateConfigurations(params, 4); + const { configurations, capped } = enumerateConfigurations( + params, + [], + 4 + ); expect(capped).toBe(true); expect(configurations).toEqual([]); }); @@ -141,11 +134,9 @@ describe("countConfigurations", () => { }); }); - // Cosmetic and quantity parameters ride their defaults rather than - // multiplying the count, so they leave nothing to vary either. - it("ignores parameters that don't vary the build", () => { - const cosmetic = enumParam("A", ["x", "y"], { isCosmetic: true }); - expect(countConfigurations([cosmetic])).toMatchObject({ + it("counts an excluded parameter as varying nothing", () => { + const excluded = enumParam("A", ["x", "y"]); + expect(countConfigurations([excluded], ["A"])).toMatchObject({ count: 0, band: IndexingBand.AUTOMATIC }); @@ -171,22 +162,18 @@ describe("countConfigurations", () => { ); it("reports no count past the cap, where enumeration stops", () => { - expect( - countConfigurations( - paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS + 1) - ) - ).toMatchObject({ count: null, band: IndexingBand.EXCEEDED }); + const counted = countConfigurations( + paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS + 1) + ); + expect(counted.count).toBeUndefined(); + expect(counted.band).toBe(IndexingBand.EXCEEDED); }); }); describe("countCombinations", () => { it("counts an insertable with nothing to vary as having none", () => { expect(countCombinations([])).toBe(0); - expect( - countCombinations([ - enumParam("A", ["x", "y"], { isCosmetic: true }) - ]) - ).toBe(0); + expect(countCombinations([enumParam("A", ["x", "y"])], ["A"])).toBe(0); }); it("agrees with countConfigurations under the index cap", () => { @@ -200,7 +187,7 @@ describe("countCombinations", () => { it("counts on past the index cap, which countConfigurations stops at", () => { const params = paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS * 4); - expect(countConfigurations(params).count).toBeNull(); + expect(countConfigurations(params).count).toBeUndefined(); expect(countCombinations(params)).toBe( MAX_PART_NUMBER_CONFIGURATIONS * 4 ); @@ -220,14 +207,14 @@ describe("countCombinations", () => { }); it("gives up past its own cap rather than counting forever", () => { - expect(countCombinations(paramsWithConfigs(64), 32)).toBeNull(); + expect( + countCombinations(paramsWithConfigs(64), [], 32) + ).toBeUndefined(); }); }); describe("isIndexingEnabled", () => { it.each([ - // Under the threshold everything indexes, custom included: a part with - // no part number is a normal record, not a reason to skip it. { band: IndexingBand.AUTOMATIC, force: false, on: true }, { band: IndexingBand.AUTOMATIC, force: true, on: true }, // Past the threshold it waits to be enabled. @@ -252,22 +239,35 @@ describe("isIndexedParameter", () => { expect(isIndexedParameter(stringParam("s"))).toBe(false); }); - it("does not vary a parameter excluded from properties", () => { - expect( - isIndexedParameter(enumParam("a", ["x", "y"], { isCosmetic: true })) - ).toBe(false); + it("does not vary a parameter an admin excluded", () => { + expect(isIndexedParameter(enumParam("a", ["x", "y"]), ["a"])).toBe( + false + ); }); - // The card reports indexing off this helper, so it has to describe exactly - // what enumeration varies. + // A role says how a part is drawn or derived, never which part it is. + it("never varies a parameter with a role", () => { + const color = { + ...enumParam("p", ["x", "y"]), + role: ParameterRole.COLOR + }; + expect(isIndexedParameter(color)).toBe(false); + }); + + // The admin card reports indexing from this. it("matches the keys enumeration actually varies", () => { const parameters = [ enumParam("varied", ["x", "y"]), boolParam("flag"), - enumParam("cosmetic", ["x", "y"], { isCosmetic: true }), + enumParam("excluded", ["x", "y"]), + { ...boolParam("color"), role: ParameterRole.COLOR }, quantityParam("length") ]; - const { configurations } = enumerateConfigurations(parameters); + const excluded = ["excluded"]; + const { configurations } = enumerateConfigurations( + parameters, + excluded + ); const enumeratedKeys = new Set( configurations.flatMap((configuration) => Object.keys(configuration) @@ -275,7 +275,7 @@ describe("isIndexedParameter", () => { ); expect([...enumeratedKeys].sort()).toEqual( parameters - .filter(isIndexedParameter) + .filter((parameter) => isIndexedParameter(parameter, excluded)) .map((parameter) => parameter.id) .sort() ); diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index 84f2a8a29..c78fcbaac 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -1,7 +1,4 @@ -/** - * Enumerates an insertable's configuration combinations. Only enum and boolean - * parameters vary; quantity and string ones ride their Onshape defaults. - */ +/** Only indexed enum and boolean parameters vary; the rest keep their defaults. */ import { type PartialSelection, BooleanParameter, @@ -11,16 +8,10 @@ import { } from "./contract"; import { evaluateCondition, getVisibleOptions } from "./utils"; -/** - * The most combinations we enumerate for one insertable; beyond it nothing is - * indexed, which is what bounds load time and Onshape usage. - */ +/** Past this nothing is indexed, which bounds load time and Onshape usage. */ export const MAX_PART_NUMBER_CONFIGURATIONS = 512; -/** - * At or above this, indexing waits for an admin, who can trim the count back - * with "exclude from properties"; see the `MANUAL_INDEXING_REQUIRED` build issue. - */ +/** At or above this, an admin decides whether to index; see `MANUAL_INDEXING_REQUIRED`. */ export const AUTO_INDEX_THRESHOLD = 128; /** Where a configuration count sits relative to the two indexing limits. */ @@ -33,10 +24,7 @@ export enum IndexingBand { EXCEEDED = "exceeded" } -/** - * Shared with the admin card so the two cannot disagree. The count is the only - * gate: past the cap nothing enumerates, past the threshold an admin decides. - */ +/** Shared with the admin card so the two can't disagree. */ export function isIndexingEnabled( band: IndexingBand, indexConfigurations: boolean @@ -52,11 +40,8 @@ export function isIndexingEnabled( } export interface ConfigurationCount { - /** - * The number of combinations, `0` when there is nothing to vary, or `null` - * past the cap — enumeration stops there, so the true total is unknown. - */ - count: number | null; + /** Undefined past the cap, where enumeration stops. */ + count?: number; band: IndexingBand; /** The combinations counted, so the load path need not enumerate again. */ configurations: PartialSelection[]; @@ -64,14 +49,17 @@ export interface ConfigurationCount { /** Shared, so the load path and the admin UI agree on which limit applies. */ export function countConfigurations( - parameters: ConfigurationParameter[] + parameters: ConfigurationParameter[], + excludedParameterIds: readonly string[] = [] ): ConfigurationCount { - const { configurations, capped } = enumerateConfigurations(parameters); + const { configurations, capped } = enumerateConfigurations( + parameters, + excludedParameterIds + ); if (capped) { - return { count: null, band: IndexingBand.EXCEEDED, configurations: [] }; + return { band: IndexingBand.EXCEEDED, configurations: [] }; } - // The lone default that nothing-to-vary enumerates to is not a configuration - // of its own: a non-configurable insertable has none. + // The lone default isn't a configuration of its own. const count = configurations.some( (selection) => Object.keys(selection).length > 0 ) @@ -88,25 +76,23 @@ export function countConfigurations( } /** - * Whether indexing varies this parameter, and so multiplies the count. Shared - * with the admin card so it cannot drift from {@link enumerateConfigurations}. + * Never one with a role, which changes how a part is drawn, not which part it + * is. No list or checkbox in the libraries has a role yet, so this only guards + * a future color or tessellation list. Shared with the admin card. */ export function isIndexedParameter( - parameter: ConfigurationParameter + parameter: ConfigurationParameter, + excludedParameterIds: readonly string[] = [] ): parameter is EnumParameter | BooleanParameter { - if ( - parameter.type !== ParameterType.ENUM && - parameter.type !== ParameterType.BOOLEAN - ) { - return false; - } - return !parameter.isCosmetic; + return ( + (parameter.type === ParameterType.ENUM || + parameter.type === ParameterType.BOOLEAN) && + parameter.role === undefined && + !excludedParameterIds.includes(parameter.id) + ); } -/** - * What enumeration varies this parameter over, given what is fixed so far. None - * when visibility leaves it no option, which leaves it for Onshape to default. - */ +/** Empty when visibility leaves no option, so Onshape defaults it. */ export function parameterValues( parameter: EnumParameter | BooleanParameter, selection: PartialSelection, @@ -129,10 +115,13 @@ export const MAX_COUNTED_CONFIGURATIONS = 100_000; /** The true count, which runs past the index cap so the admin card can show it. */ export function countCombinations( parameters: ConfigurationParameter[], + excludedParameterIds: readonly string[] = [], cap: number = MAX_COUNTED_CONFIGURATIONS -): number | null { - // Depth-first: only the count is wanted, so one path is held rather than all. - const indexed = parameters.filter(isIndexedParameter); +): number | undefined { + // Depth-first, holding one path, since only the count is wanted. + const indexed = parameters.filter((parameter) => + isIndexedParameter(parameter, excludedParameterIds) + ); let count = 0; let capped = false; @@ -160,37 +149,36 @@ export function countCombinations( }; walk(0, {}); - return capped ? null : count; + return capped ? undefined : count; } interface EnumerateResult { - /** What each combination varies — enums and booleans — which is why these - * are partial; the only caller runs `toSelection` over them. */ + /** Only the enums and booleans each combination varies. */ configurations: PartialSelection[]; /** True when enumeration was stopped for exceeding the cap. */ capped: boolean; } /** - * The cartesian product of enum and boolean values, minus what visibility hides. - * Declaration order is load-bearing: search dedupes first-wins. + * The product of enum and boolean values, minus what visibility hides. Order + * matters: search dedupes first-wins. */ export function enumerateConfigurations( parameters: ConfigurationParameter[], + excludedParameterIds: readonly string[] = [], cap: number = MAX_PART_NUMBER_CONFIGURATIONS ): EnumerateResult { let configurations: PartialSelection[] = [{}]; for (const parameter of parameters) { - if (!isIndexedParameter(parameter)) { + if (!isIndexedParameter(parameter, excludedParameterIds)) { continue; } const next: PartialSelection[] = []; for (const selection of configurations) { const values = parameterValues(parameter, selection, parameters); - // Nothing to vary here, so the parameter is left unset; - // `toSelection` fills it from the default Onshape would apply. + // Left unset, so `toSelection` fills in the default. if (values.length === 0) { next.push(selection); continue; diff --git a/src/backend/features/configurations/contract.ts b/src/backend/features/configurations/contract.ts index 1d57b3807..c317de8b7 100644 --- a/src/backend/features/configurations/contract.ts +++ b/src/backend/features/configurations/contract.ts @@ -70,24 +70,19 @@ interface AlwaysShownVisibilityCondition { export interface ConfigurationResult { parameters: ConfigurationParameter[]; - /** The insertable's search records, so the insert menu can show the part - * number + name of the selected configuration. Empty when not indexed. */ + /** Empty when not indexed. */ records: SearchRecord[]; } -/** - * The slice of a {@link ConfigurationRecord} search needs. MiniSearch-free, so - * the index and the `/configuration` route can share it. - */ +/** MiniSearch-free, so the index and `/configuration` share it. */ export interface SearchRecord { partNumber?: string; name?: string; /** The vendor's page for this part, when one can be resolved. */ url?: string; - /** - * The key of the selection producing it, so it names the same render the - * insert menu asks for; empty for the element's own defaults. - */ + /** The enumerated values producing it; empty for the element's defaults. */ + values: PartialSelection; + /** Those values' key, which is only for naming its thumbnail. */ configurationKey: ConfigurationKey; } @@ -97,13 +92,23 @@ export type ConfigurationParameter = | BooleanParameter | StringParameter; +/** Parameters about how a part is derived or drawn, not which part it is; see `roles.ts`. */ +export enum ParameterRole { + /** The insert menu fills it with a unique value, so each derive is its own configuration. */ + DERIVATION_VARIABLE = "derivation-variable", + COLOR = "color", + /** One of a color's R, G and B, when a part spells a color out as three. */ + COLOR_CHANNEL = "color-channel", + TESSELLATION = "tessellation" +} + interface ConfigurationParameterBase { id: string; name: string; default: string; - /** Parameters excluded from configuration properties. */ - isCosmetic: boolean; condition?: VisibilityCondition; + /** Absent for an ordinary parameter, which is most of them. */ + role?: ParameterRole; } export interface BooleanParameter extends ConfigurationParameterBase { type: ParameterType.BOOLEAN; @@ -133,34 +138,19 @@ export interface QuantityParameter extends ConfigurationParameterBase { unit: Unit; // Always UNITLESS for QuantityType.INTEGER and QuantityType.REAL } -/** - * One selection, keyed by parameter id: always complete, always canonical. - * `toSelection` is what makes one; nothing else may claim to. - */ +/** Every declared parameter, as entered; see AGENTS.md. */ export type Selection = Record; -/** - * A selection still being built: enumeration names only what it varies, and a - * search hit only what it overrides. `toSelection` is what makes one whole. - */ +/** `toSelection` makes one whole. */ export type PartialSelection = Partial; -/** - * A selection's identity: what it overrides, which is what addresses a render. - * {@link DEFAULT_CONFIGURATION_KEY} — empty — overrides nothing, and so is the default. - */ +/** Names a selection's thumbnail and nothing else; see AGENTS.md. */ export type ConfigurationKey = string; -/** - * The key of a selection that overrides nothing: the element's own defaults. Here - * rather than in `selection.ts`, which `utils.ts` would have to import back from. - */ +// Here rather than in `selection.ts` to avoid an import cycle with `utils.ts`. export const DEFAULT_CONFIGURATION_KEY: ConfigurationKey = ""; -/** - * The part one probe resolved to: the element itself from its own defaults, a - * {@link ConfigurationRecord} from any other selection. - */ +/** The part one probe resolved to. */ export interface PartMetadata { partNumber?: string; name?: string; @@ -175,41 +165,23 @@ export interface PartMetadata { isOpenComposite: boolean; } -/** - * {@link PartMetadata} for one selection, as it came back from Onshape — keyed - * by the selection as written rather than as it is stored. - */ -export interface ProbedRecord extends PartMetadata { - /** The selection probed, before it is keyed for storage. */ - selection: Selection; -} - -/** A probe as it is stored. Kept only for an indexed insertable. */ +/** What one probe came back with, and the enumerated values it probed. */ export interface ConfigurationRecord extends PartMetadata { - /** The key of the selection that produces it. */ - configurationKey: ConfigurationKey; + /** The enum and boolean values enumeration chose; empty for the defaults. */ + values: PartialSelection; } -/** - * An insertable's configuration: the parameters it exposes and a record for each - * configuration we probed. Mirrors the `configurations` row. - */ +/** Mirrors the `configurations` row. */ export interface Configuration { parameters: ConfigurationParameter[]; records: ConfigurationRecord[]; } -/** - * The current document's units. Every field is optional: an absent one leaves - * the quantity on its own default unit. - */ +/** A document's units, which quantities are shown in. */ export interface UnitInfo { - angleUnit?: Unit; - lengthUnit?: Unit; - lengthPrecision?: number; - anglePrecision?: number; - realPrecision?: number; + angleUnit: Unit; + lengthUnit: Unit; + lengthPrecision: number; + anglePrecision: number; + realPrecision: number; } - -/** No document units available; each quantity falls back to its own unit. */ -export const EMPTY_UNIT_INFO: UnitInfo = {}; diff --git a/src/backend/features/configurations/enums.ts b/src/backend/features/configurations/enums.ts index 988a0f320..04077295a 100644 --- a/src/backend/features/configurations/enums.ts +++ b/src/backend/features/configurations/enums.ts @@ -1,6 +1,4 @@ -/** - * Value enums Onshape emits unchanged. - */ +/** As Onshape sends them. */ export enum QuantityType { LENGTH = "LENGTH", diff --git a/src/backend/features/configurations/input-parser.test.ts b/src/backend/features/configurations/input-parser.test.ts index 700a28114..345d1116e 100644 --- a/src/backend/features/configurations/input-parser.test.ts +++ b/src/backend/features/configurations/input-parser.test.ts @@ -3,7 +3,6 @@ import { QuantityType, Unit } from "./enums"; import { evaluateExpression, EvaluateOptions, - Result, valueWithUnits } from "./input-parser"; @@ -36,11 +35,12 @@ describe("evaluateExpression", () => { ["90 deg", DEGREES, "90 deg"], // The display unit fills in for an expression that names none. ["5", LENGTH, "5 mm"], + ["1 in", LENGTH, "25.4 mm"], [" 7 mm + 3 mm ", LENGTH, "10 mm"] ])("evaluates %s", (expression, options, display) => { const result = evaluateExpression(expression, options); - expect(result.hasError).toBe(false); - expect((result as Result).displayExpression).toBe(display); + expect(result.errorMessage).toBeUndefined(); + expect(result.display).toBe(display); }); it("evaluates an angle in radians", () => { @@ -48,8 +48,8 @@ describe("evaluateExpression", () => { "3.14159265359 rad", defaultOptions(QuantityType.ANGLE, Unit.RADIAN) ); - expect(result.hasError).toBe(false); - expect((result as Result).displayExpression).toContain("rad"); + expect(result.errorMessage).toBeUndefined(); + expect(result.display).toContain("rad"); }); it.each([ @@ -61,14 +61,21 @@ describe("evaluateExpression", () => { ["(5 mm) mm", "a unit is applied to something already dimensioned"], ["5 mm * 2 mm", "two units are multiplied"], ["5 mm / 2 mm", "two units are divided"], + // Once threw out of the range check instead of being reported. + ["2 deg", "an angle is no length"], ["-100.001 mm", "it falls below the minimum"], ["100.001 mm", "it rises above the maximum"] ])("rejects %s, since %s", (expression) => { - expect(evaluateExpression(expression, LENGTH).hasError).toBe(true); + expect( + evaluateExpression(expression, LENGTH).errorMessage + ).toBeDefined(); }); - // `expression` is what the input redisplays and the menu stores, so it has - // to be something this same parser still reads. + it("rejects a length where an angle is wanted", () => { + expect(evaluateExpression("2 mm", DEGREES).errorMessage).toBeDefined(); + }); + + // The stored expression has to re-parse. it.each([ ["(1 + 2) * 3 mm"], ["(2 + 3) mm"], @@ -77,11 +84,8 @@ describe("evaluateExpression", () => { ["-(2 + 3) mm"] ])("re-parses its own output for %s", (input) => { const first = evaluateExpression(input, LENGTH); - expect(first.hasError).toBe(false); + expect(first.errorMessage).toBeUndefined(); const second = evaluateExpression(first.expression, LENGTH); - expect(second.hasError).toBe(false); - expect((second as Result).displayExpression).toBe( - (first as Result).displayExpression - ); + expect(second).toEqual(first); }); }); diff --git a/src/backend/features/configurations/input-parser.ts b/src/backend/features/configurations/input-parser.ts index a19c76a6a..ee888355e 100644 --- a/src/backend/features/configurations/input-parser.ts +++ b/src/backend/features/configurations/input-parser.ts @@ -47,10 +47,7 @@ function canonicalPrecision(type: UnitType): number { return Math.round(-Math.log10(TOLERANCE[type])); } -/** - * The one spelling of a value: its base unit, to the decimals its tolerance - * distinguishes. Two values read as equal spell the same, which is why it keys. - */ +/** Base unit, to the precision its tolerance distinguishes: equal values spell the same. */ export function formatBaseValue(value: ValueWithUnits): string { return formatValueWithUnits( value, @@ -59,10 +56,7 @@ export function formatBaseValue(value: ValueWithUnits): string { ); } -/** - * The same value in another unit, still to full precision — every unit here is - * larger than its base, so the decimals a base spelling keeps are never fewer. - */ +/** Full precision, since every unit here is larger than its base. */ export function formatValueInUnit(value: ValueWithUnits, unit: Unit): string { return formatValueWithUnits(value, unit, canonicalPrecision(value.type)); } @@ -129,10 +123,7 @@ interface ValueWithUnits { interface ValueLiteral { value: number; - /** - * The raw string value. - * Used to maintain decimal accuracy. - */ + /** Kept as a string to preserve decimal accuracy. */ rawValue: string; type: UnitType; unit: Unit; @@ -277,8 +268,7 @@ function classifyUnit(identifier: string): Unit { } /* - * The grammar below, which the recursive-descent parser implements but does not - * state. Precedence tightest first: unary sign, `*` and `/`, unit, `+` and `-`. + * Precedence, tightest first: unary sign, `*` and `/`, unit, `+` and `-`. * * EXP ::= POSTFIX { ("+" | "-") POSTFIX } * POSTFIX ::= TERM [ Identifier ] // the unit applies to the TERM @@ -306,8 +296,7 @@ PRIMARY.setPattern( }; return { kind: "value", value: valueLiteral }; }), - // Kept as a node rather than unwrapped to the inner expression, so - // `stringify` writes the parens back and its output re-parses. + // Kept as a node so `stringify` writes the parens back. apply( kmid(tok(TokenKind.LParen), EXP, tok(TokenKind.RParen)), (expr): Expr => ({ kind: "paren", expr }) @@ -396,8 +385,8 @@ function getOpName(op: Operator): string { } /** - * Matching Onshape, a type is only assumed for the final result — so unitless + - * unit is always invalid, as are units on a unitless quantityType. + * Like Onshape, a type is only assumed for the final result, so unitless + unit + * is invalid, as are units on a unitless quantity type. */ function evaluateExpressionValue( expr: Expr, @@ -491,8 +480,6 @@ function evaluateExpressionValue( ); case "/": - // unit / number -> unit - // number / number -> number if (right.type === "number") { if (tolerantEqualsZero(right)) { throw new ParseError(`Cannot divide by 0`); @@ -564,109 +551,64 @@ function roundToPrecision(num: number, precision: number): string { return String(Math.round(num * factor) / factor); } -function formatExpression( - expr: Expr, +/** What a quantity type measures, and so the only kind a result may be. */ +function expectedType(quantityType: QuantityType): UnitType { + switch (quantityType) { + case QuantityType.LENGTH: + return "length"; + case QuantityType.ANGLE: + return "angle"; + case QuantityType.INTEGER: + case QuantityType.REAL: + return "number"; + } +} + +/** Why a value the parser accepted can't go in this box, if it can't. */ +function checkValue( value: ValueWithUnits, options: EvaluateOptions -): Result | ErrorResult { - const { quantityType, displayUnit, displayPrecision } = options; - - let expression = stringify(expr); - if ( - (quantityType === QuantityType.LENGTH || - quantityType === QuantityType.ANGLE) && - value.type === "number" - ) { - value = valueWithUnits(value.value, displayUnit); - - if (expr.kind === "binary") { - expression = `(${expression})`; - } - expression = expression + " " + getUnitDisplayStr(displayUnit); +): string | undefined { + const expected = expectedType(options.quantityType); + // "2 deg" parses in a length box, and the bounds check would throw on it. + if (value.type !== expected) { + return `Expected ${expected === "number" ? "a number" : `a ${expected}`}`; } - + const format = (bound: ValueWithUnits) => + formatValueWithUnits( + bound, + options.displayUnit, + options.displayPrecision + ); if (tolerantLessThan(value, options.min)) { - return { - hasError: true, - expression, - errorMessage: `Value must be greater than or equal to ${formatValueWithUnits( - options.min, - options.displayUnit, - options.displayPrecision - )}` - }; - } else if (tolerantGreaterThan(value, options.max)) { - return { - hasError: true, - expression, - errorMessage: `Value must be less than or equal to ${formatValueWithUnits( - options.max, - options.displayUnit, - options.displayPrecision - )}` - }; + return `Value must be greater than or equal to ${format(options.min)}`; } - - return { - hasError: false, - displayExpression: formatValueWithUnits( - value, - displayUnit, - displayPrecision - ), - expression - }; -} - -export interface Result { - hasError: false; - /** - * The formatted result. Includes the value rounded to the correct display precision and the display unit. - * @example `12.00 in` - */ - displayExpression: string; - /** - * The formatted expression. Essentially the raw input with clean spacing and possibly the display unit applied. - * @example "(3.5 + 8.5) in" - */ - expression: string; + if (tolerantGreaterThan(value, options.max)) { + return `Value must be less than or equal to ${format(options.max)}`; + } + return undefined; } -interface ErrorResult { - hasError: true; - /** - * The original, unformatted expression. - */ +/** What a quantity box shows for an input. */ +export interface EvaluatedExpression { + /** The input with clean spacing, and the display unit if it had none: "(3.5 + 8.5) in". */ expression: string; - /** - * An error message to display to the user. - */ - errorMessage: string; + /** Rounded to display precision, in the display unit: `12.00 in`; the expression when it has an error. */ + display: string; + errorMessage?: string; } export interface EvaluateOptions { - /** - * The type of the expression. - */ quantityType: QuantityType; - /** - * Number of decimals to round to. - * Should be 0 for real and integer expressions. - */ + /** 0 for real and integer expressions. */ displayPrecision: number; - /** - * Unit to use in the displayExpression. - * Should be unitless for real and integer expressions. - */ + /** Unitless for real and integer expressions. */ displayUnit: Unit; max: ValueWithUnits; min: ValueWithUnits; } -/** - * The value in base units: meters, radians, or unitless. A bare number takes - * `defaultUnit`, as in the input; undefined when it does not parse. - */ +/** In meters, radians or unitless; a bare number takes `defaultUnit`. */ export function evaluateBaseValue( input: string, quantityType: QuantityType, @@ -685,41 +627,55 @@ export function evaluateBaseValue( ) { value = valueWithUnits(value.value, defaultUnit); } - return value; + return value.type === expectedType(quantityType) ? value : undefined; } export function evaluateExpression( input: string, options: EvaluateOptions -): Result | ErrorResult { - const quantityType = options.quantityType; +): EvaluatedExpression { + const { quantityType, displayUnit, displayPrecision } = options; + const failed = (expression: string, errorMessage: string) => ({ + expression, + display: expression, + errorMessage + }); if (input.trim().length === 0) { - return { - hasError: true, - expression: input, - errorMessage: "Enter an expression" - }; + return failed(input, "Enter an expression"); } - let expr; - let value; + let expr: Expr; + let value: ValueWithUnits; try { expr = parseExpression(input); value = evaluateExpressionValue(expr, quantityType); } catch (error) { - let errorMessage; - if (error instanceof ParseError) { - errorMessage = error.message; - } else { - errorMessage = "Invalid expression"; + return failed( + input, + error instanceof ParseError ? error.message : "Invalid expression" + ); + } + + let expression = stringify(expr); + if ( + (quantityType === QuantityType.LENGTH || + quantityType === QuantityType.ANGLE) && + value.type === "number" + ) { + value = valueWithUnits(value.value, displayUnit); + if (expr.kind === "binary") { + expression = `(${expression})`; } - return { - hasError: true, - expression: input, - errorMessage - }; + expression += " " + getUnitDisplayStr(displayUnit); } - return formatExpression(expr, value, options); + const errorMessage = checkValue(value, options); + if (errorMessage) { + return failed(expression, errorMessage); + } + return { + expression, + display: formatValueWithUnits(value, displayUnit, displayPrecision) + }; } diff --git a/src/backend/features/configurations/instances.test.ts b/src/backend/features/configurations/instances.test.ts index 4feae20e0..48cf511ff 100644 --- a/src/backend/features/configurations/instances.test.ts +++ b/src/backend/features/configurations/instances.test.ts @@ -102,8 +102,7 @@ describe("toParameterInstances", () => { optionConditions: [shownWhen(["s2"], "vendor", "wcp")] }); - // Both series leave a generic part the same list, so the series is not - // what decides it and is left off that path. + // Both series leave generic the same list, so the series is left off. expect(labels([series, gated, size])).toEqual([ "series", "old › vendor", @@ -114,8 +113,7 @@ describe("toParameterInstances", () => { }); it("names a checkbox by its own name and the state it is in", () => { - // A checkbox has no option names to borrow, so "true" on its own would - // say nothing about which checkbox it is. + // "true" alone wouldn't say which checkbox. const hub = boolParam("hub"); const style = enumParam("style", ["plain", "splined"], { optionConditions: [shownWhen(["splined"], "hub", "true")] @@ -132,8 +130,6 @@ describe("toParameterInstances", () => { it("names both choices when a checkbox and a list each narrow the options", () => { const vendor = enumParam("vendor", ["generic", "wcp"]); const hub = boolParam("hub"); - // Two independent conditions, so the four combinations leave four - // different lists and each path has to name both choices. const style = enumParam("style", ["plain", "wcpOnly", "hubbed"], { optionConditions: [ shownWhen(["wcpOnly"], "vendor", "wcp"), @@ -183,8 +179,6 @@ describe("toParameterInstances", () => { }); it("reports a parameter whole when its condition names a stale parameter", () => { - // A RANGE condition on something that is no longer an enum throws; the - // report has to survive it. const stale: ConfigurationParameter = { ...quantityParam("length"), condition: { diff --git a/src/backend/features/configurations/instances.ts b/src/backend/features/configurations/instances.ts index 03d6f25df..1b1becfc2 100644 --- a/src/backend/features/configurations/instances.ts +++ b/src/backend/features/configurations/instances.ts @@ -1,7 +1,6 @@ /** - * The ways one parameter can be shown. A list whose options another choice - * filters is not one list but several, and anything reporting on it as one - * merges choices that were never offered together. + * A list whose options another choice filters is really several lists, one per + * way it is shown; reporting on it as one merges choices never offered together. */ import { type ConfigurationParameter, @@ -14,51 +13,39 @@ import { } from "./contract"; import { parameterValues } from "./combinations"; import { formatValue } from "./selection"; -import { evaluateCondition, getOption, getVisibleOptions } from "./utils"; +import { + evaluateCondition, + getOption, + getVisibleOptions, + resolveSelectedOption +} from "./utils"; -/** - * The combinations of controlling choices one parameter is walked over, and the - * instances that may come out of it. Past either the parameter is reported - * whole: an instanced report nobody can read is worse than an aggregated one. - */ +// Past either cap a parameter is reported whole: too many instances is +// unreadable. const MAX_COMBINATIONS = 256; const MAX_INSTANCES = 16; /** One controlling choice on the way to an instance. */ -export interface InstanceStep { +interface InstanceStep { parameterId: string; - /** The choices leading here, e.g. "Generic"; several when they lead to the - * same list, joined as "Generic or WCP". */ + /** e.g. "Generic", or "Generic or WCP" when both lead to the same list. */ label: string; } /** One parameter as it is shown under one set of controlling choices. */ -export interface ParameterInstance { +interface ParameterInstance { parameter: ConfigurationParameter; - /** The choices it is shown under, outermost first; empty when nothing - * conditions it. */ + /** Outermost first; empty when nothing conditions it. */ path: InstanceStep[]; - /** What it offers here, in declaration order; empty for anything that is - * not an enum, which declares no options to filter. */ + /** In declaration order; empty for anything but an enum. */ options: EnumOption[]; - /** - * The {@link toInstanceKeys} keys whose counts belong to this instance. - * Absent where the parameter is reported whole, which every recorded key - * counts towards — including one written before it had conditions. - */ + /** The `toInstanceKeys` keys counted here; absent where it is reported whole. */ keys?: string[]; - /** - * The option the app lands on here because the declared default is not - * offered; see `resolveSelectedOption`, which falls through to the first. - */ + /** Set when the declared default isn't offered; see `resolveSelectedOption`. */ implicitDefaultId?: string; } -/** - * Every parameter, once per way it is shown. Parameter order is kept, and a - * parameter nothing conditions yields exactly one instance with an empty path — - * which is what an un-instanced report already was. - */ +/** One per way each parameter is shown; an unconditioned one gets a single instance with an empty path. */ export function toParameterInstances( parameters: ConfigurationParameter[] ): ParameterInstance[] { @@ -66,9 +53,7 @@ export function toParameterInstances( try { return instancesOf(parameter, parameters); } catch { - // `evaluateCondition` throws on a condition naming a parameter that - // is not an enum any more. One part's conditions going stale must - // not take down a whole library's report, so it reports whole. + // A stale condition shouldn't take down the library's whole report. return [wholeInstance(parameter)]; } }); @@ -88,8 +73,7 @@ function instancesOf( return [wholeInstance(parameter)]; } - // Keyed by what the parameter offers, so two vendors that filter the list - // the same way are one instance rather than two identical ones. + // Keyed by the options, so vendors that filter alike share an instance. const groups = new Map< string, { combinations: PartialSelection[]; options: EnumOption[] } @@ -112,9 +96,8 @@ function instancesOf( } } - // No combination shows it, which means the conditions cannot be satisfied - // the way they were read. The values recorded against it say otherwise, so - // it is reported whole rather than dropped. + // Nothing shows it, yet values were recorded against it, so report it whole + // rather than drop it. if (groups.size === 0 || groups.size > MAX_INSTANCES) { return [wholeInstance(parameter)]; } @@ -212,10 +195,8 @@ function conditionsOf( } /** - * The parameters whose choices decide how `parameter` is shown, in declaration - * order. Transitive: a list filtered by a vendor whose own options a series - * filters is shown once per pair, and enumerating the vendor needs the series - * fixed first. + * Transitive: a list filtered by a vendor whose options a series filters is + * shown once per pair. */ function controllingParameters( parameter: ConfigurationParameter, @@ -237,8 +218,7 @@ function controllingParameters( } } - // Only an enum or a boolean can be walked over: a quantity takes any number - // the user types, which is no set of instances. + // A quantity takes any number, so it can't be enumerated. return parameters.filter( (entry) => found.has(entry.id) && @@ -247,11 +227,7 @@ function controllingParameters( ); } -/** - * Every combination of controlling choices, in declaration order so each is - * enumerated against what is already fixed. Undefined past the cap, where the - * paths would outnumber the options they lead to. - */ +/** In declaration order, so each is enumerated against what is fixed. Undefined past the cap. */ function enumerateControls( controllers: ConfigurationParameter[], parameters: ConfigurationParameter[] @@ -268,8 +244,7 @@ function enumerateControls( const next: PartialSelection[] = []; for (const combination of combinations) { const values = parameterValues(controller, combination, parameters); - // Nothing to vary here — it is hidden under what is already fixed — - // so it stays unset, and the conditions reading it do not hold. + // Hidden under what is fixed, so it stays unset. if (values.length === 0) { next.push(combination); continue; @@ -300,10 +275,7 @@ function valuesOf( return values; } -/** - * The steps naming this instance. A controller whose every value leads here - * says nothing about it, so it is left out. - */ +/** Leaves out a controller whose every value leads here. */ function toPath( group: PartialSelection[], combinations: PartialSelection[], @@ -326,9 +298,8 @@ function toPath( } /** - * What to call one step. An option names itself, so the enum it belongs to is - * left out — "Generic", not "Vendor: Generic". A checkbox has no such name, so - * it is the parameter that is named and the state that qualifies it. + * An option names itself ("Generic", not "Vendor: Generic"); a checkbox names + * its parameter and state. */ function toStepLabel( parameter: ConfigurationParameter, @@ -349,11 +320,13 @@ function toImplicitDefault( parameter: ConfigurationParameter, options: EnumOption[] ): string | undefined { - if (parameter.type !== ParameterType.ENUM || options.length === 0) { - return undefined; - } - if (options.some((option) => option.id === parameter.default)) { + if (parameter.type !== ParameterType.ENUM) { return undefined; } - return options[0].id; + const settled = resolveSelectedOption( + options, + undefined, + parameter.default + ); + return settled?.id === parameter.default ? undefined : settled?.id; } diff --git a/src/backend/features/configurations/part-number.ts b/src/backend/features/configurations/part-number.ts index b86341658..4a113b77d 100644 --- a/src/backend/features/configurations/part-number.ts +++ b/src/backend/features/configurations/part-number.ts @@ -8,10 +8,7 @@ export function isPlaceholderPartNumber(text: string): boolean { return PLACEHOLDER_PART_NUMBER.test(text.trim()); } -/** - * The part number when it identifies the part, and nothing when it repeats the - * name or holds a placeholder. One rule, so an unsearchable number is never shown. - */ +/** Undefined when it repeats the name or is a placeholder. */ export function meaningfulPartNumber( partNumber: string | undefined | null, name?: string | null diff --git a/src/backend/features/configurations/roles.test.ts b/src/backend/features/configurations/roles.test.ts new file mode 100644 index 000000000..165443c92 --- /dev/null +++ b/src/backend/features/configurations/roles.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { isDerivationVariable, withRoles } from "./roles"; +import { type ConfigurationParameter, ParameterRole } from "./contract"; +import { + enumParam, + stringParam +} from "../../../__test_utils__/configuration-fixtures"; + +const named = (name: string): ConfigurationParameter => ({ + ...enumParam(name, ["x", "y"]), + name +}); + +/** The role each of `parameters` is recognized in, alongside the rest. */ +const rolesOf = (...parameters: ConfigurationParameter[]) => + withRoles(parameters).map((parameter) => parameter.role); + +describe("recognizing roles", () => { + it.each([ + ["Color", ParameterRole.COLOR], + ["Part colour", ParameterRole.COLOR], + ["Tessellation Quality", ParameterRole.TESSELLATION], + ["Tesselation quality", ParameterRole.TESSELLATION] + ])("recognizes %s", (name, role) => { + expect(rolesOf(named(name))).toEqual([role]); + }); + + it.each(["Length", "Bearing", "Gear Ratio", "Colorway"])( + "gives an ordinary parameter named %s no role", + (name) => { + expect(rolesOf(named(name))).toEqual([undefined]); + } + ); + + it("recognizes a color channel beside its two siblings", () => { + expect(rolesOf(named("R"), named("G"), named("B"))).toEqual([ + ParameterRole.COLOR_CHANNEL, + ParameterRole.COLOR_CHANNEL, + ParameterRole.COLOR_CHANNEL + ]); + }); + + // A lone "B" is as likely a size as a blue. + it("gives a single letter with no channel siblings no role", () => { + expect(rolesOf(named("A"), named("B"))).toEqual([undefined, undefined]); + }); +}); + +describe("derivation variables", () => { + it("recognizes a text parameter named for derivation", () => { + const [parameter] = withRoles([ + { ...stringParam("d"), name: "Derivation Variable" } + ]); + expect(parameter.role).toBe(ParameterRole.DERIVATION_VARIABLE); + expect(isDerivationVariable(parameter)).toBe(true); + }); + + it("gives one of another type no role", () => { + const [parameter] = withRoles([named("Derivation Variable")]); + expect(parameter.role).toBeUndefined(); + expect(isDerivationVariable(parameter)).toBe(false); + }); +}); diff --git a/src/backend/features/configurations/roles.ts b/src/backend/features/configurations/roles.ts new file mode 100644 index 000000000..2c06d9439 --- /dev/null +++ b/src/backend/features/configurations/roles.ts @@ -0,0 +1,57 @@ +/** Onshape doesn't mark roles, so they're recognized by name at load and stored as `role`. */ +import { + type ConfigurationParameter, + ParameterRole, + ParameterType +} from "./contract"; + +/** A color's channels, when a part spells one out as three parameters. */ +const COLOR_CHANNELS = [ + ["r", "g", "b"], + ["red", "green", "blue"] +]; + +function normalizedName(parameter: ConfigurationParameter): string { + return parameter.name.trim().toLowerCase(); +} + +/** A channel counts only beside its two siblings. A derivation variable must be text, since the app fills it with a UUID. */ +function identifyRole( + parameter: ConfigurationParameter, + names: Set +): ParameterRole | undefined { + const name = normalizedName(parameter); + if ( + parameter.type === ParameterType.STRING && + name.includes("derivation") + ) { + return ParameterRole.DERIVATION_VARIABLE; + } + if (/\bcolou?r\b/.test(name)) { + return ParameterRole.COLOR; + } + if (/tess?ell?ation/.test(name)) { + return ParameterRole.TESSELLATION; + } + const isChannel = COLOR_CHANNELS.some( + (set) => set.includes(name) && set.every((entry) => names.has(entry)) + ); + return isChannel ? ParameterRole.COLOR_CHANNEL : undefined; +} + +/** An insertable's parameters, each carrying the role it was recognized in. */ +export function withRoles( + parameters: ConfigurationParameter[] +): ConfigurationParameter[] { + const names = new Set(parameters.map(normalizedName)); + return parameters.map((parameter) => { + const role = identifyRole(parameter, names); + return role ? { ...parameter, role } : parameter; + }); +} + +export function isDerivationVariable( + parameter: ConfigurationParameter +): boolean { + return parameter.role === ParameterRole.DERIVATION_VARIABLE; +} diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 428f7ad6e..5ab10c535 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -5,12 +5,10 @@ import { validate } from "../../lib/validate"; import { getApp } from "../../lib/context"; import { getInsertableParam, insertableRoute } from "../../lib/route-params"; import { getDb } from "../../db/client"; -import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; import { configurations, insertables } from "../../db/schema"; -import { type ConfigurationResult, type UnitInfo } from "./contract"; -import { toSearchRecords } from "../search/records"; -import { DEFAULT_QUANTITY_PRECISION, toRecords } from "./utils"; -import { QuantityType, type Unit } from "./enums"; +import { type ConfigurationResult } from "./contract"; +import { getUnitInfoCached } from "./units"; +import { searchRecordsOf } from "../search/records"; import { INSTANCE_TYPES } from "../../lib/onshape/path"; import { internalError } from "../../lib/api-error"; import { HttpStatus } from "http-status-ts"; @@ -30,8 +28,7 @@ configurationRoutes.get( async (c) => { const insertableId = getInsertableParam(c); const db = getDb(c.env.DB); - // Left join: the element's own part data is the fallback record, and it - // lives on the insertable whether or not it is configurable. + // The element's own part data is the fallback record, on the insertable. const config = await db .select({ partMetadata: insertables.partMetadata, @@ -56,64 +53,16 @@ configurationRoutes.get( const result: ConfigurationResult = { parameters: config.parameters ?? [], - records: toSearchRecords( - toRecords(config.partMetadata, config.records ?? []), - config.vendors - ) + records: searchRecordsOf(config) }; return c.json(result); } ); -/** One entry of Onshape's `defaultUnits`: which unit a quantity type is in. */ -interface OnshapeUnit { - key: QuantityType; - value: Unit; -} - -/** - * The document's unit for a quantity type. Onshape names one for every type, so a - * missing entry is a response we do not understand, not an absent preference. - */ -function getDefaultUnit( - units: OnshapeUnit[], - quantityType: QuantityType -): Unit { - const unit = units.find((entry) => entry.key === quantityType); - if (!unit) { - throw internalError( - `Onshape named no default unit for ${quantityType}`, - HttpStatus.BAD_GATEWAY - ); - } - return unit.value; -} - /** GET /api/unit-info?documentId=X&instanceId=Y&instanceType=v */ configurationRoutes.get( "/unit-info", cacheMiddleware(), validate("query", instancePathQuery), - async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const instancePath = c.req.valid("query"); - - const rawUnitInfo = await getUnitInfo(onshapeApi, instancePath); - // Onshape answers with strings; this is where the app decides they are - // the quantity types and units it knows. - const units = rawUnitInfo.defaultUnits.units as OnshapeUnit[]; - - const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); - const lengthUnit = getDefaultUnit(units, QuantityType.LENGTH); - - const result: UnitInfo = { - angleUnit, - lengthUnit, - anglePrecision: rawUnitInfo.unitsDisplayPrecision[angleUnit], - lengthPrecision: rawUnitInfo.unitsDisplayPrecision[lengthUnit], - // Onshape carries no display precision for a unitless real. - realPrecision: DEFAULT_QUANTITY_PRECISION - }; - return c.json(result); - } + async (c) => c.json(await getUnitInfoCached(c, c.req.valid("query"))) ); diff --git a/src/backend/features/configurations/routes.test.ts b/src/backend/features/configurations/routes.worker.test.ts similarity index 96% rename from src/backend/features/configurations/routes.test.ts rename to src/backend/features/configurations/routes.worker.test.ts index e288da922..13d2ec323 100644 --- a/src/backend/features/configurations/routes.test.ts +++ b/src/backend/features/configurations/routes.worker.test.ts @@ -39,8 +39,6 @@ describe("configuration routes", () => { expect(body).toEqual({ parameters: TEST_PARAMETERS, records: [] }); }); - // The element's own part data is the record an unset configuration falls - // back to, and it lives on the insertable, not in a configurations row. it("GET /configuration/insertable/:insertableId serves the element's own part data as a record", async () => { await seedPartStudio(db, { partMetadata: { @@ -69,6 +67,7 @@ describe("configuration routes", () => { partNumber: "WCP-0405", name: "2x1 Tube", url: "https://wcproducts.com/products/wcp-0405", + values: {}, configurationKey: "" } ] diff --git a/src/backend/features/configurations/selection.test.ts b/src/backend/features/configurations/selection.test.ts index fff18af34..bbca7d3af 100644 --- a/src/backend/features/configurations/selection.test.ts +++ b/src/backend/features/configurations/selection.test.ts @@ -1,18 +1,30 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_CONFIGURATION_KEY, VisibilityType } from "./contract"; +import { + type ConfigurationParameter, + DEFAULT_CONFIGURATION_KEY, + OptionVisibilityType, + ParameterType, + VisibilityType +} from "./contract"; import { appliedValues, + canonicalValues, + findRecord, formatValue, - fromKey, + normalizeSelection, + onshapeOverrides, toKey, - toSelection + toSelection, + toStoredSelection, + fillDerivationValues } from "./selection"; import { QuantityType, Unit } from "./enums"; import { decodeConfiguration } from "./utils"; import { boolParam, enumParam, - quantityParam + quantityParam, + derivationParam } from "../../../__test_utils__/configuration-fixtures"; const size = enumParam("size", ["s", "l"]); @@ -20,28 +32,34 @@ const flag = boolParam("flag"); const length = quantityParam("length"); const parameters = [size, flag, length]; -/** What every boundary does: whatever arrived, made whole and canonical. */ -function select(values: Record, params = parameters) { +/** What every boundary does: whatever arrived, made whole. */ +function select( + values: Record, + params: ConfigurationParameter[] = parameters +) { return toSelection(values, params); } describe("toSelection", () => { - it("names every parameter, whatever it was given", () => { + it("names every parameter, defaults in their own unit", () => { expect(select({ size: "l" })).toEqual({ size: "l", flag: "false", - length: "0.0254 m" + length: "1 in" }); }); - it("spells equivalent values the same way", () => { - for (const value of ["2in", "2 in", "50.8 mm", "(1 + 1) in"]) { - expect(select({ length: value }).length).toBe("0.0508 m"); - } + it("keeps an expression as it was entered", () => { + expect(select({ length: "(2 + 3) in" }).length).toBe("(2 + 3) in"); + expect(select({ length: " 50.8 mm " }).length).toBe("50.8 mm"); }); - it("keeps an unparseable value as typed", () => { - expect(select({ length: "#value" }).length).toBe("#value"); + it("spells a checkbox the one way Onshape does", () => { + expect(select({ flag: "TRUE" }).flag).toBe("true"); + }); + + it("drops what the parameters do not declare", () => { + expect(select({ gone: "x" })).not.toHaveProperty("gone"); }); it("is unchanged by a second pass", () => { @@ -61,23 +79,11 @@ describe("toKey", () => { ); }); - it("names parameters in declaration order, not object order", () => { - const key = toKey(select({ flag: "true", size: "l" }), parameters); - expect(key).toBe("size=l;flag=true"); - expect(toKey(select({ size: "l", flag: "true" }), parameters)).toBe( - key - ); - }); - - it("drops a parameter the selection hides", () => { - const hidden = enumParam("hidden", ["x", "y"], { - condition: { type: VisibilityType.EQUAL, id: "size", value: "s" } - }); - const params = [size, hidden]; - // size=l hides `hidden`, so its value cannot affect the render. - expect(toKey(select({ size: "l", hidden: "y" }, params), params)).toBe( - "size=l" + it("keys every spelling of one value alike", () => { + const keys = ["2in", "2 in", "50.8 mm", "(1 + 1) in"].map((value) => + toKey(select({ length: value }), parameters) ); + expect(new Set(keys).size).toBe(1); }); it("keys a value equal to the default in another unit as no override", () => { @@ -90,19 +96,31 @@ describe("toKey", () => { it("ignores a difference below the parser's tolerance", () => { expect( toKey(select({ length: "0.02540000000001 m" }), parameters) - ).toBe(toKey(select({ length: "1 in" }), parameters)); + ).toBe(DEFAULT_CONFIGURATION_KEY); + }); + + it("names parameters in declaration order, not object order", () => { + const key = toKey(select({ flag: "true", size: "l" }), parameters); + expect(key).toBe("size=l;flag=true"); + }); + + it("drops a parameter the selection hides", () => { + const hidden = enumParam("hidden", ["x", "y"], { + condition: { type: VisibilityType.EQUAL, id: "size", value: "s" } + }); + const params = [size, hidden]; + expect(toKey(select({ size: "l", hidden: "y" }, params), params)).toBe( + "size=l" + ); }); it("spells an angle in radians", () => { const angle = quantityParam("angle", { quantityType: QuantityType.ANGLE, unit: Unit.DEGREE, - default: "0 deg", defaultValue: 0, max: 360 }); - // Read back out of the key rather than sliced off it, so this stays - // about the spelling and not about how a key encodes one. const key = toKey(select({ angle: "180 deg" }, [angle]), [angle]); const spelled = decodeConfiguration(key).angle; expect(spelled).toMatch(/ rad$/); @@ -110,30 +128,25 @@ describe("toKey", () => { }); }); -describe("fromKey", () => { - it("round-trips every key toKey produces", () => { - const cases: Record[] = [ - { size: "l" }, - { size: "l", flag: "true", length: "2 in" }, - { size: "s" } - ]; - for (const values of cases) { - const key = toKey(select(values), parameters); - expect(toKey(fromKey(key, parameters), parameters)).toBe(key); - } +describe("onshapeOverrides", () => { + it("sends a quantity as it was entered, not as its value", () => { + expect( + onshapeOverrides(select({ length: "(2 + 3) in" }), parameters) + ).toEqual({ length: "(2 + 3) in" }); }); - it("fills what the key leaves unnamed", () => { - expect(fromKey("size=l", parameters)).toEqual(select({ size: "l" })); + it("leaves out a value that is the default however it is spelled", () => { + expect( + onshapeOverrides(select({ length: "25.4 mm" }), parameters) + ).toEqual({}); }); }); describe("appliedValues", () => { - const hidden = boolParam("reinforced"); const params = [ size, { - ...hidden, + ...boolParam("reinforced"), condition: { type: VisibilityType.EQUAL as const, id: "size", @@ -156,8 +169,41 @@ describe("appliedValues", () => { }); }); +describe("canonicalValues", () => { + // Analytics counts these, where one value typed two ways is one value. + it("spells two expressions of one value alike", () => { + expect(canonicalValues(select({ length: "5 in" }), parameters)).toEqual( + canonicalValues(select({ length: "(2 + 3) in" }), parameters) + ); + }); +}); + +describe("findRecord", () => { + const own = { values: {} }; + const large = { values: { size: "l" } }; + const largeFlagged = { values: { size: "l", flag: "true" } }; + const records = [own, large, largeFlagged]; + + it("picks the record naming the most of the selection", () => { + expect(findRecord(select({ size: "l", flag: "true" }), records)).toBe( + largeFlagged + ); + expect(findRecord(select({ size: "l" }), records)).toBe(large); + }); + + it("matches a record that omits a parameter it hid", () => { + const b = { values: { size: "l" } }; + expect(findRecord({ size: "l", hidden: "x" }, [own, b])).toBe(b); + }); + + it("falls back to the element's own record", () => { + expect(findRecord(select({}), records)).toBe(own); + }); +}); + describe("formatValue", () => { - it("reads a quantity back in the unit its parameter declares", () => { + it("reads a quantity evaluated, in the unit its parameter declares", () => { + expect(formatValue(length, "(1 + 1) in")).toBe("2 in"); expect(formatValue(length, "0.0508 m")).toBe("2 in"); }); @@ -168,45 +214,150 @@ describe("formatValue", () => { it("leaves everything else as stored", () => { expect(formatValue(size, "l")).toBe("l"); - // An option id needs the options to be named, which this does not have. expect(formatValue(flag, "unset")).toBe("unset"); }); }); -// An indexed record varies enumerated parameters only, while the insert menu -// holds the whole selection. One key is what makes the two agree on a render. -describe("the surfaces agree", () => { - const finish = enumParam("finish", ["matte", "gloss"], { - isCosmetic: true - }); - const all = [size, flag, finish, length]; - - it("spells an enumerated record and the equivalent selection alike", () => { - expect(toKey(select({ size: "l", flag: "false" }, all), all)).toBe( - toKey( - select( - { - size: "l", - flag: "false", - finish: "matte", - length: "1 in" - }, - all - ), - all - ) +describe("derivation variables", () => { + const derivation = derivationParam("dv"); + const params: ConfigurationParameter[] = [size, derivation]; + + // Unique to each insert by design, so it must not split one render in two. + it("leaves them out of the key", () => { + expect(toKey(select({ size: "l", dv: "abc" }, params), params)).toBe( + "size=l" ); }); - it("spells a non-default cosmetic or quantity value differently", () => { - // Enumeration never varies these, but they do change what renders. - const record = toKey(select({ size: "l" }, all), all); - const cases: Record[] = [ - { size: "l", finish: "gloss" }, - { size: "l", length: "2 in" } - ]; - for (const values of cases) { - expect(toKey(select(values, all), all)).not.toBe(record); - } + it("fills one still at its default, and keeps one already filled", () => { + const filled = fillDerivationValues( + select({ size: "l" }, params), + params + ); + expect(filled.dv).not.toBe(derivation.default); + expect(fillDerivationValues(filled, params)).toEqual(filled); + }); + + it("leaves them out of what is stored", () => { + expect(toStoredSelection({ size: "l", dv: "abc" }, params)).toEqual({ + size: "l" + }); + }); +}); + +const NORMALIZE_SIZE: ConfigurationParameter = { + id: "size", + name: "Size", + default: "small", + type: ParameterType.ENUM, + options: [ + { id: "small", name: "Small" }, + { id: "large", name: "Large" } + ], + optionConditions: [] +}; + +/** Only shown for the large size, so it is hidden by default. */ +const NORMALIZE_REINFORCED: ConfigurationParameter = { + id: "reinforced", + name: "Reinforced", + default: "false", + type: ParameterType.BOOLEAN, + condition: { type: VisibilityType.EQUAL, id: "size", value: "large" } +}; + +const NORMALIZE_PARAMS = [NORMALIZE_SIZE, NORMALIZE_REINFORCED]; + +describe("normalizeSelection", () => { + it("settles a hidden parameter on its default", () => { + const selection = toSelection( + { size: "small", reinforced: "true" }, + NORMALIZE_PARAMS + ); + expect(normalizeSelection(selection, NORMALIZE_PARAMS).reinforced).toBe( + "false" + ); + }); + + it("leaves a shown parameter's value alone", () => { + const selection = toSelection( + { size: "large", reinforced: "true" }, + NORMALIZE_PARAMS + ); + expect(normalizeSelection(selection, NORMALIZE_PARAMS).reinforced).toBe( + "true" + ); + }); + + it("is idempotent, which is what lets the panel stop", () => { + const once = normalizeSelection( + toSelection({}, NORMALIZE_PARAMS), + NORMALIZE_PARAMS + ); + // Identity: the second pass finds nothing to change. + expect(normalizeSelection(once, NORMALIZE_PARAMS)).toBe(once); + }); + + it("falls back to a visible option when the selected one is hidden", () => { + const material: ConfigurationParameter = { + id: "material", + name: "Material", + default: "alu", + type: ParameterType.ENUM, + options: [ + { id: "alu", name: "Aluminium" }, + { id: "steel", name: "Steel" } + ], + // Steel is only offered on the large size. + optionConditions: [ + { + type: OptionVisibilityType.LIST, + controlledOptions: ["steel"], + condition: { + type: VisibilityType.EQUAL, + id: "size", + value: "large" + } + }, + { + type: OptionVisibilityType.LIST, + controlledOptions: ["alu"], + condition: { type: VisibilityType.ALWAYS_SHOWN } + } + ] + }; + const params = [NORMALIZE_SIZE, material]; + const selection = toSelection( + { size: "small", material: "steel" }, + params + ); + expect(normalizeSelection(selection, params).material).toBe("alu"); + }); + + it("settles a chain where one parameter decides the next", () => { + const bolts: ConfigurationParameter = { + id: "bolts", + name: "Bolts", + default: "2", + type: ParameterType.ENUM, + options: [ + { id: "2", name: "Two" }, + { id: "4", name: "Four" } + ], + optionConditions: [], + condition: { + type: VisibilityType.EQUAL, + id: "reinforced", + value: "true" + } + }; + const params = [NORMALIZE_SIZE, NORMALIZE_REINFORCED, bolts]; + const selection = toSelection( + { size: "small", reinforced: "true", bolts: "4" }, + params + ); + const settled = normalizeSelection(selection, params); + expect(settled.reinforced).toBe("false"); + expect(settled.bolts).toBe("2"); }); }); diff --git a/src/backend/features/configurations/selection.ts b/src/backend/features/configurations/selection.ts index 5c00f5cc5..5940ffce2 100644 --- a/src/backend/features/configurations/selection.ts +++ b/src/backend/features/configurations/selection.ts @@ -1,52 +1,43 @@ /** - * The two forms a configuration takes, and the only place either is built. - * Canonicalizing is lossy: "2 + 3 in" survives only in the input it was typed into. + * Builds the two forms of a configuration: a selection, as entered, and the + * key derived from it to name a thumbnail. See AGENTS.md. */ import { type ConfigurationKey, type ConfigurationParameter, + type ConfigurationRecord, ParameterType, type PartialSelection, type QuantityParameter, type Selection } from "./contract"; +import { getUnitDisplayStr } from "./enums"; +import { isDerivationVariable } from "./roles"; import { DEFAULT_QUANTITY_PRECISION, - decodeConfiguration, encodeConfiguration, - evaluateCondition + evaluateCondition, + getVisibleOptions, + resolveSelectedOption } from "./utils"; import { evaluateBaseValue, formatBaseValue, - formatValueInUnit, formatValueWithUnits } from "./input-parser"; -/** Normalizes one parameter's raw value to its canonical spelling. */ -export function canonicalizeValue( - parameter: ConfigurationParameter, - value: string +/** A quantity's default in the parameter's own unit, e.g. "1 in". */ +export function quantityDefault( + parameter: Pick ): string { - if (parameter.type === ParameterType.QUANTITY) { - // "1in", "1 in" and "25.4 mm" are one configuration: the parser reads - // them to one base value. Unparseable values ride as typed. - const base = evaluateBaseValue( - value, - parameter.quantityType, - parameter.unit - ); - return base === undefined ? value.trim() : formatBaseValue(base); - } - if (parameter.type === ParameterType.BOOLEAN) { - return value.trim().toLowerCase(); - } - return value.trim(); + const abbreviation = getUnitDisplayStr(parameter.unit); + const value = String(parameter.defaultValue); + return abbreviation ? `${value} ${abbreviation}` : value; } /** - * Every declared parameter, canonically spelled and in parameter order. Filled - * from the defaults, so a partial map — a search hit's overrides — comes whole. + * Every declared parameter, as entered, with missing ones defaulted. Checkboxes + * are normalized to Onshape's spelling and quantities trimmed. */ export function toSelection( values: PartialSelection, @@ -54,18 +45,19 @@ export function toSelection( ): Selection { const selection: Selection = {}; for (const parameter of parameters) { - selection[parameter.id] = canonicalizeValue( - parameter, - values[parameter.id] ?? parameter.default - ); + const value = values[parameter.id] ?? parameter.default; + if (parameter.type === ParameterType.BOOLEAN) { + selection[parameter.id] = value.trim().toLowerCase(); + } else if (parameter.type === ParameterType.QUANTITY) { + selection[parameter.id] = value.trim(); + } else { + selection[parameter.id] = value; + } } return selection; } -/** - * What a selection actually applies: Onshape never applies a parameter its - * condition hides, so a hidden one is left off. - */ +/** Drops parameters hidden by a condition, which Onshape never applies. */ export function appliedValues( selection: Selection, parameters: ConfigurationParameter[] @@ -83,8 +75,51 @@ export function appliedValues( return values; } -/** What a selection changes from the element's own defaults, and nothing else. */ -function overriddenValues( +/** Quantities in base units, so "1in", "1 in" and "25.4 mm" agree. */ +export function canonicalValue( + parameter: ConfigurationParameter, + value: string +): string { + if (parameter.type !== ParameterType.QUANTITY) { + return value; + } + const base = evaluateBaseValue( + value, + parameter.quantityType, + parameter.unit + ); + return base === undefined ? value : formatBaseValue(base); +} + +/** + * Applied values, canonically spelled, for comparing and counting. Derivation + * variables are left out since each insert's is unique. + */ +export function canonicalValues( + selection: Selection, + parameters: ConfigurationParameter[] +): Selection { + const applied = appliedValues(selection, parameters); + const values: Selection = {}; + for (const parameter of parameters) { + const value = applied[parameter.id]; + if (value !== undefined && !isDerivationVariable(parameter)) { + values[parameter.id] = canonicalValue(parameter, value); + } + } + return values; +} + +/** Whether a value is the parameter's default, however either is spelled. */ +function isDefault(parameter: ConfigurationParameter, value: string): boolean { + return ( + canonicalValue(parameter, value) === + canonicalValue(parameter, parameter.default) + ); +} + +/** The values that differ from the element's defaults, as entered. */ +export function onshapeOverrides( selection: Selection, parameters: ConfigurationParameter[] ): Selection { @@ -92,7 +127,7 @@ function overriddenValues( const overrides: Selection = {}; for (const parameter of parameters) { const value = values[parameter.id]; - if (value !== undefined && value !== parameter.default) { + if (value !== undefined && !isDefault(parameter, value)) { overrides[parameter.id] = value; } } @@ -100,91 +135,85 @@ function overriddenValues( } /** - * A selection's identity: what it overrides, encoded. Two selections that - * render the same thing key the same, and so share a cache entry. + * Selections that render the same part get the same key, so derivation + * variables are left out. */ export function toKey( selection: Selection, parameters: ConfigurationParameter[] ): ConfigurationKey { - return encodeConfiguration(overriddenValues(selection, parameters)); + const overrides = onshapeOverrides(selection, parameters); + const canonical: Selection = {}; + for (const parameter of parameters) { + const value = overrides[parameter.id]; + if (value !== undefined && !isDerivationVariable(parameter)) { + canonical[parameter.id] = canonicalValue(parameter, value); + } + } + return encodeConfiguration(canonical); } -/** - * Values encoded the way Onshape is told them, quantities in their own unit. - * Never a key and never stored as one: a key is an identity, so it stays in - * base units where two equal values spell alike, while this is only ever read - * by Onshape, which would rather be told "1.5 in". - */ -function encodeForOnshape( - values: Selection, +/** Gives each derivation variable still at its default a unique value, so each derive is its own configuration. */ +export function fillDerivationValues( + selection: Selection, parameters: ConfigurationParameter[] -): string { - const spelled: Selection = {}; +): Selection { + const next = { ...selection }; for (const parameter of parameters) { - const value = values[parameter.id]; - if (value === undefined) { - continue; + if ( + isDerivationVariable(parameter) && + next[parameter.id] === parameter.default + ) { + next[parameter.id] = crypto.randomUUID(); } - spelled[parameter.id] = - parameter.type === ParameterType.QUANTITY - ? toExpression(parameter, value) - : value; } - return encodeConfiguration(spelled); + return next; } -/** The overrides an insert hands Onshape: the short form, empty for defaults. */ -export function toOnshapeConfiguration( - selection: Selection, +/** Strips derivation variables before a selection is stored or shared. */ +export function toStoredSelection( + selection: PartialSelection, parameters: ConfigurationParameter[] -): string { - return encodeForOnshape( - overriddenValues(selection, parameters), - parameters - ); +): PartialSelection { + const stored = { ...selection }; + for (const parameter of parameters) { + if (isDerivationVariable(parameter)) { + delete stored[parameter.id]; + } + } + return stored; } /** - * The shortest configuration that is not empty: the first parameter the - * selection applies, at the value it applies. Onshape fills the rest in from the - * element's own defaults, so it names the same render "" does — for a caller - * that must hand Onshape a configuration but cannot hand it "". - * - * Itself empty only when a condition hides every parameter the element has. + * Records name only what enumeration varied, so several can match; the most + * specific wins. */ -export function toShortestConfiguration( +export function findRecord>( selection: Selection, - parameters: ConfigurationParameter[] -): string { - const values = appliedValues(selection, parameters); - const first = parameters.find( - (parameter) => values[parameter.id] !== undefined - ); - return first === undefined ? "" : encodeForOnshape(values, [first]); -} - -/** The selection a key names: its overrides, over the parameters' defaults. */ -export function fromKey( - key: ConfigurationKey, - parameters: ConfigurationParameter[] -): Selection { - return toSelection(decodeConfiguration(key), parameters); + records: T[] +): T | undefined { + let best: T | undefined; + let bestNamed = -1; + for (const record of records) { + const named = Object.entries(record.values); + const matches = named.every(([id, value]) => selection[id] === value); + if (matches && named.length > bestNamed) { + best = record; + bestNamed = named.length; + } + } + return best; } /** - * A value as a person reads it: a quantity in the unit its parameter declares - * rather than the base unit it is stored in, and a checkbox as its state. An - * enum's value is its option id, which only its own options can name, so the - * caller holding them spells that one. + * Enums are left as their option id, which the caller spells from the options + * it holds. */ export function formatValue( parameter: ConfigurationParameter, value: string ): string { if (parameter.type === ParameterType.BOOLEAN) { - // Anything else was not written by `canonicalizeValue`, so it rides as - // stored rather than being read as a "No". if (value === "true") return "Yes"; if (value === "false") return "No"; return value; @@ -206,23 +235,59 @@ export function formatValue( ); } +/** One pass: hidden parameters take their default, enums the option they land on. */ +function normalizeOnce( + selection: Selection, + parameters: ConfigurationParameter[] +): Selection { + const next = { ...selection }; + for (const parameter of parameters) { + if (!evaluateCondition(parameter.condition, next, parameters)) { + next[parameter.id] = parameter.default; + continue; + } + if (parameter.type !== ParameterType.ENUM) { + continue; + } + const visible = getVisibleOptions(parameter, next, parameters); + next[parameter.id] = + resolveSelectedOption( + visible, + next[parameter.id], + parameter.default + )?.id ?? parameter.default; + } + return next; +} + +export function sameSelection( + a: PartialSelection | undefined, + b: Selection +): boolean { + if (!a) return false; + const keys = Object.keys(b); + return ( + keys.length === Object.keys(a).length && + keys.every((key) => a[key] === b[key]) + ); +} + /** - * What Onshape is handed for a quantity: the parameter's own unit, so a derived - * feature reads "1.5 in" rather than the "0.0381 meter" a selection stores. - * Both name the same value — this is the one a person recognizes as theirs. - * - * Not the expression that was typed: that lives in the input and nowhere else, - * so "2 + 3 in" arrives here as "5 in". Unlike {@link formatValue} it keeps - * every decimal, being the value Onshape builds from rather than a label. + * Repeated because settling one parameter can change another's options. The + * pass cap stops parameters whose conditions name each other, so the result + * isn't always a fixed point. */ -export function toExpression( - parameter: QuantityParameter, - value: string -): string { - const base = evaluateBaseValue( - value, - parameter.quantityType, - parameter.unit - ); - return base === undefined ? value : formatValueInUnit(base, parameter.unit); +export function normalizeSelection( + selection: Selection, + parameters: ConfigurationParameter[] +): Selection { + let current = selection; + for (let pass = 0; pass <= parameters.length; pass++) { + const next = normalizeOnce(current, parameters); + if (sameSelection(current, next)) { + return current; + } + current = next; + } + return current; } diff --git a/src/backend/features/configurations/units.ts b/src/backend/features/configurations/units.ts new file mode 100644 index 000000000..7a68796ba --- /dev/null +++ b/src/backend/features/configurations/units.ts @@ -0,0 +1,93 @@ +/** + * A workspace's units, cached so each panel open needn't ask Onshape. A + * transient webhook drops the entry when they change; since Onshape may drop + * the webhook quietly, entries also expire, and the next miss watches again. + */ +import { HttpStatus } from "http-status-ts"; +import type { AppContext } from "../../lib/context"; +import { internalError } from "../../lib/api-error"; +import { runInBackground } from "../../lib/background"; +import { kvStore } from "../../lib/kv-store"; +import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; +import type { InstancePath } from "../../lib/onshape/path"; +import { watchWorkspaceUnits } from "../webhooks/transient"; +import type { UnitInfo } from "./contract"; +import { QuantityType, type Unit } from "./enums"; +import { DEFAULT_QUANTITY_PRECISION } from "./utils"; + +const unitInfos = kvStore("unit-info", { + ttlSeconds: 7 * 24 * 3600 +}); + +function unitInfoId(path: InstancePath): string { + return `${path.documentId}:${path.instanceType}:${path.instanceId}`; +} + +/** One entry of Onshape's `defaultUnits`: which unit a quantity type is in. */ +interface OnshapeUnit { + key: QuantityType; + value: Unit; +} + +/** Onshape names a unit for every type, so a missing one is a response we don't understand. */ +function getDefaultUnit( + units: OnshapeUnit[], + quantityType: QuantityType +): Unit { + const unit = units.find((entry) => entry.key === quantityType); + if (!unit) { + throw internalError( + `Onshape named no default unit for ${quantityType}`, + HttpStatus.BAD_GATEWAY + ); + } + return unit.value; +} + +async function fetchUnitInfo( + c: AppContext, + path: InstancePath +): Promise { + const raw = await getUnitInfo(await c.var.getOnshapeApi(), path); + const units = raw.defaultUnits.units as OnshapeUnit[]; + const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); + const lengthUnit = getDefaultUnit(units, QuantityType.LENGTH); + return { + angleUnit, + lengthUnit, + anglePrecision: raw.unitsDisplayPrecision[angleUnit], + lengthPrecision: raw.unitsDisplayPrecision[lengthUnit], + // Onshape carries no display precision for a unitless real. + realPrecision: DEFAULT_QUANTITY_PRECISION + }; +} + +export async function getUnitInfoCached( + c: AppContext, + path: InstancePath +): Promise { + const cached = await unitInfos.get(c.env.KV, unitInfoId(path)); + if (cached) { + return cached; + } + const unitInfo = await fetchUnitInfo(c, path); + await unitInfos.put(c.env.KV, unitInfoId(path), unitInfo); + // A version's units never change, so only a workspace is watched. + if (path.instanceType === "w") { + await runInBackground(c, "watch workspace units", async () => + watchWorkspaceUnits( + await c.var.getOnshapeApi(), + path, + c.env.APP_URL + ) + ); + } + return unitInfo; +} + +export function forgetUnitInfo( + kv: KVNamespace, + workspace: InstancePath +): Promise { + return unitInfos.delete(kv, unitInfoId(workspace)); +} diff --git a/src/backend/features/configurations/units.worker.test.ts b/src/backend/features/configurations/units.worker.test.ts new file mode 100644 index 000000000..9a55fc40a --- /dev/null +++ b/src/backend/features/configurations/units.worker.test.ts @@ -0,0 +1,101 @@ +import { env } from "cloudflare:workers"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi +} from "vitest"; +import { createTestApp, jsonRequest } from "../../../__test_utils__"; +import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents"; +import * as WebhookEndpoints from "../../lib/onshape/endpoints/webhooks"; +import { QuantityType, Unit } from "./enums"; + +function mockUnits() { + return vi.spyOn(DocumentEndpoints, "getUnitInfo").mockResolvedValue({ + defaultUnits: { + units: [ + { key: QuantityType.ANGLE, value: Unit.DEGREE }, + { key: QuantityType.LENGTH, value: Unit.INCH } + ] + }, + unitsDisplayPrecision: { [Unit.DEGREE]: 1, [Unit.INCH]: 3 } + }); +} + +const unitInfo = (instanceType: "w" | "v", documentId = "doc") => + createTestApp().request( + `https://app.example.com/api/unit-info?documentId=${documentId}&instanceId=ws&instanceType=${instanceType}`, + jsonRequest("GET"), + env + ); + +const deliver = (url: string, event: string) => + createTestApp().request(url, jsonRequest("POST", { event }), env); + +describe("a workspace's units", () => { + let watch: MockInstance; + beforeEach(() => { + watch = vi + .spyOn(WebhookEndpoints, "createWebhook") + .mockResolvedValue({ id: "hook", url: "", events: [] }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("asks Onshape once, then serves what it kept", async () => { + const fetch = mockUnits(); + + await unitInfo("w", "doc-once"); + const res = await unitInfo("w", "doc-once"); + + expect(await res.json()).toMatchObject({ lengthUnit: Unit.INCH }); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("watches a workspace with a transient webhook", async () => { + mockUnits(); + + await unitInfo("w", "doc-watch"); + + expect(watch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + documentId: "doc-watch", + workspaceId: "ws", + events: ["onshape.model.lifecycle.updateworkspaceunits"], + isTransient: true + }) + ); + }); + + // A version's units never change. + it("watches nothing for a version", async () => { + mockUnits(); + await unitInfo("v", "doc-version"); + expect(watch).not.toHaveBeenCalled(); + }); + + it("asks again once the webhook says they changed", async () => { + const fetch = mockUnits(); + await unitInfo("w", "doc-changed"); + const url = watch.mock.calls[0][1].url; + + await deliver(url, "onshape.model.lifecycle.updateworkspaceunits"); + await unitInfo("w", "doc-changed"); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("keeps its copy through Onshape's registration check", async () => { + const fetch = mockUnits(); + await unitInfo("w", "doc-register"); + const url = watch.mock.calls[0][1].url; + + expect((await deliver(url, "webhook.register")).status).toBe(200); + await unitInfo("w", "doc-register"); + + expect(fetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts index 95ee47a9f..0fcbac859 100644 --- a/src/backend/features/configurations/utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -4,15 +4,12 @@ import { encodeConfiguration, encodeQueryConfiguration, evaluateCondition, - findRecordForConfiguration, getPartUrl, - getVisibleOptions, - toQueryConfiguration + getVisibleOptions } from "./utils"; import { OptionVisibilityType, PartMetadata, - SearchRecord, VisibilityType, type ConfigurationParameter } from "./contract"; @@ -20,43 +17,6 @@ import { LogicalOp } from "./enums"; import { Vendor } from "../library/vendors"; import { enumParam } from "../../../__test_utils__/configuration-fixtures"; -function rec(configurationKey: string, partNumber = "PN"): SearchRecord { - return { partNumber, configurationKey }; -} - -describe("findRecordForConfiguration", () => { - it("returns the record whose enumerated values match the selection", () => { - const records = [rec("size=s", "PN-S"), rec("size=l", "PN-L")]; - // The selection also carries a non-enumerated (quantity) param, ignored. - expect( - findRecordForConfiguration("size=l;qty=3", records)?.partNumber - ).toBe("PN-L"); - }); - - it("prefers the most specific match when several apply", () => { - const records = [rec("", "default"), rec("size=l", "PN-L")]; - expect(findRecordForConfiguration("size=l", records)?.partNumber).toBe( - "PN-L" - ); - }); - - it("falls back to a less specific record when a parameter is hidden", () => { - const records = [ - rec("mode=a;detail=x", "A-X"), - // `detail` is hidden when mode=b, so this record omits it. - rec("mode=b", "B") - ]; - expect( - findRecordForConfiguration("mode=b;detail=x", records)?.partNumber - ).toBe("B"); - }); - - it("returns undefined when nothing matches", () => { - const records = [rec("size=s", "PN-S")]; - expect(findRecordForConfiguration("size=l", records)).toBeUndefined(); - }); -}); - describe("configuration text", () => { it("encodes the empty configuration as the empty string", () => { expect(encodeConfiguration({})).toBe(""); @@ -107,8 +67,7 @@ describe("configuration text", () => { }); describe("encodeQueryConfiguration", () => { - // Escaped once more by whatever puts it in a query, so a space has to - // still be a space here; Onshape's own examples read `dia1=1+m`. + // The query adds a layer; Onshape's examples read `dia1=1+m`. it("leaves a quantity's space for the query layer to escape", () => { expect(encodeQueryConfiguration({ length: "0.0508 m" })).toBe( "length=0.0508 m" @@ -135,13 +94,6 @@ describe("encodeQueryConfiguration", () => { decodeConfiguration(encodeQueryConfiguration(configuration)) ).toEqual(configuration); }); - - it("rewrites a key into the query form", () => { - expect(toQueryConfiguration("length=0.0508%20m;size=l")).toBe( - "length=0.0508 m;size=l" - ); - expect(toQueryConfiguration("")).toBe(""); - }); }); function metadata(fields: Partial): PartMetadata { @@ -193,8 +145,6 @@ describe("getPartUrl", () => { ).toBeUndefined(); }); - // A part configurable across vendors carries a generic vendor, but each - // configuration's number still says who sells that one. it("reads the vendor out of the part number over a generic tagging", () => { const url = getPartUrl(metadata({ partNumber: "TTB-0016" }), [ Vendor.WCP, @@ -215,8 +165,6 @@ describe("getPartUrl", () => { }); describe("getVisibleOptions", () => { - // Only the last three sizes are restricted, to the heavy style; nothing is - // said about s1 and s2. const style = enumParam("style", ["light", "heavy"]); const size = enumParam("size", ["s1", "s2", "s3", "s4", "s5"], { optionConditions: [ @@ -239,8 +187,7 @@ describe("getVisibleOptions", () => { expect(getVisibleOptions(plain, {}, [plain])).toHaveLength(2); }); - // The panel drops an enum with no options left, so reading the conditions - // as a list of what may be shown took the whole parameter off the screen. + // The panel drops an enum with no options left. it("keeps the options no condition names", () => { const visible = getVisibleOptions(size, { style: "light" }, params); expect(visible.map((option) => option.id)).toEqual(["s1", "s2"]); @@ -292,8 +239,7 @@ describe("getVisibleOptions", () => { }); describe("evaluateCondition", () => { - // The parser drops children it cannot represent, so a logical can arrive - // holding none — and an OR of nothing reads as never. + // The parser drops children it can't represent. it.each([LogicalOp.AND, LogicalOp.OR])( "shows a parameter whose %s condition holds no children", (operation) => { diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index 7b22d943e..d94b4a48a 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -1,10 +1,7 @@ import { - type ConfigurationKey, - type ConfigurationRecord, type PartMetadata, type PartialSelection, Selection, - DEFAULT_CONFIGURATION_KEY, EnumOption, EnumParameter, OptionVisibilityCondition, @@ -12,7 +9,6 @@ import { ConfigurationParameter, ParameterType, QuantityParameter, - SearchRecord, UnitInfo, VisibilityCondition, VisibilityType @@ -26,32 +22,7 @@ import { import { LogicalOp, QuantityType, Unit } from "./enums"; import { type EvaluateOptions, valueWithUnits } from "./input-parser"; -/** - * The record a selection produces. Several can match, since records name only - * enumerated parameters, so the most specific wins. - */ -export function findRecordForConfiguration( - configurationKey: ConfigurationKey, - records: SearchRecord[] -): SearchRecord | undefined { - const selected = new Set(splitConfiguration(configurationKey)); - let best: SearchRecord | undefined; - let bestNamed = -1; - for (const record of records) { - const named = splitConfiguration(record.configurationKey); - const matches = named.every((assignment) => selected.has(assignment)); - if (matches && named.length > bestNamed) { - best = record; - bestNamed = named.length; - } - } - return best; -} - -/** - * Whether a parameter is shown. Takes a partial selection: visibility is what - * enumeration consults while a combination is still being built up. - */ +/** Takes a partial selection, since enumeration checks it mid-combination. */ export function evaluateCondition( condition: VisibilityCondition | undefined, selection: PartialSelection, @@ -62,8 +33,7 @@ export function evaluateCondition( } if (condition.type === VisibilityType.LOGICAL) { - // An OR of no children reads as false, which would hide a parameter - // over a condition the parser merely failed to represent. + // An empty OR is a condition the parser failed to represent; don't hide over it. if (condition.children.length === 0) { return true; } @@ -100,13 +70,9 @@ export function evaluateCondition( return true; } -/** A description holding a link is the link, rather than a description. */ const ABSOLUTE_URL = new RegExp("^https?://", "i"); -/** - * The page for a part, in descending precision: a description that is already a - * url, then the vendor the part number names, then the taggings standing in. - */ +/** A description that is a url, else the vendor's page for the part number, else the tagged vendor's. */ export function getPartUrl( record: PartMetadata, vendors: Vendor[] = [] @@ -114,8 +80,6 @@ export function getPartUrl( if (record.description && ABSOLUTE_URL.test(record.description)) { return record.description; } - // WCP-123 -> WCP, then what the part says it is, then the insertable's - // tagging when it names one vendor and one only. let vendor = parseVendorFromPartNumber(record.partNumber); vendor ??= parseVendor(record.vendor); if (!vendor && vendors.length === 1) { @@ -125,24 +89,22 @@ export function getPartUrl( } /** - * The text form of a configuration: `id=value;id=value`, values percent-encoded - * so a `;` or `=` typed into a string parameter cannot read as the end of the - * assignment. {@link decodeConfiguration} is the other half, and `utils.test.ts` - * pins the round trip. - * - * This is the form a key is stored and addressed by, and the form a request body - * carries, where nothing escapes it a second time. A query parameter is escaped - * again in transport, so it takes {@link encodeQueryConfiguration} instead. + * `id=value;id=value`, percent-encoded so a typed `;` or `=` can't end an + * assignment. For keys and request bodies; a query parameter uses + * {@link encodeQueryConfiguration}. */ -export function encodeConfiguration(configuration?: Selection): string { - if (!configuration) { - return ""; - } - return Object.entries(configuration) +export function encodeConfiguration(configuration?: PartialSelection): string { + return assignments(configuration) .map(([id, value]) => `${id}=${encodeURIComponent(value)}`) .join(";"); } +function assignments(configuration?: PartialSelection): [string, string][] { + return Object.entries(configuration ?? {}).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ); +} + /** The characters the text form is structured by, which a value must not spell. */ const QUERY_ESCAPES: Record = { "%": "%25", @@ -155,38 +117,24 @@ function escapeForQuery(value: string): string { } /** - * The form Onshape's `configuration` query parameter takes: the same - * assignments, with only the three structural characters escaped. - * - * Putting it in a query escapes it once more, and Onshape's examples show a - * quantity arriving with exactly that one layer — `dia1=1+m`, `theta=2+degree` - * — so a value percent-encoded here reaches them as the literal `0.381%20m`, - * which is no quantity. The structural three still keep a typed `;` from ending - * an assignment, and `decodeConfiguration` reads this form back too. + * Onshape's `configuration` query parameter. Only `;`, `=` and `%` are escaped: + * the query adds its own layer, and a value encoded twice reaches Onshape as + * `0.381%20m`, which isn't a quantity. */ -export function encodeQueryConfiguration(configuration?: Selection): string { - if (!configuration) { - return ""; - } - return Object.entries(configuration) +export function encodeQueryConfiguration( + configuration?: PartialSelection +): string { + return assignments(configuration) .map(([id, value]) => `${id}=${escapeForQuery(value)}`) .join(";"); } -/** The same, for a configuration already encoded as a key. */ -export function toQueryConfiguration(configurationKey: string): string { - return encodeQueryConfiguration(decodeConfiguration(configurationKey)); -} - /** The assignments a configuration text names, each still `id=value`. */ function splitConfiguration(configuration: string): string[] { return configuration.split(";").filter((assignment) => assignment !== ""); } -/** - * The values a configuration text names. A key names only what it overrides, so - * what it omits is the parameter's own default — `fromKey` fills those in. - */ +/** The values a configuration text names, in either encoding. */ export function decodeConfiguration(configuration: string): Selection { const values: Selection = {}; for (const assignment of splitConfiguration(configuration)) { @@ -207,10 +155,6 @@ export function getOption( return options.find((option) => option.id === optionId); } -/** - * The enum options the selection leaves visible, by the parameter's own option - * conditions. Partial for the same reason {@link evaluateCondition} is. - */ /** The options one condition controls, listed or spanned. */ function getControlledOptionIds( optionCondition: OptionVisibilityCondition, @@ -228,11 +172,8 @@ function getControlledOptionIds( } /** - * The options an enum currently offers. A condition restricts the options it - * names and says nothing about the rest, so an option no condition names is - * always offered, and one several name is offered while any of them holds. - * Offering only what a passing condition names instead empties a - * partly-conditioned enum, and the panel drops a parameter with no options. + * An option no condition names is always offered; one several name is offered + * while any holds. */ export function getVisibleOptions( enumParameter: EnumParameter, @@ -270,16 +211,32 @@ export function getVisibleOptions( ); } +/** The selected option if still visible, else the default, else the first. */ +export function resolveSelectedOption( + visibleOptions: EnumOption[], + currentOptionId: string | undefined, + defaultOptionId: string +): EnumOption | undefined { + if (visibleOptions.length === 0) { + return undefined; + } + return ( + (currentOptionId + ? getOption(visibleOptions, currentOptionId) + : undefined) ?? + getOption(visibleOptions, defaultOptionId) ?? + visibleOptions[0] + ); +} + /** Display precision used when the document's units aren't available. */ export const DEFAULT_QUANTITY_PRECISION = 3; -/** - * The evaluation settings for a quantity parameter: its own bounds, plus the - * document's display unit and precision, falling back to the parameter's own. - */ +/** Bounds from the parameter; unit and precision from the document, else the parameter. */ export function getEvaluateOptions( parameter: QuantityParameter, - unitInfo: UnitInfo + /** The document's; without one, each quantity shows in its own unit. */ + unitInfo: UnitInfo | undefined ): EvaluateOptions { const quantityType = parameter.quantityType; const minAndMax = { @@ -290,23 +247,23 @@ export function getEvaluateOptions( return { quantityType, displayPrecision: - unitInfo.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: unitInfo.lengthUnit ?? parameter.unit, + unitInfo?.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo?.lengthUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType === QuantityType.ANGLE) { return { quantityType, displayPrecision: - unitInfo.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: unitInfo.angleUnit ?? parameter.unit, + unitInfo?.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo?.angleUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType === QuantityType.REAL) { return { quantityType, displayPrecision: - unitInfo.realPrecision ?? DEFAULT_QUANTITY_PRECISION, + unitInfo?.realPrecision ?? DEFAULT_QUANTITY_PRECISION, displayUnit: Unit.UNITLESS, ...minAndMax }; @@ -318,18 +275,3 @@ export function getEvaluateOptions( ...minAndMax }; } - -/** - * An insertable's full record list: its own part data first — the record an - * unset configuration falls back to — then one per indexed configuration. - */ -export function toRecords( - partMetadata: PartMetadata | null, - records: ConfigurationRecord[] -): ConfigurationRecord[] { - if (!partMetadata) return records; - return [ - { ...partMetadata, configurationKey: DEFAULT_CONFIGURATION_KEY }, - ...records - ]; -} diff --git a/src/backend/features/entry/routes.test.ts b/src/backend/features/entry/routes.test.ts deleted file mode 100644 index 1e2c1c29a..000000000 --- a/src/backend/features/entry/routes.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { env } from "cloudflare:workers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { eq } from "drizzle-orm"; -import { users } from "../../db/schema"; -import { events } from "../analytics/schema"; -import { EVENT_SCHEMA_VERSION, EventType } from "../analytics/usage"; -import { DEFAULT_LIBRARY, LibraryId } from "../library/library-id"; -import { type AppTab, UtilityTab } from "../settings/app-tab"; -import { Theme } from "../settings/settings"; -import { - TEST_GROUP_ID, - TEST_USER_ID, - createTestApp, - jsonRequest, - TEST_LIBRARY_ID, - resetDb, - seedGroup, - seedLibrary, - seedUser -} from "../../../__test_utils__"; -import { getDb } from "../../db/client"; - -const db = getDb(env.DB); - -describe("GET /init", () => { - beforeEach(async () => { - await resetDb(db); - }); - - it("sends a user to the library they last used", async () => { - await seedUser(db, TEST_USER_ID, LibraryId.MKCAD); - - const res = await createTestApp().request( - "/init?documentId=doc&workspaceId=ws", - jsonRequest("GET"), - env - ); - - expect(res.status).toBe(302); - const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.pathname).toBe(`/app/library/${LibraryId.MKCAD}`); - // The Onshape params have to survive the redirect. - expect(location.searchParams.get("documentId")).toBe("doc"); - expect(location.searchParams.get("workspaceId")).toBe("ws"); - }); - - // A utility tab resumes the same way, and has no library to count under. - it("resumes in a utility tab, and logs no library open", async () => { - await seedUser(db, TEST_USER_ID, UtilityTab.VERSION_MANAGER); - - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - // A utility is a page of its own, not one of the libraries. - expect(location.pathname).toBe(`/app/${UtilityTab.VERSION_MANAGER}`); - expect(await db.select().from(events).get()).toBeUndefined(); - }); - - // The frontend 404s a tab id it does not know, so a row naming one the app - // has dropped would strand the caller on every panel open. - it("sends a user whose stored tab is unknown to the default", async () => { - await seedLibrary(db); - await db - .insert(users) - .values({ id: TEST_USER_ID, tabId: "old-frc-lib" as AppTab }) - .onConflictDoNothing(); - - const res = await createTestApp().request( - "/init?documentId=doc", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.pathname).toBe(`/app/library/${DEFAULT_LIBRARY}`); - expect(location.searchParams.get("documentId")).toBe("doc"); - }); - - it("sends a user with no row to the default library", async () => { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - - expect(res.status).toBe(302); - expect(res.headers.get("Location")).toContain( - `/app/library/${LibraryId.FRC_DESIGN_LIB}` - ); - }); - - it("seeds the saved theme and forwards Onshape's color scheme", async () => { - await seedUser(db); - await db - .update(users) - .set({ theme: Theme.DARK }) - .where(eq(users.id, TEST_USER_ID)); - - const res = await createTestApp().request( - "/init?theme=light", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - // Onshape's scheme becomes systemTheme; theme carries the user's choice. - expect(location.searchParams.get("systemTheme")).toBe("light"); - expect(location.searchParams.get("theme")).toBe(Theme.DARK); - }); - - it("seeds the default theme for a user with no row", async () => { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.searchParams.get("theme")).toBe(Theme.SYSTEM); - }); - - // The seed is what tells the app a tab was chosen; without one the caller - // lands in the default library for the welcome to ask over. - it("seeds the tab a row names, and none for a user who has not chosen", async () => { - const seededTab = async () => { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - const location = new URL(res.headers.get("Location")!, "http://x"); - return [ - location.pathname, - location.searchParams.get("tabId") - ] as const; - }; - - expect(await seededTab()).toEqual([ - `/app/library/${DEFAULT_LIBRARY}`, - null - ]); - - await seedLibrary(db); - await db.insert(users).values({ id: TEST_USER_ID }); - expect(await seededTab()).toEqual([ - `/app/library/${DEFAULT_LIBRARY}`, - null - ]); - - await db - .update(users) - .set({ tabId: LibraryId.FTC_DESIGN_LIB }) - .where(eq(users.id, TEST_USER_ID)); - expect(await seededTab()).toEqual([ - `/app/library/${LibraryId.FTC_DESIGN_LIB}`, - LibraryId.FTC_DESIGN_LIB - ]); - }); - - /** The tab and group a user left off in, as their row records them. */ - async function seedResume(tabId: AppTab, groupId: string | null) { - await seedUser(db, TEST_USER_ID, tabId); - await db - .update(users) - .set({ groupId }) - .where(eq(users.id, TEST_USER_ID)); - } - - async function entryPath(): Promise { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - return new URL(res.headers.get("Location")!, "http://x").pathname; - } - - it("resumes in the group they last opened", async () => { - await seedGroup(db); - await seedResume(TEST_LIBRARY_ID, TEST_GROUP_ID); - - expect(await entryPath()).toBe( - `/app/library/${TEST_LIBRARY_ID}/groups/${TEST_GROUP_ID}` - ); - }); - - // The group is gone, so the caller lands in the library rather than on a - // "group not found" page. - it("falls back to the library when the group has been deleted", async () => { - await seedResume(TEST_LIBRARY_ID, "deleted-group"); - - expect(await entryPath()).toBe(`/app/library/${TEST_LIBRARY_ID}`); - }); - - // Whichever library they switched to, they have not opened a group in it. - it("ignores a group belonging to another library", async () => { - await seedGroup(db); - await seedResume(LibraryId.MKCAD, TEST_GROUP_ID); - - expect(await entryPath()).toBe(`/app/library/${LibraryId.MKCAD}`); - }); - - it("versions the open it records", async () => { - await createTestApp().request("/init", jsonRequest("GET"), env); - - const event = await db.select().from(events).get(); - expect(event).toMatchObject({ - type: EventType.APP_OPEN, - schemaVersion: EVENT_SCHEMA_VERSION - }); - }); - - /** Where the gate sends a caller Onshape will not take. */ - async function signInRedirect(path: string): Promise { - const res = await createTestApp({ - isAuthenticated: false, - signedIn: false - }).request(path, jsonRequest("GET"), env); - - expect(res.status).toBe(302); - return new URL(res.headers.get("Location")!, "http://x"); - } - - it("signs in a caller Onshape will not take, scoped to their company", async () => { - const location = await signInRedirect( - "/init?sessionCompanyId=company-1" - ); - - expect(location.pathname).toBe("/auth/sign-in"); - expect(location.searchParams.get("sessionCompanyId")).toBe("company-1"); - const redirectUrl = new URL( - location.searchParams.get("redirectUrl")!, - "http://x" - ); - expect(redirectUrl.pathname).toBe("/init"); - expect(redirectUrl.searchParams.get("sessionCompanyId")).toBe( - "company-1" - ); - }); - - // Onshape decides which company a token is scoped to, and there is no - // personal company to ask it for, so a caller carrying an enterprise - // session into a plain document fails the gate every time it is tried. - it("opens the app rather than signing a caller in twice", async () => { - const location = await signInRedirect("/init"); - const res = await createTestApp({ - isAuthenticated: false, - signedIn: false - }).request( - location.searchParams.get("redirectUrl")!, - jsonRequest("GET"), - env - ); - - expect(res.status).toBe(302); - const entry = new URL(res.headers.get("Location")!, "http://x"); - expect(entry.pathname).toBe(`/app/library/${LibraryId.FRC_DESIGN_LIB}`); - // Spent, so it never reaches the app or a later sign-in. - expect(entry.searchParams.has("signInAttempted")).toBe(false); - }); - - // The enterprise the caller needs is a company Onshape's authorize endpoint - // takes, so a session scoped elsewhere is worth trying to replace. - it("signs in a caller whose session is scoped to another company", async () => { - const res = await createTestApp({ isAuthenticated: false }).request( - "/init?sessionCompanyId=company-1", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.pathname).toBe("/auth/sign-in"); - }); - - // There is no personal company to ask Onshape for, so the round trip comes - // back with the same session it started with. - it("opens the app for a session it cannot ask Onshape to rescope", async () => { - const res = await createTestApp({ isAuthenticated: false }).request( - "/init", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.pathname).toBe( - `/app/library/${LibraryId.FRC_DESIGN_LIB}` - ); - }); - - // Nobody is signed in, so there is no row to read and no open to record. - it("records no open for a caller it opens signed out", async () => { - await createTestApp({ - isAuthenticated: false, - signedIn: false - }).request("/init?signInAttempted=1", jsonRequest("GET"), env); - - expect(await db.select().from(events).get()).toBeUndefined(); - }); - - it("never caches the gate's verdict", async () => { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - expect(res.headers.get("Cache-Control")).toBe("private, no-store"); - }); -}); diff --git a/src/backend/features/entry/routes.ts b/src/backend/features/entry/routes.ts index 328e6fe8a..bd3269f65 100644 --- a/src/backend/features/entry/routes.ts +++ b/src/backend/features/entry/routes.ts @@ -1,48 +1,23 @@ -/** - * `/init` is where Onshape lands. It gates on auth, then resumes the caller in - * the tab and theme they last used. - */ -import { and, eq } from "drizzle-orm"; -import { getDb, type Db } from "../../db/client"; -import { groups, users } from "../../db/schema"; +/** Where Onshape lands: gates on auth, then hands the launch to the app. */ import { cacheMiddleware } from "../../lib/cache"; import { getApp, type AppContext } from "../../lib/context"; -import { isSignedIn } from "../auth/request-auth"; -import { getSessionCompanyId, PERSONAL_COMPANY_ID } from "../auth/session"; -import { DEFAULT_SETTINGS } from "../settings/settings"; -import { DEFAULT_LIBRARY } from "../library/library-id"; -import { - type AppTab, - getTabPath, - isLibraryTab, - toAppTab -} from "../settings/app-tab"; -import { trackAppOpen, trackInBackground } from "../analytics/tracking"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { requireSignInMiddleware } from "../auth/guards"; +import { getSessionCompanyId } from "../auth/company"; +import { trackAppOpen } from "../analytics/tracking"; /** Marks the `/init` a sign-in returns to; see {@link needsSignIn}. */ const SIGN_IN_ATTEMPTED = "signInAttempted"; /** - * Whether to send the caller through Onshape's sign-in before opening the app. - * - * The gate is `isAuthenticated`: a session Onshape takes, scoped to the company - * whose document the panel was opened in. What a sign-in cannot always do is - * produce one. Onshape's authorize endpoint takes a `company_id` to scope a - * token to an enterprise and documents no value standing for a personal - * account, so for a caller holding an enterprise session who opens a plain - * cad.onshape.com document there is nothing to ask for on their behalf — and - * the reported redirect loop is what came of asking anyway. - * - * So a caller with no session at all is always worth signing in, and one whose - * session is merely scoped elsewhere only when there is a company to name. - * `SIGN_IN_ATTEMPTED` backstops both: whatever came back, the app opens on the - * second pass rather than bouncing a third time. + * No session, or one for another company than the document's. Asked once, + * marked by `SIGN_IN_ATTEMPTED`: Onshape may hand back a token for the company + * the caller is signed in to whatever is asked, which would otherwise loop. */ async function needsSignIn(c: AppContext): Promise { - if (await c.var.isAuthenticated()) return false; - if (c.req.query(SIGN_IN_ATTEMPTED) !== undefined) return false; return ( - !(await isSignedIn(c)) || getSessionCompanyId(c) !== PERSONAL_COMPANY_ID + c.req.query(SIGN_IN_ATTEMPTED) === undefined && + !(await c.var.isAuthenticated()) ); } @@ -58,86 +33,32 @@ function getSignInUrl(c: AppContext): string { return `/auth/sign-in?${query.toString()}`; } -interface AppEntry { - url: string; - /** Absent when nobody is signed in, so there is no one to look up. */ - userId?: string; - /** Where the caller lands: the default until they have chosen. */ - tabId: AppTab; -} - -/** Where the caller left off, as their row records it. */ -function getUserEntry(db: Db, userId: string) { - // The join is the check on the stored group: one deleted, or left behind by - // a tab switch, comes back null and lands the caller in the tab itself. - return db - .select({ - tabId: users.tabId, - theme: users.theme, - groupId: groups.id - }) - .from(users) - .leftJoin( - groups, - and(eq(groups.id, users.groupId), eq(groups.libraryId, users.tabId)) - ) - .where(eq(users.id, userId)) - .get(); -} - -/** - * The url the caller resumes at, seeded with what they last used. Returns who - * they are too, so `/init` records the open without a second lookup. - */ -async function getAppEntry(c: AppContext): Promise { - const userId = (await isSignedIn(c)) ? await c.var.getUserId() : undefined; - const user = userId - ? await getUserEntry(getDb(c.env.DB), userId) - : undefined; - - const search = new URL(c.req.url).searchParams; - // Ours, and spent: the app is being opened, however that turned out. - search.delete(SIGN_IN_ATTEMPTED); - const systemTheme = search.get("theme"); - if (systemTheme !== null) { - search.set("systemTheme", systemTheme); - } - search.set("theme", user?.theme ?? DEFAULT_SETTINGS.theme); - - // Checked rather than trusted, as the stored group above is: the frontend - // 404s an id it does not know, and this url is the only thing between a - // stale row and the caller's panel. - const chosenTab = user?.tabId - ? toAppTab(user.tabId, DEFAULT_LIBRARY) - : undefined; - // A caller with no tab lands in the default library, where the welcome - // asks for one; the seed is how the app tells the two apart. - if (chosenTab) { - search.set("tabId", chosenTab); - } - const tabId = chosenTab ?? DEFAULT_LIBRARY; - const path = getTabPath(tabId); - // Only a library has groups to resume in; the join above already returns - // none for a tab that is not one. - const groupPath = user?.groupId ? `${path}/groups/${user.groupId}` : path; - return { url: `${groupPath}?${search.toString()}`, userId, tabId }; -} - export const entryRoutes = getApp(); -/** GET /init */ +/** GET /init: the app resumes the caller's last tab itself, from the browser's storage. */ entryRoutes.get("/init", cacheMiddleware(), async (c) => { + // A version can't be changed, so there is nothing to insert into. + const instanceType = c.req.query("instanceType"); + if (instanceType === "v" || instanceType === "m") { + return c.redirect("/version-error"); + } if (await needsSignIn(c)) { return c.redirect(getSignInUrl(c)); } - const { url, userId, tabId } = await getAppEntry(c); - // Reaching here is exactly "the panel was opened", and it is the only entry - // Onshape uses. Best-effort, so the redirect never waits on it. Only a - // library open is logged, the log being library-scoped. - if (userId && isLibraryTab(tabId)) { - await trackInBackground(c, () => - trackAppOpen(c, { libraryId: tabId, userId }) - ); - } - return c.redirect(url); + const search = new URL(c.req.url).searchParams; + // Ours, and spent: the app is being opened, however that turned out. + search.delete(SIGN_IN_ATTEMPTED); + return c.redirect(`/?${search.toString()}`); }); + +export const appOpenRoutes = getApp(); + +/** POST /api/app-open/library/:libraryId: sent by the app on a launch from Onshape. */ +appOpenRoutes.post( + "/app-open" + libraryRoute(), + requireSignInMiddleware, + async (c) => { + await trackAppOpen(c, getLibraryParam(c)); + return c.json({}); + } +); diff --git a/src/backend/features/entry/routes.worker.test.ts b/src/backend/features/entry/routes.worker.test.ts new file mode 100644 index 000000000..8a4b7a559 --- /dev/null +++ b/src/backend/features/entry/routes.worker.test.ts @@ -0,0 +1,164 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { events } from "../analytics/schema"; +import { EVENT_SCHEMA_VERSION, EventType } from "../analytics/usage"; +import { LibraryId } from "../library/library-id"; +import { + TEST_USER_ID, + createTestApp, + jsonRequest, + resetDb, + seedUser +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; + +const db = getDb(env.DB); + +describe("GET /init", () => { + beforeEach(async () => { + await resetDb(db); + }); + + // The app resumes the caller's tab from the browser's own storage. + it("hands Onshape's launch to the app", async () => { + const res = await createTestApp().request( + "/init?documentId=doc&workspaceId=ws", + jsonRequest("GET"), + env + ); + + expect(res.status).toBe(302); + const location = new URL(res.headers.get("Location")!, "http://x"); + expect(location.pathname).toBe("/"); + expect(location.searchParams.get("documentId")).toBe("doc"); + expect(location.searchParams.get("workspaceId")).toBe("ws"); + }); + + /** Where the gate sends a caller Onshape will not take. */ + async function signInRedirect(path: string): Promise { + const res = await createTestApp({ + isAuthenticated: false, + signedIn: false + }).request(path, jsonRequest("GET"), env); + + expect(res.status).toBe(302); + return new URL(res.headers.get("Location")!, "http://x"); + } + + it("signs in a caller Onshape will not take, scoped to their company", async () => { + const location = await signInRedirect( + "/init?sessionCompanyId=company-1" + ); + + expect(location.pathname).toBe("/auth/sign-in"); + expect(location.searchParams.get("sessionCompanyId")).toBe("company-1"); + const redirectUrl = new URL( + location.searchParams.get("redirectUrl")!, + "http://x" + ); + expect(redirectUrl.pathname).toBe("/init"); + expect(redirectUrl.searchParams.get("sessionCompanyId")).toBe( + "company-1" + ); + }); + + // There's no personal company to ask Onshape for, so this would loop. + it("opens the app rather than signing a caller in twice", async () => { + const location = await signInRedirect("/init"); + const res = await createTestApp({ + isAuthenticated: false, + signedIn: false + }).request( + location.searchParams.get("redirectUrl")!, + jsonRequest("GET"), + env + ); + + expect(res.status).toBe(302); + const entry = new URL(res.headers.get("Location")!, "http://x"); + expect(entry.pathname).toBe("/"); + // Spent, so it never reaches the app or a later sign-in. + expect(entry.searchParams.has("signInAttempted")).toBe(false); + }); + + it("signs in a caller whose session is scoped to another company", async () => { + const res = await createTestApp({ isAuthenticated: false }).request( + "/init?sessionCompanyId=company-1", + jsonRequest("GET"), + env + ); + + const location = new URL(res.headers.get("Location")!, "http://x"); + expect(location.pathname).toBe("/auth/sign-in"); + }); + + // An enterprise session opening a personal document. + it("signs in a caller whose session is for a company the document isn't", async () => { + const res = await createTestApp({ isAuthenticated: false }).request( + "/init", + jsonRequest("GET"), + env + ); + + const location = new URL(res.headers.get("Location")!, "http://x"); + expect(location.pathname).toBe("/auth/sign-in"); + }); + + it.each(["v", "m"])( + "sends a launch in a %s instance to the version error", + async (instanceType) => { + const res = await createTestApp().request( + `/init?documentId=doc&instanceType=${instanceType}`, + jsonRequest("GET"), + env + ); + expect(res.headers.get("Location")).toBe("/version-error"); + } + ); + + it("never caches the gate's verdict", async () => { + const res = await createTestApp().request( + "/init", + jsonRequest("GET"), + env + ); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); +}); + +describe("POST /app-open", () => { + beforeEach(async () => { + await resetDb(db); + await seedUser(db); + }); + + it("records a versioned open in the library", async () => { + const res = await createTestApp().request( + `/api/app-open/library/${LibraryId.FTC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + + expect(res.status).toBe(200); + expect(await db.select().from(events).get()).toMatchObject({ + type: EventType.APP_OPEN, + libraryId: LibraryId.FTC_DESIGN_LIB, + userId: TEST_USER_ID, + schemaVersion: EVENT_SCHEMA_VERSION + }); + }); + + it("records nothing for a caller who isn't signed in", async () => { + const res = await createTestApp({ + isAuthenticated: false, + signedIn: false + }).request( + `/api/app-open/library/${LibraryId.FTC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + + expect(res.status).not.toBe(200); + expect(await db.select().from(events).get()).toBeUndefined(); + }); +}); diff --git a/src/backend/features/favorites/contract.ts b/src/backend/features/favorites/contract.ts index 4343f3a95..53472c084 100644 --- a/src/backend/features/favorites/contract.ts +++ b/src/backend/features/favorites/contract.ts @@ -5,10 +5,7 @@ import { } from "../configurations/contract"; import { LibraryId } from "../library/library-id"; -/** - * The most favorites one user may keep in one library. Far past what anyone - * curates by hand, and low enough that reordering stays a single batch. - */ +/** Low enough that reordering stays one batch. */ export const MAX_FAVORITES = 250; export interface Favorite { @@ -17,14 +14,9 @@ export interface Favorite { libraryId: LibraryId; /** The selection it opens with; absent for the element's own defaults. */ defaultSelection?: Selection; - /** That selection's key, which names its thumbnail. Derived per response: - * a reload moves the defaults, and a card has no parameters of its own. */ + /** Derived per response, since a reload can move the defaults. */ configurationKey?: ConfigurationKey; - /** - * What that selection is called and numbered. Derived here for the same - * reason as the key, and carried so a favorite row never has to read a part - * number off anything but the configuration it was saved with. - */ + /** Derived like the key, so the part number always matches the saved configuration. */ record?: SearchRecord; } diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index daeddc72b..3ff55ac79 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -12,15 +12,19 @@ import { import { type Db, getDb } from "../../db/client"; import { chunkForInArray } from "../../db/chunk"; import { users, favorites, configurations, insertables } from "../../db/schema"; -import { toKey, toSelection } from "../configurations/selection"; +import { + findRecord, + toKey, + toSelection, + toStoredSelection +} from "../configurations/selection"; import { MAX_FAVORITES, type Favorite, type FavoritesData } from "./contract"; import { - DEFAULT_CONFIGURATION_KEY, type ConfigurationParameter, + type PartialSelection, type SearchRecord } from "../configurations/contract"; -import { findRecordForConfiguration } from "../configurations/utils"; -import { toSearchRecords } from "../search/records"; +import { searchRecordsOf } from "../search/records"; import type { LibraryId } from "../library/library-id"; import { z } from "zod"; import { validate } from "../../lib/validate"; @@ -63,8 +67,7 @@ async function getFavorites( .orderBy(asc(favorites.sortOrder)) .all(); - // Keyed here rather than stored: the parameters a selection is canonical - // against move with the library, and only the row is ours to keep. + // Computed, not stored: a reload can move the defaults a key is measured against. const configurationsById = await getConfigurations( db, rows.map((row) => row.insertableId) @@ -75,27 +78,26 @@ async function getFavorites( for (const row of rows) { const { parameters = [], records = [] } = configurationsById.get(row.insertableId) ?? {}; - const stored = row.defaultSelection ?? undefined; - // Made whole on the way out as well as in: a row written before a - // parameter existed still has to answer as a selection. - const defaultSelection = stored - ? toSelection(stored, parameters) + // A row written before a parameter existed still has to be whole. + const defaultSelection = row.defaultSelection + ? toSelection( + toStoredSelection(row.defaultSelection, parameters), + parameters + ) : undefined; - // A favorite storing no selection opens on the element's own defaults, - // which is what the empty key names — so it resolves the right record - // while the field itself stays absent, as the contract has it. - const configurationKey = defaultSelection - ? toKey(defaultSelection, parameters) - : DEFAULT_CONFIGURATION_KEY; const fav: Favorite = { id: row.id, insertableId: row.insertableId, libraryId, defaultSelection, - configurationKey: defaultSelection ? configurationKey : undefined, - // The record this favorite's own selection produces, so a row can - // never show a part number belonging to another configuration. - record: findRecordForConfiguration(configurationKey, records) + configurationKey: defaultSelection + ? toKey(defaultSelection, parameters) + : undefined, + // So a row never shows another configuration's part number. + record: findRecord( + defaultSelection ?? toSelection({}, parameters), + records + ) }; favoritesOut[row.id] = fav; favoriteOrder.push(row.id); @@ -103,6 +105,13 @@ async function getFavorites( return { favorites: favoritesOut, favoriteOrder }; } +function toFavoriteSelection( + selection: PartialSelection, + parameters: ConfigurationParameter[] +): PartialSelection { + return toStoredSelection(toSelection(selection, parameters), parameters); +} + /** One insertable's parameters, for making a selection whole. */ async function getParametersFor( db: Db, @@ -114,33 +123,30 @@ async function getParametersFor( ); } -/** The parameters of each insertable named, for keying against. */ /** What a favorite's insertable contributes to the response. */ interface InsertableConfiguration { - /** What a stored selection is made whole and canonical against. */ + /** What a stored selection is made whole against. */ parameters: ConfigurationParameter[]; - /** What each configuration is called, for the one this favorite names. */ + /** Includes the element's own. */ records: SearchRecord[]; } /** - * Joined from the insertable rather than the configurations row alone: a - * record's vendor url is derived from the insertable's own vendors. An - * insertable with nothing to configure has no configurations row, which the - * left join answers as empty. + * Joined with the insertable, whose vendors give a record its url. A left join, + * since an unconfigurable insertable has no configurations row. */ async function getConfigurations( db: Db, insertableIds: string[] ): Promise> { - // Chunked: a caller can have more favorites than one statement can bind ids - // for. + // Chunked: there can be more favorites than one statement binds. const reads = await Promise.all( chunkForInArray(insertableIds).map((ids) => db .select({ insertableId: insertables.id, vendors: insertables.vendors, + partMetadata: insertables.partMetadata, parameters: configurations.parameters, records: configurations.records }) @@ -154,13 +160,15 @@ async function getConfigurations( ) ); return new Map( - reads.flat().map((row) => [ - row.insertableId, - { - parameters: row.parameters ?? [], - records: toSearchRecords(row.records ?? [], row.vendors) - } - ]) + reads.flat().map((row) => { + return [ + row.insertableId, + { + parameters: row.parameters ?? [], + records: searchRecordsOf(row) + } + ]; + }) ); } @@ -191,19 +199,14 @@ favoriteRoutes.post( const db = getDb(c.env.DB); - // The favorite's own key requires a user row, which a caller who has - // never changed a setting does not have yet. Named rather than left to - // the dead column's default, which points at a library this caller may - // have no row for. + // The favorite references a user row, which a new caller doesn't have yet. + // The library is named since the dead column's default may not exist. await db .insert(users) .values({ id: userId, libraryId }) .onConflictDoNothing(); - // Counted to see whether there is room for one more, and the highest - // order taken so the new one lands after it. Not the count: deleting - // from the middle leaves a gap, and counting would then reuse an order - // a live favorite still holds. + // The highest order, not the count: deletes leave gaps. const existing = await db .select({ value: count(), @@ -220,8 +223,6 @@ favoriteRoutes.post( const sortOrder = (existing?.highestOrder ?? -1) + 1; if ((existing?.value ?? 0) >= MAX_FAVORITES) { - // Handled rather than internal: the caller can act on this, and - // removing one is the whole of what it takes. throw handledError( `You can keep up to ${MAX_FAVORITES} favorites in a library. Remove one to add another.`, HttpStatus.CONFLICT @@ -236,7 +237,7 @@ favoriteRoutes.post( libraryId, insertableId, defaultSelection: selection - ? toSelection( + ? toFavoriteSelection( selection, await getParametersFor(db, insertableId) ) @@ -274,8 +275,7 @@ favoriteRoutes.post( const userId = await c.var.getUserId(); const db = getDb(c.env.DB); - // Scoped to the owner rather than checked first: a favorite that is not - // theirs matches nothing, which costs no extra read. + // Scoped to the owner, so someone else's favorite matches nothing. const writes = favoriteOrder.map((id, i) => db .update(favorites) @@ -316,7 +316,7 @@ favoriteRoutes.post( await db .update(favorites) .set({ - defaultSelection: toSelection( + defaultSelection: toFavoriteSelection( selection, await getParametersFor(db, row.insertableId) ) diff --git a/src/backend/features/favorites/routes.test.ts b/src/backend/features/favorites/routes.worker.test.ts similarity index 86% rename from src/backend/features/favorites/routes.test.ts rename to src/backend/features/favorites/routes.worker.test.ts index e2e896688..d3c61a0b7 100644 --- a/src/backend/features/favorites/routes.test.ts +++ b/src/backend/features/favorites/routes.worker.test.ts @@ -8,6 +8,14 @@ import { beforeEach, describe, expect, it } from "vitest"; import { configurations, favorites, insertables } from "../../db/schema"; import { ElementType } from "../../lib/onshape/element-type"; import { MAX_FAVORITES } from "./contract"; +import { + configurationRecord, + quantityParam, + derivationParam +} from "../../../__test_utils__/configuration-fixtures"; + +const partMetadata = (partNumber: string) => + configurationRecord({ partNumber }); import { ApiErrorKind } from "../../lib/api-error"; import { TEST_ASSEMBLY_ID, @@ -43,23 +51,19 @@ interface FavoritesBody { favoriteOrder: string[]; } -/** - * Rows straight in, since the route under test is the one being filled up. One - * insertable apiece: a favorite is unique per user, library and insertable. - */ +/** Inserted directly, since the route under test is what's being filled. */ async function fillFavorites(howMany: number) { const db = getDb(env.DB); await seedGroup(db); - await seedUser(db, "test-user", TEST_LIBRARY_ID); + await seedUser(db, "test-user"); const rows = Array.from({ length: howMany }, (_, i) => ({ id: `filler-${i}`, insertableId: `filler-insertable-${i}`, sortOrder: i })); - // D1 binds at most 100 parameters per query, and an insertable row spends - // sixteen of them, so these go in small chunks rather than one statement. - for (const chunk of inChunks(rows, 6)) { + // D1 binds at most 100 parameters, and an insertable row takes seventeen. + for (const chunk of inChunks(rows, 5)) { await db.insert(insertables).values( chunk.map((row) => ({ id: row.insertableId, @@ -130,8 +134,7 @@ describe("favorites routes", () => { expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); - // Derived per response rather than stored, so it cannot go stale when a - // reload changes what the parameters default to. + // Derived per response, so a reload that changes defaults can't stale it. it("derives each favorite's key from the selection it stores", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -151,8 +154,6 @@ describe("favorites routes", () => { ); }); - // The selection is what the favorite opens with, so it keeps a value - // the key drops for matching the parameter's default. it("answers with a whole selection, not only its overrides", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -174,26 +175,24 @@ describe("favorites routes", () => { expect(favorite.configurationKey).toBe(""); }); - // The row's thumbnail is this configuration's, so its part number has - // to be too — resolving it from anything else shows two parts at once. + // The thumbnail is this configuration's, so the part number must be too. it("resolves the record its own selection produces", async () => { await seedPartStudio(db); await seedConfiguration(db); await db .update(configurations) .set({ - // The favorite's own record listed second, so picking the - // first would answer with the default instead. + // Listed second, so taking the first would give the default. records: [ { - configurationKey: "", + values: { boolean: "true" }, partNumber: "WCP-2222", name: "Default", hasMultipleParts: false, isOpenComposite: false }, { - configurationKey: "boolean=false", + values: { boolean: "false" }, partNumber: "WCP-1111", name: "Plain", hasMultipleParts: false, @@ -219,26 +218,23 @@ describe("favorites routes", () => { expect(favorite.record?.name).toBe("Plain"); }); - // A favorite saved with no selection of its own opens on the element's - // defaults, so that is the record it has to name. - it("resolves the default record for a favorite with no selection", async () => { + // The defaults' part data is on the insertable, not in the records. + it("resolves the element's own record for a favorite with no selection", async () => { await seedPartStudio(db); await seedConfiguration(db); + await db + .update(insertables) + .set({ partMetadata: partMetadata("WCP-2222") }) + .where(eq(insertables.id, TEST_PART_STUDIO_ID)); await db .update(configurations) .set({ records: [ { - configurationKey: "boolean=false", + values: { boolean: "false" }, partNumber: "WCP-1111", hasMultipleParts: false, isOpenComposite: false - }, - { - configurationKey: "", - partNumber: "WCP-2222", - hasMultipleParts: false, - isOpenComposite: false } ] }) @@ -255,10 +251,12 @@ describe("favorites routes", () => { expect(favorite.record?.partNumber).toBe("WCP-2222"); }); - // An insertable with nothing to configure has no configurations row at - // all; the join has to answer that as no record rather than throwing. - it("has no record for an insertable with no configuration", async () => { + it("resolves the record of an insertable with no configuration", async () => { await seedPartStudio(db); + await db + .update(insertables) + .set({ partMetadata: partMetadata("WCP-3333") }) + .where(eq(insertables.id, TEST_PART_STUDIO_ID)); await seedFavorite(db, TEST_PART_STUDIO_ID); const res = await createTestApp().request( @@ -267,7 +265,9 @@ describe("favorites routes", () => { env ); expect(res.status).toBe(200); - expect(soleFavorite(await res.json()).record).toBeUndefined(); + expect(soleFavorite(await res.json()).record?.partNumber).toBe( + "WCP-3333" + ); }); it("only returns the current user's favorites", async () => { @@ -309,8 +309,6 @@ describe("favorites routes", () => { expect(row?.sortOrder).toBe(1); }); - // Counting instead would reuse an order a live favorite still holds, - // and the two would then sort against each other arbitrarily. it("does not reuse an order after one is deleted from the middle", async () => { await fillFavorites(3); await seedPartStudio(db); @@ -344,7 +342,6 @@ describe("favorites routes", () => { ); expect(res.status).toBe(409); - // Handled, so the client shows this rather than its own wording. expect(await res.json()).toMatchObject({ kind: ApiErrorKind.HANDLED, message: expect.stringContaining(String(MAX_FAVORITES)) @@ -371,8 +368,7 @@ describe("favorites routes", () => { it("stamps createdAt, leaving rows that predate the column null", async () => { await seedPartStudio(db); await seedAssembly(db); - // Seeded without a timestamp, as every row predating the column - // looks. + // As rows predating the column look. const old = await seedFavorite(db, TEST_PART_STUDIO_ID); const app = createTestApp(); @@ -553,8 +549,6 @@ describe("favorites routes", () => { }); }); - // Stored as a selection, so what is written is what the insertable - // declares — not whatever the request happened to name. it("drops a value for a parameter the insertable does not have", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -563,5 +557,35 @@ describe("favorites routes", () => { boolean: "true" }); }); + + // Each insert fills its own, so a kept one would only be stale. + it("leaves a derivation variable out", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + await db + .update(configurations) + .set({ + parameters: [quantityParam("length"), derivationParam("dv")] + }) + .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); + + expect(await post({ length: "2 in", dv: "abc" })).toEqual({ + length: "2 in" + }); + }); + + // What the favorite opens with is what was typed, not what it evaluates to. + it("keeps a quantity as the expression it was entered as", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + await db + .update(configurations) + .set({ parameters: [quantityParam("length")] }) + .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); + + expect(await post({ length: "(2 + 3) in" })).toEqual({ + length: "(2 + 3) in" + }); + }); }); }); diff --git a/src/backend/features/insert-location/contract.ts b/src/backend/features/insert-location/contract.ts index af998592f..1e21fbd63 100644 --- a/src/backend/features/insert-location/contract.ts +++ b/src/backend/features/insert-location/contract.ts @@ -1,15 +1,9 @@ -/** - * The insert location: a marker in the assembly being inserted into, which new - * parts land on instead of the origin. A leaf, so the frontend can name it - * without pulling the Onshape client into the bundle. - */ +/** A marker in an assembly that new parts land on. A leaf, so the frontend can import it. */ import { type ElementPath } from "../../lib/onshape/path"; /** - * The tab the marker is inserted from. A standalone mate connector cannot be - * created in an assembly through the API, so the app inserts a sketch that - * carries one instead — pinned to a version, which is what makes the ids below - * stable. + * The API can't create a standalone mate connector in an assembly, so a sketch + * carrying one is inserted instead, from a pinned version so the ids are stable. */ export const INSERT_LOCATION_SOURCE: ElementPath = { documentId: "6c26fe7a89b71b80707ee3cf", @@ -18,27 +12,13 @@ export const INSERT_LOCATION_SOURCE: ElementPath = { elementId: "8252e798e07255ac1235e7e5" }; -/** - * The sketch inside that tab: what an assembly gets an instance of. Only the - * insert names it — recognizing a marker goes by the tab, so an assembly - * holding one from before the sketch was redrawn still counts. - */ +/** Only inserts use it; recognizing a marker goes by the tab. */ export const INSERT_LOCATION_SKETCH_ID = "FoHmJsKNNEuStrH_0"; -/** - * The mate connector that sketch carries. An id in the source tab rather than - * in any one assembly: two SELECTION messages from different assemblies - * reported it unchanged, with only the occurrence around it differing. - * - * Unused — kept for whatever eventually points the caller at the marker in the - * viewport. The frontend's `messages.ts` says how far that got. - */ +/** Unused, kept for pointing the caller at the marker; see `messages.ts`. */ export const INSERT_LOCATION_MATE_CONNECTOR_ID = "F2t8fekeOt5UXBq_0"; export interface InsertLocationOut { - /** - * The marker's instance id in this assembly — not the sketch's own id, - * which every assembly shares. Absent when the assembly has none. - */ + /** Not the sketch's id, which every assembly shares. */ instanceId?: string; } diff --git a/src/backend/features/insert-location/parse.test.ts b/src/backend/features/insert-location/parse.test.ts index 35ea25bd0..9ea021566 100644 --- a/src/backend/features/insert-location/parse.test.ts +++ b/src/backend/features/insert-location/parse.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { INSERT_LOCATION_SKETCH_ID, INSERT_LOCATION_SOURCE } from "./contract"; -import { findInsertLocationInstance, getInstanceTransform } from "./parse"; +import { findInsertLocation, getInsertLocationTransform } from "./parse"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { type ElementPath } from "../../lib/onshape/path"; import { type OnshapeAssemblyDefinition, type OnshapeAssemblyInstance @@ -27,50 +29,63 @@ function toAssembly( }; } -describe("findInsertLocationInstance", () => { - it("finds the sketch the app inserts, among other instances", () => { +const ASSEMBLY_PATH: ElementPath = { + documentId: "doc", + instanceType: "w", + instanceId: "ws", + elementId: "asm" +}; + +/** A client whose every request is answered with `assembly`. */ +function answering(assembly: OnshapeAssemblyDefinition): MockOnshapeApi { + const api = new MockOnshapeApi(); + vi.spyOn(api, "get").mockResolvedValue(assembly); + return api; +} + +const findIn = (assembly: OnshapeAssemblyDefinition) => + findInsertLocation(answering(assembly), ASSEMBLY_PATH); + +const transformIn = (assembly: OnshapeAssemblyDefinition, instanceId: string) => + getInsertLocationTransform(answering(assembly), ASSEMBLY_PATH, instanceId); + +describe("findInsertLocation", () => { + it("finds the sketch the app inserts, among other instances", async () => { const assembly = toAssembly([ { id: "part", type: "Part" }, MARKER, { id: "sub", type: "Assembly" } ]); - expect(findInsertLocationInstance(assembly)?.id).toBe("marker"); + expect(await findIn(assembly)).toBe("marker"); }); // An assembly can hold a marker inserted before the sketch was revised. - it("matches whatever version the marker was inserted from", () => { + it("matches whatever version the marker was inserted from", async () => { const assembly = toAssembly([ { ...MARKER, documentVersion: "older" } as OnshapeAssemblyInstance ]); - expect(findInsertLocationInstance(assembly)?.id).toBe("marker"); + expect(await findIn(assembly)).toBe("marker"); }); - it("ignores a suppressed marker, which nothing can insert at", () => { + it("ignores a suppressed marker, which nothing can insert at", async () => { expect( - findInsertLocationInstance( - toAssembly([{ ...MARKER, suppressed: true }]) - ) + await findIn(toAssembly([{ ...MARKER, suppressed: true }])) ).toBeUndefined(); }); - it("ignores a sketch inserted from some other tab", () => { + it("ignores a sketch inserted from some other tab", async () => { expect( - findInsertLocationInstance( - toAssembly([{ ...MARKER, elementId: "elsewhere" }]) - ) + await findIn(toAssembly([{ ...MARKER, elementId: "elsewhere" }])) ).toBeUndefined(); }); - // The marker inserted from a version of the tab we no longer name, so the - // sketch's own id is no longer what it was when the constant was written. - it("matches a sketch id the constant does not name", () => { + // A marker from an older version of the tab has a different sketch id. + it("matches a sketch id the constant does not name", async () => { const assembly = toAssembly([{ ...MARKER, featureId: "redrawn" }]); - expect(findInsertLocationInstance(assembly)?.id).toBe("marker"); + expect(await findIn(assembly)).toBe("marker"); }); - // Onshape naming the tab only on the partStudioFeatures entry, which is the - // shape the instance list alone cannot be matched against. - it("finds a marker whose instance names only its feature", () => { + it("finds a marker whose instance names only its feature", async () => { const assembly = toAssembly( [{ id: "marker", type: "Feature", featureId: "sketch" }], undefined, @@ -82,10 +97,10 @@ describe("findInsertLocationInstance", () => { } ] ); - expect(findInsertLocationInstance(assembly)?.id).toBe("marker"); + expect(await findIn(assembly)).toBe("marker"); }); - it("ignores a feature inserted from some other tab's sketch", () => { + it("ignores a feature inserted from some other tab's sketch", async () => { const assembly = toAssembly( [{ id: "other", type: "Feature", featureId: "sketch" }], undefined, @@ -97,23 +112,21 @@ describe("findInsertLocationInstance", () => { } ] ); - expect(findInsertLocationInstance(assembly)).toBeUndefined(); + expect(await findIn(assembly)).toBeUndefined(); }); - // An instance naming no feature at all, against a tab whose entry names no - // feature either: nothing lines up, so nothing matches. - it("does not pair an instance and an entry by what both leave out", () => { + it("does not pair an instance and an entry by what both leave out", async () => { const assembly = toAssembly([{ id: "part", type: "Part" }], undefined, [ { documentId: INSERT_LOCATION_SOURCE.documentId, elementId: INSERT_LOCATION_SOURCE.elementId } ]); - expect(findInsertLocationInstance(assembly)).toBeUndefined(); + expect(await findIn(assembly)).toBeUndefined(); }); }); -describe("getInstanceTransform", () => { +describe("getInsertLocationTransform", () => { // prettier-ignore const transform = [ 1, 0, 0, -0.139, @@ -122,27 +135,24 @@ describe("getInstanceTransform", () => { 0, 0, 0, 1 ]; - it("reads where a top-level instance has been dragged to", () => { + it("reads where a top-level instance has been dragged to", async () => { const assembly = toAssembly( [MARKER], [{ path: ["marker"], transform }] ); - expect(getInstanceTransform(assembly, "marker")).toEqual(transform); + expect(await transformIn(assembly, "marker")).toEqual(transform); }); - // A deeper path is the same instance inside a subassembly, which is a - // different thing in a different place. - it("ignores an occurrence nested under another instance", () => { + // A nested one is inside a subassembly. + it("ignores an occurrence nested under another instance", async () => { const assembly = toAssembly( [MARKER], [{ path: ["sub", "marker"], transform }] ); - expect(getInstanceTransform(assembly, "marker")).toBeUndefined(); + expect(await transformIn(assembly, "marker")).toBeUndefined(); }); - it("has no transform for an instance that is no longer there", () => { - expect( - getInstanceTransform(toAssembly([], []), "marker") - ).toBeUndefined(); + it("has no transform for an instance that is no longer there", async () => { + expect(await transformIn(toAssembly([], []), "marker")).toBeUndefined(); }); }); diff --git a/src/backend/features/insert-location/parse.ts b/src/backend/features/insert-location/parse.ts index b2151cef5..40e42eb0a 100644 --- a/src/backend/features/insert-location/parse.ts +++ b/src/backend/features/insert-location/parse.ts @@ -8,11 +8,8 @@ import { type OnshapeAssemblyInstance } from "../../lib/onshape/types"; -/** - * The assembly as the insert location needs it. A sketch is not a solid, so - * without `includeNonSolids` the marker is not in the response at all. - */ -export function getAssemblyWithMarkers( +/** Sketches aren't solids, so the marker needs `includeNonSolids`. */ +function getAssemblyWithMarkers( onshapeApi: OnshapeApi, assemblyPath: ElementPath ): Promise { @@ -31,19 +28,12 @@ function isFromSourceTab(reference: { } /** - * The marker's instance, matched on the tab it came from rather than on its - * name, which anybody can rename. The version is left out of the match: an - * assembly can hold a marker inserted from an older one. So is the sketch's own - * feature id — the tab holds nothing but that sketch, so anything an assembly - * holds from it is a marker, and an id that has to be right is an id that can - * go stale. - * - * Which fields Onshape fills in on a sketch instance is not something it - * documents, and a marker that inserted fine was not found again, so both - * places the tab can be named are accepted: the instance itself, and the - * `partStudioFeatures` entry its `featureId` points at. + * Matched on the source tab, not the name (renameable), version (an older + * marker still counts) or feature id (could go stale). Onshape doesn't document + * which fields a sketch instance fills in, so both the instance and its + * `partStudioFeatures` entry are checked. */ -export function findInsertLocationInstance( +function findInsertLocationInstance( assembly: OnshapeAssemblyDefinition ): OnshapeAssemblyInstance | undefined { const markerFeatureIds = new Set( @@ -62,12 +52,8 @@ export function findInsertLocationInstance( ); } -/** - * Where a top-level instance sits, as a transform an insert can be placed by. - * Undefined when the instance is gone, which is what a marker deleted since the - * app opened looks like — the insert then lands at the origin. - */ -export function getInstanceTransform( +/** Undefined when the marker was deleted, so the insert lands at the origin. */ +function getInstanceTransform( assembly: OnshapeAssemblyDefinition, instanceId: string ): number[] | undefined { @@ -77,7 +63,7 @@ export function getInstanceTransform( )?.transform; } -/** {@link findInsertLocationInstance} against the assembly as it is now. */ +/** The marker's instance id in the assembly as it is now. */ export async function findInsertLocation( onshapeApi: OnshapeApi, assemblyPath: ElementPath @@ -86,10 +72,7 @@ export async function findInsertLocation( return findInsertLocationInstance(assembly)?.id; } -/** - * Where the insert location is now, which only Onshape knows: the caller has - * the marker's instance id, and it moves whenever somebody drags it. - */ +/** Asked of Onshape, since the marker moves whenever someone drags it. */ export async function getInsertLocationTransform( onshapeApi: OnshapeApi, assemblyPath: ElementPath, diff --git a/src/backend/features/insert-location/placement.ts b/src/backend/features/insert-location/placement.ts index 7437b33aa..8cc78e974 100644 --- a/src/backend/features/insert-location/placement.ts +++ b/src/backend/features/insert-location/placement.ts @@ -1,10 +1,7 @@ /** Where a new insert location marker goes. */ import { type OnshapeBoundingBox } from "../../lib/onshape/types"; -/** - * How far past the face the marker sits, in metres. Enough to be grabbable - * rather than flush against the geometry it is clearing. - */ +/** In metres; enough to grab the marker. */ const CLEARANCE = 0.01; export type Point = [number, number, number]; @@ -12,12 +9,9 @@ export type Point = [number, number, number]; const ORIGIN: Point = [0, 0, 0]; /** - * The closest point to the origin that is clear of the assembly's geometry: the - * origin itself when nothing is over it, and otherwise just past whichever of - * the six faces the origin is nearest. A marker buried inside the robot cannot - * be grabbed, and one flung to a corner cannot be found. - * - * No box — an empty assembly, or a call that failed — leaves it at the origin. + * The origin if nothing covers it, else just past the nearest face: buried + * markers can't be grabbed and far-flung ones can't be found. No box leaves it + * at the origin. */ export function toMarkerPoint(box: OnshapeBoundingBox | undefined): Point { if (!box) { @@ -35,8 +29,7 @@ export function toMarkerPoint(box: OnshapeBoundingBox | undefined): Point { return ORIGIN; } - // Inside: leave by the nearest face. `low` is at or below zero here and - // `high` at or above it, so each distance is the face's own magnitude. + // `low` <= 0 <= `high` here, so each distance is the face's magnitude. const exits = axes.flatMap(([low, high], axis) => [ { axis, to: low - CLEARANCE, distance: -low }, { axis, to: high + CLEARANCE, distance: high } diff --git a/src/backend/features/insert-location/routes.ts b/src/backend/features/insert-location/routes.ts index c0a8a587f..3764ab991 100644 --- a/src/backend/features/insert-location/routes.ts +++ b/src/backend/features/insert-location/routes.ts @@ -49,8 +49,7 @@ insertLocationRoutes.post( const onshapeApi = await c.var.getOnshapeApi(); const { targetPath } = c.req.valid("json"); - // A second marker would leave the lookup picking between them, so an - // assembly that already has one keeps it. + // A second marker would make the lookup ambiguous. const existing = await findInsertLocation(onshapeApi, targetPath); if (existing) { return c.json({ @@ -58,8 +57,7 @@ insertLocationRoutes.post( } satisfies InsertLocationOut); } - // Clear of the geometry, so the marker can be grabbed and dragged. - // A box we cannot read is not a reason to refuse to add one. + // Clear of the geometry, so it can be grabbed. An unreadable box isn't a reason to refuse. const box = await getAssemblyBoundingBox(onshapeApi, targetPath).catch( () => undefined ); @@ -72,9 +70,7 @@ insertLocationRoutes.post( toTranslation(toMarkerPoint(box)) ); - // The insert answers with the occurrence it made, whose path is the new - // instance's own id. Looked up again if it did not, rather than leaving - // the caller thinking the marker is still missing. + // Looked up again if the insert didn't report the occurrence. const instanceId = inserted.insertInstanceResponses?.[0]?.occurrences?.[0]?.path[0] ?? (await findInsertLocation(onshapeApi, targetPath)); diff --git a/src/backend/features/insert-location/routes.test.ts b/src/backend/features/insert-location/routes.worker.test.ts similarity index 100% rename from src/backend/features/insert-location/routes.test.ts rename to src/backend/features/insert-location/routes.worker.test.ts diff --git a/src/backend/features/library/contract.ts b/src/backend/features/library/contract.ts index dda0c6692..d6761f5c4 100644 --- a/src/backend/features/library/contract.ts +++ b/src/backend/features/library/contract.ts @@ -41,12 +41,9 @@ export interface GroupOut { export type Insertables = Record; export type Groups = Record; -/** - * What an insert answers with: the fasten mate's feature when one was built, and - * null whenever none was — the part studio path never builds one. - */ +/** The feature to open: the derive in a part studio, the fasten mate in an assembly. */ export interface InsertOut { - featureId: string | null; + featureId?: string; } export interface LibraryOut { diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index b7af13222..1ace176e5 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -10,14 +10,10 @@ import { } from "../../db/schema"; import { LibraryId } from "./library-id"; import { InsertableOut, LibraryOut, Insertables, Groups } from "./contract"; -import { ConfigurationRecord } from "../configurations/contract"; -import { toRecords } from "../configurations/utils"; +import { type SearchRecord } from "../configurations/contract"; import { buildSearchDb } from "../search/build"; +import { searchRecordsOf } from "../search/records"; -/** - * Assembles the full `LibraryOut` (groups + insertables, in sort order) for a - * library from D1. - */ export async function getLibraryOut( db: Db, libraryId: LibraryId @@ -42,8 +38,7 @@ export async function getLibraryOut( .where(eq(insertables.libraryId, libraryId)) .orderBy(asc(insertables.sortOrder)) .all(), - // Ids only: a configurations row exists exactly when there are - // parameters, and its payload is fetched when one is opened. + // Ids only; the payload is fetched when one is opened. db .select({ insertableId: configurations.insertableId }) .from(configurations) @@ -68,8 +63,7 @@ export async function getLibraryOut( groupInsertables.sort((a, b) => a.name.localeCompare(b.name)); } const insertableOrder = groupInsertables.map((ins) => ins.id); - // A shell group has no version to link to, so its path stops at the - // document rather than pointing at a `/v/placeholder` that 404s. + // A shell group has no version, so link to the document instead. const hasVersion = group.versionId !== PLACEHOLDER_VERSION_ID; groupsOut[group.id] = { id: group.id, @@ -122,10 +116,7 @@ export async function getLibraryOut( }; } -/** - * Renumbers a library's groups to open a slot and returns its sort order. The - * caller writes the row, since it also decides create vs. update. - */ +/** Returns the new slot; the caller writes the row. */ export async function placeNewGroup( db: Db, libraryId: LibraryId, @@ -144,8 +135,6 @@ export async function placeNewGroup( // An unknown or unspecified selection puts the new group last. const newIndex = selectedIndex === -1 ? siblings.length : selectedIndex + 1; - // Renumber every sibling to close any gaps: those at or past the new slot - // shift up by one to make room for it. await Promise.all( siblings.map((sibling, index) => db @@ -158,10 +147,7 @@ export async function placeNewGroup( return newIndex; } -/** - * The row everything pointing at a library needs first. Called wherever a library - * id is written: a library gets its row on the first group added to it. - */ +/** Called wherever a library id is written. */ export async function ensureLibrary( db: Db, libraryId: LibraryId @@ -182,9 +168,9 @@ export async function bumpLibraryVersion( }); } -/** The R2 object key holding a library's serialized MiniSearch index. */ +/** Versioned by shape: an older index is ignored and the route rebuilds it. */ export function searchIndexKey(libraryId: LibraryId): string { - return `search-index/${libraryId}.json`; + return `search-index/v2/${libraryId}.json`; } /** Rebuilds a library's search index into R2; bump `cacheVersion` alongside. */ @@ -193,31 +179,28 @@ export async function rebuildSearchDb( db: Db, libraryId: LibraryId ): Promise { - const [libraryData, recordsMap] = await Promise.all([ + const [libraryData, indexed] = await Promise.all([ getLibraryOut(db, libraryId), - getRecordsMap(db, libraryId) + getSearchRecords(db, libraryId) ]); - const searchDb = JSON.stringify(buildSearchDb(libraryData, recordsMap)); - // Uncompressed: encoding here would leave the runtime compressing an - // already-compressed body. + const searchDb = JSON.stringify(buildSearchDb(libraryData, indexed)); + // Uncompressed, since the runtime compresses responses itself. await bucket.put(searchIndexKey(libraryId), searchDb, { httpMetadata: { contentType: "application/json" } }); return searchDb; } -/** - * The records `buildSearchDb` dedupes: an element's own part data plus one per - * indexed configuration. Left joined — an unconfigurable element has no row. - */ -async function getRecordsMap( +async function getSearchRecords( db: Db, libraryId: LibraryId -): Promise> { +): Promise> { const rows = await db .select({ id: insertables.id, partMetadata: insertables.partMetadata, + vendors: insertables.vendors, + parameters: configurations.parameters, records: configurations.records }) .from(insertables) @@ -228,12 +211,7 @@ async function getRecordsMap( .where(eq(insertables.libraryId, libraryId)) .all(); - const recordsMap: Record = {}; - for (const row of rows) { - const records = toRecords(row.partMetadata, row.records ?? []); - if (records.length > 0) { - recordsMap[row.id] = records; - } - } - return recordsMap; + return Object.fromEntries( + rows.map((row) => [row.id, searchRecordsOf(row)]) + ); } diff --git a/src/backend/features/library/db.test.ts b/src/backend/features/library/db.worker.test.ts similarity index 89% rename from src/backend/features/library/db.test.ts rename to src/backend/features/library/db.worker.test.ts index 8a1722d06..5312eabc9 100644 --- a/src/backend/features/library/db.test.ts +++ b/src/backend/features/library/db.worker.test.ts @@ -15,10 +15,7 @@ import { getLibraryOut, placeNewGroup, rebuildSearchDb } from "./db"; const db = getDb(env.DB); -/** - * Inserts a minimal group row at the given sort order — mirrors what the load-group - * workflow writes once `placeNewGroup` has told it where the new group belongs. - */ +/** Mirrors what the load writes once `placeNewGroup` has made room. */ async function insertGroupAt(id: string, sortOrder: number): Promise { await db.insert(groups).values({ id, @@ -58,8 +55,6 @@ describe("placeNewGroup", () => { const sortOrder = await placeNewGroup(db, TEST_LIBRARY_ID, "g1"); expect(sortOrder).toBe(1); - // g1.5 itself is never inserted by placeNewGroup — only the existing - // siblings get renumbered to make room for it. const rows = await db .select() .from(groups) @@ -75,8 +70,6 @@ describe("rebuildSearchDb", () => { await resetDb(db); }); - // An unconfigurable insertable has no configurations row, so its part - // number reaches search only through the insertable's own part data. it("indexes an unconfigurable insertable's part number", async () => { await seedGroup(db); await seedInsertable(db, { diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 235687118..080353dc6 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -9,24 +9,26 @@ import { getSessionId } from "../../auth/session"; import { getDocument } from "../../../lib/onshape/endpoints/documents"; import { requireEditorMiddleware } from "../../auth/guards"; import { type DocumentPath } from "../../../lib/onshape/path"; -import { groups, insertables, favorites } from "../../../db/schema"; -import { bumpLibraryVersion, ensureLibrary, rebuildSearchDb } from "../db"; +import { + groups, + insertables, + favorites, + WebhookSubject +} from "../../../db/schema"; +import { bumpLibraryVersion, rebuildSearchDb } from "../db"; import { HttpStatus } from "http-status-ts"; import { handledError } from "../../../lib/api-error"; -import { - getJobStatus, - isReloadRunning, - trackJob -} from "../../load/job-tracker"; +import { getJobStatus, requestLoads } from "../../load/jobs"; +import { createShellGroup } from "../../load/workflows"; +import { removeWebhook } from "../../webhooks/registration"; +import { deleteStaleThumbnails } from "../../thumbnails/reconcile"; +import { parseThumbnailUrl } from "../../thumbnails/keys"; +import { runInBackground } from "../../../lib/background"; import { z } from "zod"; import { validate } from "../../../lib/validate"; export const groupRoutes = getApp(); -const reloadGroupsQuery = z.object({ - forceReload: z.stringbool().default(false) -}); - const setVisibilityBody = z.object({ insertableIds: z.array(z.string()), isVisible: z.boolean() @@ -46,37 +48,7 @@ const addGroupBody = z.object({ const deleteGroupQuery = z.object({ groupId: z.string().min(1) }); -/** POST /api/reload-groups/library/:libraryId?forceReload=true */ -groupRoutes.post( - "/reload-groups" + libraryRoute(), - requireEditorMiddleware, - validate("query", reloadGroupsQuery), - async (c) => { - const libraryId = getLibraryParam(c); - const { forceReload } = c.req.valid("query"); - const sessionId = getSessionId(c); - - // Only one reload per library at a time. Racy under a sub-second - // double-trigger (KV has no compare-and-swap), which is fine here. - if (await isReloadRunning(c.env, libraryId)) { - return c.json({ status: "already-running" }); - } - - const db = getDb(c.env.DB); - await ensureLibrary(db, libraryId); - - // The workflow owns the per-group version check — unchanged documents - // are skipped inside it (unless forceReload). - const instance = await c.env.LOAD_LIBRARY_WORKFLOW.create({ - params: { libraryId, sessionId, forceReload } - }); - await trackJob(c.env, libraryId, "reload", instance.id); - - return c.json({ status: "triggered" }); - } -); - -/** GET /api/job-status/library/:libraryId — checked on load, then polled. */ +/** GET /api/job-status/library/:libraryId — checked on load, then pushed. */ groupRoutes.get( "/job-status" + libraryRoute(), requireEditorMiddleware, @@ -97,8 +69,7 @@ groupRoutes.post( const db = getDb(c.env.DB); - // "Hide all elements" names every insertable in a group, which is more - // ids than one statement can bind. + // "Hide all" can name more ids than one statement binds. const writes: BatchItem<"sqlite">[] = []; for (const insertableIds of chunkForInArray(body.insertableIds)) { if (!body.isVisible) { @@ -131,8 +102,7 @@ groupRoutes.post( ); } - // Rebuild before bumping: the new version makes /search-db immutable, - // so a client fetching in between would pin the stale index for a year. + // Before the bump, which makes /search-db immutable for a year. await rebuildSearchDb(c.env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); return c.json({ success: true }); @@ -234,18 +204,21 @@ groupRoutes.post( } const groupId = crypto.randomUUID(); - - const instance = await c.env.ADD_GROUP_WORKFLOW.create({ - params: { - groupId, - documentId: body.newDocumentId, - documentName, + await createShellGroup(c.env, { + groupId, + documentId: body.newDocumentId, + documentName, + libraryId, + selectedGroupId: body.selectedGroupId + }); + await requestLoads(c.env, [ + { libraryId, + groupId, sessionId, - selectedGroupId: body.selectedGroupId + forceReload: false } - }); - await trackJob(c.env, libraryId, "add-group", instance.id); + ]); return c.json({ name: documentName }); } @@ -262,13 +235,57 @@ groupRoutes.delete( const db = getDb(c.env.DB); + // Read first: the cascade takes the rows naming them. + const elements = await db + .select({ elementId: insertables.elementId }) + .from(insertables) + .where(eq(insertables.groupId, groupId)); + // Cascade deletes insertables → favorites, and configurations automatically - await db + const [deleted] = await db .delete(groups) - .where( - and(eq(groups.id, groupId), eq(groups.libraryId, libraryId)) + .where(and(eq(groups.id, groupId), eq(groups.libraryId, libraryId))) + .returning({ + documentId: groups.documentId, + smallThumbnailUrl: groups.smallThumbnailUrl + }); + + if (deleted) { + const thumbnailElement = deleted.smallThumbnailUrl + ? parseThumbnailUrl(deleted.smallThumbnailUrl)?.elementId + : undefined; + await runInBackground(c, "delete the group's thumbnails", () => + deleteStaleThumbnails(c.env.BLOB, db, { + documentId: deleted.documentId, + elementIds: [ + ...elements.map((row) => row.elementId), + ...(thumbnailElement ? [thumbnailElement] : []) + ] + }).then(() => undefined) ); + // The document's webhook goes with the last group loaded from it. + const stillUsed = await db + .select({ id: groups.id }) + .from(groups) + .where(eq(groups.documentId, deleted.documentId)) + .get(); + if (!stillUsed) { + // Logged: a leftover webhook matches no group, so it reloads nothing. + await removeWebhook( + c.env, + await c.var.getOnshapeApi(), + WebhookSubject.DOCUMENT, + deleted.documentId + ).catch((error: unknown) => { + console.error( + `Failed to remove the webhook for ${deleted.documentId}`, + error + ); + }); + } + } + await rebuildSearchDb(c.env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); return c.json({ success: true }); diff --git a/src/backend/features/library/groups/routes.test.ts b/src/backend/features/library/groups/routes.worker.test.ts similarity index 62% rename from src/backend/features/library/groups/routes.test.ts rename to src/backend/features/library/groups/routes.worker.test.ts index 2ee8b779b..a53ce1249 100644 --- a/src/backend/features/library/groups/routes.test.ts +++ b/src/backend/features/library/groups/routes.worker.test.ts @@ -19,21 +19,19 @@ import type { JobStatus } from "../../load/contract"; import { searchIndexKey } from "../db"; import { SEARCH_OPTIONS, type SearchDocument } from "../../search/contract"; import * as DocumentsEndpoint from "../../../lib/onshape/endpoints/documents"; -import * as JobTracker from "../../load/job-tracker"; +import * as Reconcile from "../../thumbnails/reconcile"; +import * as Jobs from "../../load/jobs"; const db = getDb(env.DB); -/** - * `jsonRequest` plus a session cookie, for routes calling `getSessionId` — which - * reads the request directly rather than going through the mocked services. - */ +/** `getSessionId` reads the cookie directly, bypassing the mocks. */ function sessionRequest(method: string, body?: unknown): RequestInit { const init = jsonRequest(method, body); return { ...init, headers: { ...init.headers, - Cookie: "frc-design-app-cookie=test-session" + Cookie: "frc-design-app-session=test-session" } }; } @@ -71,8 +69,7 @@ describe("group admin routes", () => { expect(remaining).toHaveLength(0); }); - // Search reads isVisible out of the index, not the row, so leaving it stale - // drops the insertable from every result until the next full load. + // Search reads isVisible from the index. it.each([false, true])( "POST /set-insertable-visibility rebuilds the search index (isVisible=%s)", async (isVisible) => { @@ -97,8 +94,7 @@ describe("group admin routes", () => { } ); - // "Hide all elements" sends a whole group's ids, and D1 takes at most 100 - // bound parameters in a statement. + // "Hide all" sends a whole group; D1 binds at most 100 parameters. it("POST /set-insertable-visibility handles more ids than a statement can bind", async () => { const count = 120; await seedGroup(db); @@ -180,66 +176,28 @@ describe("group admin routes", () => { expect(await db.select().from(groups).all()).toHaveLength(0); expect(await db.select().from(insertables).all()).toHaveLength(0); }); -}); - -describe("POST /reload-groups", () => { - beforeEach(() => resetDb(db)); - afterEach(() => vi.restoreAllMocks()); - - // The "false" case is a regression test: z.coerce.boolean() reads the string - // "false" as true, so passing forceReload=false used to force a reload. - it.each([ - ["omitted", "", false], - ["false", "?forceReload=false", false], - ["true", "?forceReload=true", true] - ])( - "triggers one library workflow with forceReload %s", - async (_label, query, forceReload) => { - await seedGroup(db, TEST_GROUP_ID); - vi.spyOn(JobTracker, "isReloadRunning").mockResolvedValue(false); - const trackSpy = vi - .spyOn(JobTracker, "trackJob") - .mockResolvedValue(); - const createSpy = vi - .spyOn(env.LOAD_LIBRARY_WORKFLOW, "create") - .mockResolvedValue({ id: "wf" } as never); - const res = await createTestApp().request( - `/api/reload-groups/library/${TEST_LIBRARY_ID}${query}`, - sessionRequest("POST"), - env - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ status: "triggered" }); - expect(createSpy).toHaveBeenCalledOnce(); - expect(createSpy.mock.calls[0][0]?.params).toEqual({ - libraryId: TEST_LIBRARY_ID, - sessionId: "test-session", - forceReload - }); - // The new run's instance id is tracked for the running-job checks. - expect(trackSpy).toHaveBeenCalledWith( - expect.anything(), - TEST_LIBRARY_ID, - "reload", - "wf" - ); - } - ); - - it("skips creating a workflow when a reload is already running", async () => { - await seedGroup(db, TEST_GROUP_ID); - vi.spyOn(JobTracker, "isReloadRunning").mockResolvedValue(true); - const createSpy = vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "create"); - - const res = await createTestApp().request( - `/api/reload-groups/library/${TEST_LIBRARY_ID}`, - sessionRequest("POST"), + it("DELETE /group cleans up the thumbnails of the elements it had", async () => { + await seedTestData(db); + const elementIds = ( + await db + .select({ elementId: insertables.elementId }) + .from(insertables) + ).map((row) => row.elementId); + const clean = vi + .spyOn(Reconcile, "deleteStaleThumbnails") + .mockResolvedValue(0); + + await createTestApp().request( + `/api/group/library/${TEST_LIBRARY_ID}?groupId=${TEST_GROUP_ID}`, + jsonRequest("DELETE"), env ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ status: "already-running" }); - expect(createSpy).not.toHaveBeenCalled(); + + expect(clean.mock.calls[0][2].elementIds).toEqual( + expect.arrayContaining(elementIds) + ); + clean.mockRestore(); }); }); @@ -248,10 +206,10 @@ describe("GET /job-status", () => { afterEach(() => vi.restoreAllMocks()); it.each([ - { running: true, runningForMs: 4_000 }, - { running: false } - ])("reports $running", async (status) => { - vi.spyOn(JobTracker, "getJobStatus").mockResolvedValue(status); + { loadingGroupIds: [TEST_GROUP_ID], awaitingApprovalGroupIds: [] }, + { loadingGroupIds: [], awaitingApprovalGroupIds: [TEST_GROUP_ID] } + ])("reports $loadingGroupIds loading", async (status) => { + vi.spyOn(Jobs, "getJobStatus").mockResolvedValue(status); const res = await createTestApp().request( `/api/job-status/library/${TEST_LIBRARY_ID}`, @@ -260,7 +218,7 @@ describe("GET /job-status", () => { ); expect(res.status).toBe(200); expect(await res.json()).toEqual(status); - // Polled for live state, so it must never be served from a cache. + // Asked for live state, so it must never be served from a cache. expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); }); @@ -269,16 +227,13 @@ describe("POST /group", () => { beforeEach(() => resetDb(db)); afterEach(() => vi.restoreAllMocks()); - it("forwards selectedGroupId and triggers the workflow, without writing the group row itself", async () => { + it("writes the group after the selected one, and asks for its load", async () => { await seedGroup(db, TEST_GROUP_ID); // sortOrder 0 vi.spyOn(DocumentsEndpoint, "getDocument").mockResolvedValue({ id: "doc-new", name: "New Doc" }); - const createSpy = vi - .spyOn(env.ADD_GROUP_WORKFLOW, "create") - .mockResolvedValue({ id: "wf" } as never); - const trackSpy = vi.spyOn(JobTracker, "trackJob").mockResolvedValue(); + const loadSpy = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); const res = await createTestApp().request( `/api/group/library/${TEST_LIBRARY_ID}`, @@ -291,27 +246,23 @@ describe("POST /group", () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ name: "New Doc" }); - expect(createSpy).toHaveBeenCalledOnce(); - const params: unknown = createSpy.mock.calls[0][0]?.params; - expect(params).toMatchObject({ - groupId: expect.any(String), - documentId: "doc-new", - libraryId: TEST_LIBRARY_ID, - sessionId: "test-session", - selectedGroupId: TEST_GROUP_ID - }); - expect(trackSpy).toHaveBeenCalledWith( - expect.anything(), - TEST_LIBRARY_ID, - "add-group", - "wf" - ); - - // The route (the workflow is mocked here) doesn't compute or write sort - // order itself anymore — only the existing group exists, untouched. - const rows = await db.select().from(groups).all(); - expect(rows.map((r) => r.documentId)).toEqual([`doc-${TEST_GROUP_ID}`]); - expect(rows[0].sortOrder).toBe(0); + const rows = await db + .select() + .from(groups) + .orderBy(asc(groups.sortOrder)) + .all(); + expect(rows.map((row) => row.documentId)).toEqual([ + `doc-${TEST_GROUP_ID}`, + "doc-new" + ]); + expect(loadSpy).toHaveBeenCalledWith(expect.anything(), [ + { + libraryId: TEST_LIBRARY_ID, + groupId: rows[1].id, + sessionId: "test-session", + forceReload: false + } + ]); }); it("422s when the document was already added", async () => { @@ -320,9 +271,7 @@ describe("POST /group", () => { id: "doc-test-group", name: "Dup" }); - const createSpy = vi - .spyOn(env.ADD_GROUP_WORKFLOW, "create") - .mockResolvedValue({ id: "wf" } as never); + const loadSpy = vi.spyOn(Jobs, "requestLoads"); const res = await createTestApp().request( `/api/group/library/${TEST_LIBRARY_ID}`, @@ -330,6 +279,6 @@ describe("POST /group", () => { env ); expect(res.status).toBe(422); - expect(createSpy).not.toHaveBeenCalled(); + expect(loadSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 9a01930a2..b0cc469ed 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -1,10 +1,16 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { handledError, internalError } from "../../../lib/api-error"; import { validate } from "../../../lib/validate"; import { HttpStatus } from "http-status-ts"; import z from "zod"; -import { getApp } from "../../../lib/context"; -import { getInsertableParam, insertableRoute } from "../../../lib/route-params"; +import { type AppContext, getApp } from "../../../lib/context"; +import type { BatchItem } from "drizzle-orm/batch"; +import { + getInsertableParam, + getLibraryParam, + insertableRoute, + libraryRoute +} from "../../../lib/route-params"; import { getDb, type Db } from "../../../db/client"; import { requireEditorMiddleware, @@ -13,6 +19,7 @@ import { import { insertables, configurations } from "../../../db/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../db"; import { type InsertOut } from "../contract"; +import type { LibraryId } from "../library-id"; import { toElementPath, INSTANCE_TYPES } from "../../../lib/onshape/path"; import { type ConfigurationParameter, @@ -22,11 +29,12 @@ import { INDEXING_ISSUE_TYPES, NO_RECORDS, decideIndexing, + type IndexingSettings, parseConfigurationRecords } from "../../load/parse-configuration-records"; import { ElementType } from "../../../lib/onshape/element-type"; import { InsertSource } from "../../analytics/usage"; -import { trackInBackground, trackInsert } from "../../analytics/tracking"; +import { trackInsert } from "../../analytics/tracking"; import { DerivedFeature } from "../../../lib/onshape/objects/derive-feature"; import { addPartStudioFeature } from "../../../lib/onshape/endpoints/part-studios"; import { @@ -34,11 +42,8 @@ import { addAssemblyFeature } from "../../../lib/onshape/endpoints/assemblies"; import { PartType } from "../../../lib/onshape/endpoints/documents"; -import { - toShortestConfiguration, - toOnshapeConfiguration, - toSelection -} from "../../configurations/selection"; +import { onshapeOverrides, toSelection } from "../../configurations/selection"; +import { encodeConfiguration } from "../../configurations/utils"; import { fastenMate } from "../../../lib/onshape/objects/assembly-features"; import { parseFastenInfo } from "../../load/parse-fasten"; import { getFastenQuery } from "./fasten-query"; @@ -47,31 +52,43 @@ import { addBuildIssue, clearBuildIssue } from "../../build-checker/issues"; export const insertableRoutes = getApp(); -/** POST /api/toggle-insert-and-fasten/insertable/:insertableId */ +/** An editor's access is to the path's library, so the insertable must be in it. */ +function inLibrary(libraryId: LibraryId, insertableId: string) { + return and( + eq(insertables.id, insertableId), + eq(insertables.libraryId, libraryId) + ); +} + +/** POST /api/toggle-insert-and-fasten/library/:libraryId/insertable/:insertableId */ const setFastenBody = z.object({ supportsFasten: z.boolean() }); const indexConfigurationsBody = z.object({ indexConfigurations: z.boolean() }); +const excludedParametersBody = z.object({ + excludedParameterIds: z.array(z.string()) +}); + insertableRoutes.post( - "/toggle-insert-and-fasten" + insertableRoute(), + "/toggle-insert-and-fasten" + libraryRoute() + insertableRoute(), requireEditorMiddleware, validate("json", setFastenBody), async (c) => { const db = getDb(c.env.DB); + const libraryId = getLibraryParam(c); const insertableId = getInsertableParam(c); const { supportsFasten } = c.req.valid("json"); const row = await db .select({ - libraryId: insertables.libraryId, documentId: insertables.documentId, versionId: insertables.versionId, elementId: insertables.elementId, elementType: insertables.elementType }) .from(insertables) - .where(eq(insertables.id, insertableId)) + .where(inLibrary(libraryId, insertableId)) .get(); if (!row) throw internalError("Insertable not found", HttpStatus.NOT_FOUND); @@ -90,117 +107,122 @@ insertableRoutes.post( .set({ supportsFasten, fastenInfo }) .where(eq(insertables.id, insertableId)); - await bumpLibraryVersion(db, row.libraryId); + await bumpLibraryVersion(db, libraryId); return c.json({ success: true }); } ); -/** POST /api/index-configurations/insertable/:insertableId */ +/** POST /api/index-configurations/library/:libraryId/insertable/:insertableId */ insertableRoutes.post( - "/index-configurations" + insertableRoute(), + "/index-configurations" + libraryRoute() + insertableRoute(), requireEditorMiddleware, validate("json", indexConfigurationsBody), async (c) => { - const db = getDb(c.env.DB); - const insertableId = getInsertableParam(c); - const body = c.req.valid("json"); + const { indexConfigurations } = c.req.valid("json"); + await reindex(c, getLibraryParam(c), getInsertableParam(c), { + indexConfigurations + }); + return c.json({ success: true }); + } +); - const row = await db - .select({ - libraryId: insertables.libraryId, - documentId: insertables.documentId, - versionId: insertables.versionId, - elementId: insertables.elementId, - elementType: insertables.elementType, - vendors: insertables.vendors, - isOpenComposite: insertables.isOpenComposite, - buildIssues: insertables.buildIssues +/** POST /api/excluded-parameters/library/:libraryId/insertable/:insertableId */ +insertableRoutes.post( + "/excluded-parameters" + libraryRoute() + insertableRoute(), + requireEditorMiddleware, + validate("json", excludedParametersBody), + async (c) => { + const { excludedParameterIds } = c.req.valid("json"); + await reindex(c, getLibraryParam(c), getInsertableParam(c), { + excludedParameterIds + }); + return c.json({ success: true }); + } +); + +/** Probes before writing anything, so a failure writes nothing. */ +async function reindex( + c: AppContext, + libraryId: LibraryId, + insertableId: string, + change: Partial +): Promise { + const db = getDb(c.env.DB); + const row = await db + .select({ + documentId: insertables.documentId, + versionId: insertables.versionId, + elementId: insertables.elementId, + elementType: insertables.elementType, + isOpenComposite: insertables.isOpenComposite, + buildIssues: insertables.buildIssues, + indexConfigurations: insertables.indexConfigurations, + excludedParameterIds: insertables.excludedParameterIds, + parameters: configurations.parameters + }) + .from(insertables) + .leftJoin( + configurations, + eq(configurations.insertableId, insertables.id) + ) + .where(inLibrary(libraryId, insertableId)) + .get(); + if (!row) { + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); + } + const parameters = row.parameters ?? []; + const settings: IndexingSettings = { + indexConfigurations: row.indexConfigurations, + excludedParameterIds: row.excludedParameterIds, + ...change + }; + const indexing = decideIndexing(parameters, settings); + const indexed = indexing.shouldIndex + ? await parseConfigurationRecords( + await c.var.getOnshapeApi(), + { + elementPath: toElementPath(row), + elementType: row.elementType, + isOpenComposite: row.isOpenComposite + }, + parameters, + indexing.configurations + ) + : NO_RECORDS; + + // Cleared first, so an issue the reindex resolved doesn't stick around. + const buildIssues = addBuildIssue( + clearBuildIssue(row.buildIssues, ...INDEXING_ISSUE_TYPES), + ...indexed.buildIssues, + ...indexing.buildIssues + ); + + const writes: BatchItem<"sqlite">[] = [ + db + .update(insertables) + .set({ + ...settings, + partMetadata: indexed.partMetadata, + buildIssues }) - .from(insertables) .where(eq(insertables.id, insertableId)) - .get(); - if (!row) - throw internalError("Insertable not found", HttpStatus.NOT_FOUND); - - const parameters = - ( - await db - .select({ parameters: configurations.parameters }) - .from(configurations) - .where(eq(configurations.insertableId, insertableId)) - .get() - )?.parameters ?? []; - const indexing = decideIndexing( - row.elementType, - parameters, - body.indexConfigurations - ); - - // Index before committing anything: if this throws, nothing is written. - // The error reaches the client via the app's onError handler. - const indexed = indexing.shouldIndex - ? await parseConfigurationRecords( - await c.var.getOnshapeApi(), - { - elementPath: toElementPath(row), - elementType: row.elementType, - isOpenComposite: row.isOpenComposite - }, - parameters, - indexing.configurations - ) - : NO_RECORDS; - - // Clear first, so an issue the reindex resolved (or that disabling makes - // moot) doesn't stick around. - const buildIssues = addBuildIssue( - clearBuildIssue(row.buildIssues, ...INDEXING_ISSUE_TYPES), - ...indexed.buildIssues, - ...indexing.buildIssues - ); - - // A configurations row exists exactly when the insertable is configurable. - const configWrite = - parameters.length > 0 - ? db - .insert(configurations) - .values({ - insertableId, - parameters, - records: indexed.records - }) - .onConflictDoUpdate({ - target: configurations.insertableId, - set: { records: indexed.records } - }) - : db - .delete(configurations) - .where(eq(configurations.insertableId, insertableId)); - - await db.batch([ + ]; + if (parameters.length > 0) { + writes.push( db - .update(insertables) - .set({ - indexConfigurations: body.indexConfigurations, - partMetadata: indexed.partMetadata, - buildIssues - }) - .where(eq(insertables.id, insertableId)), - configWrite - ]); - - // Records feed the search index; rebuild before the bump makes the - // /search-db url immutable, or a stale index gets pinned for a year. - await rebuildSearchDb(c.env.BLOB, db, row.libraryId); - await bumpLibraryVersion(db, row.libraryId); - return c.json({ success: true }); + .update(configurations) + .set({ records: indexed.records }) + .where(eq(configurations.insertableId, insertableId)) + ); } -); + await db.batch([writes[0], ...writes.slice(1)]); + + // Before the bump, which makes /search-db immutable for a year. + await rebuildSearchDb(c.env.BLOB, db, libraryId); + await bumpLibraryVersion(db, libraryId); +} -/** - * The tab being inserted into, in the body so the whole path arrives as one - * object. A half-built one is rejected here, not as a nonsense Onshape URL. - */ +/** Rejected here if half-built, rather than reaching Onshape as a bad url. */ const targetPathSchema = z.object({ documentId: z.string().min(1), instanceId: z.string().min(1), @@ -210,10 +232,7 @@ const targetPathSchema = z.object({ const selectionSchema = z.record(z.string(), z.string()).optional(); -/** - * What an insert applies, made whole against the insertable's parameters. Every - * request crosses here, so nothing past it holds a partial or as-typed map. - */ +/** Every request goes through here, so nothing past it holds a partial selection. */ async function readSelection( db: Db, insertableId: string, @@ -243,8 +262,7 @@ const insertBody = z.object({ selection: selectionSchema, isFavorite: z.boolean().default(false), isQuickInsert: z.boolean().default(false), - // Where the insert began, which `isFavorite` does not answer. Defaulted so - // an older client cannot drop the whole tracking batch on a NOT NULL. + // Defaulted so an older client can't fail the tracking batch on NOT NULL. source: z.enum(InsertSource).default(InsertSource.BROWSE) }); @@ -310,25 +328,22 @@ insertableRoutes.post( feature.getFeature() ); - await trackInBackground(c, async () => - trackInsert(c, { - libraryId: row.libraryId, - userId: await c.var.getUserId(), - path: sourcePath, - insertableId, - targetElementType: ElementType.PART_STUDIO, - selection, - parameters, - isFavorite: body.isFavorite, - isQuickInsert: body.isQuickInsert, - source: body.source, - // Insert-and-fasten is only offered for assembly targets. - fasten: false - }) - ); + await trackInsert(c, { + libraryId: row.libraryId, + path: sourcePath, + insertableId, + targetElementType: ElementType.PART_STUDIO, + selection, + parameters, + isFavorite: body.isFavorite, + isQuickInsert: body.isQuickInsert, + source: body.source, + // Insert-and-fasten is only offered for assembly targets. + fasten: false + }); return c.json({ - featureId: result.feature?.featureId ?? null + featureId: result.feature?.featureId } satisfies InsertOut); } ); @@ -378,28 +393,25 @@ insertableRoutes.post( body.selection ); - // Only what the selection overrides. Onshape applies the element's own - // default to every parameter left out, so this inserts the same thing — - // and a whole selection can outrun the configuration Onshape accepts. + // Only the overrides: a whole selection can exceed what Onshape accepts. let configuration = selection - ? toOnshapeConfiguration(selection, parameters) + ? encodeConfiguration(onshapeOverrides(selection, parameters)) : undefined; - // Except a part studio at its defaults, which Onshape refuses to insert - // from an empty configuration and from no configuration alike, though an - // assembly inserts from either. Naming one parameter, at the default it - // already holds, is enough: what Onshape wants turns out to be a - // configuration that is there, not one that is complete. + // Onshape won't insert a part studio from an empty configuration (an assembly + // is fine), so name one parameter at its default. if ( selection && configuration === "" && row.elementType === ElementType.PART_STUDIO ) { - configuration = toShortestConfiguration(selection, parameters); + const [first] = parameters; + configuration = encodeConfiguration({ + [first.id]: selection[first.id] + }); } - // Resolved here rather than sent by the client: the marker moves - // whenever somebody drags it, so only Onshape knows where it is now. + // Resolved here, since the marker moves whenever someone drags it. const transform = body.insertLocationId ? await getInsertLocationTransform( onshapeApi, @@ -420,28 +432,24 @@ insertableRoutes.post( } ); - // The insert has landed, and every path below records it exactly once — so a - // fasten that never happened leaves none unrecorded, and `fasten` says what was. + // Every path below records the insert exactly once. const track = (fasten: boolean) => - trackInBackground(c, async () => - trackInsert(c, { - libraryId: row.libraryId, - userId: await c.var.getUserId(), - path: sourcePath, - insertableId, - targetElementType: ElementType.ASSEMBLY, - selection, - parameters, - isFavorite: body.isFavorite, - isQuickInsert: body.isQuickInsert, - source: body.source, - fasten - }) - ); + trackInsert(c, { + libraryId: row.libraryId, + path: sourcePath, + insertableId, + targetElementType: ElementType.ASSEMBLY, + selection, + parameters, + isFavorite: body.isFavorite, + isQuickInsert: body.isQuickInsert, + source: body.source, + fasten + }); if (!body.fasten) { await track(false); - return c.json({ featureId: null } satisfies InsertOut); + return c.json({} satisfies InsertOut); } const fastenInfo = row.fastenInfo; diff --git a/src/backend/features/library/insertables/routes.test.ts b/src/backend/features/library/insertables/routes.worker.test.ts similarity index 78% rename from src/backend/features/library/insertables/routes.test.ts rename to src/backend/features/library/insertables/routes.worker.test.ts index c58d9754f..085c3562e 100644 --- a/src/backend/features/library/insertables/routes.test.ts +++ b/src/backend/features/library/insertables/routes.worker.test.ts @@ -6,12 +6,14 @@ import { dailyConfigurationMetrics, events } from "../../analytics/schema"; import { InsertSource } from "../../analytics/usage"; import { ElementType } from "../../../lib/onshape/element-type"; import { Vendor } from "../vendors"; +import type { InsertOut } from "../contract"; import { MateLocation } from "./fasten"; import { BuildIssueType } from "../../build-checker/issues"; import { MOCK_ONSHAPE_API, TEST_ASSEMBLY_ID, TEST_ASSEMBLY_PATH, + TEST_LIBRARY_ID, TEST_PART_STUDIO_ID, createTestApp, jsonRequest, @@ -28,7 +30,10 @@ import * as AssemblyEndpoints from "../../../lib/onshape/endpoints/assemblies"; import * as PartsEndpoints from "../../../lib/onshape/endpoints/parts"; import { OnshapeRateLimitError } from "../../../lib/onshape/client"; import { AUTO_INDEX_THRESHOLD } from "../../configurations/combinations"; -import { enumParam } from "../../../../__test_utils__/configuration-fixtures"; +import { + enumParam, + quantityParam +} from "../../../../__test_utils__/configuration-fixtures"; const db = getDb(env.DB); @@ -68,7 +73,7 @@ describe("insertable routes", () => { await seedPartStudio(db); const res = await createTestApp().request( - `/api/toggle-insert-and-fasten/insertable/${TEST_PART_STUDIO_ID}`, + `/api/toggle-insert-and-fasten/library/${TEST_LIBRARY_ID}/insertable/${TEST_PART_STUDIO_ID}`, jsonRequest("POST", { supportsFasten: false }), env ); @@ -108,8 +113,6 @@ describe("insertable routes", () => { ); }); - // The client names which of the two searches it was; the route takes it - // whole rather than deriving anything from the request. it("POST /add-to-part-studio records the source it was sent", async () => { await seedPartStudio(db); vi.spyOn(PartStudioEndpoints, "addPartStudioFeature").mockResolvedValue( @@ -134,8 +137,6 @@ describe("insertable routes", () => { expect(event?.source).toBe(InsertSource.GROUP_SEARCH); }); - // The one place a request's configuration is made whole, so an insert that - // names nothing still applies — and records — every parameter. it("POST /add-to-part-studio fills the selection it was not given", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -164,8 +165,33 @@ describe("insertable routes", () => { }); }); - // A half-built target used to reach Onshape as a nonsense URL and fail - // opaquely; the boundary rejects it instead. + // The feature dialog shows the typed expression, not its value. + it("POST /add-to-part-studio derives with the expression that was typed", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + await db + .update(configurations) + .set({ parameters: [quantityParam("length")] }) + .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); + const spy = vi + .spyOn(PartStudioEndpoints, "addPartStudioFeature") + .mockResolvedValue({ feature: { featureId: "feat-1" } }); + + const res = await createTestApp().request( + `/api/add-to-part-studio/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { + targetPath, + selection: { length: "(2 + 3) in" } + }), + env + ); + expect(res.status).toBe(200); + + expect(JSON.stringify(spy.mock.calls[0][2])).toContain( + '"expression":"(2 + 3) in"' + ); + }); + it.each([ ["a missing instance id", { documentId: "d", elementId: "e" }], [ @@ -214,8 +240,8 @@ describe("insertable routes", () => { ); expect(res.status).toBe(200); - const body: { featureId: string | null } = await res.json(); - expect(body.featureId).toBeNull(); + const body: InsertOut = await res.json(); + expect(body.featureId).toBeUndefined(); expect(spy).toHaveBeenCalledWith( MOCK_ONSHAPE_API, @@ -280,8 +306,7 @@ describe("insertable routes", () => { ); }); - // A marker can be deleted between the app opening and an insert, and an - // insert at the origin beats refusing to insert at all. + // The marker can be deleted after the app opens. it("POST /add-to-assembly inserts at the origin when the location is gone", async () => { await seedAssembly(db); vi.spyOn(AssemblyEndpoints, "getAssembly").mockResolvedValue({ @@ -313,9 +338,7 @@ describe("insertable routes", () => { ); }); - // Onshape applies the element's own default to whatever the configuration - // leaves out, so naming every parameter says the same thing at far greater - // length — and long enough is a configuration Onshape refuses to insert. + // Onshape defaults what's left out, and a long enough configuration is refused. it.each([ ["nothing when the selection is all defaults", { boolean: "true" }, ""], ["the override alone", { boolean: "false" }, "boolean=false"] @@ -345,6 +368,49 @@ describe("insertable routes", () => { } ); + // Onshape refuses a part studio's empty configuration, though not an assembly's. + it("POST /add-to-assembly names one default for a part studio left on its defaults", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + const spy = vi + .spyOn(AssemblyEndpoints, "addElementToAssembly") + .mockResolvedValue({}); + + await createTestApp().request( + `/api/add-to-assembly/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { targetPath, selection: {}, fasten: false }), + env + ); + + expect(spy.mock.calls[0][4]?.configuration).toBe("boolean=true"); + }); + + it("POST /add-to-assembly sends a quantity as the expression typed", async () => { + await seedAssembly(db); + await seedConfiguration(db, TEST_ASSEMBLY_ID); + await db + .update(configurations) + .set({ parameters: [quantityParam("length")] }) + .where(eq(configurations.insertableId, TEST_ASSEMBLY_ID)); + const spy = vi + .spyOn(AssemblyEndpoints, "addElementToAssembly") + .mockResolvedValue({}); + + await createTestApp().request( + `/api/add-to-assembly/insertable/${TEST_ASSEMBLY_ID}`, + jsonRequest("POST", { + targetPath, + selection: { length: "(2 + 3) in" }, + fasten: false + }), + env + ); + + expect(spy.mock.calls[0][4]?.configuration).toBe( + "length=(2%20%2B%203)%20in" + ); + }); + /** An assembly that supports insert-and-fasten, and a landed insert to fasten. */ async function seedFastenable() { await seedAssembly(db); @@ -400,8 +466,7 @@ describe("insertable routes", () => { expect(await loggedInsert()).toMatchObject({ fasten: true }); }); - // The part is in the assembly either way, so the insert is still recorded — - // as the unfastened insert it turned out to be. + // The part is in the assembly either way. it("keeps the insert but drops the fasten when the mate fails", async () => { await seedFastenable(); vi.spyOn(AssemblyEndpoints, "addAssemblyFeature").mockRejectedValue( @@ -429,7 +494,7 @@ describe("insertable routes", () => { ]); const res = await createTestApp().request( - `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + `/api/index-configurations/library/${TEST_LIBRARY_ID}/insertable/${TEST_PART_STUDIO_ID}`, jsonRequest("POST", { indexConfigurations: true }), env ); @@ -438,8 +503,6 @@ describe("insertable routes", () => { const row = await readInsertable(TEST_PART_STUDIO_ID); expect(row?.indexConfigurations).toBe(true); - // Nothing to configure, so the part data lands on the insertable and - // no configurations row is manufactured to hold it. expect(row?.partMetadata).toEqual({ partNumber: "PN-123", hasMultipleParts: false, @@ -448,6 +511,38 @@ describe("insertable routes", () => { expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); + it("POST /excluded-parameters stores the exclusion and reindexes without it", async () => { + await seedPartStudio(db); + await db.insert(configurations).values({ + insertableId: TEST_PART_STUDIO_ID, + parameters: [ + enumParam("size", ["s", "l"]), + enumParam("finish", ["matte", "gloss"]) + ] + }); + const parts = vi + .spyOn(PartsEndpoints, "getParts") + .mockResolvedValue([{ partId: "p", partNumber: "PN" }]); + + const res = await createTestApp().request( + `/api/excluded-parameters/library/${TEST_LIBRARY_ID}/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { excludedParameterIds: ["finish"] }), + env + ); + expect(res.status).toBe(200); + + expect( + (await readInsertable(TEST_PART_STUDIO_ID))?.excludedParameterIds + ).toEqual(["finish"]); + // The default, then size=l alone: finish rides its default. + expect(parts).toHaveBeenCalledTimes(2); + expect( + (await readConfig(TEST_PART_STUDIO_ID))?.records.map( + (record) => record.values + ) + ).toEqual([{ size: "l" }]); + }); + it("POST /index-configurations leaves the flag off when indexing fails", async () => { await seedPartStudio(db); vi.spyOn(PartsEndpoints, "getParts").mockRejectedValue( @@ -455,12 +550,10 @@ describe("insertable routes", () => { ); const res = await createTestApp().request( - `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + `/api/index-configurations/library/${TEST_LIBRARY_ID}/insertable/${TEST_PART_STUDIO_ID}`, jsonRequest("POST", { indexConfigurations: true }), env ); - // Surfaced to the client rather than silently enabling. How long to - // wait is the loader's business; the caller is told to try again. expect(res.status).toBe(429); const body: { message: string } = await res.json(); expect(body.message).toContain("rate limit"); @@ -471,8 +564,6 @@ describe("insertable routes", () => { expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); - // Over the auto-index threshold nothing indexes unless an admin asks, so - // turning force off there drops the records and the configuration row. it("POST /index-configurations clears the data when forcing off", async () => { await seedGroup(db); await seedInsertable(db); @@ -492,14 +583,14 @@ describe("insertable routes", () => { .spyOn(PartsEndpoints, "getParts") .mockResolvedValue([{ partId: "p", partNumber: "PN-123" }]); await createTestApp().request( - `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + `/api/index-configurations/library/${TEST_LIBRARY_ID}/insertable/${TEST_PART_STUDIO_ID}`, jsonRequest("POST", { indexConfigurations: true }), env ); spy.mockClear(); const res = await createTestApp().request( - `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + `/api/index-configurations/library/${TEST_LIBRARY_ID}/insertable/${TEST_PART_STUDIO_ID}`, jsonRequest("POST", { indexConfigurations: false }), env ); @@ -513,8 +604,6 @@ describe("insertable routes", () => { expect((await readConfig(TEST_PART_STUDIO_ID))?.records).toEqual([]); }); - // Nothing gates on vendors any more, so a part below the threshold indexes - // whether or not anyone sells it. it("POST /index-configurations keeps indexing a custom part", async () => { await seedGroup(db); await seedInsertable(db, { @@ -526,7 +615,7 @@ describe("insertable routes", () => { ]); const res = await createTestApp().request( - `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + `/api/index-configurations/library/${TEST_LIBRARY_ID}/insertable/${TEST_PART_STUDIO_ID}`, jsonRequest("POST", { indexConfigurations: false }), env ); @@ -538,8 +627,7 @@ describe("insertable routes", () => { expect(row?.buildIssues).toEqual([]); }); - // The route merges into the row's stored issues, so it has to clear the ones - // indexing owns first, or a resolved issue would stick around forever. + // Indexing's own issues are cleared before merging, or a resolved one sticks. it("POST /index-configurations replaces stale part-number issues", async () => { await seedPartStudio(db); await db @@ -556,14 +644,13 @@ describe("insertable routes", () => { ]); const res = await createTestApp().request( - `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + `/api/index-configurations/library/${TEST_LIBRARY_ID}/insertable/${TEST_PART_STUDIO_ID}`, jsonRequest("POST", { indexConfigurations: true }), env ); expect(res.status).toBe(200); const row = await readInsertable(TEST_PART_STUDIO_ID); - // The cap no longer applies, and the unrelated issue survives. expect(row?.buildIssues).toEqual([{ type: BuildIssueType.NO_VENDORS }]); }); }); diff --git a/src/backend/features/library/library-id.ts b/src/backend/features/library/library-id.ts index c04c28130..30621b9ad 100644 --- a/src/backend/features/library/library-id.ts +++ b/src/backend/features/library/library-id.ts @@ -4,9 +4,5 @@ export enum LibraryId { MKCAD = "mkcad" } -/** - * The library the app falls back to: what a caller with no choice of their own - * opens in, and what a library-scoped reader uses when the tab showing is not - * a library. - */ +/** For callers with no choice of their own, and readers on a non-library tab. */ export const DEFAULT_LIBRARY = LibraryId.FRC_DESIGN_LIB; diff --git a/src/backend/features/library/routes.ts b/src/backend/features/library/routes.ts index 134da21a3..769588a1b 100644 --- a/src/backend/features/library/routes.ts +++ b/src/backend/features/library/routes.ts @@ -4,7 +4,7 @@ import { getApp } from "../../lib/context"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { getDb } from "../../db/client"; import { libraries } from "../../db/schema"; -import { getLibraryOut, searchIndexKey } from "./db"; +import { getLibraryOut, rebuildSearchDb, searchIndexKey } from "./db"; export const libraryRoutes = getApp(); @@ -35,10 +35,7 @@ libraryRoutes.get( } ); -/** - * GET /api/search-db/library/:libraryId?v=:cacheVersion. Never set - * `Content-Encoding` here: the runtime would compress it a second time. - */ +/** GET /api/search-db/library/:libraryId?v=:cacheVersion. No `Content-Encoding`: the runtime compresses. */ libraryRoutes.get( "/search-db" + libraryRoute(), cacheMiddleware(CachePolicy.PUBLIC_CACHE), @@ -47,7 +44,15 @@ libraryRoutes.get( const object = await c.env.BLOB.get(searchIndexKey(libraryId)); if (!object) { - return c.notFound(); + // Not built in this deploy's shape yet. + const searchDb = await rebuildSearchDb( + c.env.BLOB, + getDb(c.env.DB), + libraryId + ); + return new Response(searchDb, { + headers: { "Content-Type": "application/json" } + }); } const headers = new Headers(); diff --git a/src/backend/features/library/routes.test.ts b/src/backend/features/library/routes.worker.test.ts similarity index 92% rename from src/backend/features/library/routes.test.ts rename to src/backend/features/library/routes.worker.test.ts index 6758599e8..1cd9a68b8 100644 --- a/src/backend/features/library/routes.test.ts +++ b/src/backend/features/library/routes.worker.test.ts @@ -73,8 +73,7 @@ describe("library routes", () => { ); const body = await res.text(); - // The seeded parameter's name is distinctive; the payload stays in D1 - // and is fetched from /api/configuration/:insertableId when needed. + // Parameters are fetched per insertable, not sent with the library. expect(body).not.toContain(TEST_PARAMETERS[0].name); expect(body).not.toContain("parameters"); expect(body).not.toContain("records"); @@ -92,8 +91,7 @@ describe("library routes", () => { env ); expect(res.status).toBe(200); - // A hand-set Content-Encoding gets compressed again by the runtime, - // leaving the client a gzip stream after one inflate. + // The runtime would compress it again. expect(res.headers.get("Content-Encoding")).toBeNull(); expect(res.headers.get("Content-Type")).toBe("application/json"); @@ -102,8 +100,9 @@ describe("library routes", () => { expect(parsed.documentCount).toBeGreaterThan(0); }); - it("GET /search-db 404s when the library has no index", async () => { - await seedLibrary(db); + // Every library misses the first time a deploy with a new index shape reads it. + it("GET /search-db builds the index when there is none", async () => { + await seedTestData(db); const app = createTestApp(); const res = await app.request( @@ -111,7 +110,10 @@ describe("library routes", () => { jsonRequest("GET"), env ); - expect(res.status).toBe(404); + expect(res.status).toBe(200); + expect( + await env.BLOB.head(searchIndexKey(TEST_LIBRARY_ID)) + ).not.toBeNull(); }); it("GET /library-version returns the library's version", async () => { diff --git a/src/backend/features/library/vendors.test.ts b/src/backend/features/library/vendors.test.ts index 4faae1eb2..edf44b6e6 100644 --- a/src/backend/features/library/vendors.test.ts +++ b/src/backend/features/library/vendors.test.ts @@ -87,8 +87,7 @@ describe("getLibraryVendors", () => { expect(frc).toContain(Vendor.MCM); }); - // The roster itself, so a vendor cannot quietly go missing: FRC teams see - // this list and nothing else, in this order. + // Pinned so a vendor can't quietly go missing. it("stocks FRC with exactly the vendors it buys from", () => { expect( getLibraryVendors(LibraryId.FRC_DESIGN_LIB).map((vendor) => [ @@ -114,8 +113,6 @@ describe("getLibraryVendors", () => { ]); }); - // The same roster check for FTC, whose list is its own and shares only - // part of FRC's. it("stocks FTC with exactly the vendors it buys from", () => { expect( getLibraryVendors(LibraryId.FTC_DESIGN_LIB).map((vendor) => [ diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts index f86c86003..38315fa78 100644 --- a/src/backend/features/library/vendors.ts +++ b/src/backend/features/library/vendors.ts @@ -31,8 +31,7 @@ export enum Vendor { TTB = "TTB", VEX = "VEX", WCP = "WCP", - /** Last, being the absence of a vendor: the team made it, so nobody sells - * it and it has no part number. */ + /** Made by the team, so nobody sells it. */ CUSTOM = "Custom" } @@ -78,18 +77,12 @@ const FTC_VENDORS: Vendor[] = [ Vendor.CUSTOM ]; -/** - * The vendors a library stocks, which is what its filters offer. Tagging stays - * library-generic. MKCad is FRC, so it shares that list. - */ +/** What a library's filters offer. MKCad shares FRC's list. */ export function getLibraryVendors(libraryId: LibraryId): Vendor[] { return libraryId === LibraryId.FTC_DESIGN_LIB ? FTC_VENDORS : FRC_VENDORS; } -/** - * Resolves the free text Onshape carries as a vendor to one we know, written - * either as its code or as its full name. - */ +/** Accepts a code or a full name. */ export function parseVendor(vendor: string | undefined): Vendor | undefined { const text = clean(vendor)?.toUpperCase(); if (!text) { @@ -102,20 +95,14 @@ export function parseVendor(vendor: string | undefined): Vendor | undefined { ); } -/** - * The vendor a part number names itself, e.g. `WCP-1025` — more precise than an - * insertable's tagging, which is generic wherever one part spans vendors. - */ +/** More precise than tagging, which is generic when a part spans vendors. */ export function parseVendorFromPartNumber( partNumber: string | undefined ): Vendor | undefined { return parseVendor(VENDOR_PREFIX.exec(clean(partNumber) ?? "")?.[1]); } -/** - * The vendor's page for a part, or its search for one where that is all the - * site offers. Most vendors have no url derivable from a part number at all. - */ +/** Most vendors have no url derivable from a part number. */ export function getVendorPartUrl( vendor: Vendor | undefined, partNumber: string | undefined @@ -130,8 +117,7 @@ export function getVendorPartUrl( case Vendor.WCP: return `https://wcproducts.com/products/${query.toLowerCase()}`; case Vendor.AM: - // AndyMark redirects a bare part number to the product page for it, - // and 404s when it sells no such part. + // Redirects to the product, or 404s. return `https://andymark.com/${query.toLowerCase()}`; case Vendor.REV: return `https://www.revrobotics.com/search.php?search_query=${query}§ion=product`; diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index c45805ab6..f57b9dcfb 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -1,82 +1,76 @@ import type { WorkflowStep } from "cloudflare:workers"; +import { createLimiter, type Limiter } from "../../lib/limiter"; import type { AppBindings } from "../../lib/context"; import { getOnshapeApiFromSessionId } from "../auth/request-auth"; -import type { OnshapeApi } from "../../lib/onshape/client"; +import { getBackgroundOnshapeApi } from "../auth/background-sessions"; +import type { OAuthApi } from "../../lib/onshape/client"; import type { ElementType } from "../../lib/onshape/element-type"; import type { LibraryId } from "../library/library-id"; import type { ElementPath, InstancePath } from "../../lib/onshape/path"; /** - * How many insertables a load probes Onshape for at once — see - * `probeInsertable`, which is the part of a load that asks Onshape anything - * beyond a thumbnail. What bounds this is Onshape's rate limit rather than - * anything here: past it the extra calls come back 429 and wait out their - * `Retry-After` (see `ONSHAPE_STEP_RETRIES`), and a step whose five attempts run - * out fails its insertable. - * - * Back to 15 after 40 drove a load into a rate limit it never climbed out of: - * six attempts on one step, all 429, across five minutes. Still not measured - * against where Onshape actually starts pushing back, so this is the number that - * was working before rather than a considered one. + * Bounded by Onshape's rate limit: at 40 a load hit 429s it never recovered + * from. 15 is what worked before, not a measured limit. */ export const LOAD_CONCURRENCY = 15; -/** Runs a task, waiting for a slot when the limiter is full. */ -type Limiter = (task: () => Promise) => Promise; - -/** - * Runs at most `max` tasks at once, queueing the rest in call order, so a - * rate-limit burst only hits the running few. - */ -export function createLimiter(max: number): Limiter { - let active = 0; - const queue: (() => void)[] = []; - - const release = () => { - active--; - const next = queue.shift(); - if (next) next(); - }; - - return async (task: () => Promise): Promise => { - if (active >= max) { - await new Promise((resolve) => queue.push(resolve)); - } - active++; - try { - return await task(); - } finally { - release(); - } - }; -} +/** Separate from probing, since a thumbnail step holds its slot through minutes of retries. */ +const THUMBNAIL_CONCURRENCY = 10; /** The runtime plumbing a load runs against. */ export interface LoadContext { env: AppBindings; - sessionId: string; + libraryId: LibraryId; + /** Whoever asked for the load; absent for a webhook's. */ + sessionId?: string; step: WorkflowStep; /** Bounds concurrent Onshape probing across the whole run. */ limit: Limiter; + thumbnailLimit: Limiter; + /** Resolved once a run needs it; see {@link getOnshapeApiFromContext}. */ + adminApi?: Promise; } export function createLoadContext( env: AppBindings, - sessionId: string, + libraryId: LibraryId, + sessionId: string | undefined, step: WorkflowStep ): LoadContext { return { env, + libraryId, sessionId, step, - limit: createLimiter(LOAD_CONCURRENCY) + limit: createLimiter(LOAD_CONCURRENCY), + thumbnailLimit: createLimiter(THUMBNAIL_CONCURRENCY) }; } -export function getOnshapeApiFromContext( +/** The requester's session while it works, else an admin's. */ +export async function getOnshapeApiFromContext( ctx: LoadContext -): Promise { - return getOnshapeApiFromSessionId(ctx.env.KV, ctx.sessionId); +): Promise { + if (ctx.sessionId) { + try { + return await getOnshapeApiFromSessionId(ctx.env.KV, ctx.sessionId); + } catch { + // Signed out or expired since asking; an admin carries on. + } + } + ctx.adminApi ??= getBackgroundOnshapeApi(ctx.env, [ctx.libraryId]).then( + (api) => { + if (!api) { + throw new Error("No owner or admin session to load with"); + } + return api; + } + ); + // A failure is retried by the step, so it must not be memoized. + return ctx.adminApi.catch((error: unknown) => { + ctx.adminApi = undefined; + throw error; + }); } /** A group a load reads, and what the document told us about it. */ @@ -86,25 +80,21 @@ export interface GroupTarget { versionPath: InstancePath; /** When Onshape cut `versionPath`'s version. */ versionCreatedAt: Date; - /** - * The document's default workspace. Everything the library shows is pinned - * to the version; this is only where thumbnails are read from, because the - * version form of that endpoint does not reliably return them. - */ - workspacePath: InstancePath; name: string; /** The tab the document renders its thumbnail from, when one is set. */ thumbnailElementId?: string; } +/** Only a group that loads gets a thumbnail workspace; see `loadGroup`. */ +export interface LoadingGroup extends GroupTarget { + /** See `thumbnails/workspace.ts`. */ + thumbnailPath: InstancePath; +} + /** An insertable a load reads, and what the document's tab listing told us. */ export interface InsertableTarget { insertableId: string; - /** - * The same tab in the document's workspace, which its thumbnail falls back - * to when the version will not answer. - */ - elementWorkspacePath: ElementPath; + thumbnailPath: ElementPath; libraryId: LibraryId; groupId: string; elementPath: ElementPath; diff --git a/src/backend/features/load/context.worker.test.ts b/src/backend/features/load/context.worker.test.ts new file mode 100644 index 000000000..da3a803a0 --- /dev/null +++ b/src/backend/features/load/context.worker.test.ts @@ -0,0 +1,61 @@ +import { env } from "cloudflare:workers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FAKE_STEP, TEST_LIBRARY_ID } from "../../../__test_utils__"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import * as RequestAuth from "../auth/request-auth"; +import * as BackgroundSessions from "../auth/background-sessions"; +import { createLoadContext, getOnshapeApiFromContext } from "./context"; + +const REQUESTER = new MockOnshapeApi(); +const ADMIN = new MockOnshapeApi(); + +const context = (sessionId?: string) => + createLoadContext(env, TEST_LIBRARY_ID, sessionId, FAKE_STEP); + +describe("the session a load calls Onshape with", () => { + afterEach(() => vi.restoreAllMocks()); + + it("is the requester's while it works", async () => { + vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockResolvedValue( + REQUESTER + ); + expect(await getOnshapeApiFromContext(context("s"))).toBe(REQUESTER); + }); + + it("is an admin's once the requester's stops working", async () => { + vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockRejectedValue( + new Error("expired") + ); + const admin = vi + .spyOn(BackgroundSessions, "getBackgroundOnshapeApi") + .mockResolvedValue(ADMIN); + + expect(await getOnshapeApiFromContext(context("s"))).toBe(ADMIN); + expect(admin).toHaveBeenCalledWith(env, [TEST_LIBRARY_ID]); + }); + + // A webhook's load has no requester. + it("is an admin's when nobody asked, found once a run", async () => { + const admin = vi + .spyOn(BackgroundSessions, "getBackgroundOnshapeApi") + .mockResolvedValue(ADMIN); + const ctx = context(); + + await getOnshapeApiFromContext(ctx); + expect(await getOnshapeApiFromContext(ctx)).toBe(ADMIN); + expect(admin).toHaveBeenCalledOnce(); + }); + + // So the step's retry looks again, rather than repeat the same failure. + it("fails without an admin session, and looks again next time", async () => { + const admin = vi + .spyOn(BackgroundSessions, "getBackgroundOnshapeApi") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(ADMIN); + const ctx = context(); + + await expect(getOnshapeApiFromContext(ctx)).rejects.toThrow(); + expect(await getOnshapeApiFromContext(ctx)).toBe(ADMIN); + expect(admin).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/backend/features/load/contract.ts b/src/backend/features/load/contract.ts index b752b231d..265d69149 100644 --- a/src/backend/features/load/contract.ts +++ b/src/backend/features/load/contract.ts @@ -1,7 +1,21 @@ -/** - * Whether a library-load job is running, and how long it has been going. - * Milliseconds since the oldest running job started paces the client's polling. - */ -export type JobStatus = - | { running: false } - | { running: true; runningForMs: number }; +/** Which of a library's groups are loading, so each can show that it is. */ +export interface JobStatus { + loadingGroupIds: string[]; + /** Holding a new version until an admin approves it. */ + awaitingApprovalGroupIds: string[]; +} + +export interface VersionApprovalOut { + /** Whether new versions wait for an admin's approval before loading. */ + enabled: boolean; +} + +export interface ApproveVersionsOut { + /** How many documents were let through. */ + documents: number; +} + +export interface ReloadOut { + /** How many documents were asked to reload. */ + documents: number; +} diff --git a/src/backend/features/load/flag.ts b/src/backend/features/load/flag.ts new file mode 100644 index 000000000..2bbb41525 --- /dev/null +++ b/src/backend/features/load/flag.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { groups } from "../../db/schema"; +import { bumpLibraryVersion } from "../library/db"; +import type { LibraryId } from "../library/library-id"; +import { pushLibraryChanged } from "../push/notify"; +import { addBuildIssue, BuildIssueType } from "../build-checker/issues"; + +/** Marks each group for an admin to reload; publishing the change is the caller's. */ +export async function flagFailedLoads( + env: AppBindings, + groupIds: string[] +): Promise { + const type = BuildIssueType.LOAD_FAILED; + const db = getDb(env.DB); + for (const groupId of groupIds) { + const row = await db + .select({ buildIssues: groups.buildIssues }) + .from(groups) + .where(eq(groups.id, groupId)) + .get(); + if (row) { + await db + .update(groups) + .set({ buildIssues: addBuildIssue(row.buildIssues, { type }) }) + .where(eq(groups.id, groupId)); + } + } +} + +/** For a flag raised outside a load, which would otherwise publish it. */ +export async function publishLibraries( + env: AppBindings, + libraryIds: Iterable +): Promise { + const db = getDb(env.DB); + for (const libraryId of new Set(libraryIds)) { + await bumpLibraryVersion(db, libraryId); + await pushLibraryChanged(env, libraryId); + } +} diff --git a/src/backend/features/load/job-tracker.test.ts b/src/backend/features/load/job-tracker.test.ts deleted file mode 100644 index 4faa39ebd..000000000 --- a/src/backend/features/load/job-tracker.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { env } from "cloudflare:workers"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getJobStatus, - isReloadRunning, - trackJob, - untrackJob -} from "./job-tracker"; -import { TEST_LIBRARY_ID } from "../../../__test_utils__"; - -interface Job { - id: string; - kind: "reload" | "add-group"; - startedAt?: number; -} - -/** Stubs the stored jobs array and every instance's live status. */ -function mockJobs(jobs: Job[], status?: string) { - vi.spyOn(env.KV, "get").mockResolvedValue(JSON.stringify(jobs) as never); - const instance = { status: () => Promise.resolve({ status }) } as never; - vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "get").mockResolvedValue(instance); - vi.spyOn(env.ADD_GROUP_WORKFLOW, "get").mockResolvedValue(instance); -} - -describe("job-tracker", () => { - afterEach(() => vi.restoreAllMocks()); - - it("reports nothing running when no jobs are stored", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue(null as never); - expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(false); - expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ - running: false - }); - }); - - it.each(["queued", "running", "waiting", "paused", "waitingForPause"])( - "counts a %s reload as running", - async (status) => { - mockJobs([{ id: "r1", kind: "reload" }], status); - expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(true); - expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe( - true - ); - } - ); - - it.each(["complete", "errored", "terminated", "unknown"])( - "counts a %s reload as finished", - async (status) => { - mockJobs([{ id: "r1", kind: "reload" }], status); - expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(false); - expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe( - false - ); - } - ); - - it("a running add-group counts as any-job but not as a reload", async () => { - mockJobs([{ id: "a1", kind: "add-group" }], "running"); - expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(false); - expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe(true); - }); - - it("treats an aged-out instance as finished", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue( - JSON.stringify([{ id: "r1", kind: "reload" }]) as never - ); - vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "get").mockRejectedValue( - new Error("not found") - ); - expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe(false); - }); - - it("reports how long the oldest running job has been going", async () => { - const now = Date.now(); - mockJobs( - [ - { id: "r1", kind: "reload", startedAt: now - 30_000 }, - { id: "a1", kind: "add-group", startedAt: now - 5_000 } - ], - "running" - ); - - const status = await getJobStatus(env, TEST_LIBRARY_ID); - expect(status.running).toBe(true); - if (!status.running) return; - expect(status.runningForMs).toBeGreaterThanOrEqual(30_000); - expect(status.runningForMs).toBeLessThan(40_000); - }); - - it("appends a tracked job under the library key with a TTL", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue(null as never); - const putSpy = vi.spyOn(env.KV, "put").mockResolvedValue(); - - await trackJob(env, TEST_LIBRARY_ID, "reload", "r1"); - - const [key, value, options] = putSpy.mock.calls[0]; - expect(key).toBe(`library-jobs:${TEST_LIBRARY_ID}`); - expect(JSON.parse(value as string)).toEqual([ - { id: "r1", kind: "reload", startedAt: expect.any(Number) } - ]); - expect(options).toEqual( - expect.objectContaining({ expirationTtl: expect.any(Number) }) - ); - }); - - it("untrackJob removes only its own entry, keeping others", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue( - JSON.stringify([ - { id: "r1", kind: "reload" }, - { id: "a1", kind: "add-group" } - ]) as never - ); - const putSpy = vi.spyOn(env.KV, "put").mockResolvedValue(); - - await untrackJob(env, TEST_LIBRARY_ID, "r1"); - - expect(putSpy).toHaveBeenCalledWith( - `library-jobs:${TEST_LIBRARY_ID}`, - JSON.stringify([{ id: "a1", kind: "add-group" }]), - expect.objectContaining({ expirationTtl: expect.any(Number) }) - ); - }); - - it("untrackJob deletes the key once the last job finishes", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue( - JSON.stringify([{ id: "r1", kind: "reload" }]) as never - ); - const deleteSpy = vi.spyOn(env.KV, "delete").mockResolvedValue(); - - await untrackJob(env, TEST_LIBRARY_ID, "r1"); - - expect(deleteSpy).toHaveBeenCalledWith( - `library-jobs:${TEST_LIBRARY_ID}` - ); - }); -}); diff --git a/src/backend/features/load/job-tracker.ts b/src/backend/features/load/job-tracker.ts deleted file mode 100644 index 13d94837d..000000000 --- a/src/backend/features/load/job-tracker.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { AppBindings } from "../../lib/context"; -import type { LibraryId } from "../library/library-id"; -import type { JobStatus } from "./contract"; - -/** - * Backstop for a job that crashes before untracking itself; must outlast the - * longest load (reloads and large adds can run for hours). - */ -const JOB_TTL_SECONDS = 60 * 60 * 24; - -/** Instance statuses that mean a job is still live. */ -const ACTIVE_STATUSES = new Set([ - "queued", - "running", - "paused", - "waiting", - "waitingForPause" -]); - -type JobKind = "reload" | "add-group"; - -interface TrackedJob { - id: string; - kind: JobKind; - /** Epoch ms the job was created. */ - startedAt: number; -} - -function jobsKey(libraryId: LibraryId): string { - return `library-jobs:${libraryId}`; -} - -function workflowForKind(env: AppBindings, kind: JobKind) { - return kind === "reload" - ? env.LOAD_LIBRARY_WORKFLOW - : env.ADD_GROUP_WORKFLOW; -} - -/** Whether a tracked job's workflow instance is still live. */ -async function isJobActive( - env: AppBindings, - job: TrackedJob -): Promise { - try { - const instance = await workflowForKind(env, job.kind).get(job.id); - const status = (await instance.status()).status; - return ACTIVE_STATUSES.has(status); - } catch { - return false; // Instance aged out of retention or never existed. - } -} - -async function readJobs( - env: AppBindings, - libraryId: LibraryId -): Promise { - const raw = await env.KV.get(jobsKey(libraryId)); - return raw ? (JSON.parse(raw) as TrackedJob[]) : []; -} - -/** The tracked jobs whose workflow is still running. */ -async function activeJobs( - env: AppBindings, - libraryId: LibraryId -): Promise { - const jobs = await readJobs(env, libraryId); - const live = await Promise.all(jobs.map((job) => isJobActive(env, job))); - return jobs.filter((_, i) => live[i]); -} - -/** Whether a reload is running — used to keep reloads a singleton per library. */ -export async function isReloadRunning( - env: AppBindings, - libraryId: LibraryId -): Promise { - const jobs = await activeJobs(env, libraryId); - return jobs.some((job) => job.kind === "reload"); -} - -/** Reports the oldest running job's age, which paces the client's polling. */ -export async function getJobStatus( - env: AppBindings, - libraryId: LibraryId -): Promise { - const jobs = await activeJobs(env, libraryId); - if (jobs.length === 0) { - return { running: false }; - } - const startedAt = Math.min(...jobs.map((job) => job.startedAt)); - return { running: true, runningForMs: Date.now() - startedAt }; -} - -/** Records a newly-created job, pruning any that have since finished. */ -export async function trackJob( - env: AppBindings, - libraryId: LibraryId, - kind: JobKind, - instanceId: string -): Promise { - const jobs = await activeJobs(env, libraryId); - jobs.push({ id: instanceId, kind, startedAt: Date.now() }); - await env.KV.put(jobsKey(libraryId), JSON.stringify(jobs), { - expirationTtl: JOB_TTL_SECONDS - }); -} - -/** - * Called in the workflow's final step so running state clears promptly rather - * than waiting out the TTL. Touches only its own entry. - */ -export async function untrackJob( - env: AppBindings, - libraryId: LibraryId, - instanceId: string -): Promise { - const remaining = (await readJobs(env, libraryId)).filter( - (job) => job.id !== instanceId - ); - if (remaining.length === 0) { - await env.KV.delete(jobsKey(libraryId)); - } else { - await env.KV.put(jobsKey(libraryId), JSON.stringify(remaining), { - expirationTtl: JOB_TTL_SECONDS - }); - } -} diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts new file mode 100644 index 000000000..7254632cf --- /dev/null +++ b/src/backend/features/load/jobs.ts @@ -0,0 +1,339 @@ +/** + * One load per group at a time: two writing the same rows would interleave. + * A load requested meanwhile replaces the running one. In D1 since concurrent + * KV writes lose updates. + */ +import { and, eq, inArray } from "drizzle-orm"; +import type { BatchItem } from "drizzle-orm/batch"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { chunkForInArray } from "../../db/chunk"; +import { loadJobs } from "../../db/schema"; +import { bumpLibraryVersion, rebuildSearchDb } from "../library/db"; +import type { LibraryId } from "../library/library-id"; +import { pushJobStatus, pushLibraryChanged } from "../push/notify"; +import type { JobStatus } from "./contract"; +import { flagFailedLoads, publishLibraries } from "./flag"; + +export interface LoadDocumentParams { + libraryId: LibraryId; + groupId: string; + /** Who asked for the load, whose session it tries first; see `getOnshapeApiFromContext`. */ + sessionId?: string; + /** Reloads insertables whose version has not changed, too. */ + forceReload: boolean; + /** Waits for an admin to approve a new version before loading it. */ + awaitApproval?: boolean; +} + +/** What a held load waits for; see `approveHeldLoads`. */ +export const APPROVE_EVENT = "approve-version"; + +/** A version nobody approves in this long loads anyway. */ +export const APPROVAL_TIMEOUT = "2 days"; + +/** Instance statuses that mean a load is still live. */ +const ACTIVE_STATUSES = new Set([ + "queued", + "running", + "paused", + "waiting", + "waitingForPause" +]); + +/** After this, a claimed row with no instance is treated as a failed start. */ +const CLAIM_GRACE_MS = 60_000; + +/** Workflows create at most this many instances a call. */ +const CREATE_BATCH = 100; + +/** Five columns a row, under D1's 100 bound parameters a statement. */ +const ROWS_PER_INSERT = 15; + +type LoadJob = typeof loadJobs.$inferSelect; + +/** Whether a row's load is still running; one that crashed left its row behind. */ +async function isAlive(env: AppBindings, job: LoadJob): Promise { + if (!job.instanceId) { + return Date.now() - job.startedAt.getTime() < CLAIM_GRACE_MS; + } + try { + const instance = await env.LOAD_DOCUMENT_WORKFLOW.get(job.instanceId); + return ACTIVE_STATUSES.has((await instance.status()).status); + } catch { + return false; // Aged out of retention, or never created. + } +} + +async function clearDead( + env: AppBindings, + jobs: LoadJob[] +): Promise { + const alive = await Promise.all(jobs.map((job) => isAlive(env, job))); + const dead = jobs.filter((_, i) => !alive[i]); + const db = getDb(env.DB); + for (const chunk of chunkForInArray(dead.map((job) => job.groupId))) { + await db.delete(loadJobs).where(inArray(loadJobs.groupId, chunk)); + } + if (dead.length > 0) { + // It crashed before it could record its own failure. + await flagFailedLoads( + env, + dead.map((job) => job.groupId) + ); + await publishLibraries( + env, + dead.map((job) => job.libraryId) + ); + } + return jobs.filter((_, i) => alive[i]); +} + +/** + * Starts a load of each group. One already running is terminated and replaced, + * since the new load reads the latest version itself. + */ +export async function requestLoads( + env: AppBindings, + requests: LoadDocumentParams[] +): Promise { + const db = getDb(env.DB); + const groupIds = requests.map((request) => request.groupId); + + const existing: LoadJob[] = []; + for (const chunk of chunkForInArray(groupIds)) { + existing.push( + ...(await db + .select() + .from(loadJobs) + .where(inArray(loadJobs.groupId, chunk))) + ); + } + const running = new Map( + (await clearDead(env, existing)).map((job) => [job.groupId, job]) + ); + await terminateLoads(env, [...running.values()]); + + const starts = requests.map((request) => ({ + id: crypto.randomUUID(), + params: { + ...request, + // A forced reload isn't undone by a plain load replacing it. + forceReload: + request.forceReload || + (running.get(request.groupId)?.forceReload ?? false) + } + })); + const startedAt = new Date(); + const replacing = starts.filter((start) => + running.has(start.params.groupId) + ); + const fresh = starts.filter((start) => !running.has(start.params.groupId)); + + const writes: BatchItem<"sqlite">[] = replacing.map((start) => + db + .update(loadJobs) + .set({ + instanceId: start.id, + startedAt, + forceReload: start.params.forceReload, + awaitingApproval: false + }) + .where(eq(loadJobs.groupId, start.params.groupId)) + ); + for (let i = 0; i < fresh.length; i += ROWS_PER_INSERT) { + writes.push( + db + .insert(loadJobs) + .values( + fresh.slice(i, i + ROWS_PER_INSERT).map((start) => ({ + groupId: start.params.groupId, + libraryId: start.params.libraryId, + instanceId: start.id, + startedAt, + forceReload: start.params.forceReload + })) + ) + .onConflictDoNothing() + ); + } + if (writes.length > 0) { + await db.batch( + writes as [BatchItem<"sqlite">, ...BatchItem<"sqlite">[]] + ); + } + + // Each load stands alone, so they all start at once. + const batches: (typeof starts)[] = []; + for (let i = 0; i < starts.length; i += CREATE_BATCH) { + batches.push(starts.slice(i, i + CREATE_BATCH)); + } + await Promise.all( + batches.map((batch) => env.LOAD_DOCUMENT_WORKFLOW.createBatch(batch)) + ); + + const libraryIds = new Set(requests.map((request) => request.libraryId)); + for (const libraryId of libraryIds) { + await pushJobStatus( + env, + libraryId, + await runningStatus(env, libraryId) + ); + } +} + +/** Stops loads about to be replaced. One that has just finished can't be, which is fine. */ +async function terminateLoads( + env: AppBindings, + jobs: LoadJob[] +): Promise { + await Promise.all( + jobs.map(async (job) => { + if (!job.instanceId) return; + try { + const instance = await env.LOAD_DOCUMENT_WORKFLOW.get( + job.instanceId + ); + await instance.terminate(); + } catch (error) { + console.warn( + `Failed to stop the load of ${job.groupId}`, + error + ); + } + }) + ); +} + +/** + * Whether it failed or not. Publishes what it wrote, then releases the group, + * unless a newer load has replaced this one. + */ +export async function finishLoad( + env: AppBindings, + params: LoadDocumentParams, + instanceId: string, + changed: boolean +): Promise { + const db = getDb(env.DB); + // Before the bump, which makes /search-db immutable for a year. + if (changed) { + await rebuildSearchDb(env.BLOB, db, params.libraryId); + await bumpLibraryVersion(db, params.libraryId); + await pushLibraryChanged(env, params.libraryId); + } + await db + .delete(loadJobs) + .where( + and( + eq(loadJobs.groupId, params.groupId), + eq(loadJobs.instanceId, instanceId) + ) + ); + await pushJobStatus( + env, + params.libraryId, + await runningStatus(env, params.libraryId) + ); +} + +/** The groups loading, from the rows alone, trusting each to be live. */ +async function runningStatus( + env: AppBindings, + libraryId: LibraryId +): Promise { + const rows = await getDb(env.DB) + .select({ + groupId: loadJobs.groupId, + awaitingApproval: loadJobs.awaitingApproval + }) + .from(loadJobs) + .where(eq(loadJobs.libraryId, libraryId)); + return { + loadingGroupIds: rows + .filter((row) => !row.awaitingApproval) + .map((row) => row.groupId), + awaitingApprovalGroupIds: rows + .filter((row) => row.awaitingApproval) + .map((row) => row.groupId) + }; +} + +/** From inside the load, as it starts and stops waiting for approval. */ +export async function setAwaitingApproval( + env: AppBindings, + params: LoadDocumentParams, + awaitingApproval: boolean +): Promise { + await getDb(env.DB) + .update(loadJobs) + .set({ awaitingApproval }) + .where(eq(loadJobs.groupId, params.groupId)); + await pushJobStatus( + env, + params.libraryId, + await runningStatus(env, params.libraryId) + ); +} + +/** An event sent before a load reaches its wait is kept for it. */ +async function releaseHeldLoads( + env: AppBindings, + jobs: LoadJob[] +): Promise { + await Promise.all( + jobs.map(async (job) => { + if (!job.instanceId) return; + try { + const instance = await env.LOAD_DOCUMENT_WORKFLOW.get( + job.instanceId + ); + await instance.sendEvent({ type: APPROVE_EVENT, payload: {} }); + } catch (error) { + console.error( + `Failed to approve the load of ${job.groupId}`, + error + ); + } + }) + ); + const db = getDb(env.DB); + for (const chunk of chunkForInArray(jobs.map((job) => job.groupId))) { + await db + .update(loadJobs) + .set({ awaitingApproval: false }) + .where(inArray(loadJobs.groupId, chunk)); + } +} + +/** Lets every load the library is holding through. Returns how many there were. */ +export async function approveHeldLoads( + env: AppBindings, + libraryId: LibraryId +): Promise { + const held = await getDb(env.DB) + .select() + .from(loadJobs) + .where( + and( + eq(loadJobs.libraryId, libraryId), + eq(loadJobs.awaitingApproval, true) + ) + ); + await releaseHeldLoads(env, held); + await pushJobStatus(env, libraryId, await runningStatus(env, libraryId)); + return held.length; +} + +/** Also clears rows left by crashed loads. Pushes keep the client current after this. */ +export async function getJobStatus( + env: AppBindings, + libraryId: LibraryId +): Promise { + const jobs = await getDb(env.DB) + .select() + .from(loadJobs) + .where(eq(loadJobs.libraryId, libraryId)); + await clearDead(env, jobs); + return runningStatus(env, libraryId); +} diff --git a/src/backend/features/load/jobs.worker.test.ts b/src/backend/features/load/jobs.worker.test.ts new file mode 100644 index 000000000..849773500 --- /dev/null +++ b/src/backend/features/load/jobs.worker.test.ts @@ -0,0 +1,210 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TEST_LIBRARY_ID, resetDb, seedGroup } from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { groups, loadJobs } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { BuildIssueType } from "../build-checker/issues"; +import * as LibraryDb from "../library/db"; +import { + APPROVE_EVENT, + approveHeldLoads, + finishLoad, + getJobStatus, + setAwaitingApproval, + requestLoads, + type LoadDocumentParams +} from "./jobs"; + +const db = getDb(env.DB); + +function params(groupId: string, forceReload = false): LoadDocumentParams { + return { + libraryId: TEST_LIBRARY_ID, + groupId, + sessionId: "session", + forceReload + }; +} + +/** Every instance reports `status`, as far as the jobs can tell. Returns their controls. */ +function instancesAre(status: InstanceStatus["status"]) { + const instance = { + status: () => Promise.resolve({ status }), + sendEvent: vi.fn().mockResolvedValue(undefined), + terminate: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(env.LOAD_DOCUMENT_WORKFLOW, "get").mockResolvedValue( + instance as never + ); + return instance; +} + +const job = (groupId: string) => + db + .select() + .from(loadJobs) + .all() + .then((rows) => rows.find((row) => row.groupId === groupId)); + +describe("document loads", () => { + beforeEach(async () => { + await resetDb(db); + await seedGroup(db, "a"); + await seedGroup(db, "b"); + }); + afterEach(() => vi.restoreAllMocks()); + + it("starts a load per group, all in one batch", async () => { + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") + .mockResolvedValue([]); + + await requestLoads(env, [params("a"), params("b")]); + + expect(create).toHaveBeenCalledOnce(); + const started = create.mock.calls[0][0]; + expect(started.map((start) => start.params?.groupId)).toEqual([ + "a", + "b" + ]); + expect((await job("a"))?.instanceId).toBe(started[0].id); + instancesAre("running"); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + loadingGroupIds: ["a", "b"], + awaitingApprovalGroupIds: [] + }); + }); + + // Two loads writing one group's rows at once would interleave. + it("replaces the group's running load, which it stops", async () => { + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") + .mockResolvedValue([]); + await requestLoads(env, [params("a")]); + const { terminate } = instancesAre("running"); + + await requestLoads(env, [params("a")]); + + expect(terminate).toHaveBeenCalledOnce(); + const replacement = create.mock.calls[1][0][0]; + expect((await job("a"))?.instanceId).toBe(replacement.id); + }); + + it("keeps a replaced load's force", async () => { + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") + .mockResolvedValue([]); + await requestLoads(env, [params("a", true)]); + instancesAre("running"); + + await requestLoads(env, [params("a", false)]); + + expect(create.mock.calls[1][0][0].params).toMatchObject({ + forceReload: true + }); + expect(await job("a")).toMatchObject({ forceReload: true }); + }); + + it("replaces the row of a load that crashed, and flags its group", async () => { + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") + .mockResolvedValue([]); + await requestLoads(env, [params("a")]); + instancesAre("errored"); + + await requestLoads(env, [params("a")]); + + expect(create).toHaveBeenCalledTimes(2); + const group = await db + .select({ buildIssues: groups.buildIssues }) + .from(groups) + .where(eq(groups.id, "a")) + .get(); + expect(group?.buildIssues).toContainEqual({ + type: BuildIssueType.LOAD_FAILED + }); + }); + + describe("approval", () => { + const held = { ...params("a"), awaitApproval: true }; + + beforeEach(async () => { + vi.spyOn( + env.LOAD_DOCUMENT_WORKFLOW, + "createBatch" + ).mockResolvedValue([]); + await requestLoads(env, [held]); + await setAwaitingApproval(env, held, true); + }); + + it("reports a held load apart from the running ones", async () => { + instancesAre("waiting"); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + loadingGroupIds: [], + awaitingApprovalGroupIds: ["a"] + }); + }); + + it("lets every held load through", async () => { + const { sendEvent } = instancesAre("waiting"); + + expect(await approveHeldLoads(env, TEST_LIBRARY_ID)).toBe(1); + + expect(sendEvent).toHaveBeenCalledWith({ + type: APPROVE_EVENT, + payload: {} + }); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + loadingGroupIds: ["a"], + awaitingApprovalGroupIds: [] + }); + }); + + it("replaces a held load with one someone asked for outright", async () => { + instancesAre("waiting"); + await requestLoads(env, [params("a")]); + expect(await job("a")).toMatchObject({ awaitingApproval: false }); + }); + }); + + describe("finishing", () => { + beforeEach(() => { + vi.spyOn( + env.LOAD_DOCUMENT_WORKFLOW, + "createBatch" + ).mockResolvedValue([]); + }); + + it("leaves the group to the load that replaced it", async () => { + await requestLoads(env, [params("a")]); + const replaced = (await job("a"))?.instanceId ?? ""; + instancesAre("running"); + await requestLoads(env, [params("a")]); + + await finishLoad(env, params("a"), replaced, false); + + expect((await job("a"))?.instanceId).not.toBe(replaced); + }); + + // Each load publishes what it wrote, standing alone. + it("rebuilds search for a load that changed its group", async () => { + const rebuild = vi + .spyOn(LibraryDb, "rebuildSearchDb") + .mockResolvedValue(""); + await requestLoads(env, [params("a"), params("b")]); + const instanceOf = async (groupId: string) => + (await job(groupId))?.instanceId ?? ""; + + await finishLoad(env, params("a"), await instanceOf("a"), true); + expect(rebuild).toHaveBeenCalledOnce(); + + await finishLoad(env, params("b"), await instanceOf("b"), false); + expect(rebuild).toHaveBeenCalledOnce(); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + loadingGroupIds: [], + awaitingApprovalGroupIds: [] + }); + }); + }); +}); diff --git a/src/backend/features/load/load-group.ts b/src/backend/features/load/load-group.ts index 4dcbf092a..aba258962 100644 --- a/src/backend/features/load/load-group.ts +++ b/src/backend/features/load/load-group.ts @@ -3,6 +3,7 @@ import type { BatchItem } from "drizzle-orm/batch"; import { type Db, getDb } from "../../db/client"; import { chunkForInArray } from "../../db/chunk"; import { ElementType } from "../../lib/onshape/element-type"; +import type { DocumentPath } from "../../lib/onshape/path"; import type { ThumbnailUrls } from "../thumbnails/contract"; import { addBuildIssue, @@ -24,9 +25,15 @@ import { type GroupTarget, type InsertableTarget, type LoadContext, + type LoadingGroup, getOnshapeApiFromContext } from "./context"; +import { + deleteStaleThumbnailWorkspaces, + syncThumbnailWorkspace +} from "../thumbnails/workspace"; import { ONSHAPE_STEP_RETRIES, uploadThumbnailsStep } from "./steps"; +import { deleteStaleThumbnails } from "../thumbnails/reconcile"; interface GroupLoadResult { loadedElements: number; @@ -46,17 +53,29 @@ interface ParsedGroup { versionId?: string; /** Moves with `versionId`, so the row's date is always that version's. */ versionCreatedAt?: Date; + /** Moves with `versionId`, which it holds. */ + thumbnailWorkspaceId?: string; } export async function loadGroup( ctx: LoadContext, - target: GroupTarget, + group: GroupTarget, forceReload: boolean ): Promise { - const { groupId, versionPath } = target; + const { groupId, versionPath } = group; + + // Here rather than when resolving, so a skipped group branches nothing. + const thumbnailPath = await ctx.step.do( + `thumbnail-workspace-${groupId}`, + { retries: ONSHAPE_STEP_RETRIES }, + async () => + syncThumbnailWorkspace( + await getOnshapeApiFromContext(ctx), + versionPath + ) + ); + const target: LoadingGroup = { ...group, thumbnailPath }; - // Read once and derive from it: the loadable tabs, and the element the - // group's own thumbnail comes from, which is often not one of them. const contents = await ctx.step.do( `document-contents-${groupId}`, { retries: ONSHAPE_STEP_RETRIES }, @@ -105,6 +124,44 @@ export async function loadGroup( }) ); + // After the save, so the rows name what this load stored. + await ctx.step + .do(`delete-stale-thumbnails-${groupId}`, () => + deleteStaleThumbnails(ctx.env.BLOB, getDb(ctx.env.DB), { + documentId: versionPath.documentId, + elementIds: [ + ...contents.elements.map((element) => element.id), + ...storedInsertables.map((stored) => stored.elementId) + ], + dropRenders: forceReload + }) + ) + .catch((error: unknown) => { + console.error( + `Failed to delete stale thumbnails of ${groupId}`, + error + ); + }); + + // Only once the row names the kept workspace. A leftover is clutter, not + // breakage, so this is never fatal. + if (failedInsertableIds.length === 0) { + await ctx.step + .do(`delete-stale-workspaces-${groupId}`, async () => + deleteStaleThumbnailWorkspaces( + await getOnshapeApiFromContext(ctx), + versionPath, + await namedWorkspaces(getDb(ctx.env.DB), versionPath) + ) + ) + .catch((error: unknown) => { + console.error( + `Failed to delete stale thumbnail workspaces of ${groupId}`, + error + ); + }); + } + return { loadedElements: insertablesToLoad.length - failedInsertableIds.length, deletedElements: removedInsertableIds.length, @@ -112,10 +169,23 @@ export async function loadGroup( }; } +/** The thumbnail workspaces the document's groups name, in every library. */ +async function namedWorkspaces( + db: Db, + document: DocumentPath +): Promise> { + const rows = await db + .select({ workspaceId: groups.thumbnailWorkspaceId }) + .from(groups) + .where(eq(groups.documentId, document.documentId)); + return new Set( + rows.flatMap((row) => (row.workspaceId ? [row.workspaceId] : [])) + ); +} + /** - * Starts every selected insertable at once, returning the ids of the ones that - * failed. `loadInsertable` holds a limiter slot for the part of itself that - * asks Onshape anything, so what runs in parallel here is bounded there. + * Returns the ids that failed. `loadInsertable` takes a limiter slot for its + * Onshape calls, which bounds the parallelism. */ async function loadInsertables( ctx: LoadContext, @@ -127,8 +197,7 @@ async function loadInsertables( try { await loadInsertable(ctx, target); } catch (error) { - // The only record of why: the row stores that it failed, never - // what failed. + // The row records only that it failed, so this is the only record of why. console.error( `Failed to load insertable ${target.insertableId} (${target.name})`, error @@ -140,19 +209,15 @@ async function loadInsertables( return failedInsertableIds; } -/** - * The group's own thumbnail. Which element it comes from is its own question, - * asked here rather than in the renderer, which only renders. - */ async function loadDocumentThumbnail( ctx: LoadContext, - target: GroupTarget, + target: LoadingGroup, contents: OnshapeDocumentContents ): Promise { - const { groupId, versionPath, workspacePath } = target; + const { groupId, thumbnailPath } = target; - // Never fatal: `checkGroup` already flags a missing thumbnail, and failing - // the load over a cosmetic one would lose the group's insertables. + // Not fatal: `checkGroup` flags a missing thumbnail, and failing here would + // lose the group's insertables. const element = documentThumbnailElement(target, contents); if (!element) { return null; @@ -165,19 +230,13 @@ async function loadDocumentThumbnail( uploadThumbnails( ctx.env.BLOB, await getOnshapeApiFromContext(ctx), - { ...versionPath, elementId: element.id }, - { ...workspacePath, elementId: element.id }, + { ...thumbnailPath, elementId: element.id }, element.microversionId ) ); } -/** - * The element a group's thumbnail is taken from: the one the document - * designates, or the first it has. Which element that is, and the document's - * name, both came back with the document when the group was resolved, so this - * asks Onshape nothing. - */ +/** The element the document designates, or its first. */ function documentThumbnailElement( target: GroupTarget, contents: OnshapeDocumentContents @@ -198,13 +257,9 @@ interface SaveGroupInput { failedInsertableIds: string[]; } -/** - * Writes the group row, applies the document's tab order, drops the insertables - * whose tabs are gone, and flags the ones that failed to load. - */ async function saveGroup( db: Db, - target: GroupTarget, + target: LoadingGroup, input: SaveGroupInput ): Promise { const { thumbnailUrls, removedInsertableIds } = input; @@ -220,21 +275,20 @@ async function saveGroup( smallThumbnailUrl: thumbnailUrls?.small ?? null, largeThumbnailUrl: thumbnailUrls?.large ?? null, buildIssues, - // Stamp the successful load; failures never reach here, so a failed - // reload leaves the group's last-good time untouched. + // Failed loads never get here, so they keep the last good time. lastLoadedAt: new Date() }; if (!hasFailedInsertables) { parsed.versionId = target.versionPath.instanceId; parsed.versionCreatedAt = target.versionCreatedAt; + parsed.thumbnailWorkspaceId = target.thumbnailPath.instanceId; } const writes: BatchItem<"sqlite">[] = [ db.update(groups).set(parsed).where(eq(groups.id, target.groupId)) ]; if (!hasFailedInsertables) { - // A skipped tab never reaches saveInsertable, so move the whole group - // forward: the stale id is what insertion and document links use. + // Skipped tabs are never saved, so move the whole group to the new version. writes.push( db .update(insertables) @@ -253,8 +307,7 @@ async function saveGroup( .where(eq(insertables.id, insertableId)) ); } - // Configurations and favorites follow deleted insertables via their - // cascading foreign keys. + // Configurations and favorites cascade. for (const ids of chunkForInArray(removedInsertableIds)) { writes.push(db.delete(insertables).where(inArray(insertables.id, ids))); } @@ -266,15 +319,14 @@ async function saveGroup( } /** - * Keeps the issues the last good load recorded. A brand-new insertable has no - * row yet, so the group's `INSERTABLES_FAILED` covers it instead. + * Keeps the issues the last good load recorded. A new insertable has no row, + * so the group's `INSERTABLES_FAILED` covers it. */ async function flagFailedInsertables( db: Db, failedInsertableIds: string[] ): Promise[]> { - // Chunked: a rate-limited load can fail more insertables at once than one - // statement can bind ids for. + // Chunked: a rate-limited load can fail more ids than one statement binds. const reads = await Promise.all( chunkForInArray(failedInsertableIds).map((ids) => db @@ -299,15 +351,11 @@ async function flagFailedInsertables( ); } -/** - * What an existing insertable row contributes to the reload decision: its id, so - * a reload keeps it, and its microversion, to tell whether it changed. - */ export interface StoredInsertable { id: string; elementId: string; microversionId: string; - /** Read so a row the last load failed on is retried rather than skipped. */ + /** So a row the last load failed on is retried. */ buildIssues: BuildIssue[]; /** Where the row sits now, which is what the tab order is compared against. */ sortOrder: number; @@ -330,16 +378,11 @@ async function fetchStoredInsertables( } /** - * New tabs, stored ones whose microversion changed, and stored ones the last - * load failed on. A stored insertable keeps its id so favorites and links - * survive. - * - * The failed ones are picked up because a failure writes no microversion: the - * tab looks unchanged next time, so matching on it alone would leave a - * transient Onshape failure flagged until someone forced a reload. + * New tabs, changed ones, and ones the last load failed on: a failure writes no + * microversion, so the tab would otherwise look unchanged. */ export function selectInsertablesToLoad( - target: GroupTarget, + target: LoadingGroup, insertableTabs: OnshapeElement[], stored: StoredInsertable[], forceReload: boolean @@ -366,10 +409,7 @@ export function selectInsertablesToLoad( groupId: target.groupId, elementPath: { ...target.versionPath, elementId: tab.id }, versionCreatedAt: target.versionCreatedAt, - elementWorkspacePath: { - ...target.workspacePath, - elementId: tab.id - }, + thumbnailPath: { ...target.thumbnailPath, elementId: tab.id }, // OnshapeElementType and the app ElementType share these values. elementType: tab.elementType as unknown as ElementType, name: tab.name, @@ -380,10 +420,6 @@ export function selectInsertablesToLoad( return insertableTargets; } -/** - * Finds the stored insertables whose tab no longer exists in the document; - * their ids are the rows to delete. - */ export function findRemovedInsertables( insertableTabs: OnshapeElement[], storedInsertables: StoredInsertable[] @@ -395,18 +431,14 @@ export function findRemovedInsertables( } /** A stored insertable's new position in the document's tab order. */ -export interface InsertableOrder { +interface InsertableOrder { insertableId: string; sortOrder: number; } /** - * The stored rows the tab order has moved, with the positions to write. - * - * Reordering tabs changes no microversion, so the moved rows are usually ones - * the load skips entirely: the order has to be written from the tab list rather - * than fall out of saving an insertable. A new row already carries its position - * from `selectInsertablesToLoad`, and a removed one is not in the tab list. + * Stored rows the tab order moved. Reordering changes no microversion, so these + * are usually rows the load skips, and the order is written here instead. */ export function findMovedInsertables( insertableTabs: OnshapeElement[], diff --git a/src/backend/features/load/load-group.test.ts b/src/backend/features/load/load-group.worker.test.ts similarity index 77% rename from src/backend/features/load/load-group.test.ts rename to src/backend/features/load/load-group.worker.test.ts index 33abb378b..361167a07 100644 --- a/src/backend/features/load/load-group.test.ts +++ b/src/backend/features/load/load-group.worker.test.ts @@ -24,10 +24,13 @@ import { } from "./load-group"; import { LOAD_CONCURRENCY, - createLimiter, type GroupTarget, - type LoadContext + type LoadContext, + type LoadingGroup } from "./context"; +import * as WorkspaceEndpoints from "../../lib/onshape/endpoints/workspaces"; +import { thumbnailWorkspaceDescription } from "../thumbnails/workspace"; +import { createLimiter } from "../../lib/limiter"; import * as LoadCommonModule from "./context"; import { FAKE_STEP, @@ -39,8 +42,9 @@ import { seedInsertable, TEST_VERSION_CREATED_AT } from "../../../__test_utils__"; +import { LibraryId } from "../library/library-id"; -const GROUP: GroupTarget = { +const GROUP: LoadingGroup = { libraryId: TEST_LIBRARY_ID, groupId: "group-1", name: "Group", @@ -50,7 +54,7 @@ const GROUP: GroupTarget = { instanceType: "v" }, versionCreatedAt: TEST_VERSION_CREATED_AT, - workspacePath: { + thumbnailPath: { documentId: "doc-1", instanceId: "w-1", instanceType: "w" @@ -212,8 +216,6 @@ describe("findMovedInsertables", () => { expect(moved).toEqual([]); }); - // A new tab is inserted with its position, and a removed one is about to be - // deleted, so neither belongs in the reorder. it("names only stored rows the document still has a tab for", () => { const moved = findMovedInsertables( [tab("new"), tab("e1")], @@ -238,19 +240,21 @@ const LOADED_TARGET: GroupTarget = { instanceId: "v-2", instanceType: "v" }, - versionCreatedAt: LOADED_VERSION_CREATED_AT, - workspacePath: { - documentId: `doc-${TEST_GROUP_ID}`, - instanceId: "w-2", - instanceType: "w" - } + versionCreatedAt: LOADED_VERSION_CREATED_AT +}; + +const WORKSPACE = { + id: "w-thumbnails", + name: "FRCDesignApp Thumbnails (DO NOT EDIT)" }; const CTX: LoadContext = { env, + libraryId: TEST_LIBRARY_ID, sessionId: "test-session", step: FAKE_STEP, - limit: createLimiter(LOAD_CONCURRENCY) + limit: createLimiter(LOAD_CONCURRENCY), + thumbnailLimit: createLimiter(LOAD_CONCURRENCY) }; /** Serves the given tabs as the document's contents, all in one folder. */ @@ -287,14 +291,17 @@ describe("loadGroup", () => { beforeEach(async () => { await resetDb(db); await seedGroup(db); - // The real one reads OAuth tokens out of KV; every Onshape call these - // tests reach is mocked at the endpoint wrapper instead. + // Onshape calls are mocked at the endpoint wrappers instead. vi.spyOn( LoadCommonModule, "getOnshapeApiFromContext" ).mockResolvedValue(MOCK_ONSHAPE_API); // Every part-studio load probes its parts for the open-composite flag. vi.spyOn(PartsEndpoints, "getParts").mockResolvedValue([]); + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([]); + vi.spyOn(WorkspaceEndpoints, "createWorkspace").mockResolvedValue( + WORKSPACE + ); }); afterEach(() => vi.restoreAllMocks()); @@ -310,8 +317,7 @@ describe("loadGroup", () => { expect(result).toMatchObject({ loadedElements: 2, failedElements: 0 }); const groupRow = await readGroup(); expect(groupRow?.versionId).toBe("v-2"); - // The version's date moves with the version, not with the sync: the - // card dates the version, and reloading an old one does not freshen it. + // Dates the version, not the sync. expect(groupRow?.versionCreatedAt).toEqual(LOADED_VERSION_CREATED_AT); expect(groupRow?.name).toBe("Reloaded Group"); expect(groupRow?.lastLoadedAt).toEqual(expect.any(Date)); @@ -326,9 +332,7 @@ describe("loadGroup", () => { } }); - // Every thumbnail gets both: the version is asked first because that is - // what the library shows, and the workspace is where it falls back to. - it("gives every tab's thumbnail a workspace to fall back to", async () => { + it("reads every thumbnail from a workspace made off the version", async () => { mockContents([tab("e1"), tab("e2")]); vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( NO_CONFIGURATION @@ -343,19 +347,102 @@ describe("loadGroup", () => { await loadGroup(CTX, LOADED_TARGET, false); - // (bucket, api, elementPath, elementWorkspacePath, microversionId) + expect(WorkspaceEndpoints.createWorkspace).toHaveBeenCalledWith( + MOCK_ONSHAPE_API, + LOADED_TARGET.versionPath, + expect.objectContaining({ versionId: "v-2" }) + ); for (const elementId of ["e1", "e2"]) { const call = uploaded.mock.calls.find( (args) => args[2].elementId === elementId ); - expect(call?.[2]).toMatchObject({ instanceType: "v", elementId }); - expect(call?.[3]).toMatchObject({ instanceType: "w", elementId }); + expect(call?.[2]).toEqual({ + documentId: `doc-${TEST_GROUP_ID}`, + instanceId: WORKSPACE.id, + instanceType: "w", + elementId + }); } + expect((await readGroup())?.thumbnailWorkspaceId).toBe(WORKSPACE.id); + }); + + it("branches a fresh workspace for a new version, and deletes our others", async () => { + mockContents([tab("e1")]); + vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( + NO_CONFIGURATION + ); + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([ + { id: "main", name: "Main" }, + { + ...WORKSPACE, + id: "w-old", + description: thumbnailWorkspaceDescription("v-1") + }, + // Only shares part of the name. + { id: "w-lookalike", name: "FRCDesignApp Thumbnails" } + ]); + const deleted = vi + .spyOn(WorkspaceEndpoints, "deleteWorkspace") + .mockResolvedValue(); + + await loadGroup(CTX, LOADED_TARGET, false); + + expect(WorkspaceEndpoints.createWorkspace).toHaveBeenCalledWith( + MOCK_ONSHAPE_API, + LOADED_TARGET.versionPath, + expect.objectContaining({ versionId: "v-2" }) + ); + expect(deleted.mock.calls.map((call) => call[2])).toEqual(["w-old"]); + expect((await readGroup())?.thumbnailWorkspaceId).toBe(WORKSPACE.id); + }); + + // Held for approval in one library, the document can be a version behind there. + it("keeps a workspace another library's group still reads from", async () => { + await seedGroup(db, "held-group", LibraryId.FTC_DESIGN_LIB, { + documentId: `doc-${TEST_GROUP_ID}`, + thumbnailWorkspaceId: "w-held" + }); + mockContents([tab("e1")]); + vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( + NO_CONFIGURATION + ); + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([ + { + ...WORKSPACE, + id: "w-held", + description: thumbnailWorkspaceDescription("v-1") + }, + { + ...WORKSPACE, + id: "w-old", + description: thumbnailWorkspaceDescription("v-0") + } + ]); + const deleted = vi + .spyOn(WorkspaceEndpoints, "deleteWorkspace") + .mockResolvedValue(); + + await loadGroup(CTX, LOADED_TARGET, false); + + expect(deleted.mock.calls.map((call) => call[2])).toEqual(["w-old"]); + }); + + // Another group of the document, or a retried step, made it already. + it("reuses the workspace already branched off the version", async () => { + mockContents([tab("e1")]); + vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( + NO_CONFIGURATION + ); + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([ + { ...WORKSPACE, description: thumbnailWorkspaceDescription("v-2") } + ]); + + await loadGroup(CTX, LOADED_TARGET, true); + + expect(WorkspaceEndpoints.createWorkspace).not.toHaveBeenCalled(); + expect((await readGroup())?.thumbnailWorkspaceId).toBe(WORKSPACE.id); }); - // The element a group's thumbnail comes from is often not a loadable tab, - // and which one it is already came back with the document, so resolving it - // should cost nothing. it("takes the group thumbnail from the designated element without re-reading the document", async () => { mockContents([tab("e1"), drawing("cover")]); vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( @@ -375,16 +462,11 @@ describe("loadGroup", () => { const groupCall = uploaded.mock.calls.find( (args) => args[2].elementId === "cover" ); - expect(groupCall?.[2]).toMatchObject({ instanceType: "v" }); - expect(groupCall?.[3]).toMatchObject({ - instanceType: "w", - elementId: "cover" - }); + expect(groupCall?.[2]).toMatchObject({ instanceId: WORKSPACE.id }); expect(document).not.toHaveBeenCalled(); }); - // A skipped tab never reaches saveInsertable, but its version still has to - // move: that id is what insertion and every document link are built from. + // Its id is what inserts and document links are built from. it("advances a skipped insertable's version along with the group's", async () => { mockContents([tab("e1")]); // Same microversion as the tab, so the load skips it entirely. @@ -405,7 +487,6 @@ describe("loadGroup", () => { expect(result).toMatchObject({ loadedElements: 0 }); // Nothing was reloaded... expect(configurationSpy).not.toHaveBeenCalled(); - // ...but it no longer points at the version the group just left. const row = await db .select() .from(insertables) @@ -415,8 +496,7 @@ describe("loadGroup", () => { expect((await readGroup())?.versionId).toBe("v-2"); }); - // The reason the order has to be written from the tab list: moving a tab - // changes no microversion, so every row that moved is one the load skips. + // Moving a tab changes no microversion, so the load skips these rows. it("applies the document's tab order to insertables it did not reload", async () => { mockContents([tab("e2"), tab("e1")]); await seedInsertable(db, { @@ -450,8 +530,6 @@ describe("loadGroup", () => { expect(rows.map((row) => row.elementId)).toEqual(["e2", "e1"]); }); - // A new tab is inserted at its own position, which moves everything below - // it: the rows that shift are saved by the group, not by their own load. it("makes room in the tab order for a newly added tab", async () => { mockContents([tab("new"), tab("e1")]); await seedInsertable(db, { @@ -476,8 +554,7 @@ describe("loadGroup", () => { expect(rows.map((row) => row.sortOrder)).toEqual([0, 1]); }); - // The version is what makes a failure self-healing: leaving it stale is what - // brings the next reload back to retry only the insertable that failed. + // The stale version brings the next reload back to retry it. it("holds the version back and flags the insertable that failed", async () => { mockContents([tab("e1"), tab("e2")]); vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockImplementation( diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index 7240eed29..4187a8fc9 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -4,7 +4,7 @@ import { type Configuration, type PartMetadata, type ConfigurationParameter, - DEFAULT_CONFIGURATION_KEY + type PartialSelection } from "../configurations/contract"; import { addBuildIssue, @@ -28,7 +28,10 @@ import { NO_RECORDS, computeOpenComposite, decideIndexing, - loadConfigurationRecords + indexRecords, + type ConfigurationRecordsResult, + type IndexingSettings, + type ProbeTarget } from "./parse-configuration-records"; import { type InsertableTarget, @@ -37,10 +40,7 @@ import { } from "./context"; import { ONSHAPE_STEP_RETRIES, uploadThumbnailsStep } from "./steps"; -/** - * Exactly the columns a reload overwrites; the rest of the row is identity or - * user-owned. - */ +/** The columns a reload overwrites; the rest is identity or user-owned. */ export interface ParsedInsertable { vendors: Vendor[]; thumbnailUrls: ThumbnailUrls | null; @@ -54,16 +54,11 @@ export interface ParsedInsertable { } /** The user-owned flags that decide how much of a load runs. */ -interface InsertableFlags { +interface InsertableFlags extends IndexingSettings { supportsFasten: boolean; - /** Forces part-number indexing on, overriding the auto heuristic. */ - indexConfigurations: boolean; } -/** - * What a load reads under the limiter: every Onshape call an insertable makes - * except the thumbnail's. - */ +/** Every Onshape call but the thumbnail's, all under the limiter. */ interface ProbedInsertable { vendors: Vendor[]; fastenInfo: FastenInfo | null; @@ -80,16 +75,11 @@ export async function loadInsertable( ctx: LoadContext, target: InsertableTarget ): Promise { - const { insertableId, elementPath } = target; + const { insertableId } = target; - // Bounded, because this is where an insertable's Onshape calls are: an - // indexed element probes once per configuration. + // Limited here since an indexed element probes once per configuration. const probed = await ctx.limit(() => probeInsertable(ctx, target)); - // Fetched here rather than queued: an element's own thumbnail is one - // Onshape already rendered when the document was saved, so reading it - // starts nothing and races nothing. Only a configuration has to queue. - // // Nothing is asked for an empty studio, which renders to nothing at all. const thumbnailUrls = probed.hasParts ? await uploadThumbnailsStep( @@ -99,8 +89,7 @@ export async function loadInsertable( uploadThumbnails( ctx.env.BLOB, await getOnshapeApiFromContext(ctx), - elementPath, - target.elementWorkspacePath, + target.thumbnailPath, target.microversionId ) ) @@ -145,15 +134,9 @@ async function probeInsertable( const parts = await readPartsStep(ctx, target); const { isOpenComposite } = parts; - // An empty studio renders nothing and probes to nothing, so what it raises - // decides how much of the rest of the load is worth running. const hasParts = !hasBuildIssue(parts.buildIssues, BuildIssueType.NO_PARTS); - const indexing = decideIndexing( - target.elementType, - parameters, - flags.indexConfigurations - ); + const indexing = decideIndexing(parameters, flags); const recordsResult = indexing.shouldIndex ? await loadConfigurationRecords( @@ -180,10 +163,7 @@ async function probeInsertable( }; } -/** - * Reads the flags that decide how much of the load runs. A brand-new insertable - * has no row yet, so it gets the same defaults the save writes. - */ +/** A new insertable has no row, so it gets the defaults the save writes. */ function readFlagsStep( ctx: LoadContext, insertableId: string @@ -192,12 +172,19 @@ function readFlagsStep( const row = await getDb(ctx.env.DB) .select({ supportsFasten: insertables.supportsFasten, - indexConfigurations: insertables.indexConfigurations + indexConfigurations: insertables.indexConfigurations, + excludedParameterIds: insertables.excludedParameterIds }) .from(insertables) .where(eq(insertables.id, insertableId)) .get(); - return row ?? { supportsFasten: false, indexConfigurations: false }; + return ( + row ?? { + supportsFasten: false, + indexConfigurations: false, + excludedParameterIds: [] + } + ); }); } @@ -225,10 +212,7 @@ interface PartsSummary { buildIssues: BuildIssue[]; } -/** - * Runs on every load, not just under indexing, so the insert path always asks - * for the right part types. Assemblies have nothing to read, so they skip it. - */ +/** Every load, so inserts always ask for the right part types. */ function readPartsStep( ctx: LoadContext, { insertableId, elementPath, elementType }: InsertableTarget @@ -243,7 +227,7 @@ function readPartsStep( const parts = await getParts( await getOnshapeApiFromContext(ctx), elementPath, - DEFAULT_CONFIGURATION_KEY + {} ); return { isOpenComposite: computeOpenComposite(parts), @@ -271,9 +255,8 @@ function parseFastenInfoStep( } /** - * Everything outside `parsed` is written only on insert, so a reload preserves - * the user's flags. Sort order is seeded here and maintained by the group's save - * instead, which is the only place the document's tab order is known. + * Only `parsed` is overwritten, so a reload keeps the user's flags. Sort order + * is maintained by the group's save, which knows the tab order. */ export async function saveInsertable( db: Db, @@ -306,8 +289,7 @@ export async function saveInsertable( documentId: target.elementPath.documentId, elementId: target.elementPath.elementId, sortOrder: target.sortOrder, - // A new insertable starts hidden with its features off. An existing - // one keeps the user's choices, since `set` omits these. + // Only on insert, so an existing row keeps the user's choices. isVisible: false, supportsFasten: false, indexConfigurations: false, @@ -336,3 +318,25 @@ export async function saveInsertable( await db.batch([insertableWrite, configurationWrite]); } + +/** One durable step per batch. An exhausted batch throws rather than save a partial list. */ +function loadConfigurationRecords( + ctx: LoadContext, + insertableId: string, + target: ProbeTarget, + parameters: ConfigurationParameter[], + configurations: PartialSelection[] +): Promise { + return indexRecords( + () => getOnshapeApiFromContext(ctx), + (name, read) => + ctx.step.do( + `records-${insertableId}-${name}`, + { retries: ONSHAPE_STEP_RETRIES }, + read + ), + target, + parameters, + configurations + ); +} diff --git a/src/backend/features/load/load-insertable.test.ts b/src/backend/features/load/load-insertable.worker.test.ts similarity index 87% rename from src/backend/features/load/load-insertable.test.ts rename to src/backend/features/load/load-insertable.worker.test.ts index dc1436c67..76ceb5ede 100644 --- a/src/backend/features/load/load-insertable.test.ts +++ b/src/backend/features/load/load-insertable.worker.test.ts @@ -7,6 +7,7 @@ import type { PartMetadata } from "../configurations/contract"; import { configurationRecord } from "../../../__test_utils__/configuration-fixtures"; import { FAKE_STEP, + TEST_LIBRARY_ID, MOCK_ONSHAPE_API, TEST_PARAMETERS, TEST_PART_STUDIO_ID, @@ -22,7 +23,8 @@ import * as ConfigurationEndpoints from "../../lib/onshape/endpoints/configurati import * as PartsEndpoints from "../../lib/onshape/endpoints/parts"; import * as ThumbnailStore from "../thumbnails/store"; import { BuildIssueType } from "../build-checker/issues"; -import { createLimiter, LOAD_CONCURRENCY, type LoadContext } from "./context"; +import { LOAD_CONCURRENCY, type LoadContext } from "./context"; +import { createLimiter } from "../../lib/limiter"; import * as LoadContextModule from "./context"; import { loadInsertable, saveInsertable } from "./load-insertable"; @@ -39,8 +41,8 @@ function readInsertable() { const partMetadata = (partNumber?: string): PartMetadata => configurationRecord({ partNumber }); -const record = (partNumber?: string, configurationKey = "") => - configurationRecord({ partNumber, configurationKey }); +const record = (partNumber?: string, values = {}) => + configurationRecord({ partNumber, values }); describe("saveInsertable", () => { beforeEach(async () => { @@ -83,7 +85,6 @@ describe("saveInsertable", () => { }) .where(eq(insertables.id, TEST_PART_STUDIO_ID)); - // A reload finds it renamed and no longer a composite. await saveInsertable( db, insertableTarget({ @@ -108,7 +109,10 @@ describe("saveInsertable", () => { }); it("writes the computed configuration records", async () => { - const records = [record("PN-1", "p=v1"), record("PN-2", "p=v2")]; + const records = [ + record("PN-1", { p: "v1" }), + record("PN-2", { p: "v2" }) + ]; await saveInsertable( db, insertableTarget(), @@ -125,8 +129,7 @@ describe("saveInsertable", () => { expect(config?.records).toEqual(records); }); - // The element's own part number is not a configuration of it, so probing an - // unconfigurable element must not manufacture a configurations row. + // Unconfigurable elements get no configurations row. it("stores part data on the insertable without a configuration row", async () => { await saveInsertable( db, @@ -146,8 +149,7 @@ describe("saveInsertable", () => { expect(await db.select().from(configurations).all()).toHaveLength(0); }); - // features/library/db.ts treats the row's existence as "configurable", so an - // insertable that stops being configurable must lose the row, not blank it. + // `library/db.ts` reads the row's existence as "configurable". it("drops the configuration row when there are no parameters", async () => { await saveInsertable( db, @@ -167,9 +169,11 @@ describe("saveInsertable", () => { const ctx = (): LoadContext => ({ env, + libraryId: TEST_LIBRARY_ID, sessionId: "test-session", step: FAKE_STEP, - limit: createLimiter(LOAD_CONCURRENCY) + limit: createLimiter(LOAD_CONCURRENCY), + thumbnailLimit: createLimiter(LOAD_CONCURRENCY) }); describe("loadInsertable", () => { @@ -192,11 +196,7 @@ describe("loadInsertable", () => { afterEach(() => vi.restoreAllMocks()); - // A render can take half an hour to land. Holding a limiter slot while - // waiting on one stalls every insertable queued behind it, which is most of - // what a slow load spends its time on. - // A thumbnail neither instance will give up is a build issue, not a - // failed insertable: the row is still worth having without a picture. + // A missing thumbnail is a build issue; the row is still worth having. it("records a failed thumbnail rather than failing the insertable", async () => { vi.spyOn(ThumbnailStore, "uploadThumbnails").mockRejectedValue( new Error("no thumbnail anywhere") diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts index 2a02d7030..8f44bc5c8 100644 --- a/src/backend/features/load/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -8,11 +8,10 @@ import type { } from "../../lib/onshape/types"; import { ElementPath } from "../../lib/onshape/path"; import { - DEFAULT_CONFIGURATION_KEY, - Selection, - ConfigurationParameter + type ConfigurationParameter, + type PartialSelection, + type Selection } from "../configurations/contract"; -import { decodeConfiguration } from "../configurations/utils"; import { enumParam, paramsWithConfigs @@ -39,11 +38,11 @@ const CLIENT = {} as OnshapeApi; afterEach(() => vi.restoreAllMocks()); +const NO_SETTINGS = { indexConfigurations: false, excludedParameterIds: [] }; const MANY = [{ type: BuildIssueType.MANUAL_INDEXING_REQUIRED }]; const TOO_MANY = [{ type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED }]; describe("decideIndexing", () => { - // Vendors no longer enter into it: the configuration count is the only gate. it.each([ // Below the auto line it indexes on its own. { configs: 127, force: false, index: true, issues: [] }, @@ -51,15 +50,13 @@ describe("decideIndexing", () => { { configs: 128, force: false, index: false, issues: MANY }, // Enabling it overrides the count, and clears the flag. { configs: 128, force: true, index: true, issues: [] }, - // Past the hard cap there is nothing to enumerate, so enabling it can't - // help — it stays unindexed and flagged either way. + // Past the cap there is nothing to enumerate, forced or not. { configs: 600, force: false, index: false, issues: TOO_MANY }, { configs: 600, force: true, index: false, issues: TOO_MANY } ])("configs=$configs force=$force", ({ configs, force, index, issues }) => { const { shouldIndex, buildIssues } = decideIndexing( - ElementType.PART_STUDIO, paramsWithConfigs(configs), - force + { indexConfigurations: force, excludedParameterIds: [] } ); expect({ shouldIndex, buildIssues }).toEqual({ shouldIndex: index, @@ -67,12 +64,15 @@ describe("decideIndexing", () => { }); }); - // No assembly configures its part properties, so the default probe is the - // whole of it however many combinations the count would have enumerated. - it("probes only the default for an assembly", () => { + it("leaves excluded parameters out of the combinations", () => { + const parameters = [ + enumParam("A", ["a1", "a2"]), + enumParam("B", ["b1", "b2"]) + ]; + const settings = { ...NO_SETTINGS, excludedParameterIds: ["B"] }; expect( - decideIndexing(ElementType.ASSEMBLY, paramsWithConfigs(600), false) - ).toEqual({ shouldIndex: true, buildIssues: [], configurations: [] }); + decideIndexing(parameters, settings).configurations + ).toHaveLength(2); }); }); @@ -94,7 +94,7 @@ describe("parsePartStudioRecord", () => { false ) ).toEqual({ - selection: { size: "L" }, + values: { size: "L" }, partNumber: "217-2600", name: "Bracket", description: "A bracket", @@ -140,7 +140,7 @@ describe("parsePartStudioRecord", () => { true ) ).toEqual({ - selection: { size: "S" }, + values: { size: "S" }, hasMultipleParts: false, // The composite it was expected to resolve to is gone. isOpenComposite: false @@ -149,7 +149,7 @@ describe("parsePartStudioRecord", () => { it("returns an all-null record for an empty response", () => { expect(parsePartStudioRecord([], { A: "a1" }, false)).toEqual({ - selection: { A: "a1" }, + values: { A: "a1" }, hasMultipleParts: false, isOpenComposite: false }); @@ -170,7 +170,7 @@ describe("parseAssemblyRecord", () => { ] }; expect(parseAssemblyRecord(metadata, { q: "1" })).toEqual({ - selection: { q: "1" }, + values: { q: "1" }, partNumber: "AM-1234", name: "Gearbox", description: "A gearbox", @@ -182,27 +182,23 @@ describe("parseAssemblyRecord", () => { }); }); -/** - * Mocks the parts endpoint, deriving a studio's parts from what the probe - * overrode — a key names that alone, the defaults being left out of it. - */ +/** Parts derive from the overrides alone, as Onshape defaults the rest. */ function mockParts(partsFor: (overrides: Selection) => OnshapePart[]) { return vi .spyOn(PartsEndpoints, "getParts") - .mockImplementation((_client, _path, configurationKey) => - Promise.resolve(partsFor(decodeConfiguration(configurationKey))) + .mockImplementation((_client, _path, configuration) => + Promise.resolve(partsFor(configuration)) ); } -/** - * The combinations the load would probe: whole selections, which is what - * `decideIndexing` makes of what enumeration names. - */ +/** The combinations the load would probe, as enumeration names them. */ function probeSelections( - parameters: ConfigurationParameter[], - elementType: ElementType = ElementType.PART_STUDIO -): Selection[] { - return decideIndexing(elementType, parameters, true).configurations; + parameters: ConfigurationParameter[] +): PartialSelection[] { + return decideIndexing(parameters, { + indexConfigurations: true, + excludedParameterIds: [] + }).configurations; } /** Probes an element the way the load does: its own combinations, in full. */ @@ -219,7 +215,7 @@ function probeRecords( isOpenComposite: options.isOpenComposite ?? false }, parameters, - probeSelections(parameters, elementType) + probeSelections(parameters) ); } @@ -232,24 +228,19 @@ describe("parseConfigurationRecords", () => { const result = await probeRecords([enumParam("A", ["a1", "a2"])]); expect(result.buildIssues).toEqual([]); - // "a1" is A's default, so that combination is the element's own probe - // under another name and is not probed again. + // "a1" is A's default, so that combination repeats the default probe. expect(result.partMetadata?.partNumber).toBe("PN-default"); expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a2"]); }); - // A stored record is addressed by its key; carrying the selection it was - // probed with would put a second copy of that in every row. - it("stores the key alone, not the selection behind it", async () => { + it("stores the values each record was probed with", async () => { mockParts(() => [{ partId: "p", partNumber: "PN" }]); const result = await probeRecords([enumParam("A", ["a1", "a2"])]); - expect(result.records).not.toHaveLength(0); - for (const record of result.records) { - expect(record).not.toHaveProperty("selection"); - expect(record.configurationKey).toBe("A=a2"); - } + expect(result.records.map((record) => record.values)).toEqual([ + { A: "a2" } + ]); }); it("fills the vendor Onshape leaves unset, per configuration", async () => { @@ -288,8 +279,7 @@ describe("parseConfigurationRecords", () => { expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a1"]); }); - // The element's own defaults hold, so only the configurations that break - // are at fault, and the first of them is what the build card opens. + // The defaults hold, so only the breaking configurations are at fault. it("blames the configurations that resolve to more than one part", async () => { mockParts((configuration) => configuration.A === "a2" || configuration.A === "a3" @@ -305,14 +295,13 @@ describe("parseConfigurationRecords", () => { expect(result.buildIssues).toEqual([ { type: BuildIssueType.CONFIGURATION_MULTIPLE_PARTS, - configurationKey: "A=a2", + values: { A: "a2" }, configurationCount: 2 } ]); }); - // Every configuration inherits a broken default, so there is nothing - // narrower to blame or to open. + // Every configuration inherits a broken default. it("blames the part itself when its own defaults resolve to more than one part", async () => { mockParts(() => [ { partId: "p1", partNumber: "PN-1" }, @@ -347,14 +336,12 @@ describe("parseConfigurationRecords", () => { expect(result.buildIssues).toEqual([ { type: BuildIssueType.UNSTABLE_COMPOSITE, - configurationKey: "A=a2", + values: { A: "a2" }, configurationCount: 1 } ]); }); - // Past the cap decideIndexing turns indexing off and raises the issue, so - // this only ever runs with nothing to enumerate. it("records just the default when there are no combinations", async () => { const spy = mockParts(() => [ { partId: "p", partNumber: "PN-default" } @@ -385,10 +372,6 @@ describe("parseConfigurationRecords", () => { hasMultipleParts: false, isOpenComposite: false }); - expect(spy).toHaveBeenCalledWith( - CLIENT, - PATH, - DEFAULT_CONFIGURATION_KEY - ); + expect(spy).toHaveBeenCalledWith(CLIENT, PATH, {}); }); }); diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index ee86001bd..34b2ba92f 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -1,18 +1,13 @@ -/** - * Probes an insertable's configurations for the metadata we store. Every probe - * is kept: search dedupes itself, and build checks read the ones it drops. - */ +/** Every probe is kept: search dedupes, and build checks read the duplicates. */ import { OnshapeApi } from "../../lib/onshape/client"; import { parseRecordVendor } from "./parse-vendors"; import { ElementPath } from "../../lib/onshape/path"; import { ElementType } from "../../lib/onshape/element-type"; import { - Selection, - ConfigurationParameter, - DEFAULT_CONFIGURATION_KEY, - PartMetadata, - ConfigurationRecord, - ProbedRecord + type ConfigurationParameter, + type ConfigurationRecord, + type PartialSelection, + type PartMetadata } from "../configurations/contract"; import { addBuildIssue, @@ -25,15 +20,13 @@ import { IndexingBand, isIndexingEnabled } from "../configurations/combinations"; -import { toKey, toSelection } from "../configurations/selection"; +import { onshapeOverrides, toSelection } from "../configurations/selection"; import { getParts } from "../../lib/onshape/endpoints/parts"; import { getElementMetadata } from "../../lib/onshape/endpoints/metadata"; import type { OnshapeMetadataObject, OnshapePart } from "../../lib/onshape/types"; -import { type LoadContext, getOnshapeApiFromContext } from "./context"; -import { ONSHAPE_STEP_RETRIES } from "./steps"; import { clean } from "../../lib/text"; /** Configurations fetched per workflow step. */ @@ -55,10 +48,7 @@ export const NO_RECORDS: ConfigurationRecordsResult = { buildIssues: [] }; -/** - * The issue types indexing owns. A caller merging a fresh result into stored - * issues clears these first, so a resolved issue doesn't stick around. - */ +/** Cleared before merging a fresh result, so a resolved issue doesn't stick. */ export const INDEXING_ISSUE_TYPES = [ BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED, BuildIssueType.MANUAL_INDEXING_REQUIRED, @@ -74,27 +64,25 @@ interface IndexingDecision { /** The limit issues this decision raises, if any. */ buildIssues: BuildIssue[]; /** The combinations to probe, already enumerated by the count. */ - configurations: Selection[]; + configurations: PartialSelection[]; +} + +/** What an admin decided about indexing an insertable. */ +export interface IndexingSettings { + /** Index even past the automatic threshold. */ + indexConfigurations: boolean; + excludedParameterIds: string[]; } /** Past the hard cap forcing it on cannot help, since enumeration stops there. */ export function decideIndexing( - elementType: ElementType, parameters: ConfigurationParameter[], - indexConfigurations: boolean + settings: IndexingSettings ): IndexingDecision { - // No assembly configures its part properties today, so every combination - // would probe back to what the default already says. - if (elementType === ElementType.ASSEMBLY) { - return { shouldIndex: true, buildIssues: [], configurations: [] }; - } - - const counted = countConfigurations(parameters); - const band = counted.band; - // Enumeration names only what varies; every probe past here is a whole - // selection, so nothing downstream has to wonder which it holds. - const configurations = counted.configurations.map((partial) => - toSelection(partial, parameters) + const { indexConfigurations } = settings; + const { band, configurations } = countConfigurations( + parameters, + settings.excludedParameterIds ); const shouldIndex = isIndexingEnabled(band, indexConfigurations); @@ -127,10 +115,7 @@ interface PartsEvaluation { partToUse: OnshapePart | undefined; } -/** - * The one place that reads meaning out of a `/parts` response. A studio holds - * one part; an open composite is the exception, and its constituents are ignored. - */ +/** A studio holds one part; an open composite's constituents are ignored. */ function evaluateParts(parts: OnshapePart[]): PartsEvaluation { const composites = parts.filter((part) => part.bodyType === "composite"); if (parts.length > 1 && composites.length > 0) { @@ -152,28 +137,23 @@ export function computeOpenComposite(parts: OnshapePart[]): boolean { return evaluateParts(parts).isOpenComposite; } -/** - * Reads the studio's single part, or its composite when open. A configuration - * that loses the composite its default has stores no part at all. - */ export function parsePartStudioRecord( parts: OnshapePart[], - selection: Selection, + values: PartialSelection, isOpenComposite: boolean -): ProbedRecord { +): ConfigurationRecord { const evaluation = evaluateParts(parts); - // An element that is an open composite everywhere else has no part to read - // in a configuration that loses it; toResult raises the build issue. + // A configuration that loses the default's composite has no part; toResult flags it. if (isOpenComposite && !evaluation.isOpenComposite) { return { - selection, + values, hasMultipleParts: false, isOpenComposite: false }; } const part = evaluation.partToUse; return { - selection, + values, partNumber: clean(part?.partNumber), name: clean(part?.name), description: clean(part?.description), @@ -207,11 +187,11 @@ function readMetadataValue(value: unknown): string | undefined { /** Builds a record from an assembly's element metadata for one configuration. */ export function parseAssemblyRecord( metadata: OnshapeMetadataObject, - selection: Selection -): ProbedRecord { + values: PartialSelection +): ConfigurationRecord { // An assembly is never a composite, so it reads nothing about one. - const record: ProbedRecord = { - selection, + const record: ConfigurationRecord = { + values, hasMultipleParts: false, isOpenComposite: false }; @@ -225,7 +205,6 @@ export function parseAssemblyRecord( return record; } -/** The element a probe reads, carried together rather than threaded apart. */ export interface ProbeTarget { elementPath: ElementPath; elementType: ElementType; @@ -233,31 +212,29 @@ export interface ProbeTarget { } /** - * How one Onshape read is run. A request awaits it directly; the workflow wraps - * each in a durable step, so a rate-limited retry re-fetches only that batch. - * The client is fetched per read rather than held, since a step that retries - * hours later needs a token that has not expired. + * A load wraps each read in a durable step, so a rate-limited retry refetches + * one batch. The client is fetched per read since a retry hours later needs a + * fresh token. */ type ProbeRunner = ( name: string, - read: () => Promise -) => Promise; + read: () => Promise +) => Promise; -async function indexRecords( +export async function indexRecords( getClient: () => Promise, run: ProbeRunner, target: ProbeTarget, parameters: ConfigurationParameter[], - configurations: Selection[] + configurations: PartialSelection[] ): Promise { - // The element's own defaults, probed as a batch of one so every read the - // runner sees has the same shape. + // A batch of one, so every read has the same shape. const [defaultRecord] = await run("default", async () => fetchBatch(await getClient(), target, parameters, [{}]) ); const batches = planBatches(configurations, parameters); - const batchRecords: ProbedRecord[][] = []; + const batchRecords: ConfigurationRecord[][] = []; for (const [index, batch] of batches.entries()) { batchRecords.push( await run(`batch-${index}`, async () => @@ -273,7 +250,7 @@ export function parseConfigurationRecords( client: OnshapeApi, target: ProbeTarget, parameters: ConfigurationParameter[], - configurations: Selection[] + configurations: PartialSelection[] ): Promise { return indexRecords( () => Promise.resolve(client), @@ -284,75 +261,48 @@ export function parseConfigurationRecords( ); } -/** - * One durable step per batch. An exhausted batch throws rather than saving a - * half-built list. - */ -export function loadConfigurationRecords( - ctx: LoadContext, - insertableId: string, - target: ProbeTarget, - parameters: ConfigurationParameter[], - configurations: Selection[] -): Promise { - return indexRecords( - () => getOnshapeApiFromContext(ctx), - (name, read) => - ctx.step.do( - `records-${insertableId}-${name}`, - { retries: ONSHAPE_STEP_RETRIES }, - read - ), - target, - parameters, - configurations - ); -} - -/** - * Splits the combinations to fetch into batches, minus anything the separate - * default probe already covers. - */ function planBatches( - configurations: Selection[], + configurations: PartialSelection[], parameters: ConfigurationParameter[] -): Selection[][] { - // Canonicalizing to the default means landing on the default probe's record, - // so drop every all-defaults combination, not just the empty one. +): PartialSelection[][] { + // Any all-defaults combination repeats the default probe. const toFetch = configurations.filter( - (selection) => - toKey(selection, parameters) !== DEFAULT_CONFIGURATION_KEY + (values) => Object.keys(overridesOf(values, parameters)).length > 0 ); - const batches: Selection[][] = []; + const batches: PartialSelection[][] = []; for (let i = 0; i < toFetch.length; i += BATCH_SIZE) { batches.push(toFetch.slice(i, i + BATCH_SIZE)); } return batches; } -/** - * Reads the record Onshape reports for an element in a given configuration. - * Asks by key rather than by whole selection, so the probe and the record it is - * stored under name the same thing, and neither carries what it never overrode. - */ +/** What enumerated values change from the element's defaults. */ +function overridesOf( + values: PartialSelection, + parameters: ConfigurationParameter[] +) { + return onshapeOverrides(toSelection(values, parameters), parameters); +} + +/** Reads the record Onshape reports for an element in one configuration. */ async function probeConfiguration( client: OnshapeApi, target: ProbeTarget, parameters: ConfigurationParameter[], - selection: Selection -): Promise { + values: PartialSelection +): Promise { const { elementPath, elementType, isOpenComposite } = target; - const configurationKey = toKey(selection, parameters); + const configuration = overridesOf(values, parameters); if (elementType === ElementType.ASSEMBLY) { return parseAssemblyRecord( - await getElementMetadata(client, elementPath, configurationKey), - selection + await getElementMetadata(client, elementPath, configuration), + values ); } return parsePartStudioRecord( - await getParts(client, elementPath, configurationKey), - selection, + await getParts(client, elementPath, configuration), + values, isOpenComposite ); } @@ -362,12 +312,12 @@ async function fetchBatch( client: OnshapeApi, target: ProbeTarget, parameters: ConfigurationParameter[], - batch: Selection[] -): Promise { - const records: ProbedRecord[] = []; - for (const selection of batch) { + batch: PartialSelection[] +): Promise { + const records: ConfigurationRecord[] = []; + for (const values of batch) { records.push( - await probeConfiguration(client, target, parameters, selection) + await probeConfiguration(client, target, parameters, values) ); } return records; @@ -375,23 +325,21 @@ async function fetchBatch( /** Onshape's vendor when a part carries one, otherwise the parsed one. */ function resolveVendor( - record: ProbedRecord, + record: ConfigurationRecord, parameters: ConfigurationParameter[] ): string | undefined { return ( record.vendor ?? - parseRecordVendor(record.name, record.selection, parameters) + parseRecordVendor(record.name, record.values, parameters) ); } /** Folds the default probe and every batch together, the default first. */ function toResult( - defaultRecord: ProbedRecord, - batches: ProbedRecord[][], + defaultRecord: ConfigurationRecord, + batches: ConfigurationRecord[][], parameters: ConfigurationParameter[] ): ConfigurationRecordsResult { - // The element's own probe describes the element, not a configuration of it, - // so it sheds the (empty) configuration that produced it. const partMetadata: PartMetadata = { partNumber: defaultRecord.partNumber, name: defaultRecord.name, @@ -402,27 +350,16 @@ function toResult( isOpenComposite: defaultRecord.isOpenComposite }; - // Canonical, so a record addresses the thumbnail the insert menu does. The - // selection is dropped rather than stored: the key already says what it held. - const records: ConfigurationRecord[] = batches.flat().map((probe) => { - const { selection, ...record } = probe; - return { - ...record, - // Read before keying, which names only overrides — and so drops a - // default vendor option. - vendor: resolveVendor(probe, parameters), - configurationKey: toKey(selection, parameters) - }; - }); + const records: ConfigurationRecord[] = batches.flat().map((record) => ({ + ...record, + vendor: resolveVendor(record, parameters) + })); - // A capped insertable never reaches here: decideIndexing turns indexing off - // past the cap, and raises CONFIGURATION_LIMIT_EXCEEDED itself. + // Capped insertables never get here; decideIndexing flags those. let buildIssues: BuildIssue[] = []; - // Which probe fails decides whose problem it is. The element's own defaults - // failing is the part being wrong, and every configuration inherits it, so - // there is nothing narrower to report; a configuration failing where the - // defaults hold is that configuration's problem, and can be opened. + // If the defaults fail, every configuration does, so report the part. If only + // a configuration fails, report that configuration. if (partMetadata.hasMultipleParts) { buildIssues = addBuildIssue(buildIssues, { type: BuildIssueType.MULTIPLE_PARTS @@ -440,8 +377,6 @@ function toResult( } } - // The element's own probe sets the expectation; losing the composite in any - // configuration is what makes it unstable. if (partMetadata.isOpenComposite) { const offenders = records.filter((record) => !record.isOpenComposite); if (offenders.length > 0) { diff --git a/src/backend/features/load/parse-configuration.test.ts b/src/backend/features/load/parse-configuration.test.ts index 996291461..6ae862681 100644 --- a/src/backend/features/load/parse-configuration.test.ts +++ b/src/backend/features/load/parse-configuration.test.ts @@ -27,7 +27,6 @@ const RESPONSE: OnshapeConfigurationResponse = { btType: OnshapeParameterType.BOOLEAN, parameterId: "Show_list", parameterName: "Show list", - isCosmetic: true, defaultValue: true, visibilityCondition: NONE }, @@ -35,7 +34,6 @@ const RESPONSE: OnshapeConfigurationResponse = { btType: OnshapeParameterType.ENUM, parameterId: "Vendor", parameterName: "Vendor", - isCosmetic: false, defaultValue: "Default", options: [ { option: "Default", optionName: "WCP" }, @@ -58,7 +56,6 @@ const RESPONSE: OnshapeConfigurationResponse = { btType: OnshapeParameterType.ENUM, parameterId: "List", parameterName: "List", - isCosmetic: false, defaultValue: "WCP_1", options: [ { option: "Default", optionName: "Always shown" }, @@ -87,8 +84,7 @@ const RESPONSE: OnshapeConfigurationResponse = { } ] }, - // A logical wrapper whose only child is the no-op condition, which the - // parser drops — exercising the empty-children path. + // Its only child is a no-op the parser drops. visibilityCondition: { btType: OnshapeVisibilityConditionType.LOGICAL, operation: LogicalOp.AND, @@ -99,7 +95,6 @@ const RESPONSE: OnshapeConfigurationResponse = { btType: OnshapeParameterType.QUANTITY, parameterId: "TTB_Length", parameterName: "TTB Length", - isCosmetic: false, quantityType: QuantityType.LENGTH, rangeAndDefault: { defaultValue: 1, @@ -134,12 +129,11 @@ describe("parseOnshapeConfiguration", () => { ]); }); - it("parses a BOOLEAN parameter and its cosmetic flag", () => { + it("parses a BOOLEAN parameter", () => { expect(parameters[0]).toEqual({ type: ParameterType.BOOLEAN, id: "Show_list", name: "Show list", - isCosmetic: true, default: "true", condition: undefined }); @@ -147,7 +141,6 @@ describe("parseOnshapeConfiguration", () => { it("parses an ENUM parameter with options and a logical condition", () => { const vendor = parameters[1]; - expect(vendor.isCosmetic).toBe(false); if (vendor.type !== ParameterType.ENUM) throw new Error("expected ENUM"); expect(vendor.default).toBe("Default"); @@ -195,8 +188,6 @@ describe("parseOnshapeConfiguration", () => { ]); }); - // A logical left with no children says nothing about when to show the - // parameter, so it is dropped rather than stored as a condition of its own. it("drops a logical condition whose children were all no-ops", () => { expect(parameters[2].condition).toBeUndefined(); }); @@ -205,9 +196,8 @@ describe("parseOnshapeConfiguration", () => { const length = parameters[3]; if (length.type !== ParameterType.QUANTITY) throw new Error("expected QUANTITY"); - // Canonical, like every value it will be compared against; the - // numeric form below is what the input seeds its display from. - expect(length.default).toBe("0.0254 m"); + // In its own unit, as Onshape declares it and a person would type it. + expect(length.default).toBe("1 in"); expect(length.defaultValue).toBe(1); expect(length.min).toBe(0); expect(length.max).toBe(100000); diff --git a/src/backend/features/load/parse-configuration.ts b/src/backend/features/load/parse-configuration.ts index 52bdb7f6c..7eecc6eff 100644 --- a/src/backend/features/load/parse-configuration.ts +++ b/src/backend/features/load/parse-configuration.ts @@ -1,3 +1,4 @@ +import { withRoles } from "../configurations/roles"; import { type EnumOption, type OptionVisibilityCondition, @@ -7,8 +8,7 @@ import { type VisibilityCondition, VisibilityType } from "../configurations/contract"; -import { getUnitDisplayStr } from "../configurations/enums"; -import { canonicalizeValue } from "../configurations/selection"; +import { quantityDefault } from "../configurations/selection"; import { type OnshapeConfigurationResponse, type OnshapeEnumOptionVisibilityConditionList, @@ -33,8 +33,7 @@ function parseVisibilityCondition( (condition): condition is VisibilityCondition => !!condition ); - // Nothing left that says when to show the parameter, so it is not a - // condition: stored as one, an OR of nothing would read as never. + // Stored, an OR of nothing would read as never. if (children.length === 0) { return undefined; } @@ -123,7 +122,6 @@ export function parseOnshapeConfiguration( const base = { id: parameter.parameterId, name: parameter.parameterName, - isCosmetic: parameter.isCosmetic, condition: parseVisibilityCondition(parameter.visibilityCondition) }; @@ -155,29 +153,21 @@ export function parseOnshapeConfiguration( }); } else if (parameter.btType === OnshapeParameterType.QUANTITY) { const range = parameter.rangeAndDefault; - const unit = range.units; - const val = range.defaultValue; - - const abbr = getUnitDisplayStr(unit); - const defaultStr = abbr ? `${val} ${abbr}` : String(val); - - parameters.push({ + const quantity = { ...base, - type: ParameterType.QUANTITY, + type: ParameterType.QUANTITY as const, quantityType: parameter.quantityType, - default: defaultStr, - defaultValue: val, + defaultValue: range.defaultValue, min: range.minValue, max: range.maxValue, - unit + unit: range.units + }; + parameters.push({ + ...quantity, + default: quantityDefault(quantity) }); } } - // Canonical from here on, so a default is spelled the way a chosen value - // is and nothing downstream has to canonicalize one to compare them. - return parameters.map((parameter) => ({ - ...parameter, - default: canonicalizeValue(parameter, parameter.default) - })); + return withRoles(parameters); } diff --git a/src/backend/features/load/parse-document-contents.ts b/src/backend/features/load/parse-document-contents.ts index bbb24503d..873d7e54c 100644 --- a/src/backend/features/load/parse-document-contents.ts +++ b/src/backend/features/load/parse-document-contents.ts @@ -1,6 +1,3 @@ -/** - * Extracts the insertable tabs from a document's contents listing. - */ import { ElementType } from "../../lib/onshape/element-type"; import { type OnshapeDocumentContents, @@ -14,10 +11,7 @@ const VALID_ELEMENT_TYPES = new Set([ ElementType.PART_STUDIO ]); -/** - * Tabs in tab-bar order: `elements` is unordered, so the folder tree defines it. - * Onshape sometimes omits a tab from the tree, so leftovers are appended. - */ +/** In tab-bar order, from the folder tree; tabs Onshape omits from the tree are appended. */ export function parseInsertableTabs( contents: OnshapeDocumentContents ): OnshapeElement[] { diff --git a/src/backend/features/load/parse-vendors.test.ts b/src/backend/features/load/parse-vendors.test.ts index d2c63c95b..1e1c580d3 100644 --- a/src/backend/features/load/parse-vendors.test.ts +++ b/src/backend/features/load/parse-vendors.test.ts @@ -26,8 +26,7 @@ describe("parseNameVendor", () => { expect(parseNameVendor("REDUX module")).toBe(Vendor.REDUX); }); - // FTC vendors spell themselves out in element names rather than using the - // code a part number carries. + // FTC vendors spell out their names. it("detects a vendor written as its whole name", () => { expect(parseNameVendor("goBILDA 5203 Motor")).toBe(Vendor.GB); expect(parseNameVendor("Misumi Extrusion")).toBe(Vendor.MIS); @@ -49,7 +48,6 @@ describe("parseVendors", () => { type: ParameterType.ENUM as const, id: "vendor", name: "Vendor", - isCosmetic: false, default: "wcp", condition: undefined, optionConditions: [], @@ -70,7 +68,6 @@ describe("parseVendors", () => { type: ParameterType.ENUM as const, id: "vendor", name: "Vendor", - isCosmetic: false, default: "am", condition: undefined, optionConditions: [], @@ -87,7 +84,6 @@ describe("parseVendors", () => { type: ParameterType.QUANTITY as const, id: "length", name: "Length", - isCosmetic: false, default: "10 mm", condition: undefined, quantityType: QuantityType.LENGTH, @@ -100,8 +96,6 @@ describe("parseVendors", () => { expect(parseVendors("Generic Part", parameters)).toEqual([]); }); - // Custom marks a part nobody sells, so a missing part number is expected - // rather than a warning. The name is the only thing that sets it. it("reads Custom out of a name, whatever its case", () => { expect(parseVendors("Custom Bracket", [])).toEqual([Vendor.CUSTOM]); expect(parseVendors("CUSTOM gusset", [])).toEqual([Vendor.CUSTOM]); @@ -116,7 +110,6 @@ const vendorParameter = { id: "vendor", name: "Vendor", default: "wcp", - isCosmetic: false, type: ParameterType.ENUM as const, options: [ { id: "wcp", name: "West Coast Products" }, diff --git a/src/backend/features/load/parse-vendors.ts b/src/backend/features/load/parse-vendors.ts index 72a22e829..f2d9c6d15 100644 --- a/src/backend/features/load/parse-vendors.ts +++ b/src/backend/features/load/parse-vendors.ts @@ -2,7 +2,7 @@ import { Vendor, parseVendor } from "../library/vendors"; import { ParameterType, type ConfigurationParameter, - type Selection + type PartialSelection } from "../configurations/contract"; /** A vendor named by one of a text's words, as its code or as its whole name. */ @@ -38,19 +38,15 @@ export function parseVendors( return [...vendors]; } -/** - * The vendor one configuration resolves to. Its selected options name it more - * precisely than the part does, so they are read before the part's own name. - */ +/** The selected options name it more precisely than the part name, so they're read first. */ export function parseRecordVendor( partName: string | undefined, - selection: Selection, + selection: PartialSelection, parameters: ConfigurationParameter[] ): Vendor | undefined { for (const param of parameters) { if (param.type !== ParameterType.ENUM) continue; - // An absent value is the parameter's default, which is what the - // element's own probe — configured with nothing — resolves to. + // Absent means the default, which the element's own probe resolves to. const selected = selection[param.id] ?? param.default; const option = param.options.find((o) => o.id === selected); const vendor = option && parseOptionVendor(option.name); diff --git a/src/backend/features/load/routes.ts b/src/backend/features/load/routes.ts new file mode 100644 index 000000000..0ef1c8dea --- /dev/null +++ b/src/backend/features/load/routes.ts @@ -0,0 +1,104 @@ +import { eq } from "drizzle-orm"; +import * as z from "zod"; +import { getApp } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { groups, libraries } from "../../db/schema"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { validate } from "../../lib/validate"; +import { requireAdminMiddleware, requireOwner } from "../auth/guards"; +import { getSessionId } from "../auth/session"; +import { approveHeldLoads, requestLoads } from "./jobs"; +import type { + ApproveVersionsOut, + ReloadOut, + VersionApprovalOut +} from "./contract"; +import { ensureLibrary } from "../library/db"; + +export const loadRoutes = getApp(); + +const reloadBody = z.object({ + /** Reloads documents whose version has not changed, too. */ + forceReload: z.boolean() +}); + +/** + * POST /api/reload/library/:libraryId: the documents with a new version or a + * failed load, or every one when forced. Forcing spends a lot of the Onshape + * allocation, so it is the owner's alone. + */ +loadRoutes.post( + "/reload" + libraryRoute(), + requireAdminMiddleware, + validate("json", reloadBody), + async (c) => { + const libraryId = getLibraryParam(c); + const { forceReload } = c.req.valid("json"); + if (forceReload) { + await requireOwner(c); + } + const rows = await getDb(c.env.DB) + .select({ groupId: groups.id, libraryId: groups.libraryId }) + .from(groups) + .where(eq(groups.libraryId, libraryId)); + const sessionId = getSessionId(c); + await requestLoads( + c.env, + rows.map((row) => ({ + ...row, + sessionId, + forceReload + })) + ); + return c.json({ documents: rows.length } satisfies ReloadOut); + } +); + +const versionApprovalBody = z.object({ enabled: z.boolean() }); + +/** GET /api/version-approval/library/:libraryId */ +loadRoutes.get( + "/version-approval" + libraryRoute(), + requireAdminMiddleware, + async (c) => { + const library = await getDb(c.env.DB) + .select({ enabled: libraries.approveVersions }) + .from(libraries) + .where(eq(libraries.id, getLibraryParam(c))) + .get(); + return c.json({ + enabled: library?.enabled ?? false + } satisfies VersionApprovalOut); + } +); + +/** POST /api/version-approval/library/:libraryId: turning it off lets held versions through. */ +loadRoutes.post( + "/version-approval" + libraryRoute(), + requireAdminMiddleware, + validate("json", versionApprovalBody), + async (c) => { + const libraryId = getLibraryParam(c); + const { enabled } = c.req.valid("json"); + const db = getDb(c.env.DB); + await ensureLibrary(db, libraryId); + await db + .update(libraries) + .set({ approveVersions: enabled }) + .where(eq(libraries.id, libraryId)); + if (!enabled) { + await approveHeldLoads(c.env, libraryId); + } + return c.json({ enabled } satisfies VersionApprovalOut); + } +); + +/** POST /api/approve-versions/library/:libraryId */ +loadRoutes.post( + "/approve-versions" + libraryRoute(), + requireAdminMiddleware, + async (c) => { + const documents = await approveHeldLoads(c.env, getLibraryParam(c)); + return c.json({ documents } satisfies ApproveVersionsOut); + } +); diff --git a/src/backend/features/load/routes.worker.test.ts b/src/backend/features/load/routes.worker.test.ts new file mode 100644 index 000000000..7b3850498 --- /dev/null +++ b/src/backend/features/load/routes.worker.test.ts @@ -0,0 +1,122 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createTestApp, + jsonRequest, + resetDb, + seedGroup +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { AccessLevel } from "../auth/access-level"; +import { LibraryId } from "../library/library-id"; +import * as Jobs from "./jobs"; + +const db = getDb(env.DB); +const PATH = `/api/reload/library/${LibraryId.FRC_DESIGN_LIB}`; + +function reload(accessLevel: AccessLevel, forceReload: boolean) { + const init = jsonRequest("POST", { forceReload }); + return createTestApp({ accessLevel }).request( + PATH, + { + ...init, + headers: { ...init.headers, Cookie: "frc-design-app-session=s" } + }, + env + ); +} + +describe("reloading a library", () => { + beforeEach(async () => { + await resetDb(db); + await seedGroup(db, "frc", LibraryId.FRC_DESIGN_LIB); + await seedGroup(db, "ftc", LibraryId.FTC_DESIGN_LIB); + }); + afterEach(() => vi.restoreAllMocks()); + + it("reloads the library's outdated documents for an admin", async () => { + const load = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); + + const res = await reload(AccessLevel.ADMIN, false); + + expect(await res.json()).toEqual({ documents: 1 }); + expect(load.mock.calls[0][1]).toEqual([ + expect.objectContaining({ + groupId: "frc", + sessionId: "s", + forceReload: false + }) + ]); + }); + + it("reloads every document for the owner", async () => { + const load = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); + + await reload(AccessLevel.OWNER, true); + + expect(load.mock.calls[0][1]).toEqual([ + expect.objectContaining({ groupId: "frc", forceReload: true }) + ]); + }); + + it("keeps reloading every document to the owner", async () => { + expect((await reload(AccessLevel.ADMIN, true)).status).toBe(403); + }); + + it("turns away an editor", async () => { + expect((await reload(AccessLevel.EDITOR, false)).status).toBe(403); + }); +}); + +describe("approving versions", () => { + const APPROVAL_PATH = `/api/version-approval/library/${LibraryId.FRC_DESIGN_LIB}`; + + function call(method: "GET" | "POST", path: string, body?: object) { + const init = body ? jsonRequest(method, body) : { method }; + return createTestApp({ accessLevel: AccessLevel.ADMIN }).request( + path, + { + ...init, + headers: { + ...("headers" in init ? init.headers : {}), + Cookie: "frc-design-app-session=s" + } + }, + env + ); + } + + beforeEach(async () => { + await resetDb(db); + await seedGroup(db, "frc", LibraryId.FRC_DESIGN_LIB); + }); + afterEach(() => vi.restoreAllMocks()); + + it("is off until an admin turns it on", async () => { + expect(await (await call("GET", APPROVAL_PATH)).json()).toEqual({ + enabled: false + }); + await call("POST", APPROVAL_PATH, { enabled: true }); + expect(await (await call("GET", APPROVAL_PATH)).json()).toEqual({ + enabled: true + }); + }); + + it("lets held versions through when turned off", async () => { + const approve = vi.spyOn(Jobs, "approveHeldLoads").mockResolvedValue(2); + await call("POST", APPROVAL_PATH, { enabled: false }); + expect(approve).toHaveBeenCalledWith( + expect.anything(), + LibraryId.FRC_DESIGN_LIB + ); + }); + + it("approves every held version", async () => { + vi.spyOn(Jobs, "approveHeldLoads").mockResolvedValue(2); + const res = await call( + "POST", + `/api/approve-versions/library/${LibraryId.FRC_DESIGN_LIB}` + ); + expect(await res.json()).toEqual({ documents: 2 }); + }); +}); diff --git a/src/backend/features/load/steps.test.ts b/src/backend/features/load/steps.test.ts index f4269553c..a84b7b99e 100644 --- a/src/backend/features/load/steps.test.ts +++ b/src/backend/features/load/steps.test.ts @@ -7,8 +7,7 @@ const secondsOf = (delay: string) => Number.parseInt(delay, 10); /** The spread `rateLimitDelay` adds on top of what Onshape asked for. */ const JITTER_SECONDS = 20; -// A poll capped at two minutes waited four hours between attempts until these -// were pinned; see CONSTANT_BACKOFF for what the platform was adding. +// Any curve multiplies the delay; see CONSTANT_BACKOFF. describe("every retry config", () => { it("leaves the platform no curve to apply on top", () => { for (const retries of [ONSHAPE_STEP_RETRIES]) { @@ -43,9 +42,7 @@ describe("ONSHAPE_STEP_RETRIES", () => { expect(delay).toBeLessThanOrEqual(7 + JITTER_SECONDS); }); - // What the platform actually hands the callback. Passing the instance above - // is what let an `instanceof` check pass here and fail in production, where - // every 429 fell through to the curve and hammered Onshape six times. + // What Workflows actually passes the callback: a rebuilt Error, not the instance. it("waits out a rate limit Workflows rebuilt as a plain Error", () => { const rebuilt = new Error( new OnshapeRateLimitError("slow down", 450).message diff --git a/src/backend/features/load/steps.ts b/src/backend/features/load/steps.ts index ff4e5a425..51e259ab2 100644 --- a/src/backend/features/load/steps.ts +++ b/src/backend/features/load/steps.ts @@ -4,11 +4,8 @@ import { type ThumbnailUrls } from "../thumbnails/contract"; import type { LoadContext } from "./context"; /** - * Pinned because the platform's curve compounds with the callbacks below: - * `backoff` defaults to exponential and multiplies what `delay` returned, which - * turned the thumbnail poll's capped 120 seconds into 120 × 2^7 — a run waiting - * 4h16m between attempts. Cloudflare documents the two settings separately and, - * as far as I saw, not how they combine, so that is read off a run. + * Workflows multiplies what `delay` returns by the backoff curve, which is + * undocumented; exponential turned a 120s delay into hours. */ const CONSTANT_BACKOFF: WorkflowBackoff = "constant"; @@ -19,35 +16,26 @@ interface RetryDelayInput { } /** - * Spread added on top of Onshape's `Retry-After`. Every step caught in one - * burst is handed the same number to wait, so without this they all wake at the - * same instant and re-send together — the burst that earned the 429. Twenty - * seconds trickles a full set of probe slots back in at a few per second. + * Every step in a 429 burst gets the same `Retry-After`; without jitter they + * all retry at once and trip it again. */ const RATE_LIMIT_JITTER_SECONDS = 20; /** - * How long Onshape asked us to wait plus jitter, or `null` when the error - * wasn't a rate limit. - * - * Read off the message: this runs on an error Workflows rebuilt, which is no - * longer an `OnshapeRateLimitError`, so an `instanceof` here answered false for - * every real 429 and quietly handed back the curve below instead. + * Undefined when the error wasn't a rate limit. Reads the message because + * Workflows rebuilds the error, so `instanceof` fails. */ -function rateLimitDelay(error: Error): `${number} seconds` | null { +export function rateLimitDelay(error: Error): `${number} seconds` | undefined { const retryAfterSeconds = readRetryAfterSeconds(error); - if (retryAfterSeconds === null) { - return null; + if (retryAfterSeconds === undefined) { + return undefined; } // Rounded: Workflows documents whole units, not fractional ones. const jitter = Math.round(Math.random() * RATE_LIMIT_JITTER_SECONDS); return `${retryAfterSeconds + jitter} seconds`; } -/** - * Retry delay honoring Onshape's `Retry-After` on a 429, with an - * exponential-ish fallback for other transient errors. - */ +/** Honors `Retry-After` on a 429; exponential otherwise. */ function onshapeRetryDelay(input: RetryDelayInput): `${number} seconds` { const rateLimited = rateLimitDelay(input.error); if (rateLimited) { @@ -57,35 +45,30 @@ function onshapeRetryDelay(input: RetryDelayInput): `${number} seconds` { return `${seconds} seconds`; } -/** - * Every step that calls Onshape takes this. The platform default would retry - * too, but on its own curve — a 429 carries a `Retry-After` and this is what - * honors it. Five attempts, matching that default rather than shortening it. - */ +/** For every step that calls Onshape, so a 429's `Retry-After` is honored. */ export const ONSHAPE_STEP_RETRIES = { limit: 5, delay: onshapeRetryDelay, backoff: CONSTANT_BACKOFF }; -/** - * Three tries about ten seconds apart, honouring a rate limit when Onshape - * asks for one. The workspace either has the thumbnail or does not; this only - * covers Onshape still writing one out just after a save. - */ +/** A freshly branched workspace takes minutes to render: about 16 minutes in all. */ +const THUMBNAIL_RETRY_SECONDS = [30, 60, 120, 240, 240, 240]; + +function thumbnailRetryDelay(input: RetryDelayInput): `${number} seconds` { + const scheduled = THUMBNAIL_RETRY_SECONDS.at(input.ctx.attempt - 1) ?? 240; + return rateLimitDelay(input.error) ?? `${scheduled} seconds`; +} + const THUMBNAIL_RETRIES = { - limit: 3, - delay: onshapeRetryDelay, + limit: THUMBNAIL_RETRY_SECONDS.length, + delay: thumbnailRetryDelay, backoff: CONSTANT_BACKOFF }; /** - * Fetches an element's thumbnails and returns where they are stored, or `null` - * when neither the version nor the workspace would give one up — which the - * caller records as a build issue rather than failing the whole load. - * - * Bounded by the run's limiter: these are ordinary Onshape reads that start no - * render, so what caps them is the rate limit rather than anything else. + * `null` when Onshape never renders them, which becomes a build issue. The slot + * is taken outside the step so waiting for it doesn't count against its timeout. */ export async function uploadThumbnailsStep( ctx: LoadContext, @@ -93,12 +76,7 @@ export async function uploadThumbnailsStep( upload: () => Promise ): Promise { try { - // Slot first, step inside — the order `loadInsertable` already takes. A - // step's timeout covers its whole callback, so acquiring within one - // counted the wait for a slot against it: under a rate limit these spent - // all ten minutes queued behind probes that were themselves backing off, - // and timed out having asked Onshape for nothing. - return await ctx.limit(() => + return await ctx.thumbnailLimit(() => ctx.step.do(name, { retries: THUMBNAIL_RETRIES }, upload) ); } catch { diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 91ecf104e..9a7ceb99a 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -10,15 +10,17 @@ import type { LibraryId } from "../library/library-id"; import { bumpLibraryVersion, ensureLibrary, - placeNewGroup, - rebuildSearchDb + placeNewGroup } from "../library/db"; import { getDocument } from "../../lib/onshape/endpoints/documents"; import { getLatestVersion } from "../../lib/onshape/endpoints/versions"; import type { InstancePath } from "../../lib/onshape/path"; -import { groups, PLACEHOLDER_VERSION_ID } from "../../db/schema"; import { - addBuildIssue, + groups, + PLACEHOLDER_VERSION_ID, + WebhookSubject +} from "../../db/schema"; +import { type BuildIssue, BuildIssueType, hasBuildIssue @@ -30,160 +32,146 @@ import { createLoadContext, getOnshapeApiFromContext } from "./context"; -import { untrackJob } from "./job-tracker"; +import { + APPROVAL_TIMEOUT, + APPROVE_EVENT, + finishLoad, + setAwaitingApproval, + type LoadDocumentParams +} from "./jobs"; +import { pushLibraryChanged } from "../push/notify"; +import { flagFailedLoads } from "./flag"; import { loadGroup } from "./load-group"; import { ONSHAPE_STEP_RETRIES } from "./steps"; -import { reconcileThumbnails } from "../thumbnails/reconcile"; - -export interface LoadLibraryParams { - libraryId: LibraryId; - sessionId: string; - forceReload?: boolean; -} - -/** The outcome of loading a single group within a run. */ -type GroupResult = - | { groupId: string; status: "skipped" | "failed" } - | { - groupId: string; - status: "created" | "reloaded"; - loadedElements: number; - deletedElements: number; - failedElements: number; - }; +import { ensureWebhook } from "../webhooks/registration"; -/** - * Reloads every group in a library whose document has a new version (or all of - * them, on forceReload), then rebuilds the search index once at the end. - */ -export class LoadLibraryWorkflow extends WorkflowEntrypoint< +/** Loads one group's document when its version moved, or on a forced reload. One per group at a time; see `jobs.ts`. */ +export class LoadDocumentWorkflow extends WorkflowEntrypoint< AppBindings, - LoadLibraryParams + LoadDocumentParams > { async run( - event: WorkflowEvent, + event: WorkflowEvent, step: WorkflowStep - ): Promise { - const { libraryId, sessionId, forceReload = false } = event.payload; - const ctx = createLoadContext(this.env, sessionId, step); - - const storedGroups = await step.do("list-groups", () => - getDb(ctx.env.DB) - .select({ - groupId: groups.id, - documentId: groups.documentId, - versionId: groups.versionId, - buildIssues: groups.buildIssues - }) - .from(groups) - .where(eq(groups.libraryId, libraryId)) + ): Promise { + const params = event.payload; + const ctx = createLoadContext( + this.env, + params.libraryId, + params.sessionId, + step ); + // A throw past loadDocument's own handling may have written too. + let changed = true; + try { + changed = await loadDocument(ctx, params); + } finally { + // Always, so whatever queued behind this load starts. + await step.do("finish", () => + finishLoad(this.env, params, event.instanceId, changed) + ); + } + } +} - const results = await Promise.all( - storedGroups.map(async (storedGroup): Promise => { - const { groupId, documentId } = storedGroup; - try { - const target = await resolveGroupTarget( - ctx, - { libraryId, groupId, documentId }, - `-${groupId}` - ); - if ( - storedGroup.versionId === - target.versionPath.instanceId && - !forceReload && - !hasFailedLoad(storedGroup.buildIssues) - ) { - return { groupId, status: "skipped" }; - } - const loaded = await loadGroup(ctx, target, forceReload); - return { groupId, status: "reloaded", ...loaded }; - } catch (error) { - // The only record of why: the group row stores that it - // failed, never what failed. - console.error(`Failed to load group ${groupId}`, error); - await ctx.step.do(`flag-failed-${groupId}`, () => - flagFailedGroup(ctx.env, groupId) - ); - return { groupId, status: "failed" }; - } +/** Whether it wrote to the group, which a failure does by flagging it. */ +async function loadDocument( + ctx: LoadContext, + params: LoadDocumentParams +): Promise { + const { groupId, libraryId, forceReload } = params; + const stored = await ctx.step.do("read-group", () => + getDb(ctx.env.DB) + .select({ + documentId: groups.documentId, + versionId: groups.versionId, + buildIssues: groups.buildIssues }) + .from(groups) + .where(eq(groups.id, groupId)) + .get() + ); + // Deleted since the load was asked for. + if (!stored) { + return false; + } + + let changed = true; + try { + const target = await resolveGroupTarget(ctx, { + libraryId, + groupId, + documentId: stored.documentId + }); + const isNewVersion = stored.versionId !== target.versionPath.instanceId; + if ( + !isNewVersion && + !forceReload && + !hasFailedLoad(stored.buildIssues) + ) { + changed = false; + } else { + if (isNewVersion && params.awaitApproval && !forceReload) { + await waitForApproval(ctx, params); + } + await loadGroup(ctx, target, forceReload); + } + } catch (error) { + // The row records only that it failed, so this is the only record of why. + console.error(`Failed to load group ${groupId}`, error); + await ctx.step.do("flag-failed", () => + flagFailedLoads(ctx.env, [groupId]) ); + } - await step.do("finalize", () => finalizeLibrary(ctx.env, libraryId)); - // Last, so every group that was going to write rows has. A group that - // failed kept its old rows, so its thumbnails still read as live. - await step.do("reconcile-thumbnails", () => - reconcileThumbnails(ctx.env.BLOB, getDb(ctx.env.DB)) + // After the load, so an unreadable document gets no webhook. Not fatal: the + // next load retries. + try { + await ctx.step.do( + "register-webhook", + { retries: ONSHAPE_STEP_RETRIES }, + async () => + ensureWebhook( + ctx.env, + await getOnshapeApiFromContext(ctx), + WebhookSubject.DOCUMENT, + stored.documentId + ) ); - await step.do("untrack-job", () => - untrackJob(ctx.env, libraryId, event.instanceId) + } catch (error) { + console.error( + `Failed to register a webhook for ${stored.documentId}`, + error ); - - return results; } + return changed; } -export interface AddGroupParams { - /** The new group's id, minted by the route. */ - groupId: string; - documentId: string; - /** The document's name, already fetched by the route. */ - documentName: string; - libraryId: LibraryId; - sessionId: string; - /** An existing group to place the new group after. */ - selectedGroupId?: string; -} - -/** - * Adds an Onshape document to a library by inserting and then loading it. - */ -export class AddGroupWorkflow extends WorkflowEntrypoint< - AppBindings, - AddGroupParams -> { - async run( - event: WorkflowEvent, - step: WorkflowStep - ): Promise { - const params = event.payload; - const ctx = createLoadContext(this.env, params.sessionId, step); - - // Written before anything can fail, so an add that dies partway leaves a - // group the library still shows and an editor can retry or delete. - await step.do("create-shell-group", () => - createShellGroup(ctx.env, params) - ); - - let result: GroupResult; - try { - const target = await resolveGroupTarget(ctx, params, ""); - const loaded = await loadGroup(ctx, target, false); - result = { groupId: params.groupId, status: "created", ...loaded }; - } catch { - await step.do("flag-failed-group", () => - flagFailedGroup(ctx.env, params.groupId) - ); - result = { groupId: params.groupId, status: "failed" }; - } - - await step.do("finalize", () => - finalizeLibrary(ctx.env, params.libraryId) - ); - await step.do("untrack-job", () => - untrackJob(ctx.env, params.libraryId, event.instanceId) - ); - return result; +/** Loads anyway once nobody has approved it for `APPROVAL_TIMEOUT`. */ +async function waitForApproval( + ctx: LoadContext, + params: LoadDocumentParams +): Promise { + await ctx.step.do("hold-for-approval", () => + setAwaitingApproval(ctx.env, params, true) + ); + try { + await ctx.step.waitForEvent("approval", { + type: APPROVE_EVENT, + timeout: APPROVAL_TIMEOUT + }); + } catch { + // Timed out. } + // An approval has cleared it already; a timeout hasn't. + await ctx.step.do("release-approval", () => + setAwaitingApproval(ctx.env, params, false) + ); } /** - * Whether the group's stored issues record a load that did not finish. The - * version alone cannot decide a skip: a failure leaves the row's version where - * it was, so a group that failed while already on the latest version — a forced - * reload, or a blip in the version probe below, which runs even for a group that - * is about to be skipped — would keep its flag until someone forced another. + * A failure leaves the version where it was, so a group that failed on the + * latest version would otherwise be skipped until a forced reload. */ function hasFailedLoad(buildIssues: BuildIssue[]): boolean { return hasBuildIssue( @@ -196,20 +184,18 @@ function hasFailedLoad(buildIssues: BuildIssue[]): boolean { /** Reads the document and its latest version, pinning the group to that version. */ async function resolveGroupTarget( ctx: LoadContext, - ids: { libraryId: LibraryId; groupId: string; documentId: string }, - stepSuffix: string + ids: { libraryId: LibraryId; groupId: string; documentId: string } ): Promise { const { documentId } = ids; const document = await ctx.step.do( - `document${stepSuffix}`, + "document", { retries: ONSHAPE_STEP_RETRIES }, async () => getDocument(await getOnshapeApiFromContext(ctx), { documentId }) ); - // The step hands back what Onshape sent, `createdAt` still an ISO string: - // a step's result is persisted for replay, which a Date does not survive. + // `createdAt` stays a string: step results are persisted, and a Date isn't. const version = await ctx.step.do( - `version${stepSuffix}`, + "version", { retries: ONSHAPE_STEP_RETRIES }, async () => getLatestVersion(await getOnshapeApiFromContext(ctx), { @@ -222,34 +208,30 @@ async function resolveGroupTarget( instanceId: version.id, instanceType: "v" }; - // Thrown rather than defaulted: every thumbnail in the group is read from - // this workspace, so guessing one would quietly load the wrong document. - if (!document.defaultWorkspace) { - throw new Error(`Document ${documentId} reports no default workspace`); - } - const workspacePath: InstancePath = { - documentId, - instanceId: document.defaultWorkspace.id, - instanceType: "w" - }; return { libraryId: ids.libraryId, groupId: ids.groupId, versionPath, versionCreatedAt: new Date(version.createdAt), - workspacePath, name: document.name, thumbnailElementId: document.documentThumbnailElementId }; } -/** - * Writes the group row the load then fills in, creating the library if this is - * its first groups. Exported for its tests. - */ +export interface ShellGroup { + groupId: string; + documentId: string; + /** The document's name, already fetched by the route. */ + documentName: string; + libraryId: LibraryId; + /** An existing group to place the new group after. */ + selectedGroupId?: string; +} + +/** Written before the load, so a failed add still leaves a group to retry or delete. */ export async function createShellGroup( env: AppBindings, - params: AddGroupParams + params: ShellGroup ): Promise { const db = getDb(env.DB); await ensureLibrary(db, params.libraryId); @@ -270,46 +252,8 @@ export async function createShellGroup( sortOrder }) .onConflictDoNothing(); - // Without this the row is unreachable until the load finishes: every - // library response is pinned to the version, immutably. No search rebuild - // to go with it, since buildSearchDb indexes insertables and the shell has - // none — the index the old version served is still right for the new one. + // Library responses are pinned to the version, so the row is unreachable until + // it bumps. The search index is unaffected: the shell has no insertables. await bumpLibraryVersion(db, params.libraryId); -} - -/** - * Records the failure on the group row, so the library flags it rather than - * showing an empty group. A later successful load recomputes the issues afresh. - */ -async function flagFailedGroup( - env: AppBindings, - groupId: string -): Promise { - const db = getDb(env.DB); - const row = await db - .select({ buildIssues: groups.buildIssues }) - .from(groups) - .where(eq(groups.id, groupId)) - .get(); - if (!row) { - return; - } - await db - .update(groups) - .set({ - buildIssues: addBuildIssue(row.buildIssues, { - type: BuildIssueType.LOAD_FAILED - }) - }) - .where(eq(groups.id, groupId)); -} - -/** Rebuild the library's search index and bump its cache version. */ -async function finalizeLibrary( - env: AppBindings, - libraryId: LibraryId -): Promise { - const db = getDb(env.DB); - await rebuildSearchDb(env.BLOB, db, libraryId); - await bumpLibraryVersion(db, libraryId); + await pushLibraryChanged(env, params.libraryId); } diff --git a/src/backend/features/load/workflows.test.ts b/src/backend/features/load/workflows.worker.test.ts similarity index 83% rename from src/backend/features/load/workflows.test.ts rename to src/backend/features/load/workflows.worker.test.ts index 708bafe6d..ab372ec15 100644 --- a/src/backend/features/load/workflows.test.ts +++ b/src/backend/features/load/workflows.worker.test.ts @@ -9,16 +9,15 @@ import { resetDb, seedGroup } from "../../../__test_utils__"; -import { createShellGroup, type AddGroupParams } from "./workflows"; +import { createShellGroup, type ShellGroup } from "./workflows"; const db = getDb(env.DB); -const PARAMS: AddGroupParams = { +const PARAMS: ShellGroup = { groupId: "new-group", documentId: "doc-new", documentName: "New Doc", - libraryId: TEST_LIBRARY_ID, - sessionId: "test-session" + libraryId: TEST_LIBRARY_ID }; function readVersion(): Promise { @@ -57,9 +56,8 @@ describe("createShellGroup", () => { expect(shell.lastLoadedAt).toBeNull(); }); - // The row lands before the load runs, but every library response is pinned - // to the cache version, so without a bump nothing can fetch the group until - // the load finishes — hours, for a large document. + // Library responses are pinned to the version, so without a bump the group + // is unreachable until the load finishes. it("bumps the library version, so the group is reachable", async () => { await seedGroup(db); const startVersion = await readVersion(); diff --git a/src/backend/features/push/contract.ts b/src/backend/features/push/contract.ts new file mode 100644 index 000000000..f244bedb7 --- /dev/null +++ b/src/backend/features/push/contract.ts @@ -0,0 +1,37 @@ +/** Nothing private: a push says what changed, and the client refetches under its own access. */ +import type { LibraryId } from "../library/library-id"; +import type { JobStatus } from "../load/contract"; +import type { ConfigurationKey } from "../configurations/contract"; + +export enum PushType { + JOBS = "jobs", + LIBRARY = "library", + THUMBNAIL = "thumbnail" +} + +/** A library's load jobs started or finished. */ +export interface JobsPush { + type: PushType.JOBS; + libraryId: LibraryId; + status: JobStatus; +} + +/** A library's contents or admin team changed. */ +export interface LibraryPush { + type: PushType.LIBRARY; + libraryId: LibraryId; +} + +/** A configuration's thumbnail finished rendering. */ +export interface ThumbnailPush { + type: PushType.THUMBNAIL; + elementId: string; + microversionId: string; + configurationKey: ConfigurationKey; +} + +export type PushMessage = JobsPush | LibraryPush | ThumbnailPush; + +/** Under `/api`. A client connects here naming the library it is showing. */ +export const PUSH_ROUTE = "/push"; +export const PUSH_LIBRARY_PARAM = "library"; diff --git a/src/backend/features/push/notify.ts b/src/backend/features/push/notify.ts new file mode 100644 index 000000000..1a94a83a5 --- /dev/null +++ b/src/backend/features/push/notify.ts @@ -0,0 +1,41 @@ +/** A failed push is logged, not thrown: a client that missed it resyncs on reconnect. */ +import type { AppBindings } from "../../lib/context"; +import type { LibraryId } from "../library/library-id"; +import type { JobStatus } from "../load/contract"; +import { type PushMessage, PushType, type ThumbnailPush } from "./contract"; +import { getPushHub } from "./push-hub"; + +async function broadcast( + env: AppBindings, + message: PushMessage +): Promise { + try { + await getPushHub(env).broadcast(message); + } catch (error) { + console.error(`Failed to push ${message.type}`, error); + } +} + +/** A library's jobs as they now stand, after one started or finished. */ +export function pushJobStatus( + env: AppBindings, + libraryId: LibraryId, + status: JobStatus +): Promise { + return broadcast(env, { type: PushType.JOBS, libraryId, status }); +} + +/** Tells a library's viewers to move to its new cache version. */ +export function pushLibraryChanged( + env: AppBindings, + libraryId: LibraryId +): Promise { + return broadcast(env, { type: PushType.LIBRARY, libraryId }); +} + +export function pushThumbnailRendered( + env: AppBindings, + thumbnail: Omit +): Promise { + return broadcast(env, { type: PushType.THUMBNAIL, ...thumbnail }); +} diff --git a/src/backend/features/push/push-hub.ts b/src/backend/features/push/push-hub.ts new file mode 100644 index 000000000..23927b8d1 --- /dev/null +++ b/src/backend/features/push/push-hub.ts @@ -0,0 +1,46 @@ +/** + * Holds every client's WebSocket and relays pushes. One instance for the app, + * since pushes are few and small. Sockets hibernate, so idle clients cost + * nothing, and are tagged with the library they show. + */ +import { DurableObject } from "cloudflare:workers"; +import type { AppBindings } from "../../lib/context"; +import { LibraryId } from "../library/library-id"; +import { PUSH_LIBRARY_PARAM, type PushMessage, PushType } from "./contract"; + +export class PushHub extends DurableObject { + fetch(request: Request): Response { + const libraryId = new URL(request.url).searchParams.get( + PUSH_LIBRARY_PARAM + ); + const [client, server] = Object.values(new WebSocketPair()); + this.ctx.acceptWebSocket( + server, + isLibraryId(libraryId) ? [libraryId] : [] + ); + return new Response(null, { status: 101, webSocket: client }); + } + + /** A library's message to the clients showing it; any other to every client. */ + broadcast(message: PushMessage): void { + const tag = + message.type === PushType.THUMBNAIL ? undefined : message.libraryId; + const data = JSON.stringify(message); + for (const socket of this.ctx.getWebSockets(tag)) { + try { + socket.send(data); + } catch { + // Closing already; its client reconnects and resyncs. + } + } + } +} + +function isLibraryId(value: string | null): value is LibraryId { + return Object.values(LibraryId).includes(value); +} + +/** The one instance every client connects to. */ +export function getPushHub(env: AppBindings) { + return env.PUSH_HUB.getByName("all"); +} diff --git a/src/backend/features/push/push-hub.worker.test.ts b/src/backend/features/push/push-hub.worker.test.ts new file mode 100644 index 000000000..28eec7163 --- /dev/null +++ b/src/backend/features/push/push-hub.worker.test.ts @@ -0,0 +1,72 @@ +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { createTestApp } from "../../../__test_utils__"; +import { LibraryId } from "../library/library-id"; +import { PUSH_ROUTE, type PushMessage, PushType } from "./contract"; +import { getPushHub } from "./push-hub"; + +/** A client connected for `libraryId`, collecting what it is sent. */ +async function connect(libraryId: LibraryId) { + const res = await createTestApp().request( + `/api${PUSH_ROUTE}?library=${libraryId}`, + { headers: { Upgrade: "websocket" } }, + env + ); + expect(res.status).toBe(101); + const socket = res.webSocket; + if (!socket) { + throw new Error("No WebSocket in the upgrade response"); + } + socket.accept(); + const received: PushMessage[] = []; + socket.addEventListener("message", (event) => { + received.push(JSON.parse(event.data as string) as PushMessage); + }); + return { socket, received }; +} + +/** Lets a message cross from the Durable Object to its sockets. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +describe("the push hub", () => { + it("wants a WebSocket upgrade", async () => { + const res = await createTestApp().request(`/api${PUSH_ROUTE}`, {}, env); + expect(res.status).toBe(426); + }); + + it("sends a library's messages to its viewers alone", async () => { + const frc = await connect(LibraryId.FRC_DESIGN_LIB); + const ftc = await connect(LibraryId.FTC_DESIGN_LIB); + const message: PushMessage = { + type: PushType.LIBRARY, + libraryId: LibraryId.FRC_DESIGN_LIB + }; + + await getPushHub(env).broadcast(message); + await settle(); + + expect(frc.received).toEqual([message]); + expect(ftc.received).toEqual([]); + frc.socket.close(); + ftc.socket.close(); + }); + + it("sends a message for no library to everyone", async () => { + const frc = await connect(LibraryId.FRC_DESIGN_LIB); + const ftc = await connect(LibraryId.FTC_DESIGN_LIB); + + const message: PushMessage = { + type: PushType.THUMBNAIL, + elementId: "e1", + microversionId: "mv1", + configurationKey: "" + }; + await getPushHub(env).broadcast(message); + await settle(); + + expect(frc.received).toEqual([message]); + expect(ftc.received).toEqual([message]); + frc.socket.close(); + ftc.socket.close(); + }); +}); diff --git a/src/backend/features/push/routes.ts b/src/backend/features/push/routes.ts new file mode 100644 index 000000000..08e89a7e0 --- /dev/null +++ b/src/backend/features/push/routes.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from "http-status-ts"; +import { getApp } from "../../lib/context"; +import { handledError } from "../../lib/api-error"; +import { PUSH_ROUTE } from "./contract"; +import { getPushHub } from "./push-hub"; + +export const pushRoutes = getApp(); + +/** GET /api/push?library=: open to anyone, since nothing pushed is private. */ +pushRoutes.get(PUSH_ROUTE, (c) => { + if (c.req.header("Upgrade") !== "websocket") { + throw handledError( + "Expected a WebSocket upgrade", + HttpStatus.UPGRADE_REQUIRED + ); + } + return getPushHub(c.env).fetch(c.req.raw); +}); diff --git a/src/backend/features/search/build.test.ts b/src/backend/features/search/build.test.ts index affa18077..a61da9c3b 100644 --- a/src/backend/features/search/build.test.ts +++ b/src/backend/features/search/build.test.ts @@ -1,14 +1,22 @@ import { describe, expect, it } from "vitest"; import { buildSearchDb } from "./build"; -import { toSearchRecords } from "./records"; +import { distinctRecords, toSearchRecords } from "./records"; import { LibraryOut } from "../library/contract"; import { ElementType } from "../../lib/onshape/element-type"; import { Vendor } from "../library/vendors"; -import { configurationRecord as record } from "../../../__test_utils__/configuration-fixtures"; +import { + enumParam, + configurationRecord as record +} from "../../../__test_utils__/configuration-fixtures"; +import { type ConfigurationRecord } from "../configurations/contract"; + +/** Records of an element with nothing to configure. */ +const unconfigured = (records: ConfigurationRecord[], vendors?: Vendor[]) => + toSearchRecords(records, [], vendors); describe("toSearchRecords", () => { it("drops a part number that only repeats the name", () => { - const [result] = toSearchRecords([ + const [result] = unconfigured([ record({ partNumber: "Spacer", name: "Spacer" }) ]); expect(result.partNumber).toBeUndefined(); @@ -16,14 +24,14 @@ describe("toSearchRecords", () => { }); it("ignores case and surrounding space when comparing the two", () => { - const [result] = toSearchRecords([ + const [result] = unconfigured([ record({ partNumber: " spacer ", name: "Spacer" }) ]); expect(result.partNumber).toBeUndefined(); }); it("will not link a repeated part number to a vendor", () => { - const [result] = toSearchRecords( + const [result] = unconfigured( [record({ partNumber: "Bearing", name: "Bearing" })], [Vendor.WCP] ); @@ -31,7 +39,7 @@ describe("toSearchRecords", () => { }); it("keeps a part number that says something the name does not", () => { - const [result] = toSearchRecords( + const [result] = unconfigured( [record({ partNumber: "WCP-1025", name: "Gearbox" })], [Vendor.WCP] ); @@ -41,25 +49,23 @@ describe("toSearchRecords", () => { it("keeps a record that is left with only a name", () => { expect( - toSearchRecords([record({ partNumber: "Spacer", name: "Spacer" })]) + unconfigured([record({ partNumber: "Spacer", name: "Spacer" })]) ).toHaveLength(1); }); it("drops a record with neither", () => { - expect(toSearchRecords([record({})])).toHaveLength(0); + expect(unconfigured([record({})])).toHaveLength(0); }); - // The placeholder an admin writes in identifies nothing, so it is dropped - // here rather than indexed and shown. it("drops a placeholder part number", () => { - const [result] = toSearchRecords([ + const [result] = unconfigured([ record({ partNumber: "N/A", name: "Spacer" }) ]); expect(result).toMatchObject({ partNumber: undefined, name: "Spacer" }); }); it("will not link a placeholder to a vendor", () => { - const [result] = toSearchRecords( + const [result] = unconfigured( [record({ partNumber: "N/A", name: "Spacer" })], [Vendor.WCP] ); @@ -67,16 +73,34 @@ describe("toSearchRecords", () => { }); it("drops a record the placeholder leaves with nothing", () => { - expect(toSearchRecords([record({ partNumber: "N/A" })])).toEqual([]); + expect(unconfigured([record({ partNumber: "N/A" })])).toEqual([]); }); + // Only thumbnails read the key; the values are what open the menu. + it("carries a record's values, and their key for its thumbnail", () => { + const size = enumParam("size", ["s", "l"]); + const [result] = toSearchRecords( + [record({ name: "Gear", values: { size: "l" } })], + [size] + ); + expect(result).toMatchObject({ + values: { size: "l" }, + configurationKey: "size=l" + }); + }); +}); + +describe("distinctRecords", () => { it("keeps the first of a repeated (number, name)", () => { - expect( - toSearchRecords([ - record({ partNumber: "WCP-1025", name: "Gear" }), - record({ partNumber: "WCP-1025", name: "Gear" }) - ]) - ).toHaveLength(1); + const [first, second] = unconfigured([ + record({ + partNumber: "WCP-1025", + name: "Gear", + values: { a: "1" } + }), + record({ partNumber: "WCP-1025", name: "Gear", values: { a: "2" } }) + ]); + expect(distinctRecords([first, second])).toEqual([first]); }); }); @@ -125,13 +149,7 @@ describe("buildSearchDb", () => { it("keeps a placeholder part number out of the index and the records", () => { const db = buildSearchDb(library("Spacer"), { - i1: [ - record({ - partNumber: "N/A", - name: "Spacer", - configurationKey: "" - }) - ] + i1: unconfigured([record({ partNumber: "N/A", name: "Spacer" })]) }); expect(db.search("n/a")).toEqual([]); expect(stored(db).records).toEqual([ @@ -142,14 +160,16 @@ describe("buildSearchDb", () => { // The vendor is a resolution fallback, not something to match against. it("never searches the vendor", () => { const db = buildSearchDb(library("Spacer", [Vendor.WCP]), { - i1: [ - record({ - partNumber: "WCP-1025", - name: "Spacer", - vendor: "WestCoast Products", - configurationKey: "" - }) - ] + i1: unconfigured( + [ + record({ + partNumber: "WCP-1025", + name: "Spacer", + vendor: "WestCoast Products" + }) + ], + [Vendor.WCP] + ) }); expect(db.search("westcoast")).toEqual([]); expect(stored(db).records).toEqual([ diff --git a/src/backend/features/search/build.ts b/src/backend/features/search/build.ts index 97a882fef..e361f20e7 100644 --- a/src/backend/features/search/build.ts +++ b/src/backend/features/search/build.ts @@ -1,12 +1,9 @@ -/** - * Builds the index a library is served as. Worker-side: it reads the whole - * library, which the client never holds. - */ +/** Server-side, since it reads the whole library. */ import MiniSearch from "minisearch"; import { LibraryOut } from "../library/contract"; -import { ConfigurationRecord, SearchRecord } from "../configurations/contract"; +import { type SearchRecord } from "../configurations/contract"; import { SEARCH_OPTIONS, type SearchDocument } from "./contract"; -import { toSearchRecords } from "./records"; +import { distinctRecords } from "./records"; /** Joins the distinct non-null values with spaces (a searchable field's form). */ function uniqueJoin(values: (string | undefined)[]): string { @@ -17,7 +14,8 @@ function uniqueJoin(values: (string | undefined)[]): string { export function buildSearchDb( libraryData: LibraryOut, - recordsMap: Record = {} + /** Each insertable's records, by id; see `searchRecordsOf`. */ + searchRecords: Record = {} ): MiniSearch { const searchDb = new MiniSearch(SEARCH_OPTIONS); @@ -27,10 +25,7 @@ export function buildSearchDb( .filter((element) => !!element) .map((element) => { const parentGroup = libraryData.groups[element.groupId]; - const records = toSearchRecords( - recordsMap[element.id] ?? [], - element.vendors - ); + const records = distinctRecords(searchRecords[element.id] ?? []); return { id: element.id, groupId: element.groupId, @@ -39,11 +34,9 @@ export function buildSearchDb( name: element.name, groupName: parentGroup.name, partNumbers: uniqueJoin( - records.map((record: SearchRecord) => record.partNumber) - ), - partNames: uniqueJoin( - records.map((record: SearchRecord) => record.name) + records.map((record) => record.partNumber) ), + partNames: uniqueJoin(records.map((record) => record.name)), records }; }); diff --git a/src/backend/features/search/contract.ts b/src/backend/features/search/contract.ts index c4dc5144d..fc6b90c82 100644 --- a/src/backend/features/search/contract.ts +++ b/src/backend/features/search/contract.ts @@ -1,12 +1,8 @@ -/** - * What the index holds and how it is queried. The backend builds the index with - * these options and the frontend deserializes it with the same ones, so they - * are the one thing both ends must agree on exactly. - */ +/** The index options, shared so the backend and frontend agree exactly. */ import { Options } from "minisearch"; import { Vendor } from "../library/vendors"; import { SearchRecord } from "../configurations/contract"; -import { processTerm, tokenize } from "./tokenize"; +import { tokenize } from "./tokenize"; import { GROUP_NAME_FIELD, NAME_FIELD, @@ -21,26 +17,19 @@ export interface SearchDocument { vendors: Vendor[]; name: string; groupName: string; - // Space-joined, deduped part numbers (a searchable field); empty when the - // insertable has no indexed part numbers. + // Space-joined and deduped; empty when none are indexed. partNumbers: string; - // Space-joined, deduped configuration (part) names (a searchable field); - // empty when the insertable has no indexed records. + // Space-joined and deduped; empty when none are indexed. partNames: string; - // Stored, not indexed: picks the best-matching configuration for a hit and - // launches it in the insert menu. + // Stored, not indexed: picks a hit's configuration for the insert menu. records: SearchRecord[]; } /** What the insertable itself is called, and where it lives. */ export const INSERTABLE_FIELDS = [NAME_FIELD, GROUP_NAME_FIELD]; -/** - * What its individual configurations are called and numbered. Separated so a - * surface can leave them out: they describe every configuration at once, which - * a list showing one specific configuration has no way to represent. - */ -export const CONFIGURATION_FIELDS = [PART_NUMBER_FIELD, PART_NAME_FIELD]; +/** Separate so a surface showing one configuration can leave them out. */ +const CONFIGURATION_FIELDS = [PART_NUMBER_FIELD, PART_NAME_FIELD]; export const SEARCH_OPTIONS: Options = { fields: [...INSERTABLE_FIELDS, ...CONFIGURATION_FIELDS], @@ -54,12 +43,11 @@ export const SEARCH_OPTIONS: Options = { "records" ], searchOptions: { - // The insertable's own title leads; part names and the group name are - // weaker signals, so a title match outranks them. + // The title outranks part names and the group name. boost: { partNames: 0.7, groupName: 0.5 }, prefix: true }, - // Custom tokenizer to split on special characters tokenize, - processTerm + // Terms come out of `tokenize` already lowercased and split. + processTerm: (term) => term }; diff --git a/src/backend/features/search/fields.ts b/src/backend/features/search/fields.ts index 7503bea7c..5214a26ed 100644 --- a/src/backend/features/search/fields.ts +++ b/src/backend/features/search/fields.ts @@ -1,8 +1,4 @@ -/** - * The indexed field names, named once. `tokenize` splits by field, `contract` - * groups them, and `records` reads which ones a hit matched — so a bare string - * in any of the three has to agree with the other two. - */ +/** Named once, since `tokenize`, `contract` and `records` must agree. */ /** The insertable's own title. */ export const NAME_FIELD = "name"; diff --git a/src/backend/features/search/records.ts b/src/backend/features/search/records.ts index 3d1bf990e..e7f53f45f 100644 --- a/src/backend/features/search/records.ts +++ b/src/backend/features/search/records.ts @@ -1,35 +1,25 @@ -/** - * Which configuration a hit names. Kept beside the tokenizers it scores with, - * and MiniSearch-free: the caller says which fields matched, so the same - * scoring can answer for anything holding records. - */ +/** Picks which configuration a hit names. MiniSearch-free, so any caller holding records can use it. */ import { + type ConfigurationParameter, type ConfigurationRecord, - DEFAULT_CONFIGURATION_KEY, - SearchRecord + type PartMetadata, + type SearchRecord } from "../configurations/contract"; +import { toKey, toSelection } from "../configurations/selection"; import { getPartUrl } from "../configurations/utils"; import { meaningfulPartNumber } from "../configurations/part-number"; import { Vendor } from "../library/vendors"; import { clean } from "../../lib/text"; -import { - normalizeForMatch, - tokenizeName, - tokenizePartNumber -} from "./tokenize"; +import { nameSpans, partNumberSpans, queryWords } from "./tokenize"; import { PART_NAME_FIELD, PART_NUMBER_FIELD } from "./fields"; /** - * The element's own defaults first. `toKey` leaves out whatever a selection does - * not override, so that record is the one keyed by the empty string. - * - * Records arrive in the order `enumerateConfigurations` produced them, which is - * option declaration order — the default lands wherever Onshape happens to - * declare it, and for a boolean parameter defaulting to false it is never first. + * Moves the record naming no values to the front. Records come in Onshape's + * declaration order, where the default can land anywhere. */ function defaultFirst(records: SearchRecord[]): SearchRecord[] { const index = records.findIndex( - (record) => record.configurationKey === DEFAULT_CONFIGURATION_KEY + (record) => Object.keys(record.values).length === 0 ); if (index <= 0) { return records; @@ -41,171 +31,123 @@ function defaultFirst(records: SearchRecord[]): SearchRecord[] { ]; } -/** - * The best record by part number or name, whichever the query describes better, - * else the default — so a row shows one even when only the title matched. - */ -export function matchedRecord( - query: string, - documentRecords: SearchRecord[], - matchedFields: string[] -): SearchRecord | undefined { - // Reordered once, so the tie-break inside findBestRecord and the fallback - // below both land on the configuration the insert menu opens with. - const records = defaultFirst(documentRecords); - const byNumber = matchedFields.includes(PART_NUMBER_FIELD) - ? findBestRecord(query, records, (r) => r.partNumber, LITERAL) - : undefined; - const byName = matchedFields.includes(PART_NAME_FIELD) - ? findBestRecord(query, records, (r) => r.name, DESCRIPTIVE) - : undefined; - - const best = [byNumber, byName] - .filter((match) => match !== undefined) - // Part number first, so it wins a tie: it is the more specific field. - .sort((a, b) => b.score - a.score)[0]; - return best?.record ?? records[0]; -} - -interface RecordMatch { - record: SearchRecord; - score: number; -} +/** The terms of a record's field, as the index read them. */ +type RecordTerms = (record: SearchRecord) => string[]; -/** - * How a field's text is read for scoring: a part number is compared as typed, - * a name around the decimals its sizes are indexed as. - */ -interface FieldReader { - normalize: (text: string) => string; - terms: (text: string) => string[]; -} +const PART_NUMBER_TERMS: RecordTerms = (record) => + partNumberSpans(record.partNumber ?? "").map((span) => span.term); -/** A part number identifies: `217-2600` is a code, not a number. */ -const LITERAL: FieldReader = { - normalize: (text) => text.trim().toLowerCase(), - terms: tokenizePartNumber -}; +const PART_NAME_TERMS: RecordTerms = (record) => + nameSpans(record.name ?? "").map((span) => span.term); -/** A name describes, so `1/2`, `.5` and `0.5` are one size. */ -const DESCRIPTIVE: FieldReader = { - normalize: normalizeForMatch, - terms: (text) => tokenizeName(text).map((term) => term.toLowerCase()) -}; - -/** - * A term matched whole beats one matched as a prefix, which every longer number - * satisfies too: `1` names the size `1"`, but only starts `16`. - */ -function termScore(valueTerms: string[], queryTerm: string): number { - // A unit is not part of the number's spelling, so `1` still names `1"`. - if ( - valueTerms.includes(queryTerm) || - valueTerms.includes(queryTerm + '"') - ) { +/** A whole term beats a prefix: `1` names a `1"` shaft but only starts `16`. */ +function termScore(terms: string[], queryTerm: string): number { + if (terms.includes(queryTerm)) { return 2; } - return valueTerms.some((valueTerm) => valueTerm.startsWith(queryTerm)) - ? 1 - : 0; -} - -/** How much of the query the value covers, term by term. */ -function coveredTerms( - value: string, - queryTerms: string[], - field: FieldReader -): number { - const valueTerms = field.terms(value); - return queryTerms.reduce( - (score, queryTerm) => score + termScore(valueTerms, queryTerm), - 0 - ); -} - -/** - * How well a value answers the query: a whole-query match ranks above any - * number of loose terms, so a part number typed out in full still wins. - */ -function matchScore( - value: string, - normalizedQuery: string, - queryTerms: string[], - field: FieldReader -): number { - let whole = 0; - if (value === normalizedQuery) { - whole = 3; - } else if (value.startsWith(normalizedQuery)) { - whole = 2; - } else if (value.includes(normalizedQuery)) { - whole = 1; + if (terms.some((term) => term.startsWith(queryTerm))) { + return 1; } - // Outweighs full term coverage, which is worth 2 a term. - return ( - whole * (2 * queryTerms.length + 1) + - coveredTerms(value, queryTerms, field) - ); + return 0; } /** - * Scored by term rather than by the whole query, which "maxspline 24t" matches - * no record as. Ties go to whichever came first in `records`, which - * {@link defaultFirst} has already put the element's defaults at the front of. + * The record whose matched fields cover most of the query, every reading of + * each word counting. Falls back to the default, so a row shows one even when + * only the title matched; ties go to it too, as the insert menu opens on it. */ -function findBestRecord( +export function matchedRecord( query: string, - records: SearchRecord[], - selector: (record: SearchRecord) => string | undefined, - field: FieldReader -): RecordMatch | undefined { - // Read the query the way the field was indexed, so a `.5` query lines up - // with a stored "1/2 Bearing" and a typed part number with itself. - const normalizedQuery = field.normalize(query.trim()); - if (records.length === 0 || normalizedQuery === "") { - return undefined; + documentRecords: SearchRecord[], + matchedFields: string[] +): SearchRecord | undefined { + const records = defaultFirst(documentRecords); + const readers: RecordTerms[] = []; + if (matchedFields.includes(PART_NUMBER_FIELD)) { + readers.push(PART_NUMBER_TERMS); + } + if (matchedFields.includes(PART_NAME_FIELD)) { + readers.push(PART_NAME_TERMS); } - const queryTerms = field.terms(query); + const queryTerms = queryWords(query).flat(); - let best: RecordMatch | undefined; + let best = records[0]; + let bestScore = 0; for (const record of records) { - const value = field.normalize(selector(record) ?? ""); - if (!value) continue; - const score = matchScore(value, normalizedQuery, queryTerms, field); - if (score > (best?.score ?? 0)) { - best = { record, score }; + const terms = readers.flatMap((read) => read(record)); + const score = queryTerms.reduce( + (total, queryTerm) => total + termScore(terms, queryTerm), + 0 + ); + if (score > bestScore) { + best = record; + bestScore = score; } } return best; } -/** - * First of each distinct (part number, name) in enumeration order, which keeps - * the latest revision. One identifying nothing is dropped before the index sees it. - */ +/** Drops records that identify nothing. */ export function toSearchRecords( records: ConfigurationRecord[], + parameters: ConfigurationParameter[], vendors: Vendor[] = [] ): SearchRecord[] { - const seen = new Set(); const searchRecords: SearchRecord[] = []; - for (const raw of records) { - const partNumber = meaningfulPartNumber(raw.partNumber, raw.name); - const name = clean(raw.name); + for (const record of records) { + const partNumber = meaningfulPartNumber(record.partNumber, record.name); + const name = clean(record.name); if (!partNumber && !name) { continue; } - const key = JSON.stringify([partNumber, name]); - if (seen.has(key)) { - continue; - } - seen.add(key); searchRecords.push({ partNumber, name, - url: getPartUrl({ ...raw, partNumber }, vendors), - configurationKey: raw.configurationKey + url: getPartUrl({ ...record, partNumber }, vendors), + values: record.values, + configurationKey: toKey( + toSelection(record.values, parameters), + parameters + ) }); } return searchRecords; } + +/** + * First of each distinct (part number, name), which keeps the latest revision. + * Enough for the index, which only picks a hit's record. + */ +export function distinctRecords(records: SearchRecord[]): SearchRecord[] { + const seen = new Set(); + return records.filter((record) => { + const key = JSON.stringify([record.partNumber, record.name]); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +/** An insertable's stored configuration data, as the joined rows read it. */ +export interface StoredConfiguration { + /** The element's own part data; null when it has none to show. */ + partMetadata: PartMetadata | null; + /** Null for an element with nothing to configure, which has no row. */ + parameters: ConfigurationParameter[] | null; + records: ConfigurationRecord[] | null; + vendors: Vendor[]; +} + +/** The element's own part data first, then one per indexed configuration. */ +export function searchRecordsOf(stored: StoredConfiguration): SearchRecord[] { + const own: ConfigurationRecord[] = stored.partMetadata + ? [{ ...stored.partMetadata, values: {} }] + : []; + return toSearchRecords( + [...own, ...(stored.records ?? [])], + stored.parameters ?? [], + stored.vendors + ); +} diff --git a/src/backend/features/search/tokenize.test.ts b/src/backend/features/search/tokenize.test.ts index 2d350859a..a5f690b80 100644 --- a/src/backend/features/search/tokenize.test.ts +++ b/src/backend/features/search/tokenize.test.ts @@ -1,34 +1,32 @@ import { describe, expect, it } from "vitest"; import { - normalizeForMatch, - processTerm, + nameSpans, + partNumberSpans, + queryWords, tokenize, - tokenizeName, - tokenizePartNumber, - tokenizeQuery + type TermSpan } from "./tokenize"; -// A part number identifies the part; splitting or folding it makes it name a -// different one, so it is indexed as typed alongside its segments. -describe("tokenizePartNumber", () => { +const terms = (spans: TermSpan[]) => [ + ...new Set(spans.map((span) => span.term)) +]; + +/** Each span as the characters it covers, for checking offsets. */ +const covered = (text: string, spans: TermSpan[]) => + spans.map((span) => text.slice(span.start, span.end)); + +// Read as typed, plus segments: splitting it would name a different part. +describe("partNumberSpans", () => { it("keeps the number whole, and adds its segments", () => { - expect(tokenizePartNumber("WCP-1025")).toEqual([ + expect(terms(partNumberSpans("WCP-1025"))).toEqual([ "wcp-1025", "wcp", "1025" ]); }); - it("keeps leading zeros, which spell the segment", () => { - expect(tokenizePartNumber("TTB-0016")).toEqual([ - "ttb-0016", - "ttb", - "0016" - ]); - }); - - it("leaves a fraction inside a number alone", () => { - expect(tokenizePartNumber("TTB-0016-5/32")).toEqual([ + it("keeps leading zeros and fractions as written", () => { + expect(terms(partNumberSpans("TTB-0016-5/32"))).toEqual([ "ttb-0016-5/32", "ttb", "0016", @@ -37,177 +35,126 @@ describe("tokenizePartNumber", () => { ]); }); - it("does not read a number as a quantity", () => { - expect(tokenizePartNumber("217-2600")).toEqual([ - "217-2600", - "217", - "2600" - ]); + // The index joins an element's part numbers with spaces. + it("reads space-separated part numbers apart", () => { + const text = "WCP-0100 WCP-0101"; + const spans = partNumberSpans(text); + expect(terms(spans)).toContain("wcp-0101"); + expect(terms(spans)).not.toContain(text.toLowerCase()); + expect(covered(text, spans)).toContain("WCP-0101"); }); - it.each(["", " "])("has nothing to say about %s", (value) => { - expect(tokenizePartNumber(value)).toEqual([]); + it.each(["", " "])("has nothing to say about %j", (value) => { + expect(partNumberSpans(value)).toEqual([]); }); }); // A name describes the part, so its sizes are read as sizes. -describe("tokenizeName", () => { - it("splits on punctuation, keeping the words whole", () => { - expect(tokenizeName('1" Linear (REV)')).toEqual([ - '1"', - "Linear", - "REV" +describe("nameSpans", () => { + it("splits on punctuation, inch marks included", () => { + expect(terms(nameSpans('1" Linear (REV)'))).toEqual([ + "1", + "linear", + "rev" ]); - expect(tokenizeName("Bearings & Bushings #X-Contact")).toEqual([ - "Bearings", - "Bushings", - "X", - "Contact" + expect(terms(nameSpans("Bearings & Bushings #X-Contact"))).toEqual([ + "bearings", + "bushings", + "x", + "contact" ]); }); - // The standards write the same measurement both ways, so one decimal form - // is what lets either spelling find the other. - it("canonicalizes fractions and decimals to a 2-dp decimal", () => { - expect(tokenizeName("1/2")).toEqual(["0.5"]); - expect(tokenizeName(".5")).toEqual(["0.5"]); - expect(tokenizeName("0.50")).toEqual(["0.5"]); - expect(tokenizeName("3/4")).toEqual(["0.75"]); - expect(tokenizeName("1-1/2")).toEqual(["1.5"]); - expect(tokenizeName("1/3")).toEqual(["0.33"]); - }); - it.each([ - ['1/2" Hex Bearing (1.125" OD, 0.313" WD, Flanged)', '0.5"'], - // Stored to 2dp, so `1.125` and `1.13` are one size. - ['1/2" Hex Bearing (1.125" OD, 0.313" WD, Flanged)', '1.13"'], - ['#10-32 x 2.5" L SHCS', "10"], - // Sizes are stored to 2dp, so `.159` and `.16` are one size. - [".159 ID x SplineXL OD MotionX Hub", "0.16"] - ])("reads the sizes in %s", (name, size) => { - expect(tokenizeName(name)).toContain(size); + ["1/2", ["0.5"]], + [".5", ["0.5"]], + ["0.50", ["0.5"]], + ["3/4", ["0.75"]], + ["1-1/2", ["1.5"]], + ["1/3", ["0.33"]] + ])("reads %s as a 2dp decimal", (text, expected) => { + expect(terms(nameSpans(text))).toEqual(expected); }); - it("keeps a thread spec's halves apart", () => { - expect(tokenizeName("#10-32 Screw")).toEqual(["10", "32", "Screw"]); - }); - - // The standards list a part's dimensions in a comma-separated aside. - it("does not leave a comma stuck to the word before it", () => { - expect(tokenizeName('1.125" OD, Flanged')).toEqual([ - '1.13"', - "OD", - "Flanged", - '1.12"' - ]); - }); - - // One vendor writes .196 as .2 and the next writes .19, so the part is - // stored as both and either spelling finds it. + // Vendors write .196 as both .2 and .19. it("spells a measurement as what it rounds to and what it starts", () => { - expect(tokenizeName(".196 ID Hub")).toEqual([ + expect(terms(nameSpans(".196 ID Hub"))).toEqual([ "0.2", - "ID", - "Hub", - "0.19" + "0.19", + "id", + "hub" ]); - expect(tokenizeName('2.140" L')).toEqual(['2.14"', "L"]); }); - // The mark is what makes `1"` a size rather than a prefix of 1.5 and 16T. - it("keeps an inch mark on the number it measures", () => { - expect(tokenizeName('1" Hex Shaft')).toEqual(['1"', "Hex", "Shaft"]); - expect(tokenizeName('1/2" Hex')).toEqual(['0.5"', "Hex"]); - expect(tokenizeName('1"x2" Tube')).toEqual(['1"', 'x2"', "Tube"]); + it("keeps a thread spec's halves apart", () => { + expect(terms(nameSpans("#10-32 Screw"))).toEqual(["10", "32", "screw"]); }); - it("still drops quotes that quote something", () => { - expect(tokenizeName('The "Long" Bracket')).toEqual([ - "The", - "Long", - "Bracket" + it("reads a part number inside a name as part 16 in 5/32", () => { + expect(terms(nameSpans("TTB-0016-5/32"))).toEqual([ + "ttb", + "16", + "0.16", + "0.15" ]); }); -}); -describe("processTerm", () => { - it.each(["MAXSpline", "MaxSpline"])("splits %s into its words", (term) => { - expect(processTerm(term)).toEqual( - expect.arrayContaining(["max", "spline", "maxspline"]) - ); + it("keeps a size's span on its written form", () => { + const text = '1-1/2" Tube'; + expect(covered(text, nameSpans(text))).toEqual(["1-1/2", "Tube"]); + }); + + it("adds the words of a compound, where they sit", () => { + const text = "MAXSpline"; + const spans = nameSpans(text); + expect(terms(spans)).toEqual(["maxspline", "max", "spline"]); + expect(covered(text, spans)).toEqual(["MAXSpline", "MAX", "Spline"]); }); it.each([ - ["SplineXL", ["spline", "xl"]], ["roboRIO", ["robo", "rio"]], - ["MAXTube", ["max", "tube"]] - ])("splits the product name %s", (term, words) => { - expect(processTerm(term)).toEqual(expect.arrayContaining(words)); + ["SplineXL", ["spline", "xl"]] + ])("splits the product name %s", (text, words) => { + expect(terms(nameSpans(text))).toEqual(expect.arrayContaining(words)); }); - // Its segments are already separate tokens; splitting the code again would - // only invent words inside it. - it("leaves a part number whole", () => { - expect(processTerm("WCP-1025", "partNumbers")).toEqual(["wcp-1025"]); + it("marks only an unchanged spelling as literal", () => { + expect(nameSpans("Bracket")[0].literal).toBe(true); + expect(nameSpans("1/2")[0].literal).toBe(false); + expect(nameSpans("0016")[0].literal).toBe(false); }); }); describe("tokenize", () => { it("reads each field the way that field is written", () => { expect(tokenize("TTB-0016-5/32", "partNumbers")).toContain("0016"); - expect(tokenize("TTB-0016-5/32", "partNames")).toEqual([ - "TTB", - "16", - "0.16", - "0.15" - ]); + expect(tokenize("TTB-0016-5/32", "partNames")).not.toContain("0016"); }); }); -// A query has no field, so it has to offer both readings: the caller may have -// typed a size or a part number. -describe("tokenizeQuery", () => { - it("offers the part number as typed, and as a name would read it", () => { - expect( - tokenizeQuery("TTB-0016").map((term) => term.toLowerCase()) - ).toEqual(expect.arrayContaining(["ttb", "16", "0016", "ttb-0016"])); +// A query word could be a size or a part number, so it's read both ways. +describe("queryWords", () => { + it("offers a part number as typed, and as a name would read it", () => { + expect(queryWords("TTB-0016")).toEqual([ + expect.arrayContaining(["ttb", "16", "0016", "ttb-0016"]) + ]); }); - // `1` prefix-matches every number in the library, so a size is not split - // into the segments a part number would be. + // A bare `1` would prefix-match every number. it("does not split a bare size into its digits", () => { - expect(tokenizeQuery("1/2")).toEqual(["0.5", "1/2"]); + expect(queryWords("1/2")).toEqual([["0.5", "1/2"]]); }); - it("leaves an ordinary word alone", () => { - expect(tokenizeQuery("bearing")).toEqual(["bearing"]); + it("keeps the words apart, since each has to match", () => { + expect(queryWords("L bracket")).toEqual([["l"], ["bracket"]]); }); - // Nothing carries the placeholder, and splitting it leaves `n` and `a` — - // a one-letter prefix, which matches most of the library. + // Splitting it leaves one-letter prefixes that match most of the library. it.each(["n/a", "N/A"])("has nothing to search for in %s", (query) => { - expect(tokenizeQuery(query)).toEqual([]); + expect(queryWords(query)).toEqual([]); }); it("still reads the rest of a query the placeholder is in", () => { - expect(tokenizeQuery("n/a bearing")).toEqual(["bearing"]); - }); - - // Answering as the caller types is the point, and the first keystroke is - // one character. - it.each(["l", "L", "1"])("still searches for a typed %s", (query) => { - expect(tokenizeQuery(query)).toEqual([query]); - }); - - it("keeps a letter typed beside another word", () => { - expect(tokenizeQuery("L bracket")).toEqual(["L", "bracket"]); - }); -}); - -describe("normalizeForMatch", () => { - it("reads a written size and its decimal as one string", () => { - expect(normalizeForMatch('1/2" Hex')).toBe( - normalizeForMatch('.5" hex') - ); + expect(queryWords("n/a bearing")).toEqual([["bearing"]]); }); }); diff --git a/src/backend/features/search/tokenize.ts b/src/backend/features/search/tokenize.ts index 5c6fecf4d..bbfb9e809 100644 --- a/src/backend/features/search/tokenize.ts +++ b/src/backend/features/search/tokenize.ts @@ -1,199 +1,163 @@ /** - * How search reads text: where names and part numbers break into terms, and how - * a measurement is spelled. The index is built with these and queried with - * them, so a change here is a change to both ends at once. + * How names and part numbers become terms. Each term keeps where it sits in + * its text, so what was indexed is also what gets underlined. */ import { isPlaceholderPartNumber } from "../configurations/part-number"; -import { clean } from "../../lib/text"; import { PART_NUMBER_FIELD } from "./fields"; -/** Where a name breaks: punctuation and space, plus a quote used as a quote. */ -const NAME_SEPARATORS = new RegExp("(? max spline, MAXTube -> max tube. */ -const WORD_BOUNDARIES = new RegExp( - "(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", - "g" -); - -// A mixed number, fraction, decimal or integer. Ordered longest-first so `1-1/2` -// is consumed whole rather than as `1` + `1/2`. -const NUMERIC_PATTERN = - /(\d+)-(\d+)\/(\d+)|(\d+)\/(\d+)|\d*\.\d+|\d+\.\d*|\d+/g; - -/** Leading zeros are spelling, not value: `TTB-0016` and `TTB-16` are one part. */ -function withoutLeadingZeros(digits: string): string { - return digits.replace(/^0+(?=\d)/, ""); +/** One term, and the characters of its text it was read from. */ +export interface TermSpan { + /** Lowercase, and numbers in canonical decimal form. */ + term: string; + start: number; + end: number; + /** Spelled as written, so a typed prefix of it can be underlined alone. */ + literal: boolean; } -/** How a measurement is spelled to 2dp: what it rounds to, and what it starts. */ -type DecimalSpelling = (value: number) => string; - -const rounded: DecimalSpelling = (value) => - String(Math.round(value * 100) / 100); -const truncated: DecimalSpelling = (value) => - String(Math.trunc(value * 100) / 100); - /** - * Both spellings, since the library writes the same measurement either way: one - * vendor's `.2` is the next one's `.19`. Storing both lets either find the part. + * A piece of a name: a mixed number or fraction, which spans the separators, + * else a run between them. A mixed number's whole can't lead with a zero, so + * `TTB-0016-5/32` stays part 16 in 5/32. */ -const DECIMAL_SPELLINGS: DecimalSpelling[] = [rounded, truncated]; +const NAME_PIECE = /[1-9]\d*-\d+\/\d+|\d+\/\d+|[^\s\-()',#&/"]+/g; -/** - * One 2-dp decimal at index and query time alike, which is what lets the raw - * fragments go unstored. Names only: `217-2600` is not two thousand six hundred. - */ -function canonicalizeNumbers(text: string, toDecimal: DecimalSpelling): string { - return text.replace( - NUMERIC_PATTERN, - ( - match: string, - mixedWhole: string | undefined, - mixedNum: string | undefined, - mixedDen: string | undefined, - fracNum: string | undefined, - fracDen: string | undefined - ) => { - // Left as written, so a long one cannot round-trip through a float. - if (/^\d+$/.test(match)) { - return withoutLeadingZeros(match); - } +/** camelCase and PascalCase boundaries: MAXSpline -> MAX Spline. */ +const WORD_BOUNDARY = /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/; - let value: number; - if (mixedWhole !== undefined) { - const fraction = Number(mixedNum) / Number(mixedDen); - // A leading zero marks a part number segment, not a quantity: `TTB-0016-5/32` is - // part 16 in 5/32", not 16 and 5/32. Each half still canonicalizes on its own. - if (mixedWhole.startsWith("0")) { - return Number.isFinite(fraction) - ? `${withoutLeadingZeros(mixedWhole)}-${toDecimal(fraction)}` - : match; - } - value = Number(mixedWhole) + fraction; - } else if (fracNum !== undefined) { - value = Number(fracNum) / Number(fracDen); - } else { - value = Number(match); - } - if (!Number.isFinite(value)) { - return match; - } - return toDecimal(value); - } - ); -} - -/** - * For direct, non-tokenized comparison: the index's canonicalization, - * lowercased, so a `.5` query lines up with a stored `"1/2 Bearing"`. - */ -export function normalizeForMatch(text: string): string { - return canonicalizeNumbers(text, rounded).toLowerCase(); -} +const MIXED = /^(\d+)-(\d+)\/(\d+)$/; +const FRACTION = /^(\d+)\/(\d+)$/; +const DECIMAL = /^(\d*\.\d+|\d+\.\d*)$/; +const INTEGER = /^\d+$/; -/** - * A name's words, with its sizes in the one decimal spelling. The inch mark - * stays on its number, so `1"` is a size rather than a prefix of `1.5` and `16t`. - */ -export function tokenizeName(text: string): string[] { - const tokens = new Set(); - // Canonicalized before splitting: fractions span `/` and `-`. Casing stays, - // since processTerm splits on camelCase. - for (const toDecimal of DECIMAL_SPELLINGS) { - for (const token of splitWithMarks( - canonicalizeNumbers(text, toDecimal) - )) { - tokens.add(token); - } +/** A size's value, or undefined for anything that isn't one. */ +function numericValue(piece: string): number | undefined { + const mixed = MIXED.exec(piece); + if (mixed) { + return Number(mixed[1]) + Number(mixed[2]) / Number(mixed[3]); } - return Array.from(tokens); -} - -/** Splits on `NAME_SEPARATORS`, keeping a `"` that measures its number. */ -function splitWithMarks(text: string): string[] { - const tokens: string[] = []; - for (const piece of text.split(NAME_SEPARATORS)) { - // The split consumed the separators, so an inch mark left inside a - // piece ends the token it measures: `1"x2"` is two sizes. - for (const token of piece.split(/(?<=")/)) { - if (token) tokens.push(token); - } + const fraction = FRACTION.exec(piece); + if (fraction) { + return Number(fraction[1]) / Number(fraction[2]); } - return tokens; + return DECIMAL.test(piece) ? Number(piece) : undefined; } /** - * A part number identifies, it does not describe: it is indexed as typed, plus - * its segments, so `WCP-1025` is found by the whole number or either half. + * To 2dp both rounded and truncated, since vendors write `.196` as `.2` and + * as `.19`. */ -export function tokenizePartNumber(text: string): string[] { - const whole = clean(text)?.toLowerCase(); - if (!whole) { - return []; - } - const segments = whole.split(PART_NUMBER_SEPARATORS).filter(Boolean); - return Array.from(new Set([whole, ...segments])); +function decimalSpellings(value: number): string[] { + const rounded = String(Math.round(value * 100) / 100); + const truncated = String(Math.trunc(value * 100) / 100); + return rounded === truncated ? [rounded] : [rounded, truncated]; } -/** The fields holding an identifier rather than a description. */ -function isPartNumberField(field?: string): boolean { - return field === PART_NUMBER_FIELD; +function pieceSpans(piece: string, start: number): TermSpan[] { + const end = start + piece.length; + if (INTEGER.test(piece)) { + // Leading zeros are spelling, not value: `TTB-0016` is part 16. + const term = piece.replace(/^0+(?=\d)/, ""); + return [{ term, start, end, literal: term === piece }]; + } + const value = numericValue(piece); + if (value !== undefined && Number.isFinite(value)) { + return decimalSpellings(value).map((term) => ({ + term, + start, + end, + literal: false + })); + } + const spans: TermSpan[] = [ + { term: piece.toLowerCase(), start, end, literal: true } + ]; + // So `MAXSpline` is found by `spline`. + const words = piece.split(WORD_BOUNDARY); + let offset = start; + for (const word of words.length > 1 ? words : []) { + spans.push({ + term: word.toLowerCase(), + start: offset, + end: offset + word.length, + literal: true + }); + offset += word.length; + } + return spans; } -/** Splits a field's text the way that field reads; a query has no field. */ -export function tokenize(text: string, field?: string): string[] { - if (field === undefined) { - return tokenizeQuery(text); - } - return isPartNumberField(field) - ? tokenizePartNumber(text) - : tokenizeName(text); +/** A name describes the part, so `1/2`, `.5` and `0.50` read as one size. */ +export function nameSpans(text: string): TermSpan[] { + return [...text.matchAll(NAME_PIECE)].flatMap((match) => + pieceSpans(match[0], match.index) + ); } /** - * A query is split both ways, since the caller may have typed either kind of - * text: the words of a name, and the literal a part number is indexed as. + * A part number identifies, so it is read literally: whole, and by segment. + * Space-separated runs are read apart, since the index joins every part + * number an element has with spaces. */ -export function tokenizeQuery(text: string): string[] { - const tokens: string[] = []; - // The name reading keeps its case for processTerm to split camelCase on, so the - // literal reading of one word is a duplicate rather than a second term. - const seen = new Set(); - for (const word of text.trim().split(/\s+/)) { - // Typed, it is still the word for a part number nobody has, and searching its - // letters would answer with whatever starts with `n` or `a`. - if (!word || isPlaceholderPartNumber(word)) { - continue; - } - // Segments only what carries a letter, as a part number does: splitting a bare - // `1/2` would search `1`, and a prefix that short matches every number. - const literal = /[a-z]/i.test(word) - ? tokenizePartNumber(word) - : [word.toLowerCase()]; - for (const token of [...tokenizeName(word), ...literal]) { - if (seen.has(token.toLowerCase())) { - continue; +export function partNumberSpans(text: string): TermSpan[] { + return [...text.matchAll(/\S+/g)].flatMap((match) => { + const whole = match[0]; + const spans: TermSpan[] = [ + { + term: whole.toLowerCase(), + start: match.index, + end: match.index + whole.length, + literal: true } - seen.add(token.toLowerCase()); - tokens.push(token); + ]; + for (const segment of whole.matchAll(/[^-/]+/g)) { + if (segment[0] === whole) continue; + const start = match.index + segment.index; + spans.push({ + term: segment[0].toLowerCase(), + start, + end: start + segment[0].length, + literal: true + }); } - } - return tokens; + return spans; + }); +} + +function uniqueTerms(spans: TermSpan[]): string[] { + return [...new Set(spans.map((span) => span.term))]; } /** - * Adds the words inside a compound term, so `MAXSpline` is found by `spline`. - * A part number is left whole: its segments are already separate tokens. + * Each word of a query and every way it could be meant: as a name reads it, + * and, for a word with a letter, as a part number. A bare `1/2` isn't split, + * or it would search `1`. */ -export function processTerm(term: string, field?: string): string[] { - const base = term.toLowerCase(); - if (isPartNumberField(field)) { - return [base]; +export function queryWords(query: string): string[][] { + return ( + query + .trim() + .split(/\s+/) + // A placeholder like "n/a" would match anything starting with its letters. + .filter((word) => word && !isPlaceholderPartNumber(word)) + .map((word) => { + const literal = /[a-z]/i.test(word) + ? uniqueTerms(partNumberSpans(word)) + : [word.toLowerCase()]; + return [ + ...new Set([...uniqueTerms(nameSpans(word)), ...literal]) + ]; + }) + .filter((terms) => terms.length > 0) + ); +} + +/** MiniSearch's tokenizer: a field's text, or one query word (which has no field). */ +export function tokenize(text: string, field?: string): string[] { + if (field === undefined) { + return queryWords(text).flat(); } - const words = term.split(WORD_BOUNDARIES).map((word) => word.toLowerCase()); - return Array.from(new Set([...words, base])); + return uniqueTerms( + field === PART_NUMBER_FIELD ? partNumberSpans(text) : nameSpans(text) + ); } diff --git a/src/backend/features/settings/app-tab.test.ts b/src/backend/features/settings/app-tab.test.ts deleted file mode 100644 index 4c63ca039..000000000 --- a/src/backend/features/settings/app-tab.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { LibraryId } from "../library/library-id"; -import { getTabPath, isLibraryTab, toAppTab, UtilityTab } from "./app-tab"; - -describe("app tabs", () => { - // The two kinds are addressed differently: libraries share one route and - // are named by id, a utility has a route of its own. - it("gives each kind of tab its own path", () => { - expect(getTabPath(LibraryId.FTC_DESIGN_LIB)).toBe( - "/app/library/ftc-design-lib" - ); - expect(getTabPath(UtilityTab.VERSION_MANAGER)).toBe( - "/app/version-manager" - ); - }); - - it("tells a library from a utility", () => { - expect(isLibraryTab(LibraryId.MKCAD)).toBe(true); - expect(isLibraryTab(UtilityTab.VERSION_MANAGER)).toBe(false); - }); - - it("falls back for a tab the app no longer knows", () => { - expect(toAppTab("old-frc-lib", LibraryId.FRC_DESIGN_LIB)).toBe( - LibraryId.FRC_DESIGN_LIB - ); - expect(toAppTab(undefined, LibraryId.FRC_DESIGN_LIB)).toBe( - LibraryId.FRC_DESIGN_LIB - ); - expect(toAppTab(UtilityTab.VERSION_MANAGER, LibraryId.MKCAD)).toBe( - UtilityTab.VERSION_MANAGER - ); - }); -}); diff --git a/src/backend/features/settings/app-tab.ts b/src/backend/features/settings/app-tab.ts deleted file mode 100644 index b075188c6..000000000 --- a/src/backend/features/settings/app-tab.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * What the navbar offers, and so what the app resumes into. Onshape calls its - * elements tabs too, which is what the `App` in the name holds off. - */ -import { LibraryId } from "../library/library-id"; - -/** A tab that is not a library, having a page of the app's own instead. */ -export enum UtilityTab { - VERSION_MANAGER = "version-manager" -} - -export type AppTab = LibraryId | UtilityTab; - -const APP_TABS: string[] = [ - ...Object.values(LibraryId), - ...Object.values(UtilityTab) -]; - -export function isLibraryTab(tab: AppTab): tab is LibraryId { - return Object.values(LibraryId).includes(tab as LibraryId); -} - -/** - * Where a tab opens. A library is one of many under one route, so it is named - * by its id; a utility is a page of its own, and its id is that page's path. - */ -export function getTabPath(tabId: AppTab): string { - return isLibraryTab(tabId) ? `/app/library/${tabId}` : `/app/${tabId}`; -} - -/** - * A stored tab, or the default when it is not one the app still knows: the - * column is plain text under its `$type`, so a row can name anything. - */ -export function toAppTab(tabId: string | undefined, fallback: AppTab): AppTab { - return APP_TABS.includes(tabId as AppTab) ? (tabId as AppTab) : fallback; -} diff --git a/src/backend/features/settings/routes.test.ts b/src/backend/features/settings/routes.test.ts deleted file mode 100644 index 16e720cc6..000000000 --- a/src/backend/features/settings/routes.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { eq } from "drizzle-orm"; -import { env } from "cloudflare:workers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { users } from "../../db/schema"; -import { LibraryId } from "../library/library-id"; - -import { Theme } from "./settings"; -import { - TEST_USER_ID, - createTestApp, - jsonRequest, - resetDb -} from "../../../__test_utils__"; -import { getDb } from "../../db/client"; - -const db = getDb(env.DB); - -describe("settings routes", () => { - beforeEach(async () => { - await resetDb(db); - }); - - it("POST /settings updates the caller's settings", async () => { - const app = createTestApp(); - - const res = await app.request( - "/api/settings", - jsonRequest("POST", { theme: Theme.DARK }), - env - ); - expect(res.status).toBe(200); - - const row = await db - .select() - .from(users) - .where(eq(users.id, TEST_USER_ID)) - .get(); - expect(row?.theme).toBe(Theme.DARK); - }); - - it("POST /settings records the tab the caller chose", async () => { - const app = createTestApp(); - - const res = await app.request( - "/api/settings", - jsonRequest("POST", { tabId: LibraryId.FTC_DESIGN_LIB }), - env - ); - expect(res.status).toBe(200); - - const row = await db - .select() - .from(users) - .where(eq(users.id, TEST_USER_ID)) - .get(); - expect(row?.tabId).toBe(LibraryId.FTC_DESIGN_LIB); - }); - - it("POST /settings records and clears the open group", async () => { - const app = createTestApp(); - const post = (body: unknown) => - app.request("/api/settings", jsonRequest("POST", body), env); - const storedGroupId = async () => - ( - await db - .select() - .from(users) - .where(eq(users.id, TEST_USER_ID)) - .get() - )?.groupId; - - await post({ groupId: "group-1" }); - expect(await storedGroupId()).toBe("group-1"); - - // Null is leaving the group, which is not the same as saying nothing. - await post({ groupId: null }); - expect(await storedGroupId()).toBeNull(); - }); -}); diff --git a/src/backend/features/settings/routes.ts b/src/backend/features/settings/routes.ts deleted file mode 100644 index 570360219..000000000 --- a/src/backend/features/settings/routes.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { eq } from "drizzle-orm"; -import { getApp } from "../../lib/context"; -import { getDb } from "../../db/client"; -import { users } from "../../db/schema"; -import { z } from "zod"; -import { validate } from "../../lib/validate"; -import { requireSignInMiddleware } from "../auth/guards"; -import { DEFAULT_LIBRARY, LibraryId } from "../library/library-id"; -import { ensureLibrary } from "../library/db"; -import { UtilityTab } from "./app-tab"; -import { Theme } from "./settings"; - -export const settingsRoutes = getApp(); - -const settingsBody = z.object({ - theme: z.enum(Theme).optional(), - tabId: z.union([z.enum(LibraryId), z.enum(UtilityTab)]).optional(), - // Null on leaving a group: the caller resumes in the tab itself. - groupId: z.string().nullable().optional() -}); - -/** POST /api/settings — update the caller's stored settings */ -settingsRoutes.post( - "/settings", - requireSignInMiddleware, - validate("json", settingsBody), - async (c) => { - const userId = await c.var.getUserId(); - const body = c.req.valid("json"); - - const db = getDb(c.env.DB); - - // The row's dead `library_id` still defaults to this one and still - // points at `libraries`, so the insert needs it to be there. - await ensureLibrary(db, DEFAULT_LIBRARY); - // No tab: a caller who has not chosen one has nothing to record. - await db.insert(users).values({ id: userId }).onConflictDoNothing(); - - if (Object.keys(body).length > 0) { - await db.update(users).set(body).where(eq(users.id, userId)); - } - - return c.json({ success: true }); - } -); diff --git a/src/backend/features/settings/settings.ts b/src/backend/features/settings/settings.ts deleted file mode 100644 index 038d056c5..000000000 --- a/src/backend/features/settings/settings.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AppTab } from "./app-tab"; - -export enum Theme { - SYSTEM = "system", - LIGHT = "light", - DARK = "dark" -} - -/** User settings, which the entry redirect reads and seeds the app with. */ -export interface Settings { - theme: Theme; - /** The tab they last opened, and land in next time; null until they pick - * one, which is what the welcome asks for. */ - tabId: AppTab | null; - /** The group they last opened in that tab; null for the tab itself. */ - groupId: string | null; -} - -export type SettingsUpdate = Partial; - -export const DEFAULT_SETTINGS: Settings = { - theme: Theme.SYSTEM, - tabId: null, - groupId: null -}; diff --git a/src/backend/features/thumbnails/contract.ts b/src/backend/features/thumbnails/contract.ts index 62ca58ecb..9aa63042a 100644 --- a/src/backend/features/thumbnails/contract.ts +++ b/src/backend/features/thumbnails/contract.ts @@ -1,38 +1,23 @@ -/** - * The two thumbnail sizes we generate and store, as the `WxH` Onshape wants. - * SMALL fills list rows; LARGE fills the hover card and the insert preview. - */ +/** As the `WxH` Onshape wants. SMALL is for list rows; LARGE for hover cards and the insert preview. */ export enum ThumbnailSize { SMALL = "70x40", LARGE = "300x300" } -/** An element's two stored thumbnail URLs, returned once both are stored. */ -export interface ThumbnailUrls { - small: string; - large: string; +/** What asking for a configuration's render found. */ +export enum RenderStatus { + /** Rendering, or already rendered; a push says when each size lands. */ + RENDERING = "rendering", + /** The configuration regenerates into nothing, so no render is coming. */ + NO_PART = "no-part" } -/** - * Which surface asked for a render, which is what orders it in the queue every - * request has to join. See `ThumbnailRenderer` for why there is a queue. - */ -export enum RenderSource { - /** The only source that may take the render thread off a job holding it. */ - INSERT_MENU = "insert", - /** A row in a list — favorites, search results. Waits its turn. */ - ROW = "row", - /** A library load, which nobody is waiting on. */ - LOAD = "load" +export interface RenderOut { + status: RenderStatus; } -/** - * The size each surface shows first. Both are always queued, so a row and the - * hover card it opens cannot disagree, but they are separate renders and only - * one runs at a time — so which goes first is worth getting right. - */ -export const PREFERRED_SIZE: Record = { - [RenderSource.INSERT_MENU]: ThumbnailSize.LARGE, - [RenderSource.ROW]: ThumbnailSize.SMALL, - [RenderSource.LOAD]: ThumbnailSize.SMALL -}; +/** An insertable's two stored thumbnail URLs, returned once both are stored. */ +export interface ThumbnailUrls { + small: string; + large: string; +} diff --git a/src/backend/features/thumbnails/keys.ts b/src/backend/features/thumbnails/keys.ts index d4aaf665c..f0155539b 100644 --- a/src/backend/features/thumbnails/keys.ts +++ b/src/backend/features/thumbnails/keys.ts @@ -3,15 +3,12 @@ import { type ConfigurationKey, DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; -import { RenderSource, ThumbnailSize } from "./contract"; +import { ThumbnailSize } from "./contract"; /** Everything a thumbnail is stored under, and what reconciliation scans. */ export const THUMBNAIL_PREFIX = "thumbnails/"; -/** - * Defaults get their own prefix, everything falling back to them so they never - * expire. A configuration is url-encoded, keeping `/` and `;` out of the path. - */ +/** Defaults get their own prefix. The configuration is url-encoded to keep `/` and `;` out of the path. */ export function thumbnailKey( elementId: string, microversionId: string, @@ -25,10 +22,7 @@ export function thumbnailKey( return `${THUMBNAIL_PREFIX}config/${elementId}/${microversionId}/${segment}/${size}`; } -/** - * What a stored thumbnail depicts. Both prefixes carry it in the same two - * segments, so a configuration render lives and dies with its element's. - */ +/** Both prefixes share these two segments, so a render is cleaned up with its element's. */ export interface ThumbnailSubject { elementId: string; microversionId: string; @@ -39,11 +33,7 @@ export function subjectKey(subject: ThumbnailSubject): string { return `${subject.elementId}/${subject.microversionId}`; } -/** - * The element and microversion a key was written for, or undefined when the key - * is not one {@link thumbnailKey} produces. Reconciliation deletes what this - * resolves, so anything it does not recognize is left alone. - */ +/** Undefined for a key it didn't write, which reconciliation then leaves alone. */ export function parseThumbnailKey(key: string): ThumbnailSubject | undefined { if (!key.startsWith(THUMBNAIL_PREFIX)) { return undefined; @@ -65,13 +55,6 @@ interface ThumbnailUrlOptions { size: ThumbnailSize; /** Empty (the default) serves the element's own thumbnail. */ configurationKey: ConfigurationKey; - /** - * Set to have a miss queue this configuration for rendering; which surface - * is asking is what orders it against everything else queued. - */ - renderSource?: RenderSource; - /** Only needed to render: what the render resolves the element from. */ - insertableId?: string; } /** The app URL serving a thumbnail; `v` busts caches when the document changes. */ @@ -79,28 +62,17 @@ export function thumbnailUrl({ elementId, microversionId, size, - configurationKey, - renderSource, - insertableId + configurationKey }: ThumbnailUrlOptions): string { - // `v` is the one abbreviation: it is the cache version every immutable url - // carries, and a render is pinned to the microversion it was taken from. + // `v` is the cache version every immutable url carries. const query = new URLSearchParams({ v: microversionId }); if (configurationKey !== DEFAULT_CONFIGURATION_KEY) { query.set("configurationKey", configurationKey); - if (renderSource && insertableId) { - query.set("renderSource", renderSource); - query.set("insertableId", insertableId); - } } return `/api/thumbnail/${size}/${elementId}?${query.toString()}`; } -/** - * The subject of a url {@link thumbnailUrl} built. Groups record their document - * thumbnail only as these two urls, so this is what tells reconciliation which - * element and microversion they still stand for; `keys.test.ts` pins the pair. - */ +/** Groups store only these urls, so reconciliation reads the subject back from them. */ export function parseThumbnailUrl(url: string): ThumbnailSubject | undefined { // Relative, so it needs a base to parse against; the origin is discarded. const parsed = URL.parse(url, "https://x.invalid"); diff --git a/src/backend/features/thumbnails/reconcile.test.ts b/src/backend/features/thumbnails/reconcile.test.ts deleted file mode 100644 index 91fa6cf20..000000000 --- a/src/backend/features/thumbnails/reconcile.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { env } from "cloudflare:workers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { getDb, type Db } from "../../db/client"; -import { - resetDb, - seedGroup, - seedInsertable, - seedPartStudio -} from "../../../__test_utils__/seed"; -import { ThumbnailSize } from "./contract"; -import { thumbnailKey, thumbnailUrl } from "./keys"; -import { DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; -import { reconcileThumbnails } from "./reconcile"; -import { LibraryId } from "../library/library-id"; - -const LIVE_ELEMENT = "element-1"; -const LIVE_MICROVERSION = "mv-live"; -const OLD_MICROVERSION = "mv-old"; - -/** A day and a half on, so what these tests store is past the grace period. */ -const LATER = Date.now() + 36 * 60 * 60 * 1000; - -let db: Db; - -/** Writes a byte at each key, which is all reconciliation reads. */ -async function store(...keys: string[]): Promise { - await Promise.all(keys.map((key) => env.BLOB.put(key, "x"))); -} - -async function storedKeys(): Promise { - const listed = await env.BLOB.list({ prefix: "thumbnails/" }); - return listed.objects.map((object) => object.key).sort(); -} - -/** Both sizes for one subject, as the store always writes them in a pair. */ -function defaultKeys(elementId: string, microversionId: string): string[] { - return Object.values(ThumbnailSize).map((size) => - thumbnailKey(elementId, microversionId, size) - ); -} - -beforeEach(async () => { - db = getDb(env.DB); - await resetDb(db); - const listed = await env.BLOB.list(); - await Promise.all(listed.objects.map((o) => env.BLOB.delete(o.key))); -}); - -describe("reconcileThumbnails", () => { - it("keeps what an insertable still names and deletes the rest", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await store( - ...defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION), - ...defaultKeys(LIVE_ELEMENT, OLD_MICROVERSION), - ...defaultKeys("element-gone", LIVE_MICROVERSION) - ); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.deleted).toBe(4); - expect(await storedKeys()).toEqual( - defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION).sort() - ); - }); - - // A configuration render is addressed by the same element and microversion, - // so it has to follow the element rather than outlive it. - it("deletes a configuration render whose microversion moved on", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - const liveConfig = thumbnailKey( - LIVE_ELEMENT, - LIVE_MICROVERSION, - ThumbnailSize.LARGE, - "size=l" - ); - const staleConfig = thumbnailKey( - LIVE_ELEMENT, - OLD_MICROVERSION, - ThumbnailSize.LARGE, - "size=l" - ); - await store(liveConfig, staleConfig); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.deleted).toBe(1); - expect(await storedKeys()).toEqual([liveConfig]); - }); - - // A group's document thumbnail is usually not one of its own insertables, - // and the urls on the row are the only record of which element it is. - it("keeps the document thumbnail a group's urls still point at", async () => { - const subject = { - elementId: "doc-thumbnail-element", - microversionId: "mv-doc" - }; - await seedGroup(db, "g1", undefined, { - smallThumbnailUrl: thumbnailUrl({ - ...subject, - size: ThumbnailSize.SMALL, - configurationKey: DEFAULT_CONFIGURATION_KEY - }), - largeThumbnailUrl: thumbnailUrl({ - ...subject, - size: ThumbnailSize.LARGE, - configurationKey: DEFAULT_CONFIGURATION_KEY - }) - }); - await seedInsertable(db, { - groupId: "g1", - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - const keys = defaultKeys(subject.elementId, subject.microversionId); - await store(...keys); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.deleted).toBe(0); - expect(await storedKeys()).toEqual(keys.sort()); - }); - - it("leaves a key it does not recognize alone", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await store("thumbnails/something-else", "thumbnails/config/only-two"); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.unrecognized).toBe(2); - expect(result.deleted).toBe(0); - expect(await storedKeys()).toHaveLength(2); - }); - - it("touches nothing outside the thumbnail prefix", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await env.BLOB.put("search-index/frcDesignLib.json", "{}"); - - await reconcileThumbnails(env.BLOB, db, LATER); - - expect(await env.BLOB.get("search-index/frcDesignLib.json")).not.toBe( - null - ); - }); - - // An empty read and an empty library look identical, and only one of them - // is worth emptying the bucket over. - it("deletes nothing when the live set is empty", async () => { - await store(...defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION)); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.skipped).toBe(true); - expect(result.deleted).toBe(0); - expect(await storedKeys()).toHaveLength(2); - }); - - it("is idempotent, so a retried step re-deletes nothing", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await store(...defaultKeys(LIVE_ELEMENT, OLD_MICROVERSION)); - - expect((await reconcileThumbnails(env.BLOB, db, LATER)).deleted).toBe( - 2 - ); - const second = await reconcileThumbnails(env.BLOB, db, LATER); - expect(second.deleted).toBe(0); - expect(second.scanned).toBe(0); - }); - - // A group load stores thumbnails as it goes and writes its rows at the - // end, and a configuration render is started by a user rather than a job — - // so something in flight is indistinguishable from something orphaned. - it("keeps an orphan too new to tell apart from a render in flight", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await store(...defaultKeys("element-being-added", "mv-new")); - - // Reconciled now, so what was just stored is inside the grace period. - const result = await reconcileThumbnails(env.BLOB, db); - - expect(result.tooRecent).toBe(2); - expect(result.deleted).toBe(0); - expect(await storedKeys()).toHaveLength(2); - }); - - it("collects that same orphan once it is old enough", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await store(...defaultKeys("element-being-added", "mv-new")); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.tooRecent).toBe(0); - expect(result.deleted).toBe(2); - }); - - // The key names no library, so a set built from one library would read - // every other library's thumbnails as orphaned. - it("keeps a thumbnail belonging to another library", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await seedGroup(db, "g-ftc", LibraryId.FTC_DESIGN_LIB); - await seedInsertable(db, { - id: "other-library-insertable", - groupId: "g-ftc", - libraryId: LibraryId.FTC_DESIGN_LIB, - elementId: "element-ftc", - microversionId: "mv-ftc" - }); - const keys = defaultKeys("element-ftc", "mv-ftc"); - await store(...keys, ...defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION)); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.deleted).toBe(0); - expect(await storedKeys()).toHaveLength(4); - }); -}); diff --git a/src/backend/features/thumbnails/reconcile.ts b/src/backend/features/thumbnails/reconcile.ts index 45b373708..40f1d5909 100644 --- a/src/backend/features/thumbnails/reconcile.ts +++ b/src/backend/features/thumbnails/reconcile.ts @@ -1,157 +1,113 @@ /** - * Deletes stored thumbnails nothing in the library still points at. Renders are - * kept indefinitely by design — the element default is what every unrendered - * configuration falls back to — so nothing expires them, and an element edited - * or removed would otherwise leave its thumbnails behind for good. + * Deletes a document's stored thumbnails nothing points at any more. Nothing + * else expires them, so an edited or removed element would leave its + * thumbnails behind for good. */ -import { isNotNull, or } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; +import { chunkForInArray } from "../../db/chunk"; import { type Db } from "../../db/client"; import { groups, insertables } from "../../db/schema"; import { THUMBNAIL_PREFIX, parseThumbnailKey, parseThumbnailUrl, - subjectKey, - type ThumbnailSubject + subjectKey } from "./keys"; /** R2 returns at most this many per call, and takes at most this many to delete. */ const R2_BATCH = 1000; /** - * A bucket large enough to need more than this is reconciled over several runs. - * Bounds one step's work rather than the total, which repeated reloads reach. + * A load stores thumbnails before the rows naming them, and a load replacing + * another can be storing them while the one it replaced cleans up. */ -const MAX_PAGES = 50; +export const STALE_THUMBNAIL_GRACE_MS = 60 * 60 * 1000; + +interface StaleThumbnailsScope { + documentId: string; + /** Every element of the document the thumbnails could belong to, past and present. */ + elementIds: string[]; + /** Drops every configuration render too, for a forced reload to redo. */ + dropRenders?: boolean; +} /** - * How long a thumbnail is left alone regardless of the live set. A render is - * stored before the row naming it is written — a group load uploads as it goes - * and commits its rows at the end, and a configuration render is started by a - * user opening the insert menu, outside any job this could wait on. Either one - * would look orphaned while it is in flight. Matched to the job TTL, since that - * is the longest a load is expected to take; anything genuinely orphaned is - * simply collected by a later reload instead. + * Across every library, since another library can load the same document. + * Idempotent. */ -const MIN_AGE_MS = 24 * 60 * 60 * 1000; +export async function deleteStaleThumbnails( + bucket: R2Bucket, + db: Db, + scope: StaleThumbnailsScope, + graceMs = STALE_THUMBNAIL_GRACE_MS, + now = Date.now() +): Promise { + const elementIds = [...new Set(scope.elementIds)]; + const live = await liveSubjects(db, scope.documentId, elementIds); -export interface ReconcileResult { - /** Objects under the thumbnail prefix this run looked at. */ - scanned: number; - deleted: number; - /** Keys left alone because nothing recognized them. */ - unrecognized: number; - /** Kept because they are too new to tell apart from a render in flight. */ - tooRecent: number; - /** True when the scan hit its page budget, so more may remain. */ - truncated: boolean; - /** True when nothing was deleted because the live set could not be trusted. */ - skipped: boolean; + let deleted = 0; + for (const elementId of elementIds) { + for (const kind of ["default", "config"]) { + let cursor: string | undefined; + do { + const listed = await bucket.list({ + prefix: `${THUMBNAIL_PREFIX}${kind}/${elementId}/`, + limit: R2_BATCH, + cursor + }); + const stale = listed.objects + .filter((object) => { + if (kind === "config" && scope.dropRenders) { + return true; + } + const subject = parseThumbnailKey(object.key); + return ( + subject && + !live.has(subjectKey(subject)) && + now - object.uploaded.getTime() >= graceMs + ); + }) + .map((object) => object.key); + if (stale.length > 0) { + await bucket.delete(stale); + deleted += stale.length; + } + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); + } + } + return deleted; } -/** - * Every element and microversion the library still shows. Spans every library: - * a thumbnail key names no library, so a set built from one would read every - * other library's thumbnails as orphaned. - */ -async function liveSubjects(db: Db): Promise> { - const [insertableRows, groupRows] = await Promise.all([ - db +async function liveSubjects( + db: Db, + documentId: string, + elementIds: string[] +): Promise> { + const live = new Set(); + for (const chunk of chunkForInArray(elementIds)) { + const rows = await db .select({ elementId: insertables.elementId, microversionId: insertables.microversionId }) - .from(insertables), - // A group's document thumbnail is recorded only as the urls serving it, - // and its element is often not one of the group's own insertables. - db - .select({ - smallThumbnailUrl: groups.smallThumbnailUrl, - largeThumbnailUrl: groups.largeThumbnailUrl - }) - .from(groups) - .where( - or( - isNotNull(groups.smallThumbnailUrl), - isNotNull(groups.largeThumbnailUrl) - ) - ) - ]); - - const live = new Set(insertableRows.map(subjectKey)); + .from(insertables) + .where(inArray(insertables.elementId, chunk)); + rows.forEach((row) => live.add(subjectKey(row))); + } + // A group's thumbnail element is often not one of its insertables. + const groupRows = await db + .select({ + smallThumbnailUrl: groups.smallThumbnailUrl, + largeThumbnailUrl: groups.largeThumbnailUrl + }) + .from(groups) + .where(eq(groups.documentId, documentId)); for (const row of groupRows) { for (const url of [row.smallThumbnailUrl, row.largeThumbnailUrl]) { const subject = url ? parseThumbnailUrl(url) : undefined; - if (subject) { - live.add(subjectKey(subject)); - } + if (subject) live.add(subjectKey(subject)); } } return live; } - -/** Whether a key's subject is one the library still shows. */ -function isLive(live: Set, subject: ThumbnailSubject): boolean { - return live.has(subjectKey(subject)); -} - -/** - * Idempotent, so a retried workflow step only re-deletes what is already gone. - * Deletes nothing when the live set is empty: a library really can have no - * elements, but so can a read that failed, and only one of those is worth - * emptying the bucket over. - */ -export async function reconcileThumbnails( - bucket: R2Bucket, - db: Db, - now: number = Date.now() -): Promise { - const live = await liveSubjects(db); - const result: ReconcileResult = { - scanned: 0, - deleted: 0, - unrecognized: 0, - tooRecent: 0, - truncated: false, - skipped: live.size === 0 - }; - if (result.skipped) { - return result; - } - - let cursor: string | undefined; - for (let page = 0; page < MAX_PAGES; page++) { - const listed = await bucket.list({ - prefix: THUMBNAIL_PREFIX, - limit: R2_BATCH, - cursor - }); - result.scanned += listed.objects.length; - - const orphaned: string[] = []; - for (const object of listed.objects) { - const subject = parseThumbnailKey(object.key); - if (!subject) { - result.unrecognized += 1; - } else if (isLive(live, subject)) { - continue; - } else if (now - object.uploaded.getTime() < MIN_AGE_MS) { - result.tooRecent += 1; - } else { - orphaned.push(object.key); - } - } - if (orphaned.length > 0) { - await bucket.delete(orphaned); - result.deleted += orphaned.length; - } - - if (!listed.truncated) { - return result; - } - cursor = listed.cursor; - } - - result.truncated = true; - return result; -} diff --git a/src/backend/features/thumbnails/reconcile.worker.test.ts b/src/backend/features/thumbnails/reconcile.worker.test.ts new file mode 100644 index 000000000..4c479c2ee --- /dev/null +++ b/src/backend/features/thumbnails/reconcile.worker.test.ts @@ -0,0 +1,166 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { getDb, type Db } from "../../db/client"; +import { + resetDb, + seedGroup, + seedInsertable, + seedPartStudio +} from "../../../__test_utils__/seed"; +import { ThumbnailSize } from "./contract"; +import { thumbnailKey, thumbnailUrl } from "./keys"; +import { DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; +import { deleteStaleThumbnails } from "./reconcile"; +import { LibraryId } from "../library/library-id"; + +const ELEMENT = "element-1"; +const LIVE_MICROVERSION = "mv-live"; +const OLD_MICROVERSION = "mv-old"; +const DOCUMENT = "doc-g1"; + +/** Past the grace period, so what these tests store counts as settled. */ +const LATER = Date.now() + 2 * 60 * 60 * 1000; + +let db: Db; + +async function store(...keys: string[]): Promise { + await Promise.all(keys.map((key) => env.BLOB.put(key, "x"))); +} + +async function storedKeys(): Promise { + const listed = await env.BLOB.list({ prefix: "thumbnails/" }); + return listed.objects.map((object) => object.key).sort(); +} + +function defaultKeys(elementId: string, microversionId: string): string[] { + return Object.values(ThumbnailSize).map((size) => + thumbnailKey(elementId, microversionId, size) + ); +} + +const clean = (elementIds: string[], now = LATER) => + deleteStaleThumbnails( + env.BLOB, + db, + { documentId: DOCUMENT, elementIds }, + undefined, + now + ); + +beforeEach(async () => { + db = getDb(env.DB); + await resetDb(db); + const listed = await env.BLOB.list(); + await Promise.all(listed.objects.map((o) => env.BLOB.delete(o.key))); +}); + +describe("deleteStaleThumbnails", () => { + it("deletes an element's old microversion, renders included", async () => { + await seedPartStudio(db, { + elementId: ELEMENT, + microversionId: LIVE_MICROVERSION + }); + const render = (microversionId: string) => + thumbnailKey( + ELEMENT, + microversionId, + ThumbnailSize.LARGE, + "size=l" + ); + await store( + ...defaultKeys(ELEMENT, LIVE_MICROVERSION), + ...defaultKeys(ELEMENT, OLD_MICROVERSION), + render(LIVE_MICROVERSION), + render(OLD_MICROVERSION) + ); + + expect(await clean([ELEMENT])).toBe(3); + expect(await storedKeys()).toEqual( + [ + ...defaultKeys(ELEMENT, LIVE_MICROVERSION), + render(LIVE_MICROVERSION) + ].sort() + ); + }); + + it("deletes everything of an element nothing names any more", async () => { + await store(...defaultKeys("element-removed", OLD_MICROVERSION)); + expect(await clean(["element-removed"])).toBe(2); + expect(await storedKeys()).toEqual([]); + }); + + it("leaves elements outside the document alone", async () => { + await store(...defaultKeys("elsewhere", OLD_MICROVERSION)); + expect(await clean([ELEMENT])).toBe(0); + expect(await storedKeys()).toHaveLength(2); + }); + + // The row's urls are the only record of which element it is. + it("keeps the document thumbnail a group's urls point at", async () => { + const subject = { + elementId: "thumbnail-tab", + microversionId: "mv-doc" + }; + const url = (size: ThumbnailSize) => + thumbnailUrl({ + ...subject, + size, + configurationKey: DEFAULT_CONFIGURATION_KEY + }); + await seedGroup(db, "g1", undefined, { + smallThumbnailUrl: url(ThumbnailSize.SMALL), + largeThumbnailUrl: url(ThumbnailSize.LARGE) + }); + await store(...defaultKeys(subject.elementId, subject.microversionId)); + + expect(await clean([subject.elementId])).toBe(0); + }); + + // Another library can load the same document. + it("keeps what another library's insertable still names", async () => { + await seedGroup(db, "g-ftc", LibraryId.FTC_DESIGN_LIB); + await seedInsertable(db, { + id: "ftc-insertable", + groupId: "g-ftc", + libraryId: LibraryId.FTC_DESIGN_LIB, + elementId: ELEMENT, + microversionId: LIVE_MICROVERSION + }); + await store(...defaultKeys(ELEMENT, LIVE_MICROVERSION)); + + expect(await clean([ELEMENT])).toBe(0); + }); + + it("keeps what was stored too recently to be settled", async () => { + await store(...defaultKeys(ELEMENT, OLD_MICROVERSION)); + expect(await clean([ELEMENT], Date.now())).toBe(0); + expect(await storedKeys()).toHaveLength(2); + }); + + // A forced reload redoes them, and may be clearing out bad ones. + it("drops every configuration render when asked to", async () => { + await seedPartStudio(db, { + elementId: ELEMENT, + microversionId: LIVE_MICROVERSION + }); + const render = thumbnailKey( + ELEMENT, + LIVE_MICROVERSION, + ThumbnailSize.LARGE, + "size=l" + ); + await store(...defaultKeys(ELEMENT, LIVE_MICROVERSION), render); + + await deleteStaleThumbnails( + env.BLOB, + db, + { documentId: DOCUMENT, elementIds: [ELEMENT], dropRenders: true }, + undefined, + Date.now() + ); + + expect(await storedKeys()).toEqual( + defaultKeys(ELEMENT, LIVE_MICROVERSION).sort() + ); + }); +}); diff --git a/src/backend/features/thumbnails/reload.ts b/src/backend/features/thumbnails/reload.ts index ed194d39c..f5c26eed0 100644 --- a/src/backend/features/thumbnails/reload.ts +++ b/src/backend/features/thumbnails/reload.ts @@ -1,11 +1,4 @@ -/** - * Re-fetching one stored thumbnail on demand. - * - * A load queues nothing and waits for nothing, so a thumbnail neither instance - * would give up at the time is simply missing until the next load. This is the - * way to ask again for one, without reloading the document it belongs to. - */ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { type Db } from "../../db/client"; import { groups, insertables } from "../../db/schema"; import { @@ -25,59 +18,61 @@ import { ThumbnailSize, type ThumbnailUrls } from "./contract"; import { thumbnailKey } from "./keys"; import { uploadThumbnails } from "./store"; import { bumpLibraryVersion } from "../library/db"; -import type { OnshapeDocumentInfo } from "../../lib/onshape/types"; +import { syncThumbnailWorkspace } from "./workspace"; +import type { LibraryId } from "../library/library-id"; /** What one reload needs to ask Onshape and to name what it stores. */ interface ReloadTarget { - elementPath: ElementPath; - elementWorkspacePath: ElementPath; + thumbnailPath: ElementPath; microversionId: string; } -/** - * The document's workspace, which is where a thumbnail falls back to. Stored - * rows are pinned to a version and carry no workspace, so reading the document - * is the one thing a reload has to do before it can ask for the picture. - */ -function workspacePath( - document: OnshapeDocumentInfo, - documentId: string -): InstancePath { - if (!document.defaultWorkspace) { - throw handledError( - "Onshape reports no default workspace for this document.", - HttpStatus.BAD_GATEWAY - ); +/** The workspace branched off the group's version, recorded on its row. */ +async function thumbnailWorkspace( + db: Db, + onshapeApi: OnshapeApi, + group: { id: string; documentId: string; versionId: string }, + stored: string | null +): Promise { + const workspace = await syncThumbnailWorkspace(onshapeApi, { + documentId: group.documentId, + instanceId: group.versionId, + instanceType: "v" + }); + if (workspace.instanceId !== stored) { + await db + .update(groups) + .set({ thumbnailWorkspaceId: workspace.instanceId }) + .where(eq(groups.id, group.id)); } - return { - documentId, - instanceId: document.defaultWorkspace.id, - instanceType: "w" - }; + return workspace; } -/** - * Drops what is stored before fetching, since `uploadThumbnails` skips a size - * the bucket already holds — which is the whole point of asking again. - */ +/** Deletes first, since `uploadThumbnails` skips stored sizes. */ async function replaceThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, target: ReloadTarget ): Promise { - const { elementPath, microversionId } = target; + const { thumbnailPath, microversionId } = target; await bucket.delete( [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => - thumbnailKey(elementPath.elementId, microversionId, size) + thumbnailKey(thumbnailPath.elementId, microversionId, size) ) ); - return uploadThumbnails( - bucket, - onshapeApi, - elementPath, - target.elementWorkspacePath, - microversionId - ); + try { + return await uploadThumbnails( + bucket, + onshapeApi, + thumbnailPath, + microversionId + ); + } catch { + throw handledError( + "Onshape has not rendered this thumbnail yet. Try again in a few minutes.", + HttpStatus.SERVICE_UNAVAILABLE + ); + } } /** The row's new urls, and the issue that said it had none. */ @@ -97,36 +92,40 @@ export async function reloadInsertableThumbnail( db: Db, bucket: R2Bucket, onshapeApi: OnshapeApi, + libraryId: LibraryId, insertableId: string ): Promise { const row = await db .select({ - libraryId: insertables.libraryId, + groupId: insertables.groupId, documentId: insertables.documentId, versionId: insertables.versionId, elementId: insertables.elementId, microversionId: insertables.microversionId, - buildIssues: insertables.buildIssues + buildIssues: insertables.buildIssues, + thumbnailWorkspaceId: groups.thumbnailWorkspaceId }) .from(insertables) - .where(eq(insertables.id, insertableId)) + .innerJoin(groups, eq(groups.id, insertables.groupId)) + .where( + and( + eq(insertables.id, insertableId), + eq(insertables.libraryId, libraryId) + ) + ) .get(); if (!row) { throw handledError("No such element.", HttpStatus.NOT_FOUND); } - const document = await getDocument(onshapeApi, { - documentId: row.documentId - }); - const workspace = workspacePath(document, row.documentId); + const workspace = await thumbnailWorkspace( + db, + onshapeApi, + { ...row, id: row.groupId }, + row.thumbnailWorkspaceId + ); const urls = await replaceThumbnails(bucket, onshapeApi, { - elementPath: { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v", - elementId: row.elementId - }, - elementWorkspacePath: { ...workspace, elementId: row.elementId }, + thumbnailPath: { ...workspace, elementId: row.elementId }, microversionId: row.microversionId }); @@ -134,31 +133,27 @@ export async function reloadInsertableThumbnail( .update(insertables) .set(reloaded(urls, row.buildIssues)) .where(eq(insertables.id, insertableId)); - // The urls are unchanged — they are built from the element and its - // microversion — so this is for the build issue the row no longer has. - await bumpLibraryVersion(db, row.libraryId); + // The urls don't change; this clears the build issue. + await bumpLibraryVersion(db, libraryId); } -/** - * Re-fetches a group's own thumbnail. Which element it comes from is the - * document's to say, so unlike an insertable's this has to read the contents - * to find it and the microversion its key is built on. - */ +/** Reads the document's contents to find the element and microversion. */ export async function reloadGroupThumbnail( db: Db, bucket: R2Bucket, onshapeApi: OnshapeApi, + libraryId: LibraryId, groupId: string ): Promise { const row = await db .select({ - libraryId: groups.libraryId, documentId: groups.documentId, versionId: groups.versionId, - buildIssues: groups.buildIssues + buildIssues: groups.buildIssues, + thumbnailWorkspaceId: groups.thumbnailWorkspaceId }) .from(groups) - .where(eq(groups.id, groupId)) + .where(and(eq(groups.id, groupId), eq(groups.libraryId, libraryId))) .get(); if (!row) { throw handledError("No such group.", HttpStatus.NOT_FOUND); @@ -173,7 +168,12 @@ export async function reloadGroupThumbnail( getDocument(onshapeApi, { documentId: row.documentId }), getContents(onshapeApi, versionPath) ]); - const workspace = workspacePath(document, row.documentId); + const workspace = await thumbnailWorkspace( + db, + onshapeApi, + { ...row, id: groupId }, + row.thumbnailWorkspaceId + ); const designated = document.documentThumbnailElementId; const element = designated @@ -187,8 +187,7 @@ export async function reloadGroupThumbnail( } const urls = await replaceThumbnails(bucket, onshapeApi, { - elementPath: { ...versionPath, elementId: element.id }, - elementWorkspacePath: { ...workspace, elementId: element.id }, + thumbnailPath: { ...workspace, elementId: element.id }, microversionId: element.microversionId }); @@ -196,5 +195,5 @@ export async function reloadGroupThumbnail( .update(groups) .set(reloaded(urls, row.buildIssues)) .where(eq(groups.id, groupId)); - await bumpLibraryVersion(db, row.libraryId); + await bumpLibraryVersion(db, libraryId); } diff --git a/src/backend/features/thumbnails/reload.test.ts b/src/backend/features/thumbnails/reload.worker.test.ts similarity index 61% rename from src/backend/features/thumbnails/reload.test.ts rename to src/backend/features/thumbnails/reload.worker.test.ts index 1fbadfb39..a1e0df220 100644 --- a/src/backend/features/thumbnails/reload.test.ts +++ b/src/backend/features/thumbnails/reload.worker.test.ts @@ -2,12 +2,13 @@ import { env } from "cloudflare:workers"; import { eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getDb } from "../../db/client"; -import { insertables } from "../../db/schema"; +import { groups, insertables } from "../../db/schema"; import { MOCK_ONSHAPE_API, resetDb, seedGroup, - TEST_GROUP_ID + TEST_GROUP_ID, + TEST_LIBRARY_ID } from "../../../__test_utils__"; import { insertableTarget, @@ -15,27 +16,27 @@ import { } from "../../../__test_utils__/insertable-fixtures"; import { saveInsertable } from "../load/load-insertable"; import { BuildIssueType } from "../build-checker/issues"; +import { LibraryId } from "../library/library-id"; import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents"; import { OnshapeElementType, OnshapeFolderEntryType } from "../../lib/onshape/types"; import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; +import * as WorkspaceEndpoints from "../../lib/onshape/endpoints/workspaces"; +import { thumbnailWorkspaceDescription } from "./workspace"; import { OnshapeApiError } from "../../lib/onshape/client"; import { ThumbnailSize } from "./contract"; import { thumbnailKey } from "./keys"; import { reloadGroupThumbnail, reloadInsertableThumbnail } from "./reload"; const db = getDb(env.DB); -const WORKSPACE_ID = "w-1"; +const STORED_BRANCH = "w-stored"; -/** Onshape's answer per instance type, so the fallback is observable. */ -function mockThumbnails( - answer: (instanceType: string) => Promise -) { +function mockThumbnails(answer: () => Promise) { return vi .spyOn(ThumbnailEndpoints, "getElementThumbnail") - .mockImplementation((_client, path) => answer(path.instanceType)); + .mockImplementation(answer); } const rendered = () => Promise.resolve(new ArrayBuffer(4)); @@ -60,10 +61,26 @@ describe("reloading a thumbnail", () => { }) .where(eq(insertables.id, target.insertableId)); + await db + .update(groups) + .set({ thumbnailWorkspaceId: STORED_BRANCH }) + .where(eq(groups.id, TEST_GROUP_ID)); + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([ + { + id: STORED_BRANCH, + name: "FRCDesignApp Thumbnails (DO NOT EDIT)", + description: thumbnailWorkspaceDescription( + (await db.select().from(insertables).get())!.versionId + ) + } + ]); + vi.spyOn(WorkspaceEndpoints, "createWorkspace").mockResolvedValue({ + id: "w-unexpected", + name: "FRCDesignApp Thumbnails (DO NOT EDIT)" + }); vi.spyOn(DocumentEndpoints, "getDocument").mockResolvedValue({ id: "doc", - name: "Doc", - defaultWorkspace: { id: WORKSPACE_ID } + name: "Doc" }); }); @@ -83,6 +100,7 @@ describe("reloading a thumbnail", () => { db, env.BLOB, MOCK_ONSHAPE_API, + TEST_LIBRARY_ID, target.insertableId ); @@ -91,28 +109,54 @@ describe("reloading a thumbnail", () => { expect(row?.buildIssues).toEqual([]); }); - // The same fallback a load uses: the version is asked first, and the - // workspace is what actually answers. - it("falls back to the workspace", async () => { - const calls = mockThumbnails((instanceType) => - instanceType === "v" ? missing() : rendered() + it("reads from the group's thumbnail workspace, branching nothing", async () => { + const calls = mockThumbnails(rendered); + + await reloadInsertableThumbnail( + db, + env.BLOB, + MOCK_ONSHAPE_API, + TEST_LIBRARY_ID, + target.insertableId ); + for (const call of calls.mock.calls) { + expect(call[1]).toMatchObject({ + instanceType: "w", + instanceId: STORED_BRANCH + }); + } + expect(WorkspaceEndpoints.createWorkspace).not.toHaveBeenCalled(); + }); + + it("makes a workspace for a document that has none, and keeps it", async () => { + await db + .update(groups) + .set({ thumbnailWorkspaceId: null }) + .where(eq(groups.id, TEST_GROUP_ID)); + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([]); + vi.spyOn(WorkspaceEndpoints, "createWorkspace").mockResolvedValue({ + id: "w-new", + name: "FRCDesignApp thumbnails" + }); + mockThumbnails(rendered); + await reloadInsertableThumbnail( db, env.BLOB, MOCK_ONSHAPE_API, + TEST_LIBRARY_ID, target.insertableId ); - expect( - calls.mock.calls.some((call) => call[1].instanceType === "w") - ).toBe(true); - expect((await readRow())?.buildIssues).toEqual([]); + const group = await db + .select() + .from(groups) + .where(eq(groups.id, TEST_GROUP_ID)) + .get(); + expect(group?.thumbnailWorkspaceId).toBe("w-new"); }); - // `uploadThumbnails` skips a size the bucket already holds, which would - // make asking again do nothing at all. it("replaces what is already stored", async () => { const key = thumbnailKey( target.elementPath.elementId, @@ -126,13 +170,14 @@ describe("reloading a thumbnail", () => { db, env.BLOB, MOCK_ONSHAPE_API, + TEST_LIBRARY_ID, target.insertableId ); expect(await (await env.BLOB.get(key))?.text()).not.toBe("stale-bytes"); }); - it("leaves the row alone when neither instance has one", async () => { + it("leaves the row alone while Onshape has not rendered one", async () => { mockThumbnails(missing); await expect( @@ -140,6 +185,7 @@ describe("reloading a thumbnail", () => { db, env.BLOB, MOCK_ONSHAPE_API, + TEST_LIBRARY_ID, target.insertableId ) ).rejects.toThrow(); @@ -155,11 +201,25 @@ describe("reloading a thumbnail", () => { db, env.BLOB, MOCK_ONSHAPE_API, + TEST_LIBRARY_ID, "not-an-insertable" ) ).rejects.toThrow(); }); + // An editor's access is to the named library alone. + it("refuses an element of another library", async () => { + await expect( + reloadInsertableThumbnail( + db, + env.BLOB, + MOCK_ONSHAPE_API, + LibraryId.FTC_DESIGN_LIB, + target.insertableId + ) + ).rejects.toThrow(); + }); + it("reloads a group's own thumbnail from its document", async () => { vi.spyOn(DocumentEndpoints, "getContents").mockResolvedValue({ elements: [ @@ -181,6 +241,7 @@ describe("reloading a thumbnail", () => { db, env.BLOB, MOCK_ONSHAPE_API, + TEST_LIBRARY_ID, TEST_GROUP_ID ); diff --git a/src/backend/features/thumbnails/render-workflow.ts b/src/backend/features/thumbnails/render-workflow.ts new file mode 100644 index 000000000..a6bf0e6cb --- /dev/null +++ b/src/backend/features/thumbnails/render-workflow.ts @@ -0,0 +1,96 @@ +/** + * Onshape renders a configuration's thumbnail when first asked, answering 404 + * until it's ready, so this asks until the bytes land and stores them. Element + * defaults render on save and never come through here. + */ +import { + WorkflowEntrypoint, + type WorkflowEvent, + type WorkflowStep +} from "cloudflare:workers"; +import type { AppBindings } from "../../lib/context"; +import { getThumbnailFromId } from "../../lib/onshape/endpoints/thumbnails"; +import { getOnshapeApiFromSessionId } from "../auth/request-auth"; +import { rateLimitDelay } from "../load/steps"; +import { type ConfigurationKey } from "../configurations/contract"; +import { ThumbnailSize } from "./contract"; +import { putThumbnail } from "./store"; +import { pushThumbnailRendered } from "../push/notify"; + +/** One stored size: where it goes, and what to ask Onshape for. */ +export interface RenderTarget { + size: ThumbnailSize; + /** The R2 key, which is also what the route serves the size from. */ + key: string; +} + +export interface RenderThumbnailParams { + /** Resolved once by the route; fixed for an element and configuration. */ + thumbnailId: string; + /** Both sizes, stored as each lands. */ + targets: RenderTarget[]; + /** What is told to clients waiting on the render once each size lands. */ + elementId: string; + /** Tagged onto each stored object, for telling later what it depicts. */ + microversionId: string; + configurationKey: ConfigurationKey; + /** Whose Onshape tokens the render is asked for under. */ + sessionId: string; +} + +/** About a minute; a render that takes longer is abandoned rather than spend the allocation. */ +const RENDER_RETRIES = { + limit: 12, + delay: (input: { error: Error }) => + rateLimitDelay(input.error) ?? ("5 seconds" as const), + backoff: "constant" as const +}; + +export class RenderThumbnailWorkflow extends WorkflowEntrypoint< + AppBindings, + RenderThumbnailParams +> { + async run( + event: WorkflowEvent, + step: WorkflowStep + ): Promise { + await Promise.all( + event.payload.targets.map((target) => + step.do( + `store-${target.size}`, + { retries: RENDER_RETRIES }, + () => storeRender(this.env, event.payload, target) + ) + ) + ); + } +} + +/** One try at a size; throws while Onshape is still rendering it. */ +async function storeRender( + env: AppBindings, + params: RenderThumbnailParams, + target: RenderTarget +): Promise { + if (await env.BLOB.head(target.key)) { + return; + } + const onshapeApi = await getOnshapeApiFromSessionId( + env.KV, + params.sessionId + ); + const thumbnail = await getThumbnailFromId( + onshapeApi, + params.thumbnailId, + target.size + ); + await putThumbnail(env.BLOB, target.key, thumbnail, { + microversionId: params.microversionId, + configurationKey: params.configurationKey + }); + await pushThumbnailRendered(env, { + elementId: params.elementId, + microversionId: params.microversionId, + configurationKey: params.configurationKey + }); +} diff --git a/src/backend/features/thumbnails/render-workflow.worker.test.ts b/src/backend/features/thumbnails/render-workflow.worker.test.ts new file mode 100644 index 000000000..474104268 --- /dev/null +++ b/src/backend/features/thumbnails/render-workflow.worker.test.ts @@ -0,0 +1,59 @@ +import { env } from "cloudflare:workers"; +import { introspectWorkflowInstance } from "cloudflare:test"; +import { afterEach, expect, it, vi } from "vitest"; +import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; +import * as RequestAuth from "../auth/request-auth"; +import { OnshapeApiError, type OAuthApi } from "../../lib/onshape/client"; +import { ThumbnailSize } from "./contract"; +import { thumbnailKey } from "./keys"; + +afterEach(() => vi.restoreAllMocks()); + +const key = (size: ThumbnailSize) => thumbnailKey("e1", "mv1", size, "a=1"); + +it("stores both sizes once Onshape has rendered them", async () => { + vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockResolvedValue( + {} as OAuthApi + ); + const fetch = vi + .spyOn(ThumbnailEndpoints, "getThumbnailFromId") + .mockRejectedValueOnce(new OnshapeApiError("rendering", 404)) + .mockResolvedValue(new TextEncoder().encode("gif").buffer); + + await using instance = await introspectWorkflowInstance( + env.RENDER_THUMBNAIL_WORKFLOW, + "render-test" + ); + await instance.modify(async (m) => { + await m.disableRetryDelays(); + }); + await env.RENDER_THUMBNAIL_WORKFLOW.create({ + id: "render-test", + params: { + thumbnailId: "thumbnail-id", + targets: [ + { size: ThumbnailSize.LARGE, key: key(ThumbnailSize.LARGE) }, + { size: ThumbnailSize.SMALL, key: key(ThumbnailSize.SMALL) } + ], + elementId: "e1", + microversionId: "mv1", + configurationKey: "a=1", + sessionId: "session" + } + }); + await instance.waitForStatus("complete"); + + // The id it was handed, never one it resolved again itself. + expect(fetch).toHaveBeenCalledWith( + expect.anything(), + "thumbnail-id", + ThumbnailSize.LARGE + ); + for (const size of Object.values(ThumbnailSize)) { + const stored = await env.BLOB.get(key(size)); + expect(stored?.customMetadata).toEqual({ + microversionId: "mv1", + configurationKey: "a=1" + }); + } +}); diff --git a/src/backend/features/thumbnails/render.ts b/src/backend/features/thumbnails/render.ts new file mode 100644 index 000000000..33c21ac64 --- /dev/null +++ b/src/backend/features/thumbnails/render.ts @@ -0,0 +1,171 @@ +/** Starts a configuration's render once; asking again while it runs starts nothing. */ +import { eq } from "drizzle-orm"; +import { HttpStatus } from "http-status-ts"; +import type { AppContext } from "../../lib/context"; +import { handledError } from "../../lib/api-error"; +import { getDb } from "../../db/client"; +import { groups, insertables } from "../../db/schema"; +import { type ElementPath } from "../../lib/onshape/path"; +import { getThumbnailId } from "../../lib/onshape/endpoints/thumbnails"; +import { getSessionId } from "../auth/session"; +import { type ConfigurationKey } from "../configurations/contract"; +import { decodeConfiguration } from "../configurations/utils"; +import { RenderStatus, ThumbnailSize } from "./contract"; +import { thumbnailKey } from "./keys"; + +interface RenderRequest { + insertableId: string; + configurationKey: ConfigurationKey; +} + +/** Statuses of an instance still working towards its bytes. */ +const ACTIVE = new Set([ + "queued", + "running", + "waiting", + "paused", + "waitingForPause" +]); + +/** + * Renders from the insertable's current microversion, in its group's thumbnail + * workspace; a group without one has it after its next load. + */ +export async function requestRender( + c: AppContext, + request: RenderRequest +): Promise { + const sessionId = getSessionId(c); + const target = await renderTargetOf(c, request.insertableId); + if (!target.workspacePath) { + console.warn("No thumbnail workspace to render from", request); + return RenderStatus.RENDERING; + } + const thumbnailId = await getThumbnailId( + await c.var.getOnshapeApi(), + target.workspacePath, + decodeConfiguration(request.configurationKey) + ); + if (!thumbnailId) { + return RenderStatus.NO_PART; + } + const { elementId, microversionId } = target; + const { configurationKey } = request; + + const workflow = c.env.RENDER_THUMBNAIL_WORKFLOW; + const id = await renderInstanceId( + elementId, + microversionId, + configurationKey + ); + const existing = await findInstance(workflow, id); + if (existing) { + const { status } = await existing.status(); + // A finished one restarts, and returns at once if its bytes are stored. + if (!ACTIVE.has(status)) { + await existing.restart(); + } + return RenderStatus.RENDERING; + } + + try { + await workflow.create({ + id, + params: { + thumbnailId, + targets: Object.values(ThumbnailSize).map((size) => ({ + size, + key: thumbnailKey( + elementId, + microversionId, + size, + configurationKey + ) + })), + elementId, + microversionId, + configurationKey, + sessionId + } + }); + } catch (error) { + // Two requests racing to start the same render; the other one won. + if (!(await findInstance(workflow, id))) { + throw error; + } + } + return RenderStatus.RENDERING; +} + +/** + * Keyed by where the render is stored, not by Onshape's thumbnail id: two + * configurations can share that id, and restarting the other's instance would + * store its bytes under the other's key. Hashed to fit an instance id. + */ +async function renderInstanceId( + elementId: string, + microversionId: string, + configurationKey: ConfigurationKey +): Promise { + const subject = `${elementId}/${microversionId}/${configurationKey}`; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(subject) + ); + const hex = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0") + ).join(""); + return `render-${hex}`; +} + +/** Undefined for an id no instance holds, which `get` answers by throwing. */ +async function findInstance( + workflow: Workflow, + id: string +): Promise { + try { + return await workflow.get(id); + } catch { + return undefined; + } +} + +interface RenderTarget { + elementId: string; + microversionId: string; + /** Undefined until the group's next load branches one. */ + workspacePath?: ElementPath; +} + +async function renderTargetOf( + c: AppContext, + insertableId: string +): Promise { + const row = await getDb(c.env.DB) + .select({ + documentId: insertables.documentId, + elementId: insertables.elementId, + microversionId: insertables.microversionId, + thumbnailWorkspaceId: groups.thumbnailWorkspaceId + }) + .from(insertables) + .innerJoin(groups, eq(groups.id, insertables.groupId)) + .where(eq(insertables.id, insertableId)) + .get(); + if (!row) { + throw handledError("No such part.", HttpStatus.NOT_FOUND); + } + const { elementId, microversionId, thumbnailWorkspaceId } = row; + return { + elementId, + microversionId, + workspacePath: thumbnailWorkspaceId + ? { + documentId: row.documentId, + instanceId: thumbnailWorkspaceId, + instanceType: "w", + elementId + } + : undefined + }; +} diff --git a/src/backend/features/thumbnails/renderer.test.ts b/src/backend/features/thumbnails/renderer.test.ts deleted file mode 100644 index 6a4389b25..000000000 --- a/src/backend/features/thumbnails/renderer.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -import { env } from "cloudflare:workers"; -import { runDurableObjectAlarm, runInDurableObject } from "cloudflare:test"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getDb } from "../../db/client"; -import { resetDb, seedGroup } from "../../../__test_utils__"; -import { - insertableTarget, - parsedInsertable -} from "../../../__test_utils__/insertable-fixtures"; -import { saveInsertable } from "../load/load-insertable"; -import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; -import { - OnshapeApiError, - OnshapeRateLimitError -} from "../../lib/onshape/client"; -import { RenderSource, ThumbnailSize } from "./contract"; -import { thumbnailKey } from "./keys"; -import type { ThumbnailRequest } from "./renderer"; - -const MICROVERSION = "mv-1"; -const SESSION_ID = "renderer-session"; -const CONFIGURATION = "size=l"; - -/** - * A fresh object and a fresh element per test: the queue is per object, but R2 - * is one bucket, and a key another test stored is a key this one skips. - */ -let testId = ""; -const renderer = () => env.THUMBNAIL_RENDERER.getByName(testId); -const elementIdFor = (name: string) => `${testId}-${name}`; - -/** - * A render of one configuration. The element is per test and the configuration - * names the render, so two of these are two different renders competing. - */ -const request = (name = "e", configurationKey = CONFIGURATION) => - ({ - insertableId: testId, - elementId: elementIdFor(name), - microversionId: MICROVERSION, - configurationKey - }) satisfies ThumbnailRequest; - -const keyFor = ( - name: string, - size: ThumbnailSize, - configuration = CONFIGURATION -) => thumbnailKey(elementIdFor(name), MICROVERSION, size, configuration); - -const bothKeys = (name: string, configuration = CONFIGURATION) => - [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => - keyFor(name, size, configuration) - ); - -/** The stored insertable a render resolves its element path from. */ -async function seedInsertableRow() { - const db = getDb(env.DB); - await resetDb(db); - await seedGroup(db); - await saveInsertable( - db, - insertableTarget({ insertableId: testId }), - parsedInsertable() - ); -} - -/** Stands in for a session, which is what the renderer resolves tokens from. */ -async function seedSession() { - await env.KV.put( - `tokens:${SESSION_ID}`, - JSON.stringify({ - accessToken: "token", - refreshToken: "refresh", - expiresAt: Date.now() + 3_600_000, - userId: "renderer-user" - }) - ); -} - -/** - * Onshape's answer for a render, by the id it was asked about. The id is - * derived from the configuration so two renders are told apart. - */ -function mockRenders(answer: (thumbnailId: string) => Promise) { - vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockImplementation( - (_client, _path, configurationKey) => - Promise.resolve(`tid-${configurationKey}`) - ); - return vi - .spyOn(ThumbnailEndpoints, "getThumbnailFromId") - .mockImplementation((_client, thumbnailId) => answer(thumbnailId)); -} - -const stillRendering = () => - Promise.reject(new OnshapeApiError("Onshape API error 404: nope", 404)); - -const notAcceptable = () => - Promise.reject(new OnshapeApiError("Onshape API error 406: nope", 406)); - -const rendered = () => Promise.resolve(new ArrayBuffer(4)); - -/** - * For tests that only read the queue's order. It keeps jobs queued — a 404 is - * "rendering" and would take the thread, which sorts that job first — and keeps - * the runtime from reaching the real Onshape while they look. - */ -const notAsked = () => Promise.reject(new Error("not asked in this test")); - -/** - * Drives the queue with its own alarm until `reached` holds, without waiting on - * wall time. Enqueuing schedules an alarm for now, so the runtime may already - * be draining; each pass yields, which lets one in flight finish, and runs the - * next alarm if one is scheduled. - * - * Deliberately no timers. The pool runs files in parallel and promises nothing - * about how long a drain takes, so anything paced by the clock is both slower - * and a coin toss. - */ -async function drainUntil( - reached: () => boolean | Promise -): Promise { - for (let pass = 0; pass < 50; pass++) { - if (await reached()) return; - await runDurableObjectAlarm(renderer()); - } - throw new Error("the renderer never reached the expected state"); -} - -/** Runs several more alarms, so "nothing changed" means it had the chance to. */ -async function settle(): Promise { - for (let pass = 0; pass < 5; pass++) { - await runDurableObjectAlarm(renderer()); - } -} - -/** The key holding the render thread, once one does. */ -async function renderingKey(): Promise { - await drainUntil(async () => - (await renderer().queued()).some((job) => job.rendering) - ); - const jobs = await renderer().queued(); - return jobs.find((job) => job.rendering)!.key; -} - -/** The soonest any job may next be touched. */ -async function soonestDueAt(): Promise { - return runInDurableObject( - renderer(), - (_instance, state) => - state.storage.sql - .exec<{ - dueAt: number | null; - }>("SELECT MIN(dueAt) AS dueAt FROM jobs") - .one().dueAt ?? 0 - ); -} - -async function queueOf(count: number) { - await drainUntil(async () => (await renderer().queued()).length === count); - return renderer().queued(); -} - -/** What Onshape was asked about one configuration, in order. */ -function callsFor( - calls: ReturnType, - configuration: string -): unknown[] { - return calls.mock.calls.filter( - (call) => call[1] === `tid-${configuration}` - ); -} - -describe("ThumbnailRenderer", () => { - beforeEach(async () => { - testId = `t${crypto.randomUUID()}`; - await seedSession(); - await seedInsertableRow(); - }); - - afterEach(() => vi.restoreAllMocks()); - - it("queues both sizes of one request", async () => { - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - expect((await renderer().queued()).map((job) => job.key)).toEqual( - expect.arrayContaining(bothKeys("e")) - ); - }); - - // A client polls the route, and every poll re-enqueues; without this the - // queue would grow a job per poll. - it("names a job by its key, so enqueuing twice enqueues once", async () => { - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - expect(await renderer().queued()).toHaveLength(2); - }); - - it("stores what Onshape rendered and drops the job", async () => { - mockRenders(rendered); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - expect(await queueOf(0)).toEqual([]); - for (const key of bothKeys("e")) { - expect(await env.BLOB.head(key)).not.toBeNull(); - } - }); - - // The whole point of the object: asking Onshape for a different thumbnail - // abandons the render in flight, so a job that has started one is polled to - // the exclusion of everything else. - it("holds the render thread until its own render lands", async () => { - // Two configurations of one element: two renders, and Onshape will - // only ever finish the second. - const calls = mockRenders((thumbnailId) => - thumbnailId === "tid-size=l" ? stillRendering() : rendered() - ); - await renderer().enqueue( - request("e", "size=l"), - SESSION_ID, - RenderSource.LOAD - ); - await renderer().enqueue( - request("e", "size=s"), - SESSION_ID, - RenderSource.LOAD - ); - - const held = await renderingKey(); - // More passes, so "never asked about" means the queue had every chance - // to ask and did not. - await settle(); - - expect(held).toContain(encodeURIComponent("size=l")); - // The job behind it was due the whole time and never asked about: - // asking would have abandoned the render in flight. - expect(callsFor(calls, "size=s")).toEqual([]); - }); - - // Nobody is waiting on any one thumbnail of a load, so it yields to the - // configuration someone is watching a spinner for. - it("runs what someone is watching before what a load queued", async () => { - mockRenders(notAsked); - await renderer().enqueue( - request("loaded"), - SESSION_ID, - RenderSource.LOAD - ); - await renderer().enqueue( - request("watched"), - SESSION_ID, - RenderSource.ROW - ); - - const queued = await renderer().queued(); - expect( - queued.slice(0, 2).every((job) => job.key.includes("watched")) - ).toBe(true); - }); - - // A row shows the small one and reveals the large on hover; the insert menu - // shows only the large. They are separate renders, and only one may run. - it("runs the size its surface shows first", async () => { - mockRenders(notAsked); - await renderer().enqueue(request("row"), SESSION_ID, RenderSource.ROW); - expect((await renderer().queued())[0].key).toBe( - keyFor("row", ThumbnailSize.SMALL) - ); - - testId = `t${crypto.randomUUID()}`; - await renderer().enqueue( - request("menu"), - SESSION_ID, - RenderSource.INSERT_MENU - ); - expect((await renderer().queued())[0].key).toBe( - keyFor("menu", ThumbnailSize.LARGE) - ); - }); - - // Somebody is watching this one render; the alternative is making them wait - // out a library load. - it("takes the render thread for the insert menu", async () => { - mockRenders(stillRendering); - await renderer().enqueue( - request("loaded"), - SESSION_ID, - RenderSource.LOAD - ); - expect(await renderingKey()).toContain(elementIdFor("loaded")); - - await renderer().enqueue( - request("watched"), - SESSION_ID, - RenderSource.INSERT_MENU - ); - - await drainUntil(async () => - (await renderer().queued()).some( - (job) => - job.rendering && job.key.includes(elementIdFor("watched")) - ) - ); - // The load's render is gone, not its job: it starts over when its turn - // comes round again. - expect((await renderer().queued()).map((job) => job.key)).toEqual( - expect.arrayContaining(bothKeys("loaded")) - ); - }); - - // The insert menu polls its own render every couple of seconds. Treating - // that as a reason to restart it is the exact thrash this all prevents. - it("does not interrupt the render the insert menu is waiting on", async () => { - mockRenders(stillRendering); - await renderer().enqueue( - request("watched"), - SESSION_ID, - RenderSource.INSERT_MENU - ); - const held = await renderingKey(); - - await renderer().enqueue( - request("watched"), - SESSION_ID, - RenderSource.INSERT_MENU - ); - - await runInDurableObject(renderer(), (_instance, state) => { - const started = state.storage.sql - .exec<{ - key: string; - }>("SELECT key FROM jobs WHERE startedAt IS NOT NULL") - .toArray(); - expect(started).toHaveLength(1); - expect(started[0].key).toBe(held); - }); - }); - - // A render an earlier load already stored, or the other size of a pair. - it("checks R2 before taking the thread, not on every poll", async () => { - for (const key of bothKeys("stored")) { - await env.BLOB.put(key, "bytes"); - } - const calls = mockRenders(rendered); - await renderer().enqueue( - request("stored"), - SESSION_ID, - RenderSource.LOAD - ); - - expect(await queueOf(0)).toEqual([]); - expect(callsFor(calls, "stored")).toEqual([]); - }); - - // Onshape answers a thumbnail it has not rendered with a JSON error, which - // it cannot send when the request rules that content type out. Read as a - // failure it drops the job after three strikes, and the render never lands. - it("treats a 406 as a render still running, not a failure", async () => { - mockRenders(notAcceptable); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - const held = await renderingKey(); - expect(held).toContain(elementIdFor("e")); - // Still queued rather than dropped, and holding the thread. - expect(await renderer().queued()).toHaveLength(2); - }); - - // Onshape is pushing back on the account, not on this render, and waiting - // is not the same as switching to another one. - it("waits out a rate limit without giving up the thread", async () => { - mockRenders(() => - Promise.reject(new OnshapeRateLimitError("slow down", 30)) - ); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - // Waits for the stand-down itself. Waiting on the queue length instead - // waits for nothing — both jobs are queued the moment `enqueue` - // returns, so the assertion would race the drain that rate-limits them. - await drainUntil( - async () => (await soonestDueAt()) > Date.now() + 20_000 - ); - // Both still queued: a rate limit is not a reason to drop anything. - expect(await renderer().queued()).toHaveLength(2); - }); - - // Retrying asks Onshape the same question for the same answer. - it("drops a configuration Onshape has no insertable for", async () => { - vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockRejectedValue( - new ThumbnailEndpoints.NoSuchConfigurationError("no such thing") - ); - - await renderer().enqueue( - request(), - SESSION_ID, - RenderSource.INSERT_MENU - ); - - expect(await queueOf(0)).toEqual([]); - }); - - // What the client polls for: it is waiting on a spinner that can only run - // out, and the wording it shows depends on knowing the difference. - it("tells a later request that the configuration has no insertable", async () => { - const ids = vi - .spyOn(ThumbnailEndpoints, "getThumbnailId") - .mockRejectedValue( - new ThumbnailEndpoints.NoSuchConfigurationError("no such thing") - ); - - await renderer().enqueue( - request(), - SESSION_ID, - RenderSource.INSERT_MENU - ); - await queueOf(0); - - expect( - await renderer().enqueue( - request(), - SESSION_ID, - RenderSource.INSERT_MENU - ) - ).toBe("no-such-configuration"); - // Nothing requeued, and the sibling size never spent a call of its own - // learning what the first already found out. - expect(await renderer().queued()).toEqual([]); - expect(ids).toHaveBeenCalledTimes(1); - }); - - it("queues a configuration it has nothing against", async () => { - mockRenders(stillRendering); - - expect( - await renderer().enqueue(request(), SESSION_ID, RenderSource.ROW) - ).toBe("queued"); - }); - - it("abandons a render that runs out of thread time", async () => { - mockRenders(stillRendering); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - const held = await renderingKey(); - - // Backdated past the limit rather than waited out: what is under test - // is that reaching it ends the job, not how long reaching it takes. - await runInDurableObject(renderer(), (_instance, state) => { - state.storage.sql.exec( - `UPDATE jobs SET startedAt = 0, dueAt = ? - WHERE startedAt IS NOT NULL`, - Date.now() - ); - }); - - const gone = async () => - !(await renderer().queued()).some((job) => job.key === held); - await drainUntil(gone); - - // Dropped outright rather than requeued for another hold on the thread. - await settle(); - expect(await gone()).toBe(true); - }); - - it("throws away a queue left by an older generation", async () => { - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - expect(await renderer().queued()).toHaveLength(2); - - await runInDurableObject(renderer(), (_instance, state) => { - state.storage.kv.put("generation", 0); - }); - // Anything that touches the queue is what notices. - await renderer().enqueue( - request("after"), - SESSION_ID, - RenderSource.LOAD - ); - - // Only what was queued after the purge. - expect( - (await renderer().queued()).every((job) => - job.key.includes(elementIdFor("after")) - ) - ).toBe(true); - }); - - it("sets no alarm when there is nothing queued", async () => { - expect(await runDurableObjectAlarm(renderer())).toBe(false); - }); -}); diff --git a/src/backend/features/thumbnails/renderer.ts b/src/backend/features/thumbnails/renderer.ts deleted file mode 100644 index 78ccceb35..000000000 --- a/src/backend/features/thumbnails/renderer.ts +++ /dev/null @@ -1,683 +0,0 @@ -/** - * The one place that asks Onshape to render a configuration. - * - * Onshape runs one such render per user at a time. Asking for one it has not - * got answers 404 and starts rendering it; asking for a *different* one - * abandons that render and starts the new one. So a caller that interleaves two - * finishes neither, however long it polls. Onshape does not document this — it - * is read off renders that either land in under a minute or never. - * - * Hence one object per user, and a job that has started rendering holds the - * render thread: every pass polls that one key until it lands. Only the insert - * menu may take the thread off a job, since that is the one surface where - * somebody is watching a particular render happen. - * - * An element's own thumbnail does not come through here at all. Onshape renders - * those when a document is saved, so reading one starts nothing and cannot - * displace a render in flight — a load fetches them itself, in parallel. - */ -import { DurableObject } from "cloudflare:workers"; -import { HttpStatus } from "http-status-ts"; -import { eq } from "drizzle-orm"; -import type { AppBindings } from "../../lib/context"; -import { getDb } from "../../db/client"; -import { insertables } from "../../db/schema"; -import { - OnshapeApiError, - OnshapeRateLimitError, - type OnshapeApi -} from "../../lib/onshape/client"; -import { getOnshapeApiFromSessionId } from "../auth/request-auth"; -import type { ElementPath } from "../../lib/onshape/path"; -import { - getThumbnailFromId, - getThumbnailId, - NoSuchConfigurationError -} from "../../lib/onshape/endpoints/thumbnails"; -import { type ConfigurationKey } from "../configurations/contract"; -import { PREFERRED_SIZE, RenderSource, ThumbnailSize } from "./contract"; -import { thumbnailKey } from "./keys"; -import { putThumbnail } from "./store"; - -/** A configuration's render, the kind Onshape only does one of at a time. */ -export interface ThumbnailRequest { - /** What the element path is resolved from, since the caller has only this. */ - insertableId: string; - elementId: string; - microversionId: string; - configurationKey: ConfigurationKey; -} - -/** Both stored sizes, which a request always queues and drops together. */ -const SIZES = [ThumbnailSize.SMALL, ThumbnailSize.LARGE] as const; - -/** Where each source sits in the queue; lower goes first. */ -const SOURCE_RANK: Record = { - [RenderSource.INSERT_MENU]: 0, - [RenderSource.ROW]: 10, - [RenderSource.LOAD]: 20 -}; - -/** - * How often the render holding the thread is asked about, by how long it has - * held it. Polling the same render does not disturb it, so the first minute — - * where they normally land — is worth about ten calls. Past that they are - * outliers, and the backoff stops spending the account's allocation on them. - */ -const POLL_SCHEDULE = [ - { withinMs: 60_000, everyMs: 6_000 }, - { withinMs: 3 * 60_000, everyMs: 20_000 }, - { withinMs: Infinity, everyMs: 60_000 } -] as const; - -/** - * The most thread time one render can ever cost. Well past the two minutes an - * outlier takes, and hard: a render that reaches it is abandoned rather than - * retried, so one bad one cannot hold up the queue behind it for longer. - */ -const RENDER_TIMEOUT_MS = 5 * 60_000; - -/** - * Failures before a job is dropped. Being preempted is not one — that is the - * queue's doing, not the job's. - */ -const MAX_FAILURES = 3; - -/** How long a failed job waits before it is worth the thread again. */ -const RETRY_DELAY_MS = 30_000; - -/** How long a job stays queued; past it a later request asks again. */ -const JOB_TTL_MS = 60 * 60_000; - -/** - * How long a configuration Onshape had no insertable for stays remembered. - * The answer only changes when the document does, and a reload moves the - * microversion, which keys a different render — so this is really only a floor - * on how often a mistyped configuration is worth asking about again. - */ -const INVALID_TTL_MS = 60 * 60_000; - -/** Bounds one alarm, not the queue. */ -const ALARM_BUDGET_MS = 20_000; - -/** - * Bumped to throw away every queue. A job carries the shape the code that - * queued it expected, so a deploy that changes that shape leaves work behind - * which can only fail quietly. Raising this drops it the next time each object - * is touched. - */ -const QUEUE_GENERATION = 3; - -/** A row of the queue, as stored; the index signature is what `sql.exec` reads it as. */ -interface JobRow extends Record { - key: string; - request: string; - source: string; - /** Sort order: the source's rank, plus one when this is its second size. */ - queueRank: number; - /** When this job may next be touched. */ - dueAt: number; - expiresAt: number; - failures: number; - /** Resolved once per job and kept, since it cannot change. */ - thumbnailId: string | null; - /** When this job took the render thread; null while it is only queued. */ - startedAt: number | null; -} - -/** What one call to Onshape came back with, which is all the loop needs. */ -type Attempt = - | { outcome: "stored" } - /** Onshape is rendering it; this job holds the thread until it lands. */ - | { outcome: "rendering" } - | { outcome: "failed"; error: unknown } - /** Everything must wait; Onshape said how long. */ - | { outcome: "rate-limited"; retryAfterSeconds: number }; - -/** - * What {@link ThumbnailRenderer.enqueue} did with a request. A configuration - * Onshape has no insertable for is not queued at all — the caller is told, so - * it can say the configuration is what is wrong rather than waiting out a - * render that was never going to happen. - */ -export type EnqueueOutcome = "queued" | "no-such-configuration"; - -/** One entry of {@link ThumbnailRenderer.queued}. */ -export interface QueuedJob { - key: string; - source: string; - /** Whether this is the job currently holding the render thread. */ - rendering: boolean; -} - -/** - * `#private` rather than the `private` used elsewhere in the codebase, and not - * as a matter of taste: a Durable Object's prototype methods are its RPC - * surface, and TypeScript's `private` is erased, so a `private drain()` is - * callable by anything holding a stub (checked against the runtime, not just - * the docs). Only `enqueue` and `queued` are meant to be. - */ -export class ThumbnailRenderer extends DurableObject { - /** - * Keeps two alarms from overlapping, which would put two Onshape calls in - * the air. Whether the runtime already rules that out is not something I - * could find documented — the input gate is open across non-storage I/O, - * so an `enqueue` landing mid-drain can set an alarm for now. - */ - #running = false; - - constructor(ctx: DurableObjectState, env: AppBindings) { - super(ctx, env); - // Not under `blockConcurrencyWhile`: SQLite storage is synchronous, so - // no request can arrive on a half-built schema. - ctx.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS jobs ( - key TEXT PRIMARY KEY, - request TEXT NOT NULL, - source TEXT NOT NULL, - queueRank INTEGER NOT NULL, - dueAt INTEGER NOT NULL, - expiresAt INTEGER NOT NULL, - failures INTEGER NOT NULL DEFAULT 0, - thumbnailId TEXT, - startedAt INTEGER - ); - CREATE INDEX IF NOT EXISTS jobs_queue ON jobs (queueRank, dueAt); - CREATE TABLE IF NOT EXISTS invalid ( - key TEXT PRIMARY KEY, - expiresAt INTEGER NOT NULL - ); - `); - } - - /** - * Throws away a queue an older deploy left behind. Checked on the way in - * rather than in the constructor: an object already warm would otherwise - * keep working its stale queue until something happened to evict it. - */ - #purgeStaleQueue(): void { - if ( - this.ctx.storage.kv.get("generation") === QUEUE_GENERATION - ) { - return; - } - this.ctx.storage.sql.exec("DELETE FROM jobs"); - this.ctx.storage.kv.put("generation", QUEUE_GENERATION); - } - - /** - * Queues both sizes and returns; callers read the bytes back out of R2. - * A configuration an earlier attempt found no insertable for is declined - * instead — see {@link EnqueueOutcome}. - * - * A job is named by the R2 key it will write, so asking twice is asking - * once and a client may poll as fast as it likes. A repeat does refresh the - * session and improve the rank, but never moves the next attempt earlier — - * that is what let a polling client reset a render's backoff endlessly. - */ - async enqueue( - request: ThumbnailRequest, - sessionId: string, - source: RenderSource - ): Promise { - this.#purgeStaleQueue(); - - const keys = SIZES.map((size) => keyOf(request, size)); - if (this.#isInvalid(keys)) { - return "no-such-configuration"; - } - - const now = Date.now(); - // Any live session for this user can render any of these jobs, so the - // newest one wins — which is what keeps a job queued under a session - // that has since expired renderable. - this.ctx.storage.kv.put("sessionId", sessionId); - - for (const size of SIZES) { - this.ctx.storage.sql.exec( - `INSERT INTO jobs - (key, request, source, queueRank, dueAt, expiresAt) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (key) DO UPDATE SET - source = CASE WHEN excluded.queueRank < jobs.queueRank - THEN excluded.source ELSE jobs.source END, - queueRank = MIN(jobs.queueRank, excluded.queueRank)`, - keyOf(request, size), - JSON.stringify(request), - source, - rankOf(source, size), - now, - now + JOB_TTL_MS - ); - } - - if (source === RenderSource.INSERT_MENU) { - this.#preemptFor(keys); - } - await this.#scheduleNext(); - return "queued"; - } - - /** - * The queue in the order it will run, the held render first. Exposed - * because the order is what this object is for: it is how to tell someone - * how far down they are, and how to see what a stalled queue is stalled on. - */ - queued(): QueuedJob[] { - return this.ctx.storage.sql - .exec( - `SELECT * FROM jobs - ORDER BY startedAt IS NULL, queueRank, dueAt` - ) - .toArray() - .map((job) => ({ - key: job.key, - source: job.source, - rendering: job.startedAt !== null - })); - } - - async alarm(): Promise { - if (this.#running) return; - this.#purgeStaleQueue(); - this.#running = true; - try { - await this.#drain(); - } finally { - this.#running = false; - await this.#scheduleNext(); - } - } - - /** - * One Onshape call per pass. While a job holds the render thread every pass - * polls that one key — asking about anything else abandons its render. - */ - async #drain(): Promise { - const until = Date.now() + ALARM_BUDGET_MS; - - while (Date.now() < until) { - this.#dropExpired(); - - const rendering = this.#renderingJob(); - if (rendering) { - // Not due yet — and nothing else may run in the meantime, so - // there is nothing to do but let the alarm come back. - if (rendering.dueAt > Date.now()) return; - // Terminal, not a strike. Retrying would take the thread for - // another five minutes on a render that has already shown it - // is not coming, with the whole queue waiting behind it. - if (this.#outOfRenderTime(rendering)) { - this.#finish(rendering.key); - continue; - } - this.#record(rendering, await this.#poll(rendering)); - continue; - } - - const next = this.#nextQueuedJob(); - if (!next) return; - this.#record(next, await this.#start(next)); - } - } - - /** - * Takes the render thread for a job: checks R2 first, since another caller - * may have stored these bytes since it was queued, and only then asks - * Onshape — which is the call that starts the render. - */ - async #start(job: JobRow): Promise { - try { - if (await this.env.BLOB.head(job.key)) { - return { outcome: "stored" }; - } - return await this.#fetch(job); - } catch (error) { - return classify(error); - } - } - - /** - * Asks again about the render this job started. No R2 check — these bytes - * arrive only by way of this call, so looking first answers nothing. - */ - async #poll(job: JobRow): Promise { - try { - return await this.#fetch(job); - } catch (error) { - return classify(error); - } - } - - /** The one Onshape call, and the store when it comes back with bytes. */ - async #fetch(job: JobRow): Promise { - const request = requestOf(job); - const onshapeApi = await this.#onshapeApi(); - const thumbnail = await getThumbnailFromId( - onshapeApi, - await this.#thumbnailId(job, request, onshapeApi), - sizeOf(job.key) - ); - - await putThumbnail(this.env.BLOB, job.key, thumbnail, { - microversionId: request.microversionId, - configurationKey: request.configurationKey - }); - return { outcome: "stored" }; - } - - /** - * The render's id, written to both sizes at once: it is fixed for an - * element and configuration, so asking per size or per poll only spends a - * call to be told the same thing. - */ - async #thumbnailId( - job: JobRow, - request: ThumbnailRequest, - onshapeApi: OnshapeApi - ): Promise { - if (job.thumbnailId) return job.thumbnailId; - - const elementPath = await this.#elementPath(request.insertableId); - const thumbnailId = await getThumbnailId( - onshapeApi, - elementPath, - request.configurationKey - ); - this.ctx.storage.sql.exec( - "UPDATE jobs SET thumbnailId = ? WHERE request = ?", - thumbnailId, - job.request - ); - return thumbnailId; - } - - /** Read rather than passed in: a request can carry a version it has moved past. */ - async #elementPath(insertableId: string): Promise { - const row = await getDb(this.env.DB) - .select({ - documentId: insertables.documentId, - versionId: insertables.versionId, - elementId: insertables.elementId - }) - .from(insertables) - .where(eq(insertables.id, insertableId)) - .get(); - if (!row) { - throw new NoSuchConfigurationError(`No insertable ${insertableId}`); - } - return { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v", - elementId: row.elementId - }; - } - - async #onshapeApi(): Promise { - const sessionId = this.ctx.storage.kv.get("sessionId"); - if (!sessionId) { - throw new Error("No session to render under"); - } - return getOnshapeApiFromSessionId(this.env.KV, sessionId); - } - - /** Writes back what one call decided: done, keep polling, or give up. */ - #record(job: JobRow, attempt: Attempt): void { - switch (attempt.outcome) { - case "stored": - this.#finish(job.key); - return; - - case "rendering": - // Takes the thread: nothing else may be asked of Onshape until - // this lands, or Onshape abandons it. - this.ctx.storage.sql.exec( - `UPDATE jobs - SET startedAt = COALESCE(startedAt, ?), dueAt = ? - WHERE key = ?`, - Date.now(), - Date.now() + pollDelay(job), - job.key - ); - return; - - case "rate-limited": - // The account is being throttled, not this job, so the render - // keeps its hold: waiting is not the same as switching away. - this.#delayAll(attempt.retryAfterSeconds * 1000); - return; - - case "failed": - // A configuration Onshape has no insertable for, or one that - // left the library: retrying asks the same question. - if (attempt.error instanceof NoSuchConfigurationError) { - this.#markInvalid(job); - return; - } - this.#fail(job); - return; - } - } - - /** - * Drops both of a request's sizes and remembers why. Neither can render — - * the id they would both have rendered from is what Onshape had nothing - * for — so the sibling is dropped rather than left to spend a call - * learning the same thing. - */ - #markInvalid(job: JobRow): void { - const request = requestOf(job); - for (const size of SIZES) { - const key = keyOf(request, size); - this.ctx.storage.sql.exec( - `INSERT INTO invalid (key, expiresAt) VALUES (?, ?) - ON CONFLICT (key) DO UPDATE SET expiresAt = excluded.expiresAt`, - key, - Date.now() + INVALID_TTL_MS - ); - this.#finish(key); - } - } - - /** Whether either size of a request is one already marked invalid. */ - #isInvalid(keys: string[]): boolean { - const placeholders = keys.map(() => "?").join(", "); - return ( - this.ctx.storage.sql - .exec<{ - count: number; - }>( - `SELECT COUNT(*) AS count FROM invalid - WHERE key IN (${placeholders}) AND expiresAt > ?`, - ...keys, - Date.now() - ) - .one().count > 0 - ); - } - - /** Gives the thread back after a job broke, and drops it once it keeps doing so. */ - #fail(job: JobRow): void { - if (job.failures + 1 >= MAX_FAILURES) { - this.#finish(job.key); - return; - } - this.ctx.storage.sql.exec( - `UPDATE jobs - SET startedAt = NULL, failures = failures + 1, dueAt = ? - WHERE key = ?`, - Date.now() + RETRY_DELAY_MS, - job.key - ); - } - - /** - * Hands the render thread to the insert menu, the only surface that may - * take it: somebody is watching that render, and the alternative is making - * them wait out everything else queued. - * - * The job that loses it is requeued rather than dropped — its render is - * gone, so it starts over at its turn. `dueAt` moves to now, putting it - * behind its waiting siblings, which is also what stops a user flipping - * between two configurations from bouncing one pair forever. - */ - #preemptFor(keys: string[]): void { - const rendering = this.#renderingJob(); - // The insert menu polling its own render must not interrupt it, which - // is the exact thrash this all exists to avoid. - if (!rendering || keys.includes(rendering.key)) { - return; - } - this.ctx.storage.sql.exec( - "UPDATE jobs SET startedAt = NULL, dueAt = ? WHERE key = ?", - Date.now(), - rendering.key - ); - } - - #finish(key: string): void { - this.ctx.storage.sql.exec("DELETE FROM jobs WHERE key = ?", key); - } - - /** The job holding the render thread, if one does. */ - #renderingJob(): JobRow | undefined { - return this.ctx.storage.sql - .exec("SELECT * FROM jobs WHERE startedAt IS NOT NULL") - .toArray()[0]; - } - - #nextQueuedJob(): JobRow | undefined { - return this.ctx.storage.sql - .exec( - `SELECT * FROM jobs WHERE startedAt IS NULL AND dueAt <= ? - ORDER BY queueRank, dueAt LIMIT 1`, - Date.now() - ) - .toArray()[0]; - } - - /** Only a render that actually started can run out of time holding the thread. */ - #outOfRenderTime(job: JobRow): boolean { - return ( - job.startedAt !== null && - Date.now() - job.startedAt > RENDER_TIMEOUT_MS - ); - } - - /** A render nothing came back for; a later request queues it again. */ - #dropExpired(): void { - this.ctx.storage.sql.exec( - "DELETE FROM jobs WHERE expiresAt <= ?", - Date.now() - ); - this.ctx.storage.sql.exec( - "DELETE FROM invalid WHERE expiresAt <= ?", - Date.now() - ); - } - - #delayAll(delayMs: number): void { - this.ctx.storage.sql.exec( - "UPDATE jobs SET dueAt = MAX(dueAt, ?)", - Date.now() + delayMs - ); - } - - /** - * Wakes for the next thing that can actually run. While a job holds the - * thread that is its next poll: waking for a queued job whose turn cannot - * come would spin the alarm against a due time already in the past. - */ - async #scheduleNext(): Promise { - const rendering = this.#renderingJob(); - const next = - rendering?.dueAt ?? - this.ctx.storage.sql - .exec<{ - dueAt: number | null; - }>("SELECT MIN(dueAt) AS dueAt FROM jobs") - .one().dueAt; - - if (next === null || next === undefined) { - await this.ctx.storage.deleteAlarm(); - return; - } - await this.ctx.storage.setAlarm(next); - } -} - -/** A 404 is Onshape rendering in the background, which is the normal case. */ -function classify(error: unknown): Attempt { - if (error instanceof OnshapeRateLimitError) { - return { - outcome: "rate-limited", - retryAfterSeconds: error.retryAfterSeconds - }; - } - // A 406 reads the same way: it means Onshape wanted to answer with - // something the request's Accept header excluded, and the only thing it has - // to say about a render that is not ready is a JSON error. - if ( - error instanceof OnshapeApiError && - (error.status === HttpStatus.NOT_FOUND || - error.status === HttpStatus.NOT_ACCEPTABLE) - ) { - return { outcome: "rendering" }; - } - return { outcome: "failed", error }; -} - -function requestOf(job: JobRow): ThumbnailRequest { - return JSON.parse(job.request) as ThumbnailRequest; -} - -/** The R2 key a request writes at one size, which is also the job's name. */ -function keyOf(request: ThumbnailRequest, size: ThumbnailSize): string { - return thumbnailKey( - request.elementId, - request.microversionId, - size, - request.configurationKey - ); -} - -/** Every key ends in the size it stores; see `thumbnailKey`. */ -function sizeOf(key: string): ThumbnailSize { - return key.slice(key.lastIndexOf("/") + 1) as ThumbnailSize; -} - -/** The size a surface shows first goes first; its other size follows. */ -function rankOf(source: RenderSource, size: ThumbnailSize): number { - return SOURCE_RANK[source] + (size === PREFERRED_SIZE[source] ? 0 : 1); -} - -/** How long to wait before asking about a render again; see POLL_SCHEDULE. */ -function pollDelay(job: JobRow): number { - // A job on its first 404 has not been stamped yet, so it reads as zero. - const heldMs = Date.now() - (job.startedAt ?? Date.now()); - const step = POLL_SCHEDULE.find(({ withinMs }) => heldMs < withinMs); - // The schedule ends in an unbounded step, so this only satisfies the types. - return step?.everyMs ?? POLL_SCHEDULE[POLL_SCHEDULE.length - 1].everyMs; -} - -/** Who a render runs as: the queue it joins, and the tokens it uses. */ -export interface Renderer { - /** The Onshape user whose one render thread this queue is for. */ - userId: string; - sessionId: string; -} - -/** Queues a render with the object holding this user's Onshape render thread. */ -export function requestThumbnails( - env: AppBindings, - { userId, sessionId }: Renderer, - request: ThumbnailRequest, - source: RenderSource -): Promise { - return env.THUMBNAIL_RENDERER.getByName(userId).enqueue( - request, - sessionId, - source - ); -} diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index e9036f5ad..55e4619a3 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -3,19 +3,22 @@ import { HttpStatus } from "http-status-ts"; import { handledError } from "../../lib/api-error"; import { validate } from "../../lib/validate"; import { CachePolicy, setCache } from "../../lib/cache"; -import { getApp, type AppContext } from "../../lib/context"; +import { getApp } from "../../lib/context"; -import { RenderSource, ThumbnailSize } from "./contract"; +import { type RenderOut, ThumbnailSize } from "./contract"; import { thumbnailKey } from "./keys"; import { DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; - +import { requestRender } from "./render"; +import { + requireEditorMiddleware, + requireSignInMiddleware +} from "../auth/guards"; import { - type EnqueueOutcome, - requestThumbnails, - type ThumbnailRequest -} from "./renderer"; -import { getSessionId } from "../auth/session"; -import { requireEditorMiddleware } from "../auth/guards"; + getInsertableParam, + getLibraryParam, + insertableRoute, + libraryRoute +} from "../../lib/route-params"; import { getDb } from "../../db/client"; import { reloadGroupThumbnail, reloadInsertableThumbnail } from "./reload"; @@ -29,94 +32,61 @@ const storedThumbnailParams = z.object({ /** Absent means the element default, which is what `""` encodes. */ const configurationKeyQuery = z.string().default(DEFAULT_CONFIGURATION_KEY); -/** - * Only the two sources a client can legitimately be. The insert menu may take - * the render thread from whatever holds it, so this is not free-form. - */ -const renderSourceQuery = z.enum([RenderSource.INSERT_MENU, RenderSource.ROW]); - const storedThumbnailQuery = z.object({ /** The microversion, part of the key — which is what makes a hit immutable. */ v: z.string().min(1), - configurationKey: configurationKeyQuery, - /** Absent means serve what is stored and queue nothing. */ - renderSource: renderSourceQuery.optional(), - /** The insertable to render from; only sent with `renderSource`. */ - insertableId: z.string().optional() + configurationKey: configurationKeyQuery }); -/** - * GET /api/thumbnail/:size/:elementId?v=&configurationKey=&renderSource= - * Each answer caches itself: stored bytes are pinned by the url, a miss is not. - */ +/** GET /api/thumbnail/:size/:elementId?v=&configurationKey= */ thumbnailRoutes.get( "/thumbnail/:size/:elementId", validate("param", storedThumbnailParams), validate("query", storedThumbnailQuery), async (c) => { const { size, elementId } = c.req.valid("param"); - const { - v: microversionId, - configurationKey, - renderSource, - insertableId - } = c.req.valid("query"); + const { v: microversionId, configurationKey } = c.req.valid("query"); const object = await c.env.BLOB.get( thumbnailKey(elementId, microversionId, size, configurationKey) ); if (object) { - // The microversion and the configuration are both in the url, so - // these bytes are the only ones it will ever mean. + // The url pins microversion and configuration. return setCache( thumbnailResponse(object), CachePolicy.PUBLIC_CACHE ); } - // A configuration this has not rendered is a miss, not the element's - // own thumbnail: standing that in shows a part the caller did not ask - // for, and a favorite pinned to a configuration would show the wrong - // one. A caller that wants the element default asks for it by key. - if ( - configurationKey !== DEFAULT_CONFIGURATION_KEY && - renderSource && - insertableId - ) { - const outcome = await queueConfigurationRender( - c, - { - insertableId, - elementId, - configurationKey, - microversionId - }, - renderSource - ); - if (outcome === "no-such-configuration") { - return noSuchConfiguration(); - } - } + // Never the element's default, which would show the wrong part. return notRenderedYet(); } ); -/** Nothing to serve yet, and a render landing later must not be shadowed. */ -function notRenderedYet(): Response { - return setCache( - new Response(null, { status: HttpStatus.NOT_FOUND }), - CachePolicy.NO_CACHE - ); -} +const renderBody = z.object({ configurationKey: z.string().min(1) }); /** - * Onshape has no insertable for this configuration, so no render is coming. - * Told apart from a miss by its status, which is what lets a client stop - * polling and say the configuration is what is wrong; the answer is not cached, - * since a reload of the document can make it wrong. + * POST /api/render-thumbnail/insertable/:insertableId: starts rendering a + * configuration the stored thumbnail route missed. Signed in, since the render + * calls Onshape as the caller. */ -function noSuchConfiguration(): Response { +thumbnailRoutes.post( + "/render-thumbnail" + insertableRoute(), + requireSignInMiddleware, + validate("json", renderBody), + async (c) => { + const { configurationKey } = c.req.valid("json"); + const status = await requestRender(c, { + insertableId: getInsertableParam(c), + configurationKey + }); + return c.json({ status } satisfies RenderOut); + } +); + +/** Nothing to serve yet, and a render landing later must not be shadowed. */ +function notRenderedYet(): Response { return setCache( - new Response(null, { status: HttpStatus.UNPROCESSABLE_ENTITY }), + new Response(null, { status: HttpStatus.NOT_FOUND }), CachePolicy.NO_CACHE ); } @@ -127,52 +97,19 @@ function thumbnailResponse(object: R2ObjectBody): Response { return new Response(object.body, { headers }); } -/** - * Queues the render and returns; the client polls this route until the bytes - * land. Polling is free — the queue names a job by the key it will write, so - * asking twice is asking once and never disturbs a render already running. - */ -async function queueConfigurationRender( - c: AppContext, - request: ThumbnailRequest, - source: RenderSource -): Promise { - try { - // Read first, so a caller with no session queues nothing: the render - // runs later, under this caller's Onshape tokens. - const sessionId = getSessionId(c); - const userId = await c.var.getUserId(); - return await requestThumbnails( - c.env, - { userId, sessionId }, - request, - source - ); - } catch { - // Never fatal: the caller just gets a miss until the render lands. - return undefined; - } -} - const reloadThumbnailBody = z.object({ /** Exactly one: a group's own thumbnail, or one element's. */ groupId: z.string().min(1).optional(), insertableId: z.string().min(1).optional() }); -/** - * POST /api/reload-thumbnail - * - * Asks Onshape for a thumbnail again and replaces what is stored. A load does - * not wait for one, so a thumbnail that was not there at the time stays missing - * until the next reload of the whole document — this is the way to ask for just - * the one. - */ +/** POST /api/reload-thumbnail/library/:libraryId: refetches one thumbnail, since loads don't wait for them. */ thumbnailRoutes.post( - "/reload-thumbnail", + "/reload-thumbnail" + libraryRoute(), requireEditorMiddleware, validate("json", reloadThumbnailBody), async (c) => { + const libraryId = getLibraryParam(c); const { groupId, insertableId } = c.req.valid("json"); const db = getDb(c.env.DB); const onshapeApi = await c.var.getOnshapeApi(); @@ -182,10 +119,17 @@ thumbnailRoutes.post( db, c.env.BLOB, onshapeApi, + libraryId, insertableId ); } else if (groupId) { - await reloadGroupThumbnail(db, c.env.BLOB, onshapeApi, groupId); + await reloadGroupThumbnail( + db, + c.env.BLOB, + onshapeApi, + libraryId, + groupId + ); } else { throw handledError( "Name a group or an element to reload.", diff --git a/src/backend/features/thumbnails/routes.test.ts b/src/backend/features/thumbnails/routes.worker.test.ts similarity index 50% rename from src/backend/features/thumbnails/routes.test.ts rename to src/backend/features/thumbnails/routes.worker.test.ts index a2b622a9a..1757212d1 100644 --- a/src/backend/features/thumbnails/routes.test.ts +++ b/src/backend/features/thumbnails/routes.worker.test.ts @@ -1,8 +1,19 @@ import { env } from "cloudflare:workers"; -import { runDurableObjectAlarm } from "cloudflare:test"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTestApp, jsonRequest } from "../../../__test_utils__"; -import { RenderSource, ThumbnailSize } from "./contract"; +import { introspectWorkflow } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TEST_GROUP_ID, + TEST_PART_STUDIO_ID, + createTestApp, + jsonRequest, + resetDb, + seedPartStudio +} from "../../../__test_utils__"; +import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; +import { getDb } from "../../db/client"; +import { groups } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { type RenderOut, RenderStatus, ThumbnailSize } from "./contract"; import { parseThumbnailKey, parseThumbnailUrl, @@ -17,17 +28,19 @@ const MICROVERSION = "mv-1"; const CANONICAL_CONFIGURATION = "size=l"; const SESSION_ID = "test-session"; -const INSERTABLE_ID = "test-insertable"; + +const db = getDb(env.DB); function get(url: string, sessionId?: string) { const init = jsonRequest("GET"); if (sessionId) { init.headers = { ...init.headers, - Cookie: `frc-design-app-cookie=${sessionId}` + Cookie: `frc-design-app-session=${sessionId}` }; } - return createTestApp().request(url, init, env); + // Signed in only with a session, as a real caller is. + return createTestApp({ signedIn: !!sessionId }).request(url, init, env); } describe("thumbnailKey", () => { @@ -37,8 +50,7 @@ describe("thumbnailKey", () => { ); }); - // A configuration's separators would otherwise open path segments of their - // own, so two different selections could name one key. + // Otherwise two selections could name one key. it("encodes a configuration into a single segment", () => { const key = thumbnailKey("e1", MICROVERSION, SIZE, "a=1;b=2/3"); expect(key).toBe( @@ -54,8 +66,7 @@ describe("thumbnailKey", () => { }); }); -// Reconciliation reads keys and urls back to decide what to delete, so the -// readers have to keep pace with the builders above them. +// Reconciliation deletes by what these read back. describe("reading a thumbnail address back", () => { const SUBJECT = { elementId: "e1", microversionId: MICROVERSION }; @@ -107,9 +118,7 @@ describe("reading a thumbnail address back", () => { const url = thumbnailUrl({ ...SUBJECT, size: SIZE, - configurationKey: "a=1;b=2", - renderSource: RenderSource.ROW, - insertableId: INSERTABLE_ID + configurationKey: "a=1;b=2" }); expect(parseThumbnailUrl(url)).toEqual(SUBJECT); }); @@ -158,8 +167,7 @@ describe("thumbnail serving", () => { expect(res.status).toBe(400); }); - // Standing the element in would show a part nobody asked for: a favorite - // pinned to a configuration would render as the default one. + // The element's default would show a part nobody asked for. it("misses rather than standing the element default in", async () => { const elementId = "unrendered-configuration"; await env.BLOB.put( @@ -248,189 +256,147 @@ describe("thumbnail serving", () => { }); }); -describe("queueing a configuration's thumbnail", () => { - /** The queue the route enqueues on; `createTestApp` signs in as this user. */ - const queue = () => env.THUMBNAIL_RENDERER.getByName("test-user"); +describe("rendering a configuration's thumbnail", () => { + afterEach(() => vi.restoreAllMocks()); - /** Seeds only the default, so a configuration request always misses. */ - async function seedDefaultOnly(elementId: string) { - await env.BLOB.put( - thumbnailKey(elementId, MICROVERSION, SIZE), - "default-bytes" + async function render( + configurationKey = CANONICAL_CONFIGURATION, + signedIn = true + ): Promise { + const init = jsonRequest("POST", { configurationKey }); + if (signedIn) { + init.headers = { + ...init.headers, + Cookie: `frc-design-app-session=${SESSION_ID}` + }; + } + return createTestApp({ signedIn }).request( + `/api/render-thumbnail/insertable/${TEST_PART_STUDIO_ID}`, + init, + env ); } - function renderUrl(renderSource: RenderSource, elementId: string) { - return thumbnailUrl({ - elementId, - microversionId: MICROVERSION, - size: SIZE, - configurationKey: CANONICAL_CONFIGURATION, - renderSource, - insertableId: INSERTABLE_ID - }); - } - - async function getRender( - elementId: string, - renderSource = RenderSource.ROW - ) { - return get(renderUrl(renderSource, elementId), SESSION_ID); + async function statusOf(res: Response): Promise { + const body: RenderOut = await res.json(); + return body.status; } - /** What the queue holds for one element, whatever else is in it. */ - async function queuedFor(elementId: string) { - const jobs = await queue().queued(); - return jobs.filter((job) => job.key.includes(elementId)); + /** Onshape resolving the configuration to a render id. */ + function mockThumbnailId() { + return vi + .spyOn(ThumbnailEndpoints, "getThumbnailId") + .mockResolvedValue("thumbnail-id"); } - it("passes the source the validator accepts", () => { - const url = thumbnailUrl({ - elementId: "any", - microversionId: MICROVERSION, - size: SIZE, - configurationKey: CANONICAL_CONFIGURATION, - renderSource: RenderSource.INSERT_MENU, - insertableId: INSERTABLE_ID - }); - expect(new URL(url, "http://x").searchParams.get("renderSource")).toBe( - "insert" + /** Workflows started while `run` is called, with their steps stubbed out. */ + async function startedDuring(run: () => Promise) { + await using workflows = await introspectWorkflow( + env.RENDER_THUMBNAIL_WORKFLOW ); - }); - - // Without one there is nothing to resolve the element from, so the render - // is simply not requested. - it("omits the source when no insertable is named", () => { - const url = thumbnailUrl({ - elementId: "any", - microversionId: MICROVERSION, - size: SIZE, - configurationKey: CANONICAL_CONFIGURATION, - renderSource: RenderSource.ROW + await workflows.modifyAll(async (m) => { + for (const size of Object.values(ThumbnailSize)) { + await m.mockStepResult({ name: `store-${size}` }, null); + } }); - expect( - new URL(url, "http://x").searchParams.get("renderSource") - ).toBeNull(); + await run(); + return (await workflows.get()).length; + } + + beforeEach(async () => { + await resetDb(db); + await seedPartStudio(db); + await db + .update(groups) + .set({ thumbnailWorkspaceId: "w-branch" }) + .where(eq(groups.id, TEST_GROUP_ID)); }); - // Both, so the row and the hover card it opens never disagree. - it("queues both sizes on a miss", async () => { - await seedDefaultOnly("warm-element"); + it("starts one render, however often it is asked", async () => { + const thumbnailId = mockThumbnailId(); - const res = await getRender("warm-element"); + const started = await startedDuring(async () => { + expect(await statusOf(await render())).toBe(RenderStatus.RENDERING); + await render(); + }); - expect(res.status).toBe(404); - expect((await queuedFor("warm-element")).map((job) => job.key)).toEqual( - expect.arrayContaining([ - thumbnailKey( - "warm-element", - MICROVERSION, - ThumbnailSize.SMALL, - CANONICAL_CONFIGURATION - ), - thumbnailKey( - "warm-element", - MICROVERSION, - ThumbnailSize.LARGE, - CANONICAL_CONFIGURATION - ) - ]) - ); + expect(started).toBe(1); + expect(thumbnailId).toHaveBeenCalledTimes(2); }); - // Polling is how the client waits, so asking twice has to be asking once. - it("queues nothing new when the same render is asked for again", async () => { - await seedDefaultOnly("repeat-element"); + // Restarting the first's instance would store its bytes under its own key. + it("renders each configuration, even ones Onshape gives one thumbnail id", async () => { + mockThumbnailId(); - await getRender("repeat-element"); - await getRender("repeat-element"); + const started = await startedDuring(async () => { + await render(); + await render("size=other"); + }); - expect(await queuedFor("repeat-element")).toHaveLength(2); + expect(started).toBe(2); }); - it("records which surface asked, since that is what orders the queue", async () => { - await seedDefaultOnly("sourced-element"); + it("renders from the group's thumbnail workspace", async () => { + const thumbnailId = mockThumbnailId(); - await getRender("sourced-element", RenderSource.INSERT_MENU); + await startedDuring(() => render()); - const jobs = await queuedFor("sourced-element"); - expect( - jobs.every((job) => job.source === RenderSource.INSERT_MENU) - ).toBe(true); + expect(thumbnailId.mock.calls[0][1]).toMatchObject({ + instanceId: "w-branch", + instanceType: "w" + }); }); - /** What the renderer resolves its Onshape tokens from. */ - async function seedRendererSession() { - await env.KV.put( - `tokens:${SESSION_ID}`, - JSON.stringify({ - accessToken: "token", - refreshToken: "refresh", - expiresAt: Date.now() + 3_600_000, - userId: "test-user" - }) + // The client words "still rendering" and "never will" differently. + it("says when the configuration has no part to render", async () => { + vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockResolvedValue( + undefined ); - } - - /** The route's answer once the queue has had the chance to fail the job. */ - async function statusAfterDraining(elementId: string): Promise { - for (let pass = 0; pass < 20; pass++) { - await runDurableObjectAlarm(queue()); - const { status } = await getRender(elementId); - if (status !== 404) return status; - } - return 404; - } - - // A miss is a render still coming; this is one that never will be, and the - // client shows different wording for each. - it("answers a render that cannot resolve its element with its own status", async () => { - await seedDefaultOnly("invalid-element"); - await seedRendererSession(); - // Nothing stores a row for INSERTABLE_ID, so the render has no element - // path to resolve — the terminal failure a bad configuration also takes. - expect((await getRender("invalid-element")).status).toBe(404); - - expect(await statusAfterDraining("invalid-element")).toBe(422); + const started = await startedDuring(async () => { + expect(await statusOf(await render())).toBe(RenderStatus.NO_PART); + }); + expect(started).toBe(0); }); - it("queues no render when there is no session to run it under", async () => { - await seedDefaultOnly("sessionless-element"); - - const res = await get( - renderUrl(RenderSource.ROW, "sessionless-element") - ); + it("starts nothing for a group with no thumbnail workspace yet", async () => { + await db + .update(groups) + .set({ thumbnailWorkspaceId: null }) + .where(eq(groups.id, TEST_GROUP_ID)); + mockThumbnailId(); - expect(res.status).toBe(404); - expect(await queuedFor("sessionless-element")).toEqual([]); + const started = await startedDuring(() => render()); + expect(started).toBe(0); }); - // Search results show many configurations at once; one cold search must not - // queue a render per row against a thread that runs one at a time. - it("queues nothing when no source is named", async () => { - await seedDefaultOnly("cold-element"); + it("refuses a caller with no session to render under", async () => { + const thumbnailId = mockThumbnailId(); - const res = await get( - thumbnailUrl({ - elementId: "cold-element", - microversionId: MICROVERSION, - size: SIZE, - configurationKey: CANONICAL_CONFIGURATION - }), - SESSION_ID - ); + const started = await startedDuring(async () => { + expect((await render(CANONICAL_CONFIGURATION, false)).ok).toBe( + false + ); + }); - expect(res.status).toBe(404); - expect(await queuedFor("cold-element")).toEqual([]); + expect(started).toBe(0); + expect(thumbnailId).not.toHaveBeenCalled(); }); - // A client must not be able to label itself a library load, nor invent a - // surface that outranks the insert menu. - it("rejects a source that is not one a client may claim", async () => { - const res = await get( - `/api/thumbnail/${SIZE}/any?v=${MICROVERSION}&configurationKey=x&renderSource=load` - ); - expect(res.status).toBe(400); + // One cold search mustn't start a render per row. + it("starts nothing on a stored thumbnail's miss", async () => { + const started = await startedDuring(async () => { + const res = await get( + thumbnailUrl({ + elementId: "cold-element", + microversionId: MICROVERSION, + size: SIZE, + configurationKey: CANONICAL_CONFIGURATION + }), + SESSION_ID + ); + expect(res.status).toBe(404); + }); + expect(started).toBe(0); }); }); diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts index f4fc7adba..7015263c1 100644 --- a/src/backend/features/thumbnails/store.ts +++ b/src/backend/features/thumbnails/store.ts @@ -1,8 +1,3 @@ -/** - * Where thumbnails live in R2, and how a caller reads one back. Only - * `ThumbnailRenderer` asks Onshape for one; this is the storage either side. - */ - import { CachePolicy, immutableCacheControl } from "../../lib/cache"; import { getElementThumbnail } from "../../lib/onshape/endpoints/thumbnails"; @@ -16,11 +11,7 @@ import { } from "../configurations/contract"; import { OnshapeApi } from "../../lib/onshape/client"; -/** - * What produced a stored thumbnail, tagged onto the R2 object. The key already - * addresses it; this is for reading an object back and telling what it is. - */ -export interface ThumbnailMetadata extends Record { +interface ThumbnailMetadata extends Record { microversionId: string; /** Empty for an element's own thumbnail, as everywhere else. */ configurationKey: ConfigurationKey; @@ -45,53 +36,27 @@ export async function putThumbnail( const BOTH_SIZES = [ThumbnailSize.SMALL, ThumbnailSize.LARGE]; /** - * One size, the version first and the workspace when it will not answer. - * - * Any failure pivots, not just a 404: the version form of this endpoint has - * been unreliable for element thumbnails and the workspace form has not, but - * the version is what the library shows, so it is still asked first. - */ -async function fetchThumbnail( - onshapeApi: OnshapeApi, - elementPath: ElementPath, - elementWorkspacePath: ElementPath, - size: ThumbnailSize -): Promise { - try { - return await getElementThumbnail(onshapeApi, elementPath, size); - } catch { - return getElementThumbnail(onshapeApi, elementWorkspacePath, size); - } -} - -/** - * Stores both sizes, skipping either the bucket already holds, and throws when - * neither instance will give one up. - * - * Onshape renders these when a document is saved, so reading one starts no work - * and races nothing — unlike a configuration, which `ThumbnailRenderer` has to - * serialize. A load fetches them directly, several elements at a time. + * Skips sizes already stored; throws while Onshape hasn't rendered. Keyed by + * the version's microversion though read from the thumbnail workspace, + * assuming the restored content renders the same. */ export async function uploadThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, - elementPath: ElementPath, - elementWorkspacePath: ElementPath, + thumbnailPath: ElementPath, microversionId: string ): Promise { - const { elementId } = elementPath; + const { elementId } = thumbnailPath; - // One size at a time: an attempt that fails should cost one call rather - // than two, and what runs in parallel is elements, not their sizes. + // Sequential, so a failed attempt costs one call. for (const size of BOTH_SIZES) { const key = thumbnailKey(elementId, microversionId, size); if (await bucket.head(key)) { continue; } - const thumbnail = await fetchThumbnail( + const thumbnail = await getElementThumbnail( onshapeApi, - elementPath, - elementWorkspacePath, + thumbnailPath, size ); await putThumbnail(bucket, key, thumbnail, { @@ -124,26 +89,3 @@ export function thumbnailUrls( }) }; } - -/** - * The urls for a subject, or null while either size is still rendering. Both or - * neither, so nothing records half a pair. - */ -export async function readThumbnailUrls( - bucket: R2Bucket, - elementId: string, - microversionId: string, - configurationKey: ConfigurationKey = DEFAULT_CONFIGURATION_KEY -): Promise { - const stored = await Promise.all( - BOTH_SIZES.map((size) => - bucket.head( - thumbnailKey(elementId, microversionId, size, configurationKey) - ) - ) - ); - if (stored.some((object) => object === null)) { - return null; - } - return thumbnailUrls(elementId, microversionId, configurationKey); -} diff --git a/src/backend/features/thumbnails/workspace.ts b/src/backend/features/thumbnails/workspace.ts new file mode 100644 index 000000000..e976d8718 --- /dev/null +++ b/src/backend/features/thumbnails/workspace.ts @@ -0,0 +1,78 @@ +/** + * Each document gets a workspace to read thumbnails from: Onshape sometimes + * never renders them in a version, and the document's own workspace drifts. + * Each new version gets a fresh one branched off it, and the old one is + * deleted once nothing reads it, so the document keeps no branches of ours. A + * new branch takes minutes to render. + */ +import { type OnshapeApi } from "../../lib/onshape/client"; +import { type DocumentPath, type InstancePath } from "../../lib/onshape/path"; +import { + createWorkspace, + deleteWorkspace, + getWorkspaces +} from "../../lib/onshape/endpoints/workspaces"; +import { type OnshapeWorkspaceInfo } from "../../lib/onshape/types"; + +/** Cleanup deletes nothing without it. */ +const WORKSPACE_NAME = "FRCDesignApp Thumbnails (DO NOT EDIT)"; + +function isOurs(workspace: OnshapeWorkspaceInfo): boolean { + return workspace.name === WORKSPACE_NAME; +} + +/** Names the version, so a load of it finds the workspace already made. */ +export function thumbnailWorkspaceDescription(versionId: string): string { + return `Made by the FRCDesignApp to read thumbnails from version ${versionId}.`; +} + +/** + * A workspace of ours branched off `versionPath`, made if there isn't one. + * Shared by every group of the document and by a retried step. + */ +export async function syncThumbnailWorkspace( + client: OnshapeApi, + versionPath: InstancePath +): Promise { + const description = thumbnailWorkspaceDescription(versionPath.instanceId); + const existing = (await getWorkspaces(client, versionPath)).find( + (workspace) => + isOurs(workspace) && workspace.description === description + ); + const workspace = + existing ?? + (await createWorkspace(client, versionPath, { + name: WORKSPACE_NAME, + description, + versionId: versionPath.instanceId + })); + return toWorkspacePath(versionPath, workspace.id); +} + +function toWorkspacePath( + document: DocumentPath, + workspaceId: string +): InstancePath { + return { + documentId: document.documentId, + instanceId: workspaceId, + instanceType: "w" + }; +} + +/** + * Every workspace of ours no group names. A group in another library can be + * pinned to an older version, held for approval, and still read from its own. + */ +export async function deleteStaleThumbnailWorkspaces( + client: OnshapeApi, + documentPath: DocumentPath, + keepWorkspaceIds: ReadonlySet +): Promise { + const stale = (await getWorkspaces(client, documentPath)).filter( + (workspace) => isOurs(workspace) && !keepWorkspaceIds.has(workspace.id) + ); + for (const workspace of stale) { + await deleteWorkspace(client, documentPath, workspace.id); + } +} diff --git a/src/backend/features/webhooks/registration.ts b/src/backend/features/webhooks/registration.ts new file mode 100644 index 000000000..228338e04 --- /dev/null +++ b/src/backend/features/webhooks/registration.ts @@ -0,0 +1,161 @@ +/** + * One per library document, for its new versions, registered with + * `isTransient: false`, which exempts it from Onshape's cleanup. + */ +import { and, eq } from "drizzle-orm"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { onshapeWebhooks, WebhookSubject } from "../../db/schema"; +import { type OAuthApi, OnshapeApiError } from "../../lib/onshape/client"; +import { + createWebhook, + deleteWebhook, + getWebhook +} from "../../lib/onshape/endpoints/webhooks"; + +/** Under `/api`. */ +export const WEBHOOK_ROUTE = "/webhooks/onshape"; + +export enum WebhookEvent { + CREATE_VERSION = "onshape.model.lifecycle.createversion", + UNREGISTER = "webhook.unregister" +} + +export type RegisteredWebhook = typeof onshapeWebhooks.$inferSelect; + +function whereSubject(subject: WebhookSubject, subjectId: string) { + return and( + eq(onshapeWebhooks.subject, subject), + eq(onshapeWebhooks.subjectId, subjectId) + ); +} + +function subjectParams(subject: WebhookSubject, subjectId: string) { + switch (subject) { + case WebhookSubject.DOCUMENT: + return { + documentId: subjectId, + events: [WebhookEvent.CREATE_VERSION] + }; + } +} + +/** + * Whether Onshape still has it. A webhook it cancelled (a failed registration + * ping) or deactivated (deliveries that errored) sends us nothing to say so. + */ +async function isStillRegistered( + onshapeApi: OAuthApi, + webhookId: string +): Promise { + try { + await getWebhook(onshapeApi, webhookId); + return true; + } catch (error) { + if (error instanceof OnshapeApiError && error.status === 404) { + return false; + } + throw error; + } +} + +/** Registers a webhook for the subject unless Onshape still has the one on record. */ +export async function ensureWebhook( + env: AppBindings, + onshapeApi: OAuthApi, + subject: WebhookSubject, + subjectId: string +): Promise { + const db = getDb(env.DB); + const existing = await db + .select({ webhookId: onshapeWebhooks.webhookId }) + .from(onshapeWebhooks) + .where(whereSubject(subject, subjectId)) + .get(); + if ( + existing?.webhookId && + (await isStillRegistered(onshapeApi, existing.webhookId)) + ) { + return; + } + if (existing?.webhookId) { + console.warn("Webhook gone from Onshape; registering again", { + subject, + subjectId, + webhookId: existing.webhookId + }); + } + + // Stored first: Onshape posts webhook.register before create returns. + const token = crypto.randomUUID(); + await db + .insert(onshapeWebhooks) + .values({ subject, subjectId, token }) + .onConflictDoUpdate({ + target: [onshapeWebhooks.subject, onshapeWebhooks.subjectId], + set: { token, webhookId: null } + }); + const url = new URL("/api" + WEBHOOK_ROUTE, env.APP_URL); + url.searchParams.set("token", token); + + const webhook = await createWebhook(onshapeApi, { + ...subjectParams(subject, subjectId), + url: url.href, + name: "FRCDesignApp", + description: `Keeps the FRCDesignApp in step with this ${subject}.`, + options: { collapseEvents: false }, + isTransient: false + }); + await db + .update(onshapeWebhooks) + .set({ webhookId: webhook.id }) + .where(whereSubject(subject, subjectId)); +} + +/** Unregisters the subject's webhook, when nothing needs it any more. */ +export async function removeWebhook( + env: AppBindings, + onshapeApi: OAuthApi, + subject: WebhookSubject, + subjectId: string +): Promise { + const db = getDb(env.DB); + const existing = await db + .select({ webhookId: onshapeWebhooks.webhookId }) + .from(onshapeWebhooks) + .where(whereSubject(subject, subjectId)) + .get(); + if (existing?.webhookId) { + try { + await deleteWebhook(onshapeApi, existing.webhookId); + } catch (error) { + // Already gone is what was wanted. + if (!(error instanceof OnshapeApiError && error.status === 404)) { + throw error; + } + } + } + await db.delete(onshapeWebhooks).where(whereSubject(subject, subjectId)); +} + +/** The webhook a delivery's token belongs to, or undefined for a stranger. */ +export function findWebhookByToken( + env: AppBindings, + token: string +): Promise { + return getDb(env.DB) + .select() + .from(onshapeWebhooks) + .where(eq(onshapeWebhooks.token, token)) + .get(); +} + +/** So the next load registers another. */ +export async function forgetWebhook( + env: AppBindings, + webhook: RegisteredWebhook +): Promise { + await getDb(env.DB) + .delete(onshapeWebhooks) + .where(whereSubject(webhook.subject, webhook.subjectId)); +} diff --git a/src/backend/features/webhooks/registration.worker.test.ts b/src/backend/features/webhooks/registration.worker.test.ts new file mode 100644 index 000000000..dd72596ec --- /dev/null +++ b/src/backend/features/webhooks/registration.worker.test.ts @@ -0,0 +1,115 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { resetDb } from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { onshapeWebhooks, WebhookSubject } from "../../db/schema"; +import { OnshapeApiError } from "../../lib/onshape/client"; +import { ensureWebhook, removeWebhook } from "./registration"; + +const db = getDb(env.DB); +const ORIGIN = "https://app.example.com"; + +function mockOnshape() { + const onshapeApi = new MockOnshapeApi(); + vi.spyOn(onshapeApi, "get").mockResolvedValue({ + id: "owner", + company: { id: "company" } + }); + const post = vi + .spyOn(onshapeApi, "post") + .mockResolvedValue({ id: "new-webhook" }); + const remove = vi + .spyOn(onshapeApi, "deleteNone") + .mockResolvedValue(undefined); + return { onshapeApi, post, remove }; +} + +const stored = () => db.select().from(onshapeWebhooks).all(); + +describe("registering webhooks", () => { + beforeEach(() => resetDb(db)); + afterEach(() => vi.restoreAllMocks()); + + it("registers a document's for its new versions, delivered with its token", async () => { + const { onshapeApi, post } = mockOnshape(); + + await ensureWebhook( + { ...env, APP_URL: ORIGIN }, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc" + ); + + const [row] = await stored(); + expect(row.webhookId).toBe("new-webhook"); + expect(post).toHaveBeenCalledWith("/webhooks", { + body: expect.objectContaining({ + documentId: "doc", + events: ["onshape.model.lifecycle.createversion"], + url: `${ORIGIN}/api/webhooks/onshape?token=${row.token}`, + isTransient: false + }) as unknown + }); + }); + + // Registered as never transient, so one on record is taken to stand. + it("registers nothing for a subject already registered", async () => { + const { onshapeApi, post } = mockOnshape(); + await ensureWebhook( + { ...env, APP_URL: ORIGIN }, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc" + ); + await ensureWebhook( + { ...env, APP_URL: ORIGIN }, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc" + ); + expect(post).toHaveBeenCalledOnce(); + }); + + // A cancelled or deactivated webhook sends nothing to say so. + it("registers again when Onshape no longer has the one on record", async () => { + const { onshapeApi, post } = mockOnshape(); + await ensureWebhook( + { ...env, APP_URL: ORIGIN }, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc" + ); + vi.spyOn(onshapeApi, "get").mockRejectedValue( + new OnshapeApiError("gone", 404) + ); + post.mockResolvedValue({ id: "replacement" }); + + await ensureWebhook( + { ...env, APP_URL: ORIGIN }, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc" + ); + + expect(post).toHaveBeenCalledTimes(2); + const [row] = await stored(); + expect(row.webhookId).toBe("replacement"); + }); + + it("removes one Onshape already dropped without complaint", async () => { + const { onshapeApi, remove } = mockOnshape(); + await ensureWebhook( + { ...env, APP_URL: ORIGIN }, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc" + ); + remove.mockRejectedValue(new OnshapeApiError("gone", 404)); + + await removeWebhook(env, onshapeApi, WebhookSubject.DOCUMENT, "doc"); + + expect(remove).toHaveBeenCalledWith("/webhooks/new-webhook"); + expect(await stored()).toEqual([]); + }); +}); diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts new file mode 100644 index 000000000..b58062fcb --- /dev/null +++ b/src/backend/features/webhooks/routes.ts @@ -0,0 +1,96 @@ +/** + * Acts on the subject the url's token was registered for, never on what the + * payload names: the token is all that keeps others from triggering this. + */ +import { eq } from "drizzle-orm"; +import { type AppBindings, getApp } from "../../lib/context"; +import { forbiddenError } from "../../lib/api-error"; +import { getDb } from "../../db/client"; +import { groups, libraries, WebhookSubject } from "../../db/schema"; +import { requestLoads } from "../load/jobs"; +import { + findWebhookByToken, + forgetWebhook, + WEBHOOK_ROUTE, + WebhookEvent +} from "./registration"; +import { runInBackground } from "../../lib/background"; +import { readUnitsDelivery, UNITS_WEBHOOK_ROUTE } from "./transient"; +import { forgetUnitInfo } from "../configurations/units"; + +export const webhookRoutes = getApp(); + +/** The fields read off a notification; Onshape sends more. */ +interface WebhookNotification { + event: string; +} + +/** + * POST /api/webhooks/onshape?token=. Answers 200 at once and does the work + * after: Onshape deactivates a webhook whose deliveries error or stall. + */ +webhookRoutes.post(WEBHOOK_ROUTE, async (c) => { + const { event } = await c.req.json(); + const token = c.req.query("token"); + const webhook = token ? await findWebhookByToken(c.env, token) : undefined; + if (!webhook) { + console.warn("Webhook delivery with an unknown token", { event }); + throw forbiddenError("Unrecognized webhook"); + } + const { subject, subjectId } = webhook; + + await runInBackground(c, `handle ${event} for ${subjectId}`, async () => { + switch (event) { + case WebhookEvent.CREATE_VERSION: + if (subject === WebhookSubject.DOCUMENT) { + await reloadDocument(c.env, subjectId); + } + break; + case WebhookEvent.UNREGISTER: + await forgetWebhook(c.env, webhook); + break; + // Anything else, webhook.register included, only wants the 200. + } + }); + return c.json({}); +}); + +/** POST /api/webhooks/units?documentId=&workspaceId= */ +webhookRoutes.post(UNITS_WEBHOOK_ROUTE, async (c) => { + const workspace = readUnitsDelivery(c.req.query()); + if (!workspace) { + throw forbiddenError("Unrecognized webhook"); + } + // Registration and pings only want a 200; any other event is the change. + const { event } = await c.req.json(); + if (!event.startsWith("webhook.")) { + await forgetUnitInfo(c.env.KV, workspace); + } + return c.json({}); +}); + +/** + * Nobody is signed in behind a webhook, so each load finds an admin's session. + * A library that approves versions holds the load until an admin does. + */ +async function reloadDocument( + env: AppBindings, + documentId: string +): Promise { + const documentGroups = await getDb(env.DB) + .select({ + groupId: groups.id, + libraryId: groups.libraryId, + awaitApproval: libraries.approveVersions + }) + .from(groups) + .innerJoin(libraries, eq(libraries.id, groups.libraryId)) + .where(eq(groups.documentId, documentId)); + await requestLoads( + env, + documentGroups.map((group) => ({ + ...group, + forceReload: false + })) + ); +} diff --git a/src/backend/features/webhooks/routes.worker.test.ts b/src/backend/features/webhooks/routes.worker.test.ts new file mode 100644 index 000000000..d03ee3023 --- /dev/null +++ b/src/backend/features/webhooks/routes.worker.test.ts @@ -0,0 +1,96 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TEST_GROUP_ID, + TEST_LIBRARY_ID, + createTestApp, + jsonRequest, + resetDb, + seedGroup +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { onshapeWebhooks, WebhookSubject } from "../../db/schema"; +import * as Jobs from "../load/jobs"; +import { WebhookEvent } from "./registration"; + +const db = getDb(env.DB); +const DOCUMENT = `doc-${TEST_GROUP_ID}`; + +/** Delivers a notification the way Onshape would, with `token` on the url. */ +function deliver(body: object, token: string) { + return createTestApp().request( + `http://localhost/api/webhooks/onshape?token=${token}`, + jsonRequest("POST", body), + env + ); +} + +async function registered(subject: WebhookSubject, subjectId: string) { + const token = `${subject}-token`; + await db + .insert(onshapeWebhooks) + .values({ subject, subjectId, token, webhookId: `${subject}-webhook` }); + return token; +} + +describe("receiving a webhook", () => { + beforeEach(async () => { + await resetDb(db); + await seedGroup(db, TEST_GROUP_ID); + }); + afterEach(() => vi.restoreAllMocks()); + + it("turns away a delivery without a registered token", async () => { + await registered(WebhookSubject.DOCUMENT, DOCUMENT); + const res = await deliver({ event: "webhook.ping" }, "guess"); + expect(res.status).toBe(403); + }); + + // Onshape deactivates a webhook whose deliveries error. + it("answers 200 even when handling the delivery fails", async () => { + const token = await registered(WebhookSubject.DOCUMENT, DOCUMENT); + vi.spyOn(Jobs, "requestLoads").mockRejectedValue(new Error("down")); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const res = await deliver( + { event: WebhookEvent.CREATE_VERSION }, + token + ); + expect(res.status).toBe(200); + }); + + // Registration fails unless Onshape's own check is answered. + it("answers Onshape's registration check", async () => { + const token = await registered(WebhookSubject.DOCUMENT, DOCUMENT); + const res = await deliver({ event: "webhook.register" }, token); + expect(res.status).toBe(200); + }); + + // Nobody is signed in behind a webhook, so the load finds a session itself. + it("loads the document's groups on a new version, with no session", async () => { + const token = await registered(WebhookSubject.DOCUMENT, DOCUMENT); + const load = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); + + await deliver( + // What the payload names is not trusted; the token's subject is. + { event: WebhookEvent.CREATE_VERSION, documentId: "elsewhere" }, + token + ); + + expect(load).toHaveBeenCalledWith(expect.anything(), [ + { + libraryId: TEST_LIBRARY_ID, + groupId: TEST_GROUP_ID, + awaitApproval: false, + forceReload: false + } + ]); + }); + + // So the next load of the document registers a new one. + it("forgets a webhook Onshape dropped", async () => { + const token = await registered(WebhookSubject.DOCUMENT, DOCUMENT); + await deliver({ event: WebhookEvent.UNREGISTER }, token); + expect(await db.select().from(onshapeWebhooks).all()).toEqual([]); + }); +}); diff --git a/src/backend/features/webhooks/transient.ts b/src/backend/features/webhooks/transient.ts new file mode 100644 index 000000000..36ba87092 --- /dev/null +++ b/src/backend/features/webhooks/transient.ts @@ -0,0 +1,46 @@ +/** + * Transient webhooks, for caches that save Onshape calls. Onshape deletes one + * after a while without events, so they are registered best effort and never + * recorded or removed. Onshape doesn't sign what the API registers, and the + * url carries its subject unsigned: a forged delivery only costs a refetch. + */ +import type { OnshapeApi } from "../../lib/onshape/client"; +import { createWebhook } from "../../lib/onshape/endpoints/webhooks"; +import type { InstancePath } from "../../lib/onshape/path"; + +/** Under `/api`. */ +export const UNITS_WEBHOOK_ROUTE = "/webhooks/units"; + +const UPDATE_WORKSPACE_UNITS = "onshape.model.lifecycle.updateworkspaceunits"; + +/** Tells the units cache when the workspace's units change. */ +export async function watchWorkspaceUnits( + onshapeApi: OnshapeApi, + workspace: InstancePath, + appUrl: string +): Promise { + const url = new URL("/api" + UNITS_WEBHOOK_ROUTE, appUrl); + url.searchParams.set("documentId", workspace.documentId); + url.searchParams.set("workspaceId", workspace.instanceId); + await createWebhook(onshapeApi, { + documentId: workspace.documentId, + workspaceId: workspace.instanceId, + events: [UPDATE_WORKSPACE_UNITS], + url: url.href, + name: "FRCDesignApp units", + description: "Keeps the FRCDesignApp's copy of these units current.", + options: { collapseEvents: true }, + isTransient: true + }); +} + +/** The workspace a units delivery names. */ +export function readUnitsDelivery( + query: Record +): InstancePath | undefined { + const { documentId, workspaceId } = query; + if (!documentId || !workspaceId) { + return undefined; + } + return { documentId, instanceId: workspaceId, instanceType: "w" }; +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 169eec74a..a53fa1c89 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,14 +1,13 @@ -/** - * Re-exported because the runtime looks them up here: a Durable Object or - * Workflow class has to be an export of the Worker's entrypoint for the - * `class_name`s in wrangler.jsonc to resolve. - */ -export { - AddGroupWorkflow, - LoadLibraryWorkflow -} from "./features/load/workflows"; -export { ThumbnailRenderer } from "./features/thumbnails/renderer"; +/** Workflow and Durable Object classes must be exported here for wrangler.jsonc's `class_name`s to resolve. */ +export { LoadDocumentWorkflow } from "./features/load/workflows"; +export { RenderThumbnailWorkflow } from "./features/thumbnails/render-workflow"; +export { PushHub } from "./features/push/push-hub"; import { createApp } from "./app"; import { productionAuth } from "./features/auth/request-auth"; +import type { AppBindings } from "./lib/context"; -export default createApp(productionAuth); +const app = createApp(productionAuth); + +export default { + fetch: app.fetch +} satisfies ExportedHandler; diff --git a/src/backend/lib/api-error.ts b/src/backend/lib/api-error.ts index d04c6f001..6f7e11cb8 100644 --- a/src/backend/lib/api-error.ts +++ b/src/backend/lib/api-error.ts @@ -1,10 +1,6 @@ -/** - * The one shape every failed /api response takes; `kind` tells the client what - * to do about it. A leaf module the frontend imports. - */ +/** Every failed /api response; `kind` tells the client what to do. A leaf the frontend imports. */ import { HttpStatus } from "http-status-ts"; -// Type-only, so this stays a leaf the frontend can import: the statuses that -// can carry a body, which is every status an error of ours is sent with. +// Type-only, to stay a leaf. import type { ContentfulStatusCode } from "hono/utils/http-status"; export enum ApiErrorKind { diff --git a/src/backend/lib/background.ts b/src/backend/lib/background.ts new file mode 100644 index 000000000..5bebb4dcb --- /dev/null +++ b/src/backend/lib/background.ts @@ -0,0 +1,20 @@ +import type { AppContext } from "./context"; + +/** + * Runs `work` after the response, for what the caller need not wait on. Its + * errors are logged, never thrown. Awaits when there is no execution context. + */ +export async function runInBackground( + c: AppContext, + description: string, + work: () => Promise +): Promise { + const guarded = work().catch((error: unknown) => { + console.error(`Failed to ${description}`, error); + }); + try { + c.executionCtx.waitUntil(guarded); + } catch { + await guarded; + } +} diff --git a/src/backend/lib/cache.ts b/src/backend/lib/cache.ts index b1ca9c308..9cf5c7724 100644 --- a/src/backend/lib/cache.ts +++ b/src/backend/lib/cache.ts @@ -30,10 +30,7 @@ function cacheControl(policy: CachePolicy): string { : immutableCacheControl(policy); } -/** - * For a route whose answers differ: the same url can serve bytes it pins and a - * stand-in it does not. One whose answers are alike takes {@link cacheMiddleware}. - */ +/** For a route whose answers differ in cacheability; otherwise use {@link cacheMiddleware}. */ export function setCache(response: Response, policy: CachePolicy): Response { response.headers.set("Cache-Control", cacheControl(policy)); return response; @@ -51,8 +48,7 @@ export function cacheMiddleware( } return async (c, next) => { - // An immutable response has to be pinned by something, or the next - // version of it is unreachable behind the cache. + // Unpinned, the next version would be unreachable behind the cache. if (!c.req.query("v")) { throw internalError( "Missing cache version", diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 00251f480..4355b3f13 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -1,10 +1,9 @@ import { type Context, type MiddlewareHandler, Hono } from "hono"; -import type { - AddGroupParams, - LoadLibraryParams -} from "../features/load/workflows"; -import type { ThumbnailRenderer } from "../features/thumbnails/renderer"; +import type { LoadDocumentParams } from "../features/load/jobs"; +import type { RenderThumbnailParams } from "../features/thumbnails/render-workflow"; +import type { PushHub } from "../features/push/push-hub"; import { type AccessLevel } from "../features/auth/access-level"; +import type { LibraryId } from "../features/library/library-id"; import { type OAuthApi } from "./onshape/client"; export interface AppBindings { @@ -13,11 +12,16 @@ export interface AppBindings { ASSETS: Fetcher; /** Thumbnails and search indexes; prefixes keep them apart. */ BLOB: R2Bucket; - LOAD_LIBRARY_WORKFLOW: Workflow; - ADD_GROUP_WORKFLOW: Workflow; - /** One per Onshape user; every thumbnail Onshape renders queues here. */ - THUMBNAIL_RENDERER: DurableObjectNamespace; - ADMIN_TEAM: string; + /** One instance per group being loaded at a time; see `load/jobs.ts`. */ + LOAD_DOCUMENT_WORKFLOW: Workflow; + /** One instance per configuration being rendered; see `requestRender`. */ + RENDER_THUMBNAIL_WORKFLOW: Workflow; + /** Relays pushes to open clients; see `features/push`. */ + PUSH_HUB: DurableObjectNamespace; + /** Where the app is served, without a trailing slash; see `wrangler.jsonc`. */ + APP_URL: string; + /** The Onshape user id granted `AccessLevel.OWNER`; unset grants nobody. */ + OWNER_USER_ID?: string; /** Dev-only: the access level granted, bypassing Onshape. */ VITE_ACCESS_LEVEL_OVERRIDE?: string; /** Testing-only: treat requests as signed in with a fake user. Not for production. */ @@ -32,7 +36,7 @@ interface AppVariables { /** Injected by {@link bindAuth}; see {@link RequestAuth}. */ getOnshapeApi: () => Promise; getUserId: () => Promise; - getAccessLevel: () => Promise; + getAccessLevel: (libraryId: LibraryId) => Promise; isAuthenticated: () => Promise; } @@ -43,14 +47,12 @@ export interface AppContextEnv { export type AppContext = Context; -/** - * Who is making the request and what they may do. Resolved lazily, so a route that - * asks nothing calls Onshape not at all, and per request, so a test can answer. - */ +/** Lazy, so a route that asks nothing never calls Onshape; per request, so tests can stub it. */ interface RequestAuth { getOnshapeApi: () => Promise; getUserId: () => Promise; - getAccessLevel: () => Promise; + /** The caller's access to one library; the owner's is the same in all. */ + getAccessLevel: (libraryId: LibraryId) => Promise; isAuthenticated: () => Promise; } diff --git a/src/backend/lib/errors.ts b/src/backend/lib/errors.ts index f5a3a9149..638a35c21 100644 --- a/src/backend/lib/errors.ts +++ b/src/backend/lib/errors.ts @@ -12,10 +12,7 @@ import { } from "./api-error"; import type { AppContextEnv } from "./context"; -/** - * What we are willing to say about an Onshape failure. Anything not named here - * is ours to explain, not Onshape's, so it stays generic. - */ +/** Anything not named here stays generic. */ function fromOnshapeError(error: OnshapeApiError): ApiError { if (error instanceof OnshapeRateLimitError) { return handledError( @@ -50,8 +47,7 @@ export const errorHandler: ErrorHandler = (err, c) => { } return c.json(apiError.body, apiError.status); } - // A raw HTTPException is a validator rejecting a malformed request, which - // is our bug rather than something the user can act on. + // A validator rejecting a malformed request: our bug, not the user's. if (err instanceof HTTPException) { console.error(err); return c.json( diff --git a/src/backend/lib/errors.test.ts b/src/backend/lib/errors.worker.test.ts similarity index 89% rename from src/backend/lib/errors.test.ts rename to src/backend/lib/errors.worker.test.ts index 2ce16e8eb..b60e0bccf 100644 --- a/src/backend/lib/errors.test.ts +++ b/src/backend/lib/errors.worker.test.ts @@ -18,7 +18,7 @@ describe("api error responses", () => { const app = createTestApp({ signedIn: false }); const res = await app.request( - `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, jsonRequest("POST"), env ); @@ -34,7 +34,7 @@ describe("api error responses", () => { const app = createTestApp({ accessLevel: AccessLevel.USER }); const res = await app.request( - `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, jsonRequest("POST"), env ); @@ -46,8 +46,6 @@ describe("api error responses", () => { }); }); - // A malformed request is our bug, so the client falls back to its own - // wording rather than showing a validator's message. it("marks a rejected request as internal", async () => { const app = createTestApp({ accessLevel: AccessLevel.ADMIN }); diff --git a/src/backend/lib/kv-store.ts b/src/backend/lib/kv-store.ts new file mode 100644 index 000000000..9dcf32989 --- /dev/null +++ b/src/backend/lib/kv-store.ts @@ -0,0 +1,39 @@ +/** + * Every KV key belongs to one of these, so the namespace stays a set of + * declared stores rather than keys written from anywhere. Values are JSON. + */ +export interface KvStore { + get(kv: KVNamespace, id: string): Promise; + put(kv: KVNamespace, id: string, value: T): Promise; + delete(kv: KVNamespace, id: string): Promise; +} + +interface KvStoreOptions { + /** Omitted, entries last until deleted. */ + ttlSeconds?: number; +} + +export function kvStore( + prefix: string, + options: KvStoreOptions = {} +): KvStore { + const key = (id: string) => `${prefix}:${id}`; + return { + async get(kv, id) { + try { + return (await kv.get(key(id), "json")) ?? undefined; + } catch { + // Written in another shape; a miss is what to make of it. + return undefined; + } + }, + put(kv, id, value) { + return kv.put(key(id), JSON.stringify(value), { + expirationTtl: options.ttlSeconds + }); + }, + delete(kv, id) { + return kv.delete(key(id)); + } + }; +} diff --git a/src/backend/features/load/context.test.ts b/src/backend/lib/limiter.test.ts similarity index 96% rename from src/backend/features/load/context.test.ts rename to src/backend/lib/limiter.test.ts index de97b5c46..8999e45b7 100644 --- a/src/backend/features/load/context.test.ts +++ b/src/backend/lib/limiter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createLimiter } from "./context"; +import { createLimiter } from "./limiter"; describe("createLimiter", () => { it("never runs more than `max` tasks at once", async () => { diff --git a/src/backend/lib/limiter.ts b/src/backend/lib/limiter.ts new file mode 100644 index 000000000..38596bb20 --- /dev/null +++ b/src/backend/lib/limiter.ts @@ -0,0 +1,26 @@ +/** Runs a task, waiting for a slot when the limiter is full. */ +export type Limiter = (task: () => Promise) => Promise; + +/** Queues the rest in call order, so a rate-limit burst only hits the running few. */ +export function createLimiter(max: number): Limiter { + let active = 0; + const queue: (() => void)[] = []; + + const release = () => { + active--; + const next = queue.shift(); + if (next) next(); + }; + + return async (task: () => Promise): Promise => { + if (active >= max) { + await new Promise((resolve) => queue.push(resolve)); + } + active++; + try { + return await task(); + } finally { + release(); + } + }; +} diff --git a/src/backend/lib/onshape/api-path.ts b/src/backend/lib/onshape/api-path.ts deleted file mode 100644 index dcf9e376c..000000000 --- a/src/backend/lib/onshape/api-path.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DocumentPath } from "./path"; - -interface ApiPathOptions { - endRoute?: string; - endId?: string; - featureId?: string; - /** For the document-level endpoints that take a bare id, without `/d/`. */ - skipDocumentD?: boolean; -} - -/** - * @example - * apiPath("documents", instancePath, toInstanceApiPath, { endRoute: "elements" }) - * // → "/documents/d/{did}/w/{wid}/elements" - */ -export function apiPath( - route: string, - path?: T, - serialize?: (path: T) => string, - options?: ApiPathOptions -): string { - let result = route.startsWith("/") ? route : "/" + route; - - if (path !== undefined) { - if (options?.skipDocumentD) { - result += "/" + path.documentId; - } else if (serialize !== undefined) { - result += serialize(path); - } - } - - if (options?.endRoute !== undefined) { - const end = options.endRoute; - result += end.startsWith("/") ? end : "/" + end; - } - - if (options?.endId !== undefined) { - result += "/" + encodeURIComponent(options.endId); - } - - if (options?.featureId !== undefined) { - result += "/featureId/" + encodeURIComponent(options.featureId); - } - - return result; -} diff --git a/src/backend/lib/onshape/client.test.ts b/src/backend/lib/onshape/client.test.ts index 9735b3110..8dd3dc0fe 100644 --- a/src/backend/lib/onshape/client.test.ts +++ b/src/backend/lib/onshape/client.test.ts @@ -44,8 +44,6 @@ describe("OnshapeApi error handling", () => { it("aborts a call that never answers", async () => { const api = new TestApi(new Response("{}")); await api.get("/x"); - // Nothing here waits a minute, so this asserts the signal is armed - // rather than that it fires. expect(api.lastInit?.signal).toBeInstanceOf(AbortSignal); expect(api.lastInit?.signal?.aborted).toBe(false); }); diff --git a/src/backend/lib/onshape/client.ts b/src/backend/lib/onshape/client.ts index 390b86456..f5e79047b 100644 --- a/src/backend/lib/onshape/client.ts +++ b/src/backend/lib/onshape/client.ts @@ -5,8 +5,6 @@ import { type PostOptions } from "../query-params"; -// Constant across all environments (dev/cert/production), so hardcoded here -// rather than duplicated as a per-environment var in wrangler.jsonc. const ONSHAPE_API_BASE_PATH = "https://cad.onshape.com"; const ONSHAPE_API_VERSION = 16; @@ -27,22 +25,12 @@ export class OnshapeApiError extends Error { /** Fallback wait when a 429 response omits (or malforms) the Retry-After header. */ const DEFAULT_RETRY_AFTER_SECONDS = 60; -/** - * Ceiling on a single Onshape call, so a socket that never answers surfaces as a - * retryable failure instead of being left to whatever is waiting on it. Well - * above any call we make — a rate limit answers in milliseconds — and well under - * the ten minutes a workflow step gets, so the step still has room to retry. - */ +/** Well under a workflow step's ten minutes, so the step can still retry. */ const REQUEST_TIMEOUT_MS = 60_000; /** - * Thrown on a 429, carrying Onshape's `Retry-After` seconds so callers can wait - * it out. Extends {@link OnshapeApiError}, so `status` handling still works. - * - * The wait is also spelled into the message, because the message is all that - * survives a Workflows retry: the `delay` callback is handed an error rebuilt - * across the RPC layer, which keeps `name` and `message` but neither the - * prototype nor any own property. {@link readRetryAfterSeconds} reads it back. + * The wait is also in the message, since that's all that survives Workflows + * rebuilding the error; {@link readRetryAfterSeconds} reads it back. */ export class OnshapeRateLimitError extends OnshapeApiError { constructor( @@ -60,14 +48,10 @@ export class OnshapeRateLimitError extends OnshapeApiError { /** Matches what {@link OnshapeRateLimitError} spells into its message. */ const RETRY_AFTER_PATTERN = /Onshape API error 429 \(retry after (\d+)s\)/; -/** - * The seconds a 429 asked us to wait, or null when the error is not one. Reads - * the message rather than the instance, so it answers the same for an error - * Workflows rebuilt as for the one that was thrown. - */ -export function readRetryAfterSeconds(error: Error): number | null { +/** Reads the message, so it works on an error Workflows rebuilt. */ +export function readRetryAfterSeconds(error: Error): number | undefined { const match = RETRY_AFTER_PATTERN.exec(error.message); - return match ? Number.parseInt(match[1], 10) : null; + return match ? Number.parseInt(match[1], 10) : undefined; } export abstract class OnshapeApi { @@ -88,13 +72,8 @@ export abstract class OnshapeApi { return this._call("GET", path, options); } - // Accepts any media type rather than `image/*`, matching the Python - // implementation this was ported from, which set no Accept header at all. - // Onshape answers a thumbnail it has not rendered yet with a JSON error, - // which it cannot send under `image/*` — so it replies 406 rather than the - // 404 that means "still rendering", and a caller reading status codes takes - // a slow render for a dead one. That is my best explanation for the - // intermittent 406s this code recorded and could not account for. + // Any media type: under `image/*`, a thumbnail still rendering seems to come + // back 406 instead of 404, since its JSON error can't be sent as an image. async getImage(path: string, options?: QueryOptions): Promise { const res = await this._call("GET", path, { ...options, @@ -205,12 +184,8 @@ export class OAuthApi extends OnshapeApi { } /** - * Signs with an API key pair instead of a user's session, for the callers that - * have no session to borrow: scripts, and reproducing a request the app made. - * - * Onshape verifies an HMAC over the request line rather than a bearer token, so - * every header the signature covers has to be the one actually sent — which is - * why these are built together rather than merged in afterwards. + * Signs with an API key pair, for scripts. Onshape verifies an HMAC over the + * request, so the signed headers have to be the ones sent. */ export class ApiKeyApi extends OnshapeApi { constructor( @@ -242,10 +217,7 @@ export class ApiKeyApi extends OnshapeApi { const contentType = "application/json"; const { pathname, search } = new URL(url); - // Lowercased because Onshape signs the folded form on both ends, so the - // request keeps its own casing. The trailing newline after the query is - // in Onshape's sample clients but not in its docs, and the keys in .env - // are dead, so this half is unverified against a live pair. + // Lowercased, as Onshape signs the folded form. Untested against a live key. const signature = await sign( this._secretKey, [ diff --git a/src/backend/lib/onshape/element-type.ts b/src/backend/lib/onshape/element-type.ts index f1771e544..f05f1b002 100644 --- a/src/backend/lib/onshape/element-type.ts +++ b/src/backend/lib/onshape/element-type.ts @@ -1,6 +1,3 @@ -/** - * The type of the Onshape tab the app is open in. - */ export enum ElementType { PART_STUDIO = "PARTSTUDIO", ASSEMBLY = "ASSEMBLY" diff --git a/src/backend/lib/onshape/endpoints/assemblies.ts b/src/backend/lib/onshape/endpoints/assemblies.ts index 567c6dce5..3176554f4 100644 --- a/src/backend/lib/onshape/endpoints/assemblies.ts +++ b/src/backend/lib/onshape/endpoints/assemblies.ts @@ -1,7 +1,6 @@ import { OnshapeApi } from "../client"; import { assertWorkspace } from "../assertions"; import { ElementPath, toElementApiObject, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; import { PartType } from "./documents"; import { ElementType } from "../element-type"; import { IDENTITY_TRANSFORM } from "../objects/transform"; @@ -23,7 +22,7 @@ export function getAssembly( excludeSuppressed?: boolean; } = {} ): Promise { - return client.get(apiPath("assemblies", assemblyPath, toElementApiPath), { + return client.get(`/assemblies${toElementApiPath(assemblyPath)}`, { query: new URLSearchParams({ includeMateFeatures: String(options.includeMateFeatures ?? false), includeNonSolids: String(options.includeNonSolids ?? false), @@ -35,10 +34,7 @@ export function getAssembly( }); } -/** - * Adds the contents of an element tab to an assembly. For a part studio, - * `options.partTypes` defaults to PARTS and COMPOSITE_PARTS. - */ +/** For a part studio, `options.partTypes` defaults to PARTS and COMPOSITE_PARTS. */ export function addElementToAssembly( client: OnshapeApi, assemblyPath: ElementPath, @@ -60,9 +56,7 @@ export function addElementToAssembly( ...toElementApiObject(elementPath) }; - // An empty configuration is left off rather than sent as "", which Onshape - // treats the same way. A caller Onshape does need told something — a part - // studio insert is one — passes a non-empty configuration instead. + // Onshape treats an empty configuration as absent. if (configuration) { instance.configuration = configuration; } @@ -80,27 +74,18 @@ export function addElementToAssembly( return insertInstance(client, assemblyPath, instance, transform); } -/** - * What the assembly's geometry spans, in metres. Sketches are left out, so a - * marker already in the assembly does not widen it. - */ +/** In metres. Excludes sketches, so a marker doesn't widen it. */ export function getAssemblyBoundingBox( client: OnshapeApi, assemblyPath: ElementPath ): Promise { return client.get( - apiPath("assemblies", assemblyPath, toElementApiPath, { - endRoute: "boundingboxes" - }), + `/assemblies${toElementApiPath(assemblyPath)}/boundingboxes`, { query: { includeSketches: "false" } } ); } -/** - * Inserts one part studio feature — a sketch — as an instance of its own. - * Onshape takes the same instance definition as a part insert, naming the - * feature in place of the part types to include. - */ +/** Inserts a single part studio feature, such as a sketch. */ export function addFeatureToAssembly( client: OnshapeApi, assemblyPath: ElementPath, @@ -125,9 +110,7 @@ function insertInstance( transform?: number[] ): Promise { return client.post( - apiPath("assemblies", assemblyPath, toElementApiPath, { - endRoute: "transformedinstances" - }), + `/assemblies${toElementApiPath(assemblyPath)}/transformedinstances`, { body: { transformGroups: [ @@ -141,23 +124,15 @@ function insertInstance( ); } -/** - * Adds or updates a feature in an assembly. - * - * @param featureId If specified, the existing feature with this ID is updated rather than creating a new one. - */ +/** Adds a feature to an assembly. */ export function addAssemblyFeature( client: OnshapeApi, assemblyPath: ElementPath, - feature: object, - featureId?: string + feature: object ): Promise { assertWorkspace(assemblyPath); return client.post( - apiPath("assemblies", assemblyPath, toElementApiPath, { - endRoute: "features", - featureId - }), + `/assemblies${toElementApiPath(assemblyPath)}/features`, { body: { feature } } ); } diff --git a/src/backend/lib/onshape/endpoints/configurations.ts b/src/backend/lib/onshape/endpoints/configurations.ts index af1507688..f20bae910 100644 --- a/src/backend/lib/onshape/endpoints/configurations.ts +++ b/src/backend/lib/onshape/endpoints/configurations.ts @@ -1,6 +1,5 @@ import { OnshapeApi } from "../client"; import { ElementPath, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; import { OnshapeConfigurationResponse } from "../types"; export function getConfiguration( @@ -8,8 +7,6 @@ export function getConfiguration( elementPath: ElementPath ): Promise { return client.get( - apiPath("elements", elementPath, toElementApiPath, { - endRoute: "configuration" - }) + `/elements${toElementApiPath(elementPath)}/configuration` ); } diff --git a/src/backend/lib/onshape/endpoints/documents.ts b/src/backend/lib/onshape/endpoints/documents.ts index 67467085b..061a27424 100644 --- a/src/backend/lib/onshape/endpoints/documents.ts +++ b/src/backend/lib/onshape/endpoints/documents.ts @@ -1,11 +1,5 @@ import { OnshapeApi } from "../client"; -import { - DocumentPath, - InstancePath, - toDocumentApiPath, - toInstanceApiPath -} from "../path"; -import { apiPath } from "../api-path"; +import { DocumentPath, InstancePath, toInstanceApiPath } from "../path"; import { OnshapeDocumentContents, OnshapeDocumentInfo } from "../types"; /** Describes possible part types. */ @@ -19,11 +13,7 @@ export function getDocument( client: OnshapeApi, documentPath: DocumentPath ): Promise { - return client.get( - apiPath("documents", documentPath, toDocumentApiPath, { - skipDocumentD: true - }) - ); + return client.get(`/documents/${documentPath.documentId}`); } export function getContents( @@ -31,12 +21,9 @@ export function getContents( instancePath: InstancePath, includeThumbnails = false ): Promise { - return client.get( - apiPath("documents", instancePath, toInstanceApiPath, { - endRoute: "contents" - }), - { query: { withThumbnails: includeThumbnails } } - ); + return client.get(`/documents${toInstanceApiPath(instancePath)}/contents`, { + query: { withThumbnails: includeThumbnails } + }); } /** The document's units, as much of the response as anything here reads. */ @@ -52,8 +39,6 @@ export function getUnitInfo( instancePath: InstancePath ): Promise { return onshapeApi.get( - apiPath("documents", instancePath, toInstanceApiPath, { - endRoute: "unitinfo" - }) + `/documents${toInstanceApiPath(instancePath)}/unitinfo` ); } diff --git a/src/backend/lib/onshape/endpoints/metadata.ts b/src/backend/lib/onshape/endpoints/metadata.ts index cff8bbcfd..95165e421 100644 --- a/src/backend/lib/onshape/endpoints/metadata.ts +++ b/src/backend/lib/onshape/endpoints/metadata.ts @@ -1,26 +1,25 @@ import { OnshapeApi } from "../client"; import { ElementPath, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; -import { type ConfigurationKey } from "../../../features/configurations/contract"; -import { toQueryConfiguration } from "../../../features/configurations/utils"; +import { type Selection } from "../../../features/configurations/contract"; +import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import type { OnshapeMetadataObject } from "../types"; /** Returns an element's metadata properties for a given configuration. */ export function getElementMetadata( client: OnshapeApi, elementPath: ElementPath, - configurationKey: ConfigurationKey + configuration: Selection ): Promise { - // Computed properties are expensive and unused, and indexing probes this - // once per configuration. + // Computed properties are slow, and indexing probes once per configuration. const query: Record = { includeComputedProperties: "false" }; - // The query form, not the key: this is escaped again on its way out. - if (configurationKey) { - query.configuration = toQueryConfiguration(configurationKey); + // The query form: this is escaped again on its way out. + const encoded = encodeQueryConfiguration(configuration); + if (encoded) { + query.configuration = encoded; } - return client.get(apiPath("metadata", elementPath, toElementApiPath), { + return client.get(`/metadata${toElementApiPath(elementPath)}`, { query }); } diff --git a/src/backend/lib/onshape/endpoints/part-studios.ts b/src/backend/lib/onshape/endpoints/part-studios.ts index ff026acdb..663cb91d4 100644 --- a/src/backend/lib/onshape/endpoints/part-studios.ts +++ b/src/backend/lib/onshape/endpoints/part-studios.ts @@ -1,7 +1,6 @@ import { OnshapeApi } from "../client"; import { assertInstanceType } from "../assertions"; import { ElementPath, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; import { OnshapeCreatedFeature, OnshapeFeatureListResponse } from "../types"; export function addPartStudioFeature( @@ -11,9 +10,7 @@ export function addPartStudioFeature( ): Promise { assertInstanceType(partStudioPath, "w"); return client.post( - apiPath("partstudios", partStudioPath, toElementApiPath, { - endRoute: "features" - }), + `/partstudios${toElementApiPath(partStudioPath)}/features`, { body: { feature } } ); } @@ -23,9 +20,7 @@ export function getFeatures( partStudioPath: ElementPath ): Promise { return client.get( - apiPath("partstudios", partStudioPath, toElementApiPath, { - endRoute: "features" - }), + `/partstudios${toElementApiPath(partStudioPath)}/features`, { query: { includeSketches: "false", diff --git a/src/backend/lib/onshape/endpoints/parts.ts b/src/backend/lib/onshape/endpoints/parts.ts index 4f77fa253..a44cdaa03 100644 --- a/src/backend/lib/onshape/endpoints/parts.ts +++ b/src/backend/lib/onshape/endpoints/parts.ts @@ -1,20 +1,17 @@ import { OnshapeApi } from "../client"; import { ElementPath, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; -import { type ConfigurationKey } from "../../../features/configurations/contract"; -import { toQueryConfiguration } from "../../../features/configurations/utils"; +import { type Selection } from "../../../features/configurations/contract"; +import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import type { OnshapePart } from "../types"; -/** Returns the parts of a part studio for a given configuration. */ export function getParts( client: OnshapeApi, elementPath: ElementPath, - configurationKey: ConfigurationKey + configuration: Selection ): Promise { - return client.get(apiPath("parts", elementPath, toElementApiPath), { - // The query form, not the key: this is escaped again on its way out. - query: configurationKey - ? { configuration: toQueryConfiguration(configurationKey) } - : {} + // The query form: this is escaped again on its way out. + const encoded = encodeQueryConfiguration(configuration); + return client.get(`/parts${toElementApiPath(elementPath)}`, { + query: encoded ? { configuration: encoded } : {} }); } diff --git a/src/backend/lib/onshape/endpoints/teams.ts b/src/backend/lib/onshape/endpoints/teams.ts new file mode 100644 index 000000000..20e362be7 --- /dev/null +++ b/src/backend/lib/onshape/endpoints/teams.ts @@ -0,0 +1,28 @@ +import { OnshapeApi } from "../client"; + +export interface OnshapeTeamMember { + /** Whether the member administers the team, not only belongs to it. */ + admin: boolean; + member: { id: string }; +} + +/** Onshape pages the list; this is as many as it hands back at once. */ +const PAGE_SIZE = 20; + +/** Every member of a team, across however many pages. */ +export async function getTeamMembers( + client: OnshapeApi, + teamId: string +): Promise { + const members: OnshapeTeamMember[] = []; + for (let offset = 0; ; offset += PAGE_SIZE) { + const page: { items: OnshapeTeamMember[] } = await client.get( + `/teams/${encodeURIComponent(teamId)}/members`, + { query: { offset: String(offset), limit: String(PAGE_SIZE) } } + ); + members.push(...page.items); + if (page.items.length < PAGE_SIZE) { + return members; + } + } +} diff --git a/src/backend/lib/onshape/endpoints/thumbnails.ts b/src/backend/lib/onshape/endpoints/thumbnails.ts index 520ed8fff..f01d5fb55 100644 --- a/src/backend/lib/onshape/endpoints/thumbnails.ts +++ b/src/backend/lib/onshape/endpoints/thumbnails.ts @@ -1,9 +1,8 @@ -import { type ConfigurationKey } from "../../../features/configurations/contract"; -import { toQueryConfiguration } from "../../../features/configurations/utils"; +import { type Selection } from "../../../features/configurations/contract"; +import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import { OnshapeApi } from "../client"; import { assertInstanceType } from "../assertions"; import { ElementPath, toElementApiPath, toInstanceApiPath } from "../path"; -import { apiPath } from "../api-path"; import { ThumbnailSize } from "../../../features/thumbnails/contract"; /** Returns the thumbnail for a given element in a workspace or version. */ @@ -13,44 +12,36 @@ export function getElementThumbnail( size = ThumbnailSize.LARGE ): Promise { assertInstanceType(elementPath, "w", "v"); - const path = - apiPath("thumbnails", elementPath, toElementApiPath) + "/s/" + size; + const path = `/thumbnails${toElementApiPath(elementPath)}/s/${size}`; return client.getImage(path); } -/** The configuration matches no insertable, so retrying can only fail again. */ -export class NoSuchConfigurationError extends Error {} - +/** Asking for its bytes starts the render. Undefined when no part matches the configuration. */ export async function getThumbnailId( client: OnshapeApi, elementPath: ElementPath, - configurationKey?: ConfigurationKey -): Promise { + configuration: Selection +): Promise { const query = new URLSearchParams({ includeParts: "true", includeAssemblies: "true", includeCompositeParts: "true", elementId: elementPath.elementId }); - // The query form, not the key: this is escaped again on its way out. - if (configurationKey) { - query.set("configuration", toQueryConfiguration(configurationKey)); + // The query form: this is escaped again on its way out. + const encoded = encodeQueryConfiguration(configuration); + if (encoded) { + query.set("configuration", encoded); } - const insertables = await client.get( - apiPath("documents", elementPath, toInstanceApiPath, { - endRoute: "insertables" - }), + const insertables: { + items?: { predictableThumbnailId?: string }[]; + } = await client.get( + `/documents${toInstanceApiPath(elementPath)}/insertables`, { query } ); // A configuration matching nothing comes back with no items at all. - const thumbnailId = insertables.items?.[0]?.predictableThumbnailId; - if (!thumbnailId) { - throw new NoSuchConfigurationError( - "Onshape returned no insertable for the configuration" - ); - } - return thumbnailId; + return insertables.items?.[0]?.predictableThumbnailId; } /** Fails repeatedly while Onshape renders the thumbnail in the background. */ @@ -59,9 +50,6 @@ export function getThumbnailFromId( thumbnailId: string, size = ThumbnailSize.LARGE ): Promise { - const path = - apiPath("thumbnails", undefined, undefined, { endId: thumbnailId }) + - "/s/" + - size; + const path = `/thumbnails/${encodeURIComponent(thumbnailId)}/s/${size}`; return client.getImage(path); } diff --git a/src/backend/lib/onshape/endpoints/users.ts b/src/backend/lib/onshape/endpoints/users.ts index 69ec9c2f8..92c07cd24 100644 --- a/src/backend/lib/onshape/endpoints/users.ts +++ b/src/backend/lib/onshape/endpoints/users.ts @@ -1,6 +1,4 @@ -import { OAuthApi, OnshapeApi } from "../client"; -import { apiPath } from "../api-path"; -import { AccessLevel } from "../../../features/auth/access-level"; +import { OAuthApi } from "../client"; interface SessionInfo { id: string; @@ -9,30 +7,10 @@ interface SessionInfo { } export function getSessionInfo(client: OAuthApi): Promise { - return client.get( - apiPath("users", undefined, undefined, { endRoute: "sessioninfo" }) - ); + return client.get("/users/sessioninfo"); } /** Returns the user ID associated with the current session. */ export function getUserId(client: OAuthApi): Promise { return getSessionInfo(client).then((info) => info.id); } - -/** Returns the access level of the authenticated user relative to a given team. */ -export async function getAccessLevel( - client: OnshapeApi, - teamId: string -): Promise { - try { - const teamInfo = await client.get( - apiPath("teams", undefined, undefined, { endId: teamId }) - ); - if (teamInfo.admin) return AccessLevel.ADMIN; - if (teamInfo.member) return AccessLevel.EDITOR; - return AccessLevel.USER; - } catch { - // Onshape returns an error for teams the user isn't a member of - return AccessLevel.USER; - } -} diff --git a/src/backend/lib/onshape/endpoints/versions.ts b/src/backend/lib/onshape/endpoints/versions.ts index 27f6eec67..7c6e75334 100644 --- a/src/backend/lib/onshape/endpoints/versions.ts +++ b/src/backend/lib/onshape/endpoints/versions.ts @@ -1,22 +1,13 @@ import { OnshapeApi } from "../client"; import { DocumentPath, toDocumentApiPath } from "../path"; -import { apiPath } from "../api-path"; import { OnshapeVersionInfo } from "../types"; -/** - * Fetches a list of versions of a document. - * - * Versions are returned in chronological order, with the oldest version ("Start") first. - */ +/** Oldest ("Start") first. */ function getVersions( client: OnshapeApi, documentPath: DocumentPath ): Promise { - return client.get( - apiPath("documents", documentPath, toDocumentApiPath, { - endRoute: "versions" - }) - ); + return client.get(`/documents${toDocumentApiPath(documentPath)}/versions`); } /** The most recently created version of a document, with when it was cut. */ @@ -28,3 +19,13 @@ export function getLatestVersion( (versions) => versions[versions.length - 1] ); } + +export function getVersion( + client: OnshapeApi, + documentPath: DocumentPath, + versionId: string +): Promise { + return client.get( + `/documents${toDocumentApiPath(documentPath)}/versions/${encodeURIComponent(versionId)}` + ); +} diff --git a/src/backend/lib/onshape/endpoints/webhooks.ts b/src/backend/lib/onshape/endpoints/webhooks.ts new file mode 100644 index 000000000..c25eadaf3 --- /dev/null +++ b/src/backend/lib/onshape/endpoints/webhooks.ts @@ -0,0 +1,44 @@ +import { OnshapeApi } from "../client"; + +export interface OnshapeWebhookInfo { + id: string; + url: string; + events: string[]; +} + +export interface CreateWebhookParams { + /** The company whose events it hears; the caller has to administer it. */ + companyId?: string; + /** The document whose events it hears, for a document's events. */ + documentId?: string; + /** Narrows a document's events to one workspace's. */ + workspaceId?: string; + events: string[]; + url: string; + name: string; + description: string; + options: { collapseEvents: boolean }; + /** False, or Onshape deletes it after a while without events. */ + isTransient: boolean; +} + +export function getWebhook( + client: OnshapeApi, + webhookId: string +): Promise { + return client.get(`/webhooks/${encodeURIComponent(webhookId)}`); +} + +export function createWebhook( + client: OnshapeApi, + params: CreateWebhookParams +): Promise { + return client.post("/webhooks", { body: params }); +} + +export function deleteWebhook( + client: OnshapeApi, + webhookId: string +): Promise { + return client.deleteNone(`/webhooks/${encodeURIComponent(webhookId)}`); +} diff --git a/src/backend/lib/onshape/endpoints/workspaces.ts b/src/backend/lib/onshape/endpoints/workspaces.ts new file mode 100644 index 000000000..699cb046b --- /dev/null +++ b/src/backend/lib/onshape/endpoints/workspaces.ts @@ -0,0 +1,33 @@ +import { OnshapeApi } from "../client"; +import { DocumentPath, toDocumentApiPath } from "../path"; +import { OnshapeWorkspaceInfo } from "../types"; + +export function getWorkspaces( + client: OnshapeApi, + documentPath: DocumentPath +): Promise { + return client.get( + `/documents${toDocumentApiPath(documentPath)}/workspaces` + ); +} + +export function createWorkspace( + client: OnshapeApi, + documentPath: DocumentPath, + branch: { name: string; description: string; versionId: string } +): Promise { + return client.post( + `/documents${toDocumentApiPath(documentPath)}/workspaces`, + { body: branch } + ); +} + +export function deleteWorkspace( + client: OnshapeApi, + documentPath: DocumentPath, + workspaceId: string +): Promise { + return client.deleteNone( + `/documents${toDocumentApiPath(documentPath)}/workspaces/${encodeURIComponent(workspaceId)}` + ); +} diff --git a/src/backend/lib/onshape/objects/assembly-features.ts b/src/backend/lib/onshape/objects/assembly-features.ts index c65566cf9..5d84902a9 100644 --- a/src/backend/lib/onshape/objects/assembly-features.ts +++ b/src/backend/lib/onshape/objects/assembly-features.ts @@ -46,10 +46,7 @@ function mateTypeParameter(value: string): object { }; } -/** - * Takes up to two queries. With neither instance constrained, Onshape tends to - * preserve the second one's location. - */ +/** With neither instance constrained, Onshape tends to keep the second in place. */ export function fastenMate(name: string, queries: Iterable): object { return { btType: "BTMMate-64", diff --git a/src/backend/lib/onshape/objects/derive-feature.ts b/src/backend/lib/onshape/objects/derive-feature.ts index c950955f3..7cfe20083 100644 --- a/src/backend/lib/onshape/objects/derive-feature.ts +++ b/src/backend/lib/onshape/objects/derive-feature.ts @@ -3,7 +3,6 @@ import { type Selection, type ConfigurationParameter } from "../../../features/configurations/contract"; -import { toExpression } from "../../../features/configurations/selection"; import { type ElementPath } from "../path"; const PART_STUDIO_QUERY = @@ -67,9 +66,8 @@ export class DerivedFeature { return { btType: "BTMParameterQuantity-147", parameterId: parameter.id, - // In the parameter's own unit: the feature dialog shows - // this expression, and "0.0381 meter" is not what was asked for. - expression: toExpression(parameter, value) + // As entered, so the feature dialog shows what was typed. + expression: value }; case ParameterType.BOOLEAN: return { diff --git a/src/backend/lib/onshape/objects/transform.ts b/src/backend/lib/onshape/objects/transform.ts index 936ebe315..9b79046ea 100644 --- a/src/backend/lib/onshape/objects/transform.ts +++ b/src/backend/lib/onshape/objects/transform.ts @@ -1,9 +1,6 @@ /** The 4×4 transforms Onshape places an assembly instance with. */ -/** - * Row-major, with the translation in the last column: Onshape's own example of a - * 1.3 m shift along x is `[1,0,0,1.3, 0,1,0,0, 0,0,1,0, 0,0,0,1]`. - */ +/** Row-major, translation in the last column. */ // prettier-ignore export const IDENTITY_TRANSFORM = [ 1, 0, 0, 0, diff --git a/src/backend/lib/onshape/path.ts b/src/backend/lib/onshape/path.ts index 3eb49f779..538cad0aa 100644 --- a/src/backend/lib/onshape/path.ts +++ b/src/backend/lib/onshape/path.ts @@ -1,7 +1,4 @@ -import { Selection } from "../../features/configurations/contract"; - -/** The instance kinds an Onshape path can address, as one definition: the type - * and the runtime list validators check against both derive from it. */ +/** Both the type and the runtime list derive from this. */ export const INSTANCE_TYPES = ["w", "v", "m"] as const; export type InstanceType = (typeof INSTANCE_TYPES)[number]; @@ -19,8 +16,6 @@ export interface ElementPath extends InstancePath { elementId: string; } -/** Represents a part inside a Part Studio. */ - /** The version-pinned tab a stored insertable row addresses. */ export function toElementPath(row: { documentId: string; @@ -35,10 +30,6 @@ export function toElementPath(row: { }; } -export interface ConfigurablePath extends ElementPath { - selection: Selection; -} - function isDocumentPath(path: unknown): path is DocumentPath { return ( typeof path === "object" && @@ -51,8 +42,7 @@ export function isInstancePath(path: unknown): path is InstancePath { return ( isDocumentPath(path) && typeof (path as InstancePath).instanceId === "string" && - // Checked against the literals: an unrecognized instance type builds a - // path Onshape rejects, which is worth catching at the boundary. + // An unrecognized type builds a path Onshape rejects. INSTANCE_TYPES.includes((path as InstancePath).instanceType) ); } @@ -64,15 +54,6 @@ export function isElementPath(path: unknown): path is ElementPath { ); } -export function isConfigurablePath( - path: DocumentPath -): path is ConfigurablePath { - return ( - isElementPath(path) && - (path as ConfigurablePath).selection !== undefined - ); -} - export function toDocumentApiPath(path: DocumentPath): string { return `/d/${path.documentId}`; } @@ -98,13 +79,8 @@ function toInstanceTypeKey(instanceType: InstanceType): InstanceTypeKey { } } -/** - * Returns the named-ID object that Onshape API bodies/query params expect, - * e.g. `{ documentId, workspaceId }` rather than the `/d/.../w/...` path form. - */ -function toInstanceApiObject( - path: InstancePath -): Record { +/** `{ documentId, workspaceId }`, as API bodies and query params expect. */ +function toInstanceApiObject(path: InstancePath): Record { return { documentId: path.documentId, [toInstanceTypeKey(path.instanceType)]: path.instanceId diff --git a/src/backend/lib/onshape/types.ts b/src/backend/lib/onshape/types.ts index 5a5f41654..38018f7bd 100644 --- a/src/backend/lib/onshape/types.ts +++ b/src/backend/lib/onshape/types.ts @@ -1,7 +1,4 @@ -/** - * Hand-authored subsets of the Onshape responses we use. To find a field's real - * shape, regenerate `onshape-api-reference/` — see `openapi-ts.config.ts`. - */ +/** Hand-written subsets. For a field's real shape, regenerate `onshape-api-reference/`; see `openapi-ts.config.ts`. */ import { LogicalOp, QuantityType, @@ -115,7 +112,6 @@ interface OnshapeQuantityRange { interface OnshapeParameterBase { parameterId: string; parameterName: string; - isCosmetic: boolean; visibilityCondition: OnshapeVisibilityCondition; } @@ -162,6 +158,7 @@ export interface OnshapeVersionInfo { name: string; /** ISO-8601 timestamp. */ createdAt: string; + creator?: { id: string }; } // === documents (GET /documents/{did}, GET .../contents) === @@ -186,13 +183,17 @@ export interface OnshapeDocumentInfo { id: string; name: string; documentThumbnailElementId?: string; - /** - * Optional because nothing here has confirmed Onshape always sends it; the - * load throws rather than guessing when it is absent. - */ + /** Not confirmed to always be sent; the load throws when it's absent. */ defaultWorkspace?: { id: string }; } +/** A workspace, as much of Onshape's `BTWorkspaceInfo` as anything reads. */ +export interface OnshapeWorkspaceInfo { + id: string; + name: string; + description?: string; +} + /** A folder (group) node in the document contents tree. */ export interface OnshapeElementGroup { btType: OnshapeFolderEntryType.GROUP; @@ -263,10 +264,7 @@ interface OnshapeSubAssembly { features: OnshapeAssemblyFeature[]; } -/** - * A part studio feature an instance was inserted from, in the assembly's - * flattened `partStudioFeatures` list — what `parts` is for part instances. - */ +/** What `parts` is for part instances. */ interface OnshapeAssemblyPsFeature { documentId?: string; elementId?: string; diff --git a/src/backend/lib/validate.ts b/src/backend/lib/validate.ts index 2ef8f34b8..fb0840cc6 100644 --- a/src/backend/lib/validate.ts +++ b/src/backend/lib/validate.ts @@ -4,10 +4,7 @@ import { HttpStatus } from "http-status-ts"; import type { ZodType } from "zod"; import { internalError } from "./api-error"; -/** - * `zValidator` with our error shape; its own body is the one that would not - * match. A malformed request is our bug, so the detail is for the logs. - */ +/** `zValidator` with our error shape. A malformed request is our bug, so details go to the logs. */ export function validate< T extends ZodType, Target extends keyof ValidationTargets diff --git a/src/frontend/components/alerts.tsx b/src/frontend/components/alerts.tsx index 1ddf4bff3..f4931e375 100644 --- a/src/frontend/components/alerts.tsx +++ b/src/frontend/components/alerts.tsx @@ -25,15 +25,9 @@ function openWarningAlert(props: OpenWarningAlertProps): void { /> ), children: ( - // Takes the focus the trap would otherwise land on Close, which - // reads as that button being pre-selected. No outline: a focus ring - // around a paragraph reads as a text box the reader can type in. - + // Takes focus from Close, which would look pre-selected. No outline, which + // would make the text look editable. + {props.text} ), @@ -57,10 +51,3 @@ export function openCannotReorderAlert(): void { text: "To prevent confusion, favorites cannot be reordered while filters are active." }); } - -export function openCannotEditDefaultConfigurationAlert(): void { - openWarningAlert({ - title: "Cannot edit configuration", - text: "This element is not configurable, so its default configuration cannot be changed." - }); -} diff --git a/src/frontend/components/app-brand.tsx b/src/frontend/components/app-brand.tsx index 7d88a6df3..6b4d6f2f0 100644 --- a/src/frontend/components/app-brand.tsx +++ b/src/frontend/components/app-brand.tsx @@ -4,8 +4,7 @@ import { FontWeight, IconSize, maskedImage, - PrimaryColor, - RADIUS + PrimaryColor } from "../lib/style-constants"; import frcDesignBook from "/frc-design-book.svg"; @@ -17,8 +16,7 @@ const FRC_DESIGN_URL = "https://frcdesign.org"; const BOOK_SCALE = 2 / 3; interface AppBrandMarkProps { - /** The tile's side; the book is drawn to two thirds of it. - * @default IconSize.CONTROL */ + /** @default IconSize.CONTROL */ size?: IconSize; } @@ -31,10 +29,9 @@ export function AppBrandMark(props: AppBrandMarkProps): ReactNode { w={size} h={size} bg={PrimaryColor.FILLED} - // White on every library rather than the tile's contrast color, - // which flips to black on the lighter ones. + // The contrast color flips to black on lighter libraries. c="white" - style={{ borderRadius: RADIUS }} + bdrs="sm" > {/* Masked, not drawn, so the book takes the color above rather than the gray in the file. */} @@ -50,13 +47,8 @@ export function AppBrandMark(props: AppBrandMarkProps): ReactNode { /** The book and the app's name, in every navbar, linking out to FRCDesign.org. */ export function AppBrand(): ReactNode { return ( - -
+ +
diff --git a/src/frontend/components/app-hover-card.test.tsx b/src/frontend/components/app-hover-card.test.tsx new file mode 100644 index 000000000..7378d3b59 --- /dev/null +++ b/src/frontend/components/app-hover-card.test.tsx @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../__test_utils__/render"; +import { AppHoverCard } from "./app-hover-card"; + +function renderInRow() { + const openRow = vi.fn(); + renderWithProviders( +
+ badge}>card + elsewhere in the row +
+ ); + return openRow; +} + +describe("AppHoverCard on a touchscreen", () => { + it("opens on a click without the row seeing it", async () => { + const user = userEvent.setup(); + const openRow = renderInRow(); + + await user.click(screen.getByText("badge")); + + expect(screen.queryByText("card")).not.toBeNull(); + expect(openRow).not.toHaveBeenCalled(); + }); + + it("keeps a click on the card from the row too", async () => { + const user = userEvent.setup(); + const openRow = renderInRow(); + + await user.click(screen.getByText("badge")); + await user.click(screen.getByText("card")); + + expect(openRow).not.toHaveBeenCalled(); + }); + + it("closes on a click outside", async () => { + const user = userEvent.setup(); + renderInRow(); + + await user.click(screen.getByText("badge")); + await user.click(document.body); + + await waitFor(() => { + expect(screen.queryByText("card")).toBeNull(); + }); + }); +}); + +describe("AppHoverCard with a mouse", () => { + beforeEach(() => { + vi.spyOn(window, "matchMedia").mockImplementation( + (query) => + ({ + matches: query === "(hover: hover)", + media: query, + addEventListener: () => undefined, + removeEventListener: () => undefined + }) as unknown as MediaQueryList + ); + }); + afterEach(() => vi.restoreAllMocks()); + + it("opens on hover and closes when the pointer leaves", async () => { + const user = userEvent.setup(); + renderInRow(); + + await user.hover(screen.getByText("badge")); + expect(await screen.findByText("card")).not.toBeNull(); + + await user.unhover(screen.getByText("badge")); + await waitFor(() => { + expect(screen.queryByText("card")).toBeNull(); + }); + }); + + it("keeps a click on the target from the row", async () => { + const user = userEvent.setup(); + const openRow = renderInRow(); + + await user.click(screen.getByText("badge")); + + expect(openRow).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/components/app-hover-card.tsx b/src/frontend/components/app-hover-card.tsx new file mode 100644 index 000000000..55f308491 --- /dev/null +++ b/src/frontend/components/app-hover-card.tsx @@ -0,0 +1,74 @@ +import { Box, HoverCard, Popover, type PopoverProps } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import type { MouseEvent, ReactNode } from "react"; + +interface AppHoverCardProps extends Pick< + PopoverProps, + "position" | "arrowSize" +> { + /** What is hovered or tapped. Wrapped, so it need not take a ref. */ + target: ReactNode; + children: ReactNode; + /** @default "md" */ + padding?: string; + /** @default 0 */ + openDelay?: number; + /** @default 150 */ + closeDelay?: number; +} + +// The card sits inside clickable rows, and React bubbles clicks out of portals. +const stopPropagation = (event: MouseEvent) => event.stopPropagation(); + +/** Opens on hover, or on a tap where there is no hover. */ +export function AppHoverCard(props: AppHoverCardProps): ReactNode { + const { + target, + children, + padding = "md", + openDelay, + closeDelay, + ...popoverProps + } = props; + const canHover = useMediaQuery("(hover: hover)", undefined, { + getInitialValueInEffect: false + }); + + const targetBox = ( + + {target} + + ); + const dropdownProps = { + p: padding, + maw: "calc(100vw - 16px)", + onClick: stopPropagation + }; + + if (canHover) { + return ( + + {targetBox} + + {children} + + + ); + } + return ( + + {targetBox} + {children} + + ); +} diff --git a/src/frontend/components/app-icon.tsx b/src/frontend/components/app-icon.tsx index 1e8e79053..3519e2548 100644 --- a/src/frontend/components/app-icon.tsx +++ b/src/frontend/components/app-icon.tsx @@ -13,30 +13,17 @@ export interface AppIconProps color?: StatusColor | string; /** @default "regular" */ weight?: IconWeight; - /** What a screen reader calls an icon that carries meaning on its own. */ - label?: string; } -/** - * A Phosphor icon in a theme color. Box resolves the name, and sizes through - * `fz` because its own `style` would drop the icon's. - */ +/** Sized through `fz`, since Box's own `style` would drop the icon's. */ export function AppIcon({ icon, size = IconSize.SMALL, color, weight, - label, ...others }: AppIconProps): ReactNode { return ( - + ); } diff --git a/src/frontend/components/app-menu.module.css b/src/frontend/components/app-menu.module.css new file mode 100644 index 000000000..1a3a365ba --- /dev/null +++ b/src/frontend/components/app-menu.module.css @@ -0,0 +1,9 @@ +/* + * `contain` keeps a scroll that reaches either end of the dropdown from chaining + * to the list behind it, which otherwise scrolls the app out from under the + * open menu. + */ +.scrollable { + overflow-y: auto; + overscroll-behavior: contain; +} diff --git a/src/frontend/components/app-menu.tsx b/src/frontend/components/app-menu.tsx index 3cfbf71fa..c162320f4 100644 --- a/src/frontend/components/app-menu.tsx +++ b/src/frontend/components/app-menu.tsx @@ -1,25 +1,19 @@ import { PropsWithChildren, ReactNode } from "react"; import { FloatingPosition, Menu, ActionIcon } from "@mantine/core"; import { DotsThreeIcon } from "@phosphor-icons/react"; -import { IconSize, StatusColor } from "../lib/style-constants"; +import { IconSize } from "../lib/style-constants"; import { RequireAccessLevel } from "../features/auth/access-level"; +import classes from "./app-menu.module.css"; interface AppContextMenuProps { menuItems: ReactNode; children: ReactNode; - /** Set when a button owns the menu, rather than a right-click on a row. */ controlledByButton?: boolean; wideMenu?: boolean; - /** - * Caps the dropdown to the room it has and scrolls it, for a list that can - * outgrow the viewport. - */ + /** Caps the dropdown to the available room and scrolls it. */ scrollable?: boolean; } -/** - * A wrapper around Menu which displays a ContextMenu. - */ export function AppContextMenu(props: AppContextMenuProps): ReactNode { const { menuItems, @@ -40,9 +34,7 @@ export function AppContextMenu(props: AppContextMenuProps): ReactNode { return ( {menuChildren} event.stopPropagation()} - // `contain` keeps a scroll that reaches either end of the - // dropdown from chaining to the list behind it, which otherwise - // scrolls the app out from under the open menu. - style={ - scrollable - ? { - overflowY: "auto", - overscrollBehavior: "contain" - } - : undefined - } + className={scrollable ? classes.scrollable : undefined} > {menuItems} @@ -77,10 +55,7 @@ export function AppContextMenu(props: AppContextMenuProps): ReactNode { ); } -/** - * An explicit button which opens a menu with the given items. Used alongside - * the right-click context menu so the menu is reachable without a right-click. - */ +/** So the menu is reachable without a right-click. */ interface MenuButtonProps extends PropsWithChildren { /** Sizes the button to sit beside a full-height button, not in a card row. */ large?: boolean; @@ -91,8 +66,6 @@ export function MenuButton(props: MenuButtonProps): ReactNode { return ( e.stopPropagation()} @@ -108,34 +81,23 @@ export function MenuButton(props: MenuButtonProps): ReactNode { interface MenuSectionProps extends PropsWithChildren { /** What the items under it are for, e.g. "Insert". */ label: string; - /** Colors the label, for a section only some callers see. */ - color?: StatusColor; } -/** - * A run of menu items under a label naming them. Every item in a menu belongs - * to one, so a dropdown reads as a few short lists rather than one long one. - */ export function MenuSection(props: MenuSectionProps): ReactNode { - const { label, color, children } = props; + const { label, children } = props; return ( <> - {label} + {label} {children} ); } -/** - * The admin-only items of a menu, listed in place rather than behind a submenu: - * one hover less to reach them, and the label is what marks them as admin. - */ +/** In place rather than in a submenu; the label marks them as admin. */ export function AdminMenuSection(props: PropsWithChildren): ReactNode { return ( - - {props.children} - + {props.children} ); } diff --git a/src/frontend/components/app-modal.module.css b/src/frontend/components/app-modal.module.css new file mode 100644 index 000000000..241d2bed0 --- /dev/null +++ b/src/frontend/components/app-modal.module.css @@ -0,0 +1,52 @@ +/* + * A bordered card that clips rather than scrolls, so the body scrolls and the + * footer stays put. + */ +.content { + border: 1px solid var(--mantine-color-default-border); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.header { + border-bottom: 1px solid var(--mantine-color-default-border); + padding: var(--mantine-spacing-sm); + /* Otherwise a Mantine minimum, not the padding, sets the height. */ + min-height: 0; +} + +/* Shrinkable, so a long title ellipsizes rather than running under the close button. */ +.title { + min-width: 0; +} + +/* Passes the card's capped height down to what scrolls. */ +.body, +.fill { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.body { + padding: 0; +} + +/* Takes the focus the trap would otherwise land on a control. */ +.fill { + outline: none; +} + +/* A flex item otherwise floors at its content height, pushing the footer off. */ +.scroll { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +/* Holds focus unseen, so opening a modal draws no ring. */ +.focusTarget { + outline: none; +} diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx index be63abb5e..4f1cdc59b 100644 --- a/src/frontend/components/app-modal.tsx +++ b/src/frontend/components/app-modal.tsx @@ -1,30 +1,34 @@ import { Box, Group, type MantineSpacing, Modal, Stack } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; -import { BORDER, FRAME_BACKGROUND } from "../lib/style-constants"; +import styles from "../lib/styles.module.css"; +import classes from "./app-modal.module.css"; -const COLUMN = { display: "flex", flexDirection: "column" } as const; - -/** Passes the card's capped height down to the body, which is what scrolls. */ -const FILL_COLUMN = { ...COLUMN, flex: 1, minHeight: 0 } as const; +/** The framing both halves of the app's modals draw. */ +export const APP_MODAL_CLASSES = { + content: classes.content, + header: `${classes.header} ${styles.frame}`, + title: classes.title, + body: classes.body +}; /** - * The framing both halves of the app's modals draw: a bordered card that clips - * rather than scrolls, so the body below scrolls and the footer stays put. + * What every modal's content sits in, so its body can scroll. The focus trap + * takes the first `data-autofocus` it finds: a field that asks for focus gets + * it, and otherwise the empty target after the content does, since landing on + * the first control makes it look pre-selected. */ -export const APP_MODAL_STYLES = { - content: { border: BORDER, overflow: "hidden", ...COLUMN }, - header: { - background: FRAME_BACKGROUND, - borderBottom: BORDER, - padding: "var(--mantine-spacing-sm)", - // Otherwise a Mantine minimum, not the padding, sets the height. - minHeight: 0 - }, - // Shrinkable, so a long title ellipsizes rather than running under the - // close button. - title: { minWidth: 0 }, - body: { padding: 0, ...FILL_COLUMN } -}; +export function AppModalContent(props: PropsWithChildren): ReactNode { + return ( +
+ {props.children} + +
+ ); +} interface AppModalProps extends PropsWithChildren { opened: boolean; @@ -36,11 +40,7 @@ interface AppModalProps extends PropsWithChildren { dismissible?: boolean; } -/** - * A modal held open by state rather than by the manager, framed like the rest - * of the app; `openAppModal` is the imperative half. Its body belongs in an - * `AppModalBody`, and its actions, when it has any, in an `AppModalFooter`. - */ +/** Held open by state; `openAppModal` is the imperative version. */ export function AppModal(props: AppModalProps): ReactNode { const { opened, @@ -57,29 +57,17 @@ export function AppModal(props: AppModalProps): ReactNode { onClose={onClose ?? (() => undefined)} title={title} size={size} - centered withCloseButton={dismissible} closeOnClickOutside={dismissible} closeOnEscape={dismissible} - styles={APP_MODAL_STYLES} + classNames={APP_MODAL_CLASSES} > - {/* Takes the focus the trap would otherwise land on the first - control, which reads as that one being pre-selected. */} -
- {children} -
+ {children} ); } -/** - * Content pinned between the header and the scrolling body, like a preview - * image. The body below supplies the space under it. - */ +/** Pinned above the scrolling body, like a preview image. */ export function AppModalTop(props: PropsWithChildren): ReactNode { return ( @@ -93,15 +81,11 @@ interface AppModalBodyProps extends PropsWithChildren { gap?: MantineSpacing; } -/** - * A modal's content, padded away from the header and footer framing it. The one - * part of a modal that scrolls — `mih` because a flex item otherwise floors at - * its content height, which pushes the footer off the modal instead. - */ +/** The part that scrolls. */ export function AppModalBody(props: AppModalBodyProps): ReactNode { const { gap = "sm", children } = props; return ( - + {children} ); @@ -112,11 +96,9 @@ export function AppModalFooter(props: PropsWithChildren): ReactNode { return ( {props.children} diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 6697f9ae7..6a98d72d7 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -4,20 +4,20 @@ import { Divider, Group, Input, - Loader, Stack, Tabs, - TextInput, - Tooltip + TextInput } from "@mantine/core"; -import { GearIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; import { - BORDER, - FRAME_BACKGROUND, + GearIcon, + MagnifyingGlassIcon, + MoonIcon, + SunIcon +} from "@phosphor-icons/react"; +import { IconSize, NAVBAR_DIVIDER_COLOR, - NAVBAR_ROW_HEIGHT, - StatusColor + NAVBAR_ROW_HEIGHT } from "../lib/style-constants"; import { PropsWithChildren, @@ -32,26 +32,19 @@ import { useDebouncedCallback } from "@mantine/hooks"; import { AppBrand } from "./app-brand"; import { openSettingsMenu } from "../features/settings/open-settings-menu"; import { VendorMenu } from "../features/settings/components/vendor-filters"; -import { getUiState, updateUiState } from "../lib/ui-state"; +import { getUiState, Theme, updateUiState, useUiState } from "../lib/ui-state"; import { getLibraryName, useLibraryId } from "../lib/library"; import { APP_TABS, getTabName, useNavigateToTab } from "../lib/tabs"; -import { - RequireAccessLevel, - useAccessData -} from "../features/auth/access-level"; +import { useAccessData } from "../features/auth/access-level"; import { startSignIn } from "../features/auth/sign-in"; -import { useJobStatus } from "../lib/refresh"; import { LibraryId } from "@backend/features/library/library-id"; -import { type AppTab } from "@backend/features/settings/app-tab"; +import { type AppTab } from "../lib/app-tab"; import { queryClient } from "../lib/query-client"; import { getLibraryVersionQuery } from "../features/library/queries"; import { InsertLocationStatus } from "../features/insert-location/components/insert-location-status"; +import styles from "../lib/styles.module.css"; -/** - * The bar every page is topped by: the brand, then whatever that page puts - * beside it. Stretched so a full-height child lands its underline on the row's - * own border. - */ +/** Stretched so a full-height child's underline lands on the row's border. */ export function NavbarRow(props: PropsWithChildren): ReactNode { const { children } = props; return ( @@ -59,15 +52,12 @@ export function NavbarRow(props: PropsWithChildren): ReactNode { gap="sm" px="sm" h={NAVBAR_ROW_HEIGHT} - wrap="nowrap" align="stretch" - bg={FRAME_BACKGROUND} - style={{ borderBottom: BORDER }} + className={`${styles.frame} ${styles.dividerBottom}`} > {children && ( - // Closes the brand off, so the name reads as the app rather - // than the first tab. Mantine's own all but vanishes on gray. + // Mantine's own divider all but vanishes on gray. - + - - + - + @@ -103,14 +88,9 @@ export function AppNavbar(): ReactNode { ); } -/** - * Shown only when not signed in; starts the Onshape OAuth flow and returns to - * the current location, after which access-data reports the caller signed in. - */ function SignInButton(): ReactNode { const { signedIn, isPending } = useAccessData(); - // Waiting rather than assuming signed out: the placeholder would flash the - // button on every load for a caller who is already signed in. + // Otherwise the button flashes on every load for someone signed in. if (isPending || signedIn) return null; return ( @@ -120,29 +100,6 @@ function SignInButton(): ReactNode { ); } -/** Editor-only spinner shown while a library-load job is running. */ -function JobIndicator(): ReactNode { - return ( - - - - ); -} - -function RunningJobLoader(): ReactNode { - // Single editor-gated job-status consumer, so it owns refresh-on-finish. - const jobRunning = useJobStatus(); - if (!jobRunning) return null; - return ( - - - - ); -} - /** Switches tabs; the url is what actually selects one. */ function AppTabs(): ReactNode { const currentTabId = useLibraryId(); @@ -164,34 +121,29 @@ function AppTabs(): ReactNode { return; } const tabId = value as AppTab; - // Write-behind: the url displays it, this only decides where - // `/init` lands next time. + // Only decides where `/` resumes next time; the url is the source of truth. updateUiState({ tabId }); navigateToTab(tabId); }} styles={{ - // Hides the line under the tab list alone; the row owns one - // that spans it. The active indicator is colored separately. + // The row draws the line under the tabs. root: { "--tab-border-color": "transparent", minWidth: 0 }, - // Three full names outgrow a narrow panel; scrolling beats - // reflowing the navbar into two rows. + // Scroll rather than wrap onto a second row in a narrow panel. list: { - // Full height, so the underline lands on the row's border - // rather than partway up a taller bar. + // So the underline lands on the row's border. height: "100%", flexWrap: "nowrap", overflowX: "auto", scrollbarWidth: "none" }, - // Pulled onto that divider, so the active tab's indicator - // replaces it rather than stacking a line above it. + // Overlaps the row's border, so the active indicator replaces it. tab: { marginBottom: -1, paddingInline: "var(--mantine-spacing-sm)" } }} > - + {APP_TABS.map((tabId) => ( {getTabName(tabId)} @@ -202,15 +154,43 @@ function AppTabs(): ReactNode { ); } -export function SettingsButton() { +/** The theme toggle and settings, flush: a pair of icons, not two controls. */ +export function SettingsControls(): ReactNode { + return ( + + + + + ); +} + +function ThemeToggle(): ReactNode { + const theme = useUiState((state) => state.theme); + const isDark = theme === Theme.DARK; + return ( + + updateUiState({ theme: isDark ? Theme.LIGHT : Theme.DARK }) + } + > + {isDark ? ( + + ) : ( + + )} + + ); +} + +function SettingsButton() { return ( openSettingsMenu()} > @@ -228,19 +208,13 @@ function selectAllInputText(ref: RefObject) { input.setSelectionRange(0, length); } -/** - * How long typing pauses before the search runs. Each query re-searches the - * index and rebuilds the list, which is enough work to be felt between - * keystrokes. - */ const SEARCH_DEBOUNCE_MS = 200; function SearchBar() { const ref = useRef(null); const wasFocused = useRef(false); const libraryId = useLibraryId(); - // The box owns what is typed and the stored query follows a pause later, so - // a keystroke re-renders this input rather than every list reading the query. + // Local state, so a keystroke re-renders only the input. const [query, setQuery] = useState(() => getUiState().searchQuery ?? ""); const runSearch = useDebouncedCallback( (value: string) => { @@ -250,15 +224,13 @@ function SearchBar() { { delay: SEARCH_DEBOUNCE_MS, flushOnUnmount: true } ); - // `autoFocus` fires before the ref attaches, so onFocus has nothing to select - // through on the first open and last time's query keeps the caret after it. + // `autoFocus` fires before the ref attaches, so onFocus can't select. useEffect(() => { selectAllInputText(ref); }, []); const clearButton = query ? ( { setQuery(""); // Nothing to wait out: the list should empty on the click. @@ -274,19 +246,15 @@ function SearchBar() { // The panel opens to a library the caller is here to search. autoFocus flex={1} - leftSection={} + leftSection={} placeholder={`Search ${getLibraryName(libraryId)}...`} ref={ref} value={query} onFocus={() => { selectAllInputText(ref); }} - // A click on an unfocused input focuses it — selecting everything - // above — and then places the caret on mouseup, which collapses - // that selection again. Preventing the default only on the click - // that did the focusing keeps the select-all while leaving a click - // inside an already-focused field to put the caret where it was - // aimed. + // The mouseup of the click that focuses the input would collapse the + // select-all; later clicks place the caret normally. onMouseDown={() => { wasFocused.current = document.activeElement === ref.current; }} diff --git a/src/frontend/components/app-notice.tsx b/src/frontend/components/app-notice.tsx new file mode 100644 index 000000000..1b4876241 --- /dev/null +++ b/src/frontend/components/app-notice.tsx @@ -0,0 +1,83 @@ +import { Center, EmptyState, Loader } from "@mantine/core"; +import { XIcon } from "@phosphor-icons/react"; +import { IconSize, StatusColor } from "../lib/style-constants"; +import { ReactNode } from "react"; +import { AppIcon } from "./app-icon"; + +const ERROR_ICON = ( + +); + +const CONTACT_DEVELOPERS = + "If the problem persists, contact the FRCDesignApp developers."; + +interface NoticeProps { + /** Ends with a period whenever there is a description. */ + title: string; + description?: ReactNode; + /** @default a red cross */ + icon?: ReactNode; + action?: ReactNode; + className?: string; +} + +/** What content is replaced by when it is empty, loading or failed. */ +export function SectionNotice(props: NoticeProps): ReactNode { + const { title, description, icon = ERROR_ICON, action, className } = props; + return ( + + {action} + + ); +} + +interface SectionLoadingProps { + /** Takes the form "Loading {thing}...". */ + title: string; +} + +export function SectionLoading(props: SectionLoadingProps): ReactNode { + return } />; +} + +type ErrorProps = Omit; + +/** A failure with nothing more specific to say. */ +export function SectionError(props: ErrorProps): ReactNode { + return ; +} + +interface PageNoticeProps extends NoticeProps { + /** Keeps the notice nearer the top of the page. @default false */ + justifyUp?: boolean; +} + +/** A notice standing in for a whole page. */ +export function PageNotice(props: PageNoticeProps): ReactNode { + const { justifyUp = false, ...notice } = props; + if (justifyUp) { + return ; + } + return ( +
+ +
+ ); +} + +/** {@link SectionError} for a whole page. */ +export function PageError( + props: Omit +): ReactNode { + return ; +} diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 06bf036d1..76a7ff6ae 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -9,19 +9,15 @@ import { } from "@mantine/core"; import { CheckIcon, CopyIcon } from "@phosphor-icons/react"; import { type ReactNode, useEffect } from "react"; -import { modals } from "@mantine/modals"; +import { useAppModal } from "./open-app-modal"; import type { SearchRecord } from "@backend/features/configurations/contract"; -import { - FontWeight, - IconSize, - StatusColor, - TITLE_ICON_NUDGE -} from "../lib/style-constants"; +import { FontWeight, IconSize, StatusColor } from "../lib/style-constants"; import { meaningfulPartNumber } from "@backend/features/configurations/part-number"; -import { PartNumberLink } from "./part-number"; +import { PartNumber } from "./part-number"; +import styles from "../lib/styles.module.css"; interface AppTitleProps { - title: ReactNode; + title: string; /** Leading icon, at `IconSize.MEDIUM` to match the title's size. */ icon?: ReactNode; /** A quieter second line, laid out as a row so it can hold controls. */ @@ -34,25 +30,28 @@ interface AppTitleProps { export function AppTitle(props: AppTitleProps): ReactNode { const { title, icon, subtitle, rightSection } = props; return ( - + {/* Centred, not wrapped in a block, where the icon would go back to sitting on the text baseline several pixels low. */} - {icon &&
{icon}
} + {icon &&
{icon}
} - - + + {title} {rightSection} {subtitle && ( - // lh, because inheriting the title's 1 leaves no leading - // under the last line, reading low in an evenly padded header. + // Inheriting the title's line height of 1 reads low. + ) } /> @@ -96,21 +93,15 @@ interface UseMenuTitleProps extends Omit { name: string | undefined; } -/** - * Keeps a modal's header on the selection in view. The header is updated rather - * than rendered, being the modal's rather than the content's. - */ -export function useMenuTitle(modalId: string, props: UseMenuTitleProps): void { +/** Updates the modal's header, which belongs to the modal rather than the content. */ +export function useMenuTitle(props: UseMenuTitleProps): void { const { name, record, icon } = props; + const { setTitle } = useAppModal(); useEffect(() => { - if (name === undefined) { - return; + if (name !== undefined) { + setTitle(); } - modals.updateModal({ - modalId, - title: - }); - }, [modalId, name, record, icon]); + }, [setTitle, name, record, icon]); } /** The xs line box the subtitle row is otherwise sized by, floored. */ @@ -126,17 +117,11 @@ function CopyPartNumberButton(props: CopyPartNumberButtonProps): ReactNode { return ( {({ copied, copy }) => ( - + {copied ? ( @@ -151,24 +136,18 @@ function CopyPartNumberButton(props: CopyPartNumberButtonProps): ReactNode { ); } -interface PartNumberProps { +interface PartNumberLineProps { partNumber: string; url?: string; } -/** The part number, linked to the vendor's page for it when there is one. */ -function PartNumber(props: PartNumberProps): ReactNode { +function PartNumberLine(props: PartNumberLineProps): ReactNode { const { partNumber, url } = props; - if (url) { - return {partNumber}; - } - // Nowhere to send them, so offer the number itself to search with. return ( <> - - {partNumber} - - + + {/* Nowhere to send them, so offer the number to search with. */} + {!url && } ); } diff --git a/src/frontend/components/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx deleted file mode 100644 index 5818fc7f2..000000000 --- a/src/frontend/components/app-zero-state.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Center, EmptyState, Loader } from "@mantine/core"; -import { XIcon } from "@phosphor-icons/react"; -import { IconSize, StatusColor } from "../lib/style-constants"; -import { type JSX, ReactNode } from "react"; -import { AppIcon } from "./app-icon"; - -const DEFAULT_ERROR_ICON = ( - -); - -interface ZeroStateProps { - icon?: ReactNode; - title: string; - description?: ReactNode; - action?: ReactNode; - className?: string; -} - -/** - * The centered block every empty, loading and error state is built from, and - * what anything else standing in for content should use rather than laying one - * out again. - */ -export function ZeroState(props: ZeroStateProps): ReactNode { - const { icon, title, description, action, className } = props; - - return ( - - {action} - - ); -} - -interface SectionLoadingProps { - /** Takes the form "Loading {thing}...". */ - title: string; -} - -export function SectionLoading(props: SectionLoadingProps): ReactNode { - return } />; -} - -interface NoticeProps { - /** Ends with a period whenever there is a description. */ - title: string; - /** Null for none at all; omitted falls back to the contact-us line. */ - description?: string | null | JSX.Element; - className?: string; - /** @default a danger-colored cross */ - icon?: ReactNode; - action?: JSX.Element; -} - -function resolveDescription( - description: NoticeProps["description"] -): ReactNode { - if (description === undefined) { - return "If the problem persists, contact the FRCDesignApp developers."; - } - return description; -} - -/** - * Whatever a section shows in place of its content: a failure by default, and an - * empty result or a prompt when given an `icon` and `description` of its own. - */ -export function SectionNotice(props: NoticeProps): ReactNode { - const { title, action, className, icon = DEFAULT_ERROR_ICON } = props; - return ( - - ); -} - -interface PageNoticeProps extends NoticeProps { - /** Keeps the notice nearer the top of the page. @default false */ - justifyUp?: boolean; -} - -/** The same, standing in for a whole page rather than one section of one. */ -export function PageNotice(props: PageNoticeProps): ReactNode { - const { - title, - action, - className, - icon = DEFAULT_ERROR_ICON, - justifyUp = false - } = props; - - const notice = ( - - ); - - if (justifyUp) { - return notice; - } - - return
{notice}
; -} diff --git a/src/frontend/components/breadcrumbs.tsx b/src/frontend/components/breadcrumbs.tsx index 32601aa59..cbff48153 100644 --- a/src/frontend/components/breadcrumbs.tsx +++ b/src/frontend/components/breadcrumbs.tsx @@ -1,22 +1,42 @@ -import { Breadcrumbs, type MantineSpacing } from "@mantine/core"; +import { Anchor, Breadcrumbs, Text, type MantineSpacing } from "@mantine/core"; import { type ReactNode } from "react"; +import { StatusColor } from "../lib/style-constants"; + +export interface Crumb { + label: string; + /** Makes the crumb a link back to its level; without one it's dimmed text. */ + onClick?: () => void; +} interface AppBreadcrumbsProps { - /** The trail in order, separated where they meet. Bare text is wrapped for - * you; an element keeps its own typography. */ - children: ReactNode; - /** Spacing off whatever the trail sits above. */ + /** Outermost first. */ + crumbs: Crumb[]; + /** Where the trail ends; a string is set as plain text. */ + current: ReactNode; mb?: MantineSpacing; } -/** A trail of crumbs, so every one in the app is separated the same way. */ -export function AppBreadcrumbs({ - children, - mb -}: AppBreadcrumbsProps): ReactNode { +/** With no crumbs, just `current`. */ +export function AppBreadcrumbs(props: AppBreadcrumbsProps): ReactNode { + const { crumbs, current, mb } = props; + const end = typeof current === "string" ? {current} : current; + if (crumbs.length === 0) { + return end; + } return ( - {children} + {crumbs.map((crumb) => + crumb.onClick ? ( + + {crumb.label} + + ) : ( + + {crumb.label} + + ) + )} + {end} ); } diff --git a/src/frontend/components/callout.tsx b/src/frontend/components/callout.tsx index 64e39cd6a..93e7a1bbb 100644 --- a/src/frontend/components/callout.tsx +++ b/src/frontend/components/callout.tsx @@ -1,27 +1,16 @@ import { Alert, Button, Group, Text } from "@mantine/core"; import { InfoIcon } from "@phosphor-icons/react"; import { ReactNode } from "react"; -import { IconSize, NO_SHRINK, StatusColor } from "../lib/style-constants"; - -export interface CalloutAction { - /** A verb or a destination, e.g. "Instructions". */ - text: string; - icon: ReactNode; - onClick: () => void; -} +import { IconSize, StatusColor } from "../lib/style-constants"; interface CalloutProps { /** A whole sentence, ending in a period. */ text: string; - /** Omitted for a note that only reports something. */ - action?: CalloutAction; + /** A `CalloutButton`; omitted for a note that only reports something. */ + action?: ReactNode; } -/** - * A note above a list or a preview, saying something about what is under it. It - * builds its own button, so no caller can style one of its own. Blue rather - * than the library accent, so it reads as a remark beside the content. - */ +/** Blue, so it reads as a remark rather than library content. */ export function Callout(props: CalloutProps): ReactNode { const { text, action } = props; @@ -36,24 +25,34 @@ export function Callout(props: CalloutProps): ReactNode { wrapper: { alignItems: "center" } }} > - - {text} - {action && ( - // Outlined rather than filled, which would shout on a - // note; held at its own width, since the text is what - // gives on a narrow row. - - )} + {/* Wraps rather than squeezing: on a narrow panel the button drops + under the text instead of running off the edge. */} + + {text} + {action} ); } + +interface CalloutButtonProps { + /** A verb or a destination, e.g. "Instructions". */ + children: string; + icon: ReactNode; + onClick: () => void; +} + +export function CalloutButton(props: CalloutButtonProps): ReactNode { + const { children, icon, onClick } = props; + return ( + + ); +} diff --git a/src/frontend/components/change-order.test.tsx b/src/frontend/components/change-order.test.tsx new file mode 100644 index 000000000..f5de5dd85 --- /dev/null +++ b/src/frontend/components/change-order.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { Menu } from "@mantine/core"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../__test_utils__/render"; +import { ChangeOrderItems } from "./change-order"; + +const ORDER = ["a", "b", "c", "d"]; + +function renderItems(id: string, order = ORDER) { + const onOrderChange = vi.fn(); + renderWithProviders( + + + + + + + + + ); + return onOrderChange; +} + +const shownItems = () => + screen.queryAllByRole("menuitem").map((item) => item.textContent); + +describe("ChangeOrderItems", () => { + it.each([ + ["a", ["Move down", "Move to bottom"]], + // One from an end, the double move would repeat the single one. + ["b", ["Move up", "Move down", "Move to bottom"]], + ["c", ["Move up", "Move down", "Move to top"]], + ["d", ["Move up", "Move to top"]] + ])("offers %s only the moves that change something", (id, items) => { + renderItems(id); + expect(shownItems()).toEqual(items); + }); + + it("offers nothing for a lone item", () => { + renderItems("a", ["a"]); + expect(shownItems()).toEqual([]); + }); + + it("offers nothing for an id not in the list", () => { + renderItems("z"); + expect(shownItems()).toEqual([]); + }); + + it.each([ + ["Move up", "c", ["a", "c", "b", "d"]], + ["Move down", "b", ["a", "c", "b", "d"]], + ["Move to top", "c", ["c", "a", "b", "d"]], + ["Move to bottom", "b", ["a", "c", "d", "b"]] + ])("%s moves %s", async (label, id, expected) => { + const user = userEvent.setup(); + const onOrderChange = renderItems(id); + + await user.click(screen.getByText(label)); + + expect(onOrderChange).toHaveBeenCalledWith(expected); + }); + + it("leaves the order it was given alone", async () => { + const user = userEvent.setup(); + const order = [...ORDER]; + renderItems("d", order); + + await user.click(screen.getByText("Move to top")); + + expect(order).toEqual(ORDER); + }); +}); diff --git a/src/frontend/components/change-order.tsx b/src/frontend/components/change-order.tsx index 880b76d40..726d88e19 100644 --- a/src/frontend/components/change-order.tsx +++ b/src/frontend/components/change-order.tsx @@ -5,7 +5,6 @@ import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react"; -import { IconSize } from "../lib/style-constants"; import { type ReactNode } from "react"; interface ChangeOrderMenuProps { @@ -17,76 +16,19 @@ interface ChangeOrderMenuProps { /** The move items a list's order allows. */ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { const { id, order, onOrderChange } = props; - const operations = getValidOperations(id, order); - - if (operations.length === 0) { - return null; - } - - return ( - <> - {operations.includes(MoveOperation.MOVE_UP) && ( - } - onClick={() => { - onOrderChange( - applyMoveOperation(id, order, MoveOperation.MOVE_UP) - ); - }} - > - Move up - - )} - {operations.includes(MoveOperation.MOVE_DOWN) && ( - } - onClick={() => { - onOrderChange( - applyMoveOperation( - id, - order, - MoveOperation.MOVE_DOWN - ) - ); - }} - > - Move down - - )} - {operations.includes(MoveOperation.MOVE_TO_TOP) && ( - } - onClick={() => { - onOrderChange( - applyMoveOperation( - id, - order, - MoveOperation.MOVE_TO_TOP - ) - ); - }} - > - Move to top - - )} - {operations.includes(MoveOperation.MOVE_TO_BOTTOM) && ( - } - onClick={() => { - onOrderChange( - applyMoveOperation( - id, - order, - MoveOperation.MOVE_TO_BOTTOM - ) - ); - }} - > - Move to bottom - - )} - + return MOVE_ITEMS.filter((item) => operations.includes(item.operation)).map( + (item) => ( + } + onClick={() => + onOrderChange(applyMoveOperation(id, order, item.operation)) + } + > + {item.label} + + ) ); } @@ -97,13 +39,32 @@ enum MoveOperation { MOVE_TO_BOTTOM } +const MOVE_ITEMS = [ + { operation: MoveOperation.MOVE_UP, label: "Move up", icon: CaretUpIcon }, + { + operation: MoveOperation.MOVE_DOWN, + label: "Move down", + icon: CaretDownIcon + }, + { + operation: MoveOperation.MOVE_TO_TOP, + label: "Move to top", + icon: CaretDoubleUpIcon + }, + { + operation: MoveOperation.MOVE_TO_BOTTOM, + label: "Move to bottom", + icon: CaretDoubleDownIcon + } +]; + function applyMoveOperation( target: string, order: string[], operation: MoveOperation ): string[] { const index = order.indexOf(target); - if (index === -1) return order; // target not found, return unchanged + if (index === -1) return order; const result = [...order]; @@ -141,9 +102,6 @@ function applyMoveOperation( return result; } -/** - * Given a target and an order, returns a list of currently valid operations. - */ function getValidOperations(target: string, order: string[]): MoveOperation[] { const index = order.indexOf(target); if (index === -1) { @@ -154,7 +112,8 @@ function getValidOperations(target: string, order: string[]): MoveOperation[] { if (index > 0) { if (index === 1) { - operations.push(MoveOperation.MOVE_UP); // only "up" since it goes straight to top + // Up is already the top. + operations.push(MoveOperation.MOVE_UP); } else { operations.push(MoveOperation.MOVE_UP, MoveOperation.MOVE_TO_TOP); } @@ -162,7 +121,8 @@ function getValidOperations(target: string, order: string[]): MoveOperation[] { if (index < lastIndex) { if (index === lastIndex - 1) { - operations.push(MoveOperation.MOVE_DOWN); // only "down" since it goes straight to bottom + // Down is already the bottom. + operations.push(MoveOperation.MOVE_DOWN); } else { operations.push( MoveOperation.MOVE_DOWN, diff --git a/src/frontend/components/external-link.tsx b/src/frontend/components/external-link.tsx new file mode 100644 index 000000000..0e0e17524 --- /dev/null +++ b/src/frontend/components/external-link.tsx @@ -0,0 +1,37 @@ +import { Anchor, type AnchorProps } from "@mantine/core"; +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { type MouseEvent, type ReactNode } from "react"; +import { IconSize } from "../lib/style-constants"; +import styles from "../lib/styles.module.css"; + +interface ExternalLinkProps extends AnchorProps { + href: string; + children?: ReactNode; + /** Adds the out-arrow after the text at this size. */ + iconSize?: IconSize; +} + +// Links sit inside clickable rows, whose click they aren't meant for. +const stopPropagation = (event: MouseEvent) => event.stopPropagation(); + +/** Opens in a new tab. */ +export function ExternalLink(props: ExternalLinkProps): ReactNode { + const { href, children, iconSize, className, ...others } = props; + return ( + + {children} + {iconSize && } + + ); +} diff --git a/src/frontend/components/get-app.tsx b/src/frontend/components/get-app.tsx index 5863e4550..08eccf2b1 100644 --- a/src/frontend/components/get-app.tsx +++ b/src/frontend/components/get-app.tsx @@ -3,13 +3,9 @@ import { ReactNode } from "react"; import { useIsConnectedToOnshape } from "../lib/onshape-params"; import { IconSize } from "../lib/style-constants"; import { openUrlInNewTab, SETUP_URL } from "../lib/url"; -import { Callout } from "./callout"; +import { Callout, CalloutButton } from "./callout"; -/** - * Offers the app over the insert menu's preview, where a part somebody cannot - * insert is in front of them. Inside Onshape's panel they are already running - * it, so nothing renders there. - */ +/** Offers the app where someone sees a part they can't insert. Hidden inside Onshape. */ export function GetAppCallout(): ReactNode { const isConnected = useIsConnectedToOnshape(); @@ -20,11 +16,14 @@ export function GetAppCallout(): ReactNode { return ( , - onClick: () => openUrlInNewTab(SETUP_URL) - }} + action={ + } + onClick={() => openUrlInNewTab(SETUP_URL)} + > + Instructions + + } /> ); } diff --git a/src/frontend/components/input-row.tsx b/src/frontend/components/input-row.tsx index eb24f000e..ecaede8d5 100644 --- a/src/frontend/components/input-row.tsx +++ b/src/frontend/components/input-row.tsx @@ -6,49 +6,24 @@ interface InputRowProps { label: string; /** The id of the control the label describes, so clicking it focuses. */ htmlFor?: string; - /** True to lead with the control: a checkbox reads before its label. */ - controlFirst?: boolean; - /** - * True to push the control to the far end, which a menu of differently - * shaped controls wants and a form of same-width inputs does not. - */ - spread?: boolean; children: ReactNode; } -/** - * A label beside its control rather than above it. Given the control's height - * so the row stays level when an input grows to show an error. - */ -export function InputRow({ - label, - htmlFor, - controlFirst = false, - spread = false, - children -}: InputRowProps): ReactNode { - const text = ( - - {label} - - ); - +/** Given an input's height so rows stay level whatever the control. */ +export function InputRow(props: InputRowProps): ReactNode { + const { label, htmlFor, children } = props; return ( - - {controlFirst ? children : text} - {controlFirst ? text : children} + + + {label} + + {children} ); } diff --git a/src/frontend/components/item-row.tsx b/src/frontend/components/item-row.tsx index 2af343a9d..567721437 100644 --- a/src/frontend/components/item-row.tsx +++ b/src/frontend/components/item-row.tsx @@ -1,22 +1,14 @@ -/** - * The anatomy of a list row, shared by the library, favorites and search: the - * table that holds rows, a row itself, and the title block inside it. - */ import { Group, Stack, Table, Text } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; import { meaningfulPartNumber } from "@backend/features/configurations/part-number"; -import { NO_SHRINK, StatusColor } from "../lib/style-constants"; +import { StatusColor } from "../lib/style-constants"; import { AppContextMenu, MenuButton } from "./app-menu"; -import { TruncatedText } from "./truncated-text"; -import { PartNumberLink } from "./part-number"; +import { PartNumber } from "./part-number"; +import { equalsIgnoreCase } from "@backend/lib/text"; import { mergePositions, type Position } from "../lib/highlight"; +import styles from "../lib/styles.module.css"; -/** - * The configuration a row stands for, and where a query matched inside it. - * Structural rather than the search feature's own `SearchHit`: a row displays a - * match, it does not search — and a favorites row fills this from the favorite - * rather than from any query, with no positions at all. - */ +/** Not `SearchHit`: a favorites row fills this without a query. */ export interface RowMatch { /** Where the query matched inside the row's title; empty when none ran. */ positions: Position[]; @@ -50,24 +42,24 @@ export function CardTitle(props: CardTitleProps): ReactNode { } = props; return ( - + {thumbnail} {/* Shrinks to truncate, but never grows: the badge belongs beside the name, not at the row's edge. */} - - + - +
{/* The line under the title, so it sits beside it in the stack rather than inside the paragraph the title renders as. */} - + {match && }
{buildStatusBadge}
@@ -77,86 +69,47 @@ export function CardTitle(props: CardTitleProps): ReactNode { interface PartNameAndNumberProps { /** The row's own title, which neither line repeats. */ title: string; - match?: RowMatch; + match: RowMatch; } /** The matched selection's name and part number, beneath the title. */ function PartNameAndNumber(props: PartNameAndNumberProps): ReactNode { const { title, match } = props; - - // The match's best selection, minus a value repeating the title. - const partName = - match?.partName?.toLowerCase() !== title.toLowerCase() - ? match?.partName - : undefined; - const partNumber = meaningfulPartNumber(match?.partNumber, title); + const partName = equalsIgnoreCase(match.partName, title) + ? undefined + : match.partName; + const partNumber = meaningfulPartNumber(match.partNumber, title); if (!partName && !partNumber) { return null; } - return ( - + {partName && ( - + - + )} {partName && partNumber && ·} {partNumber && ( - + + + )} ); } -interface CardPartNumberProps { - partNumber: string; - /** Where the query matched inside it, for underlining. */ - positions?: Position[]; - url?: string; -} - -/** The part number, linked to the vendor's page for it when there is one. */ -function CardPartNumber(props: CardPartNumberProps): ReactNode { - const { partNumber, positions, url } = props; - const text = ; - if (!url) { - return ( - - {text} - - ); - } - return ( - - {text} - - ); -} - -/** - * Groups `ItemRow`s into a single dense, hoverable table. Loading/empty/error - * states should be rendered outside of this. - */ +/** Render loading, empty and error states outside it. */ export function ItemTable(props: PropsWithChildren): ReactNode { return ( - + {left} - + {moreButton && {menuItems}} {rightSection} @@ -214,8 +168,7 @@ function HighlightedText(props: HighlightedTextProps): ReactNode { const result: ReactNode[] = []; let currentIndex = 0; - // `mergePositions` walks an index map upward, so its runs come out - // ascending and disjoint; this reads the string in one pass on that. + // `mergePositions` returns ascending, disjoint runs. for (const { start, length } of mergePositions(positions)) { const end = start + length; if (currentIndex < start) { diff --git a/src/frontend/components/open-app-modal.tsx b/src/frontend/components/open-app-modal.tsx index 25d146072..1393317c5 100644 --- a/src/frontend/components/open-app-modal.tsx +++ b/src/frontend/components/open-app-modal.tsx @@ -1,46 +1,53 @@ import { modals } from "@mantine/modals"; -import type { ReactNode } from "react"; -import { APP_MODAL_STYLES } from "./app-modal"; - -const FILL_COLUMN = { - display: "flex", - flexDirection: "column", - flex: 1, - minHeight: 0 -} as const; +import { randomId } from "@mantine/hooks"; +import { createContext, type ReactNode, use, useMemo } from "react"; +import { APP_MODAL_CLASSES, AppModalContent } from "./app-modal"; interface OpenAppModalProps { title: ReactNode; children: ReactNode; - /** Pass one minted by the caller to update the modal while it is open. */ - modalId?: string; size?: string | number; onClose?: () => void; } -/** - * Opens a modal framed like the rest of the app. Its body is unpadded, so content - * belongs in an `AppModalBody` and actions in an `AppModalFooter`. - */ +const ModalIdContext = createContext(undefined); + +/** The body is unpadded: put content in `AppModalBody` and actions in `AppModalFooter`. */ export function openAppModal(props: OpenAppModalProps): void { - const { title, children, modalId, size, onClose } = props; + const { title, children, size, onClose } = props; + const modalId = randomId(); modals.open({ modalId, title, size, - // Takes the focus the trap would otherwise land on the close button, - // which reads as that button being pre-selected. children: ( -
- {children} -
+ + {children} + ), onClose, centered: true, - styles: APP_MODAL_STYLES + classNames: APP_MODAL_CLASSES }); } + +interface AppModalControls { + setTitle: (title: ReactNode) => void; + close: () => void; +} + +/** For content opened by `openAppModal`, to change the modal around it. */ +export function useAppModal(): AppModalControls { + const modalId = use(ModalIdContext); + return useMemo( + () => ({ + setTitle: (title) => { + if (modalId) modals.updateModal({ modalId, title }); + }, + close: () => { + if (modalId) modals.close(modalId); + } + }), + [modalId] + ); +} diff --git a/src/frontend/components/open-document-items.test.tsx b/src/frontend/components/open-document-items.test.tsx new file mode 100644 index 000000000..ec73387ce --- /dev/null +++ b/src/frontend/components/open-document-items.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Menu } from "@mantine/core"; +import { type ElementPath } from "@backend/lib/onshape/path"; +import { renderWithProviders } from "../../__test_utils__/render"; +import { OpenDocumentItems } from "./open-document-items"; +import { useOnshapeLaunch } from "../lib/onshape-params"; + +const PATH: ElementPath = { + documentId: "doc", + instanceId: "ver", + instanceType: "v", + elementId: "el" +}; + +async function copiedLink(selection?: Record) { + const user = userEvent.setup(); + const writeText = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(undefined); + renderWithProviders( + + + + + + + + + ); + await user.click(screen.getByText("Copy link")); + return decodeURIComponent(writeText.mock.calls[0][0]); +} + +describe("OpenDocumentItems", () => { + afterEach(() => { + vi.restoreAllMocks(); + useOnshapeLaunch.setState({ server: undefined }); + }); + + it("links to the configuration it is given, as it was typed", async () => { + expect(await copiedLink({ size: "large", length: "(2 + 3) in" })).toBe( + "https://cad.onshape.com/documents/doc/v/ver/e/el" + + "?configuration=size=large;length=(2 + 3) in" + ); + }); + + it("links into the Onshape the panel was launched from", async () => { + useOnshapeLaunch.setState({ server: "https://frcdesign.onshape.com" }); + expect(await copiedLink()).toBe( + "https://frcdesign.onshape.com/documents/doc/v/ver/e/el" + ); + }); + + it("links to the element itself without one", async () => { + expect(await copiedLink()).toBe( + "https://cad.onshape.com/documents/doc/v/ver/e/el" + ); + }); +}); diff --git a/src/frontend/components/open-document-items.tsx b/src/frontend/components/open-document-items.tsx index a94a69662..5e6c2af13 100644 --- a/src/frontend/components/open-document-items.tsx +++ b/src/frontend/components/open-document-items.tsx @@ -2,33 +2,38 @@ import { Menu } from "@mantine/core"; import { ArrowSquareOutIcon, LinkIcon } from "@phosphor-icons/react"; import { ReactNode } from "react"; import { - ConfigurablePath, DocumentPath, + ElementPath, InstancePath } from "@backend/lib/onshape/path"; -import { IconSize, StatusColor } from "../lib/style-constants"; +import { type PartialSelection } from "@backend/features/configurations/contract"; +import { StatusColor } from "../lib/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../lib/url"; +import { useOnshapeOrigin } from "../lib/onshape-params"; interface OpenDocumentItemsProps { /** Any Onshape path; a shell group's stops at the document. */ - path: DocumentPath | InstancePath | ConfigurablePath; + path: DocumentPath | InstancePath | ElementPath; + /** The configuration to open an element in; its defaults when absent. */ + selection?: PartialSelection; } /** The menu items for reaching an element's Onshape document. */ export function OpenDocumentItems(props: OpenDocumentItemsProps): ReactNode { - const url = makeUrl(props.path); + const origin = useOnshapeOrigin(); + const url = makeUrl(origin, props.path, props.selection); return ( <> } + leftSection={} onClick={() => openUrlInNewTab(url)} > Open document } + leftSection={} onClick={() => { void copyUrlToClipboard(url); }} diff --git a/src/frontend/components/open-url-button.tsx b/src/frontend/components/open-url-button.tsx index c52613fc7..e176fc6d8 100644 --- a/src/frontend/components/open-url-button.tsx +++ b/src/frontend/components/open-url-button.tsx @@ -1,6 +1,5 @@ import { Button } from "@mantine/core"; import { ArrowSquareOutIcon } from "@phosphor-icons/react"; -import { IconSize } from "../lib/style-constants"; import { openUrlInNewTab } from "../lib/url"; interface UrlButtonProps { @@ -11,9 +10,8 @@ interface UrlButtonProps { export function OpenUrlButton(props: UrlButtonProps) { return ( diff --git a/src/frontend/components/parameter-role.tsx b/src/frontend/components/parameter-role.tsx new file mode 100644 index 000000000..f9e80ce2e --- /dev/null +++ b/src/frontend/components/parameter-role.tsx @@ -0,0 +1,43 @@ +import { Group, Text } from "@mantine/core"; +import { + GitForkIcon, + type Icon, + PaletteIcon, + PolygonIcon, + SwatchesIcon +} from "@phosphor-icons/react"; +import { ReactNode } from "react"; +import { ParameterRole } from "@backend/features/configurations/contract"; +import { IconSize } from "../lib/style-constants"; + +const ROLE_LABELS: Record = { + [ParameterRole.DERIVATION_VARIABLE]: "Derivation variable", + [ParameterRole.COLOR]: "Color", + [ParameterRole.COLOR_CHANNEL]: "Color channel", + [ParameterRole.TESSELLATION]: "Tessellation quality" +}; + +export const ROLE_ICONS: Record = { + [ParameterRole.DERIVATION_VARIABLE]: GitForkIcon, + [ParameterRole.COLOR]: PaletteIcon, + [ParameterRole.COLOR_CHANNEL]: SwatchesIcon, + [ParameterRole.TESSELLATION]: PolygonIcon +}; + +interface ParameterRoleLabelProps { + role: ParameterRole; + /** What follows the role's name, e.g. ", so never indexed." */ + suffix?: string; +} + +/** A role's icon and name, for a tooltip. */ +export function ParameterRoleLabel(props: ParameterRoleLabelProps): ReactNode { + const { role, suffix = "" } = props; + const RoleIcon = ROLE_ICONS[role]; + return ( + + + {ROLE_LABELS[role] + suffix} + + ); +} diff --git a/src/frontend/components/part-number.tsx b/src/frontend/components/part-number.tsx index ebac0e7e6..be21af674 100644 --- a/src/frontend/components/part-number.tsx +++ b/src/frontend/components/part-number.tsx @@ -1,47 +1,47 @@ -import { Anchor, Text } from "@mantine/core"; -import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { Text } from "@mantine/core"; import { ReactNode } from "react"; -import { IconSize, NO_SHRINK } from "../lib/style-constants"; +import { IconSize } from "../lib/style-constants"; +import styles from "../lib/styles.module.css"; +import { ExternalLink } from "./external-link"; -interface PartNumberLinkProps { - /** Already-rendered text, so a caller can underline what a query matched. */ - children: ReactNode; - url: string; - /** - * Holds the link at its own width beside text that can outgrow the row, - * which a list row wants. A header wants the opposite: it lets a long part - * number shrink and ellipsize rather than push the title around. - */ - noShrink?: boolean; +interface PartNumberProps { + partNumber: string; + /** How to draw it, e.g. with a query's matches underlined. @default partNumber */ + children?: ReactNode; + /** The vendor's page for it. */ + url?: string; } -/** - * A part number pointing at the vendor's page for it. `inline-flex` so the icon - * centres on the text rather than sitting on its baseline, and takes the link's - * colour by being inside it. - */ -export function PartNumberLink(props: PartNumberLinkProps): ReactNode { - const { children, url, noShrink = false } = props; +/** Keeps its width beside anything that can shrink, but still ellipsizes past its row's. */ +export function PartNumber(props: PartNumberProps): ReactNode { + const { partNumber, children = partNumber, url } = props; + const text = ( + + {children} + + ); + if (!url) { + return ( + + {children} + + ); + } return ( - event.stopPropagation()} - display="inline-flex" - miw={0} maw="100%" - style={{ - alignItems: "center", - gap: 2, - ...(noShrink ? NO_SHRINK : {}) - }} + className={styles.noShrink} + iconSize={IconSize.TINY} > - - {children} - - - + {text} + ); } diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index f61f7f265..2e8a2acec 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -1,5 +1,5 @@ import { RequireAccessLevel } from "../features/auth/access-level"; -import { PageNotice } from "./app-zero-state"; +import { PageNotice, PageError } from "./app-notice"; import { ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { @@ -12,28 +12,27 @@ import { } from "@mantine/core"; import { CheckIcon, CopyIcon, HouseIcon } from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; -import { ReloadGroupsButton } from "../features/library/components/reload-groups-button"; +import { ReloadButton } from "../features/library/components/reload-button"; +import { AccessLevel } from "@backend/features/auth/access-level"; import { DEFAULT_LIBRARY } from "@backend/features/library/library-id"; -/** - * Catch-all error state for when a route below the root fails to load. - */ export function RootAppError(): ReactNode { return ( - - + + } /> ); } -/** - * Last-resort fallback for the ROOT route's errorComponent. - */ +/** The root route's errorComponent. */ export function RootCrash(): ReactNode { return (
+ {({ copied, copy }) => ( - + {copied ? ( @@ -124,7 +110,7 @@ export function NotFoundError(): ReactNode { title="Failed to find page." description={ <> - Click this button to fix the issue. If it doesn't, + Click this button to fix the issue. If that does not work, contact the FRCDesignApp developers with the address below. diff --git a/src/frontend/components/section.tsx b/src/frontend/components/section.tsx index 0cb975bf5..471697a52 100644 --- a/src/frontend/components/section.tsx +++ b/src/frontend/components/section.tsx @@ -31,9 +31,7 @@ interface SectionCardProps { export function SectionCard({ title, children }: SectionCardProps): ReactNode { return (
- - {children} - + {children}
); } diff --git a/src/frontend/components/status-icon.tsx b/src/frontend/components/status-icon.tsx index fc560fa68..16af6a7dc 100644 --- a/src/frontend/components/status-icon.tsx +++ b/src/frontend/components/status-icon.tsx @@ -5,11 +5,11 @@ import { AppIcon } from "./app-icon"; import { CONTROL_ICON_COLOR, IconSize, - NO_SHRINK, StatusColor } from "../lib/style-constants"; +import styles from "../lib/styles.module.css"; -export interface StatusIconProps { +interface StatusIconProps { /** What the status is about: the target tab, a build, a connection. */ icon: Icon; /** The state itself, badged on the corner — a tick or a warning. */ @@ -23,20 +23,9 @@ const SUBJECT_SIZE = IconSize.CONTROL; /** Big enough that a tick and a warning can be told apart at a glance. */ const BADGE_SIZE = 14; -/** - * How far the badge hangs past the subject's corner. The rest of it overlaps, - * and the subject has to stay recognizable under that. - */ const BADGE_OVERHANG = 4; -/** - * A subject icon with its state badged on its bottom-right corner, told by - * shape as well as by color — a tick and a warning stay apart where green and - * yellow do not. - * - * The box is the subject's own size, so the subject lines up with the plain - * icons either side of it and the badge hangs outside it. - */ +/** A tick and a warning differ by shape, not just color. Sized to the subject so it lines up with plain icons. */ export function StatusIcon(props: StatusIconProps): ReactNode { const { icon, status, color } = props; return ( @@ -44,9 +33,9 @@ export function StatusIcon(props: StatusIconProps): ReactNode { pos="relative" w={SUBJECT_SIZE} h={SUBJECT_SIZE} - // Zero line height, or the box takes a text row's height and the - // badge sits proud of the corner it is meant to hug. - style={{ ...NO_SHRINK, lineHeight: 0 }} + // Or the box takes a text row's height and the badge floats off the corner. + className={styles.noShrink} + lh={0} > (null); - const [isClipped, setIsClipped] = useState(false); - - const measure = () => { - const element = ref.current; - setIsClipped(!!element && element.scrollWidth > element.clientWidth); - }; - - return ( - - {children} - - ); -} diff --git a/src/frontend/features/admin-team/components/admin-team-setting.test.tsx b/src/frontend/features/admin-team/components/admin-team-setting.test.tsx new file mode 100644 index 000000000..1c1aff3c1 --- /dev/null +++ b/src/frontend/features/admin-team/components/admin-team-setting.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AccessLevel } from "@backend/features/auth/access-level"; +import { renderWithProviders } from "../../../../__test_utils__/render"; + +const mocks = vi.hoisted(() => ({ + level: "owner", + mutate: vi.fn() +})); + +vi.mock("../../auth/access-level", () => ({ + useAccessData: () => ({ currentAccessLevel: mocks.level }) +})); +vi.mock("../queries", () => ({ + useAdminTeamQuery: () => ({ + data: { teamId: "team-1", memberCount: 2 }, + isPending: false + }), + useSetAdminTeamMutation: () => ({ mutate: mocks.mutate, isPending: false }) +})); + +const { AdminTeamSetting } = await import("./admin-team-setting"); + +describe("the admin team setting", () => { + afterEach(() => mocks.mutate.mockReset()); + + it("saves what the owner types once they leave the field", async () => { + mocks.level = AccessLevel.OWNER; + const user = userEvent.setup(); + renderWithProviders(); + + const field = screen.getByLabelText("Admin team"); + await user.clear(field); + await user.type(field, " team-2 {Enter}"); + + expect(mocks.mutate).toHaveBeenCalledWith("team-2"); + }); + + it("clears the team when the owner empties the field", async () => { + mocks.level = AccessLevel.OWNER; + const user = userEvent.setup(); + renderWithProviders(); + + await user.clear(screen.getByLabelText("Admin team")); + await user.tab(); + + expect(mocks.mutate).toHaveBeenCalledWith(null); + }); + + it("saves nothing when the team did not change", async () => { + mocks.level = AccessLevel.OWNER; + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByLabelText("Admin team")); + await user.tab(); + + expect(mocks.mutate).not.toHaveBeenCalled(); + }); + + it("shows the team to anyone else without letting them change it", () => { + mocks.level = AccessLevel.ADMIN; + renderWithProviders(); + + const field = screen.getByLabelText("Admin team"); + expect((field as HTMLInputElement).value).toBe("team-1"); + expect(field).toHaveProperty("readOnly", true); + }); +}); diff --git a/src/frontend/features/admin-team/components/admin-team-setting.tsx b/src/frontend/features/admin-team/components/admin-team-setting.tsx new file mode 100644 index 000000000..c7a10d55a --- /dev/null +++ b/src/frontend/features/admin-team/components/admin-team-setting.tsx @@ -0,0 +1,43 @@ +import { TextInput } from "@mantine/core"; +import { type FocusEvent, type ReactNode, useId } from "react"; +import { AccessLevel } from "@backend/features/auth/access-level"; +import { InputRow } from "../../../components/input-row"; +import { SETTING_CONTROL_WIDTH } from "../../../lib/style-constants"; +import { useAccessData } from "../../auth/access-level"; +import { useAdminTeamQuery, useSetAdminTeamMutation } from "../queries"; + +/** The Onshape team whose members edit this library; only the owner sets it. */ +export function AdminTeamSetting(): ReactNode { + const { currentAccessLevel } = useAccessData(); + const query = useAdminTeamQuery(); + const mutation = useSetAdminTeamMutation(); + const id = useId(); + const isOwner = currentAccessLevel === AccessLevel.OWNER; + const stored = query.data?.teamId ?? ""; + + const save = (event: FocusEvent) => { + const teamId = event.currentTarget.value.trim(); + if (teamId !== stored) { + mutation.mutate(teamId || null); + } + }; + + return ( + + { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + + ); +} diff --git a/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx b/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx new file mode 100644 index 000000000..bbe5af97b --- /dev/null +++ b/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx @@ -0,0 +1,17 @@ +import { Button } from "@mantine/core"; +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { type ReactNode } from "react"; +import { useRefreshAdminTeamMutation } from "../queries"; + +export function RefreshAdminTeamButton(): ReactNode { + const mutation = useRefreshAdminTeamMutation(); + return ( + + ); +} diff --git a/src/frontend/features/admin-team/queries.ts b/src/frontend/features/admin-team/queries.ts new file mode 100644 index 000000000..3f308978e --- /dev/null +++ b/src/frontend/features/admin-team/queries.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AdminTeamOut } from "@backend/features/admin-team/contract"; +import { apiGet, apiPost } from "../../lib/api-client"; +import { toLibraryPath } from "../../lib/api-paths"; +import { getAppErrorHandler } from "../../lib/errors"; +import { useLibraryId } from "../../lib/library"; +import { showSuccessToast } from "../../lib/notifications"; +import { queryClient } from "../../lib/query-client"; +import { adminTeamQueryKey } from "../../lib/query-keys"; + +export function useAdminTeamQuery() { + const libraryId = useLibraryId(); + return useQuery({ + queryKey: adminTeamQueryKey(libraryId), + queryFn: () => + apiGet("/admin-team" + toLibraryPath(libraryId)) + }); +} + +/** The version bump it causes refreshes everyone's access. */ +export function useSetAdminTeamMutation() { + const libraryId = useLibraryId(); + return useMutation({ + mutationKey: ["admin-team", libraryId], + mutationFn: (teamId: string | null) => + apiPost("/admin-team" + toLibraryPath(libraryId), { + body: { teamId } + }), + onError: getAppErrorHandler("Failed to set the admin team!"), + onSuccess: (team) => { + queryClient.setQueryData(adminTeamQueryKey(libraryId), team); + showSuccessToast( + team.teamId + ? `Admin team set: ${team.memberCount} members.` + : "Admin team removed." + ); + } + }); +} + +/** Pulls the team's members again, for a change made in Onshape. */ +export function useRefreshAdminTeamMutation() { + const libraryId = useLibraryId(); + return useMutation({ + mutationKey: ["refresh-admin-team", libraryId], + mutationFn: () => + apiPost( + "/admin-team/refresh" + toLibraryPath(libraryId) + ), + onError: getAppErrorHandler("Failed to refresh the admin team!"), + onSuccess: (team) => { + queryClient.setQueryData(adminTeamQueryKey(libraryId), team); + showSuccessToast( + team.teamId + ? `Admin team refreshed: ${team.memberCount} members.` + : "This library has no admin team." + ); + } + }); +} diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index 277bef4bb..6fefd2f1b 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -7,49 +7,44 @@ import { hasEditorAccess } from "@backend/features/auth/access-level"; import { accessDataQueryKey } from "../../lib/query-keys"; +import { toLibraryPath } from "../../lib/api-paths"; +import { useLibraryId } from "../../lib/library"; +import type { LibraryId } from "@backend/features/library/library-id"; import { apiGet } from "../../lib/api-client"; -import { useGetUiState } from "../../lib/ui-state"; +import { useUiState } from "../../lib/ui-state"; /** The level the app is viewed as by default; the dev override grants it too. */ const DEFAULT_ACCESS_LEVEL = (import.meta.env.VITE_ACCESS_LEVEL_OVERRIDE as AccessLevel | undefined) ?? AccessLevel.USER; -/** - * What the app assumes until the server answers. Granted as well as viewed, or - * the clamp below would drop a dev override while the query is pending. - */ +/** Granted as well as viewed, or the clamp would drop a dev override while pending. */ const DEFAULT_ACCESS_DATA: AccessData = { maxAccessLevel: DEFAULT_ACCESS_LEVEL, signedIn: false }; -export function getAccessDataQuery() { +/** Access to one library: its admin team is what grants more than a user's. */ +export function getAccessDataQuery(libraryId: LibraryId) { return queryOptions({ - queryKey: accessDataQueryKey(), - queryFn: () => apiGet("/access-data") + queryKey: accessDataQueryKey(libraryId), + queryFn: () => apiGet("/access-data" + toLibraryPath(libraryId)) }); } /** Server access plus the level the app is currently viewed as. */ interface ResolvedAccessData extends AccessData { currentAccessLevel: AccessLevel; - /** - * While set, the rest are the placeholder — so anything rendered for a - * *signed-out* caller must wait or it flashes. Positive gates need not. - */ + /** While set, the rest are placeholders, so signed-out UI must wait or it flashes. */ isPending: boolean; } -/** - * The caller's access. The viewed level is a local choice (the settings menu can - * drop below the granted max), so it survives the query refetching on navigation. - */ +/** The viewed level is a local choice, so it survives refetches. */ export function useAccessData(): ResolvedAccessData { - const { data, isPending } = useQuery(getAccessDataQuery()); + const libraryId = useLibraryId(); + const { data, isPending } = useQuery(getAccessDataQuery(libraryId)); const serverData = data ?? DEFAULT_ACCESS_DATA; - const uiState = useGetUiState(); - const chosenLevel = uiState.accessLevel; + const chosenLevel = useUiState((state) => state.accessLevel); return useMemo(() => { const desired = chosenLevel ?? DEFAULT_ACCESS_LEVEL; let currentAccessLevel = desired; @@ -61,10 +56,7 @@ export function useAccessData(): ResolvedAccessData { }, [serverData, chosenLevel, isPending]); } -/** - * Whether the caller is signed in to Onshape. Reads signed out while access-data - * is pending, so a signed-out render wants useAccessData().isPending as well. - */ +/** Reads signed out while pending; see `isPending`. */ export function useIsSignedIn(): boolean { const accessData = useAccessData(); return accessData.signedIn; @@ -98,11 +90,6 @@ export function RequireSignIn(props: PropsWithChildren) { return useIsSignedIn() ? props.children : null; } -/** - * Whether the caller is shown what is hidden. The rule was spelled out at each - * of its call sites, half of them negated, so changing who counts as privileged - * meant finding four of them and getting the negation right at each. - */ export function useShowHidden(): boolean { const accessData = useAccessData(); return hasEditorAccess(accessData.currentAccessLevel); diff --git a/src/frontend/features/auth/sign-in.ts b/src/frontend/features/auth/sign-in.ts index 49a7aed2e..40e616771 100644 --- a/src/frontend/features/auth/sign-in.ts +++ b/src/frontend/features/auth/sign-in.ts @@ -1,20 +1,11 @@ -import { updateUiState } from "../../lib/ui-state"; +import { useOnshapeLaunch } from "../../lib/onshape-params"; -/** - * Redirects to the Onshape OAuth flow, returning to the app's entry point, - * which resumes where the caller left off and confirms the sign-in. - */ +/** Returns to the page it was started from. */ export function startSignIn(): void { - updateUiState({ justSignedIn: true }); - const search = new URLSearchParams(window.location.search); const query = new URLSearchParams({ - redirectUrl: "/" + window.location.search + redirectUrl: window.location.pathname + window.location.search }); - // The entry redirect leaves Onshape's company in the app's url. It has to - // ride as a parameter of its own: nested inside redirectUrl the sign-in - // route never reads it, and the token comes back scoped to whichever - // account Onshape picks rather than the enterprise the caller is in. - const sessionCompanyId = search.get("sessionCompanyId"); + const { sessionCompanyId } = useOnshapeLaunch.getState(); if (sessionCompanyId) { query.set("sessionCompanyId", sessionCompanyId); } diff --git a/src/frontend/features/auth/sign-out.ts b/src/frontend/features/auth/sign-out.ts index a7b77318d..ceee538cf 100644 --- a/src/frontend/features/auth/sign-out.ts +++ b/src/frontend/features/auth/sign-out.ts @@ -1,7 +1,4 @@ -/** - * Ends the app's own session and reloads where the caller stands, which is what - * makes the next load fetch access data as a signed-out one. - */ +/** Reloads in place, so access data is refetched signed out. */ export function startSignOut(): void { const url = new URL(window.location.href); const redirectUrl = url.pathname + url.search; diff --git a/src/frontend/features/build-status/components/admin-section.tsx b/src/frontend/features/build-status/components/admin-section.tsx index 2d74e50d7..84d634756 100644 --- a/src/frontend/features/build-status/components/admin-section.tsx +++ b/src/frontend/features/build-status/components/admin-section.tsx @@ -1,62 +1,26 @@ -import { Stack, Switch, Tooltip } from "@mantine/core"; +import { Stack } from "@mantine/core"; import { ReactNode } from "react"; -import { BuildIssueSeverity } from "@backend/features/build-checker/issues"; import { GroupBuildStatus, InsertableBuildStatus } from "@backend/features/build-checker/contract"; -import { ElementType } from "@backend/lib/onshape/element-type"; -import { - type ConfigurationCount, - IndexingBand, - MAX_PART_NUMBER_CONFIGURATIONS -} from "@backend/features/configurations/combinations"; -import { NO_SHRINK } from "../../../lib/style-constants"; import { useSetVisibilityMutation, useToggleInsertAndFastenMutation, - useIndexConfigurationsMutation, useToggleSortOrderMutation } from "../queries"; -import { ControlRow, SectionHeader } from "./sections"; -import { IssueIcon } from "./issues"; - -interface SwitchRowProps { - label: string; - description?: string; - checked: boolean; - onToggle: () => void; -} - -/** A label (+ description) and on/off Switch row for an editable admin flag. */ -function SwitchRow(props: SwitchRowProps): ReactNode { - return ( - - } - /> - ); -} +import { SectionHeader, SwitchRow } from "./sections"; interface InsertableAdminSectionProps { insertableId: string; status: InsertableBuildStatus; - configurationCount: ConfigurationCount; } /** The editable admin toggles for an insertable. */ export function InsertableAdminSection( props: InsertableAdminSectionProps ): ReactNode { - const { insertableId, status, configurationCount } = props; + const { insertableId, status } = props; return ( Admin @@ -68,11 +32,6 @@ export function InsertableAdminSection( insertableId={insertableId} supportsFasten={status.supportsFasten} /> - ); } @@ -112,80 +71,6 @@ function FastenSwitch(props: FastenSwitchProps): ReactNode { ); } -interface IndexingRowProps { - insertableId: string; - status: InsertableBuildStatus; - band: IndexingBand; -} - -/** - * A switch only where enabling indexing is the admin's call, an icon saying why - * not otherwise — past the cap it can't run, under the threshold it already has. - */ -function IndexingRow(props: IndexingRowProps): ReactNode { - const { insertableId, status, band } = props; - const mutation = useIndexConfigurationsMutation(insertableId); - - let control: ReactNode; - if (status.elementType === ElementType.ASSEMBLY) { - control = ( - - ); - } else if (band === IndexingBand.EXCEEDED) { - control = ( - - ); - } else if (band === IndexingBand.AUTOMATIC) { - control = ( - - ); - } else { - control = ( - mutation.mutate(!status.indexConfigurations)} - withThumbIndicator={false} - /> - ); - } - - return ( - - ); -} - -interface IndexingIconProps { - severity: BuildIssueSeverity | null; - tooltip: string; -} - -/** - * Stands in for the switch where there is nothing to toggle, reusing the - * build-check icons so the state reads the same as the callouts above it. - */ -function IndexingIcon(props: IndexingIconProps): ReactNode { - const { severity, tooltip } = props; - return ( - - - - ); -} - interface GroupAdminSectionProps { groupId: string; status: GroupBuildStatus; diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index 795811ff8..f4a777f04 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -1,14 +1,18 @@ import { + Badge, Divider, Group, - HoverCard, Loader, Stack, Text, Tooltip } from "@mantine/core"; -import { EyeSlashIcon, GitBranchIcon } from "@phosphor-icons/react"; -import { ReactNode, createContext, use, useCallback, useState } from "react"; +import { + EyeSlashIcon, + GitBranchIcon, + HourglassIcon +} from "@phosphor-icons/react"; +import { ReactNode } from "react"; import { formatDaysAgo } from "../../../lib/format-time"; import { BuildIssue, @@ -18,14 +22,16 @@ import { InsertableBuildStatus } from "@backend/features/build-checker/contract" import { FontWeight, IconSize, - NO_SHRINK, StatusColor } from "../../../lib/style-constants"; import { AppIcon } from "../../../components/app-icon"; +import { AppHoverCard } from "../../../components/app-hover-card"; import { RequireAccessLevel } from "../../auth/access-level"; -import { TruncatedText } from "../../../components/truncated-text"; import { useBuildStatusQuery } from "../queries"; -import { useIsJobRunning } from "../../library/queries"; +import { + useIsGroupAwaitingApproval, + useIsGroupLoading +} from "../../library/queries"; import { BuildChecksSection, type ConfigurationTarget, @@ -35,18 +41,21 @@ import { } from "./issues"; import { ConfigurationSection, - InsertableParsedSection, - useConfigurationCount + InsertableParsedSection } from "./parsed-section"; import { GroupAdminSection, InsertableAdminSection } from "./admin-section"; +import { IndexingSection } from "./indexing-section"; +import styles from "../../../lib/styles.module.css"; /** What the card and the badge both say about a group or an insertable. */ interface BuildStatusSubject { /** The group/insertable name shown in the header. */ name: string; + /** The group it is, or is in: a load of that group is what spins. */ + groupId: string; issues: BuildIssue[]; /** When Onshape cut the version it is pinned to (epoch ms); null if none. */ - versionCreatedAt: number | null; + versionCreatedAt?: number; /** Set for an insertable, so an issue can open the configuration it blames. */ configurationTarget?: ConfigurationTarget; /** Draws the badge as hidden-from-users instead of as its worst severity. */ @@ -58,17 +67,20 @@ interface BuildStatusCardProps extends BuildStatusSubject { children: ReactNode; } -/** - * The hover-card content: a header (name, severity summary, last-loaded time), - * the build checks (when any), and the wrapped group/insertable admin menu. - */ function BuildStatusCard(props: BuildStatusCardProps): ReactNode { - const { name, issues, versionCreatedAt, configurationTarget, children } = - props; + const { + name, + groupId, + issues, + versionCreatedAt, + configurationTarget, + children + } = props; return ( @@ -92,22 +104,7 @@ interface BuildStatusBadgeProps extends BuildStatusSubject { hoverMenu: ReactNode; } -/** - * For controls that open a modal: `HoverCard` closes on mouse-leave, which never - * fires when an overlay covers the dropdown, stranding it behind. - */ -const CloseCardContext = createContext<() => void>(() => undefined); - -/** Dismisses the build-status hover card a control is rendered inside. */ -export function useCloseBuildCard(): () => void { - return use(CloseCardContext); -} - -/** - * A severity icon whose hover card shows the build-status card wrapping the - * given admin menu. Gated first, so the card and its admin controls only exist - * for an editor. - */ +/** Gated first, so the card and its admin controls only exist for an editor. */ function BuildStatusBadge(props: BuildStatusBadgeProps): ReactNode { return ( @@ -121,6 +118,7 @@ type BuildStatusHoverCardProps = BuildStatusBadgeProps; function BuildStatusHoverCard({ name, + groupId, issues, versionCreatedAt, configurationTarget, @@ -128,79 +126,62 @@ function BuildStatusHoverCard({ hoverMenu }: BuildStatusHoverCardProps): ReactNode { const maxSeverity = getMaxSeverity(issues); - const jobRunning = useIsJobRunning(); - - // Remounting is the only way to close an uncontrolled HoverCard on demand. - const [cardKey, setCardKey] = useState(0); - const close = useCallback(() => setCardKey((key) => key + 1), []); + const loading = useIsGroupLoading(groupId); return ( - - + ) : isHidden ? ( + // Only editors see hidden insertables, so its checks don't matter yet. + + ) : ( + + ) + } + > + - - {jobRunning ? ( - - ) : isHidden ? ( - // Nobody but an editor sees a hidden insertable, so what - // its checks say about it does not matter yet. - - ) : ( - - )} - - e.stopPropagation()}> - - {hoverMenu} - - - - + {hoverMenu} + + ); } interface CardHeaderProps { name: string; + groupId: string; issues: BuildIssue[]; - versionCreatedAt: number | null; + versionCreatedAt?: number; } /** The card header: name + severity summary on the left, the version's age on the right. */ function CardHeader(props: CardHeaderProps): ReactNode { - const { name, issues, versionCreatedAt } = props; + const { name, groupId, issues, versionCreatedAt } = props; return ( - - + {name} - - + + @@ -208,32 +189,27 @@ function CardHeader(props: CardHeaderProps): ReactNode { } interface VersionAgeProps { - versionCreatedAt: number | null; + groupId: string; + versionCreatedAt?: number; } -/** - * How old the pinned Onshape version is — when the version was cut, not when we - * last synced it. A spinner (with a tooltip) stands in while a job is running; - * otherwise the version icon and a day count say it without a label. - */ +/** When the pinned version was cut, not when it was synced. */ function VersionAge(props: VersionAgeProps): ReactNode { - const { versionCreatedAt } = props; - // Asked for again rather than threaded through three components; React - // Query serves both readers from one cache entry. - const jobRunning = useIsJobRunning(); - if (jobRunning) { + const { groupId, versionCreatedAt } = props; + const loading = useIsGroupLoading(groupId); + if (loading) { return ( - - + + ); } return ( @@ -245,6 +221,7 @@ function VersionAge(props: VersionAgeProps): ReactNode { interface InsertableStatusBadgeProps { insertableId: string; + groupId: string; name: string; } @@ -252,13 +229,14 @@ interface InsertableStatusBadgeProps { export function InsertableStatusBadge( props: InsertableStatusBadgeProps ): ReactNode { - const { insertableId, name } = props; + const { insertableId, groupId, name } = props; const { data } = useBuildStatusQuery(); const insertable = data?.insertables[insertableId]; if (!insertable) return null; return ( + - + ); } @@ -313,15 +287,40 @@ export function GroupStatusBadge(props: GroupStatusBadgeProps): ReactNode { const { data } = useBuildStatusQuery(); const groupStatus = data?.groups[groupId]; const issues = useGroupBuildIssues(groupStatus, data?.insertables); - if (!groupStatus) return null; return ( - - } - /> + <> + {groupStatus && ( + + } + /> + )} + + + ); +} + +interface AwaitingApprovalBadgeProps { + groupId: string; +} + +function AwaitingApprovalBadge(props: AwaitingApprovalBadgeProps): ReactNode { + const awaiting = useIsGroupAwaitingApproval(props.groupId); + if (!awaiting) return null; + return ( + } + > + Awaiting approval + ); } diff --git a/src/frontend/features/build-status/components/indexing-section.test.tsx b/src/frontend/features/build-status/components/indexing-section.test.tsx new file mode 100644 index 000000000..9afc4f6af --- /dev/null +++ b/src/frontend/features/build-status/components/indexing-section.test.tsx @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../../__test_utils__/render"; +import { + boolParam, + enumParam, + paramsWithConfigs, + stringParam +} from "../../../../__test_utils__/configuration-fixtures"; +import type { InsertableBuildStatus } from "@backend/features/build-checker/contract"; +import type { ConfigurationParameter } from "@backend/features/configurations/contract"; +import { ElementType } from "@backend/lib/onshape/element-type"; + +const mocks = vi.hoisted(() => ({ + setIndexing: vi.fn(), + setExcluded: vi.fn() +})); + +vi.mock("../queries", () => ({ + useIndexConfigurationsMutation: () => ({ + mutate: mocks.setIndexing, + isPending: false + }), + useExcludedParametersMutation: () => ({ + mutate: mocks.setExcluded, + isPending: false + }) +})); + +const { IndexingSection } = await import("./indexing-section"); + +function status( + parameters: ConfigurationParameter[], + excludedParameterIds: string[] = [] +): InsertableBuildStatus { + return { + buildIssues: [], + elementPath: { + documentId: "d", + instanceType: "v", + instanceId: "v", + elementId: "e" + }, + elementType: ElementType.ASSEMBLY, + isVisible: true, + supportsFasten: false, + indexConfigurations: false, + excludedParameterIds, + vendors: [], + configuration: { parameters } + }; +} + +describe("indexing section", () => { + afterEach(() => { + mocks.setIndexing.mockReset(); + mocks.setExcluded.mockReset(); + }); + + it("counts the configurations its indexed parameters make", () => { + renderWithProviders( + + ); + expect(screen.getByText("3")).toBeTruthy(); + }); + + it("gives each enum and boolean a switch, and none to text", () => { + renderWithProviders( + + ); + expect(screen.getByText("Index size")).toBeTruthy(); + expect(screen.getByText("Index holes")).toBeTruthy(); + expect(screen.queryByText("Index label")).toBeNull(); + }); + + it("stops indexing an assembly's parameter", async () => { + renderWithProviders( + + ); + const switches = screen.getAllByRole("switch"); + await userEvent.setup().click(switches[switches.length - 1]); + expect(mocks.setExcluded).toHaveBeenCalledWith(["size"]); + }); + + it("lets an admin enable indexing past the automatic threshold", async () => { + renderWithProviders( + + ); + await userEvent.setup().click(screen.getAllByRole("switch")[0]); + expect(mocks.setIndexing).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/frontend/features/build-status/components/indexing-section.tsx b/src/frontend/features/build-status/components/indexing-section.tsx new file mode 100644 index 000000000..c96f0e640 --- /dev/null +++ b/src/frontend/features/build-status/components/indexing-section.tsx @@ -0,0 +1,186 @@ +import { Divider, Stack, Switch, Text, Tooltip } from "@mantine/core"; +import { ReactNode, useMemo } from "react"; +import { InsertableBuildStatus } from "@backend/features/build-checker/contract"; +import { BuildIssueSeverity } from "@backend/features/build-checker/issues"; +import { + ParameterType, + type ConfigurationParameter +} from "@backend/features/configurations/contract"; +import { + countCombinations, + countConfigurations, + IndexingBand, + isIndexedParameter, + MAX_COUNTED_CONFIGURATIONS, + MAX_PART_NUMBER_CONFIGURATIONS +} from "@backend/features/configurations/combinations"; +import { StatusColor } from "../../../lib/style-constants"; +import { + useExcludedParametersMutation, + useIndexConfigurationsMutation +} from "../queries"; +import { ControlRow, SectionHeader, SwitchRow } from "./sections"; +import { IssueIcon } from "./issues"; +import { getParameterTypeLabel } from "./parsed-section"; +import styles from "../../../lib/styles.module.css"; + +interface IndexingSectionProps { + insertableId: string; + status: InsertableBuildStatus; +} + +/** Whether search indexes an insertable's configurations, and which parameters it varies. */ +export function IndexingSection(props: IndexingSectionProps): ReactNode { + const { insertableId, status } = props; + const { excludedParameterIds } = status; + const parameters = status.configuration?.parameters; + + // Both are enumerated on demand with the load path's routine; the total + // runs past the index cap the band is decided by. + const band = useMemo( + () => countConfigurations(parameters ?? [], excludedParameterIds).band, + [parameters, excludedParameterIds] + ); + const count = useMemo( + () => countCombinations(parameters ?? [], excludedParameterIds), + [parameters, excludedParameterIds] + ); + const indexable = (parameters ?? []).filter((parameter) => + isIndexedParameter(parameter) + ); + + return ( + <> + + + Indexing + + } + /> + {indexable.map((parameter) => ( + + ))} + + + ); +} + +interface IndexingRowProps { + insertableId: string; + status: InsertableBuildStatus; + band: IndexingBand; +} + +/** A switch only where enabling is the admin's call; otherwise an icon says why. */ +function IndexingRow(props: IndexingRowProps): ReactNode { + const { insertableId, status, band } = props; + const mutation = useIndexConfigurationsMutation(insertableId); + + let control: ReactNode; + if (band === IndexingBand.EXCEEDED) { + control = ( + + ); + } else if (band === IndexingBand.AUTOMATIC) { + control = ( + + ); + } else { + control = ( + mutation.mutate(!status.indexConfigurations)} + withThumbIndicator={false} + /> + ); + } + + return ( + + ); +} + +interface IndexingIconProps { + severity?: BuildIssueSeverity; + tooltip: string; +} + +/** Reuses the build-check icons so it reads like the callouts. */ +function IndexingIcon(props: IndexingIconProps): ReactNode { + const { severity, tooltip } = props; + return ( + + + + ); +} + +interface ConfigurationCountTextProps { + count: number | undefined; +} + +/** Open-ended only past the counting cap, which nothing real reaches. */ +function ConfigurationCountText(props: ConfigurationCountTextProps): ReactNode { + const { count } = props; + if (count === undefined) { + return Over {MAX_COUNTED_CONFIGURATIONS.toLocaleString()}; + } + if (count === 0) { + return None; + } + return {count.toLocaleString()}; +} + +interface ParameterSwitchProps { + insertableId: string; + excludedParameterIds: string[]; + parameter: ConfigurationParameter; +} + +function ParameterSwitch(props: ParameterSwitchProps): ReactNode { + const { insertableId, excludedParameterIds, parameter } = props; + const mutation = useExcludedParametersMutation(insertableId); + const isIndexed = !excludedParameterIds.includes(parameter.id); + const typeLabel = getParameterTypeLabel(parameter.type); + const description = + parameter.type === ParameterType.ENUM + ? `${typeLabel}, ${parameter.options.length} options` + : typeLabel; + return ( + + mutation.mutate( + isIndexed + ? [...excludedParameterIds, parameter.id] + : excludedParameterIds.filter( + (id) => id !== parameter.id + ) + ) + } + /> + ); +} diff --git a/src/frontend/features/build-status/components/issues.tsx b/src/frontend/features/build-status/components/issues.tsx index 15cd4abbf..a5e1ef46a 100644 --- a/src/frontend/features/build-status/components/issues.tsx +++ b/src/frontend/features/build-status/components/issues.tsx @@ -1,4 +1,5 @@ -import { Anchor, Badge, Group, Stack, Text } from "@mantine/core"; +import { ExternalLink } from "../../../components/external-link"; +import { Badge, Group, Stack, Text, Tooltip } from "@mantine/core"; import { ArrowSquareOutIcon, CheckIcon, @@ -12,33 +13,27 @@ import { BuildIssue, BuildIssueSeverity, BuildIssueType, - getIssueConfigurationKey, + getIssueConfiguration, getIssueDescription, getIssueSeverity, + getIssueTitle, hasBuildIssue } from "@backend/features/build-checker/issues"; import { ConfigurationParameter } from "@backend/features/configurations/contract"; -import { fromKey } from "@backend/features/configurations/selection"; +import { toSelection } from "@backend/features/configurations/selection"; import { ElementPath } from "@backend/lib/onshape/path"; import { makeUrl } from "../../../lib/url"; import { GroupBuildStatus, InsertableBuildStatus } from "@backend/features/build-checker/contract"; -import { - IconSize, - NO_SHRINK, - RADIUS, - StatusColor, - statusBackground -} from "../../../lib/style-constants"; +import { IconSize, StatusColor } from "../../../lib/style-constants"; import { AppIcon, type AppIconProps } from "../../../components/app-icon"; import { SectionHeader } from "./sections"; +import { useOnshapeOrigin } from "../../../lib/onshape-params"; +import styles from "../../../lib/styles.module.css"; -/** - * Stored issues plus the live "no unhidden insertables" check, which needs the - * per-insertable visibility in the same response. - */ +/** Adds the live "no unhidden insertables" check, which needs visibility. */ export function useGroupBuildIssues( groupStatus: GroupBuildStatus | undefined, insertableStatuses: Record | undefined @@ -48,8 +43,7 @@ export function useGroupBuildIssues( const hasUnhidden = groupStatus.insertableOrder.some( (id) => insertableStatuses?.[id]?.isVisible ); - // A group that never loaded has no insertables to unhide, so the failure - // is the whole story. + // A group that never loaded has nothing to unhide. if ( hasUnhidden || hasBuildIssue(groupStatus.buildIssues, BuildIssueType.LOAD_FAILED) @@ -63,8 +57,8 @@ export function useGroupBuildIssues( } interface IssueIconProps extends Omit { - /** The severity to render, or null if all checks pass. */ - severity: BuildIssueSeverity | null; + /** The severity to render; absent when every check passes. */ + severity?: BuildIssueSeverity; } /** The icon each severity is drawn as; `ok` is a build with nothing to say. */ @@ -75,8 +69,8 @@ const SEVERITY_ICONS = { ok: CheckIcon }; -/** The color a severity is spoken in; null is a build with nothing to say. */ -function severityColor(severity: BuildIssueSeverity | null): StatusColor { +/** The color a severity is spoken in; none is a build with nothing to say. */ +function severityColor(severity?: BuildIssueSeverity): StatusColor { switch (severity) { case BuildIssueSeverity.ERROR: return StatusColor.ERROR; @@ -84,7 +78,7 @@ function severityColor(severity: BuildIssueSeverity | null): StatusColor { return StatusColor.WARNING; case BuildIssueSeverity.INFO: return StatusColor.INFO; - case null: + case undefined: return StatusColor.SUCCESS; } } @@ -110,8 +104,6 @@ export function SeverityBadges(props: SeverityBadgesProps): ReactNode { if (issues.length === 0) { return ( } > @@ -165,11 +157,7 @@ function CountBadge(props: CountBadgeProps): ReactNode { const { color, noun } = SEVERITY_BADGE[severity]; // Don't pluralize info, e.g. "2 infos" reads wrong. const plural = severity !== BuildIssueSeverity.INFO && count > 1 ? "s" : ""; - return ( - - {`${count} ${noun}${plural}`} - - ); + return {`${count} ${noun}${plural}`}; } /** How many issues of each severity a build carries. */ @@ -197,10 +185,7 @@ function countSeverities(issues: BuildIssue[]): SeverityCounts { return counts; } -/** - * What a configuration issue opens: the tab it belongs to, and the parameters - * its key is spelled against. An element with no configurations has none. - */ +/** Undefined for an element with no configurations. */ export interface ConfigurationTarget { elementPath: ElementPath; parameters: ConfigurationParameter[]; @@ -208,17 +193,19 @@ export interface ConfigurationTarget { /** The offending configuration in Onshape, for an issue that blames one. */ function getIssueUrl( + origin: string, issue: BuildIssue, target: ConfigurationTarget | undefined ): string | undefined { - const key = getIssueConfigurationKey(issue); - if (key === undefined || !target) { + const values = getIssueConfiguration(issue); + if (values === undefined || !target) { return undefined; } - return makeUrl({ - ...target.elementPath, - selection: fromKey(key, target.parameters) - }); + return makeUrl( + origin, + target.elementPath, + toSelection(values, target.parameters) + ); } interface BuildChecksSectionProps { @@ -230,6 +217,7 @@ interface BuildChecksSectionProps { /** The build checks: one tinted callout per issue. Rendered only when non-empty. */ export function BuildChecksSection(props: BuildChecksSectionProps): ReactNode { const { issues, configurationTarget } = props; + const origin = useOnshapeOrigin(); return ( Build checks @@ -237,7 +225,7 @@ export function BuildChecksSection(props: BuildChecksSectionProps): ReactNode { ))} @@ -252,61 +240,84 @@ const CALLOUT_LAYOUT = { p: "xs" } as const; -/** Nudged down so the icon aligns with the first line of text. */ -const CALLOUT_ICON = { ...NO_SHRINK, marginTop: 2 }; - interface IssueCalloutProps { issue: BuildIssue; /** Where the issue opens, when it blames one configuration. */ url?: string; } -/** - * A single build issue rendered as a tinted callout box in its severity color. - * An issue that names a configuration is the link to it, whole box included — - * there is nothing else in the callout to click. - */ +/** When the issue names a configuration, the whole box links to it. */ function IssueCallout(props: IssueCalloutProps): ReactNode { const { issue, url } = props; const severity = getIssueSeverity(issue); - const background = { - backgroundColor: severityBackground(severity), - borderRadius: RADIUS - }; + const background = severityBackground(severity); if (!url) { return ( - - - {getIssueDescription(issue)} + + + ); } return ( - // The box is the link, so the anchor drops its own color and rule and - // lets the callout keep the severity's. - - - - - {getIssueDescription(issue)} - - + + + + + - + + ); +} + +interface IssueTextProps { + issue: BuildIssue; +} + +function IssueText(props: IssueTextProps): ReactNode { + const { issue } = props; + const description = getIssueDescription(issue); + return ( + <> + {getIssueTitle(issue)} + {description && ( + + + + )} + ); } /** The light background tint for a build-issue callout. */ function severityBackground(severity: BuildIssueSeverity): string { - return statusBackground(severityColor(severity)); + return `var(--mantine-color-${severityColor(severity)}-light)`; +} + +interface CalloutIconProps { + severity: BuildIssueSeverity; +} + +/** Down to the first line of text, which a centred icon sits above. */ +const CALLOUT_ICON_NUDGE = { marginTop: 2 }; + +function CalloutIcon(props: CalloutIconProps): ReactNode { + return ( + + ); } diff --git a/src/frontend/features/build-status/components/parsed-section.tsx b/src/frontend/features/build-status/components/parsed-section.tsx index e3db0764b..527c5b929 100644 --- a/src/frontend/features/build-status/components/parsed-section.tsx +++ b/src/frontend/features/build-status/components/parsed-section.tsx @@ -7,67 +7,25 @@ import { Text, Tooltip } from "@mantine/core"; -import { CheckIcon, FileXIcon, XIcon } from "@phosphor-icons/react"; -import { ReactNode, useMemo } from "react"; +import { + ParameterRoleLabel, + ROLE_ICONS +} from "../../../components/parameter-role"; +import { ReactNode } from "react"; import { InsertableBuildStatus } from "@backend/features/build-checker/contract"; import { getVendorName, Vendor } from "@backend/features/library/vendors"; import { ConfigurationParameter, ParameterType } from "@backend/features/configurations/contract"; -import { - type ConfigurationCount, - countCombinations, - countConfigurations, - MAX_COUNTED_CONFIGURATIONS -} from "@backend/features/configurations/combinations"; import { CATEGORY_COLOR, IconSize, - NO_SHRINK, StatusColor } from "../../../lib/style-constants"; import { AppIcon } from "../../../components/app-icon"; import { SectionHeader } from "./sections"; - -/** Discriminated so `StateValue` renders each kind its own way. */ -type StateRowValue = - | { kind: "bool"; value: boolean } - | { kind: "text"; text: string; dimmed?: boolean } - | { kind: "vendors"; vendors: Vendor[] }; - -/** - * Enumerated rather than stored: the same shared routine the load path uses, - * and it only runs when a hover card opens. - */ -export function useConfigurationCount( - status: InsertableBuildStatus -): ConfigurationCount { - const parameters = status.configuration?.parameters; - return useMemo(() => countConfigurations(parameters ?? []), [parameters]); -} - -/** The true total, which runs past the index cap the band is decided by. */ -function useDisplayedConfigurationCount( - status: InsertableBuildStatus -): number | null { - const parameters = status.configuration?.parameters; - return useMemo(() => countCombinations(parameters ?? []), [parameters]); -} - -/** Open-ended only past the counting cap, which nothing real reaches. */ -function configurationCountValue(count: number | null): StateRowValue { - if (count === null) { - return { - kind: "text", - text: `Over ${MAX_COUNTED_CONFIGURATIONS.toLocaleString()}` - }; - } - if (count === 0) { - return { kind: "text", text: "None", dimmed: true }; - } - return { kind: "text", text: count.toLocaleString() }; -} +import styles from "../../../lib/styles.module.css"; interface InsertableParsedSectionProps { status: InsertableBuildStatus; @@ -78,20 +36,15 @@ export function InsertableParsedSection( props: InsertableParsedSectionProps ): ReactNode { const { status } = props; - const count = useDisplayedConfigurationCount(status); return ( <> Parsed - - + + Vendors + + ); @@ -101,14 +54,15 @@ export function InsertableParsedSection( const PARAMETER_LIST_MAX_HEIGHT = 220; interface ConfigurationSectionProps { - parameters?: ConfigurationParameter[]; + status: InsertableBuildStatus; } -/** Each parameter's name, the type it takes, and whether indexing varies it. */ +/** Each parameter's name and the type it takes. */ export function ConfigurationSection( props: ConfigurationSectionProps ): ReactNode { - const { parameters } = props; + const { status } = props; + const parameters = status.configuration?.parameters; if (!parameters || parameters.length === 0) return null; return ( <> @@ -137,48 +91,38 @@ interface ParameterRowProps { parameter: ConfigurationParameter; } -/** One parameter: its name, and what varies or excludes it. */ function ParameterRow(props: ParameterRowProps): ReactNode { const { parameter } = props; return ( - - {parameter.name} - - + + {parameter.name} + + ); } -interface ExcludedFromPropertiesIconProps { +interface RoleIconProps { parameter: ConfigurationParameter; } -/** - * Onshape's "exclude from affecting configured properties", the lever on the - * count. Part studios only, which Onshape itself enforces. - */ -function ExcludedFromPropertiesIcon( - props: ExcludedFromPropertiesIconProps -): ReactNode { - const { parameter } = props; - if (!parameter.isCosmetic) { - return null; - } +function RoleIcon(props: RoleIconProps): ReactNode { + const role = props.parameter.role; + if (!role) return null; return ( + } events={{ hover: true, focus: true, touch: true }} > ); @@ -188,10 +132,7 @@ interface ParameterTypeBadgeProps { parameter: ConfigurationParameter; } -/** - * The parameter's type. An enum also carries its option count, and lists the - * options on hover — the values that drive its share of the configuration count. - */ +/** An enum lists its options on hover. */ function ParameterTypeBadge(props: ParameterTypeBadgeProps): ReactNode { const { parameter } = props; const isEnum = parameter.type === ParameterType.ENUM; @@ -200,7 +141,7 @@ function ParameterTypeBadge(props: ParameterTypeBadgeProps): ReactNode { : getParameterTypeLabel(parameter.type); const badge = ( - + {label} ); @@ -210,9 +151,6 @@ function ParameterTypeBadge(props: ParameterTypeBadgeProps): ReactNode { return ( option.name).join(", ")} - multiline - maw={260} - withArrow events={{ hover: true, focus: true, touch: true }} > {badge} @@ -221,7 +159,7 @@ function ParameterTypeBadge(props: ParameterTypeBadgeProps): ReactNode { } /** The short label for a parameter's type, shown as a badge. */ -function getParameterTypeLabel(type: ParameterType): string { +export function getParameterTypeLabel(type: ParameterType): string { switch (type) { case ParameterType.ENUM: return "Enum"; @@ -234,67 +172,20 @@ function getParameterTypeLabel(type: ParameterType): string { } } -interface ParsedRowProps { - label: string; - value: StateRowValue; +interface VendorBadgesProps { + vendors: Vendor[]; } -/** A read-only label/value row in the "Parsed" section. */ -function ParsedRow(props: ParsedRowProps): ReactNode { - const { label, value } = props; - return ( - - {label} - - - ); -} - -interface StateValueProps { - value: StateRowValue; -} - -/** Renders a parsed value: a check/cross for booleans, badges for vendors. */ -function StateValue(props: StateValueProps): ReactNode { - const { value } = props; - if (value.kind === "bool") { - return value.value ? ( - - ) : ( - - ); - } - - if (value.kind === "text") { - return ( - - {value.text} - - ); - } - - if (value.vendors.length === 0) { - return ( - - None - - ); +function VendorBadges(props: VendorBadgesProps): ReactNode { + const { vendors } = props; + if (vendors.length === 0) { + return None; } return ( - {value.vendors.map((vendor) => ( + {vendors.map((vendor) => ( diff --git a/src/frontend/features/build-status/components/sections.tsx b/src/frontend/features/build-status/components/sections.tsx index 0f6eb8df3..962645a50 100644 --- a/src/frontend/features/build-status/components/sections.tsx +++ b/src/frontend/features/build-status/components/sections.tsx @@ -1,4 +1,4 @@ -import { Box, Group, Text } from "@mantine/core"; +import { Box, Group, Switch, Text } from "@mantine/core"; import { ReactNode } from "react"; import { FontWeight, StatusColor } from "../../../lib/style-constants"; @@ -22,20 +22,45 @@ interface ControlRowProps { control: ReactNode; } -/** - * A label (+ description) and a right-aligned control. Usually a Switch, but a - * setting that isn't the admin's to make shows an icon saying why instead. - */ +/** Usually a Switch; an icon when the setting isn't the admin's to make. */ export function ControlRow(props: ControlRowProps): ReactNode { return ( - + - {props.label} - - {props.description} - + {props.label} + {props.description && ( + + {props.description} + + )} {props.control} ); } + +interface SwitchRowProps { + label: string; + description?: string; + checked: boolean; + disabled?: boolean; + onToggle: () => void; +} + +export function SwitchRow(props: SwitchRowProps): ReactNode { + return ( + + } + /> + ); +} diff --git a/src/frontend/features/build-status/queries.ts b/src/frontend/features/build-status/queries.ts index d6f0a28f6..7638b8dd7 100644 --- a/src/frontend/features/build-status/queries.ts +++ b/src/frontend/features/build-status/queries.ts @@ -16,7 +16,6 @@ import { getAppErrorHandler } from "../../lib/errors"; import { patchQuery } from "../../lib/query-cache"; import { useRefreshLibrary } from "../../lib/refresh"; import { toInsertablePath, toLibraryPath } from "../../lib/api-paths"; -import { useCloseBuildCard } from "./components/build-status"; import { type LibraryBuildStatus } from "@backend/features/build-checker/contract"; import { LibraryId } from "@backend/features/library/library-id"; import { useLibraryId } from "../../lib/library"; @@ -30,8 +29,7 @@ function getBuildStatusQuery(libraryId: LibraryId, cacheVersion: number) { apiGet("/build-status/library/" + libraryId, { cacheId: cacheVersion }), - // A toggle bumps cacheVersion (and thus this key); keep the old data on - // screen while the new version refetches so the hover card doesn't close. + // So the hover card doesn't close while the new version loads. placeholderData: keepPreviousData, staleTime: Infinity, gcTime: Infinity @@ -59,8 +57,6 @@ export function useSetVisibilityMutation( const refreshLibrary = useRefreshLibrary(); const key = useBuildStatusKey(); - const closeCard = useCloseBuildCard(); - const mutation = useMutation({ mutationKey: ["set-insertable-visibility", ...insertableIds], mutationFn: async () => { @@ -105,7 +101,6 @@ export function useSetVisibilityMutation( mutation.mutate(); return; } - closeCard(); modals.openConfirmModal({ title: "Hide elements", children: @@ -114,13 +109,14 @@ export function useSetVisibilityMutation( confirmProps: { color: "red" }, onConfirm: () => mutation.mutate() }); - }, [isVisible, closeCard, mutation]); + }, [isVisible, mutation]); return { mutate, isPending: mutation.isPending }; } /** Toggles an insertable's "insert and fasten" support (a slow Onshape call). */ export function useToggleInsertAndFastenMutation(insertableId: string) { + const libraryId = useLibraryId(); const key = useBuildStatusKey(); const refreshLibrary = useRefreshLibrary(); const toastId = `insert-and-fasten-${insertableId}`; @@ -128,7 +124,9 @@ export function useToggleInsertAndFastenMutation(insertableId: string) { mutationKey: ["toggle-insert-and-fasten", insertableId], mutationFn: (supportsFasten: boolean) => apiPost( - "/toggle-insert-and-fasten" + toInsertablePath(insertableId), + "/toggle-insert-and-fasten" + + toLibraryPath(libraryId) + + toInsertablePath(insertableId), { body: { supportsFasten } } ), onMutate: (supportsFasten) => { @@ -159,25 +157,28 @@ export function useToggleInsertAndFastenMutation(insertableId: string) { }); } -/** - * Toggles part-number indexing for an insertable. The Onshape call behind it - * runs long, so the toast reports the switch rather than sitting on the response. - */ +/** The Onshape call runs long, so the toast reports the switch without waiting. */ export function useIndexConfigurationsMutation(insertableId: string) { + const libraryId = useLibraryId(); const key = useBuildStatusKey(); const refreshLibrary = useRefreshLibrary(); const toastId = `index-configurations-${insertableId}`; return useMutation({ mutationKey: ["index-configurations", insertableId], mutationFn: (indexConfigurations: boolean) => - apiPost("/index-configurations" + toInsertablePath(insertableId), { - body: { indexConfigurations } - }), + apiPost( + "/index-configurations" + + toLibraryPath(libraryId) + + toInsertablePath(insertableId), + { + body: { indexConfigurations } + } + ), onMutate: (indexConfigurations) => { showInfoToast( indexConfigurations - ? "Enabling part indexing" - : "Disabling part indexing", + ? "Enabling indexing" + : "Disabling indexing", { id: toastId } ); return patchQuery(key, (status) => { @@ -189,12 +190,47 @@ export function useIndexConfigurationsMutation(insertableId: string) { onSuccess: (_result, indexConfigurations) => showSuccessToast( indexConfigurations - ? "Part number indexing enabled." - : "Part number indexing disabled.", + ? "Indexing enabled." + : "Indexing disabled.", toastId ), onError: getAppErrorHandler( - "Unexpectedly failed to update part number indexing.", + "Unexpectedly failed to update indexing.", + toastId + ), + onSettled: (_result, error) => + refreshLibrary({ discardPatches: error !== null }) + }); +} + +/** Re-probes the part, so the toast reports the change first. */ +export function useExcludedParametersMutation(insertableId: string) { + const libraryId = useLibraryId(); + const key = useBuildStatusKey(); + const refreshLibrary = useRefreshLibrary(); + const toastId = `excluded-parameters-${insertableId}`; + return useMutation({ + mutationKey: ["excluded-parameters", insertableId], + mutationFn: (excludedParameterIds: string[]) => + apiPost( + "/excluded-parameters" + + toLibraryPath(libraryId) + + toInsertablePath(insertableId), + { + body: { excludedParameterIds } + } + ), + onMutate: (excludedParameterIds) => { + showInfoToast("Reindexing part", { id: toastId }); + return patchQuery(key, (status) => { + const insertable = status.insertables[insertableId]; + if (insertable) + insertable.excludedParameterIds = excludedParameterIds; + }); + }, + onSuccess: () => showSuccessToast("Part reindexed.", toastId), + onError: getAppErrorHandler( + "Unexpectedly failed to update the indexed parameters.", toastId ), onSettled: (_result, error) => diff --git a/src/frontend/features/dashboard/change-indicator.tsx b/src/frontend/features/dashboard/change-indicator.tsx index c6bcdb55a..5d62e3666 100644 --- a/src/frontend/features/dashboard/change-indicator.tsx +++ b/src/frontend/features/dashboard/change-indicator.tsx @@ -14,18 +14,15 @@ interface ChangeIndicatorProps { format?: (value: number) => string; } -/** - * How a measure changed, always beside the number and never without naming the - * baseline: these tiles mix windows, so a bare "+82%" would be unreadable. - */ +/** Always names the baseline, since the tiles mix windows. */ export function ChangeIndicator({ comparison, format = formatCount }: ChangeIndicatorProps): ReactNode { if (comparison.changeRatio === undefined) { return ( - - + + {shortReason(comparison)} @@ -39,17 +36,14 @@ export function ChangeIndicator({ const color = flat ? "dimmed" : rising ? "green" : "red"; const change = ( - + - - {formatPercentChange(comparison.changeRatio)} - + {formatPercentChange(comparison.changeRatio)} ); return ( diff --git a/src/frontend/features/dashboard/configuration-breakdown.tsx b/src/frontend/features/dashboard/configuration-breakdown.tsx index 50cd3318b..2f942619f 100644 --- a/src/frontend/features/dashboard/configuration-breakdown.tsx +++ b/src/frontend/features/dashboard/configuration-breakdown.tsx @@ -21,17 +21,13 @@ import { } from "../../lib/style-constants"; import { formatCount, formatPercent } from "./format"; import { ImplicitDefaultBadge } from "./implicit-default"; -import { ParameterPath } from "./parameter-path"; +import { AppBreadcrumbs } from "../../components/breadcrumbs"; interface ConfigurationBreakdownProps { parameters: ConfigurationParameterUsage[]; } -/** - * Per-parameter value counts, which is how a wrong default shows itself: the - * default sitting below another value, or options nobody ever picks. One card - * per instance, so a list another choice filters is read one branch at a time. - */ +/** Shows a wrong default: one below another value, or options nobody picks. One card per instance. */ export function ConfigurationBreakdown({ parameters }: ConfigurationBreakdownProps): ReactNode { @@ -46,8 +42,6 @@ export function ConfigurationBreakdown({ return ( {parameters.map((parameter) => ( - /* The path is what tells two instances of one parameter - apart, and what the card is titled with. */ ")}`} parameter={parameter} @@ -63,19 +57,16 @@ interface ParameterCardProps { function ParameterCard({ parameter }: ParameterCardProps): ReactNode { return ( - + - - - {parameter.name} - - - {parameter.type} - + + ({ label }))} + current={{parameter.name}} + /> + {parameter.type} - - {formatCount(parameter.total)} recorded - + {formatCount(parameter.total)} recorded {/* A quantity takes any number the user types, so the list of @@ -99,7 +90,6 @@ function ParameterCard({ parameter }: ParameterCardProps): ReactNode { ); } -/** Six rows or so, past which the card scrolls rather than the page. */ const VALUES_HEIGHT = 260; interface ValueRowProps { @@ -109,8 +99,7 @@ interface ValueRowProps { function ValueRow({ value, total }: ValueRowProps): ReactNode { const percent = total === 0 ? 0 : (value.count / total) * 100; - // Both are what an insert lands on with nothing picked, so both read as - // the value the rest of the list is measured against. + // Either is what an insert lands on untouched. const lands = value.isDefault || value.isImplicitDefault; return ( @@ -118,7 +107,6 @@ function ValueRow({ value, total }: ValueRowProps): ReactNode { @@ -126,7 +114,7 @@ function ValueRow({ value, total }: ValueRowProps): ReactNode { - + {formatCount(value.count)} ({formatPercent(percent)}) @@ -143,10 +131,6 @@ interface DefaultBadgeProps { value: ConfigurationValueUsage; } -/** - * Which kind of default this is, if either: the one the parameter declares, or - * the one the app falls to because the declared one is not offered here. - */ function DefaultBadge({ value }: DefaultBadgeProps): ReactNode { if (value.isImplicitDefault) { return ( @@ -158,11 +142,7 @@ function DefaultBadge({ value }: DefaultBadgeProps): ReactNode { ); } if (value.isDefault) { - return ( - - Default - - ); + return Default; } return null; } diff --git a/src/frontend/features/dashboard/dashboard-navbar.tsx b/src/frontend/features/dashboard/dashboard-navbar.tsx index d949f5fda..1b98e4b0e 100644 --- a/src/frontend/features/dashboard/dashboard-navbar.tsx +++ b/src/frontend/features/dashboard/dashboard-navbar.tsx @@ -20,8 +20,8 @@ import { import { type ReactNode } from "react"; import { LibraryId } from "@backend/features/library/library-id"; import { getLibraryName } from "../../lib/library"; -import { BORDER, IconSize, NAVBAR_ROW_HEIGHT } from "../../lib/style-constants"; -import { NavbarRow, SettingsButton } from "../../components/app-navbar"; +import { IconSize, NAVBAR_ROW_HEIGHT } from "../../lib/style-constants"; +import { NavbarRow, SettingsControls } from "../../components/app-navbar"; import { RangeControl } from "./range-control"; import { DASHBOARDS, @@ -30,14 +30,12 @@ import { toDashboardKey, type DashboardKey } from "./dashboard-nav"; +import styles from "../../lib/styles.module.css"; interface DashboardTabsProps { current: DashboardKey; } -/** - * Two tiers, like the panel's navbar: the dashboard over the library it reads. - */ export function DashboardNavbar(): ReactNode { const pathname = useRouterState({ select: (s) => s.location.pathname }); const current = toDashboardKey(pathname); @@ -46,9 +44,9 @@ export function DashboardNavbar(): ReactNode { - + - + {/* Only the library-scoped dashboards have anything to put here: @@ -59,9 +57,8 @@ export function DashboardNavbar(): ReactNode { gap="sm" px="sm" h={NAVBAR_ROW_HEIGHT} - wrap="nowrap" align="center" - style={{ borderBottom: BORDER }} + className={styles.dividerBottom} > @@ -94,7 +91,7 @@ function DashboardTabs({ current }: DashboardTabsProps): ReactNode { }} styles={TAB_STYLES} > - + {DASHBOARDS.map((entry) => ( {entry.label} @@ -118,12 +115,12 @@ function LibraryMenu({ dashboard }: LibraryMenuProps): ReactNode { const target = DASHBOARDS.find((entry) => entry.key === dashboard); return ( - + @@ -139,8 +136,7 @@ function LibraryMenu({ dashboard }: LibraryMenuProps): ReactNode { target?.to ?? "/dashboard/library/$libraryId", params: { libraryId }, - // Dropped, not retained: the part being - // reported on belongs to the old library. + // The part belongs to the old library. search: { element: undefined } }) } @@ -170,7 +166,6 @@ function ThresholdControl(): ReactNode { } leftSectionWidth={THRESHOLD_LABEL_WIDTH} - aria-label="Low-usage threshold" value={threshold ?? DEFAULT_THRESHOLD} onChange={(value) => void navigate({ @@ -196,12 +191,9 @@ function RefreshButton(): ReactNode { const fetching = useIsFetching({ queryKey: ["analytics"] }) > 0; return ( - + void queryClient.invalidateQueries({ @@ -218,8 +210,7 @@ function RefreshButton(): ReactNode { const TAB_STYLES = { // Hides the line under the tab list alone; the row owns one that spans it. root: { "--tab-border-color": "transparent", minWidth: 0 }, - // Full height, so the underline lands on the row's border rather than - // partway up a taller bar. + // So the underline lands on the row's border. list: { height: "100%", flexWrap: "nowrap", diff --git a/src/frontend/features/dashboard/dashboard-state.tsx b/src/frontend/features/dashboard/dashboard-state.tsx index 0c4119157..d83663cf2 100644 --- a/src/frontend/features/dashboard/dashboard-state.tsx +++ b/src/frontend/features/dashboard/dashboard-state.tsx @@ -8,10 +8,7 @@ interface DashboardStateProps { query: UseQueryResult; } -/** - * Renders the loading/error state of a dashboard query. Views call this when - * `data` is absent, so each one doesn't repeat the same two branches. - */ +/** For when a dashboard query has no `data`. */ export function DashboardState({ query }: DashboardStateProps): ReactNode { if (query.isError) { return ( diff --git a/src/frontend/features/dashboard/derived.ts b/src/frontend/features/dashboard/derived.ts index c9ac87f48..ee9da3c2b 100644 --- a/src/frontend/features/dashboard/derived.ts +++ b/src/frontend/features/dashboard/derived.ts @@ -7,18 +7,13 @@ function ratio(numerator: number, denominator: number): number { return denominator === 0 ? 0 : numerator / denominator; } -/** - * One comparison divided by another. A rate is never more knowable than its - * counts, so an unavailable term makes the rate unavailable for the same reason. - */ +/** An unavailable term makes the rate unavailable for the same reason. */ export function perUnit( numerator: PeriodComparison, denominator: PeriodComparison ): PeriodComparison { const current = ratio(numerator.current, denominator.current); const previous = ratio(numerator.previous, denominator.previous); - // Windows and their labels come from the numerator: dividing two measures - // of the same window does not change which window it is. const base = { ...numerator, current, previous }; const unavailable = numerator.unavailable ?? denominator.unavailable; diff --git a/src/frontend/features/dashboard/format.ts b/src/frontend/features/dashboard/format.ts index b237a685c..4adc674ee 100644 --- a/src/frontend/features/dashboard/format.ts +++ b/src/frontend/features/dashboard/format.ts @@ -14,10 +14,7 @@ export function formatPercent(value: number): string { return `${value.toFixed(1)}%`; } -/** - * One number as a fraction of another. A total that is absent and one that is - * zero read the same: there is nothing to take a fraction of either way. - */ +/** An absent total reads the same as zero. */ export function formatFraction( part: number, total: number | undefined diff --git a/src/frontend/features/dashboard/growth-section.tsx b/src/frontend/features/dashboard/growth-section.tsx index 973303217..5f27d59a3 100644 --- a/src/frontend/features/dashboard/growth-section.tsx +++ b/src/frontend/features/dashboard/growth-section.tsx @@ -17,10 +17,7 @@ interface RecentSectionProps { series: DailyMetricPoint[]; } -/** - * The trailing month against the one before it: what says something useful - * before there is a second season to compare against. - */ +/** Useful before there's a second season to compare. */ export function RecentSection({ growth, series diff --git a/src/frontend/features/dashboard/lifetime-tiles.tsx b/src/frontend/features/dashboard/headline-tiles.tsx similarity index 80% rename from src/frontend/features/dashboard/lifetime-tiles.tsx rename to src/frontend/features/dashboard/headline-tiles.tsx index a86f72968..726883991 100644 --- a/src/frontend/features/dashboard/lifetime-tiles.tsx +++ b/src/frontend/features/dashboard/headline-tiles.tsx @@ -10,7 +10,8 @@ import { perUnit } from "./derived"; import { toSparkSeries } from "./series"; import { StatTile } from "./stat-tiles"; -interface LifetimeTilesProps { +interface HeadlineTilesProps { + /** Over the selected window, or lifetime where there is no picker. */ totals: AnalyticsTotals; growth: GrowthOut; /** Daily points over the selected window, for the sparklines. */ @@ -19,16 +20,12 @@ interface LifetimeTilesProps { withOpens?: boolean; } -/** - * The page's headline: an all-time value with a season-over-season change, the - * two windows a maintainer actually asks about. - */ -export function LifetimeTiles({ +export function HeadlineTiles({ totals, growth, series, withOpens = false -}: LifetimeTilesProps): ReactNode { +}: HeadlineTilesProps): ReactNode { const { season } = growth; const perUser = totals.uniqueUsers === 0 ? 0 : totals.inserts / totals.uniqueUsers; @@ -48,9 +45,6 @@ export function LifetimeTiles({ change={season.activeUsers} spark={spark.activeUsers} /> - {/* Lifetime uses over everyone who ever used it, against a season's - uses over the people active in that season — the same - value-and-delta split every tile in this row has. */} }, { label: "Errors", value: formatCount(counts.errorCount), - severity: BuildIssueSeverity.ERROR + icon: }, { label: "Warnings", value: formatCount(counts.warningCount), - severity: BuildIssueSeverity.WARNING + icon: } ]; return ( {tiles.map((tile) => ( - + - {tile.severity !== undefined && ( - - )} - + {tile.icon} + {tile.label} diff --git a/src/frontend/features/dashboard/implicit-default.tsx b/src/frontend/features/dashboard/implicit-default.tsx index 8d0c03b74..045e0914f 100644 --- a/src/frontend/features/dashboard/implicit-default.tsx +++ b/src/frontend/features/dashboard/implicit-default.tsx @@ -5,12 +5,8 @@ import { type ReactNode } from "react"; const REASON = "This option is the default because it is the first visible option and the default option is not present."; -/** Wide enough for the reason to wrap rather than run out as one ribbon. */ -const TOOLTIP_WIDTH = 260; - interface ImplicitDefaultBadgeProps { - /** Taken from the badges it stands beside, which differ between the - * breakdown cards and the low-usage table. */ + /** Matches the badges beside it. */ color?: BadgeProps["color"]; size?: BadgeProps["size"]; variant?: BadgeProps["variant"]; @@ -21,7 +17,7 @@ export function ImplicitDefaultBadge( props: ImplicitDefaultBadgeProps ): ReactNode { return ( - + Implicit default ); diff --git a/src/frontend/features/dashboard/insert-mix.tsx b/src/frontend/features/dashboard/insert-mix.tsx index cfcc92f67..bbb4eab59 100644 --- a/src/frontend/features/dashboard/insert-mix.tsx +++ b/src/frontend/features/dashboard/insert-mix.tsx @@ -34,8 +34,8 @@ export function InsertSourceBreakdown({ {sources.map((source) => (
- {SOURCE_LABELS[source.source]} - + {SOURCE_LABELS[source.source]} + {formatCount(source.count)} ( {formatFraction(source.count, total)}) diff --git a/src/frontend/features/dashboard/metrics.ts b/src/frontend/features/dashboard/metrics.ts index c55a39f7c..48a056443 100644 --- a/src/frontend/features/dashboard/metrics.ts +++ b/src/frontend/features/dashboard/metrics.ts @@ -17,10 +17,6 @@ type MetricKey = | "quickFraction" | "assemblyFraction"; -/** - * How one number is derived, formatted and trended, so a metric reads the same - * way wherever it appears. - */ export interface MetricDefinition { key: MetricKey; label: string; @@ -73,7 +69,7 @@ export const METRICS: Record = { }; /** The raw numerator and denominator behind a range value. */ -export interface MetricTerms { +interface MetricTerms { numerator: number; denominator: number; } @@ -92,10 +88,7 @@ export function rangeTerms( return { numerator, denominator }; } -/** - * Folded from the same points the sparkline plots, so a tile can never disagree - * with the chart behind it. - */ +/** From the sparkline's points, so a tile can't disagree with its chart. */ function metricValue( { numerator, denominator }: MetricTerms, metric: MetricDefinition @@ -113,7 +106,6 @@ export function rangeValue( return metricValue(rangeTerms(points, metric), metric); } -/** True when the metric reads as a percentage rather than a count. */ export function isPercentage(metric: MetricDefinition): boolean { return metric.denominator !== undefined; } @@ -122,10 +114,7 @@ export interface TrendPoint extends BucketPoint { value: number; } -/** - * The value per bucket. Shares are ratioed after bucketing, or an average of - * daily percentages would over-weight quiet days. - */ +/** Shares are computed after bucketing, or quiet days would be over-weighted. */ export function toTrend( points: DailyMetricPoint[], metric: MetricDefinition, diff --git a/src/frontend/features/dashboard/options-table.tsx b/src/frontend/features/dashboard/options-table.tsx index 8fcc536fb..3526d3f00 100644 --- a/src/frontend/features/dashboard/options-table.tsx +++ b/src/frontend/features/dashboard/options-table.tsx @@ -9,7 +9,7 @@ import { LibraryId } from "@backend/features/library/library-id"; import { StatusColor } from "../../lib/style-constants"; import { formatCount, formatFraction } from "./format"; import { ImplicitDefaultBadge } from "./implicit-default"; -import { ParameterPath } from "./parameter-path"; +import { AppBreadcrumbs } from "../../components/breadcrumbs"; import { TablePagination, usePagedRows } from "./table-pagination"; /** Below this the part and parameter names wrap into each other. */ @@ -44,7 +44,7 @@ export function OptionsTable({ return ( <> -
+
{COLUMNS.map((column, index) => ( @@ -104,9 +104,10 @@ function OptionRow({ libraryId, option }: OptionRowProps): ReactNode { > {option.partName} - - {option.parameterName} - + ({ label }))} + current={{option.parameterName}} + /> @@ -129,9 +130,7 @@ function OptionLabel({ value }: OptionLabelProps): ReactNode { {value.label} {value.count === 0 && ( - - Never used - + Never used )} {/* A default nobody picks is the strongest signal the parameter is wrong. An implicit one is stronger still: it is what this @@ -140,9 +139,7 @@ function OptionLabel({ value }: OptionLabelProps): ReactNode { )} {value.isDefault && ( - - Default - + Default )} ); diff --git a/src/frontend/features/dashboard/parameter-path.tsx b/src/frontend/features/dashboard/parameter-path.tsx deleted file mode 100644 index 07beb84c6..000000000 --- a/src/frontend/features/dashboard/parameter-path.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Text } from "@mantine/core"; -import { type ReactNode } from "react"; -import { AppBreadcrumbs } from "../../components/breadcrumbs"; -import { StatusColor } from "../../lib/style-constants"; - -interface ParameterPathProps { - /** The controlling choices, outermost first. */ - path: string[]; - /** The parameter's own name, which ends the trail. The caller supplies it - * so a card can title it and a table cell can leave it as text. */ - children: ReactNode; -} - -/** - * A parameter under the choices it is shown beneath — "Generic › Tube size". - * The choices are dimmed so the parameter's own name is what the eye lands on. - */ -export function ParameterPath({ - path, - children -}: ParameterPathProps): ReactNode { - if (path.length === 0) { - return children; - } - - return ( - - {path.map((label) => ( - - {label} - - ))} - {children} - - ); -} diff --git a/src/frontend/features/dashboard/parts-sort.test.ts b/src/frontend/features/dashboard/parts-sort.test.ts index 96f2c7caa..8c11b832f 100644 --- a/src/frontend/features/dashboard/parts-sort.test.ts +++ b/src/frontend/features/dashboard/parts-sort.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { PartUsageOut } from "@backend/features/analytics/contract"; +import { LibraryId } from "@backend/features/library/library-id"; import { DEFAULT_SORT, filterAndSort, @@ -9,6 +10,7 @@ import { function part(overrides: Partial = {}): PartUsageOut { return { + libraryId: LibraryId.FRC_DESIGN_LIB, path: { documentId: "doc-1", instanceId: "v-1", @@ -41,8 +43,7 @@ const names = (parts: PartUsageOut[]) => parts.map((p) => p.name); describe("filterAndSort", () => { it("puts the highest rate first by default, not the highest total", () => { - // Tube has the most insertions but the lowest rate, so it drops below - // the two parts earning three a month. + // Tube has the most inserts but the lowest rate. expect(names(filterAndSort(PARTS, "", DEFAULT_SORT))).toEqual([ "Bearing", "Spacer", diff --git a/src/frontend/features/dashboard/parts-sort.ts b/src/frontend/features/dashboard/parts-sort.ts index 6eec3c508..1c5d1d067 100644 --- a/src/frontend/features/dashboard/parts-sort.ts +++ b/src/frontend/features/dashboard/parts-sort.ts @@ -7,10 +7,7 @@ export interface SortState { descending: boolean; } -/** - * Most used first, by rate rather than lifetime total: a part added last month - * should not sit below one that earned its total over three seasons. - */ +/** By rate, so a new part isn't buried under one with three seasons' total. */ export const DEFAULT_SORT: SortState = { column: "usesPerMonth", descending: true diff --git a/src/frontend/features/dashboard/parts-table.tsx b/src/frontend/features/dashboard/parts-table.tsx index 21d24f431..695c78131 100644 --- a/src/frontend/features/dashboard/parts-table.tsx +++ b/src/frontend/features/dashboard/parts-table.tsx @@ -1,9 +1,6 @@ -import { Anchor, Badge, Group, Table, Text } from "@mantine/core"; -import { - ArrowSquareOutIcon, - CaretDownIcon, - CaretUpIcon -} from "@phosphor-icons/react"; +import { ExternalLink } from "../../components/external-link"; +import { Badge, Group, Table, Text } from "@mantine/core"; +import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react"; import { useMemo, useState, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { AppSparkline } from "./sparkline"; @@ -11,6 +8,7 @@ import { type PartUsageOut } from "@backend/features/analytics/contract"; import { MONTH_DAYS } from "@backend/features/analytics/measures"; import { LibraryId } from "@backend/features/library/library-id"; import { makeUrl } from "../../lib/url"; +import { useOnshapeOrigin } from "../../lib/onshape-params"; import { IconSize, StatusColor } from "../../lib/style-constants"; import { formatCount } from "./format"; import { @@ -25,10 +23,7 @@ import { TablePagination, usePagedRows } from "./table-pagination"; /** Small enough to sit in a row without stretching it. */ const ROW_SPARKLINE = { h: 24, w: 80 }; -/** - * Widths for every column but the first, which takes what is left: unset, the - * text columns take all the slack and strand the numbers from the sparkline. - */ +/** The first column takes the rest; unset, text columns would take the slack. */ const COLUMN_WIDTH = { group: 180, // Wide enough that the two longest headings stay on one line. @@ -102,7 +97,7 @@ export function PartsTable({ return ( <> -
+
{SORTABLE_COLUMNS.map((heading) => ( @@ -164,11 +159,7 @@ function SortableTh({ onClick={() => onToggle(column)} style={{ cursor: "pointer", userSelect: "none" }} > - + {label} {/* Reserved even when inactive, so the header never reflows. */} {part.name} {!part.isVisible && ( - - Hidden - + Hidden )} @@ -218,16 +208,11 @@ function PartRow({ libraryId, part }: PartRowProps): ReactNode { - {/* Stops the row's own navigation: this link leaves the app. */} - event.stopPropagation()}> - - - + + ); diff --git a/src/frontend/features/dashboard/range-control.tsx b/src/frontend/features/dashboard/range-control.tsx index de93a12ec..aaea765fa 100644 --- a/src/frontend/features/dashboard/range-control.tsx +++ b/src/frontend/features/dashboard/range-control.tsx @@ -23,8 +23,7 @@ export function RangeControl(): ReactNode { size="xs" value={preset} onChange={(value) => { - // Mantine hands back a bare string; the presets are the only - // values it can be, but the router wants the narrower type. + // Narrows Mantine's string for the router. if (!isRangePreset(value)) return; void navigate({ to: ".", search: { range: value } }); }} diff --git a/src/frontend/features/dashboard/range.ts b/src/frontend/features/dashboard/range.ts index 2bfa554f6..ce3eb75c1 100644 --- a/src/frontend/features/dashboard/range.ts +++ b/src/frontend/features/dashboard/range.ts @@ -30,17 +30,12 @@ export function isRangePreset(value: unknown): value is RangePreset { return typeof value === "string" && value in RANGE_PRESETS; } -/** - * Resolves a preset to the concrete day bounds the API expects, ending on the - * last complete day: today is still filling, and a preset that reached into it - * put a dip at the end of every chart. - */ +/** Ends on the last complete day, since today is still filling. */ export function toDayRange(preset: RangePreset): DayRange { const to = toReportingDay(Date.now()); const { days } = RANGE_PRESETS[preset]; return { - // The app has no data before 2026, so "all time" just reaches back far. - // Both bounds are inclusive, so the -1 is what makes "7 days" seven. + // Inclusive bounds, so -1 makes "7 days" seven. from: days === undefined ? "2000-01-01" : addDays(to, -(days - 1)), to }; diff --git a/src/frontend/features/dashboard/series.ts b/src/frontend/features/dashboard/series.ts index a5fb5fc57..6bbcccd07 100644 --- a/src/frontend/features/dashboard/series.ts +++ b/src/frontend/features/dashboard/series.ts @@ -1,7 +1,4 @@ -/** - * Every series the dashboard plots, and the bucketing they share. Points keep - * the raw key beside the label: a reference line matches "2026-01", not "Jan 2026". - */ +/** Points keep the raw key beside the label, so a reference line can match "2026-01". */ import type { DailyInsertPoint, @@ -29,10 +26,7 @@ export interface BucketPoint { label: string; } -/** - * From the span the days cover, not how many there are: a sparse three-year - * series has few points but must still bucket, or its gaps compress silently. - */ +/** From the span, not the count: a sparse series must still bucket. */ export function pickGranularity(days: string[]): Granularity { if (days.length === 0) return Granularity.DAY; let first = days[0]; @@ -60,10 +54,6 @@ function weekStart(day: string): string { return date.toISOString().slice(0, 10); } -/** - * Points folded into their buckets, in key order. The three series the dashboard - * plots differ only in what they accumulate, so that is all a caller supplies. - */ export function bucketBy( points: Point[], granularity: Granularity, @@ -108,10 +98,7 @@ export function formatBucket(bucket: string, granularity: Granularity): string { type ChartPoint = BucketPoint & Record; -/** - * Flattens the API's per-day/per-library counts into the one-record-per-x-value - * shape charts expect, keyed by library display name so the legend reads well. - */ +/** One record per x value, keyed by library name for the legend. */ export function toChartData( series: DailyInsertPoint[], libraryIds: LibraryId[], @@ -155,10 +142,7 @@ interface Bucket { days: number; } -/** - * Folded by the chart's own rule, since two years of raw days is a smear. Users - * are averaged over a bucket, not summed: one person all week is one user. - */ +/** Users are averaged over a bucket: one person all week is one user. */ export function toSparkSeries(points: DailyMetricPoint[]): SparkSeries { const ordered = bucketBy( points, diff --git a/src/frontend/features/dashboard/sparkline.tsx b/src/frontend/features/dashboard/sparkline.tsx index f519fb1f6..e45d7cded 100644 --- a/src/frontend/features/dashboard/sparkline.tsx +++ b/src/frontend/features/dashboard/sparkline.tsx @@ -2,8 +2,7 @@ import { Sparkline } from "@mantine/charts"; import { type ReactNode } from "react"; import { PrimaryColor } from "../../lib/style-constants"; -// The charts' styles, imported where the charts are so they land in the same -// route chunk rather than the panel's bundle. +// Imported here so the styles land in the dashboard's chunk. import "@mantine/charts/styles.layer.css"; interface AppSparklineProps { @@ -13,10 +12,7 @@ interface AppSparklineProps { w?: number; } -/** - * A shape, not a chart: no axes, nothing to read a value off. Flat rather than - * absent at zero, so a row never changes height. - */ +/** Flat at zero rather than absent, so a row never changes height. */ export function AppSparkline({ data, h, w }: AppSparklineProps): ReactNode { return ( string; - /** How the measure changed, drawn to the right of the number — never below - * it, so a row of tiles scans as one line of numbers. */ + /** Beside the number, so a row of tiles scans as one line. */ change?: PeriodComparison; - /** The shape over the selected window, which follows the picker even when - * the value above is all time: a sparkline claims no total. */ + /** Follows the picker even when the value is all time. */ spark?: number[]; } @@ -28,10 +26,10 @@ export function StatTile({ spark }: StatTileProps): ReactNode { return ( - - + +
- + {label} {format(value)} diff --git a/src/frontend/features/dashboard/table-pagination.tsx b/src/frontend/features/dashboard/table-pagination.tsx index dbebc146d..84acdb38c 100644 --- a/src/frontend/features/dashboard/table-pagination.tsx +++ b/src/frontend/features/dashboard/table-pagination.tsx @@ -2,7 +2,7 @@ import { Group, Pagination } from "@mantine/core"; import { useState, type ReactNode } from "react"; /** Short enough that a page of a dashboard table reads at a glance. */ -export const ROWS_PER_PAGE = 10; +const ROWS_PER_PAGE = 10; interface Paged { rows: T[]; @@ -11,11 +11,7 @@ interface Paged { setPage: (page: number) => void; } -/** - * One page of a list. The page is clamped rather than reset, so a filter that - * shortens the list lands on its last page instead of an empty one — there is - * no effect to run, and so no render showing rows that are gone. - */ +/** Clamped rather than reset, so a shorter list lands on its last page without an effect. */ export function usePagedRows(rows: T[]): Paged { const [requestedPage, setRequestedPage] = useState(1); diff --git a/src/frontend/features/dashboard/treemap-chart.tsx b/src/frontend/features/dashboard/treemap-chart.tsx index 103918a90..9f7ccb0ab 100644 --- a/src/frontend/features/dashboard/treemap-chart.tsx +++ b/src/frontend/features/dashboard/treemap-chart.tsx @@ -3,8 +3,7 @@ import { type ReactNode } from "react"; import { formatCount } from "./format"; import { type TreemapNode } from "./treemap-data"; -// The charts' styles, imported where the charts are so they land in the same -// route chunk rather than the panel's bundle. +// Imported here so the styles land in the dashboard's chunk. import "@mantine/charts/styles.layer.css"; interface AppTreemapProps { @@ -16,8 +15,7 @@ interface AppTreemapProps { export function AppTreemap({ nodes, h, onSelect }: AppTreemapProps): ReactNode { return ( onSelect(node as unknown as TreemapNode) }} /> diff --git a/src/frontend/features/dashboard/treemap-data.test.ts b/src/frontend/features/dashboard/treemap-data.test.ts index ca5db89a8..a9b34a18d 100644 --- a/src/frontend/features/dashboard/treemap-data.test.ts +++ b/src/frontend/features/dashboard/treemap-data.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; import { LibraryId } from "@backend/features/library/library-id"; -import { toNodes, TreemapKind, type UsagePart } from "./treemap-data"; +import type { PartUsageOut } from "@backend/features/analytics/contract"; +import { toNodes, TreemapKind } from "./treemap-data"; function part({ elementId = "e-1", ...overrides -}: Partial & { elementId?: string } = {}): UsagePart { +}: Partial & { elementId?: string } = {}): PartUsageOut { return { libraryId: LibraryId.FRC_DESIGN_LIB, path: { @@ -88,8 +89,6 @@ describe("toNodes at the group level", () => { }); it("never darkens as the tiles get smaller", () => { - // Color has to reinforce area, not fight it: a lighter tile always - // means a smaller one. const many = Array.from({ length: 10 }, (_, index) => part({ groupName: `g-${index}`, insertCount: 10 - index }) ); diff --git a/src/frontend/features/dashboard/treemap-data.ts b/src/frontend/features/dashboard/treemap-data.ts index 4be7c3c72..c508a87dd 100644 --- a/src/frontend/features/dashboard/treemap-data.ts +++ b/src/frontend/features/dashboard/treemap-data.ts @@ -5,14 +5,7 @@ import { getLibraryColor } from "../../theme"; import { colorVar, FILLED_SHADE } from "../../lib/style-constants"; /** A part tagged with the library it came from, so one list spans them all. */ -export interface UsagePart extends PartUsageOut { - libraryId: LibraryId; -} - -/** - * How far in the treemap is looking: every library, one library's groups, or - * one group's parts. Parts are leaves — clicking one leaves the chart. - */ +/** Parts are leaves: clicking one leaves the chart. */ export interface TreemapPath { libraryId?: LibraryId; groupName?: string; @@ -32,10 +25,6 @@ interface TileBase { color: string; } -/** - * One tile. Discriminated rather than a bag of optional ids, so a click reads - * the level it is on instead of guessing from which keys are set. - */ export type TreemapNode = | (TileBase & { kind: TreemapKind.LIBRARY; libraryId: LibraryId }) | (TileBase & { kind: TreemapKind.GROUP; groupName: string }) @@ -45,21 +34,15 @@ export type TreemapNode = elementId: string; }); -/** - * Shades by rank off one hue, darkest first: monotone rather than cycling, so a - * lighter tile always means a smaller one. - */ +/** Darkest first, so a lighter tile is always a smaller one. */ const SHADES = [9, 8, 7, 6, 5, 4, 3]; function shade(color: string, rank: number): string { return colorVar(color, SHADES[Math.min(rank, SHADES.length - 1)]); } -/** - * A part with no uses in the window is dropped rather than drawn: a zero-value - * tile has no area but still sits in the DOM catching clicks. - */ -function within(parts: UsagePart[], path: TreemapPath): UsagePart[] { +/** Drops unused parts: a zero-area tile still catches clicks. */ +function within(parts: PartUsageOut[], path: TreemapPath): PartUsageOut[] { return parts.filter( (part) => part.insertCount > 0 && @@ -71,8 +54,8 @@ function within(parts: UsagePart[], path: TreemapPath): UsagePart[] { /** Insertions summed by a key, largest first — so an index is a shade rank. */ function totalsBy( - parts: UsagePart[], - keyOf: (part: UsagePart) => K + parts: PartUsageOut[], + keyOf: (part: PartUsageOut) => K ): { key: K; value: number }[] { const totals = new Map(); for (const part of parts) { @@ -84,11 +67,11 @@ function totalsBy( .map(([key, value]) => ({ key, value })); } -/** - * The tiles at `path`. Libraries keep the colors the charts give them, and - * everything inside one shades off that library's hue. - */ -export function toNodes(parts: UsagePart[], path: TreemapPath): TreemapNode[] { +/** Inside a library, tiles shade off its chart color. */ +export function toNodes( + parts: PartUsageOut[], + path: TreemapPath +): TreemapNode[] { const shown = within(parts, path); if (path.libraryId === undefined) { diff --git a/src/frontend/features/dashboard/trend-chart.tsx b/src/frontend/features/dashboard/trend-chart.tsx index 59ef289de..3a88598fc 100644 --- a/src/frontend/features/dashboard/trend-chart.tsx +++ b/src/frontend/features/dashboard/trend-chart.tsx @@ -14,8 +14,7 @@ import { } from "./metrics"; import { formatCount, formatPercent } from "./format"; -// The charts' styles, imported where the charts are so they land in the same -// route chunk rather than the panel's bundle. +// Imported here so the styles land in the dashboard's chunk. import "@mantine/charts/styles.layer.css"; // Keeps the hover panel short enough to fit beside a tile on a laptop. @@ -102,10 +101,7 @@ export function LibraryInsertsChart({ ); } -/** - * Season markers, placed by month: a season opens and closes on month bounds, - * and the championship closing it moves within its month every year. - */ +/** By month, since the championship moves within its month each year. */ function seasonLines( programs: Program[] | undefined, points: BucketPoint[] @@ -117,8 +113,6 @@ function seasonLines( const labels = new Map(); const mark = (month: string, label: string) => { - // A monthly bucket is the month; a finer one falls inside it, and the - // first such bucket is where the month begins on the axis. const bucket = buckets.find((candidate) => candidate.startsWith(month)); if (bucket === undefined) return; const existing = labels.get(bucket) ?? []; diff --git a/src/frontend/features/dashboard/trend-tile.tsx b/src/frontend/features/dashboard/trend-tile.tsx index 2b09b206a..c2fb5b218 100644 --- a/src/frontend/features/dashboard/trend-tile.tsx +++ b/src/frontend/features/dashboard/trend-tile.tsx @@ -22,10 +22,6 @@ interface TrendTileProps { series: DailyMetricPoint[]; } -/** - * One number and its trend. Leads with the range so it agrees with the - * sparkline beneath it. - */ export function TrendTile({ metric, totals, @@ -46,8 +42,8 @@ export function TrendTile({ : formatCount(metric.lifetimeValue(totals)); return ( - - + + {metric.label} {value} diff --git a/src/frontend/features/dashboard/usage-treemap.tsx b/src/frontend/features/dashboard/usage-treemap.tsx index 4f4a68ffe..d3ff36d09 100644 --- a/src/frontend/features/dashboard/usage-treemap.tsx +++ b/src/frontend/features/dashboard/usage-treemap.tsx @@ -1,4 +1,5 @@ -import { Anchor, Text } from "@mantine/core"; +import { Text } from "@mantine/core"; +import type { PartUsageOut } from "@backend/features/analytics/contract"; import { useNavigate } from "@tanstack/react-router"; import { useMemo, useState, type ReactNode } from "react"; import { getLibraryName } from "../../lib/library"; @@ -9,12 +10,11 @@ import { toNodes, TreemapKind, type TreemapNode, - type TreemapPath, - type UsagePart + type TreemapPath } from "./treemap-data"; interface UsageTreemapProps { - parts: UsagePart[]; + parts: PartUsageOut[]; /** The level this instance starts at and will not go above. */ root?: TreemapPath; } @@ -22,10 +22,7 @@ interface UsageTreemapProps { /** Tall enough that the smaller slices still get a readable tile. */ const CHART_HEIGHT = 360; -/** - * Insertions as area, drilled by clicking. `root` is the level the breadcrumb - * cannot climb above: every library, or one of them. - */ +/** `root` is the highest level the breadcrumb can climb to. */ export function UsageTreemap({ parts, root = {} @@ -93,24 +90,16 @@ function TreemapTrail({ root, path, onSelect }: TreemapTrailProps): ReactNode { return null; } + const current = steps[steps.length - 1]; return ( // Spaced off the chart below, which otherwise sits on the trail. - - {steps.map((step, index) => - index === steps.length - 1 ? ( - - {step.label} - - ) : ( - onSelect(step.to)} - > - {step.label} - - ) - )} - + ({ + label: step.label, + onClick: () => onSelect(step.to) + }))} + current={current.label} + /> ); } diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index 73e42c821..0ecca4e02 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -1,6 +1,6 @@ import { type ConfigurationKey, - type Selection + type PartialSelection } from "@backend/features/configurations/contract"; import { ActionIcon, Menu } from "@mantine/core"; import { HeartIcon, HeartBreakIcon } from "@phosphor-icons/react"; @@ -33,9 +33,8 @@ interface UpdateFavoritesArgs { insertable: InsertableOut; favoriteId: string; /** The selection to store; absent means the element's own default. */ - selection?: Selection; - /** That selection's key, so the new row's thumbnail is right before the - * refetch answers. */ + selection?: PartialSelection; + /** So the new row's thumbnail is right before the refetch. */ configurationKey?: ConfigurationKey; } @@ -44,14 +43,14 @@ function updateFavorites( args: UpdateFavoritesArgs, libraryId: LibraryId ): FavoritesData | undefined { - const { favoriteId, selection, configurationKey } = args; + const { favoriteId, configurationKey } = args; const insertableId = args.insertable.id; if (args.operation === Operation.ADD) { const fav: Favorite = { id: favoriteId, insertableId, libraryId, - defaultSelection: selection, + // Left to the refetch, which answers with it made whole. configurationKey }; data.favorites[favoriteId] = fav; @@ -100,8 +99,7 @@ function useUpdateFavoritesMutation() { updateFavorites(data, args, libraryId) ) ); - // No router.invalidate(): the route loader prefetches favorites, - // and that fetch would race the mutation and undo this update. + // No router.invalidate(): the loader's prefetch would race this and undo it. }, onError: (error, args) => { const action = @@ -118,17 +116,11 @@ function useUpdateFavoritesMutation() { interface FavoriteButtonProps { favorite: Favorite | undefined; insertable: InsertableOut; - /** - * The selection the new favorite opens with: what the caller is showing, - * rather than the element's own default. - */ - selection?: Selection; + /** Defaults to the element's own. */ + selection?: PartialSelection; /** That selection's key, when the caller knows it. */ configurationKey?: ConfigurationKey; - /** - * Sizes the button to sit beside a full-height button rather than in a card row. - * @default false - */ + /** @default false */ large?: boolean; } @@ -155,8 +147,6 @@ export function FavoriteButton(props: FavoriteButtonProps): ReactNode { return ( { event.stopPropagation(); @@ -182,7 +172,7 @@ interface FavoriteInsertableItemProps { favorite: Favorite | undefined; insertable: InsertableOut; /** The selection the new favorite opens with. */ - selection?: Selection; + selection?: PartialSelection; /** That selection's key, when the caller knows it. */ configurationKey?: ConfigurationKey; } @@ -220,20 +210,15 @@ export function FavoriteInsertableItem(props: FavoriteInsertableItemProps) { } interface FavoriteIconProps { - /** - * @default true - */ + /** @default true */ full?: boolean; - /** - * @default IconSize.SMALL - */ + /** @default IconSize.SMALL */ size?: IconSize; } export function FavoriteIcon(props: FavoriteIconProps): ReactNode { const { full = true, size = IconSize.SMALL } = props; - // fz, not size: Box builds its own `style`, dropping the font-size that - // Phosphor's `size` sets, which shrank the icon to 1em. + // fz, not size: Box drops the font-size Phosphor's `size` sets. return full ? ( @@ -144,22 +130,6 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { )} - } - onClick={() => { - if (!insertable.isConfigurable) { - openCannotEditDefaultConfigurationAlert(); - return; - } - openFavoriteMenu({ - favoriteId: favorite.id, - insertableName: insertable.name, - selection: favorite.defaultSelection - }); - }} - > - Edit default configuration - - + ); diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx deleted file mode 100644 index 4de9a4700..000000000 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { modals } from "@mantine/modals"; -import { - AppModalBody, - AppModalFooter, - AppModalTop -} from "../../../components/app-modal"; -import { useMenuTitle } from "../../../components/app-title"; -import { Button } from "@mantine/core"; -import { FloppyDiskIcon } from "@phosphor-icons/react"; -import { IconSize } from "../../../lib/style-constants"; -import { ReactNode, useState } from "react"; -import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; -import { ConfigurationWrapper } from "../../insert/components/configurations"; -import { FavoriteIcon } from "./favorite-button"; -import { - DEFAULT_CONFIGURATION_KEY, - Selection, - SearchRecord -} from "@backend/features/configurations/contract"; -import { - useFavoritesQuery, - useSetDefaultConfigurationMutation -} from "../queries"; -import { useLibraryQuery } from "../../library/queries"; -import { PageNotice } from "../../../components/app-zero-state"; - -interface FavoriteMenuContentProps { - favoriteId: string; - /** The modal this renders in, so the header can track the selection. */ - modalId: string; - /** What the favorite opens with today. */ - initialSelection?: Selection; -} - -export function FavoriteMenuContent( - props: FavoriteMenuContentProps -): ReactNode { - const { favoriteId, modalId, initialSelection } = props; - - const libraryQuery = useLibraryQuery(); - const favoritesQuery = useFavoritesQuery(); - const insertables = libraryQuery.data?.insertables; - const favoritesData = favoritesQuery.data; - - const [selection, setSelection] = useState( - initialSelection - ); - // Reported by ConfigurationWrapper; names this selection's thumbnail. - // Undefined until it reports, which is what gates saving. - const [configurationKey, setConfigurationKey] = useState(); - const [record, setRecord] = useState(undefined); - - const favorite = favoritesData?.favorites[favoriteId]; - const insertable = - favorite && insertables - ? insertables[favorite.insertableId] - : undefined; - - useMenuTitle(modalId, { - name: insertable?.name, - record, - icon: - }); - - const setDefaultConfigurationMutation = useSetDefaultConfigurationMutation( - favoriteId, - selection, - configurationKey - ); - - if (!insertable) { - return null; - } - if (!insertable.isConfigurable) { - return ( - - ); - } - - return ( - <> - - - - - - - - - - - ); -} diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index 47b27ace5..ba8e1f67b 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -13,11 +13,12 @@ import { type FavoritesData } from "@backend/features/favorites/contract"; import type { Insertables } from "@backend/features/library/contract"; -import { useGetUiState } from "../../../lib/ui-state"; +import { useUiState } from "../../../lib/ui-state"; import { + SectionNotice, SectionLoading, - SectionNotice -} from "../../../components/app-zero-state"; + SectionError +} from "../../../components/app-notice"; import { NoSearchResultError, SearchCallout @@ -32,20 +33,16 @@ import { FavoriteIcon } from "./favorite-button"; import { startSignIn } from "../../auth/sign-in"; import { useVendorFilters } from "../../settings/components/vendor-filters"; -/** - * A list of current favorite cards. - * Unlike the normal DocumentList, this list can be searched directly. - */ +/** Unlike DocumentList, this list can be searched directly. */ export function FavoritesList(): ReactNode { - const { searchQuery } = useGetUiState(); + const searchQuery = useUiState((state) => state.searchQuery); const vendorFilters = useVendorFilters(); const { signedIn, isPending } = useAccessData(); const favoritesQuery = useFavoritesQuery(); const libraryQuery = useLibraryQuery(); - // Only once known, and ahead of the pending branch, which favorites never - // leaves while signed out: the query stays disabled rather than 401. + // Before the pending branch, which never resolves while signed out. if (!isPending && !signedIn) { return ; } else if ( @@ -56,7 +53,7 @@ export function FavoritesList(): ReactNode { return ; } else if (libraryQuery.isError || favoritesQuery.isError) { return ( - ; } else if (searchDbQuery.isError) { - return ; + return ; } else if (!searchDbQuery.data) { - return ; + return ; } const result = searchInsertables({ @@ -144,10 +141,7 @@ function FavoriteSearchResults(props: FavoriteSearchResultsProps): ReactNode { favoritedInsertableIds: new Set( Object.values(favoritesData.favorites).map((f) => f.insertableId) ), - // A favorite is one configuration, but the index's configuration fields - // describe all of them at once, so matching on those pulls a favorite up - // for a query naming a configuration the user never saved. Off until - // favorites are indexed as themselves. + // A favorite is one configuration, so other configurations' fields mustn't match. searchConfigurations: false, showHidden }); diff --git a/src/frontend/features/favorites/components/save-favorite-configuration-button.test.tsx b/src/frontend/features/favorites/components/save-favorite-configuration-button.test.tsx new file mode 100644 index 000000000..7c66c66f0 --- /dev/null +++ b/src/frontend/features/favorites/components/save-favorite-configuration-button.test.tsx @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Favorite } from "@backend/features/favorites/contract"; +import { LibraryId } from "@backend/features/library/library-id"; +import { renderWithProviders } from "../../../../__test_utils__/render"; +import type { SelectionReport } from "../../insert/components/configurations"; + +const mutate = vi.hoisted(() => vi.fn()); +vi.mock("../queries", () => ({ + useSetDefaultConfigurationMutation: () => ({ mutate, isPending: false }) +})); + +const { SaveFavoriteConfigurationButton } = + await import("./save-favorite-configuration-button"); + +const FAVORITE: Favorite = { + id: "fav", + insertableId: "ins", + libraryId: LibraryId.FRC_DESIGN_LIB, + defaultSelection: { size: "small", length: "1 in" } +}; + +function report(selection: Record): SelectionReport { + return { + // A derivation variable rides along, but the favorite never keeps one. + selection: { ...selection, dv: "uuid" }, + stored: selection, + overrides: {}, + configurationKey: "key", + record: undefined + }; +} + +describe("saving the favorite's configuration", () => { + afterEach(() => mutate.mockReset()); + + it("saves the configuration on screen", async () => { + const user = userEvent.setup(); + const onScreen = report({ size: "large", length: "1 in" }); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button")); + + expect(mutate).toHaveBeenCalledWith({ + selection: onScreen.selection, + configurationKey: "key" + }); + }); + + it("has nothing to save when the favorite already opens with it", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("button")).toHaveProperty("disabled", true); + }); + + // Saved as entered, so a different spelling of the same size still saves. + it("saves an expression that spells the saved value differently", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("button")).toHaveProperty("disabled", false); + }); + + it("counts a favorite with no selection as saved on the defaults", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("button")).toHaveProperty("disabled", true); + }); +}); diff --git a/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx b/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx new file mode 100644 index 000000000..94b49fe4c --- /dev/null +++ b/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx @@ -0,0 +1,53 @@ +import { ActionIcon, Tooltip } from "@mantine/core"; +import { FloppyDiskIcon } from "@phosphor-icons/react"; +import { type ReactNode } from "react"; +import type { Favorite } from "@backend/features/favorites/contract"; +import type { ConfigurationKey } from "@backend/features/configurations/contract"; +import { IconSize } from "../../../lib/style-constants"; +import { sameSelection } from "@backend/features/configurations/selection"; +import type { SelectionReport } from "../../insert/components/configurations"; +import { useSetDefaultConfigurationMutation } from "../queries"; + +interface SaveFavoriteConfigurationButtonProps { + favorite: Favorite; + /** The configuration on screen. */ + report: SelectionReport; + configurationKey: ConfigurationKey; +} + +/** Makes the configuration on screen the one the favorite opens with. */ +export function SaveFavoriteConfigurationButton( + props: SaveFavoriteConfigurationButtonProps +): ReactNode { + const { favorite, report, configurationKey } = props; + const mutation = useSetDefaultConfigurationMutation(favorite.id); + // Compared as entered: saving "(2 + 3) in" over "5 in" changes what is stored. + // A favorite with no selection opens on the defaults. + const isSaved = favorite.defaultSelection + ? sameSelection(report.stored, favorite.defaultSelection) + : Object.keys(report.overrides).length === 0; + + return ( + + + mutation.mutate({ + selection: report.selection, + configurationKey + }) + } + > + + + + ); +} diff --git a/src/frontend/features/favorites/open-favorite-menu.tsx b/src/frontend/features/favorites/open-favorite-menu.tsx deleted file mode 100644 index 2456e44bd..000000000 --- a/src/frontend/features/favorites/open-favorite-menu.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { openAppModal } from "../../components/open-app-modal"; -import { type Selection } from "@backend/features/configurations/contract"; -import { FavoriteMenuContent } from "./components/favorite-menu"; -import { MenuTitle } from "../../components/app-title"; -import { FavoriteIcon } from "./components/favorite-button"; -import { IconSize } from "../../lib/style-constants"; - -interface OpenFavoriteMenuProps { - favoriteId: string; - insertableName: string; - /** What the favorite opens with today. */ - selection?: Selection; -} - -export function openFavoriteMenu(props: OpenFavoriteMenuProps) { - const { favoriteId, insertableName, selection } = props; - // Minted here so the content can update the header as the selection changes. - const modalId = crypto.randomUUID(); - openAppModal({ - modalId, - title: ( - } - /> - ), - size: 500, - children: ( - - ) - }); -} diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts index df71c4071..27eabea86 100644 --- a/src/frontend/features/favorites/queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -28,19 +28,17 @@ function getFavoritesQuery(libraryId: LibraryId) { }); } -/** Awaits access rather than blocking the loader on it: signed out has none. */ +/** Signed out has none, so this doesn't block the loader. */ export async function prefetchFavorites(libraryId: LibraryId): Promise { - const { signedIn } = - await queryClient.ensureQueryData(getAccessDataQuery()); + const { signedIn } = await queryClient.ensureQueryData( + getAccessDataQuery(libraryId) + ); if (signedIn) { await queryClient.prefetchQuery(getFavoritesQuery(libraryId)); } } -/** - * Disabled until access says signed in, the endpoint 401ing otherwise — so it - * stays pending while signed out, and callers check sign-in rather than wait. - */ +/** Stays pending while signed out, so callers check sign-in rather than wait. */ export function useFavoritesQuery() { const libraryId = useLibraryId(); const isSignedIn = useIsSignedIn(); @@ -59,27 +57,24 @@ export function useFavorite(insertableId: string): Favorite | undefined { : undefined; } -/** - * Saves what the favorite opens with. Takes its key too, so the - * cached row names the right thumbnail before the refetch answers. - */ -export function useSetDefaultConfigurationMutation( - favoriteId: string, - selection: Selection | undefined, - configurationKey: ConfigurationKey | undefined -) { +/** What a favorite is saved to open with, and the thumbnail that names. */ +interface DefaultConfiguration { + selection: Selection; + /** So the cached row names the right thumbnail before the refetch. */ + configurationKey: ConfigurationKey; +} + +/** Saves what the favorite opens with. */ +export function useSetDefaultConfigurationMutation(favoriteId: string) { const libraryId = useLibraryId(); const refreshFavorites = useRefreshFavorites(); return useMutation({ mutationKey: ["set-default-selection"], - mutationFn: async () => { - // The whole selection, not its key: the key names only what the - // selection overrides, and the favorite opens on all of it. - return apiPost("/default-selection" + toFavoritePath(favoriteId), { - body: { selection: selection } - }); - }, - onMutate: async () => { + mutationFn: async ({ selection }: DefaultConfiguration) => + apiPost("/default-selection" + toFavoritePath(favoriteId), { + body: { selection } + }), + onMutate: async ({ selection, configurationKey }) => { const queryKey = favoritesQueryKey(libraryId); await queryClient.cancelQueries({ queryKey }); queryClient.setQueryData( @@ -93,16 +88,13 @@ export function useSetDefaultConfigurationMutation( return data; }) ); - // No router.invalidate(): the route loader prefetches favorites, - // and that fetch would race the mutation and undo this update. + // No router.invalidate(): the loader's prefetch would race this and undo it. }, onError: () => { - showErrorToast( - "Unexpectedly failed to update default configuration." - ); + showErrorToast("Failed to save favorite default configuration."); }, onSuccess: () => { - showSuccessToast("Successfully updated default configuration."); + showSuccessToast("Favorite default configuration set."); }, onSettled: refreshFavorites }); @@ -132,8 +124,7 @@ export function useSetFavoriteOrderMutation() { return data; }) ); - // No router.invalidate(): the route loader prefetches favorites, - // and that fetch would race the mutation and undo this update. + // No router.invalidate(): the loader's prefetch would race this and undo it. }, onError: getAppErrorHandler( "Unexpectedly failed to reorder favorites." diff --git a/src/frontend/features/insert-location/components/insert-location-status.tsx b/src/frontend/features/insert-location/components/insert-location-status.tsx index 306824236..c98c46b6a 100644 --- a/src/frontend/features/insert-location/components/insert-location-status.tsx +++ b/src/frontend/features/insert-location/components/insert-location-status.tsx @@ -1,5 +1,5 @@ import { ReactNode } from "react"; -import { Button, Center, EmptyState, HoverCard } from "@mantine/core"; +import { Button, Center, EmptyState } from "@mantine/core"; import { CheckIcon, PlusIcon, @@ -10,24 +10,19 @@ import { type TargetElement } from "../../../lib/onshape-launch"; import { IconSize, StatusColor } from "../../../lib/style-constants"; import { AppIcon } from "../../../components/app-icon"; import { StatusIcon } from "../../../components/status-icon"; +import { AppHoverCard } from "../../../components/app-hover-card"; import { useAddInsertLocationMutation, useInsertLocationQuery, useInsertLocationTarget } from "../queries"; -/** - * Whether the assembly has somewhere to insert to, as a badged icon saying - * which. Renders nowhere but an assembly the caller is signed in to: a derive - * has no insert location, and the query needs a session. - */ +/** Only for an assembly the caller is signed in to. */ export function InsertLocationStatus(): ReactNode { const target = useInsertLocationTarget(); const { data, isPending, isError } = useInsertLocationQuery(target); - // Waiting rather than assuming: a badge that flips from a warning to a tick - // on every open would read as the assembly having changed. A failed read - // has not established there is none, so it says nothing at all. + // Say nothing until known: a badge that flips on every open looks like a change. if (!target || isPending || isError) { return null; } @@ -52,16 +47,13 @@ function InsertLocationHoverCard( const { target, instanceId } = props; const found = instanceId !== undefined; - // The bubble shows the state alone: unlike the bar, it is already about - // one thing, and its title says which. const stateIcon = found ? CheckIcon : WarningIcon; const stateColor = found ? StatusColor.SUCCESS : StatusColor.WARNING; return ( - - - {/* Wrapped, because HoverCard.Target attaches a ref to its - child and StatusIcon does not take one. */} + - - - - } - title={ - found - ? "Insert location active" - : "No insert location found" - } - description={ - found - ? "New parts will be placed at the insert location." - : "New parts will be placed at the origin." - } - > - {!found && ( - - - - )} - - - + } + > + + } + title={ + found + ? "Insert location active" + : "No insert location found" + } + description={ + found + ? "New parts will be placed at the insert location." + : "New parts will be placed at the origin." + } + > + {!found && ( + + + + )} + + ); } @@ -115,8 +106,7 @@ function AddInsertLocationButton( return ( @@ -48,7 +46,6 @@ interface ToastConfig { withCloseButton?: boolean; } -/** Ids currently on screen, so a repeat updates rather than replaces. */ const liveToasts = new Set(); /** Shows a toast, updating any existing toast with the same id. */ @@ -63,8 +60,7 @@ function showToast(config: ToastConfig): string { withCloseButton: config.withCloseButton }; - // Updating keeps the toast in place, so a loading toast becoming a success - // one reads as the same toast rather than one leaving and another arriving. + // Updated in place, so a loading toast turning into a success reads as one toast. if (config.id && liveToasts.has(config.id)) { notifications.update(props); return config.id; @@ -79,7 +75,7 @@ function showToast(config: ToastConfig): string { } interface InfoToastOptions { - /** Repeats with the same id update the toast rather than stacking one up. */ + /** A repeat with the same id updates the toast. */ id?: string; autoClose?: number | false; } diff --git a/src/frontend/lib/onshape-launch.ts b/src/frontend/lib/onshape-launch.ts index 8c166153c..ca6199e95 100644 --- a/src/frontend/lib/onshape-launch.ts +++ b/src/frontend/lib/onshape-launch.ts @@ -1,24 +1,12 @@ /** - * What Onshape launches the panel with. Kept for the tab rather than left in the - * url: the document is the caller's own, so a url carrying it is one nobody can - * usefully share. - * - * A leaf, so the store can declare these fields without reaching the hooks that - * read them back — those are in `onshape-params`. + * Kept per tab rather than in the url, since a url carrying the caller's + * document isn't shareable. A leaf, so the store can declare these fields. */ import * as z from "zod"; import { ElementType } from "@backend/lib/onshape/element-type"; import { INSTANCE_TYPES, type ElementPath } from "@backend/lib/onshape/path"; -/** A resolved color scheme, as Onshape provides it; Theme adds "system" on top. */ -export const ColorThemeType = z.enum(["light", "dark"]); - -export type ColorTheme = z.infer; - -/** - * Every field optional: the app is opened standalone as well, and a launch we - * cannot read in full is one to treat as no launch rather than half of one. - */ +/** All optional, since the app also opens standalone. */ export const OnshapeLaunchType = z.object({ documentId: z.string().optional().catch(undefined), instanceId: z.string().optional().catch(undefined), @@ -28,13 +16,13 @@ export const OnshapeLaunchType = z.object({ elementType: z.enum(ElementType).optional().catch(undefined), /** Onshape's own origin, which a client message has to be addressed to. */ server: z.string().optional().catch(undefined), - /** Onshape's color scheme, which "system" resolves to inside the panel. */ - systemTheme: ColorThemeType.optional().catch(undefined) + /** The company the session is scoped to, which sign-in asks Onshape for. */ + sessionCompanyId: z.string().optional().catch(undefined) }); export type OnshapeLaunch = z.infer; -/** The url keys a launch occupies, which the app strips once it has them. */ +/** The url keys a launch occupies. */ export const LAUNCH_KEYS = Object.keys( OnshapeLaunchType.shape ) as (keyof OnshapeLaunch)[]; @@ -43,10 +31,7 @@ export interface TargetElement extends ElementPath { elementType: ElementType; } -/** - * The element the panel can insert into: a workspace and nothing else, since a - * version and a microversion are snapshots with nothing to put a part in. - */ +/** Only a workspace; a version can't be inserted into. */ export function toTargetElement( launch: OnshapeLaunch ): TargetElement | undefined { @@ -63,8 +48,3 @@ export function toTargetElement( } return { documentId, instanceId, instanceType, elementId, elementType }; } - -/** Whether a launch names a document the app cannot be used in. */ -export function isReadOnlyInstance(launch: OnshapeLaunch): boolean { - return launch.instanceType === "v" || launch.instanceType === "m"; -} diff --git a/src/frontend/lib/onshape-params.ts b/src/frontend/lib/onshape-params.ts index 448263cda..248964db9 100644 --- a/src/frontend/lib/onshape-params.ts +++ b/src/frontend/lib/onshape-params.ts @@ -1,23 +1,27 @@ -import { Theme } from "@backend/features/settings/settings"; -import { updateUiState, useGetUiState } from "./ui-state"; +/** What Onshape launched this browser tab with, kept for the tab alone. */ +import { useMemo } from "react"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { useShallow } from "zustand/react/shallow"; import { - type ColorTheme, LAUNCH_KEYS, type OnshapeLaunch, type TargetElement, toTargetElement } from "./onshape-launch"; +import { toOnshapeOrigin } from "./url"; -/** - * Takes a launch off the url into the store, which the app reads it from for - * the rest of the tab's life. Called before the url is stripped of it. - * - * The launch's own fields, since the search also carries what entry seeded off - * the caller's row, and writing one of those here posts it straight back. Only - * the ones the url names, since this runs again on the strip. - */ +/** Per tab, since each browser tab is a different Onshape document. */ +export const useOnshapeLaunch = create()( + persist((): OnshapeLaunch => ({}), { + name: "onshapeLaunch", + storage: createJSONStorage(() => window.sessionStorage) + }) +); + +/** Only the fields present: an in-app navigation drops the rest from the url. */ export function adoptOnshapeLaunch(search: OnshapeLaunch): void { - updateUiState( + useOnshapeLaunch.setState( Object.fromEntries( LAUNCH_KEYS.filter((key) => search[key] !== undefined).map( (key) => [key, search[key]] @@ -28,28 +32,28 @@ export function adoptOnshapeLaunch(search: OnshapeLaunch): void { /** The element the panel can insert into; nothing when there is none. */ export function useTargetElement(): TargetElement | undefined { - return toTargetElement(useGetUiState()); + const launch = useOnshapeLaunch( + useShallow((state) => ({ + documentId: state.documentId, + instanceId: state.instanceId, + instanceType: state.instanceType, + elementId: state.elementId, + elementType: state.elementType + })) + ); + return useMemo(() => toTargetElement(launch), [launch]); } -/** - * Whether the app is running in an Onshape document it can insert into. A - * signed-in caller opening the app directly is not. - */ +/** A signed-in caller opening the app directly isn't. */ export function useIsConnectedToOnshape(): boolean { return useTargetElement() !== undefined; } export function useOnshapeServer(): string | undefined { - return useGetUiState().server; + return useOnshapeLaunch((state) => state.server); } -/** - * `systemTheme` is Onshape's, taken off the launch; standalone there is none, - * so the caller passes the OS preference instead. - */ -export function getColorTheme( - theme: Theme, - systemTheme: ColorTheme -): ColorTheme { - return theme === Theme.SYSTEM ? systemTheme : theme; +/** What links into Onshape are built on, so they open on the caller's company. */ +export function useOnshapeOrigin(): string { + return toOnshapeOrigin(useOnshapeServer()); } diff --git a/src/frontend/lib/push-socket.ts b/src/frontend/lib/push-socket.ts new file mode 100644 index 000000000..8d2bf4959 --- /dev/null +++ b/src/frontend/lib/push-socket.ts @@ -0,0 +1,84 @@ +/** The app's one WebSocket to the server's pushes. Reconnects with backoff; `push-sync.ts` catches up after. */ +import { + PUSH_LIBRARY_PARAM, + PUSH_ROUTE, + type PushMessage +} from "@backend/features/push/contract"; +import type { LibraryId } from "@backend/features/library/library-id"; + +type MessageListener = (message: PushMessage) => void; +type ConnectionListener = (connected: boolean) => void; + +const FIRST_RETRY_MS = 1_000; +const LAST_RETRY_MS = 30_000; + +const messageListeners = new Set(); +const connectionListeners = new Set(); + +let socket: WebSocket | undefined; +let connected = false; +let retryMs = FIRST_RETRY_MS; +let retryTimer: number | undefined; + +function setConnected(next: boolean): void { + if (connected === next) { + return; + } + connected = next; + connectionListeners.forEach((listener) => listener(next)); +} + +function pushUrl(libraryId: LibraryId): string { + const url = new URL("/api" + PUSH_ROUTE, window.location.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set(PUSH_LIBRARY_PARAM, libraryId); + return url.href; +} + +function open(libraryId: LibraryId): void { + const current = new WebSocket(pushUrl(libraryId)); + socket = current; + current.onopen = () => { + retryMs = FIRST_RETRY_MS; + setConnected(true); + }; + current.onmessage = (event: MessageEvent) => { + // Sent by our own server, in the contract's shape. + const message = JSON.parse(event.data) as PushMessage; + messageListeners.forEach((listener) => listener(message)); + }; + current.onclose = () => { + // A socket this module already let go of, which it closed itself. + if (socket !== current) { + return; + } + socket = undefined; + setConnected(false); + retryTimer = window.setTimeout(() => open(libraryId), retryMs); + retryMs = Math.min(retryMs * 2, LAST_RETRY_MS); + }; +} + +/** Connects for `libraryId`, dropping any connection for another; returns a disconnect. */ +export function connectPushes(libraryId: LibraryId): () => void { + open(libraryId); + return () => { + window.clearTimeout(retryTimer); + const current = socket; + socket = undefined; + current?.close(); + setConnected(false); + }; +} + +export function subscribePushes(listener: MessageListener): () => void { + messageListeners.add(listener); + return () => messageListeners.delete(listener); +} + +export function subscribePushConnection( + listener: ConnectionListener +): () => void { + connectionListeners.add(listener); + return () => connectionListeners.delete(listener); +} diff --git a/src/frontend/lib/push-sync.ts b/src/frontend/lib/push-sync.ts new file mode 100644 index 000000000..c3d1dd6db --- /dev/null +++ b/src/frontend/lib/push-sync.ts @@ -0,0 +1,76 @@ +/** Applies the server's pushes to the cache. Mounted once, by the app shell. */ +import { useEffect, useRef } from "react"; +import { type PushMessage, PushType } from "@backend/features/push/contract"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; +import { useAccessData } from "../features/auth/access-level"; +import { isRenderOf } from "../features/thumbnails/render-wait"; +import { useLibraryId } from "./library"; +import { + connectPushes, + subscribePushConnection, + subscribePushes +} from "./push-socket"; +import { queryClient } from "./query-client"; +import { jobStatusQueryKey } from "./query-keys"; +import { useRefreshLibrary } from "./refresh"; + +export function usePushSync(): void { + const libraryId = useLibraryId(); + const refreshLibrary = useRefreshLibrary(); + const { signedIn, currentAccessLevel } = useAccessData(); + // Non-editors would otherwise show spinners for work they can't see. + const showsJobs = signedIn && hasEditorAccess(currentAccessLevel); + const hasConnected = useRef(false); + + useEffect(() => connectPushes(libraryId), [libraryId]); + + useEffect(() => { + const apply = (message: PushMessage) => { + switch (message.type) { + case PushType.JOBS: + if (showsJobs && message.libraryId === libraryId) { + queryClient.setQueryData( + jobStatusQueryKey(libraryId), + message.status + ); + } + break; + case PushType.LIBRARY: + if (message.libraryId === libraryId) { + void refreshLibrary(); + } + break; + case PushType.THUMBNAIL: + // Rows that took a miss; anything waiting on the render hears the push itself. + void queryClient.refetchQueries({ + predicate: (query) => { + const [kind, url] = query.queryKey; + return ( + kind === "storage-thumbnail" && + typeof url === "string" && + query.state.status === "error" && + isRenderOf(url, message) + ); + } + }); + break; + } + }; + return subscribePushes(apply); + }, [libraryId, refreshLibrary, showsJobs]); + + // Pushes during the outage are lost, so a reconnect refetches. + useEffect( + () => + subscribePushConnection((connected) => { + if (!connected) { + return; + } + if (hasConnected.current) { + void refreshLibrary(); + } + hasConnected.current = true; + }), + [refreshLibrary] + ); +} diff --git a/src/frontend/lib/query-cache.ts b/src/frontend/lib/query-cache.ts index 9f46d292c..595549d67 100644 --- a/src/frontend/lib/query-cache.ts +++ b/src/frontend/lib/query-cache.ts @@ -12,10 +12,7 @@ export function getQueryUpdater(recipe: (draft: T) => void): Updater { }; } -/** - * Shows a mutation's result before the server confirms it. There is no snapshot - * to roll back to: callers invalidate on settle, and the refetch is the undo. - */ +/** No rollback: callers invalidate on settle, and the refetch is the undo. */ export async function patchQuery( queryKey: QueryKey, recipe: (draft: T) => void diff --git a/src/frontend/lib/query-client.ts b/src/frontend/lib/query-client.ts index 2f55dac31..88209f80f 100644 --- a/src/frontend/lib/query-client.ts +++ b/src/frontend/lib/query-client.ts @@ -6,7 +6,7 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (count, error) => { - // Only retry once + // At most two retries if (count >= 2) { return false; } diff --git a/src/frontend/lib/query-keys.test.ts b/src/frontend/lib/query-keys.test.ts index 9398d8540..63d8e8dbf 100644 --- a/src/frontend/lib/query-keys.test.ts +++ b/src/frontend/lib/query-keys.test.ts @@ -13,8 +13,7 @@ import { LibraryId } from "@backend/features/library/library-id"; const LIBRARY = Object.values(LibraryId)[0]; describe("isVersionedLibraryQuery", () => { - // A refresh leaves these alone: their urls are immutable, so refetching the - // version a bump is replacing serves what that version already said. + // Immutable urls: refetching would serve the old version's answer. it.each([ ["library data", libraryDataQueryKey(LIBRARY, 3)], ["search db", searchDbQueryKey(LIBRARY, 3)], diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts index d57de5a65..9030c67c9 100644 --- a/src/frontend/lib/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -1,12 +1,18 @@ -/** - * Every query key in one place. Everything scoped to a library hangs off - * {@link libraryQueryKey}, so the refresh flows invalidate that one prefix. - */ +/** Library-scoped keys hang off {@link libraryQueryKey}, so a refresh invalidates one prefix. */ import { LibraryId } from "@backend/features/library/library-id"; import { ElementPath, InstancePath } from "@backend/lib/onshape/path"; -export function accessDataQueryKey() { - return ["access-data"]; +/** Access is per library; without one, the prefix every library's shares. */ +export function accessDataQueryKey(libraryId?: LibraryId) { + return libraryId ? ["access-data", libraryId] : ["access-data"]; +} + +export function adminTeamQueryKey(libraryId: LibraryId) { + return ["admin-team", libraryId]; +} + +export function versionApprovalQueryKey(libraryId: LibraryId) { + return ["version-approval", libraryId]; } export function configurationQueryKey( @@ -29,11 +35,7 @@ export function libraryQueryKey(libraryId: LibraryId) { return ["library", libraryId]; } -/** - * The library-scoped queries pinned to a cache version. Their urls are - * immutable, so one answers for its own version and no other — which is what - * {@link isVersionedLibraryQuery} exists to keep a refresh from forgetting. - */ +/** Immutable urls; see {@link isVersionedLibraryQuery}. */ const LIBRARY_DATA = "library-data"; const SEARCH_DB = "search-db"; const BUILD_STATUS = "build-status"; @@ -76,11 +78,7 @@ export function jobStatusQueryKey(libraryId: LibraryId) { return [...libraryQueryKey(libraryId), "job-status"]; } -/** - * A render the insert preview is waiting on. Everything hangs off the prefix: - * an insert cancels the lot, and the insert buttons ask it whether one is - * still running. - */ +/** Inserting cancels everything under this prefix. */ const RENDER = "thumbnail"; export function renderQueryPrefix() { diff --git a/src/frontend/lib/refresh.ts b/src/frontend/lib/refresh.ts index 1fd7de4f5..065c6d66f 100644 --- a/src/frontend/lib/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback } from "react"; import { useRouter } from "@tanstack/react-router"; import { queryClient } from "./query-client"; -import { useIsJobRunning } from "../features/library/queries"; import { accessDataQueryKey, favoritesQueryKey, @@ -11,23 +10,13 @@ import { import { useLibraryId } from "./library"; interface RefreshLibraryOptions { - /** - * Refetch the version-keyed queries too, dropping whatever an optimistic - * patch left on them. For a mutation that failed: it bumped nothing, so the - * version on screen is still the one the server can answer for. - */ + /** For a failed mutation: refetches versioned queries to drop optimistic patches. */ discardPatches?: boolean; } /** - * Refreshes the current library and the caller's access. - * - * The version-keyed queries are left alone, because a versioned url answers for - * its own version and keeps answering for it — immutably, out of the browser's - * cache. Refetching the version a bump is replacing therefore reinstates the - * state that version had, which is what put an optimistic patch back to its old - * value for one round trip before the new version landed. Moving the pointer is - * enough: the new version is a new key, and it fetches. + * Leaves versioned queries alone: their urls are immutable, so refetching the + * old version restores its old state. The bump moves to a new key, which fetches. */ export function useRefreshLibrary(): ( options?: RefreshLibraryOptions @@ -64,19 +53,3 @@ export function useRefreshFavorites(): () => Promise { await router.invalidate(); }, [router, libraryId]); } - -/** Polls whether a load job is running and refreshes the library once it finishes. */ -export function useJobStatus(): boolean { - const refreshLibrary = useRefreshLibrary(); - const running = useIsJobRunning(); - // A ref, not state: tracking the previous value to detect the finished - // transition shouldn't trigger a render (and set-state-in-effect is banned). - const wasRunning = useRef(running); - useEffect(() => { - if (wasRunning.current && !running) { - void refreshLibrary(); - } - wasRunning.current = running; - }, [running, refreshLibrary]); - return running; -} diff --git a/src/frontend/lib/search-params.ts b/src/frontend/lib/search-params.ts index 15e4f218e..160bf9f14 100644 --- a/src/frontend/lib/search-params.ts +++ b/src/frontend/lib/search-params.ts @@ -1,9 +1,6 @@ import type { ZodType } from "zod"; -/** - * Validates url search params, omitting what fails rather than leaving it - * undefined: `retainSearchParams` tests `key in search`, reading that as cleared. - */ +/** Omits what fails, since `retainSearchParams` reads an undefined key as cleared. */ export function parseSearch( schema: ZodType, search: unknown diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 69a7c027a..ae6635775 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -1,7 +1,3 @@ -/** - * Sizes for Phosphor icons. The first three are general magnitudes for an icon - * in a line of content; the rest each name the one place they are used. - */ export enum IconSize { /** Beside xs text: badge labels and metadata rows. */ TINY = 12, @@ -23,15 +19,6 @@ export enum FontWeight { BOLD = 700 } -export const BORDER = "1px solid var(--mantine-color-default-border)"; - -/** The corner every box of ours is cut with, matching the theme's default. */ -export const RADIUS = "var(--mantine-radius-sm)"; - -/** - * The colors state is spoken in, as Mantine names them. Named here rather than - * written at each control, so an error looks like an error everywhere. - */ export enum StatusColor { ERROR = "red", WARNING = "yellow", @@ -43,70 +30,28 @@ export enum StatusColor { DIMMED = "dimmed" } -/** - * A badge naming a kind rather than a state — a configuration parameter's type, - * say. Off every {@link StatusColor} so it never reads as one, and never gray, - * which on a badge reads as disabled rather than as a label. - */ +/** For a badge naming a kind; not a status color, and not gray, which reads as disabled. */ export const CATEGORY_COLOR = "violet"; -/** - * Mantine's default step for a color: what a bare color name renders as, and - * so the step anything picking its own color should match. - */ +/** Mantine's default shade. */ export const FILLED_SHADE = 6; -/** A Mantine color at one shade, for props that take a CSS value rather than - * Mantine's own `color.shade` shorthand. */ +/** For props that take a CSS value. */ export function colorVar(color: string, shade: number): string { return `var(--mantine-color-${color}-${shade})`; } -/** - * A mark that must read as secondary but stay legible on both themes, which - * bare "gray" does not: a reference line, a bar for an unremarkable value. - */ +/** Secondary but legible in both themes, which bare "gray" isn't. */ export const MUTED_MARK = `${StatusColor.NEUTRAL}.5`; -/** The same color as a tint to sit content on, e.g. a callout's background. */ -export function statusBackground(color: StatusColor): string { - return `var(--mantine-color-${color}-light)`; -} - -/** A step off the page, for the bars framing it: the navbar's tab row, a - * modal's header and footer. */ -export const FRAME_BACKGROUND = - "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8))"; - -/** - * A surface for a render to sit on. Onshape renders a part light, on white, so - * a white card leaves the thumbnail with no edge to it; light mode steps down - * far enough to give it one. Dark mode already has the contrast, so it keeps - * the card's own background. - */ +/** Onshape renders parts light on white, so light mode needs a darker card. */ export const RENDER_BACKGROUND = "light-dark(var(--mantine-color-gray-1), var(--mantine-color-body))"; -/** - * What a subtle gray control draws its icon in — the color Mantine resolves - * `variant="subtle"` to. A bare icon standing beside one has to be given it, - * or it takes the body text color and reads darker than its neighbours. - */ +/** Matches a subtle gray control's icon, for a bare icon beside one. */ export const CONTROL_ICON_COLOR = "var(--mantine-color-gray-light-color)"; -/** - * Text reads as centred on its cap height, a pixel above its line box, so an - * icon centred on that box looks low. `text-box` clips descenders under truncation. - */ -export const TITLE_ICON_NUDGE = { transform: "translateY(-1px)" }; - -/** Holds an icon or badge at its own size beside text that can outgrow the row. */ -export const NO_SHRINK = { flexShrink: 0 }; - -/** - * Paints an image in the current text color rather than its own. The url needs - * quoting: Vite inlines an asset as a data uri, which can contain apostrophes. - */ +/** Paints an image in the text color. Quoted since data uris can hold apostrophes. */ export function maskedImage(url: string) { return { backgroundColor: "currentColor", @@ -120,25 +65,12 @@ export function maskedImage(url: string) { /** The height of a default-sized Mantine input, for aligning beside one. */ export const INPUT_HEIGHT = "36px"; -/** - * One height for every navbar row, so the app's two tiers and the dashboard's - * read as the same bar rather than three sizes of one. - */ export const NAVBAR_ROW_HEIGHT = 48; -/** - * A rule that has to read against {@link FRAME_BACKGROUND} rather than a white - * page, so it takes the same step off the frame in either theme. - */ +/** Stands out against the navbar's frame in either theme. */ export const NAVBAR_DIVIDER_COLOR = "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))"; -/** - * One height for a section header, set rather than left to the content: an - * accordion is sized by its label, a group header by its menu button. - */ -export const SECTION_HEADER_HEIGHT = 48; - /** The app's primary color as a filled background. */ export enum PrimaryColor { /** The current library's color, e.g. green for FRCDesign. */ @@ -146,3 +78,6 @@ export enum PrimaryColor { /** What reads on top of it, typically white. */ CONTRAST = "var(--mantine-primary-color-contrast)" } + +/** Wide enough for "Open dashboard", so every settings control ends on one line. */ +export const SETTING_CONTROL_WIDTH = 170; diff --git a/src/frontend/lib/styles.module.css b/src/frontend/lib/styles.module.css new file mode 100644 index 000000000..ef7f0718c --- /dev/null +++ b/src/frontend/lib/styles.module.css @@ -0,0 +1,71 @@ +/* + * Styles more than one feature draws with. A value one component alone uses + * belongs in that component's own module; one handed to a Mantine prop (an + * icon size, a status color) stays in `style-constants.ts`. + */ + +/* The rule between the app's bands: a navbar, a section header, a modal's frame. */ +.dividerBottom { + border-bottom: 1px solid var(--mantine-color-default-border); +} + +.dividerTop { + border-top: 1px solid var(--mantine-color-default-border); +} + +/* A box of ours with its edge drawn, cut like the theme cuts every other. */ +.outlined { + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); +} + +/* A step off the page, for the bars framing it: a navbar, a modal's header and footer. */ +.frame { + background: light-dark( + var(--mantine-color-gray-2), + var(--mantine-color-dark-8) + ); +} + +/* + * One height for every section header, set rather than left to the content: an + * accordion is sized by its label, a group header by its menu button. It never + * shrinks — the list under it gives up its room, not the header naming it. + */ +.sectionHeader { + min-height: rem(48px); + flex-shrink: 0; +} + +/* Holds an icon or badge at its own size beside text that can outgrow the row. */ +.noShrink { + flex-shrink: 0; +} + +/* + * A row whose text alone gives up room, so a long title truncates rather than + * squeezing the thumbnail and badge beside it smaller than the next row's. + */ +.onlyTextShrinks > * { + flex-shrink: 0; +} + +.onlyTextShrinks > .shrinkingText { + flex-shrink: 1; +} + +/* + * Text reads as centred on its cap height, a pixel above its line box, so an + * icon centred on that box looks low. + */ +.titleIcon { + transform: translateY(-1px); +} + +/* Centres an external link's arrow on its text rather than its baseline. */ +.externalLink { + display: inline-flex; + align-items: center; + gap: 0.25em; + min-width: 0; +} diff --git a/src/frontend/lib/tabs.ts b/src/frontend/lib/tabs.ts index 98db3503c..7e820e414 100644 --- a/src/frontend/lib/tabs.ts +++ b/src/frontend/lib/tabs.ts @@ -1,32 +1,13 @@ /** What the navbar offers: where each tab goes, and how it is spelled. */ import { useNavigate } from "@tanstack/react-router"; -import * as z from "zod"; import { LibraryId } from "@backend/features/library/library-id"; -import { - type AppTab, - getTabPath, - isLibraryTab, - UtilityTab -} from "@backend/features/settings/app-tab"; +import { type AppTab, getTabPath, isLibraryTab, UtilityTab } from "./app-tab"; import { getLibraryName } from "./library"; -/** - * The tabs the app can open, in the order the navbar offers them. A utility - * joins this once it has a page; `AppTab` already lets one be stored. - */ +/** In navbar order. A utility joins once it has a page. */ export const APP_TABS: AppTab[] = Object.values(LibraryId); -/** A tab id as the entry redirect spells it into the url. */ -export const AppTabType = z.enum([ - ...Object.values(LibraryId), - ...Object.values(UtilityTab) -]); - -/** - * Navigates to a tab. A library is one route with a parameter, so it is named; - * a utility is a route of its own, which `href` reaches without this route tree - * having to know it yet — a relative one still navigates in place. - */ +/** A utility is reached by `href`, so this route tree needn't know it yet. */ export function useNavigateToTab(): (tabId: AppTab) => void { const navigate = useNavigate(); diff --git a/src/frontend/lib/ui-state.test.ts b/src/frontend/lib/ui-state.test.ts deleted file mode 100644 index 5ab82198b..000000000 --- a/src/frontend/lib/ui-state.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { Theme } from "@backend/features/settings/settings"; - -/** A Storage the tests can read back, and break on demand. */ -function fakeStorage() { - const entries = new Map(); - return { - failing: false, - get length() { - return entries.size; - }, - key: (index: number) => [...entries.keys()][index] ?? null, - clear: () => entries.clear(), - getItem(key: string): string | null { - if (this.failing) throw new Error("storage is blocked"); - return entries.get(key) ?? null; - }, - setItem(key: string, value: string): void { - if (this.failing) throw new Error("storage is blocked"); - entries.set(key, value); - }, - removeItem: (key: string) => void entries.delete(key) - }; -} - -let local: ReturnType; -let session: ReturnType; - -/** A fresh module, so the state it caches is read back from these stores. */ -async function loadUiState() { - vi.resetModules(); - return import("./ui-state"); -} - -const stored = (storage: Storage, key: string): Record => - JSON.parse(storage.getItem(key) ?? "{}") as Record; - -beforeEach(() => { - local = fakeStorage(); - session = fakeStorage(); - vi.stubGlobal("window", { localStorage: local, sessionStorage: session }); -}); - -describe("ui state", () => { - it("writes a field to the store its scope names", async () => { - const { updateUiState } = await loadUiState(); - - updateUiState({ searchQuery: "gear", justSignedIn: true }); - - expect(stored(local, "uiState").searchQuery).toBe("gear"); - expect(stored(session, "uiSessionState").justSignedIn).toBe(true); - // Neither store holds the other's fields, whatever it was written with. - expect(stored(local, "uiState")).not.toHaveProperty("justSignedIn"); - expect(stored(session, "uiSessionState")).not.toHaveProperty( - "searchQuery" - ); - }); - - it("reads both stores back as one state", async () => { - local.setItem( - "uiState", - JSON.stringify({ version: 4, searchQuery: "gear" }) - ); - session.setItem( - "uiSessionState", - JSON.stringify({ version: 4, justSignedIn: true }) - ); - const { getUiState } = await loadUiState(); - - expect(getUiState()).toMatchObject({ - searchQuery: "gear", - justSignedIn: true - }); - }); - - // The split moved a field out of the local blob and added three more, which - // the stored shape tolerates: nobody's theme or filters reset over it. - it("keeps what a store written before the split holds", async () => { - local.setItem( - "uiState", - JSON.stringify({ - version: 4, - theme: "dark", - searchQuery: "bearing", - justSignedIn: true - }) - ); - const { getUiState } = await loadUiState(); - - expect(getUiState()).toMatchObject({ - theme: "dark", - searchQuery: "bearing" - }); - // Session fields are the session store's, wherever they were found. - expect(getUiState().justSignedIn).toBe(false); - }); - - it("falls back to the defaults for a store it cannot use", async () => { - local.setItem("uiState", "{ not json"); - const { getUiState } = await loadUiState(); - - expect(getUiState().searchQuery).toBe(""); - }); - - it("leaves a store alone when the update names none of its fields", async () => { - const { updateUiState } = await loadUiState(); - updateUiState({ justSignedIn: true }); - - expect(session.getItem("uiSessionState")).not.toBeNull(); - expect(local.getItem("uiState")).toBeNull(); - }); - - it("serves the session from memory when storage is blocked", async () => { - local.failing = true; - session.failing = true; - const { getUiState, updateUiState } = await loadUiState(); - - expect(() => updateUiState({ searchQuery: "gear" })).not.toThrow(); - expect(getUiState().searchQuery).toBe("gear"); - }); -}); - -describe("synced fields", () => { - it("sends a changed field to the caller's row, and stores it too", async () => { - const { setSettingsSync, updateUiState } = await loadUiState(); - const sent: unknown[] = []; - setSettingsSync((settings) => sent.push(settings)); - - updateUiState({ theme: Theme.DARK }); - - expect(sent).toEqual([{ theme: "dark" }]); - expect(stored(local, "uiState").theme).toBe("dark"); - }); - - // The entry redirect writes the group the caller resumed in, which is the - // one their row already holds. - it("sends nothing for a field that did not move", async () => { - const { setSettingsSync, updateUiState } = await loadUiState(); - updateUiState({ groupId: "group-1" }); - const sent: unknown[] = []; - setSettingsSync((settings) => sent.push(settings)); - - updateUiState({ groupId: "group-1" }); - - expect(sent).toEqual([]); - }); - - it("sends only the synced fields the update moved", async () => { - const { setSettingsSync, updateUiState } = await loadUiState(); - const sent: unknown[] = []; - setSettingsSync((settings) => sent.push(settings)); - - updateUiState({ theme: Theme.DARK, searchQuery: "gear" }); - - expect(sent).toEqual([{ theme: "dark" }]); - }); - - it("sends nothing back for a value the row is what seeded", async () => { - const { setSettingsSync, updateUiState } = await loadUiState(); - const sent: unknown[] = []; - setSettingsSync((settings) => sent.push(settings)); - - updateUiState({ theme: Theme.DARK }, { sync: false }); - - expect(sent).toEqual([]); - // Stored all the same: the store is still what the app reads. - expect(stored(local, "uiState").theme).toBe("dark"); - }); -}); diff --git a/src/frontend/lib/ui-state.ts b/src/frontend/lib/ui-state.ts index f5c89271a..c7df760fe 100644 --- a/src/frontend/lib/ui-state.ts +++ b/src/frontend/lib/ui-state.ts @@ -1,250 +1,60 @@ -import { useSyncExternalStore } from "react"; -import * as z from "zod"; -import { AccessLevel } from "@backend/features/auth/access-level"; -import { LibraryId } from "@backend/features/library/library-id"; -import { UtilityTab } from "@backend/features/settings/app-tab"; -import { Vendor } from "@backend/features/library/vendors"; -import { DEFAULT_SETTINGS, Theme } from "@backend/features/settings/settings"; -import { OnshapeLaunchType } from "./onshape-launch"; - -/** Bumped when a change to the schema makes stored state unusable. */ -const LATEST_VERSION = 4; - -const VendorType = z.enum(Object.values(Vendor)); -const AccessLevelType = z.enum(Object.values(AccessLevel)); -const ThemeType = z.enum(Object.values(Theme)); -const LibraryIdType = z.enum(Object.values(LibraryId)); -const AppTabType = z.union([LibraryIdType, z.enum(Object.values(UtilityTab))]); - /** - * Kept locally and pushed to the caller's row, so a browser that has never run - * the app starts where their last one left off. The store is still what the app - * reads: the row is the copy, and the entry redirect is what seeds it back. + * What the app shows and remembers in this browser. Components read it through + * `useUiState` with a selector, so each re-renders only for its own fields. + * The Onshape launch, which is per tab, is `useOnshapeLaunch`. */ -const SyncedStateSchema = z.object({ - theme: ThemeType.default(DEFAULT_SETTINGS.theme), - /** The tab last opened; null until one is picked, which the welcome asks - * for. */ - tabId: AppTabType.nullable().default(DEFAULT_SETTINGS.tabId), - /** The group last opened in that tab; null for the tab itself. */ - groupId: z.string().nullable().default(DEFAULT_SETTINGS.groupId) -}); - -/** Kept until the browser's storage is cleared: preferences, and where to resume. */ -const LocalStateSchema = z.object({ - isFavoritesOpen: z.boolean().default(false), - isLibraryOpen: z.boolean().default(true), - /** Vendor filters per library, so switching libraries keeps each one's; - * a library with no entry has every vendor active. */ - vendorFilters: z - .partialRecord(LibraryIdType, z.array(VendorType)) - .default({}), - searchQuery: z.string().default(""), - fasten: z.boolean().default(true), +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { AccessLevel } from "@backend/features/auth/access-level"; +import type { LibraryId } from "@backend/features/library/library-id"; +import type { Vendor } from "@backend/features/library/vendors"; +import type { PartialSelection } from "@backend/features/configurations/contract"; +import type { AppTab } from "./app-tab"; + +export enum Theme { + DARK = "dark", + LIGHT = "light" +} + +interface UiState { + theme: Theme; + /** Undefined until one is picked, which the welcome asks for. */ + tabId?: AppTab; + /** The group last opened in that tab; undefined for the tab itself. */ + groupId?: string; + isFavoritesOpen: boolean; + isLibraryOpen: boolean; + /** Per library; a library with no entry has every vendor active. */ + vendorFilters: Partial>; + searchQuery: string; + fasten: boolean; /** The access level to view the app as; absent means the granted default. */ - accessLevel: AccessLevelType.optional(), - /** The insertable whose insert menu is open, and what it is configured to. - * Written as the menu opens and closes, so a relaunch can reopen it. */ - openInsertableId: z.string().optional(), - /** Absent for the element's own defaults, which is the empty key. */ - openConfigurationKey: z.string().optional(), - /** Set when the menu was opened from a favorite rather than a row. */ - openFavoriteId: z.string().optional() -}); - -/** Kept for this tab only, being about this visit rather than this browser. */ -const SessionStateSchema = z.object({ - /** Set on leaving for Onshape, so the app can confirm the sign-in on - * return — and only in the tab that left, which a second one did not. */ - justSignedIn: z.boolean().default(false), - // What Onshape launched this panel with: a tab switch replaces it, and - // another browser tab is another document, with a store of its own. - ...OnshapeLaunchType.shape -}); - -type LocalState = z.infer; -type SessionState = z.infer; -type SyncedState = z.infer; -type UiState = LocalState & SessionState & SyncedState; - -/** - * One store's half of the state: which fields it owns, and how long they last. - * Held apart so a field's scope is declared once, beside the field. - */ -interface StateArea { - storageKey: string; - schema: z.ZodObject; - /** Read at call time: touching a blocked store is what throws. */ - getStorage: () => Storage; -} - -const LOCAL_AREA: StateArea = { - storageKey: "uiState", - // Synced fields are local too, and in the same blob: their scope is about - // where else they go, not where they are kept — and moving them to a blob - // of their own would reset the preferences already stored in this one. - schema: z.object({ - ...LocalStateSchema.shape, - ...SyncedStateSchema.shape - }), - getStorage: () => window.localStorage -}; - -const SESSION_AREA: StateArea = { - storageKey: "uiSessionState", - schema: SessionStateSchema, - getStorage: () => window.sessionStorage -}; - -const AREAS = [LOCAL_AREA, SESSION_AREA]; - -const SYNCED_KEYS = Object.keys(SyncedStateSchema.shape); - -type SettingsSync = (settings: Partial) => void; - -let settingsSync: SettingsSync | undefined; - -/** - * Installed at startup by the feature that owns the caller's row, which keeps - * the endpoint — and the sign-in it needs — out of here. - */ -export function setSettingsSync(sync: SettingsSync): void { - settingsSync = sync; -} - -type Subscriber = () => void; - -const subscribers = new Set(); - -/** The state this session is working from; the stores are written behind it. */ -let currentState: UiState | null = null; - -/** Blocked or partitioned storage must not break the app, only its memory. */ -function readStorage(area: StateArea): string | null { - try { - return area.getStorage().getItem(area.storageKey); - } catch { - return null; - } -} - -function writeStorage(area: StateArea, value: string): void { - try { - area.getStorage().setItem(area.storageKey, value); - } catch { - // Nothing to do; the in-memory cache still serves this session. - } -} - -/** - * What one area stored, or its defaults when that cannot be used — older, - * hand-edited, or naming something dropped. Losing a preference beats failing - * to start. The version is read off the raw blob rather than the schema, being - * about the stored shape rather than anything the app reads. - */ -function readArea(area: StateArea): Record { - const defaults = () => area.schema.parse({}); - const raw = readStorage(area); - if (!raw) { - return defaults(); - } - try { - // A stored null reads as absent, which is what a default fills. - const stored = JSON.parse( - raw, - (_key, value: unknown) => value ?? undefined - ) as { version?: number }; - if ((stored.version ?? 1) < LATEST_VERSION) { - return defaults(); - } - const parsed = area.schema.safeParse(stored); - return parsed.success ? parsed.data : defaults(); - } catch { - return defaults(); - } -} - -function writeArea(area: StateArea, state: UiState): void { - const stored: Record = { version: LATEST_VERSION }; - for (const key of Object.keys(area.schema.shape)) { - stored[key] = state[key as keyof UiState]; - } - writeStorage(area, JSON.stringify(stored)); -} + accessLevel?: AccessLevel; + /** So a relaunch can reopen the insert menu. */ + openInsertableId?: string; + openSelection?: PartialSelection; + openFavoriteId?: string; +} + +/** In localStorage, the persist default. */ +export const useUiState = create()( + persist( + (): UiState => ({ + theme: Theme.DARK, + isFavoritesOpen: false, + isLibraryOpen: true, + vendorFilters: {}, + searchQuery: "", + fasten: true + }), + { name: "uiState" } + ) +); export function getUiState(): UiState { - currentState ??= { - ...readArea(LOCAL_AREA), - ...readArea(SESSION_AREA) - } as UiState; - return currentState; -} - -function subscribeToUiState(callback: Subscriber) { - subscribers.add(callback); - return () => subscribers.delete(callback); -} - -/** The fields the update names whose value is not already what it says. */ -function changedKeys( - current: UiState, - partialState: Partial -): string[] { - return Object.keys(partialState).filter((key) => { - const typedKey = key as keyof UiState; - // Compared by value for the one object-valued field, which is rebuilt - // rather than mutated; the rest are primitives. - return typedKey === "vendorFilters" - ? JSON.stringify(current[typedKey]) !== - JSON.stringify(partialState[typedKey]) - : current[typedKey] !== partialState[typedKey]; - }); -} - -interface UpdateOptions { - /** - * False for a value the caller's row already holds — the entry redirect - * seeding the theme — which would otherwise be posted straight back. - * @default true - */ - sync?: boolean; -} - -/** Merges into the state, stores it, and tells every reader it changed. */ -export function updateUiState( - partialState: Partial, - options: UpdateOptions = {} -): UiState { - const current = getUiState(); - const changed = changedKeys(current, partialState); - if (changed.length === 0) { - return current; - } - const newState: UiState = { ...current, ...partialState }; - currentState = newState; - for (const area of AREAS) { - if (changed.some((key) => key in area.schema.shape)) { - writeArea(area, newState); - } - } - // Only what moved: the row is written for a field the caller changed, not - // for every field that happened to ride along with it. - const syncedChanges = changed.filter((key) => SYNCED_KEYS.includes(key)); - if ((options.sync ?? true) && syncedChanges.length > 0) { - settingsSync?.( - Object.fromEntries( - syncedChanges.map((key) => [ - key, - newState[key as keyof UiState] - ]) - ) - ); - } - subscribers.forEach((callback) => callback()); - return newState; + return useUiState.getState(); } -/** The current state, re-rendering the caller whenever it changes. */ -export function useGetUiState(): UiState { - return useSyncExternalStore(subscribeToUiState, getUiState); +export function updateUiState(update: Partial): void { + useUiState.setState(update); } diff --git a/src/frontend/lib/url.test.ts b/src/frontend/lib/url.test.ts index 6e8feb616..9d122a976 100644 --- a/src/frontend/lib/url.test.ts +++ b/src/frontend/lib/url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { makeUrl } from "./url"; +import { DEFAULT_ONSHAPE_ORIGIN, makeUrl, toOnshapeOrigin } from "./url"; describe("makeUrl", () => { const element = { @@ -10,20 +10,19 @@ describe("makeUrl", () => { } as const; it("addresses a document, an instance and an element in turn", () => { - expect(makeUrl({ documentId: "doc" })).toBe( + expect(makeUrl(DEFAULT_ONSHAPE_ORIGIN, { documentId: "doc" })).toBe( "https://cad.onshape.com/documents/doc" ); - expect(makeUrl(element)).toBe( + expect(makeUrl(DEFAULT_ONSHAPE_ORIGIN, element)).toBe( "https://cad.onshape.com/documents/doc/w/ws/e/el" ); }); - // Once, by the url: a quantity that reaches Onshape as `%2520m` is the - // value `0.381%20m`, which is no quantity. + // Escaped twice, `0.381 m` would reach Onshape as `0.381%20m`. it("escapes a configuration once", () => { - const url = makeUrl({ - ...element, - selection: { Effective_Length: "0.381 m", List_7A7: "Hex" } + const url = makeUrl(DEFAULT_ONSHAPE_ORIGIN, element, { + Effective_Length: "0.381 m", + List_7A7: "Hex" }); expect(url).toBe( @@ -35,3 +34,26 @@ describe("makeUrl", () => { ); }); }); + +describe("toOnshapeOrigin", () => { + it("keeps a company's own Onshape", () => { + expect(toOnshapeOrigin("https://frcdesign.onshape.com")).toBe( + "https://frcdesign.onshape.com" + ); + expect(toOnshapeOrigin("https://frcdesign.onshape.com/")).toBe( + "https://frcdesign.onshape.com" + ); + }); + + // The launch is a url anyone can write, and these links open as Onshape's. + it.each([ + undefined, + "", + "not a url", + "http://frcdesign.onshape.com", + "https://onshape.com.example.com", + "https://evilonshape.com" + ])("falls back to cad for %s", (server) => { + expect(toOnshapeOrigin(server)).toBe(DEFAULT_ONSHAPE_ORIGIN); + }); +}); diff --git a/src/frontend/lib/url.tsx b/src/frontend/lib/url.tsx index 8a9f82e85..fba593156 100644 --- a/src/frontend/lib/url.tsx +++ b/src/frontend/lib/url.tsx @@ -3,52 +3,62 @@ import { InstancePath, ElementPath, isInstancePath, - isElementPath, - ConfigurablePath, - isConfigurablePath + isElementPath } from "@backend/lib/onshape/path"; +import { type PartialSelection } from "@backend/features/configurations/contract"; import { encodeQueryConfiguration } from "@backend/features/configurations/utils"; import { notifications } from "@mantine/notifications"; import { LinkIcon } from "@phosphor-icons/react"; import { IconSize } from "./style-constants"; -/** The app's listing in the Onshape App Store, where it is subscribed to. */ -export const APP_STORE_URL = - "https://cad.onshape.com/appstore/apps/Manufacturers%20Models/6004ec5e83c40b107c183347"; +/** Onshape for anyone outside a company, and for a caller who never launched. */ +export const DEFAULT_ONSHAPE_ORIGIN = "https://cad.onshape.com"; + +/** A path, so it opens on the caller's own Onshape; see `useOnshapeOrigin`. */ +export const APP_STORE_PATH = + "/appstore/apps/Manufacturers%20Models/6004ec5e83c40b107c183347"; + +/** Where a caller manages the apps they have granted access. */ +export const APPLICATIONS_PATH = "/user/applications"; /** - * The setup instructions. Opened in a window of their own: a navigation would - * take the insert menu they are offered from with it. + * The company's domain for a company session, else cad's. Only https + * onshape.com origins: anyone can write a launch url. */ +export function toOnshapeOrigin(server: string | undefined): string { + const url = server ? URL.parse(server) : null; + const isOnshape = + url?.protocol === "https:" && + (url.hostname === "onshape.com" || + url.hostname.endsWith(".onshape.com")); + return isOnshape ? url.origin : DEFAULT_ONSHAPE_ORIGIN; +} + +/** Opened in a new window so the insert menu stays. */ export const SETUP_URL = "/setup"; -export function makeUrl(path: ConfigurablePath): string; -export function makeUrl(path: ElementPath): string; -export function makeUrl(path: InstancePath): string; -export function makeUrl(path: DocumentPath): string; -export function makeUrl(path: DocumentPath): string { - let url = `https://cad.onshape.com/documents/${path.documentId}`; +/** Onshape fills in whatever the configuration leaves out. */ +export function makeUrl( + origin: string, + path: DocumentPath | InstancePath | ElementPath, + configuration?: PartialSelection +): string { + let url = `${origin}/documents/${path.documentId}`; if (isInstancePath(path)) { url += `/${path.instanceType}/${path.instanceId}`; } if (isElementPath(path)) { url += `/e/${path.elementId}`; } - if (isConfigurablePath(path)) { - // Onshape's own parameter, so it keeps Onshape's name. The query form, - // this escape being the one layer Onshape unwraps. - url += - "?configuration=" + - encodeURIComponent(encodeQueryConfiguration(path.selection)); + const encoded = encodeQueryConfiguration(configuration); + if (isElementPath(path) && encoded) { + // Onshape unwraps exactly this one layer of escaping. + url += "?configuration=" + encodeURIComponent(encoded); } return url; } -/** - * The document a pasted Onshape url names, or undefined when it names none. - * Only the document id: a url pointing at a workspace or a tab carries more, - * but a link to the document itself does not, and both are worth accepting. - */ +/** Accepts a document link as well as one to a workspace or tab. */ export function parseOnshapeDocumentId(urlString: string): string | undefined { // Example pathname: /documents/{documentId}/w/{workspaceId}/e/{elementId} const url = URL.parse(urlString); @@ -59,9 +69,6 @@ export function parseOnshapeDocumentId(urlString: string): string | undefined { return documents === "documents" && documentId ? documentId : undefined; } -/** - * Opens the given URL in a new tab. - */ export function openUrlInNewTab(url: string) { window.open(url, "_blank"); } diff --git a/src/frontend/main.tsx b/src/frontend/main.tsx index 09d31e55a..bcfd60963 100644 --- a/src/frontend/main.tsx +++ b/src/frontend/main.tsx @@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client"; // Router import { RouterProvider } from "@tanstack/react-router"; import { router } from "./router"; -import { installSettingsSync } from "./features/settings/settings"; // Used to make static assets work in dev import "vite/modulepreload-polyfill"; @@ -15,8 +14,6 @@ import "@mantine/notifications/styles.layer.css"; // Custom css import "./main.scss"; -installSettingsSync(); - const rootElement: HTMLElement = document.getElementById("root")!; if (!rootElement.innerHTML) { diff --git a/src/frontend/router.ts b/src/frontend/router.ts index 74b57d6c7..b67d84762 100644 --- a/src/frontend/router.ts +++ b/src/frontend/router.ts @@ -5,8 +5,7 @@ import { RootAppSpinner } from "./components/root-spinner"; export const router = createRouter({ routeTree, scrollRestoration: true, - // Render misses at the root instead of inside `/app`, whose navbar needs a - // library in the url to render at all. + // `/app`'s navbar needs a library in the url. notFoundMode: "root", defaultPendingComponent: RootAppSpinner }); diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index a0fac8d46..59609f853 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -4,41 +4,34 @@ import { MantineProvider } from "@mantine/core"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; import { ReactNode, useMemo } from "react"; -import { useColorScheme } from "@mantine/hooks"; import { DEFAULT_LIBRARY } from "@backend/features/library/library-id"; import { queryClient } from "../lib/query-client"; import { createAppTheme } from "../theme"; -import { getColorTheme } from "../lib/onshape-params"; -import { useGetUiState } from "../lib/ui-state"; +import { useUiState } from "../lib/ui-state"; import { NotFoundError, RootCrash } from "../components/root-error"; export const Route = createRootRoute({ component: RootComponent, // notFoundComponent renders inside the root Outlet, so it has the provider. notFoundComponent: NotFoundError, - // errorComponent replaces the root component (no provider), so it must not use - // Mantine. It only fires if the always-on root component itself throws. + // Replaces the root component, provider included, so no Mantine here. errorComponent: RootCrash }); function RootComponent(): ReactNode { // The tab comes off the url, so the first paint is already its color. const params = useParams({ strict: false }); - const { theme: savedTheme, tabId, systemTheme } = useGetUiState(); + const tabId = useUiState((state) => state.tabId); + const colorScheme = useUiState((state) => state.theme); const theme = useMemo( () => createAppTheme(params.libraryId ?? tabId ?? DEFAULT_LIBRARY), [params.libraryId, tabId] ); - // Onshape's own scheme, taken off the launch; standalone there is none, - // and the OS is what "system" means. - const osColorScheme = useColorScheme(); - const colorTheme = getColorTheme(savedTheme, systemTheme ?? osColorScheme); - return ( - + @@ -46,8 +39,7 @@ function RootComponent(): ReactNode { position="bottom-center" limit={3} autoClose={4000} - // Otherwise pinned at 440px, wrapping a message with - // an action button. Still clamped to a narrow viewport. + // Otherwise pinned at 440px. containerWidth="max-content" /> diff --git a/src/frontend/routes/_pages/beta-complete.tsx b/src/frontend/routes/_pages/beta-complete.tsx index 9917fac22..9dd152f66 100644 --- a/src/frontend/routes/_pages/beta-complete.tsx +++ b/src/frontend/routes/_pages/beta-complete.tsx @@ -1,21 +1,19 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { OpenUrlButton } from "../../components/open-url-button"; -import { PageNotice } from "../../components/app-zero-state"; -import { APP_STORE_URL } from "../../lib/url"; +import { PageNotice } from "../../components/app-notice"; +import { APP_STORE_PATH } from "../../lib/url"; +import { useOnshapeOrigin } from "../../lib/onshape-params"; -/** - * Where the beta-era app extension still points. Nothing links here anymore, - * but an install old enough to predate the cutover launches straight at it, and - * without this route those callers land on the not-found page instead. - */ +/** Old installs of the beta extension still launch here. */ export const Route = createFileRoute("/_pages/beta-complete")({ component: BetaComplete }); function BetaComplete(): JSX.Element { + const origin = useOnshapeOrigin(); const frcDesignAppButton = ( - + ); return ( diff --git a/src/frontend/routes/_pages/cookie-error.tsx b/src/frontend/routes/_pages/cookie-error.tsx index 668a1dc5b..1dbba18c9 100644 --- a/src/frontend/routes/_pages/cookie-error.tsx +++ b/src/frontend/routes/_pages/cookie-error.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; export const Route = createFileRoute("/_pages/cookie-error")({ component: CookieError diff --git a/src/frontend/routes/_pages/grant-denied.tsx b/src/frontend/routes/_pages/grant-denied.tsx index 1c29c45ea..0402c83eb 100644 --- a/src/frontend/routes/_pages/grant-denied.tsx +++ b/src/frontend/routes/_pages/grant-denied.tsx @@ -1,17 +1,21 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { OpenUrlButton } from "../../components/open-url-button"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; +import { APPLICATIONS_PATH } from "../../lib/url"; +import { useOnshapeOrigin } from "../../lib/onshape-params"; export const Route = createFileRoute("/_pages/grant-denied")({ component: GrantDenied }); -const URL = "https://cad.onshape.com/user/applications"; - function GrantDenied(): JSX.Element { + const origin = useOnshapeOrigin(); const applicationAccessButton = ( - + ); return ( diff --git a/src/frontend/routes/_pages/license.tsx b/src/frontend/routes/_pages/license.tsx index 9a47ea421..dc4f026e5 100644 --- a/src/frontend/routes/_pages/license.tsx +++ b/src/frontend/routes/_pages/license.tsx @@ -11,7 +11,9 @@ function License() { GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + + Version 3, 29 June 2007 +

Copyright © 2007 Free Software Foundation, Inc. < diff --git a/src/frontend/routes/_pages/safari-error.tsx b/src/frontend/routes/_pages/safari-error.tsx index 689e78eb9..7a283306d 100644 --- a/src/frontend/routes/_pages/safari-error.tsx +++ b/src/frontend/routes/_pages/safari-error.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { OpenUrlButton } from "../../components/open-url-button"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; export const Route = createFileRoute("/_pages/safari-error")({ component: SafariError diff --git a/src/frontend/routes/_pages/setup.tsx b/src/frontend/routes/_pages/setup.tsx index 0ce5bdd3e..a2cf15915 100644 --- a/src/frontend/routes/_pages/setup.tsx +++ b/src/frontend/routes/_pages/setup.tsx @@ -3,7 +3,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { type ReactNode } from "react"; import { NavbarRow } from "../../components/app-navbar"; import { OpenUrlButton } from "../../components/open-url-button"; -import { APP_STORE_URL } from "../../lib/url"; +import { APP_STORE_PATH } from "../../lib/url"; +import { useOnshapeOrigin } from "../../lib/onshape-params"; import frcDesignAppIcon from "/frc-design-app-prod.svg"; @@ -13,6 +14,7 @@ export const Route = createFileRoute("/_pages/setup")({ /** Where "Instructions" lands: what to do to end up with the app in Onshape. */ function Setup(): ReactNode { + const origin = useOnshapeOrigin(); return ( <> {/* The brand alone: there is nowhere else to go from here. */} @@ -23,7 +25,7 @@ function Setup(): ReactNode { Get the FRCDesignApp - + The FRCDesignApp runs directly in Onshape, making it easy to add parts directly to your CAD. @@ -34,13 +36,13 @@ function Setup(): ReactNode { - + Subscribe to the FRCDesignApp in the Onshape App Store. diff --git a/src/frontend/routes/_pages/version-error.tsx b/src/frontend/routes/_pages/version-error.tsx index 6f9bdc57d..be249ee2e 100644 --- a/src/frontend/routes/_pages/version-error.tsx +++ b/src/frontend/routes/_pages/version-error.tsx @@ -1,12 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { type ReactNode } from "react"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; -/** - * Where a panel opened in a version or a microversion lands. Onshape launches - * us in one happily enough, but a snapshot cannot be changed, so there is - * nothing for the app to insert into. - */ +/** A version can't be changed, so there's nothing to insert into. */ export const Route = createFileRoute("/_pages/version-error")({ component: VersionError }); diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 930557f9c..acd703a88 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -12,12 +12,7 @@ import { ArrowUUpLeftIcon, WarningIcon } from "@phosphor-icons/react"; -import { - BORDER, - IconSize, - SECTION_HEADER_HEIGHT, - StatusColor -} from "../../../../../lib/style-constants"; +import { IconSize, StatusColor } from "../../../../../lib/style-constants"; import { ReactNode } from "react"; import { SearchResults } from "../../../../../features/search/components/search-results"; import { InsertSource } from "@backend/features/analytics/usage"; @@ -29,18 +24,20 @@ import { ItemTable } from "../../../../../components/item-row"; import { AppContextMenu, MenuButton } from "../../../../../components/app-menu"; import { SearchCallout } from "../../../../../features/search/components/search-errors"; import { - PageNotice, SectionNotice, - SectionLoading -} from "../../../../../components/app-zero-state"; + SectionLoading, + SectionError, + PageNotice +} from "../../../../../components/app-notice"; import { ClearFiltersButton, useVendorFilters } from "../../../../../features/settings/components/vendor-filters"; import { useLibraryQuery } from "../../../../../features/library/queries"; import { useLibraryId } from "../../../../../lib/library"; -import { updateUiState, useGetUiState } from "../../../../../lib/ui-state"; +import { updateUiState, useUiState } from "../../../../../lib/ui-state"; import { AppIcon } from "../../../../../components/app-icon"; +import styles from "../../../../../lib/styles.module.css"; export const Route = createFileRoute("/app/library/$libraryId/groups/$groupId")( { @@ -59,13 +56,13 @@ function GroupList(): ReactNode { from: "/app/library/$libraryId/groups/$groupId" }); - const uiState = useGetUiState(); + const searchQuery = useUiState((state) => state.searchQuery); const vendorFilters = useVendorFilters(); if (libraryQuery.isPending) { return ; } else if (libraryQuery.isError) { - return ; + return ; } const groups = libraryQuery.data.groups; const insertables = libraryQuery.data.insertables; @@ -76,7 +73,6 @@ function GroupList(): ReactNode { return ( {content} @@ -145,7 +139,8 @@ function GroupHeaderRow(props: GroupHeaderRowProps): ReactNode { const header = ( void navigate({ to: "/app/library/$libraryId", @@ -153,14 +148,9 @@ function GroupHeaderRow(props: GroupHeaderRowProps): ReactNode { }) } px="md" - h={SECTION_HEADER_HEIGHT} - // Owned here, as an accordion control owns its own, so the row and - // its divider measure the same as a section header's. It is also - // the one part that does not shrink: the list below gives up its - // room, never the header naming it. - style={{ borderBottom: BORDER, flexShrink: 0 }} + display="flex" > - + } title={group.name} @@ -190,14 +180,11 @@ function GroupListContent(props: GroupListCardsProps): ReactNode { if (groupInsertables.length === 0) { return group.isLoaded ? ( - + ) : ( ); } diff --git a/src/frontend/routes/app/library/$libraryId/index.module.css b/src/frontend/routes/app/library/$libraryId/index.module.css new file mode 100644 index 000000000..fa7ed8c00 --- /dev/null +++ b/src/frontend/routes/app/library/$libraryId/index.module.css @@ -0,0 +1,31 @@ +/* + * Mantine brightens a control to pure white or black; a section header is a + * title like the group page's, so it reads in the same text color. + * + * Stuck while its own section is on screen, until the next header pushes it + * off. Top 0 is below the navbar: the scroll container's padding clears it. + */ +.control { + color: var(--mantine-color-text); + position: sticky; + top: 0; + z-index: 1; + background: var(--mantine-color-body); +} + +/* + * A notice is one short block with nothing to scroll through, so a header + * stuck over it only half-covers it on the way out. + */ +.item:has([data-notice]) .control { + position: static; +} + +/* Its own padding would outgrow the header's height. */ +.label { + padding-block: 0; +} + +.content { + padding: 0; +} diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 3ac39849c..9d1d62370 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -2,13 +2,7 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import { Accordion, Badge } from "@mantine/core"; import { AppTitle } from "../../../../components/app-title"; import { BooksIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; -import { - BORDER, - IconSize, - PrimaryColor, - SECTION_HEADER_HEIGHT, - TITLE_ICON_NUDGE -} from "../../../../lib/style-constants"; +import { IconSize, PrimaryColor } from "../../../../lib/style-constants"; import { ReactNode, useState } from "react"; import { GroupCard } from "../../../../features/library/components/group-card"; import { ItemTable } from "../../../../components/item-row"; @@ -17,8 +11,9 @@ import { SearchResults } from "../../../../features/search/components/search-res import { InsertSource } from "@backend/features/analytics/usage"; import { SectionNotice, - SectionLoading -} from "../../../../components/app-zero-state"; + SectionLoading, + SectionError +} from "../../../../components/app-notice"; import { RequireAccessLevel } from "../../../../features/auth/access-level"; import { AddGroupButton } from "../../../../features/library/components/add-group-menu"; import { FavoritesList } from "../../../../features/favorites/components/favorites-list"; @@ -28,14 +23,17 @@ import { getLibraryStatus, useLibraryId } from "../../../../lib/library"; -import { useGetUiState, updateUiState } from "../../../../lib/ui-state"; +import { useShallow } from "zustand/react/shallow"; +import { updateUiState, useUiState } from "../../../../lib/ui-state"; import { useVendorFilters } from "../../../../features/settings/components/vendor-filters"; +import styles from "../../../../lib/styles.module.css"; +import classes from "./index.module.css"; export const Route = createFileRoute("/app/library/$libraryId/")({ component: HomeList, // Back in the library itself, which is where entry should resume. onEnter: () => { - updateUiState({ groupId: null }); + updateUiState({ groupId: undefined }); } }); @@ -51,7 +49,13 @@ interface Section { /** The sections the home list shows, in the order they are stacked. */ function useHomeSections(): Section[] { - const uiState = useGetUiState(); + const { isFavoritesOpen, isLibraryOpen, searchQuery } = useUiState( + useShallow((state) => ({ + isFavoritesOpen: state.isFavoritesOpen, + isLibraryOpen: state.isLibraryOpen, + searchQuery: state.searchQuery + })) + ); // Not persisted: search results open on every visit, unlike the library. const [isSearchOpen, setIsSearchOpen] = useState(true); const libraryId = useLibraryId(); @@ -63,7 +67,7 @@ function useHomeSections(): Section[] { icon: , title: , panel: , - opened: uiState.isFavoritesOpen, + opened: isFavoritesOpen, setOpened: (opened) => updateUiState({ isFavoritesOpen: opened }) }; @@ -78,7 +82,7 @@ function useHomeSections(): Section[] { title: , panel: ( @@ -92,13 +96,12 @@ function useHomeSections(): Section[] { icon: , title: , panel: , - opened: uiState.isLibraryOpen, + opened: isLibraryOpen, setOpened: (opened) => updateUiState({ isLibraryOpen: opened }) }; - // One slot below favorites, showing search results while a query is active - // and the library otherwise. The differing `value` remounts it on the swap. - return [favorites, uiState.searchQuery ? search : library]; + // The differing `value` remounts the slot when search starts or ends. + return [favorites, searchQuery ? search : library]; } interface SectionAccordionProps { @@ -122,20 +125,13 @@ function SectionAccordion(props: SectionAccordionProps): ReactNode { .filter((section) => section.opened) .map((section) => section.value)} onChange={handleChange} - styles={{ - // On the control, so a collapsed section still divides from - // the next one; content closes off an open one. - control: { - borderBottom: BORDER, - minHeight: SECTION_HEADER_HEIGHT, - // Mantine brightens a control to pure white or black; a section header is a title - // like the group page's, so it reads in the same text color. - color: "var(--mantine-color-text)" - }, - // Its own padding would outgrow that height. - label: { paddingBlock: 0 }, - content: { padding: 0, borderBottom: BORDER }, - icon: TITLE_ICON_NUDGE + // On the control, so a collapsed section still has a divider. + classNames={{ + control: `${classes.control} ${styles.sectionHeader} ${styles.dividerBottom}`, + item: classes.item, + label: classes.label, + content: `${classes.content} ${styles.dividerBottom}`, + icon: styles.titleIcon }} > {sections.map((section) => ( @@ -191,7 +187,7 @@ function LibraryList() { if (libraryQuery.isPending) { return ; } else if (libraryQuery.isError) { - return ; + return ; } const groups = libraryQuery.data.groups; @@ -201,7 +197,6 @@ function LibraryList() { return ( diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index f6a22279d..acba0a283 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -19,17 +19,12 @@ export const Route = createFileRoute("/app/library/$libraryId")({ stringify: ({ libraryId }) => ({ libraryId }) }, /** - * A library is a tab, and the url selects it, so the store follows the url - * here as it does the group. Written by the tabs alone it drifted — a - * resume lands elsewhere, and a panel whose storage Onshape partitioned - * away reads as the default — and switching back to the tab it still named - * then posted nothing, leaving the row where it was. - * - * Not while the tab is null: being shown the default is not choosing it. + * The url selects the library, so the store follows it. Skipped until a tab + * is chosen: being shown the default isn't choosing it. */ onEnter: (match) => { if (getUiState().tabId) { - updateUiState({ tabId: match.params.libraryId }, { sync: false }); + updateUiState({ tabId: match.params.libraryId }); } }, loader: async ({ params }) => { @@ -48,11 +43,6 @@ export const Route = createFileRoute("/app/library/$libraryId")({ } }); -/** - * The library's pages, plus the two things that follow the library rather than - * any one of them: the url the app keeps current, and the insert menu it was - * left with. - */ function Library(): ReactNode { useAppParamMirror(); useRestoreInsertMenu(); diff --git a/src/frontend/routes/app/route.tsx b/src/frontend/routes/app/route.tsx index d10ba81ce..08f639ab4 100644 --- a/src/frontend/routes/app/route.tsx +++ b/src/frontend/routes/app/route.tsx @@ -1,7 +1,6 @@ import { createFileRoute, Outlet, - redirect, retainSearchParams, type SearchSchemaInput } from "@tanstack/react-router"; @@ -9,13 +8,9 @@ import { AppShell } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { Suspense } from "react"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -import * as z from "zod"; -import { Theme } from "@backend/features/settings/settings"; -import { AppTabType } from "../../lib/tabs"; import { adoptOnshapeLaunch } from "../../lib/onshape-params"; import { - isReadOnlyInstance, - LAUNCH_KEYS, + type OnshapeLaunch, OnshapeLaunchType } from "../../lib/onshape-launch"; import { @@ -27,87 +22,42 @@ import { import { parseSearch } from "../../lib/search-params"; import { AppNavbar } from "../../components/app-navbar"; import { ProgramSelect } from "../../features/library/components/program-select"; -import { SectionLoading } from "../../components/app-zero-state"; +import { SectionLoading } from "../../components/app-notice"; import { useMessageListener } from "../../lib/messages"; -import { updateUiState } from "../../lib/ui-state"; +import { usePushSync } from "../../lib/push-sync"; import { RootAppError } from "../../components/root-error"; -/** What the entry redirect carries and the app takes off the url. */ -const LaunchSearchType = OnshapeLaunchType.extend({ - /** The caller's saved theme, from their row. */ - theme: z.enum(Theme).optional().catch(undefined), - /** The tab their row names, when it names one. */ - tabId: AppTabType.optional().catch(undefined) -}); - -/** What the entry redirect seeds off the caller's row, beside the launch. */ -const ENTRY_KEYS = ["theme", "tabId"] as const; - -type LaunchSearch = z.infer; - export const Route = createFileRoute("/app")({ component: App, validateSearch: (search: Record & SearchSchemaInput) => ({ - ...parseSearch(LaunchSearchType, search), + ...parseSearch(OnshapeLaunchType, search), ...parseSearch(AppParamsType, search) }), search: { - // Only the app's own: a launch is taken into the store below and struck - // off, so navigating never carries the caller's document around. middlewares: [retainSearchParams([...APP_PARAM_KEYS])] }, - beforeLoad: ({ search, location }) => { - adoptAppParams(search); - adoptOnshapeLaunch(search); - // The entry redirect seeds the account's saved theme; ui-state is what - // the app reads, so take it rather than leave a second answer in the url. - if (search.theme) { - updateUiState({ theme: search.theme }, { sync: false }); - } - // Seeded only when the row names one, so a tab chosen here while - // signed out is not cleared. - if (search.tabId) { - updateUiState({ tabId: search.tabId }, { sync: false }); - } - // Nothing to insert into, so the app cannot do its one job here. - if (isReadOnlyInstance(search)) { - throw redirect({ to: "/version-error", replace: true }); - } - if (isLaunch(search)) { - throw redirect({ - to: location.pathname, - search: strippedOfLaunch(search), - replace: true - }); - } - }, + beforeLoad: ({ search }) => adoptUrl(search), errorComponent: RootAppError }); -function isLaunch(search: LaunchSearch): boolean { - return ( - ENTRY_KEYS.some((key) => search[key] !== undefined) || - LAUNCH_KEYS.some((key) => search[key] !== undefined) - ); -} +// Once per load: after that the store is the source of truth, and in-app +// navigation drops everything but the app's own params from the url. +let adopted = false; -/** - * The url with the launch taken out. Undefined rather than absent: - * `retainSearchParams` reads a missing key as one it should put back. - */ -function strippedOfLaunch(search: LaunchSearch & AppParams): AppParams { - const cleared = Object.fromEntries( - [...LAUNCH_KEYS, ...ENTRY_KEYS].map((key) => [key, undefined]) - ); - return { ...search, ...cleared }; +function adoptUrl(search: OnshapeLaunch & AppParams): void { + if (adopted) { + return; + } + adopted = true; + adoptAppParams(search); + adoptOnshapeLaunch(search); } function App() { - // The navbar (control row + always-open filters) is self-sizing, so measure - // it and feed its height to AppShell rather than hardcoding one. const { ref: headerRef, height: headerHeight } = useElementSize(); useMessageListener(); + usePushSync(); return ( diff --git a/src/frontend/routes/dashboard/index.tsx b/src/frontend/routes/dashboard/index.tsx index b2c11caeb..e8692a9b5 100644 --- a/src/frontend/routes/dashboard/index.tsx +++ b/src/frontend/routes/dashboard/index.tsx @@ -3,7 +3,6 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { type ReactNode } from "react"; import { LibraryId } from "@backend/features/library/library-id"; -import type { PartUsageOut } from "@backend/features/analytics/contract"; import { type DayRange } from "@backend/features/analytics/day"; import { getOverviewQuery, @@ -14,11 +13,10 @@ import { InsertsByLibraryCard } from "../../features/dashboard/inserts-chart"; import { InsertSourceBreakdown } from "../../features/dashboard/insert-mix"; import { RangePreset, toDayRange } from "../../features/dashboard/range"; import { RecentSection } from "../../features/dashboard/growth-section"; -import { LifetimeTiles } from "../../features/dashboard/lifetime-tiles"; +import { HeadlineTiles } from "../../features/dashboard/headline-tiles"; import { METRICS } from "../../features/dashboard/metrics"; import { Section } from "../../components/section"; import { UsageTreemap } from "../../features/dashboard/usage-treemap"; -import { type UsagePart } from "../../features/dashboard/treemap-data"; import { TrendTile } from "../../features/dashboard/trend-tile"; export const Route = createFileRoute("/dashboard/")({ @@ -34,18 +32,8 @@ function useAllParts(range: DayRange) { }); } -/** Tags a library's parts with which library they came from. */ -function taggedParts( - query: { data?: PartUsageOut[] }, - index: number -): UsagePart[] { - const libraryId = Object.values(LibraryId)[index]; - return (query.data ?? []).map((part) => ({ ...part, libraryId })); -} - function DashboardOverview(): ReactNode { - // No range picker: each section names the window it reports, which is how - // one page mixes a trailing month, a season and all time. + // No range picker: each section names its own window. const range = toDayRange(RangePreset.ALL); const query = useQuery(getOverviewQuery(range)); const allParts = useAllParts(range); @@ -58,7 +46,7 @@ function DashboardOverview(): ReactNode { return (

-
- + @@ -94,7 +82,9 @@ function DashboardOverview(): ReactNode {
{allParts.every((query) => query.data) ? ( - + query.data ?? [])} + /> ) : ( )} diff --git a/src/frontend/routes/dashboard/library/$libraryId/index.tsx b/src/frontend/routes/dashboard/library/$libraryId/index.tsx index d2e677f9a..a87415a29 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/index.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/index.tsx @@ -22,7 +22,7 @@ import { getLibraryName } from "../../../../lib/library"; import { useCacheVersion } from "../../../../features/library/queries"; import { useRangePreset } from "../../../../features/dashboard/range-control"; import { UsageTreemap } from "../../../../features/dashboard/usage-treemap"; -import { LifetimeTiles } from "../../../../features/dashboard/lifetime-tiles"; +import { HeadlineTiles } from "../../../../features/dashboard/headline-tiles"; import { SectionCard } from "../../../../components/section"; export const Route = createFileRoute("/dashboard/library/$libraryId/")({ @@ -74,7 +74,7 @@ function LibraryBody({ return ( <> - {parts.data ? ( - // Sorted most used first, so the head of the distribution - // is the first page and the zeroes are pages away. + } + leftSection={} value={search} onChange={(event) => setSearch(event.currentTarget.value)} /> @@ -156,19 +150,16 @@ interface PartTitleProps { /** The part's name, linked into Onshape like a part number is to its vendor. */ function PartTitle({ report }: PartTitleProps): ReactNode { + const origin = useOnshapeOrigin(); return ( - <Anchor + <ExternalLink inherit - href={makeUrl(report.path)} - target="_blank" - rel="noreferrer" - // Centres the icon on the text rather than on its baseline. - style={{ display: "inline-flex", alignItems: "center", gap: 6 }} + href={makeUrl(origin, report.path)} + iconSize={IconSize.MEDIUM} > {report.name} - <ArrowSquareOutIcon size={IconSize.MEDIUM} /> - </Anchor> + </ExternalLink> ); } @@ -180,8 +171,8 @@ interface SummaryCardProps { function SummaryCard({ label, value }: SummaryCardProps): ReactNode { return ( - - + + {label} {value} diff --git a/src/frontend/routes/dashboard/library/$libraryId/route.tsx b/src/frontend/routes/dashboard/library/$libraryId/route.tsx index 4b4e70d0a..625827775 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/route.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/route.tsx @@ -10,8 +10,7 @@ export const Route = createFileRoute("/dashboard/library/$libraryId")({ parse: ({ libraryId }) => ({ libraryId: parseLibraryId(libraryId) }), stringify: ({ libraryId }) => ({ libraryId }) }, - // Awaited as the app's library route awaits it: what is keyed on the version - // must not fetch once at zero and again at the real one. + // Awaited, so version-keyed queries don't fetch at zero first. loader: ({ params }) => queryClient.ensureQueryData(getLibraryVersionQuery(params.libraryId)) }); diff --git a/src/frontend/routes/dashboard/library/$libraryId/unused.tsx b/src/frontend/routes/dashboard/library/$libraryId/unused.tsx index 455fd6bd0..7b18ed1ca 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/unused.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/unused.tsx @@ -32,7 +32,7 @@ function LowUsage(): ReactNode { return ( - + Low-usage parts @@ -47,7 +47,7 @@ function LowUsage(): ReactNode { )} - + Low-usage configuration options diff --git a/src/frontend/routes/dashboard/route.tsx b/src/frontend/routes/dashboard/route.tsx index c8a06585b..2d1694c21 100644 --- a/src/frontend/routes/dashboard/route.tsx +++ b/src/frontend/routes/dashboard/route.tsx @@ -10,8 +10,7 @@ import { DashboardNavbar } from "../../features/dashboard/dashboard-navbar"; import { RangePreset } from "../../features/dashboard/range"; import { parseSearch } from "../../lib/search-params"; -// Every field is caught rather than required: a hand-edited url should drop the -// bad param, not fail the whole route. +// Caught, so a hand-edited url drops the bad param instead of failing. const DashboardSearchType = z.object({ /** Preset window for the range chart; kept in the URL so views are shareable. */ range: z.enum(RangePreset).optional().catch(undefined), @@ -30,10 +29,7 @@ export const Route = createFileRoute("/dashboard")({ } }); -/** - * The dashboard is a sibling of `/app`, so it inherits none of the Onshape - * panel's shell or authed loaders — it is a full-screen public page. - */ +/** A sibling of `/app`, so it's a public full-screen page without the panel's shell. */ function DashboardLayout(): ReactNode { return ( <> diff --git a/src/frontend/routes/index.tsx b/src/frontend/routes/index.tsx index d47fd922c..99f36ac0d 100644 --- a/src/frontend/routes/index.tsx +++ b/src/frontend/routes/index.tsx @@ -1,21 +1,22 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { DEFAULT_LIBRARY } from "@backend/features/library/library-id"; -import { getTabPath, isLibraryTab } from "@backend/features/settings/app-tab"; -import { getUiState, updateUiState } from "../lib/ui-state"; -import { showSuccessToast } from "../lib/notifications"; +import { getTabPath, isLibraryTab } from "../lib/app-tab"; +import { apiPost } from "../lib/api-client"; +import { toLibraryPath } from "../lib/api-paths"; +import { getUiState } from "../lib/ui-state"; import { RootAppError } from "../components/root-error"; -// Direct entry from outside Onshape, and where signing in returns to; Onshape's -// own launch is served before this route. +// Entry, from Onshape's /init or opened directly: resumes the last tab and group. export const Route = createFileRoute("/")({ beforeLoad: ({ search }) => { - const { tabId, groupId, justSignedIn } = getUiState(); + const { tabId, groupId } = getUiState(); const tab = tabId ?? DEFAULT_LIBRARY; - if (justSignedIn) { - updateUiState({ justSignedIn: false }); - // Onshape only sends the caller back here on success, so arriving - // with the flag set is the confirmation. - showSuccessToast("Signed in to Onshape."); + // Only launches from Onshape count as opens, and only in a library. + if ("documentId" in search && isLibraryTab(tab)) { + // Signed out is refused, and there's no open to count. + void apiPost("/app-open" + toLibraryPath(tab)).catch( + () => undefined + ); } // Whatever Onshape launched with rides along; only the path is ours. if (!isLibraryTab(tab)) { diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index eb2f08776..fd8de16d2 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -1,11 +1,25 @@ -import { createTheme, type MantineColorsTuple } from "@mantine/core"; +import { + ActionIcon, + Badge, + Button, + Card, + createTheme, + Group, + HoverCard, + Input, + type MantineColorsTuple, + Menu, + Modal, + Popover, + rem, + Table, + Text, + Tooltip +} from "@mantine/core"; import { LibraryId } from "@backend/features/library/library-id"; -import { FILLED_SHADE } from "./lib/style-constants"; +import { FILLED_SHADE, IconSize, StatusColor } from "./lib/style-constants"; -/** - * FRCDesign brand green ramp (index 6 = #4cae4f, the brand color). - * Generate replacements with https://mantine.dev/colors-generator if tuning. - */ +/** Index 6 is the brand color; https://mantine.dev/colors-generator to tune. */ const frcGreen: MantineColorsTuple = [ "#eef9ee", "#dcf1dc", @@ -19,11 +33,7 @@ const frcGreen: MantineColorsTuple = [ "#236b28" ]; -/** - * Falls back rather than throwing: the root themes the app even when the url - * names a library that does not exist — which the route 404s separately — or - * a tab that is not a library, which has no library color of its own. - */ +/** Falls back for unknown libraries and non-library tabs. */ export function getLibraryColor(libraryId: string): string { switch (libraryId) { case LibraryId.FTC_DESIGN_LIB: @@ -35,12 +45,20 @@ export function getLibraryColor(libraryId: string): string { } } -/** A library's color as Mantine's `color.shade`, for a chart series, a tile, - * or text that should read as the library. */ export function getLibraryShade(libraryId: string): string { return `${getLibraryColor(libraryId)}.${FILLED_SHADE}`; } +// Phosphor icons default to 1em, so an icon in a section takes this size unless it sets its own. +const ICON_SECTION = { fontSize: rem(IconSize.SMALL) }; + +const FLOATING = { + shadow: "md", + withArrow: true, + // So a card beside a row on a phone is pushed on screen, not cut off. + middlewares: { flip: true, shift: { crossAxis: true, padding: 8 } } +}; + /** The frame stays neutral; a library's color is an accent on its controls. */ export function createAppTheme(libraryId: string) { return createTheme({ @@ -50,8 +68,42 @@ export function createAppTheme(libraryId: string) { // Mantine's "md" default reads soft for a dense CAD panel. defaultRadius: "sm", cursorType: "pointer", - // Drops the class carrying Mantine's 1px press-down translate, which - // nudged every button and icon button down on click. - activeClassName: "" + // Drops Mantine's 1px press-down nudge. + activeClassName: "", + components: { + Tooltip: Tooltip.extend({ + defaultProps: { + withArrow: true, + multiline: true, + maw: 260, + // Touch is off by default, leaving touchscreens no way to read one. + events: { hover: true, focus: true, touch: true } + } + }), + Card: Card.extend({ + defaultProps: { withBorder: true, padding: "lg", radius: "md" } + }), + Text: Text.extend({ defaultProps: { size: "sm" } }), + Group: Group.extend({ defaultProps: { wrap: "nowrap" } }), + Button: Button.extend({ + defaultProps: { variant: "light" }, + styles: { section: ICON_SECTION } + }), + ActionIcon: ActionIcon.extend({ + defaultProps: { variant: "subtle", color: StatusColor.NEUTRAL } + }), + Badge: Badge.extend({ + defaultProps: { variant: "light", size: "sm" } + }), + Menu: Menu.extend({ + defaultProps: { shadow: "md" }, + styles: { itemSection: ICON_SECTION } + }), + Popover: Popover.extend({ defaultProps: FLOATING }), + HoverCard: HoverCard.extend({ defaultProps: FLOATING }), + Input: Input.extend({ styles: { section: ICON_SECTION } }), + Modal: Modal.extend({ defaultProps: { centered: true } }), + Table: Table.extend({ defaultProps: { highlightOnHover: true } }) + } }); } diff --git a/tsconfig.test.json b/tsconfig.test.json index 0ebe625ff..0d43e2703 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -16,6 +16,7 @@ "types": [ "vitest/globals", "node", + "vite/client", "@cloudflare/vitest-pool-workers/types", "./worker-configuration.d.ts" ], diff --git a/vite.config.ts b/vite.config.ts index 1c9d267d4..0fcd6038b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,21 +1,10 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; +import { unstable_readConfig } from "wrangler"; import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; -import { existsSync, readFileSync } from "fs"; import { fileURLToPath } from "url"; -// Only enable https when localhost exists -const httpsKeyPath = "localhost-key.pem"; -const httpsCertPath = "localhost.pem"; -const httpsDevServer = - existsSync(httpsKeyPath) && existsSync(httpsCertPath) - ? { - key: readFileSync(httpsKeyPath), - cert: readFileSync(httpsCertPath) - } - : undefined; - const srcPath = (dir: string) => fileURLToPath(new URL(`./src/${dir}`, import.meta.url)); @@ -26,7 +15,7 @@ export const alias = { }; // https://vite.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => ({ resolve: { alias }, plugins: [ tanstackRouter({ @@ -40,8 +29,20 @@ export default defineConfig({ cloudflare() ], server: { - https: httpsDevServer, port: 3000, - strictPort: true + strictPort: true, + // The dev tunnel's host, which Vite otherwise turns away. + allowedHosts: [new URL(devAppUrl(mode)).hostname] + } +})); + +/** `.env` overrides the dev var, as it does for the Worker. */ +function devAppUrl(mode: string): string { + const appUrl = + loadEnv(mode, process.cwd(), "").APP_URL ?? + unstable_readConfig({ config: "wrangler.jsonc" }).vars.APP_URL; + if (typeof appUrl !== "string") { + throw new Error("Set APP_URL in wrangler.jsonc's vars"); } -}); + return appUrl; +} diff --git a/vitest.config.ts b/vitest.config.ts index d88075f64..2f9295a31 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,21 +9,37 @@ import { alias } from "./vite.config"; // test Worker would make FORCE_SIGNED_IN rewrite what the auth tests assert. process.env.CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV = "false"; +/** + * Only tests that need bindings pay for a Workers runtime and a migrated D1 + * per file, which costs far more than the tests themselves; they say so by + * their name. Everything else runs in Node. + */ +const WORKER_TESTS = "src/backend/**/*.worker.test.ts"; + export default defineConfig({ test: { projects: [ { resolve: { alias }, - // Frontend logic needs no bindings, so it runs in a fast Node environment. test: { name: "node", environment: "node", - include: ["src/frontend/**/*.test.ts"] + include: ["src/**/*.test.ts"], + exclude: [WORKER_TESTS] + } + }, + { + // Components, rendered into a DOM the way the app renders them. + resolve: { alias }, + test: { + name: "dom", + environment: "jsdom", + include: ["src/frontend/**/*.test.tsx"], + setupFiles: ["./src/__test_utils__/dom-setup.ts"] } }, { - // Backend tests run in the Workers runtime with real, per-test - // isolated D1/R2/KV bindings from wrangler.jsonc. + // Real, per-test isolated D1/R2/KV bindings from wrangler.jsonc. resolve: { alias }, plugins: [ cloudflareTest(async () => { @@ -39,7 +55,7 @@ export default defineConfig({ ], test: { name: "backend", - include: ["src/backend/**/*.test.ts"], + include: [WORKER_TESTS], setupFiles: ["./src/__test_utils__/apply-migrations.ts"] } } diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 60b2acf65..1bebc0e6a 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,61 +1,61 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: cb3ebfb19ff7900e177f197869b3908e) +// Generated by Wrangler by running `wrangler types` (hash: 11adf3a92ec427099f88e645e61af8af) // Runtime types generated with workerd@1.20260811.1 2026-05-14 nodejs_compat interface __BaseEnv_Env { KV: KVNamespace; BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; - ADMIN_TEAM: "6a62e6efcc21741bea57362c" | "5b620150b2190f0fca90ec10"; + OWNER_USER_ID: "5eace32713a966103efd2aa0"; + APP_URL: "https://dev.frcdesign.org"; NODE_ENV: "production" | "development"; API_ACCESS_KEY: string; API_SECRET_KEY: string; OAUTH_CLIENT_ID: string; OAUTH_CLIENT_SECRET: string; - SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - THUMBNAIL_RENDERER: DurableObjectNamespace; - LOAD_LIBRARY_WORKFLOW: Workflow[0]['payload']>; - ADD_GROUP_WORKFLOW: Workflow[0]['payload']>; + PUSH_HUB: DurableObjectNamespace; + LOAD_DOCUMENT_WORKFLOW: Workflow[0]['payload']>; + RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/backend/index"); - durableNamespaces: "ThumbnailRenderer"; + durableNamespaces: "PushHub"; } interface CertEnv { KV: KVNamespace; BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; - ADMIN_TEAM: "6a62e6efcc21741bea57362c"; + OWNER_USER_ID: "5eace32713a966103efd2aa0"; + APP_URL: "https://frc-design-app-cert.frcdesign-org.workers.dev"; NODE_ENV: "production"; API_ACCESS_KEY: string; API_SECRET_KEY: string; OAUTH_CLIENT_ID: string; OAUTH_CLIENT_SECRET: string; - SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - THUMBNAIL_RENDERER: DurableObjectNamespace; - LOAD_LIBRARY_WORKFLOW: Workflow[0]['payload']>; - ADD_GROUP_WORKFLOW: Workflow[0]['payload']>; + PUSH_HUB: DurableObjectNamespace; + LOAD_DOCUMENT_WORKFLOW: Workflow[0]['payload']>; + RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } interface ProductionEnv { KV: KVNamespace; BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; - ADMIN_TEAM: "5b620150b2190f0fca90ec10"; + OWNER_USER_ID: "5eace32713a966103efd2aa0"; + APP_URL: "https://app.frcdesign.org"; NODE_ENV: "production"; API_ACCESS_KEY: string; API_SECRET_KEY: string; OAUTH_CLIENT_ID: string; OAUTH_CLIENT_SECRET: string; - SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - THUMBNAIL_RENDERER: DurableObjectNamespace; - LOAD_LIBRARY_WORKFLOW: Workflow[0]['payload']>; - ADD_GROUP_WORKFLOW: Workflow[0]['payload']>; + PUSH_HUB: DurableObjectNamespace; + LOAD_DOCUMENT_WORKFLOW: Workflow[0]['payload']>; + RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } interface Env extends __BaseEnv_Env {} } @@ -64,7 +64,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index 3d2e6b85e..091f7a95f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -54,35 +54,30 @@ ], "workflows": [ { - "name": "load-library-workflow", - "binding": "LOAD_LIBRARY_WORKFLOW", - "class_name": "LoadLibraryWorkflow" + "name": "load-document-workflow", + "binding": "LOAD_DOCUMENT_WORKFLOW", + "class_name": "LoadDocumentWorkflow" }, { - "name": "add-group-workflow", - "binding": "ADD_GROUP_WORKFLOW", - "class_name": "AddGroupWorkflow" + "name": "render-thumbnail-workflow", + "binding": "RENDER_THUMBNAIL_WORKFLOW", + "class_name": "RenderThumbnailWorkflow" } ], - /** - * One renderer per Onshape user. Onshape appears to run at most one - * thumbnail render per user at a time, so every thumbnail fetch queues here - * and the object is what makes them one at a time. - */ + // Inherited by every environment. v2 swaps the thumbnail render queue, + // replaced by RenderThumbnailWorkflow, for the push hub. "durable_objects": { - "bindings": [ - { - "name": "THUMBNAIL_RENDERER", - "class_name": "ThumbnailRenderer" - } - ] + "bindings": [{ "name": "PUSH_HUB", "class_name": "PushHub" }] }, - // Inherited by every environment, which is what we want: each gets its own - // objects under the same class. "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ThumbnailRenderer"] + }, + { + "tag": "v2", + "deleted_classes": ["ThumbnailRenderer"], + "new_sqlite_classes": ["PushHub"] } ], /** @@ -92,9 +87,13 @@ * https://developers.cloudflare.com/workers/configuration/secrets/ */ "vars": { - // Onshape team ID used to determine editor/admin access level in production - "ADMIN_TEAM": "5b620150b2190f0fca90ec10", - "NODE_ENV": "development" + // The Onshape user granted the owner access level, whose session + // webhook-triggered loads run under. + "OWNER_USER_ID": "5eace32713a966103efd2aa0", + "NODE_ENV": "development", + // Where the app is served: OAuth's redirect and every webhook point here. + // The dev tunnel's; a developer on their own tunnel overrides it in `.env`. + "APP_URL": "https://dev.frcdesign.org" }, /** * Service Bindings (communicate between multiple Workers) @@ -131,30 +130,26 @@ ], "workflows": [ { - "name": "load-library-workflow-cert", - "binding": "LOAD_LIBRARY_WORKFLOW", - "class_name": "LoadLibraryWorkflow" + "name": "load-document-workflow-cert", + "binding": "LOAD_DOCUMENT_WORKFLOW", + "class_name": "LoadDocumentWorkflow" }, { - "name": "add-group-workflow-cert", - "binding": "ADD_GROUP_WORKFLOW", - "class_name": "AddGroupWorkflow" + "name": "render-thumbnail-workflow-cert", + "binding": "RENDER_THUMBNAIL_WORKFLOW", + "class_name": "RenderThumbnailWorkflow" } ], "durable_objects": { - "bindings": [ - { - "name": "THUMBNAIL_RENDERER", - "class_name": "ThumbnailRenderer" - } - ] + "bindings": [{ "name": "PUSH_HUB", "class_name": "PushHub" }] }, "vars": { - "ADMIN_TEAM": "6a62e6efcc21741bea57362c", + "OWNER_USER_ID": "5eace32713a966103efd2aa0", // Production, not because cert is production, but because // FORCE_SIGNED_IN and VITE_ACCESS_LEVEL_OVERRIDE are gated on // this alone: anything but "production" leaves them armed. - "NODE_ENV": "production" + "NODE_ENV": "production", + "APP_URL": "https://frc-design-app-cert.frcdesign-org.workers.dev" }, "limits": { "subrequests": 1000 @@ -186,27 +181,23 @@ ], "workflows": [ { - "name": "load-library-workflow-prod", - "binding": "LOAD_LIBRARY_WORKFLOW", - "class_name": "LoadLibraryWorkflow" + "name": "load-document-workflow-prod", + "binding": "LOAD_DOCUMENT_WORKFLOW", + "class_name": "LoadDocumentWorkflow" }, { - "name": "add-group-workflow-prod", - "binding": "ADD_GROUP_WORKFLOW", - "class_name": "AddGroupWorkflow" + "name": "render-thumbnail-workflow-prod", + "binding": "RENDER_THUMBNAIL_WORKFLOW", + "class_name": "RenderThumbnailWorkflow" } ], "durable_objects": { - "bindings": [ - { - "name": "THUMBNAIL_RENDERER", - "class_name": "ThumbnailRenderer" - } - ] + "bindings": [{ "name": "PUSH_HUB", "class_name": "PushHub" }] }, "vars": { - "ADMIN_TEAM": "5b620150b2190f0fca90ec10", - "NODE_ENV": "production" + "OWNER_USER_ID": "5eace32713a966103efd2aa0", + "NODE_ENV": "production", + "APP_URL": "https://app.frcdesign.org" }, "limits": { "subrequests": 100000