diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..b0d436e90 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist +# Regenerated by the TanStack Router plugin, which owns its formatting. +src/frontend/routeTree.gen.ts +# Generated by `wrangler types` and `@hey-api/openapi-ts`. +worker-configuration.d.ts +onshape-api-reference diff --git a/AGENTS.md b/AGENTS.md index 7ffe8fda2..08aa63da2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ ## Comments -Keep comments short. Prefer one line; three or more long lines is a smell, and -four is out. Explain _why_ something is done, not _what_ the code does — the what +Keep comments short. Prefer one line, with two lines as an absolute maximum. +Explain _why_ something is done, not _what_ the code does — the what should be inferable from the code. Don't restate a function's behavior in its doc comment when the signature already says it (e.g. write "returns the access -level, respecting the cache" — not a paragraph re-deriving the caching). Delete -comments that narrate obvious implementation details. +level, respecting the cache" — not a paragraph re-deriving the caching). +Aggressively delete comments that narrate obvious implementation details. # Cloudflare Workers diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index e36470bdb..c531099d9 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -39,19 +39,50 @@ KV is a key-value store (like a global dictionary). The app uses it exclusively 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. -### R2 — Thumbnail Storage (`c.env.THUMBNAILS`) +### R2 — Blob Storage (`c.env.BLOB`) -R2 is Cloudflare's blob storage, optimized for unstructured data like images and PDFs. The app uses it to store and cache thumbnails in order to improve reliability. +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: -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. Instead, we fetch a thumbnail from Onshape the first time it's needed, store it in R2, and serve it from R2 on all subsequent requests. Thumbnails are served via `/api/thumbnail/:size/:elementId`. +| Prefix | What it holds | Lifetime | +| --------------- | ------------------------------------------------- | -------------------------------- | +| `thumbnails/` | Rendered thumbnails, by element and configuration | See below | +| `search-index/` | Each library's serialized MiniSearch index | Rewritten on every index rebuild | -### Workflows — Document Sync (`c.env.LOAD_DOCUMENT_WORKFLOW`) +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. -Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. +Thumbnails are keyed by whether they are the element's default or a specific configuration: -When a user adds a new group (a new Onshape document) to the library, the app needs to walk the entire document structure, download metadata for every part and assembly, generate thumbnails, and write everything to D1. This can take many seconds — too long to do in a single HTTP request without timing out. +``` +thumbnails/default/{elementId}/{microversionId}/{size} # never expires +thumbnails/config/{elementId}/{microversionId}/{configKey}/{size} # ~90 day lifecycle rule +``` -The `LoadDocumentWorkflow` class (defined in `src/backend/parse/load-document.ts`) handles this process as a background job. The HTTP request just kicks it off and returns immediately; the workflow runs to completion independently. +The default is what everything else falls back to, so it must never be reclaimed. Configuration thumbnails expire under an R2 **lifecycle rule on the `config/` prefix**, which is configured on the bucket through the dashboard or API — it is not expressible in `wrangler.jsonc`, and it has to be set up before deploying. Per-prefix rules are what let `thumbnails/default/` and `search-index/` live in the same bucket without expiring. + +`{configKey}` is a short hash of the _canonical_ configuration — the one spelling every equivalent selection shares, with hidden and default-valued parameters dropped and quantities expressed in meters and radians. That is what makes two equivalent selections resolve to one cached image. Including `{microversionId}` makes every object immutable, so an updated document lands on new keys rather than overwriting in place. + +Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&c={canonicalConfiguration}&warm={bool}`: + +- **Hit** — streamed from R2 as immutable, cacheable for a year. +- **Miss** — the element's default is served instead, for 60 seconds only, with an `X-Thumbnail-Fallback` header so the client knows to keep checking. An immutable fallback would pin the wrong image long after the real one landed. +- **Miss with `warm=true`** — the miss also starts a `ThumbnailWorkflow` to render the configuration. Surfaces where the user picked the configuration (the insert menu, favorites) warm; 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. + +### 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. All three are defined in `src/backend/load/workflows.ts`: + +| 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 | + +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. + +Each workflow carries the requesting user's `sessionId`, since it calls Onshape under their tokens after the request has ended. ### Assets — Static File Serving (`c.env.ASSETS`) @@ -94,13 +125,13 @@ Once the backend confirms authentication and serves the React app, the frontend ## 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/auth.ts` | -| **R2** | Part and group thumbnail images | Indefinite (30-day browser cache-control) | Backend Worker in `src/backend/routes/thumbnails.ts` | -| **localStorage** | UI state: open/closed panels, active search query, vendor filters, last-opened group | Persists across browser sessions | Frontend only, via `src/frontend/api-utils/ui-state.ts` | -| **sessionStorage** | Not used | — | — | +| 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/auth.ts` | +| **R2** | Thumbnail images and per-library search indexes | Defaults and indexes permanent; configuration thumbnails ~90 days | Backend Worker in `src/backend/routes/thumbnails.ts` and `src/backend/library-data.ts` | +| **localStorage** | UI state: open/closed panels, active search query, vendor filters, last-opened group | Persists across browser sessions | Frontend only, via `src/frontend/api-utils/ui-state.ts` | +| **sessionStorage** | Not used | — | — | ## Codebase Map @@ -125,7 +156,9 @@ The Cloudflare Worker. Key files and folders: - `library-data.ts` — assembles the full library response (groups + insertables + configurations) - `routes/` — endpoints callable by the frontend - `onshape-api/` — all code that communicates with Onshape's REST API (`onshape-api.ts` for the client class, `api-path.ts` for URL construction, `endpoints/` for per-category wrappers) -- `parse/` — `load-document.ts` runs the Cloudflare Workflow that syncs an Onshape document into D1 +- `load/` — the Workflows and what they run: `workflows.ts` defines all three, `load-group.ts` and `load-insertable.ts` do the work, `load-steps.ts` holds the retry policies, `job-tracker.ts` tracks what is running +- `parse/` — pure functions turning Onshape responses into what we store: configurations, configuration records, vendors, document contents, build checks +- `sign-in-utils.ts` / `access-level-utils.ts` — the two authorization gates: signed in to Onshape at all, versus on the admin team ### `src/frontend/` diff --git a/drizzle/0002_configuration_records.sql b/drizzle/0002_configuration_records.sql new file mode 100644 index 000000000..7f55956c1 --- /dev/null +++ b/drizzle/0002_configuration_records.sql @@ -0,0 +1,16 @@ +/* + The part-number search flag becomes a force-index override, and configurations + now store a rich record per probed configuration instead of a bare part-number + map. + + None of these change a column default or touch an index, so no table recreate is + needed: SQLite (and D1) support RENAME COLUMN, DROP COLUMN, and ADD COLUMN + directly. The renamed `force_index` keeps each insertable's existing flag value. + `part_numbers` held a part-number map keyed differently from the new + `ConfigurationRecord[]`, so there's nothing to carry over — it's dropped and + `records` starts empty, to be repopulated on the next load. +*/ +ALTER TABLE `insertables` RENAME COLUMN `search_part_numbers` TO `force_index`;--> statement-breakpoint +ALTER TABLE `insertables` DROP COLUMN `default_part_number`;--> statement-breakpoint +ALTER TABLE `configurations` DROP COLUMN `part_numbers`;--> statement-breakpoint +ALTER TABLE `configurations` ADD `records` text DEFAULT '[]' NOT NULL; diff --git a/drizzle/0003_drop_search_db.sql b/drizzle/0003_drop_search_db.sql new file mode 100644 index 000000000..638252e30 --- /dev/null +++ b/drizzle/0003_drop_search_db.sql @@ -0,0 +1 @@ +ALTER TABLE `libraries` DROP COLUMN `search_db`; \ No newline at end of file diff --git a/drizzle/0004_explicit_thumbnail_urls.sql b/drizzle/0004_explicit_thumbnail_urls.sql new file mode 100644 index 000000000..003ee5156 --- /dev/null +++ b/drizzle/0004_explicit_thumbnail_urls.sql @@ -0,0 +1,13 @@ +/* + The generic `thumbnail_urls` JSON map becomes one explicit column per stored + size. The two sizes also changed (70x40 + 300x300), and thumbnails now live + under a new R2 key scheme, so there is nothing worth carrying over: the columns + start null and repopulate on the next load, which is also when the new R2 + objects are written. +*/ +ALTER TABLE `groups` DROP COLUMN `thumbnail_urls`;--> statement-breakpoint +ALTER TABLE `groups` ADD `small_thumbnail_url` text;--> statement-breakpoint +ALTER TABLE `groups` ADD `large_thumbnail_url` text;--> statement-breakpoint +ALTER TABLE `insertables` DROP COLUMN `thumbnail_urls`;--> statement-breakpoint +ALTER TABLE `insertables` ADD `small_thumbnail_url` text;--> statement-breakpoint +ALTER TABLE `insertables` ADD `large_thumbnail_url` text; diff --git a/drizzle/0005_index_configurations.sql b/drizzle/0005_index_configurations.sql new file mode 100644 index 000000000..a9f6a218d --- /dev/null +++ b/drizzle/0005_index_configurations.sql @@ -0,0 +1,8 @@ +/* + `force_index` read as though it overrode every limit, when it only lifts an + insertable over the auto-index threshold — the hard configuration cap still + applies. `index_configurations` says what it actually turns on. + + A plain RENAME COLUMN: no default or index changes, so no table recreate. +*/ +ALTER TABLE `insertables` RENAME COLUMN `force_index` TO `index_configurations`; diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..309d8cbb4 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,524 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "12bac410-c574-43dc-87a6-b0d3daa16390", + "prevId": "572a782e-664b-48aa-bea0-2224b6f00dbd", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "id": { + "name": "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": "'[]'" + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_id_insertables_id_fk": { + "name": "configurations_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": [ + "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_configuration": { + "name": "default_configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "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 + }, + "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 + }, + "thumbnail_urls": { + "name": "thumbnail_urls", + "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 + }, + "force_index": { + "name": "force_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": "'[]'" + }, + "thumbnail_urls": { + "name": "thumbnail_urls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "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 + }, + "search_db": { + "name": "search_db", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'frc-design-lib'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..4b85e4eb8 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,482 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "05908424-941d-408c-a163-e1486489864c", + "prevId": "12bac410-c574-43dc-87a6-b0d3daa16390", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "id": { + "name": "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": "'[]'" + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_id_insertables_id_fk": { + "name": "configurations_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["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_configuration": { + "name": "default_configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "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 + }, + "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 + }, + "thumbnail_urls": { + "name": "thumbnail_urls", + "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 + }, + "force_index": { + "name": "force_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": "'[]'" + }, + "thumbnail_urls": { + "name": "thumbnail_urls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'frc-design-lib'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..29fcadb5f --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,496 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3fd3f529-bd75-4b9f-a0e8-a6fec0a48d79", + "prevId": "05908424-941d-408c-a163-e1486489864c", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "id": { + "name": "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": "'[]'" + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_id_insertables_id_fk": { + "name": "configurations_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["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_configuration": { + "name": "default_configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "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 + }, + "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 + }, + "force_index": { + "name": "force_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'frc-design-lib'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 000000000..5e5a0095c --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,496 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0005b1c2-3d4e-4f50-9a6b-7c8d9e0f1a2b", + "prevId": "3fd3f529-bd75-4b9f-a0e8-a6fec0a48d79", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "id": { + "name": "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": "'[]'" + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_id_insertables_id_fk": { + "name": "configurations_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["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_configuration": { + "name": "default_configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "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 + }, + "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 + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + }, + "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 + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'frc-design-lib'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9650307b5..9ea0bd3d8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,34 @@ "when": 1786201159430, "tag": "0001_sad_arclight", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1786201159431, + "tag": "0002_configuration_records", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1786511719906, + "tag": "0003_drop_search_db", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1786511719907, + "tag": "0004_explicit_thumbnail_urls", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1786511719908, + "tag": "0005_index_configurations", + "breakpoints": true } ] } diff --git a/package-lock.json b/package-lock.json index 2d9373925..e7a0fccba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2079,9 +2079,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2099,9 +2096,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2119,9 +2113,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2139,9 +2130,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2159,9 +2147,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2179,9 +2164,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2199,9 +2181,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2219,9 +2198,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2239,9 +2215,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2265,9 +2238,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2291,9 +2261,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2317,9 +2284,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2343,9 +2307,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2369,9 +2330,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2395,9 +2353,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2421,9 +2376,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ diff --git a/src/__test_utils__/apply-migrations.ts b/src/__test_utils__/apply-migrations.ts index 216fd0157..81728d641 100644 --- a/src/__test_utils__/apply-migrations.ts +++ b/src/__test_utils__/apply-migrations.ts @@ -1,8 +1,6 @@ import { applyD1Migrations } from "cloudflare:test"; import { env } from "cloudflare:workers"; -// Setup files run outside per-test-file storage isolation and may run multiple -// times. `applyD1Migrations()` only applies migrations that haven't been applied -// yet, so it is safe to call here. `TEST_MIGRATIONS` is supplied as a test-only -// binding from vitest.config.ts. +// Setup files may run more than once; applyD1Migrations only applies what is +// missing, so calling it here 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 690c04f67..db18da3af 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -1,16 +1,15 @@ /** - * Configuration-parameter builders for tests. - * - * Import this module directly rather than through `__test_utils__/index.ts`. - * `src/shared` tests run in vitest's `node` project (see vitest.config.ts), and - * the barrel re-exports `test-app.ts`, which reaches `cloudflare:workers` — - * unresolvable outside the Workers pool. + * Import directly, not through `__test_utils__/index.ts`: the barrel reaches + * `cloudflare:workers`, which `src/shared`'s node-project tests cannot resolve. */ import { ParameterType, type BooleanParameter, - type EnumParameter + type EnumParameter, + type QuantityParameter, + type UnitInfo } from "../shared/configuration-models"; +import { QuantityType, Unit } from "../shared/configuration-enums"; /** Builds an enum parameter whose options are named after their ids. */ export function enumParam( @@ -42,3 +41,32 @@ export function boolParam(id: string): BooleanParameter { type: ParameterType.BOOLEAN }; } + +/** Builds a length quantity parameter, defaulting to `1 in`. */ +export function quantityParam( + id: string, + extra: Partial = {} +): QuantityParameter { + return { + id, + name: id, + default: "1 in", + isCosmetic: false, + type: ParameterType.QUANTITY, + quantityType: QuantityType.LENGTH, + defaultValue: 1, + min: 0, + max: 100, + unit: Unit.INCH, + ...extra + }; +} + +/** Document units: inches to 4 decimals, degrees to 3. */ +export const TEST_UNIT_INFO: UnitInfo = { + angleUnit: Unit.DEGREE, + lengthUnit: Unit.INCH, + lengthPrecision: 4, + anglePrecision: 3, + realPrecision: 3 +}; diff --git a/src/__test_utils__/fake-step.ts b/src/__test_utils__/fake-step.ts index 1ca289b5d..50e494ead 100644 --- a/src/__test_utils__/fake-step.ts +++ b/src/__test_utils__/fake-step.ts @@ -1,9 +1,8 @@ import type { WorkflowStep } from "cloudflare:workers"; /** - * A `WorkflowStep` that runs each step inline, so the load functions can be - * exercised without a real workflow instance. Durability and retries are - * Cloudflare's concern; these tests are about the orchestration. + * 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'. */ export const FAKE_STEP = { do: (_name: string, optionsOrFn: unknown, maybeFn?: unknown) => { diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index 811fd460f..717c70827 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -1,10 +1,6 @@ /** - * Plain-object factories for the load pipeline's insertable shapes, so a test can - * build a target or a parsed result and override only the fields under test. - * - * Import this module directly rather than through `__test_utils__/index.ts`: it - * reaches into the backend load modules, and the barrel also re-exports the - * Workers-only test-app helpers. + * Factories for the load pipeline's insertable shapes. Import directly: the + * barrel re-exports Workers-only helpers these tests cannot resolve. */ import type { InsertableTarget } from "../backend/load/load-common"; import type { ParsedInsertable } from "../backend/load/load-insertable"; @@ -41,10 +37,9 @@ export function parsedInsertable( vendors: [], thumbnailUrls: null, fastenInfo: null, - defaultPartNumber: null, isOpenComposite: false, buildIssues: [], - configuration: { parameters: [], partNumbers: {} }, + configuration: { parameters: [], records: [] }, ...overrides }; } diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index faaf34d84..72630ba0d 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -46,12 +46,8 @@ export const TEST_PARAMETERS: ConfigurationParameter[] = [ ]; /** - * Truncates every table these helpers touch, in FK-safe order. - * - * `@cloudflare/vitest-pool-workers` isolates D1 storage per test *file*, not per - * test, and there is no built-in per-test reset (`reset()` from `cloudflare:test` - * only clears Durable Objects). Call this in `beforeEach` to isolate tests that - * share a database. + * 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. */ export async function resetDb(db: Db): Promise { await db.delete(favorites); @@ -66,10 +62,7 @@ export async function seedLibrary( db: Db, id: LibraryId = TEST_LIBRARY_ID ): Promise { - await db - .insert(libraries) - .values({ id, searchDb: "{ fake-search-db: true }" }) - .onConflictDoNothing(); + await db.insert(libraries).values({ id }).onConflictDoNothing(); return id; } diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index c97ebd285..a2b0e4f62 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -19,9 +19,8 @@ export interface TestAppOptions { } /** - * Builds the real Hono app via `createApp`, but with the Onshape API, userId, and - * access level injected as mocks. Returns a plain Hono app — drive it with - * `app.request(path, init, env)` (pass `env` from `cloudflare:workers`). + * The real app from `createApp`, with Onshape, userId and access level mocked. + * Drive it with `app.request(path, init, env)`. */ export function createTestApp(options: TestAppOptions = {}) { const signedIn = options.signedIn ?? true; diff --git a/src/backend/app.ts b/src/backend/app.ts index e15d6a675..4ba4af115 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,5 +1,9 @@ import { type Context, type MiddlewareHandler, Hono } from "hono"; -import type { AddGroupParams, LoadLibraryParams } from "./load/workflows"; +import type { + AddGroupParams, + LoadLibraryParams, + ThumbnailWorkflowParams +} from "./load/workflows"; import { LibraryId, type AccessLevel } from "../shared/types"; import { type OAuthApi } from "./onshape-api/onshape-api"; import z from "zod"; @@ -10,9 +14,12 @@ export interface AppBindings { DB: D1Database; KV: KVNamespace; ASSETS: Fetcher; - THUMBNAILS: R2Bucket; + /** Thumbnails and search indexes; prefixes keep them apart. */ + BLOB: R2Bucket; LOAD_LIBRARY_WORKFLOW: Workflow; ADD_GROUP_WORKFLOW: Workflow; + /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ + THUMBNAIL_WORKFLOW: Workflow; ADMIN_TEAM: string; ACCESS_LEVEL_OVERRIDE?: string; /** Testing-only: treat requests as signed in with a fake user. Not for production. */ @@ -24,6 +31,8 @@ interface AppVariables { onshapeApi?: OAuthApi; /** Internal cache for isSignedIn in sign-in-utils.ts. */ signedIn?: boolean; + /** Set by {@link setCacheTtl}; read by {@link cacheMiddleware}. */ + cacheTtl?: number; /** Injected getters — see {@link AppServices} / `createApp`. */ getOnshapeApi: () => Promise; getUserId: () => Promise; @@ -96,6 +105,11 @@ interface CacheOptions { versioned?: boolean; } +/** Overrides the route's immutable default for a body its url does not pin. */ +export function setCacheTtl(c: AppContext, maxAge: number): void { + c.set("cacheTtl", maxAge); +} + /** Declares how a route's response may be cached, and enforces what that takes. */ export function cacheMiddleware( policy: CachePolicy = CachePolicy.NO_CACHE, @@ -119,7 +133,15 @@ export function cacheMiddleware( } await next(); // A miss must stay retryable, so only store what succeeded. - c.header("Cache-Control", c.res.ok ? cacheControl : NO_STORE); + if (!c.res.ok) { + c.header("Cache-Control", NO_STORE); + return; + } + const ttl = c.get("cacheTtl"); + c.header( + "Cache-Control", + ttl === undefined ? cacheControl : `${policy}, max-age=${ttl}` + ); }; } diff --git a/src/backend/create-app.ts b/src/backend/create-app.ts index 101a2da83..409d58405 100644 --- a/src/backend/create-app.ts +++ b/src/backend/create-app.ts @@ -52,9 +52,8 @@ async function getEntryUrl(c: AppContext): Promise { } /** - * Composition root for the Hono app. The injected `makeServices` factory is - * bound onto each request's context so handlers can call `c.var.getOnshapeApi()`, - * `c.var.getUserId()`, and `c.var.getAccessLevel()` directly. + * Composition root. `makeServices` is bound onto each request's context, so + * handlers reach Onshape and access level through `c.var`. */ export function createApp(makeServices: AppServicesFactory) { const app = getApp(); diff --git a/src/backend/index.ts b/src/backend/index.ts index 33089ed8c..3f69df78f 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,4 +1,8 @@ -export { AddGroupWorkflow, LoadLibraryWorkflow } from "./load/workflows"; +export { + AddGroupWorkflow, + LoadLibraryWorkflow, + ThumbnailWorkflow +} from "./load/workflows"; import { createApp } from "./create-app"; import { productionServices } from "./services"; diff --git a/src/backend/library-data.ts b/src/backend/library-data.ts index e9d2bf482..5b0950510 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -1,4 +1,4 @@ -import { asc, eq, sql } from "drizzle-orm"; +import { and, asc, eq, sql } from "drizzle-orm"; import { type Db } from "./db"; import { libraries, @@ -13,7 +13,7 @@ import { Insertables, Groups } from "../shared/api-models"; -import { PartNumberMap } from "../shared/configuration-models"; +import { ConfigurationRecord } from "../shared/configuration-models"; import { buildSearchDb } from "../shared/search"; /** @@ -44,7 +44,19 @@ export async function getLibraryOut( .where(eq(insertables.libraryId, libraryId)) .orderBy(asc(insertables.sortOrder)) .all(), - db.select({ id: configurations.id }).from(configurations).all() + // A row can exist just to hold records, so "configurable" keys on + // having parameters. Tested in SQL to leave the payload in D1. + db + .select({ id: configurations.id }) + .from(configurations) + .innerJoin(insertables, eq(configurations.id, insertables.id)) + .where( + and( + eq(insertables.libraryId, libraryId), + sql`json_array_length(${configurations.parameters}) > 0` + ) + ) + .all() ]); const configSet = new Set(allConfigurations.map((c) => c.id)); @@ -67,7 +79,8 @@ export async function getLibraryOut( instanceType: "v" }, name: group.name, - thumbnailUrls: group.thumbnailUrls ?? undefined, + smallThumbnailUrl: group.smallThumbnailUrl ?? undefined, + largeThumbnailUrl: group.largeThumbnailUrl ?? undefined, insertableOrder }; } @@ -91,7 +104,8 @@ export async function getLibraryOut( isVisible: ins.isVisible, supportsFasten: ins.supportsFasten, elementType: ins.elementType, - thumbnailUrls: ins.thumbnailUrls ?? undefined, + smallThumbnailUrl: ins.smallThumbnailUrl ?? undefined, + largeThumbnailUrl: ins.largeThumbnailUrl ?? undefined, configurationId: configSet.has(ins.id) ? ins.id : undefined, vendors: ins.vendors } satisfies InsertableOut; @@ -105,9 +119,8 @@ export async function getLibraryOut( } /** - * Renumbers a library's groups to open a slot for a new group — directly after - * `selectedGroupId`, or at the end — and returns the sort order to write it with. - * The caller creates the row itself, since it also decides create vs. update. + * 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. */ export async function placeNewGroup( db: Db, @@ -154,53 +167,58 @@ export async function bumpLibraryVersion( }); } -/** - * Rebuilds the serialized MiniSearch index for a library from its current - * groups/insertables and stores it on the `libraries` row in D1. - */ +/** The R2 object key holding a library's serialized MiniSearch index. */ +export function searchIndexKey(libraryId: LibraryId): string { + return `search-index/${libraryId}.json`; +} + +/** Rebuilds a library's search index into R2; bump `cacheVersion` alongside. */ export async function rebuildSearchDb( + bucket: R2Bucket, db: Db, libraryId: LibraryId ): Promise { - const [libraryData, partNumberMap] = await Promise.all([ + const start = Date.now(); + const [libraryData, recordsMap] = await Promise.all([ getLibraryOut(db, libraryId), - getPartNumberMap(db, libraryId) + getRecordsMap(db, libraryId) ]); - const searchDb = JSON.stringify(buildSearchDb(libraryData, partNumberMap)); - await db - .insert(libraries) - .values({ id: libraryId, searchDb }) - .onConflictDoUpdate({ target: libraries.id, set: { searchDb } }); + const searchDb = JSON.stringify(buildSearchDb(libraryData, recordsMap)); + // Uncompressed: encoding here would leave the runtime compressing an + // already-compressed body. + await bucket.put(searchIndexKey(libraryId), searchDb, { + httpMetadata: { contentType: "application/json" } + }); + console.log( + `Rebuilt search index for ${libraryId}: ` + + `${searchDb.length} B, ${Date.now() - start} ms` + ); return searchDb; } /** - * Assembles the per-insertable part-number map used to index part numbers: - * a configurable insertable's map comes from its `configurations` row, while a - * non-configurable one contributes its single `defaultPartNumber`. + * Assembles the per-insertable configuration records `buildSearchDb` dedupes into + * the part-number search map. Only indexed insertables have records. */ -async function getPartNumberMap( +async function getRecordsMap( db: Db, libraryId: LibraryId -): Promise> { +): Promise> { const rows = await db .select({ id: insertables.id, - defaultPartNumber: insertables.defaultPartNumber, - partNumbers: configurations.partNumbers + records: configurations.records }) .from(insertables) - .leftJoin(configurations, eq(configurations.id, insertables.id)) + .innerJoin(configurations, eq(configurations.id, insertables.id)) .where(eq(insertables.libraryId, libraryId)) .all(); - const partNumberMap: Record = {}; + const recordsMap: Record = {}; for (const row of rows) { - if (row.partNumbers && Object.keys(row.partNumbers).length > 0) { - partNumberMap[row.id] = row.partNumbers; - } else if (row.defaultPartNumber) { - partNumberMap[row.id] = { [row.defaultPartNumber]: {} }; + if (row.records.length > 0) { + recordsMap[row.id] = row.records; } } - return partNumberMap; + return recordsMap; } diff --git a/src/backend/load/job-tracker.test.ts b/src/backend/load/job-tracker.test.ts index 714b2c894..676543f29 100644 --- a/src/backend/load/job-tracker.test.ts +++ b/src/backend/load/job-tracker.test.ts @@ -1,7 +1,7 @@ import { env } from "cloudflare:workers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - isAnyJobRunning, + getJobStatus, isReloadRunning, trackJob, untrackJob @@ -11,6 +11,7 @@ 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. */ @@ -27,7 +28,9 @@ describe("job-tracker", () => { 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 isAnyJobRunning(env, TEST_LIBRARY_ID)).toBe(false); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + running: false + }); }); it.each(["queued", "running", "waiting", "paused", "waitingForPause"])( @@ -35,7 +38,9 @@ describe("job-tracker", () => { async (status) => { mockJobs([{ id: "r1", kind: "reload" }], status); expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(true); - expect(await isAnyJobRunning(env, TEST_LIBRARY_ID)).toBe(true); + expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe( + true + ); } ); @@ -44,14 +49,16 @@ describe("job-tracker", () => { async (status) => { mockJobs([{ id: "r1", kind: "reload" }], status); expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(false); - expect(await isAnyJobRunning(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 isAnyJobRunning(env, TEST_LIBRARY_ID)).toBe(true); + expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe(true); }); it("treats an aged-out instance as finished", async () => { @@ -61,7 +68,24 @@ describe("job-tracker", () => { vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "get").mockRejectedValue( new Error("not found") ); - expect(await isAnyJobRunning(env, TEST_LIBRARY_ID)).toBe(false); + 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 () => { @@ -70,9 +94,12 @@ describe("job-tracker", () => { await trackJob(env, TEST_LIBRARY_ID, "reload", "r1"); - expect(putSpy).toHaveBeenCalledWith( - `library-jobs:${TEST_LIBRARY_ID}`, - JSON.stringify([{ id: "r1", kind: "reload" }]), + 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) }) ); }); diff --git a/src/backend/load/job-tracker.ts b/src/backend/load/job-tracker.ts index e49ca724e..d48632de6 100644 --- a/src/backend/load/job-tracker.ts +++ b/src/backend/load/job-tracker.ts @@ -1,5 +1,6 @@ import type { AppBindings } from "../app"; import type { LibraryId } from "../../shared/types"; +import type { JobStatus } from "../../shared/api-models"; /** * Backstop for a job that crashes before untracking itself; must outlast the @@ -21,6 +22,8 @@ export type JobKind = "reload" | "add-group"; interface TrackedJob { id: string; kind: JobKind; + /** Epoch ms the job was created. */ + startedAt: number; } function jobsKey(libraryId: LibraryId): string { @@ -74,12 +77,17 @@ export async function isReloadRunning( return jobs.some((job) => job.kind === "reload"); } -/** Whether any load job (reload or add-group) is running for this library. */ -export async function isAnyJobRunning( +/** Reports the oldest running job's age, which paces the client's polling. */ +export async function getJobStatus( env: AppBindings, libraryId: LibraryId -): Promise { - return (await activeJobs(env, libraryId)).length > 0; +): 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. */ @@ -90,16 +98,15 @@ export async function trackJob( instanceId: string ): Promise { const jobs = await activeJobs(env, libraryId); - jobs.push({ id: instanceId, kind }); + jobs.push({ id: instanceId, kind, startedAt: Date.now() }); await env.KV.put(jobsKey(libraryId), JSON.stringify(jobs), { expirationTtl: JOB_TTL_SECONDS }); } /** - * Removes a job's entry as it finishes — the workflow calls this in a final step - * so the running-job state clears promptly instead of waiting out the TTL. Only - * its own entry is touched, so concurrent jobs are unaffected. + * 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, diff --git a/src/backend/load/load-common.ts b/src/backend/load/load-common.ts index fd7207d64..69e39d27f 100644 --- a/src/backend/load/load-common.ts +++ b/src/backend/load/load-common.ts @@ -12,9 +12,8 @@ export const LOAD_CONCURRENCY = 15; export type Limiter = (task: () => Promise) => Promise; /** - * Runs at most `max` tasks at once, queueing the rest in call order. Bounds - * Onshape pressure so a rate-limit burst only hits the running few and - * already-finished work is preserved. + * 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; diff --git a/src/backend/load/load-group.test.ts b/src/backend/load/load-group.test.ts index 74a504580..08f68e5f6 100644 --- a/src/backend/load/load-group.test.ts +++ b/src/backend/load/load-group.test.ts @@ -220,6 +220,38 @@ describe("loadGroup", () => { expect(rows.map((row) => row.elementId).sort()).toEqual(["e1", "e2"]); }); + // 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. + 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. + await seedInsertable(db, { + id: "ins-e1", + elementId: "e1", + name: "Existing", + microversionId: "mv-1", + versionId: "inst-1" + }); + const configurationSpy = vi.spyOn( + ConfigurationEndpoints, + "getConfiguration" + ); + + const result = await loadGroup(CTX, LOADED_TARGET, false); + + 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) + .where(eq(insertables.id, "ins-e1")) + .get(); + expect(row?.versionId).toBe("v-2"); + expect((await readGroup())?.versionId).toBe("v-2"); + }); + // 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. it("holds the version back and flags the insertable that failed", async () => { diff --git a/src/backend/load/load-group.ts b/src/backend/load/load-group.ts index 9bfe13d8a..c7a9a7a73 100644 --- a/src/backend/load/load-group.ts +++ b/src/backend/load/load-group.ts @@ -33,7 +33,8 @@ export interface GroupLoadResult { /** What a load computes for the group row. */ interface ParsedGroup { name: string; - thumbnailUrls: ThumbnailUrls | null; + smallThumbnailUrl: string | null; + largeThumbnailUrl: string | null; buildIssues: BuildIssue[]; /** When this (successful) load completed, epoch ms. */ lastLoadedAt: number; @@ -83,7 +84,7 @@ export async function loadGroup( `document-thumbnail-${groupId}`, async () => uploadDocumentThumbnails( - ctx.env.THUMBNAILS, + ctx.env.BLOB, await getOnshapeApiFromContext(ctx), versionPath ) @@ -154,7 +155,8 @@ async function saveGroup( }); const parsed: ParsedGroup = { name: target.name, - thumbnailUrls, + 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. @@ -167,6 +169,16 @@ async function saveGroup( const writes: BatchItem<"sqlite">[] = [ db.update(group).set(parsed).where(eq(group.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. + writes.push( + db + .update(insertables) + .set({ versionId: target.versionPath.instanceId }) + .where(eq(insertables.groupId, target.groupId)) + ); + } if (removedInsertableIds.length > 0) { // Configurations and favorites follow deleted insertables via their // cascading foreign keys. @@ -184,9 +196,8 @@ async function saveGroup( } /** - * Adds `LOAD_FAILED` to each failed insertable's stored issues, keeping the ones - * its last good load recorded. A brand-new insertable has no row yet, so it gets - * no write — the group's `INSERTABLES_FAILED` covers it. + * 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. */ async function flagFailedInsertables( db: Db, @@ -236,9 +247,6 @@ export interface StoredInsertable { microversionId: string; } -/** - * Fetches the group's stored insertables. - */ async function fetchStoredInsertables( ctx: LoadContext, groupId: string @@ -254,9 +262,8 @@ async function fetchStoredInsertables( } /** - * Selects the tabs to reload: new ones, and stored ones whose microversion - * changed (or all of them, on `forceReload`). A stored insertable keeps its id; - * a new one gets a fresh one. + * New tabs, and stored ones whose microversion changed. A stored insertable + * keeps its id so favorites and links survive. */ export function selectInsertablesToLoad( target: GroupTarget, diff --git a/src/backend/load/load-insertable.test.ts b/src/backend/load/load-insertable.test.ts index 5d596b57b..4c916b56d 100644 --- a/src/backend/load/load-insertable.test.ts +++ b/src/backend/load/load-insertable.test.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; import { getDb } from "../db"; import { configurations, insertables } from "../../shared/schema"; +import type { ConfigurationRecord } from "../../shared/configuration-models"; import { TEST_PARAMETERS, TEST_PART_STUDIO_ID, @@ -25,6 +26,23 @@ function readInsertable() { .get(); } +/** Builds a configuration record with the given part number and defaults. */ +function record( + partNumber: string | null, + configuration: Record = {} +): ConfigurationRecord { + return { + configuration, + partNumber, + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + }; +} + describe("saveInsertable", () => { beforeEach(async () => { await resetDb(db); @@ -35,20 +53,16 @@ describe("saveInsertable", () => { await saveInsertable( db, insertableTarget(), - parsedInsertable({ - isOpenComposite: true, - defaultPartNumber: "PN-1" - }) + parsedInsertable({ isOpenComposite: true }) ); expect(await readInsertable()).toMatchObject({ // User-owned flags start off. isVisible: false, supportsFasten: false, - searchPartNumbers: false, + indexConfigurations: false, // Computed columns come from the parse. isOpenComposite: true, - defaultPartNumber: "PN-1", lastLoadedAt: expect.any(Number) }); }); @@ -57,10 +71,7 @@ describe("saveInsertable", () => { await saveInsertable( db, insertableTarget(), - parsedInsertable({ - isOpenComposite: true, - defaultPartNumber: "PN-1" - }) + parsedInsertable({ isOpenComposite: true }) ); // The user reveals the element, turns its features on, and reorders it. await db @@ -68,12 +79,12 @@ describe("saveInsertable", () => { .set({ isVisible: true, supportsFasten: true, - searchPartNumbers: true, + indexConfigurations: true, sortOrder: 5 }) .where(eq(insertables.id, TEST_PART_STUDIO_ID)); - // A reload finds it renamed, no longer a composite, with no part number. + // A reload finds it renamed and no longer a composite. await saveInsertable( db, insertableTarget({ @@ -88,41 +99,74 @@ describe("saveInsertable", () => { // Preserved. isVisible: true, supportsFasten: true, - searchPartNumbers: true, + indexConfigurations: true, sortOrder: 5, // Overwritten. name: "Renamed", microversionId: "mv-2", - isOpenComposite: false, - defaultPartNumber: null + isOpenComposite: false }); }); - it("inserts the configuration row when there are parameters and drops it when there aren't", async () => { + it("writes the computed configuration records", async () => { + const records = [ + record("PN-1", { p: "v1" }), + record("PN-2", { p: "v2" }) + ]; + await saveInsertable( + db, + insertableTarget(), + parsedInsertable({ + configuration: { parameters: TEST_PARAMETERS, records } + }) + ); + + const config = await db + .select() + .from(configurations) + .where(eq(configurations.id, TEST_PART_STUDIO_ID)) + .get(); + expect(config?.records).toEqual(records); + }); + + // An indexed insertable with no varying parameters still needs its records + // stored, so a configurations row is kept even when `parameters` is empty. + it("keeps a configuration row for a non-configurable indexed insertable", async () => { await saveInsertable( db, insertableTarget(), parsedInsertable({ configuration: { - parameters: TEST_PARAMETERS, - partNumbers: { "PN-1": { boolean: "true" } } + parameters: [], + records: [record("PN-default")] } }) ); + const config = await db .select() .from(configurations) .where(eq(configurations.id, TEST_PART_STUDIO_ID)) .get(); - expect(config).toMatchObject({ - parameters: TEST_PARAMETERS, - partNumbers: { "PN-1": { boolean: "true" } } - }); + expect(config?.parameters).toEqual([]); + expect(config?.records).toEqual([record("PN-default")]); + }); - // getPartNumberMap in library-data.ts reads the config without - // re-checking searchPartNumbers, so a reload with no parameters must - // drop the row rather than leave stale part numbers behind. + // library-data reads `records` without re-checking that the insertable is + // still indexed, so an empty reload must drop the row, not blank it. + it("drops the configuration row when there are no parameters or records", async () => { + await saveInsertable( + db, + insertableTarget(), + parsedInsertable({ + configuration: { + parameters: TEST_PARAMETERS, + records: [record("PN-1")] + } + }) + ); await saveInsertable(db, insertableTarget(), parsedInsertable()); + expect(await db.select().from(configurations).all()).toHaveLength(0); }); }); diff --git a/src/backend/load/load-insertable.ts b/src/backend/load/load-insertable.ts index a46c01839..19bf1a4d0 100644 --- a/src/backend/load/load-insertable.ts +++ b/src/backend/load/load-insertable.ts @@ -4,7 +4,11 @@ import type { Configuration, ConfigurationParameter } from "../../shared/configuration-models"; -import { addBuildIssue, type BuildIssue } from "../../shared/build-issues"; +import { + addBuildIssue, + type BuildIssue, + BuildIssueType +} from "../../shared/build-issues"; import { ElementType, type FastenInfo, @@ -20,10 +24,11 @@ import { parseOnshapeConfiguration } from "../parse/parse-configuration"; import { parseVendors } from "../parse/parse-vendors"; import { parseFastenInfo } from "../parse/insert-and-fasten"; import { - NO_PART_NUMBERS, + NO_RECORDS, computeOpenComposite, - loadPartNumbers -} from "../parse/parse-part-number"; + decideIndexing, + loadConfigurationRecords +} from "../parse/parse-configuration-records"; import { type InsertableTarget, type LoadContext, @@ -32,16 +37,13 @@ import { import { uploadThumbnailsStep } from "./load-steps"; /** - * Everything a load computes for an insertable by reading Onshape. Exactly the - * set of columns a reload overwrites — the rest of the row is either identity or - * owned by the user. + * Exactly the columns a reload overwrites; the rest of the row is identity or + * user-owned. */ export interface ParsedInsertable { vendors: Vendor[]; thumbnailUrls: ThumbnailUrls | null; fastenInfo: FastenInfo | null; - /** Part number of the default configuration; null when not indexed. */ - defaultPartNumber: string | null; /** Whether the part studio resolves to an open composite. */ isOpenComposite: boolean; buildIssues: BuildIssue[]; @@ -51,12 +53,10 @@ export interface ParsedInsertable { /** The user-owned flags that decide how much of a load runs. */ interface InsertableFlags { supportsFasten: boolean; - searchPartNumbers: boolean; + /** Forces part-number indexing on, overriding the auto heuristic. */ + indexConfigurations: boolean; } -/** - * Loads and persists a single insertable to the database. - */ export async function loadInsertable( ctx: LoadContext, target: InsertableTarget @@ -73,45 +73,57 @@ export async function loadInsertable( ? await parseFastenInfoStep(ctx, target) : null; - const isOpenComposite = await computeOpenCompositeStep(ctx, target); + const { isOpenComposite, hasParts } = await readPartsStep(ctx, target); + + const indexing = decideIndexing(parameters, flags.indexConfigurations); - const partNumberResult = flags.searchPartNumbers - ? await loadPartNumbers( + const recordsResult = indexing.shouldIndex + ? await loadConfigurationRecords( ctx, insertableId, elementPath, target.elementType, parameters, + indexing.configurations, isOpenComposite ) - : NO_PART_NUMBERS; + : NO_RECORDS; + + // Onshape renders nothing for an empty studio, so asking would only spend + // the whole retry budget waiting for a thumbnail that cannot exist. + const thumbnailUrls = hasParts + ? await uploadThumbnailsStep( + ctx, + `thumbnail-${insertableId}`, + async () => + uploadThumbnails( + ctx.env.BLOB, + await getOnshapeApiFromContext(ctx), + elementPath, + target.microversionId + ) + ) + : null; - const thumbnailUrls = await uploadThumbnailsStep( - ctx, - `thumbnail-${insertableId}`, - async () => - uploadThumbnails( - ctx.env.THUMBNAILS, - await getOnshapeApiFromContext(ctx), - elementPath, - target.microversionId - ) + const buildIssues = addBuildIssue( + hasParts + ? checkInsertable({ + vendors, + thumbnailUrls, + records: recordsResult.records + }) + : [{ type: BuildIssueType.NO_PARTS }], + ...recordsResult.buildIssues, + ...indexing.buildIssues ); const parsed: ParsedInsertable = { vendors, thumbnailUrls, fastenInfo, - defaultPartNumber: partNumberResult.defaultPartNumber, isOpenComposite, - buildIssues: addBuildIssue( - checkInsertable({ vendors, thumbnailUrls }), - ...partNumberResult.buildIssues - ), - configuration: { - parameters, - partNumbers: partNumberResult.partNumbers - } + buildIssues, + configuration: { parameters, records: recordsResult.records } }; await ctx.step.do(`save-${insertableId}`, () => @@ -131,18 +143,15 @@ function readFlagsStep( const row = await getDb(ctx.env.DB) .select({ supportsFasten: insertables.supportsFasten, - searchPartNumbers: insertables.searchPartNumbers + indexConfigurations: insertables.indexConfigurations }) .from(insertables) .where(eq(insertables.id, insertableId)) .get(); - return row ?? { supportsFasten: false, searchPartNumbers: false }; + return row ?? { supportsFasten: false, indexConfigurations: false }; }); } -/** - * Fetches and parses the element's configuration. - */ function parseConfigurationStep( ctx: LoadContext, { insertableId, elementPath }: InsertableTarget @@ -156,29 +165,36 @@ function parseConfigurationStep( }); } +/** What one look at a part studio's default parts tells the rest of the load. */ +interface PartsSummary { + isOpenComposite: boolean; + hasParts: boolean; +} + /** - * Determines whether a part studio is an open composite from its default - * configuration. Runs on every load, not just under part-number search, so the - * insert path always requests the right part types. Assemblies are never - * composites, so they skip the fetch. + * 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. */ -function computeOpenCompositeStep( +function readPartsStep( ctx: LoadContext, { insertableId, elementPath, elementType }: InsertableTarget -): Promise { +): Promise { if (elementType !== ElementType.PART_STUDIO) { - return Promise.resolve(false); + return Promise.resolve({ isOpenComposite: false, hasParts: true }); } - return ctx.step.do(`open-composite-${insertableId}`, async () => - computeOpenComposite( - await getParts(await getOnshapeApiFromContext(ctx), elementPath, {}) - ) - ); + return ctx.step.do(`open-composite-${insertableId}`, async () => { + const parts = await getParts( + await getOnshapeApiFromContext(ctx), + elementPath, + {} + ); + return { + isOpenComposite: computeOpenComposite(parts), + hasParts: parts.length > 0 + }; + }); } -/** - * Fetches and parses the element's fasten info. - */ function parseFastenInfoStep( ctx: LoadContext, { insertableId, elementPath, elementType }: InsertableTarget @@ -193,10 +209,7 @@ function parseFastenInfoStep( } /** - * Writes a single insertable (plus its configuration) to the database. - * - * The reloaded columns come from `parsed` plus the tab facts on `target`; - * everything else is written only on insert, so a reload preserves the row's + * Everything outside `parsed` is written only on insert, so a reload preserves * sort order and the user's flags. */ export async function saveInsertable( @@ -211,9 +224,9 @@ export async function saveInsertable( microversionId: target.microversionId, versionId: target.elementPath.instanceId, vendors: parsed.vendors, - thumbnailUrls: parsed.thumbnailUrls, + smallThumbnailUrl: parsed.thumbnailUrls?.small ?? null, + largeThumbnailUrl: parsed.thumbnailUrls?.large ?? null, fastenInfo: parsed.fastenInfo, - defaultPartNumber: parsed.defaultPartNumber, isOpenComposite: parsed.isOpenComposite, buildIssues: parsed.buildIssues, lastLoadedAt: Date.now() @@ -232,7 +245,7 @@ export async function saveInsertable( // one keeps the user's choices, since `set` omits these. isVisible: false, supportsFasten: false, - searchPartNumbers: false, + indexConfigurations: false, ...reloaded }) .onConflictDoUpdate({ @@ -240,12 +253,13 @@ export async function saveInsertable( set: reloaded }); + // Keep the row while it holds either parameters or records; an insertable + // that is neither configurable nor indexed needs none. let configurationWrite; - if (configuration.parameters.length === 0) { - configurationWrite = db - .delete(configurations) - .where(eq(configurations.id, target.insertableId)); - } else { + if ( + configuration.parameters.length > 0 || + configuration.records.length > 0 + ) { configurationWrite = db .insert(configurations) .values({ id: target.insertableId, ...configuration }) @@ -253,6 +267,10 @@ export async function saveInsertable( target: configurations.id, set: configuration }); + } else { + configurationWrite = db + .delete(configurations) + .where(eq(configurations.id, target.insertableId)); } await db.batch([insertableWrite, configurationWrite]); diff --git a/src/backend/load/load-steps.test.ts b/src/backend/load/load-steps.test.ts new file mode 100644 index 000000000..a58ebb442 --- /dev/null +++ b/src/backend/load/load-steps.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; +import { NoSuchConfigurationError } from "../onshape-api/endpoints/thumbnails"; +import { ONSHAPE_STEP_RETRIES, THUMBNAIL_STEP_RETRIES } from "./load-steps"; + +/** The delay before the retry that follows attempt `attempt`. */ +function thumbnailDelay(attempt: number, error = new Error("not rendered")) { + return THUMBNAIL_STEP_RETRIES.delay({ ctx: { attempt }, error }); +} + +describe("THUMBNAIL_STEP_RETRIES", () => { + // Onshape gives no signal when a render lands, so the step polls. Starting + // at four seconds keeps a quick render from waiting on a long first delay. + it("doubles from four seconds", () => { + expect([1, 2, 3, 4, 5, 6].map((a) => thumbnailDelay(a))).toEqual([ + "4 seconds", + "8 seconds", + "16 seconds", + "32 seconds", + "64 seconds", + "128 seconds" + ]); + }); + + it("keeps doubling rather than settling on a ceiling", () => { + expect(thumbnailDelay(7)).toEqual("256 seconds"); + expect(thumbnailDelay(8)).toEqual("512 seconds"); + }); + + it("polls for about seventeen minutes before giving up", () => { + const total = Array.from( + { length: THUMBNAIL_STEP_RETRIES.limit - 1 }, + (_, i) => Number.parseInt(thumbnailDelay(i + 1), 10) + ).reduce((sum, seconds) => sum + seconds, 0); + + expect(total).toBe(1020); + }); + + // A warm request naming a configuration that matches nothing would + // otherwise hold a workflow instance for the whole budget. + it("does not wait on a configuration that matches nothing", () => { + const error = new NoSuchConfigurationError("no insertable"); + expect(thumbnailDelay(1, error)).toEqual("0 seconds"); + expect(thumbnailDelay(6, error)).toEqual("0 seconds"); + }); + + // Polling sooner than Onshape asked only earns another 429. + it("waits out a rate limit instead of its own curve", () => { + const error = new OnshapeRateLimitError("slow down", 42); + expect(thumbnailDelay(1, error)).toEqual("42 seconds"); + expect(thumbnailDelay(9, error)).toEqual("42 seconds"); + }); +}); + +describe("ONSHAPE_STEP_RETRIES", () => { + it("backs off from ten seconds, capped at five minutes", () => { + const delay = (attempt: number) => + ONSHAPE_STEP_RETRIES.delay({ + ctx: { attempt }, + error: new Error("boom") + }); + expect([1, 2, 3, 10].map(delay)).toEqual([ + "10 seconds", + "20 seconds", + "40 seconds", + "300 seconds" + ]); + }); + + it("waits out a rate limit instead of its own curve", () => { + expect( + ONSHAPE_STEP_RETRIES.delay({ + ctx: { attempt: 3 }, + error: new OnshapeRateLimitError("slow down", 7) + }) + ).toEqual("7 seconds"); + }); +}); diff --git a/src/backend/load/load-steps.ts b/src/backend/load/load-steps.ts index a789b7627..1ef2ed18a 100644 --- a/src/backend/load/load-steps.ts +++ b/src/backend/load/load-steps.ts @@ -1,5 +1,6 @@ import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; import type { ThumbnailUrls } from "../../shared/types"; +import { NoSuchConfigurationError } from "../onshape-api/endpoints/thumbnails"; import type { LoadContext } from "./load-common"; /** The retry input a Workflow `delay` callback receives. */ @@ -36,10 +37,36 @@ export const ONSHAPE_STEP_RETRIES = { delay: onshapeRetryDelay }; +/** The first wait after a render isn't ready; each attempt doubles it. */ +const THUMBNAIL_BASE_DELAY_SECONDS = 4; + +/** + * Onshape gives no signal when a render lands, so the step polls, doubling from + * four seconds. A rate limit overrides the curve. + */ +function thumbnailRetryDelay(input: RetryDelayInput): `${number} seconds` { + const rateLimited = rateLimitDelay(input.error); + if (rateLimited) { + return rateLimited; + } + // Nothing to wait for; burn the remaining attempts immediately. + if (input.error instanceof NoSuchConfigurationError) { + return "0 seconds"; + } + const seconds = THUMBNAIL_BASE_DELAY_SECONDS * 2 ** (input.ctx.attempt - 1); + return `${seconds} seconds`; +} + +export const THUMBNAIL_STEP_RETRIES = { + // 4s, 8s … 512s: a bit over seventeen minutes of polling, which a slow + // Onshape render is worth waiting out. + limit: 9, + delay: thumbnailRetryDelay +}; + /** - * Uploads thumbnails in a single step with retrying, returning `null` when they - * never showed up — the caller records that as a build issue rather than failing - * the load. + * Returns `null` when the thumbnails never showed up, which the caller records + * as a build issue rather than failing the whole load. */ export async function uploadThumbnailsStep( ctx: LoadContext, @@ -50,14 +77,7 @@ export async function uploadThumbnailsStep( return await ctx.step.do( name, { - retries: { - limit: 3, - // Wait out a rate limit; otherwise poll for the thumbnail, - // which Onshape renders asynchronously. - delay: (retry: RetryDelayInput) => - rateLimitDelay(retry.error) ?? - (retry.ctx.attempt === 1 ? "10 seconds" : "5 minutes") - } + retries: THUMBNAIL_STEP_RETRIES }, async () => { const thumbnails = await uploadFn(); diff --git a/src/backend/load/workflows.ts b/src/backend/load/workflows.ts index 48b21ac52..678065270 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/load/workflows.ts @@ -15,7 +15,8 @@ import { import { getDocument } from "../onshape-api/endpoints/documents"; import { getLatestVersionId } from "../onshape-api/endpoints/versions"; import type { InstancePath } from "../../shared/onshape-path"; -import { group, libraries } from "../../shared/schema"; +import { group, insertables, libraries } from "../../shared/schema"; +import { uploadConfigurationThumbnails } from "../routes/thumbnails"; import { type GroupTarget, type LoadContext, @@ -25,6 +26,7 @@ import { } from "./load-common"; import { untrackJob } from "./job-tracker"; import { loadGroup } from "./load-group"; +import { THUMBNAIL_STEP_RETRIES } from "./load-steps"; export interface LoadLibraryParams { libraryId: LibraryId; @@ -228,6 +230,76 @@ async function finalizeLibrary( libraryId: LibraryId ): Promise { const db = getDb(env.DB); - await rebuildSearchDb(db, libraryId); + await rebuildSearchDb(env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); } + +/** The render to run, plus the session whose Onshape tokens it runs under. */ +export interface ThumbnailWorkflowParams { + insertableId: string; + /** Part of the key, so a render lands where the request looked for it. */ + microversionId: string; + /** Never the default, which loads eagerly with the element. */ + canonicalConfiguration: string; + sessionId: string; +} + +/** + * Outside a request, since Onshape can take minutes. Until it finishes, + * requests fall back to the element's default thumbnail. + */ +export class ThumbnailWorkflow extends WorkflowEntrypoint< + AppBindings, + ThumbnailWorkflowParams +> { + async run( + event: WorkflowEvent, + step: WorkflowStep + ): Promise { + const { + insertableId, + microversionId, + canonicalConfiguration, + sessionId + } = event.payload; + + const elementPath = await step.do("resolve-element", async () => { + 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 Error(`No insertable ${insertableId}`); + } + return { + documentId: row.documentId, + instanceId: row.versionId, + instanceType: "v" as const, + elementId: row.elementId + }; + }); + + await step.do( + "render-thumbnails", + { retries: THUMBNAIL_STEP_RETRIES }, + async () => + uploadConfigurationThumbnails( + this.env.BLOB, + await getOnshapeApiFromContext({ + env: this.env, + sessionId, + step, + limit: createLimiter(1) + }), + elementPath, + microversionId, + canonicalConfiguration + ) + ); + } +} diff --git a/src/backend/onshape-api/api-path.ts b/src/backend/onshape-api/api-path.ts index 97b8d264c..0ae144f97 100644 --- a/src/backend/onshape-api/api-path.ts +++ b/src/backend/onshape-api/api-path.ts @@ -12,13 +12,6 @@ export interface ApiPathOptions { } /** - * Constructs a path suitable for the Onshape REST API. - * - * @param route - The Onshape service name, e.g. `"documents"` or `"assemblies"`. - * @param path - A path object to embed in the URL. - * @param serialize - Converts `path` to its URL segment, e.g. `toInstanceApiPath`. - * @param options - Optional tail segments and flags. - * * @example * apiPath("documents", instancePath, toInstanceApiPath, { endRoute: "elements" }) * // → "/documents/d/{did}/w/{wid}/elements" diff --git a/src/backend/onshape-api/endpoints/assemblies.ts b/src/backend/onshape-api/endpoints/assemblies.ts index b857fa123..9ba87f574 100644 --- a/src/backend/onshape-api/endpoints/assemblies.ts +++ b/src/backend/onshape-api/endpoints/assemblies.ts @@ -62,7 +62,6 @@ export function getAssemblyFeatures( ); } -/** Constructs an assembly with the given name. */ export function createAssembly( client: OnshapeApi, workspacePath: InstancePath, @@ -78,10 +77,8 @@ export function createAssembly( } /** - * Adds the contents of an element tab to an assembly. - * - * @param elementType The type of the element being inserted (part studio or assembly). - * @param options.partTypes If inserting a part studio, the types of parts to include. Defaults to PARTS and COMPOSITE_PARTS. + * Adds the contents of an element tab to an assembly. For a part studio, + * `options.partTypes` defaults to PARTS and COMPOSITE_PARTS. */ export function addElementToAssembly( client: OnshapeApi, @@ -137,10 +134,8 @@ export function addElementToAssembly( } /** - * Applies a transform to an instance in an assembly. - * - * @param isRelative True to apply the transform relative to the instance's existing location, - * false to apply it relative to the assembly origin. + * `isRelative` transforms from the instance's existing location rather than the + * assembly origin. */ export function transformInstance( client: OnshapeApi, @@ -185,7 +180,6 @@ export function addAssemblyFeature( ); } -/** Deletes a feature from an assembly. */ export function deleteFeature( client: OnshapeApi, assemblyPath: ElementPath, diff --git a/src/backend/onshape-api/endpoints/configurations.ts b/src/backend/onshape-api/endpoints/configurations.ts index 95e9dc5b2..c888a0499 100644 --- a/src/backend/onshape-api/endpoints/configurations.ts +++ b/src/backend/onshape-api/endpoints/configurations.ts @@ -63,14 +63,3 @@ export function encodeConfiguration( .map(([id, value]) => `${id}=${encodeURIComponent(value)}`) .join(";"); } - -/** Encodes a configuration into a format suitable for passing to the Onshape API via a query parameter. */ -export function encodeConfigurationForQuery( - configuration: Record -): string { - return encodeURIComponent( - Object.entries(configuration) - .map(([id, value]) => `${id}=${value}`) - .join(";") - ); -} diff --git a/src/backend/onshape-api/endpoints/documents.ts b/src/backend/onshape-api/endpoints/documents.ts index 60398485d..d33d5ab22 100644 --- a/src/backend/onshape-api/endpoints/documents.ts +++ b/src/backend/onshape-api/endpoints/documents.ts @@ -53,7 +53,6 @@ export function getWorkspaces( ); } -/** Creates a new workspace in a given document. */ export function createWorkspace( client: OnshapeApi, documentPath: DocumentPath, @@ -100,7 +99,6 @@ export function createWorkspaceFromVersion( ); } -/** Deletes a workspace. */ export function deleteWorkspace( client: OnshapeApi, workspacePath: InstancePath @@ -114,7 +112,6 @@ export function deleteWorkspace( ); } -/** Deletes an entire document. */ export function deleteDocument( client: OnshapeApi, documentPath: DocumentPath @@ -163,10 +160,8 @@ export async function getDocumentElement( } /** - * Fetches the latest microversion id of a given workspace. - * - * Note this is the microversion associated with the workspace as a whole. - * Individual elements also have their own microversion ids which are unrelated to the workspace's. + * The workspace's own microversion — unrelated to the per-element microversions + * the load path compares. */ export function getWorkspaceMicroversionId( client: OnshapeApi, @@ -351,10 +346,8 @@ export function getUnitInfo( } /** - * Updates all features in `elementPath` which reference `oldReferencePath` to the latest version. - * - * Specifically, all features in `elementPath` which reference objects in `oldReferencePath` - * are updated to use the latest version of that reference. + * Updates every feature in `elementPath` referencing `oldReferencePath` to that + * reference's latest version. */ export async function updateToLatestVersion( onshapeApi: OnshapeApi, diff --git a/src/backend/onshape-api/endpoints/feature-studios.ts b/src/backend/onshape-api/endpoints/feature-studios.ts index c8d97b5c8..dcb28914d 100644 --- a/src/backend/onshape-api/endpoints/feature-studios.ts +++ b/src/backend/onshape-api/endpoints/feature-studios.ts @@ -37,7 +37,6 @@ export function pushCode( ); } -/** Creates a feature studio with the given name. */ export function createFeatureStudio( client: OnshapeApi, instancePath: InstancePath, diff --git a/src/backend/onshape-api/endpoints/metadata.ts b/src/backend/onshape-api/endpoints/metadata.ts index 959b0085f..8d366be93 100644 --- a/src/backend/onshape-api/endpoints/metadata.ts +++ b/src/backend/onshape-api/endpoints/metadata.ts @@ -1,50 +1,24 @@ import { OnshapeApi } from "../onshape-api"; -import { - ElementPath, - InstancePath, - toElementApiPath, - toInstanceApiPath -} from "../../../shared/onshape-path"; +import { ElementPath, toElementApiPath } from "../../../shared/onshape-path"; import { apiPath } from "../api-path"; +import { encodeConfigurationForQuery } from "../../../shared/configuration-utils"; +import { ParameterValues } from "../../../shared/configuration-models"; +import type { OnshapeMetadataObject } from "../onshape-types"; -export function getInstanceMetadata( - client: OnshapeApi, - instancePath: InstancePath -): Promise { - return client.get(apiPath("metadata", instancePath, toInstanceApiPath), { - query: { includeComputedProperties: "false" } - }); -} - -export function getAllElementMetadata( - client: OnshapeApi, - instancePath: InstancePath -): Promise { - return client.get( - apiPath("metadata", instancePath, toInstanceApiPath, { endRoute: "e" }), - { query: { includeComputedProperties: "false" } } - ); -} - +/** Returns an element's metadata properties for a given configuration. */ export function getElementMetadata( - client: OnshapeApi, - elementPath: ElementPath -): Promise { - return client.get(apiPath("metadata", elementPath, toElementApiPath), { - query: { includeComputedProperties: "false" } - }); -} - -export function updateElementMetadata( client: OnshapeApi, elementPath: ElementPath, - propertyId: string, - value: unknown -): Promise { - return client.post(apiPath("metadata", elementPath, toElementApiPath), { - body: { - jsonType: "metadata-element", - properties: [{ propertyId, value }] - } + configuration: ParameterValues +): Promise { + const encoded = encodeConfigurationForQuery(configuration); + // Computed properties are expensive and unused, and indexing probes this + // once per configuration. + const query: Record = { + includeComputedProperties: "false" + }; + if (encoded) query.configuration = encoded; + return client.get(apiPath("metadata", elementPath, toElementApiPath), { + query }); } diff --git a/src/backend/onshape-api/endpoints/part-studios.ts b/src/backend/onshape-api/endpoints/part-studios.ts index 01b035dd2..227e370f0 100644 --- a/src/backend/onshape-api/endpoints/part-studios.ts +++ b/src/backend/onshape-api/endpoints/part-studios.ts @@ -12,7 +12,6 @@ import { OnshapeFeatureListResponse } from "../onshape-types"; -/** Creates a part studio in a document. */ export function createPartStudio( client: OnshapeApi, instancePath: InstancePath, @@ -46,7 +45,6 @@ export async function evaluateFeatureScript( return JSON.parse(result.console); } -/** Adds a feature to a part studio. */ export function addPartStudioFeature( client: OnshapeApi, partStudioPath: ElementPath, @@ -61,7 +59,6 @@ export function addPartStudioFeature( ); } -/** Returns the features in a part studio. */ export function getFeatures( client: OnshapeApi, partStudioPath: ElementPath diff --git a/src/backend/onshape-api/endpoints/settings.ts b/src/backend/onshape-api/endpoints/settings.ts index 894e69bca..1d02333cd 100644 --- a/src/backend/onshape-api/endpoints/settings.ts +++ b/src/backend/onshape-api/endpoints/settings.ts @@ -12,13 +12,7 @@ export async function getSetting( return result[0]?.value ?? null; } -/** - * Returns a list of company or user-level settings with the given keys. - * - * Each entry in the result has `key` and `value` fields. - * - * @param keys If omitted, all settings are returned. - */ +/** Company or user-level settings; omitting `keys` returns all of them. */ export async function getSettings( client: OAuthApi, clientId: string, diff --git a/src/backend/onshape-api/endpoints/thumbnails.ts b/src/backend/onshape-api/endpoints/thumbnails.ts index f489e473e..48c21b2a6 100644 --- a/src/backend/onshape-api/endpoints/thumbnails.ts +++ b/src/backend/onshape-api/endpoints/thumbnails.ts @@ -7,20 +7,13 @@ import { toInstanceApiPath } from "../../../shared/onshape-path"; import { apiPath } from "../api-path"; - -/** Represents the possible sizes of a thumbnail. */ -export enum ThumbnailSize { - STANDARD = "300x300", - LARGE = "600x340", - SMALL = "300x170", - TINY = "70x40" -} +import { ThumbnailSize } from "../../../shared/types"; /** Returns the thumbnail of a given document instance. */ export function getInstanceThumbnail( client: OnshapeApi, instancePath: InstancePath, - size = ThumbnailSize.STANDARD + size = ThumbnailSize.LARGE ): Promise { assertInstanceType(instancePath, "w", "v"); const path = @@ -32,7 +25,7 @@ export function getInstanceThumbnail( export function getElementThumbnail( client: OnshapeApi, elementPath: ElementPath, - size = ThumbnailSize.STANDARD + size = ThumbnailSize.LARGE ): Promise { assertInstanceType(elementPath, "w", "v"); const path = @@ -48,7 +41,7 @@ export function getElementThumbnail( export function getThumbnailFromWorkspace( client: OnshapeApi, elementPath: ElementPath, - size = ThumbnailSize.STANDARD, + size = ThumbnailSize.LARGE, configuration?: string ): Promise { assertWorkspace(elementPath); @@ -60,6 +53,9 @@ export function getThumbnailFromWorkspace( }); } +/** The configuration matches no insertable, so retrying can only fail again. */ +export class NoSuchConfigurationError extends Error {} + export async function getThumbnailId( client: OnshapeApi, elementPath: ElementPath, @@ -79,7 +75,14 @@ export async function getThumbnailId( }), { query } ); - return insertables.items[0].predictableThumbnailId; + // 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; } /** @@ -90,7 +93,7 @@ export async function getThumbnailId( export function getThumbnailFromId( client: OnshapeApi, thumbnailId: string, - size = ThumbnailSize.STANDARD + size = ThumbnailSize.LARGE ): Promise { const path = apiPath("thumbnails", undefined, undefined, { endId: thumbnailId }) + diff --git a/src/backend/onshape-api/objects/assembly-features.ts b/src/backend/onshape-api/objects/assembly-features.ts index 4f528e1d4..79cc7d73e 100644 --- a/src/backend/onshape-api/objects/assembly-features.ts +++ b/src/backend/onshape-api/objects/assembly-features.ts @@ -57,7 +57,6 @@ export class FastenMateBuilder { this.queries = [...queries]; } - /** Adds a query to the fasten mate. */ addQuery(query: object): this { this.queries.push(query); return this; @@ -80,11 +79,8 @@ export class FastenMateBuilder { } /** - * Constructs a fasten mate feature. - * - * @param queries Up to two queries to fasten. Note Onshape has a tendency to preserve the location - * of the second query in cases where neither instance is constrained. - * @param mateConnectors Implicit mate connectors owned by the feature. + * Takes up to two queries. With neither instance constrained, Onshape tends to + * preserve the second one's location. */ export function fastenMate( name: string, @@ -135,7 +131,6 @@ export function primaryAxisParameter( }; } -/** Constructs a group mate feature. */ export function groupMate(name: string, queries: Iterable): object { return { btType: "BTMMateGroup-65", @@ -145,7 +140,6 @@ export function groupMate(name: string, queries: Iterable): object { }; } -/** Constructs a mate connector feature. */ export function mateConnector( name: string, originQuery: object, diff --git a/src/backend/onshape-api/onshape-api.ts b/src/backend/onshape-api/onshape-api.ts index a7c4e4b82..5780d2594 100644 --- a/src/backend/onshape-api/onshape-api.ts +++ b/src/backend/onshape-api/onshape-api.ts @@ -28,9 +28,8 @@ export class OnshapeApiError extends Error { const DEFAULT_RETRY_AFTER_SECONDS = 60; /** - * Thrown on a 429 response. Carries the Onshape `Retry-After` value (seconds) - * so callers can wait it out. Extends {@link OnshapeApiError} (status 429) so - * existing `status`-based handling keeps working. + * Thrown on a 429, carrying Onshape's `Retry-After` seconds so callers can wait + * it out. Extends {@link OnshapeApiError}, so `status` handling still works. */ export class OnshapeRateLimitError extends OnshapeApiError { constructor( diff --git a/src/backend/onshape-api/onshape-types.ts b/src/backend/onshape-api/onshape-types.ts index 2e122d3e4..4ea581106 100644 --- a/src/backend/onshape-api/onshape-types.ts +++ b/src/backend/onshape-api/onshape-types.ts @@ -1,11 +1,6 @@ /** - * Hand-authored types for the Onshape API endpoints we use — each interface is a - * curated subset of an Onshape response, using our own enums and unions. - * - * To find the real shape of a response (or a field you want to add), regenerate - * the reference dump in `onshape-api-reference/` and read it there; see - * `openapi-ts.config.ts` for the recipe (add the operation to its include list, - * run `npm run gen:onshape-types`, then copy the subset here). + * 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`. */ import { LogicalOp, @@ -295,9 +290,27 @@ export type OnshapePartBodyType = "solid" | "sheet" | "composite"; /** A part in a part studio (the subset we read). */ export interface OnshapePart { partId: string; - /** The part's "Part number" property, when set. */ + /** The part's metadata properties, when set. */ partNumber?: string; bodyType?: OnshapePartBodyType; + name?: string; + description?: string; + material?: { displayName?: string }; + vendor?: string; +} + +// === element metadata (GET /metadata/.../e/{eid}) === + +/** One property in an element's metadata bag. */ +export interface OnshapeMetadataProperty { + /** Display name, e.g. "Part number" — how we pick out the ones we store. */ + name: string; + value: unknown; +} + +/** GET /metadata/.../e/{eid} (the subset we read). */ +export interface OnshapeMetadataObject { + properties: OnshapeMetadataProperty[]; } // === part studios (GET .../partstudios/.../features) === diff --git a/src/backend/parse/build-checks.test.ts b/src/backend/parse/build-checks.test.ts index 165429f16..28cf538b7 100644 --- a/src/backend/parse/build-checks.test.ts +++ b/src/backend/parse/build-checks.test.ts @@ -1,13 +1,26 @@ import { describe, expect, it } from "vitest"; import { ThumbnailSize, ThumbnailUrls, Vendor } from "../../shared/types"; import { BuildIssueType } from "../../shared/build-issues"; +import { DEFAULT_CANONICAL_CONFIGURATION } from "../../shared/canonical-configuration"; +import { thumbnailUrl } from "../../shared/thumbnails"; import { checkGroup, checkInsertable } from "./build-checks"; +import type { ConfigurationRecord } from "../../shared/configuration-models"; +/** What uploadThumbnails returns: the element's default configuration. */ const THUMBNAILS: ThumbnailUrls = { - [ThumbnailSize.TINY]: "/api/thumbnail/tiny/x", - [ThumbnailSize.STANDARD]: "/api/thumbnail/standard/x" + small: defaultThumbnailUrl(ThumbnailSize.SMALL), + large: defaultThumbnailUrl(ThumbnailSize.LARGE) }; +function defaultThumbnailUrl(size: ThumbnailSize): string { + return thumbnailUrl({ + elementId: "element", + microversionId: "microversion", + size, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }); +} + /** A group with nothing wrong; each test spreads in the one fault it checks. */ const HEALTHY_GROUP = { hasThumbnailTab: true, @@ -42,29 +55,76 @@ describe("checkGroup", () => { }); }); +/** An indexed record; only its part number matters to these checks. */ +function record(partNumber: string | null): ConfigurationRecord { + return { + configuration: {}, + partNumber, + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + }; +} + describe("checkInsertable", () => { + const HEALTHY_INSERTABLE = { + vendors: [Vendor.REV], + thumbnailUrls: THUMBNAILS, + records: [record("217-2600")] + }; + it("returns no issues when vendors are parsed and thumbnails generated", () => { - expect( - checkInsertable({ - vendors: [Vendor.REV], - thumbnailUrls: THUMBNAILS - }) - ).toEqual([]); + expect(checkInsertable(HEALTHY_INSERTABLE)).toEqual([]); }); it("infos when no vendors are parsed", () => { const issues = checkInsertable({ - vendors: [], - thumbnailUrls: THUMBNAILS + ...HEALTHY_INSERTABLE, + vendors: [] }); expect(issues).toEqual([{ type: BuildIssueType.NO_VENDORS }]); }); it("errors when the thumbnail failed to generate", () => { const issues = checkInsertable({ - vendors: [Vendor.REV], + ...HEALTHY_INSERTABLE, thumbnailUrls: null }); expect(issues).toEqual([{ type: BuildIssueType.THUMBNAIL_FAILED }]); }); + + it("warns when a vendor part indexed without a part number", () => { + const issues = checkInsertable({ + ...HEALTHY_INSERTABLE, + records: [record(null), record(null)] + }); + expect(issues).toEqual([{ type: BuildIssueType.NO_PART_NUMBER }]); + }); + + it("does not warn when only some configurations lack one", () => { + const issues = checkInsertable({ + ...HEALTHY_INSERTABLE, + records: [record(null), record("217-2600")] + }); + expect(issues).toEqual([]); + }); + + // Nobody sells it, so having no part number is the expected state. + it("does not warn about a custom part", () => { + const issues = checkInsertable({ + ...HEALTHY_INSERTABLE, + vendors: [Vendor.CUSTOM], + records: [record(null)] + }); + expect(issues).toEqual([]); + }); + + // Nothing was probed, so there is nothing to conclude. + it("does not warn when the insertable is not indexed", () => { + const issues = checkInsertable({ ...HEALTHY_INSERTABLE, records: [] }); + expect(issues).toEqual([]); + }); }); diff --git a/src/backend/parse/build-checks.ts b/src/backend/parse/build-checks.ts index f0d1cf801..808fb93fb 100644 --- a/src/backend/parse/build-checks.ts +++ b/src/backend/parse/build-checks.ts @@ -1,9 +1,10 @@ -import { ThumbnailUrls, Vendor } from "../../shared/types"; +import { ThumbnailUrls, Vendor, isCustomPart } from "../../shared/types"; import { addBuildIssue, BuildIssue, BuildIssueType } from "../../shared/build-issues"; +import type { ConfigurationRecord } from "../../shared/configuration-models"; interface GroupCheckInput { /** Whether the Onshape document has a designated thumbnail tab/element. */ @@ -44,6 +45,8 @@ interface InsertableCheckInput { vendors: Vendor[]; /** The uploaded thumbnail URLs, or `null` when generation failed. */ thumbnailUrls: ThumbnailUrls | null; + /** Indexed configuration records; empty when the insertable isn't indexed. */ + records: ConfigurationRecord[]; } /** @@ -63,5 +66,26 @@ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { issues = addBuildIssue(issues, { type: BuildIssueType.NO_VENDORS }); } + issues = addBuildIssue( + issues, + ...checkIndexedPartNumber(input.vendors, input.records) + ); + return issues; } + +/** + * A custom part is expected to have no part number; anything a vendor sells + * should have one in at least one configuration. + */ +export function checkIndexedPartNumber( + vendors: Vendor[], + records: ConfigurationRecord[] +): BuildIssue[] { + if (isCustomPart(vendors) || records.length === 0) { + return []; + } + return records.some((record) => record.partNumber) + ? [] + : [{ type: BuildIssueType.NO_PART_NUMBER }]; +} diff --git a/src/backend/parse/parse-configuration-records.test.ts b/src/backend/parse/parse-configuration-records.test.ts new file mode 100644 index 000000000..6f264a2a6 --- /dev/null +++ b/src/backend/parse/parse-configuration-records.test.ts @@ -0,0 +1,351 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { countConfigurations } from "../../shared/configuration-combinations"; +import * as PartsEndpoints from "../onshape-api/endpoints/parts"; +import * as MetadataEndpoints from "../onshape-api/endpoints/metadata"; +import { OnshapeApi } from "../onshape-api/onshape-api"; +import type { + OnshapeMetadataObject, + OnshapePart +} from "../onshape-api/onshape-types"; +import { ElementPath } from "../../shared/onshape-path"; +import { + ParameterValues, + ConfigurationParameter +} from "../../shared/configuration-models"; +import { enumParam } from "../../__test_utils__/configuration-fixtures"; +import { ElementType } from "../../shared/types"; +import { BuildIssueType } from "../../shared/build-issues"; +import { + decideIndexing, + parseAssemblyRecord, + parseConfigurationRecords, + parsePartStudioRecord +} from "./parse-configuration-records"; + +const PATH: ElementPath = { + documentId: "d", + instanceId: "v", + instanceType: "v", + elementId: "e" +}; + +/** The client is only forwarded to the endpoint wrappers, which are mocked. */ +const CLIENT = {} as OnshapeApi; + +afterEach(() => vi.restoreAllMocks()); + +/** A single enum whose N options make the element enumerate to N configurations. */ +function paramsWithConfigs(count: number): ConfigurationParameter[] { + return [ + enumParam( + "A", + Array.from({ length: count }, (_, i) => `o${i}`) + ) + ]; +} + +const MANY = [{ type: BuildIssueType.MANY_CONFIGURATIONS }]; +const TOO_MANY = [{ type: BuildIssueType.TOO_MANY_CONFIGURATIONS }]; + +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: [] }, + // At the line it waits, flagged so an admin can trim or enable it. + { 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. + { 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( + paramsWithConfigs(configs), + force + ); + expect({ shouldIndex, buildIssues }).toEqual({ + shouldIndex: index, + buildIssues: issues + }); + }); +}); + +describe("parsePartStudioRecord", () => { + it("reads the single part's metadata into the record", () => { + expect( + parsePartStudioRecord( + [ + { + partId: "p", + partNumber: " 217-2600 ", + name: "Bracket", + description: "A bracket", + material: { displayName: "6061 Aluminum" }, + vendor: "AM" + } + ], + { size: "L" }, + false + ) + ).toEqual({ + configuration: { size: "L" }, + partNumber: "217-2600", + name: "Bracket", + description: "A bracket", + material: "6061 Aluminum", + vendor: "AM", + hasMultipleParts: false, + isUnstableComposite: false + }); + }); + + it("reads the first part and flags more than one, when not a composite", () => { + const record = parsePartStudioRecord( + [ + { partId: "a", partNumber: "217-2601" }, + { partId: "b", partNumber: "217-2602" } + ], + {}, + false + ); + expect(record.partNumber).toBe("217-2601"); + expect(record.hasMultipleParts).toBe(true); + }); + + it("reads the composite when the studio is an open composite", () => { + const record = parsePartStudioRecord( + [ + { partId: "a", partNumber: "loose" }, + { partId: "c", partNumber: "COMP-1", bodyType: "composite" } + ], + {}, + true + ); + expect(record.partNumber).toBe("COMP-1"); + expect(record.hasMultipleParts).toBe(false); + expect(record.isUnstableComposite).toBe(false); + }); + + it("flags an unstable composite when a configuration loses its composite", () => { + expect( + parsePartStudioRecord( + [{ partId: "a", partNumber: "loose" }], + { size: "S" }, + true + ) + ).toEqual({ + configuration: { size: "S" }, + partNumber: null, + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: true + }); + }); + + it("returns an all-null record for an empty response", () => { + expect(parsePartStudioRecord([], { A: "a1" }, false)).toEqual({ + configuration: { A: "a1" }, + partNumber: null, + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + }); + }); +}); + +describe("parseAssemblyRecord", () => { + it("pulls the stored fields out of the metadata property bag", () => { + const metadata: OnshapeMetadataObject = { + properties: [ + { name: "Part number", value: " AM-1234 " }, + { name: "Name", value: "Gearbox" }, + { name: "Description", value: "A gearbox" }, + { name: "Material", value: { displayName: "Steel" } }, + { name: "Vendor", value: "AM" }, + // Not one of the fields we store — ignored. + { name: "State", value: "In Progress" } + ] + }; + expect(parseAssemblyRecord(metadata, { q: "1" })).toEqual({ + configuration: { q: "1" }, + partNumber: "AM-1234", + name: "Gearbox", + description: "A gearbox", + material: "Steel", + vendor: "AM", + hasMultipleParts: false, + isUnstableComposite: false + }); + }); +}); + +/** Mocks the parts endpoint, deriving a studio's parts from the configuration. */ +function mockParts( + partsFor: (configuration: ParameterValues) => OnshapePart[] +) { + return vi + .spyOn(PartsEndpoints, "getParts") + .mockImplementation((_client, _path, configuration) => + Promise.resolve(partsFor(configuration)) + ); +} + +describe("parseConfigurationRecords", () => { + it("returns a record per configuration, default first, in enumeration order", async () => { + mockParts((configuration) => [ + { partId: "p", partNumber: `PN-${configuration.A ?? "default"}` } + ]); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.PART_STUDIO, + [enumParam("A", ["a1", "a2"])], + countConfigurations([enumParam("A", ["a1", "a2"])]).configurations, + false + ); + + expect(result.buildIssues).toEqual([]); + // "a1" is A's default, so that combination is the default probe under + // another name and is not probed again. + expect(result.records.map((r) => r.partNumber)).toEqual([ + "PN-default", + "PN-a2" + ]); + }); + + it("probes every combination when none of them is the default", async () => { + mockParts((configuration) => [ + { partId: "p", partNumber: `PN-${configuration.A ?? "default"}` } + ]); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.PART_STUDIO, + [{ ...enumParam("A", ["a1", "a2"]), default: "a2" }], + countConfigurations([ + { ...enumParam("A", ["a1", "a2"]), default: "a2" } + ]).configurations, + false + ); + + expect(result.records.map((r) => r.partNumber)).toEqual([ + "PN-default", + "PN-a1" + ]); + }); + + it("flags a studio with more than one part in any configuration", async () => { + mockParts((configuration) => + configuration.A === "a2" + ? [ + { partId: "p1", partNumber: "PN-1" }, + { partId: "p2", partNumber: "PN-2" } + ] + : [{ partId: "p1", partNumber: "PN-1" }] + ); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.PART_STUDIO, + [enumParam("A", ["a1", "a2"])], + countConfigurations([enumParam("A", ["a1", "a2"])]).configurations, + false + ); + + expect(result.buildIssues).toEqual([ + { type: BuildIssueType.MULTIPLE_PARTS } + ]); + }); + + it("flags an unstable composite when a configuration loses its composite", async () => { + mockParts((configuration) => + configuration.A === "a2" + ? [{ partId: "p", partNumber: "PN-2" }] + : [ + { + partId: "c", + partNumber: "COMP", + bodyType: "composite" + }, + { partId: "p", partNumber: "loose" } + ] + ); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.PART_STUDIO, + [enumParam("A", ["a1", "a2"])], + countConfigurations([enumParam("A", ["a1", "a2"])]).configurations, + true + ); + + expect(result.buildIssues).toEqual([ + { type: BuildIssueType.UNSTABLE_COMPOSITE } + ]); + }); + + // 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" } + ]); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.PART_STUDIO, + paramsWithConfigs(600), + countConfigurations(paramsWithConfigs(600)).configurations, + false + ); + + expect(result.buildIssues).toEqual([]); + expect(result.records).toHaveLength(1); + expect(result.records[0].partNumber).toBe("PN-default"); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("indexes an assembly through its element metadata", async () => { + const spy = vi + .spyOn(MetadataEndpoints, "getElementMetadata") + .mockResolvedValue({ + properties: [{ name: "Part number", value: "AM-1" }] + }); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.ASSEMBLY, + [], + countConfigurations([]).configurations, + false + ); + + expect(result.records).toEqual([ + { + configuration: {}, + partNumber: "AM-1", + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + } + ]); + expect(spy).toHaveBeenCalledWith(CLIENT, PATH, {}); + }); +}); diff --git a/src/backend/parse/parse-configuration-records.ts b/src/backend/parse/parse-configuration-records.ts new file mode 100644 index 000000000..92befcfab --- /dev/null +++ b/src/backend/parse/parse-configuration-records.ts @@ -0,0 +1,400 @@ +/** + * 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. + */ +import { OnshapeApi } from "../onshape-api/onshape-api"; +import { ElementPath } from "../../shared/onshape-path"; +import { ElementType } from "../../shared/types"; +import { + ParameterValues, + ConfigurationParameter, + ConfigurationRecord +} from "../../shared/configuration-models"; +import { + addBuildIssue, + type BuildIssue, + BuildIssueType +} from "../../shared/build-issues"; +import { + countConfigurations, + IndexingBand, + isIndexingEnabled +} from "../../shared/configuration-combinations"; +import { canonicalizeConfiguration } from "../../shared/canonical-configuration"; +import { getParts } from "../onshape-api/endpoints/parts"; +import { getElementMetadata } from "../onshape-api/endpoints/metadata"; +import type { + OnshapeMetadataObject, + OnshapePart +} from "../onshape-api/onshape-types"; +import { + type LoadContext, + getOnshapeApiFromContext +} from "../load/load-common"; +import { ONSHAPE_STEP_RETRIES } from "../load/load-steps"; + +/** Configurations fetched per workflow step. */ +const BATCH_SIZE = 20; + +/** An insertable's configuration records, and the issues indexing them raised. */ +export interface ConfigurationRecordsResult { + records: ConfigurationRecord[]; + buildIssues: BuildIssue[]; +} + +/** The result for an insertable that isn't indexed. */ +export const NO_RECORDS: ConfigurationRecordsResult = { + records: [], + 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. + */ +export const INDEXING_ISSUE_TYPES = [ + BuildIssueType.TOO_MANY_CONFIGURATIONS, + BuildIssueType.MANY_CONFIGURATIONS, + BuildIssueType.MULTIPLE_PARTS, + BuildIssueType.UNSTABLE_COMPOSITE, + BuildIssueType.NO_PART_NUMBER +]; + +/** Whether to index an insertable, and how to flag it if we don't. */ +export interface IndexingDecision { + /** Index below the threshold, or above it when an admin enabled it. */ + shouldIndex: boolean; + /** The limit issues this decision raises, if any. */ + buildIssues: BuildIssue[]; + /** The combinations to probe, already enumerated by the count. */ + configurations: ParameterValues[]; +} + +/** Past the hard cap forcing it on cannot help, since enumeration stops there. */ +export function decideIndexing( + parameters: ConfigurationParameter[], + indexConfigurations: boolean +): IndexingDecision { + const { band, configurations } = countConfigurations(parameters); + const shouldIndex = isIndexingEnabled(band, indexConfigurations); + + if (band === IndexingBand.EXCEEDED) { + return { + shouldIndex, + buildIssues: [{ type: BuildIssueType.TOO_MANY_CONFIGURATIONS }], + configurations + }; + } + if (band === IndexingBand.MANUAL && !indexConfigurations) { + return { + shouldIndex, + buildIssues: [{ type: BuildIssueType.MANY_CONFIGURATIONS }], + configurations + }; + } + return { shouldIndex, buildIssues: [], configurations }; +} + +/** Trims a raw metadata value; a missing or blank one becomes `null`. */ +function normalizeText(value: string | undefined | null): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +/** What a part studio's parts resolve to, before build issues are decided. */ +export interface PartsEvaluation { + /** True when more than one part could be the one to index. */ + hasMultipleParts: boolean; + /** True when the studio is an open composite (see {@link computeOpenComposite}). */ + isOpenComposite: boolean; + /** The part whose record to read, or `undefined` when there are none. */ + 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. + */ +export function evaluateParts(parts: OnshapePart[]): PartsEvaluation { + const composites = parts.filter((part) => part.bodyType === "composite"); + if (parts.length > 1 && composites.length > 0) { + return { + hasMultipleParts: composites.length > 1, + isOpenComposite: true, + partToUse: composites[0] + }; + } + return { + hasMultipleParts: parts.length > 1, + isOpenComposite: false, + partToUse: parts[0] + }; +} + +/** Stable across configurations, so it is computed once from the default. */ +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[], + configuration: ParameterValues, + isOpenComposite: boolean +): ConfigurationRecord { + const evaluation = evaluateParts(parts); + if (isOpenComposite && !evaluation.isOpenComposite) { + return { + configuration, + partNumber: null, + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: true + }; + } + const part = evaluation.partToUse; + return { + configuration, + partNumber: normalizeText(part?.partNumber), + name: normalizeText(part?.name), + description: normalizeText(part?.description), + material: normalizeText(part?.material?.displayName), + vendor: normalizeText(part?.vendor), + hasMultipleParts: evaluation.hasMultipleParts, + isUnstableComposite: false + }; +} + +/** Onshape's default display names for the metadata properties we store. */ +const METADATA_FIELDS = { + "Part number": "partNumber", + Name: "name", + Description: "description", + Material: "material", + Vendor: "vendor" +} as const; + +/** Reads a metadata property value as text; materials arrive as `{displayName}`. */ +function readMetadataValue(value: unknown): string | null { + if (typeof value === "string") { + return normalizeText(value); + } + if (value && typeof value === "object" && "displayName" in value) { + return normalizeText((value as { displayName?: string }).displayName); + } + return null; +} + +/** Builds a record from an assembly's element metadata for one configuration. */ +export function parseAssemblyRecord( + metadata: OnshapeMetadataObject, + configuration: ParameterValues +): ConfigurationRecord { + const record: ConfigurationRecord = { + configuration, + partNumber: null, + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + }; + for (const property of metadata.properties) { + const field = + METADATA_FIELDS[property.name as keyof typeof METADATA_FIELDS]; + if (field) { + record[field] = readMetadataValue(property.value); + } + } + return record; +} + +/** + * Indexes an insertable's configuration records in one pass. For request + * handlers, which have no workflow step to hang the fetches off. + */ +export async function parseConfigurationRecords( + client: OnshapeApi, + elementPath: ElementPath, + elementType: ElementType, + parameters: ConfigurationParameter[], + configurations: ParameterValues[], + isOpenComposite: boolean +): Promise { + const defaultRecord = await probeConfiguration( + client, + elementPath, + elementType, + {}, + isOpenComposite + ); + const batches = planBatches(configurations, parameters); + + const batchRecords: ConfigurationRecord[][] = []; + for (const batch of batches) { + batchRecords.push( + await fetchBatch( + client, + elementPath, + elementType, + batch, + isOpenComposite + ) + ); + } + return toResult(defaultRecord, batchRecords, parameters); +} + +/** + * One durable step per batch, so a rate-limited retry re-fetches only that + * batch. An exhausted batch throws rather than saving a half-built list. + */ +export async function loadConfigurationRecords( + ctx: LoadContext, + insertableId: string, + elementPath: ElementPath, + elementType: ElementType, + parameters: ConfigurationParameter[], + configurations: ParameterValues[], + isOpenComposite: boolean +): Promise { + const defaultRecord = await ctx.step.do( + `records-${insertableId}-default`, + { retries: ONSHAPE_STEP_RETRIES }, + async () => + probeConfiguration( + await getOnshapeApiFromContext(ctx), + elementPath, + elementType, + {}, + isOpenComposite + ) + ); + const batches = planBatches(configurations, parameters); + + const batchRecords: ConfigurationRecord[][] = []; + for (const [index, batch] of batches.entries()) { + batchRecords.push( + await ctx.step.do( + `records-${insertableId}-batch-${index}`, + { retries: ONSHAPE_STEP_RETRIES }, + async () => + fetchBatch( + await getOnshapeApiFromContext(ctx), + elementPath, + elementType, + batch, + isOpenComposite + ) + ) + ); + } + return toResult(defaultRecord, batchRecords, parameters); +} + +/** + * Splits the combinations to fetch into batches, minus anything the separate + * default probe already covers. + */ +function planBatches( + configurations: ParameterValues[], + parameters: ConfigurationParameter[] +): ParameterValues[][] { + // Canonicalizing to the default means landing on the default probe's record, + // so drop every all-defaults combination, not just the empty one. + const toFetch = configurations.filter( + (configuration) => + Object.keys(canonicalizeConfiguration(configuration, parameters)) + .length > 0 + ); + + const batches: ParameterValues[][] = []; + 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. */ +async function probeConfiguration( + client: OnshapeApi, + elementPath: ElementPath, + elementType: ElementType, + configuration: ParameterValues, + isOpenComposite: boolean +): Promise { + if (elementType === ElementType.ASSEMBLY) { + return parseAssemblyRecord( + await getElementMetadata(client, elementPath, configuration), + configuration + ); + } + return parsePartStudioRecord( + await getParts(client, elementPath, configuration), + configuration, + isOpenComposite + ); +} + +/** Probes each configuration in a batch. */ +async function fetchBatch( + client: OnshapeApi, + elementPath: ElementPath, + elementType: ElementType, + batch: ParameterValues[], + isOpenComposite: boolean +): Promise { + const records: ConfigurationRecord[] = []; + for (const configuration of batch) { + records.push( + await probeConfiguration( + client, + elementPath, + elementType, + configuration, + isOpenComposite + ) + ); + } + return records; +} + +/** Folds the default probe and every batch together, the default first. */ +function toResult( + defaultRecord: ConfigurationRecord, + batches: ConfigurationRecord[][], + parameters: ConfigurationParameter[] +): ConfigurationRecordsResult { + // Canonical, so a record addresses the same thumbnail the insert menu does + // for the same selection. + const records = [defaultRecord, ...batches.flat()].map((record) => ({ + ...record, + configuration: canonicalizeConfiguration( + record.configuration, + parameters + ) + })); + + // A capped insertable never reaches here: decideIndexing turns indexing off + // past the cap, and raises TOO_MANY_CONFIGURATIONS itself. + let buildIssues: BuildIssue[] = []; + if (records.some((record) => record.hasMultipleParts)) { + buildIssues = addBuildIssue(buildIssues, { + type: BuildIssueType.MULTIPLE_PARTS + }); + } + if (records.some((record) => record.isUnstableComposite)) { + buildIssues = addBuildIssue(buildIssues, { + type: BuildIssueType.UNSTABLE_COMPOSITE + }); + } + + return { records, buildIssues }; +} diff --git a/src/backend/parse/parse-document-contents.ts b/src/backend/parse/parse-document-contents.ts index fba8a9dc1..33ef27354 100644 --- a/src/backend/parse/parse-document-contents.ts +++ b/src/backend/parse/parse-document-contents.ts @@ -15,13 +15,8 @@ const VALID_ELEMENT_TYPES = new Set([ ]); /** - * The part studio / assembly tabs we load, in the order they appear in the - * document's tab bar. - * - * `contents.elements` is unordered, and `contents.folders` is the folder tree - * that defines display order — so walk the tree and pick up each tab as it is - * encountered. Onshape has been known to omit a tab from the tree, so anything - * left over is appended rather than dropped. + * 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. */ export function parseInsertableTabs( contents: OnshapeDocumentContents diff --git a/src/backend/parse/parse-part-number.test.ts b/src/backend/parse/parse-part-number.test.ts deleted file mode 100644 index 2bbba3266..000000000 --- a/src/backend/parse/parse-part-number.test.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { - OnshapeAssemblyDefinition, - OnshapePart -} from "../onshape-api/onshape-types"; -import * as PartsEndpoints from "../onshape-api/endpoints/parts"; -import { OnshapeApi } from "../onshape-api/onshape-api"; -import { ElementPath } from "../../shared/onshape-path"; -import { - ParameterValues, - ConfigurationParameter -} from "../../shared/configuration-models"; -import { enumParam } from "../../__test_utils__/configuration-fixtures"; -import { ElementType } from "../../shared/types"; -import { BuildIssueType } from "../../shared/build-issues"; -import { - computeOpenComposite, - evaluateParts, - normalizePartNumber, - parseAssemblyPartNumber, - parsePartNumbers, - parsePartStudioParts -} from "./parse-part-number"; - -/** A minimal assembly definition carrying the given root part number. */ -function assembly(partNumber?: string): OnshapeAssemblyDefinition { - return { - rootAssembly: { features: [], instances: [], partNumber }, - parts: [], - subAssemblies: [] - }; -} - -const PATH: ElementPath = { - documentId: "d", - instanceId: "v", - instanceType: "v", - elementId: "e" -}; - -/** The client is only forwarded to the endpoint wrapper, which is mocked. */ -const CLIENT = {} as OnshapeApi; - -/** - * Mocks the parts endpoint, deriving the studio's parts from the requested - * configuration. An empty configuration is the element's defaults. - */ -function mockParts( - partsFor: (configuration: ParameterValues) => OnshapePart[] -) { - return vi - .spyOn(PartsEndpoints, "getParts") - .mockImplementation((_client, _path, configuration) => - Promise.resolve(partsFor(configuration)) - ); -} - -/** Mocks a single-part studio whose part number depends on the configuration. */ -function mockPartNumbers( - partNumberFor: (configuration: ParameterValues) => string | undefined -) { - return mockParts((configuration) => [ - { partId: "p", partNumber: partNumberFor(configuration) } - ]); -} - -afterEach(() => vi.restoreAllMocks()); - -describe("normalizePartNumber", () => { - it("trims surrounding whitespace", () => { - expect(normalizePartNumber(" 217-2600 ")).toBe("217-2600"); - }); - - it("maps missing or blank values to null", () => { - expect(normalizePartNumber(undefined)).toBeNull(); - expect(normalizePartNumber("")).toBeNull(); - expect(normalizePartNumber(" ")).toBeNull(); - }); -}); - -describe("evaluateParts", () => { - it("indexes the sole part of a normal single-part studio", () => { - expect( - evaluateParts([ - { partId: "p", partNumber: "PN-1", bodyType: "solid" } - ]) - ).toEqual({ - hasMultipleParts: false, - isOpenComposite: false, - partToUse: { partId: "p", partNumber: "PN-1", bodyType: "solid" } - }); - }); - - it("flags multiple parts when a non-composite studio has more than one", () => { - expect( - evaluateParts([ - { partId: "p1", bodyType: "solid" }, - { partId: "p2", bodyType: "solid" } - ]) - ).toMatchObject({ hasMultipleParts: true, isOpenComposite: false }); - }); - - it("uses the composite and ignores the constituents of an open composite", () => { - expect( - evaluateParts([ - { partId: "c", partNumber: "PN-C", bodyType: "composite" }, - { partId: "p1", partNumber: "PN-1", bodyType: "solid" } - ]) - ).toMatchObject({ - hasMultipleParts: false, - isOpenComposite: true, - partToUse: { partId: "c" } - }); - }); - - it("is not an open composite for a lone composite part", () => { - expect( - evaluateParts([{ partId: "c", bodyType: "composite" }]) - .isOpenComposite - ).toBe(false); - }); - - it("flags multiple parts when more than one composite resolves", () => { - expect( - evaluateParts([ - { partId: "c1", bodyType: "composite" }, - { partId: "c2", bodyType: "composite" } - ]) - ).toMatchObject({ hasMultipleParts: true, isOpenComposite: true }); - }); -}); - -describe("computeOpenComposite", () => { - it("reports whether the parts form an open composite", () => { - expect( - computeOpenComposite([ - { partId: "c", bodyType: "composite" }, - { partId: "p", bodyType: "solid" } - ]) - ).toBe(true); - expect(computeOpenComposite([{ partId: "p", bodyType: "solid" }])).toBe( - false - ); - }); -}); - -describe("parsePartStudioParts", () => { - it("reads the sole part's number for a normal studio", () => { - expect( - parsePartStudioParts( - [{ partId: "JHD", partNumber: " 217-2600 " }], - false - ) - ).toEqual({ - partNumber: "217-2600", - hasMultipleParts: false, - isUnstableComposite: false - }); - }); - - it("reads the composite's number when the studio is an open composite", () => { - expect( - parsePartStudioParts( - [ - { partId: "c", partNumber: "PN-C", bodyType: "composite" }, - { partId: "p", partNumber: "PN-1", bodyType: "solid" } - ], - true - ) - ).toEqual({ - partNumber: "PN-C", - hasMultipleParts: false, - isUnstableComposite: false - }); - }); - - it("flags an unstable composite and indexes nothing when an expected composite is missing", () => { - expect( - parsePartStudioParts( - [ - { partId: "p1", partNumber: "PN-1", bodyType: "solid" }, - { partId: "p2", partNumber: "PN-2", bodyType: "solid" } - ], - true - ) - ).toEqual({ - partNumber: null, - hasMultipleParts: false, - isUnstableComposite: true - }); - }); -}); - -describe("parseAssemblyPartNumber", () => { - it("returns the root assembly's part number", () => { - expect(parseAssemblyPartNumber(assembly(" AM-1234 "))).toBe("AM-1234"); - }); - - it("returns null when the assembly has no part number", () => { - expect(parseAssemblyPartNumber(assembly())).toBeNull(); - expect(parseAssemblyPartNumber(assembly(""))).toBeNull(); - }); -}); - -describe("parsePartNumbers", () => { - it("dedupes configurations resolving to the same part number (first-wins)", async () => { - const params: ConfigurationParameter[] = [ - enumParam("A", ["a1", "a2"]), - enumParam("B", ["b1", "b2"]) - ]; - // The part number depends only on A, so the two B values collapse. - mockPartNumbers( - (configuration) => `PN-${configuration.A ?? "default"}` - ); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - params, - false - ); - - expect(result.buildIssues).toEqual([]); - expect(result.partNumbers).toEqual({ - "PN-a1": { A: "a1", B: "b1" }, - "PN-a2": { A: "a2", B: "b1" } - }); - }); - - it("records the default configuration's part number even when configurable", async () => { - mockPartNumbers( - (configuration) => `PN-${configuration.A ?? "default"}` - ); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - false - ); - - expect(result.defaultPartNumber).toBe("PN-default"); - }); - - it("drops configurations with no part number", async () => { - mockPartNumbers((configuration) => - configuration.A === "a1" ? "PN-a1" : undefined - ); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - false - ); - - expect(result.partNumbers).toEqual({ "PN-a1": { A: "a1" } }); - }); - - it("stores the default part number for non-configurable insertables", async () => { - const spy = mockPartNumbers(() => "PN-default"); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [], - false - ); - - expect(result.defaultPartNumber).toBe("PN-default"); - expect(result.partNumbers).toEqual({}); - // Only the default probe; there are no combinations to enumerate. - expect(spy).toHaveBeenCalledTimes(1); - }); - - // 20 configurations per batch, so this spans three of them. Batching is - // internal, so assert every combination was still fetched exactly once. - it("indexes every configuration across batch boundaries", async () => { - const params: ConfigurationParameter[] = [ - enumParam("A", ["a1", "a2", "a3", "a4", "a5", "a6", "a7"]), - enumParam("B", ["b1", "b2", "b3", "b4", "b5", "b6", "b7"]) - ]; - const spy = mockPartNumbers( - (configuration) => `PN-${configuration.A}-${configuration.B}` - ); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - params, - false - ); - - expect(Object.keys(result.partNumbers)).toHaveLength(49); - // 49 combinations plus the default probe. - expect(spy).toHaveBeenCalledTimes(50); - }); - - it("flags capped enumeration but still records the default", async () => { - // 2^10 = 1024 combinations, past MAX_PART_NUMBER_CONFIGURATIONS. - const params: ConfigurationParameter[] = Array.from( - { length: 10 }, - (_, i) => enumParam(`P${i}`, ["x", "y"]) - ); - mockPartNumbers(() => "PN-default"); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - params, - false - ); - - expect(result.buildIssues).toEqual([ - { type: BuildIssueType.TOO_MANY_CONFIGURATIONS } - ]); - expect(result.partNumbers).toEqual({}); - expect(result.defaultPartNumber).toBe("PN-default"); - }); - - it("flags a studio with more than one part in any configuration", async () => { - mockParts((configuration) => - configuration.A === "a2" - ? [ - { partId: "p1", partNumber: "PN-1" }, - { partId: "p2", partNumber: "PN-2" } - ] - : [{ partId: "p1", partNumber: "PN-1" }] - ); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - false - ); - - expect(result.buildIssues).toEqual([ - { type: BuildIssueType.MULTIPLE_PARTS } - ]); - }); - - it("does not flag a single-part studio", async () => { - mockParts(() => [{ partId: "p1", partNumber: "PN-1" }]); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - false - ); - - expect(result.buildIssues).toEqual([]); - }); - - it("indexes an open composite from its composite part alone", async () => { - mockParts((configuration) => [ - { - partId: "c", - partNumber: `PN-${configuration.A ?? "default"}`, - bodyType: "composite" - }, - { partId: "p1", partNumber: "loose-1", bodyType: "solid" }, - { partId: "p2", partNumber: "loose-2", bodyType: "solid" } - ]); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - true - ); - - expect(result.buildIssues).toEqual([]); - expect(result.defaultPartNumber).toBe("PN-default"); - expect(result.partNumbers).toEqual({ - "PN-a1": { A: "a1" }, - "PN-a2": { A: "a2" } - }); - }); - - it("flags an unstable composite when a configuration loses its composite", async () => { - mockParts((configuration) => - configuration.A === "a2" - ? [ - { partId: "p1", partNumber: "PN-1", bodyType: "solid" }, - { partId: "p2", partNumber: "PN-2", bodyType: "solid" } - ] - : [ - { - partId: "c", - partNumber: "PN-C", - bodyType: "composite" - }, - { partId: "p1", partNumber: "loose", bodyType: "solid" } - ] - ); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - true - ); - - expect(result.buildIssues).toEqual([ - { type: BuildIssueType.UNSTABLE_COMPOSITE } - ]); - // Only the composite's number is indexed; the a2 loose parts are ignored. - expect(result.partNumbers).toEqual({ "PN-C": { A: "a1" } }); - }); - - it("indexes an assembly from its root part number", async () => { - vi.spyOn(PartsEndpoints, "getAssemblyDefinition").mockResolvedValue( - assembly("AM-1") - ); - - const result = await parsePartNumbers( - CLIENT, - PATH, - ElementType.ASSEMBLY, - [enumParam("A", ["a1", "a2"])], - false - ); - - expect(result.buildIssues).toEqual([]); - expect(result.defaultPartNumber).toBe("AM-1"); - }); -}); diff --git a/src/backend/parse/parse-part-number.ts b/src/backend/parse/parse-part-number.ts deleted file mode 100644 index 707527a02..000000000 --- a/src/backend/parse/parse-part-number.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * Part numbers: extracting them from Onshape's responses, and indexing an - * insertable's configurations into the map search uses. - * - * Part numbers become search terms and the keys of a `PartNumberMap`, so a value - * is normalized (trimmed) and a blank one is treated as unset. - * - * Like `parseFastenInfo`, the indexing entry points take a client and call - * Onshape themselves, so the extraction rules and the walk that uses them live - * together. - */ -import { OnshapeApi } from "../onshape-api/onshape-api"; -import { ElementPath } from "../../shared/onshape-path"; -import { ElementType } from "../../shared/types"; -import { - ParameterValues, - ConfigurationParameter, - PartNumberMap -} from "../../shared/configuration-models"; -import { - addBuildIssue, - type BuildIssue, - BuildIssueType -} from "../../shared/build-issues"; -import { enumerateConfigurations } from "../../shared/configuration-combinations"; -import { - getAssemblyDefinition, - getParts -} from "../onshape-api/endpoints/parts"; -import type { - OnshapeAssemblyDefinition, - OnshapePart -} from "../onshape-api/onshape-types"; -import { - type LoadContext, - getOnshapeApiFromContext -} from "../load/load-common"; -import { ONSHAPE_STEP_RETRIES } from "../load/load-steps"; - -/** Configurations fetched per workflow step. */ -const BATCH_SIZE = 20; - -/** An insertable's indexed part numbers, and the issues indexing them raised. */ -export interface PartNumberResult { - /** - * The part number of the insertable's default configuration, whether or not - * it is configurable. Also present in `partNumbers` for configurable ones. - */ - defaultPartNumber: string | null; - /** Deduped map of part number -> the configuration that produces it. */ - partNumbers: PartNumberMap; - /** Issues raised while indexing; see {@link PART_NUMBER_ISSUE_TYPES}. */ - buildIssues: BuildIssue[]; -} - -/** The result for an insertable that isn't indexed. */ -export const NO_PART_NUMBERS: PartNumberResult = { - defaultPartNumber: null, - partNumbers: {}, - 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. - */ -export const PART_NUMBER_ISSUE_TYPES = [ - BuildIssueType.TOO_MANY_CONFIGURATIONS, - BuildIssueType.MULTIPLE_PARTS, - BuildIssueType.UNSTABLE_COMPOSITE -]; - -/** - * Normalizes a raw part-number property: trims surrounding whitespace and maps - * a missing or blank value to `null`. - */ -export function normalizePartNumber( - partNumber: string | undefined -): string | null { - const trimmed = partNumber?.trim(); - return trimmed ? trimmed : null; -} - -/** What a part studio's parts resolve to, before build issues are decided. */ -export interface PartsEvaluation { - /** True when more than one part could be the one to index. */ - hasMultipleParts: boolean; - /** True when the studio is an open composite (see {@link computeOpenComposite}). */ - isOpenComposite: boolean; - /** The part whose number to index, or `undefined` when there are none. */ - partToUse: OnshapePart | undefined; -} - -/** - * The one place that reads meaning out of a `/parts` response: whether the - * studio is an open composite, and which part carries the number to index. An - * indexed part studio is meant to be a single part; an open composite is the - * exception, where only the composite matters and the loose constituents are - * ignored. More than one candidate (part, or composite) is the arbitrary-choice - * case that `MULTIPLE_PARTS` flags. - */ -export function evaluateParts(parts: OnshapePart[]): PartsEvaluation { - const composites = parts.filter((part) => part.bodyType === "composite"); - if (parts.length > 1 && composites.length > 0) { - return { - hasMultipleParts: composites.length > 1, - isOpenComposite: true, - partToUse: composites[0] - }; - } - return { - hasMultipleParts: parts.length > 1, - isOpenComposite: false, - partToUse: parts[0] - }; -} - -/** - * Whether a part studio is an open composite: it resolves to more than one part - * and one of them is the composite. Stable across configurations, so it's - * computed once from the default configuration. - */ -export function computeOpenComposite(parts: OnshapePart[]): boolean { - return evaluateParts(parts).isOpenComposite; -} - -/** What a part studio resolved to for one configuration. */ -export interface PartStudioParts { - /** The part number of its part, or `null` if none is set. */ - partNumber: string | null; - /** True when the studio has more than one part; see `MULTIPLE_PARTS`. */ - hasMultipleParts: boolean; - /** True when an open composite lost its composite here; see `UNSTABLE_COMPOSITE`. */ - isUnstableComposite: boolean; -} - -/** - * Reads a part studio's part number for one configuration. `isOpenComposite` is - * the studio's expected state (from its default configuration): a config that - * doesn't resolve to a composite when one is expected is an `UNSTABLE_COMPOSITE`, - * and we index nothing for it rather than a stray constituent part. - */ -export function parsePartStudioParts( - parts: OnshapePart[], - isOpenComposite: boolean -): PartStudioParts { - const evaluation = evaluateParts(parts); - if (isOpenComposite && !evaluation.isOpenComposite) { - return { - partNumber: null, - hasMultipleParts: false, - isUnstableComposite: true - }; - } - return { - partNumber: normalizePartNumber(evaluation.partToUse?.partNumber), - hasMultipleParts: evaluation.hasMultipleParts, - isUnstableComposite: false - }; -} - -/** - * Returns the root assembly's part number, or `null` if none is set. - */ -export function parseAssemblyPartNumber( - definition: OnshapeAssemblyDefinition -): string | null { - return normalizePartNumber(definition.rootAssembly.partNumber); -} - -/** - * Indexes an insertable's part numbers in one pass. For request handlers, which - * have no workflow step to hang the fetches off. - */ -export async function parsePartNumbers( - client: OnshapeApi, - elementPath: ElementPath, - elementType: ElementType, - parameters: ConfigurationParameter[], - isOpenComposite: boolean -): Promise { - const probe = await probePartNumber( - client, - elementPath, - elementType, - {}, - isOpenComposite - ); - const { batches, capped } = planPartNumberBatches(parameters); - - const fetched: PartNumberBatch[] = []; - for (const batch of batches) { - fetched.push( - await fetchPartNumberBatch( - client, - elementPath, - elementType, - batch, - isOpenComposite - ) - ); - } - return toPartNumberResult(probe, fetched, capped); -} - -/** - * Indexes an insertable's part numbers as part of its load, one durable step per - * batch of configurations, so a rate-limited retry re-fetches only that batch. - * Batches run sequentially — insertables already load in parallel, which is - * where the concurrency comes from. - * - * A batch that exhausts its retries throws, failing the insertable rather than - * saving a half-built map; the stored row keeps its previous part numbers. - */ -export async function loadPartNumbers( - ctx: LoadContext, - insertableId: string, - elementPath: ElementPath, - elementType: ElementType, - parameters: ConfigurationParameter[], - isOpenComposite: boolean -): Promise { - const probe = await ctx.step.do( - `part-numbers-${insertableId}-default`, - { retries: ONSHAPE_STEP_RETRIES }, - async () => - probePartNumber( - await getOnshapeApiFromContext(ctx), - elementPath, - elementType, - {}, - isOpenComposite - ) - ); - const { batches, capped } = planPartNumberBatches(parameters); - - const fetched: PartNumberBatch[] = []; - for (const [index, batch] of batches.entries()) { - fetched.push( - await ctx.step.do( - `part-numbers-${insertableId}-batch-${index}`, - { retries: ONSHAPE_STEP_RETRIES }, - async () => - fetchPartNumberBatch( - await getOnshapeApiFromContext(ctx), - elementPath, - elementType, - batch, - isOpenComposite - ) - ) - ); - } - return toPartNumberResult(probe, fetched, capped); -} - -/** A part number and the configuration that produces it. */ -interface PartNumberEntry { - partNumber: string; - configuration: ParameterValues; -} - -/** What one batch of configurations resolved to. */ -interface PartNumberBatch { - entries: PartNumberEntry[]; - /** True when any configuration in the batch resolved to >1 part. */ - hasMultipleParts: boolean; - /** True when any configuration in the batch lost its composite. */ - isUnstableComposite: boolean; -} - -/** - * Splits an insertable's configuration combinations into the batches to fetch. - * - * An insertable with nothing to vary — no parameters, or only quantity and - * string ones — enumerates to a single empty configuration, which is what the - * default probe already asked for. Dropping it leaves no batches at all rather - * than fetching the defaults twice. - */ -function planPartNumberBatches(parameters: ConfigurationParameter[]): { - batches: ParameterValues[][]; - capped: boolean; -} { - const { configurations, capped } = enumerateConfigurations(parameters); - if (capped) { - return { batches: [], capped: true }; - } - const toFetch = configurations.filter( - (configuration) => Object.keys(configuration).length > 0 - ); - - const batches: ParameterValues[][] = []; - for (let i = 0; i < toFetch.length; i += BATCH_SIZE) { - batches.push(toFetch.slice(i, i + BATCH_SIZE)); - } - return { batches, capped: false }; -} - -/** - * Reads what Onshape reports for an element in a given configuration. A part - * studio's number comes from its part; an assembly's from the root assembly. - */ -async function probePartNumber( - client: OnshapeApi, - elementPath: ElementPath, - elementType: ElementType, - configuration: ParameterValues, - isOpenComposite: boolean -): Promise { - if (elementType === ElementType.ASSEMBLY) { - return { - partNumber: parseAssemblyPartNumber( - await getAssemblyDefinition(client, elementPath, configuration) - ), - hasMultipleParts: false, - isUnstableComposite: false - }; - } - return parsePartStudioParts( - await getParts(client, elementPath, configuration), - isOpenComposite - ); -} - -/** Probes each configuration in a batch, dropping blank part numbers. */ -async function fetchPartNumberBatch( - client: OnshapeApi, - elementPath: ElementPath, - elementType: ElementType, - batch: ParameterValues[], - isOpenComposite: boolean -): Promise { - const entries: PartNumberEntry[] = []; - let hasMultipleParts = false; - let isUnstableComposite = false; - for (const configuration of batch) { - const probe = await probePartNumber( - client, - elementPath, - elementType, - configuration, - isOpenComposite - ); - hasMultipleParts ||= probe.hasMultipleParts; - isUnstableComposite ||= probe.isUnstableComposite; - if (probe.partNumber) { - entries.push({ partNumber: probe.partNumber, configuration }); - } - } - return { entries, hasMultipleParts, isUnstableComposite }; -} - -/** - * Folds the default probe and every batch into the stored result. - * - * The map is keyed by part number, first-wins, so configurations resolving to - * the same part collapse onto the earliest one. `MULTIPLE_PARTS` and - * `UNSTABLE_COMPOSITE` are raised when *any* configuration tripped them — the - * flags ride on the batch rather than on an entry because entries with a blank - * part number are dropped and would lose them. - */ -function toPartNumberResult( - probe: PartStudioParts, - batches: PartNumberBatch[], - capped: boolean -): PartNumberResult { - const partNumbers: PartNumberMap = {}; - let hasMultipleParts = probe.hasMultipleParts; - let isUnstableComposite = probe.isUnstableComposite; - for (const batch of batches) { - hasMultipleParts ||= batch.hasMultipleParts; - isUnstableComposite ||= batch.isUnstableComposite; - for (const entry of batch.entries) { - partNumbers[entry.partNumber] ??= entry.configuration; - } - } - - let buildIssues: BuildIssue[] = []; - if (capped) { - buildIssues = addBuildIssue(buildIssues, { - type: BuildIssueType.TOO_MANY_CONFIGURATIONS - }); - } - if (hasMultipleParts) { - buildIssues = addBuildIssue(buildIssues, { - type: BuildIssueType.MULTIPLE_PARTS - }); - } - if (isUnstableComposite) { - buildIssues = addBuildIssue(buildIssues, { - type: BuildIssueType.UNSTABLE_COMPOSITE - }); - } - - return { defaultPartNumber: probe.partNumber, partNumbers, buildIssues }; -} diff --git a/src/backend/parse/parse-vendors.test.ts b/src/backend/parse/parse-vendors.test.ts index 54f3a61e0..d7879bb05 100644 --- a/src/backend/parse/parse-vendors.test.ts +++ b/src/backend/parse/parse-vendors.test.ts @@ -88,4 +88,15 @@ 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]); + }); + + it("does not read Custom out of an unrelated word", () => { + expect(parseVendors("Customizable Spacer", [])).toEqual([]); + }); }); diff --git a/src/backend/routes/build-status.test.ts b/src/backend/routes/build-status.test.ts index 68ad2505a..c4bc6abfc 100644 --- a/src/backend/routes/build-status.test.ts +++ b/src/backend/routes/build-status.test.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { group, insertables } from "../../shared/schema"; import { TEST_GROUP_ID, @@ -20,6 +20,7 @@ const INSERTABLE_LOADED_AT = 2000; describe("GET /build-status", () => { beforeEach(() => resetDb(db)); + afterEach(() => vi.restoreAllMocks()); it("returns each group's and insertable's last-loaded time", async () => { await seedPartStudio(db); @@ -73,4 +74,19 @@ describe("GET /build-status", () => { const body: LibraryBuildStatus = await res.json(); expect(body.groups[TEST_GROUP_ID].lastLoadedAt).toBeNull(); }); + + // Job state lives on /job-status, which is what lets this be cached. + it("caches the response privately and immutably", async () => { + await seedPartStudio(db); + + const res = await createTestApp().request( + `/api/build-status/library/${TEST_LIBRARY_ID}?v=1`, + { method: "GET" }, + env + ); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe( + "private, max-age=31536000, immutable" + ); + }); }); diff --git a/src/backend/routes/build-status.ts b/src/backend/routes/build-status.ts index 3cd2a5183..877175abd 100644 --- a/src/backend/routes/build-status.ts +++ b/src/backend/routes/build-status.ts @@ -47,7 +47,7 @@ buildStatusRoutes.get( elementType: insertables.elementType, isVisible: insertables.isVisible, supportsFasten: insertables.supportsFasten, - searchPartNumbers: insertables.searchPartNumbers, + indexConfigurations: insertables.indexConfigurations, vendors: insertables.vendors, sortOrder: insertables.sortOrder, lastLoadedAt: insertables.lastLoadedAt @@ -92,7 +92,7 @@ buildStatusRoutes.get( elementType: ins.elementType, isVisible: ins.isVisible, supportsFasten: ins.supportsFasten, - searchPartNumbers: ins.searchPartNumbers, + indexConfigurations: ins.indexConfigurations, vendors: ins.vendors, configuration: config ? { diff --git a/src/backend/routes/configurations.test.ts b/src/backend/routes/configurations.test.ts index 8bc3a2f83..e49afecc8 100644 --- a/src/backend/routes/configurations.test.ts +++ b/src/backend/routes/configurations.test.ts @@ -36,7 +36,7 @@ describe("configuration routes", () => { expect(res.status).toBe(200); const body = await res.json(); - expect(body).toEqual({ parameters: TEST_PARAMETERS }); + expect(body).toEqual({ parameters: TEST_PARAMETERS, records: [] }); }); it("GET /configuration/:id 404s for an unknown id", async () => { diff --git a/src/backend/routes/configurations.ts b/src/backend/routes/configurations.ts index 7a4dafa66..971744603 100644 --- a/src/backend/routes/configurations.ts +++ b/src/backend/routes/configurations.ts @@ -7,6 +7,7 @@ import { type ConfigurationResult, type UnitInfo } from "../../shared/configuration-models"; +import { toSearchRecords } from "../../shared/search"; import { QuantityType, type Unit } from "../../shared/configuration-enums"; import { isInstancePath } from "../../shared/onshape-path"; import { HTTPException } from "hono/http-exception"; @@ -28,7 +29,10 @@ configurationRoutes.get( const db = getDb(c.env.DB); const config = await db - .select({ parameters: configurations.parameters }) + .select({ + parameters: configurations.parameters, + records: configurations.records + }) .from(configurations) .where(eq(configurations.id, configurationId)) .get(); @@ -40,7 +44,8 @@ configurationRoutes.get( } const result: ConfigurationResult = { - parameters: config.parameters + parameters: config.parameters, + records: toSearchRecords(config.records) }; return c.json(result); } diff --git a/src/backend/routes/favorites.ts b/src/backend/routes/favorites.ts index c81e253b2..cc60f3105 100644 --- a/src/backend/routes/favorites.ts +++ b/src/backend/routes/favorites.ts @@ -42,9 +42,7 @@ async function getFavorites( return { favorites: favoritesOut, favoriteOrder }; } -/** - * Gets the list of a user's favorites. - */ +/** GET /api/favorites/library/:libraryId */ favoriteRoutes.get( "/favorites" + libraryRoute(), requireSignInMiddleware, @@ -57,9 +55,7 @@ favoriteRoutes.get( } ); -/** - * Creates a new favorite. - */ +/** POST /api/favorites/library/:libraryId */ favoriteRoutes.post( "/favorites" + libraryRoute(), requireSignInMiddleware, @@ -106,9 +102,7 @@ favoriteRoutes.post( } ); -/** - * Deletes a user's favorites. - */ +/** DELETE /api/favorites/:favoriteId */ favoriteRoutes.delete( "/favorites/:favoriteId", requireSignInMiddleware, diff --git a/src/backend/routes/groups.test.ts b/src/backend/routes/groups.test.ts index 557c2fd76..d38a7e5b0 100644 --- a/src/backend/routes/groups.test.ts +++ b/src/backend/routes/groups.test.ts @@ -12,16 +12,19 @@ import { seedGroup, seedTestData } from "../../__test_utils__"; +import MiniSearch from "minisearch"; import { getDb } from "../db"; +import type { JobStatus } from "../../shared/api-models"; +import { searchIndexKey } from "../library-data"; +import { SEARCH_OPTIONS, type SearchDocument } from "../../shared/search"; import * as DocumentsEndpoint from "../onshape-api/endpoints/documents"; import * as JobTracker from "../load/job-tracker"; const db = getDb(env.DB); /** - * `jsonRequest` plus a session cookie — needed by routes that call `getSessionId` - * (unlike `c.var.getOnshapeApi()`, session lookup isn't part of `createTestApp`'s - * mocked services, since it's read directly from the request). + * `jsonRequest` plus a session cookie, for routes calling `getSessionId` — which + * reads the request directly rather than going through the mocked services. */ function sessionRequest(method: string, body?: unknown): RequestInit { const init = jsonRequest(method, body); @@ -39,11 +42,11 @@ describe("group admin routes", () => { await resetDb(db); }); - it("POST /set-element-visibility hides an insertable and drops its favorites", async () => { + it("POST /set-insertable-visibility hides an insertable and drops its favorites", async () => { await seedTestData(db); const res = await createTestApp().request( - `/api/set-element-visibility/library/${TEST_LIBRARY_ID}`, + `/api/set-insertable-visibility/library/${TEST_LIBRARY_ID}`, jsonRequest("POST", { insertableIds: [TEST_PART_STUDIO_ID], isVisible: false @@ -67,6 +70,32 @@ 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. + it.each([false, true])( + "POST /set-insertable-visibility rebuilds the search index (isVisible=%s)", + async (isVisible) => { + await seedTestData(db); + + const res = await createTestApp().request( + `/api/set-insertable-visibility/library/${TEST_LIBRARY_ID}`, + jsonRequest("POST", { + insertableIds: [TEST_PART_STUDIO_ID], + isVisible + }), + env + ); + expect(res.status).toBe(200); + + const object = await env.BLOB.get(searchIndexKey(TEST_LIBRARY_ID)); + const indexed = MiniSearch.loadJSON( + await object!.text(), + SEARCH_OPTIONS + ).getStoredFields(TEST_PART_STUDIO_ID); + expect(indexed?.isVisible).toBe(isVisible); + } + ); + it("POST /sort-group-alphabetically updates the flag", async () => { await seedTestData(db); @@ -128,12 +157,8 @@ describe("POST /reload-groups", () => { beforeEach(() => resetDb(db)); afterEach(() => vi.restoreAllMocks()); - // The route no longer checks document versions itself — it just spawns one - // LoadLibrary workflow, which owns the per-group skip decision. - // The "false" case is a regression test: z.coerce.boolean() coerces the - // *string* "false" to `true` (any non-empty string is truthy), so an - // explicit forceReload=false used to force a reload just by being present. - // z.stringbool() fixes this. + // 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], @@ -193,8 +218,11 @@ describe("GET /job-status", () => { beforeEach(() => resetDb(db)); afterEach(() => vi.restoreAllMocks()); - it.each([true, false])("reports running=%s", async (running) => { - vi.spyOn(JobTracker, "isAnyJobRunning").mockResolvedValue(running); + it.each([ + { running: true, runningForMs: 4_000 }, + { running: false } + ])("reports $running", async (status) => { + vi.spyOn(JobTracker, "getJobStatus").mockResolvedValue(status); const res = await createTestApp().request( `/api/job-status/library/${TEST_LIBRARY_ID}`, @@ -202,7 +230,7 @@ describe("GET /job-status", () => { env ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ running }); + expect(await res.json()).toEqual(status); // Polled for live state, so it must never be served from a cache. expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); diff --git a/src/backend/routes/groups.ts b/src/backend/routes/groups.ts index 7d7d1d962..cecf6b7a1 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/routes/groups.ts @@ -8,11 +8,7 @@ import { type DocumentPath } from "../../shared/onshape-path"; import { group, insertables, libraries, favorites } from "../../shared/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; import { HttpStatus } from "http-status-ts"; -import { - isAnyJobRunning, - isReloadRunning, - trackJob -} from "../load/job-tracker"; +import { getJobStatus, isReloadRunning, trackJob } from "../load/job-tracker"; import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; @@ -55,20 +51,19 @@ groupRoutes.post( } ); -/** GET /api/job-status/library/:libraryId */ +/** GET /api/job-status/library/:libraryId — checked on load, then polled. */ groupRoutes.get( "/job-status" + libraryRoute(), requireEditorMiddleware, cacheMiddleware(), async (c) => { - const running = await isAnyJobRunning(c.env, getLibraryParam(c)); - return c.json({ running }); + return c.json(await getJobStatus(c.env, getLibraryParam(c))); } ); -/** POST /api/set-element-visibility/library/:libraryId */ +/** POST /api/set-insertable-visibility/library/:libraryId */ groupRoutes.post( - "/set-element-visibility" + libraryRoute(), + "/set-insertable-visibility" + libraryRoute(), requireEditorMiddleware, async (c) => { const libraryId = getLibraryParam(c); @@ -100,6 +95,9 @@ 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. + await rebuildSearchDb(c.env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); return c.json({ success: true }); } @@ -244,8 +242,8 @@ groupRoutes.delete( .delete(group) .where(and(eq(group.id, groupId), eq(group.libraryId, libraryId))); + await rebuildSearchDb(c.env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); - await rebuildSearchDb(db, libraryId); return c.json({ success: true }); } ); diff --git a/src/backend/routes/insertables.test.ts b/src/backend/routes/insertables.test.ts index 8ee0ec69c..e334dfdaa 100644 --- a/src/backend/routes/insertables.test.ts +++ b/src/backend/routes/insertables.test.ts @@ -1,8 +1,8 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { insertables } from "../../shared/schema"; -import { ElementType } from "../../shared/types"; +import { configurations, insertables } from "../../shared/schema"; +import { ElementType, Vendor } from "../../shared/types"; import { BuildIssueType } from "../../shared/build-issues"; import { MOCK_ONSHAPE_API, @@ -13,6 +13,8 @@ import { jsonRequest, resetDb, seedAssembly, + seedGroup, + seedInsertable, seedPartStudio } from "../../__test_utils__"; import { getDb } from "../db"; @@ -20,6 +22,8 @@ import * as PartStudioEndpoints from "../onshape-api/endpoints/part-studios"; import * as AssemblyEndpoints from "../onshape-api/endpoints/assemblies"; import * as PartsEndpoints from "../onshape-api/endpoints/parts"; import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; +import { AUTO_INDEX_THRESHOLD } from "../../shared/configuration-combinations"; +import { enumParam } from "../../__test_utils__/configuration-fixtures"; const db = getDb(env.DB); @@ -32,8 +36,15 @@ function readInsertable(insertableId: string) { .get(); } +function readConfig(insertableId: string) { + return db + .select() + .from(configurations) + .where(eq(configurations.id, insertableId)) + .get(); +} + // The target element to insert into — must be an editable workspace ("w"). -const target = "/d/doc-target/w/w-target/e/target-element"; const targetPath = { documentId: "doc-target", instanceType: "w", @@ -70,8 +81,9 @@ describe("insertable routes", () => { .mockResolvedValue({ feature: { featureId: "feat-1" } }); const res = await createTestApp().request( - `/api/add-to-part-studio/insertable/${TEST_PART_STUDIO_ID}${target}`, + `/api/add-to-part-studio/insertable/${TEST_PART_STUDIO_ID}`, jsonRequest("POST", { + targetPath, configuration: undefined, useMateConnector: false, isFavorite: false, @@ -91,6 +103,37 @@ describe("insertable routes", () => { ); }); + // A half-built target used to reach Onshape as a nonsense URL and fail + // opaquely; the boundary rejects it instead. + it.each([ + ["a missing instance id", { documentId: "d", elementId: "e" }], + [ + "an unknown instance type", + { + documentId: "d", + instanceId: "i", + instanceType: "x", + elementId: "e" + } + ], + ["no target at all", undefined] + ])("POST /add-to-part-studio rejects %s", async (_label, targetPath) => { + await seedPartStudio(db); + + const res = await createTestApp().request( + `/api/add-to-part-studio/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { + targetPath, + configuration: undefined, + useMateConnector: false, + isFavorite: false, + isQuickInsert: false + }), + env + ); + expect(res.status).toBe(400); + }); + it("POST /add-to-assembly inserts via the Onshape API", async () => { await seedAssembly(db); const spy = vi @@ -98,8 +141,9 @@ describe("insertable routes", () => { .mockResolvedValue({}); const res = await createTestApp().request( - `/api/add-to-assembly/insertable/${TEST_ASSEMBLY_ID}${target}`, + `/api/add-to-assembly/insertable/${TEST_ASSEMBLY_ID}`, jsonRequest("POST", { + targetPath, configuration: undefined, fasten: false, isFavorite: false, @@ -121,33 +165,46 @@ describe("insertable routes", () => { ); }); - it("POST /toggle-part-number-search indexes and enables the flag", async () => { + it("POST /index-configurations indexes and forces the flag on", async () => { await seedPartStudio(db); vi.spyOn(PartsEndpoints, "getParts").mockResolvedValue([ { partId: "p", partNumber: "PN-123" } ]); const res = await createTestApp().request( - `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: true }), + `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { indexConfigurations: true }), env ); expect(res.status).toBe(200); const row = await readInsertable(TEST_PART_STUDIO_ID); - expect(row?.searchPartNumbers).toBe(true); - expect(row?.defaultPartNumber).toBe("PN-123"); + expect(row?.indexConfigurations).toBe(true); + + const config = await readConfig(TEST_PART_STUDIO_ID); + expect(config?.records).toEqual([ + { + configuration: {}, + partNumber: "PN-123", + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + } + ]); }); - it("POST /toggle-part-number-search leaves the flag off when indexing fails", async () => { + it("POST /index-configurations leaves the flag off when indexing fails", async () => { await seedPartStudio(db); vi.spyOn(PartsEndpoints, "getParts").mockRejectedValue( new OnshapeRateLimitError("rate limited", 450) ); const res = await createTestApp().request( - `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: true }), + `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { indexConfigurations: true }), env ); // Surfaced to the client rather than silently enabling. @@ -156,39 +213,82 @@ describe("insertable routes", () => { expect(body.retryAfterSeconds).toBe(450); const row = await readInsertable(TEST_PART_STUDIO_ID); - expect(row?.searchPartNumbers).toBe(false); - expect(row?.defaultPartNumber).toBeNull(); + expect(row?.indexConfigurations).toBe(false); + // Nothing was written, so no records survive. + expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); - it("POST /toggle-part-number-search clears the data when disabling", async () => { - await seedPartStudio(db); + // 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); + await db.insert(configurations).values({ + id: TEST_PART_STUDIO_ID, + parameters: [ + enumParam( + "A", + Array.from( + { length: AUTO_INDEX_THRESHOLD }, + (_, i) => `o${i}` + ) + ) + ] + }); const spy = vi .spyOn(PartsEndpoints, "getParts") .mockResolvedValue([{ partId: "p", partNumber: "PN-123" }]); await createTestApp().request( - `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: true }), + `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { indexConfigurations: true }), env ); spy.mockClear(); const res = await createTestApp().request( - `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: false }), + `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { indexConfigurations: false }), env ); expect(res.status).toBe(200); - // Disabling needs no Onshape calls. + // Past the threshold it is not auto-eligible, so nothing is re-indexed. expect(spy).not.toHaveBeenCalled(); const row = await readInsertable(TEST_PART_STUDIO_ID); - expect(row?.searchPartNumbers).toBe(false); - expect(row?.defaultPartNumber).toBeNull(); + expect(row?.indexConfigurations).toBe(false); + // The row stays to hold the parameters; only the records go. + 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, { + name: "Custom Bracket", + vendors: [Vendor.CUSTOM] + }); + vi.spyOn(PartsEndpoints, "getParts").mockResolvedValue([ + { partId: "p" } + ]); + + const res = await createTestApp().request( + `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { indexConfigurations: false }), + env + ); + expect(res.status).toBe(200); + + const config = await readConfig(TEST_PART_STUDIO_ID); + expect(config?.records).toHaveLength(1); + // Nobody sells it, so a missing part number is not worth flagging. + const row = await readInsertable(TEST_PART_STUDIO_ID); + 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. - it("POST /toggle-part-number-search replaces stale part-number issues", async () => { + it("POST /index-configurations replaces stale part-number issues", async () => { await seedPartStudio(db); await db .update(insertables) @@ -204,8 +304,8 @@ describe("insertable routes", () => { ]); const res = await createTestApp().request( - `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: true }), + `/api/index-configurations/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { indexConfigurations: true }), env ); expect(res.status).toBe(200); diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index bd0f49673..3c083dc94 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -1,23 +1,26 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; +import { zValidator } from "@hono/zod-validator"; import { HttpStatus } from "http-status-ts"; +import z from "zod"; import { getApp, getInsertableParam, insertableRoute } from "../app"; import { getDb, type Db } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; import { requireSignInMiddleware } from "../sign-in-utils"; import { insertables, configurations } from "../../shared/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; -import { type ElementPath } from "../../shared/onshape-path"; +import { type ElementPath, INSTANCE_TYPES } from "../../shared/onshape-path"; import { - type ParameterValues, - type ConfigurationParameter + type ConfigurationParameter, + type ParameterValues } from "../../shared/configuration-models"; import { - NO_PART_NUMBERS, - PART_NUMBER_ISSUE_TYPES, - parsePartNumbers, - type PartNumberResult -} from "../parse/parse-part-number"; + INDEXING_ISSUE_TYPES, + NO_RECORDS, + decideIndexing, + parseConfigurationRecords, + type ConfigurationRecordsResult +} from "../parse/parse-configuration-records"; import { type OnshapeApi } from "../onshape-api/onshape-api"; import { ElementType } from "../../shared/types"; import { DerivedFeature } from "../onshape-api/objects/derive-feature"; @@ -34,6 +37,7 @@ import { encodeConfiguration } from "../onshape-api/endpoints/configurations"; import { FastenMateBuilder } from "../onshape-api/objects/assembly-features"; import { getFastenQuery, parseFastenInfo } from "../parse/insert-and-fasten"; import { addBuildIssue, clearBuildIssue } from "../../shared/build-issues"; +import { checkIndexedPartNumber } from "../parse/build-checks"; export const insertableRoutes = getApp(); @@ -95,14 +99,14 @@ insertableRoutes.post( } ); -/** POST /api/toggle-part-number-search/insertable/:insertableId */ +/** POST /api/index-configurations/insertable/:insertableId */ insertableRoutes.post( - "/toggle-part-number-search" + insertableRoute(), + "/index-configurations" + insertableRoute(), requireEditorMiddleware, async (c) => { const db = getDb(c.env.DB); const insertableId = getInsertableParam(c); - const body = await c.req.json<{ searchPartNumbers: boolean }>(); + const body = await c.req.json<{ indexConfigurations: boolean }>(); const row = await db .select({ @@ -111,6 +115,7 @@ insertableRoutes.post( versionId: insertables.versionId, elementId: insertables.elementId, elementType: insertables.elementType, + vendors: insertables.vendors, isOpenComposite: insertables.isOpenComposite, buildIssues: insertables.buildIssues }) @@ -122,108 +127,148 @@ insertableRoutes.post( message: "Insertable not found" }); - // Index before committing anything: if this throws, the flag stays off - // rather than being enabled with nothing indexed behind it. The error - // reaches the client via the app's onError handler. - const indexed = body.searchPartNumbers - ? await indexPartNumbers(await c.var.getOnshapeApi(), db, { - insertableId, - ...row + const parameters = + ( + await db + .select({ parameters: configurations.parameters }) + .from(configurations) + .where(eq(configurations.id, insertableId)) + .get() + )?.parameters ?? []; + const indexing = decideIndexing(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 indexRecords(await c.var.getOnshapeApi(), { + documentId: row.documentId, + versionId: row.versionId, + elementId: row.elementId, + elementType: row.elementType, + isOpenComposite: row.isOpenComposite, + parameters, + configurations: indexing.configurations }) - : NO_PART_NUMBERS; + : 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, + // Vendors are read, not re-derived: the load path wrote them. + ...checkIndexedPartNumber(row.vendors, indexed.records) + ); + + // Keep a configurations row while there's parameters or records to hold; + // a non-configurable insertable that stops indexing loses its row. + const configWrite = + parameters.length > 0 || indexed.records.length > 0 + ? db + .insert(configurations) + .values({ + id: insertableId, + parameters, + records: indexed.records + }) + .onConflictDoUpdate({ + target: configurations.id, + set: { records: indexed.records } + }) + : db + .delete(configurations) + .where(eq(configurations.id, insertableId)); await db.batch([ db .update(insertables) .set({ - searchPartNumbers: body.searchPartNumbers, - defaultPartNumber: indexed.defaultPartNumber, - // Clear first, so an issue the reindex resolved (or that - // disabling makes moot) doesn't stick around. - buildIssues: addBuildIssue( - clearBuildIssue( - row.buildIssues, - ...PART_NUMBER_ISSUE_TYPES - ), - ...indexed.buildIssues - ) + indexConfigurations: body.indexConfigurations, + buildIssues }) .where(eq(insertables.id, insertableId)), - // No-op when the insertable has no configuration row. - db - .update(configurations) - .set({ partNumbers: indexed.partNumbers }) - .where(eq(configurations.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); - // Part numbers live in the search index, so rebuild it now. - await rebuildSearchDb(db, row.libraryId); return c.json({ success: true }); } ); /** - * Indexes an insertable's part numbers for the toggle route, reading the - * parameters it needs. Runs in a request, so it uses the unbatched - * {@link parsePartNumbers} rather than the workflow's stepped loader. + * Runs in a request, so it uses the unbatched {@link parseConfigurationRecords} + * rather than the workflow's stepped loader. */ -async function indexPartNumbers( +function indexRecords( client: OnshapeApi, - db: Db, insertable: { - insertableId: string; documentId: string; versionId: string; elementId: string; elementType: ElementType; isOpenComposite: boolean; + parameters: ConfigurationParameter[]; + configurations: ParameterValues[]; } -): Promise { +): Promise { const sourcePath: ElementPath = { documentId: insertable.documentId, instanceId: insertable.versionId, instanceType: "v", elementId: insertable.elementId }; - const configRow = await db - .select({ parameters: configurations.parameters }) - .from(configurations) - .where(eq(configurations.id, insertable.insertableId)) - .get(); - - return parsePartNumbers( + return parseConfigurationRecords( client, sourcePath, insertable.elementType, - configRow?.parameters ?? [], + insertable.parameters, + insertable.configurations, insertable.isOpenComposite ); } -/** POST /api/add-to-part-studio/insertable/:insertableId/d/:documentId/:instanceType/:instanceId/e/:elementId */ +/** + * 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. + */ +const targetPathSchema = z.object({ + documentId: z.string().min(1), + instanceId: z.string().min(1), + instanceType: z.enum(INSTANCE_TYPES), + elementId: z.string().min(1) +}); + +const configurationSchema = z.record(z.string(), z.string()).optional(); + +const insertBodySchema = z.object({ + targetPath: targetPathSchema, + configuration: configurationSchema, + isFavorite: z.boolean().default(false), + isQuickInsert: z.boolean().default(false) +}); + +const addToPartStudioBody = insertBodySchema.extend({ + useMateConnector: z.boolean().default(false) +}); + +const addToAssemblyBody = insertBodySchema.extend({ + fasten: z.boolean().default(false) +}); + +/** POST /api/add-to-part-studio/insertable/:insertableId */ insertableRoutes.post( - "/add-to-part-studio" + - insertableRoute() + - "/d/:documentId/:instanceType/:instanceId/e/:elementId", + "/add-to-part-studio" + insertableRoute(), requireSignInMiddleware, + zValidator("json", addToPartStudioBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); - const body = await c.req.json<{ - configuration: ParameterValues | undefined; - useMateConnector: boolean; - isFavorite: boolean; - isQuickInsert: boolean; - }>(); - - // Target part studio — from URL - const targetPath: ElementPath = { - documentId: c.req.param("documentId")!, - instanceId: c.req.param("instanceId")!, - instanceType: c.req.param("instanceType") as "w" | "v" | "m", - elementId: c.req.param("elementId")! - }; + const body = c.req.valid("json"); + const { targetPath } = body; const db = getDb(c.env.DB); const sourcePath = await getInsertableElementPath(db, insertableId); @@ -272,29 +317,16 @@ insertableRoutes.post( } ); -/** POST /api/add-to-assembly/insertable/:insertableId/d/:documentId/:instanceType/:instanceId/e/:elementId */ +/** POST /api/add-to-assembly/insertable/:insertableId */ insertableRoutes.post( - "/add-to-assembly" + - insertableRoute() + - "/d/:documentId/:instanceType/:instanceId/e/:elementId", + "/add-to-assembly" + insertableRoute(), requireSignInMiddleware, + zValidator("json", addToAssemblyBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); - const body = await c.req.json<{ - configuration: ParameterValues | undefined; - fasten: boolean; - isFavorite: boolean; - isQuickInsert: boolean; - }>(); - - // Target assembly — from URL - const targetPath: ElementPath = { - documentId: c.req.param("documentId")!, - instanceId: c.req.param("instanceId")!, - instanceType: c.req.param("instanceType") as "w" | "v" | "m", - elementId: c.req.param("elementId")! - }; + const body = c.req.valid("json"); + const { targetPath } = body; const db = getDb(c.env.DB); @@ -387,11 +419,7 @@ insertableRoutes.post( return c.json({ featureId: fastenResult.feature.featureId }); } ); -/** - * Returns the ElementPath for an insertable looked up by its ID. - * Throws 404 if the insertable does not exist. - * Insertable elements are always version-pinned (instanceType "v"). - */ +/** Always version-pinned; throws 404 when the insertable does not exist. */ export async function getInsertableElementPath( db: Db, diff --git a/src/backend/routes/library.test.ts b/src/backend/routes/library.test.ts index b928b473d..3c41f655a 100644 --- a/src/backend/routes/library.test.ts +++ b/src/backend/routes/library.test.ts @@ -12,6 +12,7 @@ import { seedLibrary } from "../../__test_utils__"; import { getDb } from "../db"; +import { rebuildSearchDb, searchIndexKey } from "../library-data"; import { LibraryOut } from "../../shared/api-models"; import { LibraryId } from "../../shared/types"; @@ -20,6 +21,8 @@ const db = getDb(env.DB); describe("library routes", () => { beforeEach(async () => { await resetDb(db); + // The R2 bucket persists across tests in this file; clear the index. + await env.BLOB.delete(searchIndexKey(TEST_LIBRARY_ID)); }); it("GET /library-data returns groups and insertables", async () => { @@ -43,8 +46,10 @@ describe("library routes", () => { ); }); - it("GET /search-db returns a serialized search index", async () => { - await seedLibrary(db); + it("GET /search-db serves the library's index from R2 as plain JSON", async () => { + await seedTestData(db); + await seedConfiguration(db, TEST_PART_STUDIO_ID); + await rebuildSearchDb(env.BLOB, db, TEST_LIBRARY_ID); const app = createTestApp(); const res = await app.request( @@ -53,10 +58,26 @@ 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. + expect(res.headers.get("Content-Encoding")).toBeNull(); + expect(res.headers.get("Content-Type")).toBe("application/json"); + + // The body is the serialized MiniSearch index (a JSON object). + const parsed: { documentCount: number } = await res.json(); + expect(parsed.documentCount).toBeGreaterThan(0); + }); + + it("GET /search-db 404s when the library has no index", async () => { + await seedLibrary(db); + const app = createTestApp(); - const body: { searchDb: string } = await res.json(); - expect(typeof body.searchDb).toBe("string"); - expect(body.searchDb.length).toBeGreaterThan(0); + const res = await app.request( + `/api/search-db/library/${TEST_LIBRARY_ID}?v=1`, + jsonRequest("GET"), + env + ); + expect(res.status).toBe(404); }); it("GET /library-version returns the library's version", async () => { @@ -88,6 +109,7 @@ describe("library routes", () => { it("caches version-keyed responses immutably", async () => { await seedTestData(db); + await rebuildSearchDb(env.BLOB, db, TEST_LIBRARY_ID); const app = createTestApp(); for (const path of ["library-data", "search-db"]) { diff --git a/src/backend/routes/library.ts b/src/backend/routes/library.ts index 7190462b1..4493a48e7 100644 --- a/src/backend/routes/library.ts +++ b/src/backend/routes/library.ts @@ -8,9 +8,7 @@ import { } from "../app"; import { getDb } from "../db"; import { libraries } from "../../shared/schema"; -import { getLibraryOut } from "../library-data"; -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; +import { getLibraryOut, searchIndexKey } from "../library-data"; export const libraryRoutes = getApp(); @@ -41,28 +39,24 @@ libraryRoutes.get( } ); -/** GET /api/search-db/library/:libraryId?v=:cacheVersion */ +/** + * GET /api/search-db/library/:libraryId?v=:cacheVersion. Never set + * `Content-Encoding` here: the runtime would compress it a second time. + */ libraryRoutes.get( "/search-db" + libraryRoute(), cacheMiddleware(CachePolicy.PUBLIC_CACHE), async (c) => { const libraryId = getLibraryParam(c); - const db = getDb(c.env.DB); - - const library = await db - .select({ searchDb: libraries.searchDb }) - .from(libraries) - .where(eq(libraries.id, libraryId)) - .get(); - - const searchDb = library?.searchDb; - if (!searchDb) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Failed to find searchDb" - }); + const object = await c.env.BLOB.get(searchIndexKey(libraryId)); + if (!object) { + return c.notFound(); } - return c.json({ searchDb }); + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set("etag", object.httpEtag); + return new Response(object.body, { headers }); } ); diff --git a/src/backend/routes/thumbnails.test.ts b/src/backend/routes/thumbnails.test.ts index 96b6f851f..a70287e4b 100644 --- a/src/backend/routes/thumbnails.test.ts +++ b/src/backend/routes/thumbnails.test.ts @@ -1,33 +1,56 @@ import { env } from "cloudflare:workers"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestApp, jsonRequest } from "../../__test_utils__"; +import { ThumbnailSize } from "../../shared/types"; +import { + THUMBNAIL_FALLBACK_CACHE_TTL, + THUMBNAIL_FALLBACK_HEADER, + thumbnailKey, + thumbnailUrl +} from "../../shared/thumbnails"; +import { + DEFAULT_CANONICAL_CONFIGURATION, + canonicalConfigurationKey +} from "../../shared/canonical-configuration"; +import { uploadConfigurationThumbnails } from "./thumbnails"; +import type { OnshapeApi } from "../onshape-api/onshape-api"; -// Mirrors r2Key() in thumbnails.ts: `thumbnails/${size}/${elementId}`. -const SIZE = "300x300"; +const SIZE = ThumbnailSize.LARGE; +const MICROVERSION = "mv-1"; +/** A configuration whose key differs from the default's. */ +const CANONICAL_CONFIGURATION = "size=l"; + +const SESSION_ID = "test-session"; +const INSERTABLE_ID = "test-insertable"; + +function get(url: string, sessionId?: string) { + const init = jsonRequest("GET"); + if (sessionId) { + init.headers = { + ...init.headers, + Cookie: `frc-design-app-cookie=${sessionId}` + }; + } + return createTestApp().request(url, init, env); +} describe("thumbnail serving", () => { - it("GET /thumbnail/:size/:elementId rejects a request with no cache version", async () => { - const app = createTestApp(); - const res = await app.request( - `/api/thumbnail/${SIZE}/anything`, - jsonRequest("GET"), - env - ); - expect(res.status).toBe(400); - }); + afterEach(() => vi.restoreAllMocks()); - it("GET /thumbnail/:size/:elementId serves a stored thumbnail from R2", async () => { + it("serves a stored thumbnail, cached immutably", async () => { const elementId = "stored-element"; - await env.THUMBNAILS.put( - `thumbnails/${SIZE}/${elementId}`, + await env.BLOB.put( + thumbnailKey(elementId, MICROVERSION, SIZE), "gif-bytes" ); - const app = createTestApp(); - const res = await app.request( - `/api/thumbnail/${SIZE}/${elementId}?v=abc123`, - jsonRequest("GET"), - env + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }) ); expect(res.status).toBe(200); expect(await res.text()).toBe("gif-bytes"); @@ -36,27 +59,338 @@ describe("thumbnail serving", () => { ); }); - it("does not demand a version from a url that is already immutable", async () => { - const app = createTestApp(); - const res = await app.request( - "/api/thumbnail-id/d/doc/v/ver/e/elem", - jsonRequest("GET"), - env + it("requires the microversion, which is part of the key", async () => { + const res = await get(`/api/thumbnail/${SIZE}/some-element`); + expect(res.status).toBe(400); + }); + + it("rejects a size that is not one we store", async () => { + const res = await get(`/api/thumbnail/999x999/some-element?v=1`); + expect(res.status).toBe(400); + }); + + it("marks a stood-in default so the client keeps waiting", async () => { + const elementId = "fallback-flagged"; + await env.BLOB.put( + thumbnailKey(elementId, MICROVERSION, SIZE), + "default-bytes" + ); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION + }) + ); + expect(res.status).toBe(200); + expect(res.headers.get(THUMBNAIL_FALLBACK_HEADER)).toBe("1"); + }); + + it("does not mark a real hit as a fallback", async () => { + const elementId = "exact-hit"; + await env.BLOB.put( + thumbnailKey( + elementId, + MICROVERSION, + SIZE, + canonicalConfigurationKey(CANONICAL_CONFIGURATION) + ), + "config-bytes" ); - // The mock refuses the Onshape call, so this only proves the request - // reached the handler rather than being rejected for a missing `?v=`. - expect(res.status).not.toBe(400); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION + }) + ); + expect(res.status).toBe(200); + expect(res.headers.get(THUMBNAIL_FALLBACK_HEADER)).toBeNull(); }); - it("GET /thumbnail/:size/:elementId 404s when the object is missing", async () => { - const app = createTestApp(); - const res = await app.request( - `/api/thumbnail/${SIZE}/does-not-exist?v=abc123`, - jsonRequest("GET"), - env + it("404s when neither the configuration nor the default exists", async () => { + const res = await get( + thumbnailUrl({ + elementId: "does-not-exist", + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }) ); expect(res.status).toBe(404); // A thumbnail uploaded later must not be shadowed by a cached miss. expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); + + // A configuration we haven't rendered stands in with the element's default, + // cached briefly so the real render can take over as soon as it lands. + it("falls back to the default thumbnail, cached only briefly", async () => { + const elementId = "fallback-element"; + await env.BLOB.put( + thumbnailKey(elementId, MICROVERSION, SIZE), + "default-bytes" + ); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION + }) + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe("default-bytes"); + expect(res.headers.get("Cache-Control")).toBe( + `public, max-age=${THUMBNAIL_FALLBACK_CACHE_TTL}` + ); + }); + + it("prefers the configuration's own thumbnail once it exists", async () => { + const elementId = "configured-element"; + await env.BLOB.put( + thumbnailKey(elementId, MICROVERSION, SIZE), + "default-bytes" + ); + await env.BLOB.put( + thumbnailKey( + elementId, + MICROVERSION, + SIZE, + canonicalConfigurationKey(CANONICAL_CONFIGURATION) + ), + "configured-bytes" + ); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION + }) + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe("configured-bytes"); + expect(res.headers.get("Cache-Control")).toContain("immutable"); + }); +}); + +describe("warming 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" + ); + } + + it("passes warm as a boolean the validator accepts", () => { + const url = thumbnailUrl({ + elementId: "any", + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION, + warm: true, + insertableId: INSERTABLE_ID + }); + expect(new URL(url, "http://x").searchParams.get("warm")).toBe("true"); + }); + + // Without one there is nothing to resolve the element from, so warming is + // simply not requested. + it("omits warm when no insertable is named", () => { + const url = thumbnailUrl({ + elementId: "any", + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION, + warm: true + }); + expect(new URL(url, "http://x").searchParams.get("warm")).toBeNull(); + }); + + it("starts the render on a miss", async () => { + const elementId = "warm-element"; + await seedDefaultOnly(elementId); + const createSpy = vi + .spyOn(env.THUMBNAIL_WORKFLOW, "create") + .mockResolvedValue({} as never); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION, + warm: true, + insertableId: INSERTABLE_ID + }), + SESSION_ID + ); + + expect(res.status).toBe(200); + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + insertableId: INSERTABLE_ID, + microversionId: MICROVERSION, + canonicalConfiguration: CANONICAL_CONFIGURATION, + // The render runs later, so it needs a session to authenticate. + sessionId: SESSION_ID + } + }) + ); + }); + + // The bytes still have to be served; only the render is given up on. + it("still serves the fallback when there is no session to render under", async () => { + const elementId = "sessionless-element"; + await seedDefaultOnly(elementId); + const createSpy = vi.spyOn(env.THUMBNAIL_WORKFLOW, "create"); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION, + warm: true, + insertableId: INSERTABLE_ID + }) + ); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("default-bytes"); + expect(createSpy).not.toHaveBeenCalled(); + }); + + // Search results show many configurations at once; one cold search must not + // kick off a render per row. + it("does not start the render when warm is absent", async () => { + const elementId = "cold-element"; + await seedDefaultOnly(elementId); + const createSpy = vi.spyOn(env.THUMBNAIL_WORKFLOW, "create"); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + canonicalConfiguration: CANONICAL_CONFIGURATION + }) + ); + + expect(res.status).toBe(200); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it("rejects a warm that is not a boolean", async () => { + const res = await get( + `/api/thumbnail/${SIZE}/any?v=${MICROVERSION}&c=x&warm=maybe` + ); + expect(res.status).toBe(400); + }); +}); + +describe("uploadConfigurationThumbnails", () => { + const elementPath = { + documentId: "d", + instanceId: "v", + instanceType: "v" as const, + elementId: "upload-element" + }; + + /** Stands in for Onshape; only the calls matter, not the bytes. */ + function fakeOnshapeApi() { + const getImage = vi.fn().mockResolvedValue(new ArrayBuffer(4)); + const api = { + get: vi.fn().mockResolvedValue({ + items: [{ predictableThumbnailId: "tid" }] + }), + getImage + } as unknown as OnshapeApi; + return { api, getImage }; + } + + it("renders and stores both sizes", async () => { + const { api } = fakeOnshapeApi(); + + await uploadConfigurationThumbnails( + env.BLOB, + api, + elementPath, + MICROVERSION, + CANONICAL_CONFIGURATION + ); + + const key = (size: ThumbnailSize) => + thumbnailKey( + elementPath.elementId, + MICROVERSION, + size, + canonicalConfigurationKey(CANONICAL_CONFIGURATION) + ); + expect(await env.BLOB.head(key(ThumbnailSize.SMALL))).not.toBeNull(); + expect(await env.BLOB.head(key(ThumbnailSize.LARGE))).not.toBeNull(); + }); + + // Runs are no longer deduplicated by instance id, so this is what keeps a + // second run from paying for a render Onshape already did. + it("skips Onshape entirely when both sizes are already stored", async () => { + const storedPath = { ...elementPath, elementId: "already-stored" }; + for (const size of [ThumbnailSize.SMALL, ThumbnailSize.LARGE]) { + await env.BLOB.put( + thumbnailKey( + storedPath.elementId, + MICROVERSION, + size, + canonicalConfigurationKey(CANONICAL_CONFIGURATION) + ), + "bytes" + ); + } + const { api, getImage } = fakeOnshapeApi(); + + await uploadConfigurationThumbnails( + env.BLOB, + api, + storedPath, + MICROVERSION, + CANONICAL_CONFIGURATION + ); + + expect(getImage).not.toHaveBeenCalled(); + }); + + // One size present is a half-done render, not a reason to skip. + it("renders when only one size is stored", async () => { + const partialPath = { ...elementPath, elementId: "half-stored" }; + await env.BLOB.put( + thumbnailKey( + partialPath.elementId, + MICROVERSION, + ThumbnailSize.SMALL, + canonicalConfigurationKey(CANONICAL_CONFIGURATION) + ), + "bytes" + ); + const { api, getImage } = fakeOnshapeApi(); + + await uploadConfigurationThumbnails( + env.BLOB, + api, + partialPath, + MICROVERSION, + CANONICAL_CONFIGURATION + ); + + expect(getImage).toHaveBeenCalled(); + }); }); diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index a1390de12..ae5d788ea 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -1,90 +1,168 @@ import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { zValidator } from "@hono/zod-validator"; import { CachePolicy, cacheMiddleware, getApp, getInsertableParam, immutableCacheControl, - insertableRoute + insertableRoute, + setCacheTtl } from "../app"; import { getInsertableElementPath } from "./insertables"; import { getDb } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; -import { requireSignInMiddleware } from "../sign-in-utils"; import { bumpLibraryVersion } from "../library-data"; import { getElementThumbnail, getThumbnailFromId, - getThumbnailId, - ThumbnailSize + getThumbnailId } from "../onshape-api/endpoints/thumbnails"; import { getDocument, getContents } from "../onshape-api/endpoints/documents"; import { type ElementPath, type InstancePath } from "../../shared/onshape-path"; import { group, insertables } from "../../shared/schema"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; -import { ThumbnailUrls } from "../../shared/types"; +import { ThumbnailSize, ThumbnailUrls } from "../../shared/types"; +import { + THUMBNAIL_FALLBACK_CACHE_TTL, + THUMBNAIL_FALLBACK_HEADER, + thumbnailKey, + thumbnailUrl +} from "../../shared/thumbnails"; +import { + DEFAULT_CANONICAL_CONFIGURATION, + DEFAULT_CONFIGURATION_KEY, + canonicalConfigurationKey +} from "../../shared/canonical-configuration"; import { OnshapeApi } from "../onshape-api/onshape-api"; +import type { AppContext } from "../app"; +import type { ThumbnailWorkflowParams } from "../load/workflows"; +import { getSessionId } from "../auth"; import { BuildIssueType, clearBuildIssue } from "../../shared/build-issues"; -function r2Key(size: string, elementId: string): string { - return `thumbnails/${size}/${elementId}`; +/** Stores one rendered thumbnail, tagging it with what produced it. */ +async function putThumbnail( + bucket: R2Bucket, + key: string, + thumbnail: ArrayBuffer, + metadata: Record +): Promise { + await bucket.put(key, thumbnail, { + httpMetadata: { + contentType: "image/gif", + cacheControl: immutableCacheControl(CachePolicy.PUBLIC_CACHE) + }, + customMetadata: metadata + }); } +/** Throws until Onshape has rendered them, which drives the load step's retries. */ export async function uploadThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, elementPath: ElementPath, microversionId: string ): Promise { - const fetchThumbnail = async ( - size: ThumbnailSize - ): Promise => { - try { - return getElementThumbnail(onshapeApi, elementPath, size); - } catch { - return null; - } - }; - - // Fetch in parallel - const [tinyThumbnail, standardThumbnail] = await Promise.all([ - fetchThumbnail(ThumbnailSize.TINY), - fetchThumbnail(ThumbnailSize.STANDARD) + const [small, large] = await Promise.all([ + getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.SMALL), + getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.LARGE) ]); - - if (!tinyThumbnail || !standardThumbnail) { + if (!small || !large) { throw new Error("Failed to find thumbnails. Try again later."); } - const uploadThumbnail = async ( - size: ThumbnailSize, - thumbnail: ArrayBuffer - ) => { - await bucket.put(r2Key(size, elementPath.elementId), thumbnail, { - httpMetadata: { - contentType: "image/gif", - cacheControl: immutableCacheControl(CachePolicy.PUBLIC_CACHE) - }, - customMetadata: { microversionId } - }); - return `/api/thumbnail/${size}/${elementPath.elementId}?v=${microversionId}`; - }; - - const [tinyUrl, standardUrl] = await Promise.all([ - uploadThumbnail(ThumbnailSize.TINY, tinyThumbnail), - uploadThumbnail(ThumbnailSize.STANDARD, standardThumbnail) + const { elementId } = elementPath; + await Promise.all([ + putThumbnail( + bucket, + thumbnailKey(elementId, microversionId, ThumbnailSize.SMALL), + small, + { microversionId } + ), + putThumbnail( + bucket, + thumbnailKey(elementId, microversionId, ThumbnailSize.LARGE), + large, + { microversionId } + ) ]); return { - [ThumbnailSize.TINY]: tinyUrl, - [ThumbnailSize.STANDARD]: standardUrl + small: thumbnailUrl({ + elementId, + microversionId, + size: ThumbnailSize.SMALL, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }), + large: thumbnailUrl({ + elementId, + microversionId, + size: ThumbnailSize.LARGE, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }) }; } +/** Whether every key is already stored, so the render can be skipped. */ +async function allStored(bucket: R2Bucket, keys: string[]): Promise { + const heads = await Promise.all(keys.map((key) => bucket.head(key))); + return heads.every((head) => head !== null); +} + /** - * Uploads document-level thumbnails using the document's designated thumbnail element. + * Both sizes, so a row and its hover never disagree. The two-stage id flow is the + * only Onshape path taking a configuration; either call can fail mid-render. */ +export async function uploadConfigurationThumbnails( + bucket: R2Bucket, + onshapeApi: OnshapeApi, + elementPath: ElementPath, + microversionId: string, + canonicalConfiguration: string +): Promise { + const configurationKey = canonicalConfigurationKey(canonicalConfiguration); + const { elementId } = elementPath; + const targets = [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => ({ + size, + key: thumbnailKey(elementId, microversionId, size, configurationKey) + })); + const keys = targets.map((target) => target.key); + + // Runs are no longer deduplicated by id, and Onshape is the expensive part. + if (await allStored(bucket, keys)) { + return; + } + + const thumbnailId = await getThumbnailId( + onshapeApi, + elementPath, + canonicalConfiguration + ); + const rendered = await Promise.all( + targets.map(async ({ size, key }) => ({ + key, + thumbnail: await getThumbnailFromId(onshapeApi, thumbnailId, size) + })) + ); + + // The render above takes minutes, long enough to have been beaten to it. + if (await allStored(bucket, keys)) { + return; + } + + await Promise.all( + rendered.map(({ key, thumbnail }) => + putThumbnail(bucket, key, thumbnail, { + microversionId, + canonicalConfiguration + }) + ) + ); +} + +/** Falls back to the first element when the document designates no thumbnail. */ export async function uploadDocumentThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, @@ -123,69 +201,103 @@ export async function uploadDocumentThumbnails( export const thumbnailRoutes = getApp(); -/** GET /api/thumbnail/:size/:elementId?v=:microversionId — static from R2 */ +const storedThumbnailParams = z.object({ + size: z.enum(ThumbnailSize), + elementId: z.string().min(1) +}); + +/** Absent means the element default, which is what `""` encodes. */ +const canonicalConfigurationQuery = z + .string() + .default(DEFAULT_CANONICAL_CONFIGURATION); + +const storedThumbnailQuery = z.object({ + /** The microversion, part of the key — which is what makes a hit immutable. */ + v: z.string().min(1), + c: canonicalConfigurationQuery, + warm: z.stringbool().default(false), + /** The insertable to render from; only sent with `warm`. */ + i: z.string().optional() +}); + +/** + * GET /api/thumbnail/:size/:elementId?v=&c=&warm= — an unrendered configuration + * falls back to the element default, and `warm` kicks off the real render. + */ thumbnailRoutes.get( "/thumbnail/:size/:elementId", cacheMiddleware(CachePolicy.PUBLIC_CACHE), + zValidator("param", storedThumbnailParams), + zValidator("query", storedThumbnailQuery), async (c) => { - const size = c.req.param("size"); - const elementId = c.req.param("elementId"); + const { size, elementId } = c.req.valid("param"); + const { + v: microversionId, + c: canonicalConfiguration, + warm, + i: insertableId + } = c.req.valid("query"); + const configurationKey = canonicalConfigurationKey( + canonicalConfiguration + ); - const obj = await c.env.THUMBNAILS.get(r2Key(size, elementId)); - if (!obj) return c.notFound(); + const object = await c.env.BLOB.get( + thumbnailKey(elementId, microversionId, size, configurationKey) + ); + if (object) { + return thumbnailResponse(object); + } - const headers = new Headers(); - obj.writeHttpMetadata(headers); - return new Response(obj.body, { headers }); - } -); + if (configurationKey === DEFAULT_CONFIGURATION_KEY) { + return c.notFound(); + } -/** GET /api/thumbnail?size=X&thumbnailId=Y&v=:microversionId — live from Onshape */ -thumbnailRoutes.get( - "/thumbnail", - requireSignInMiddleware, - cacheMiddleware(CachePolicy.PUBLIC_CACHE), - async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const size = - (c.req.query("size") as ThumbnailSize) ?? ThumbnailSize.STANDARD; - const thumbnailId = c.req.query("thumbnailId"); - if (!thumbnailId) - return c.json( - { error: "thumbnailId required" }, - HttpStatus.BAD_REQUEST - ); + if (warm && insertableId) { + await warmConfigurationThumbnail(c, { + insertableId, + microversionId, + canonicalConfiguration + }); + } - const buffer = await getThumbnailFromId(onshapeApi, thumbnailId, size); - return new Response(buffer, { - headers: { "Content-Type": "image/gif" } - }); + // Stand in with the default configuration until the real render lands. + const fallback = await c.env.BLOB.get( + thumbnailKey(elementId, microversionId, size) + ); + if (!fallback) { + return c.notFound(); + } + // Unlike a hit, this url does not pin these bytes. + setCacheTtl(c, THUMBNAIL_FALLBACK_CACHE_TTL); + const response = thumbnailResponse(fallback); + response.headers.set(THUMBNAIL_FALLBACK_HEADER, "1"); + return response; } ); -/** GET /api/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId */ -thumbnailRoutes.get( - "/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId", - requireSignInMiddleware, - // Its url names an immutable version, so there is no `?v=` to bust. - cacheMiddleware(CachePolicy.PUBLIC_CACHE, { versioned: false }), - async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const elementPath: ElementPath = { - documentId: c.req.param("docId"), - instanceId: c.req.param("instanceId"), - instanceType: c.req.param("instanceType") as "w" | "v" | "m", - elementId: c.req.param("elementId") - }; - const configuration = c.req.query("configuration"); - const thumbnailId = await getThumbnailId( - onshapeApi, - elementPath, - configuration - ); - return c.json({ thumbnailId }); +function thumbnailResponse(object: R2ObjectBody): Response { + const headers = new Headers(); + object.writeHttpMetadata(headers); + return new Response(object.body, { headers }); +} + +/** + * Concurrent requests can each start a run. Rare, and the workflow skips a + * render that is already stored, which a reused id would rule out permanently. + */ +async function warmConfigurationThumbnail( + c: AppContext, + params: Omit +): Promise { + try { + // The render runs later, under this caller's Onshape tokens. + await c.env.THUMBNAIL_WORKFLOW.create({ + params: { ...params, sessionId: getSessionId(c) } + }); + } catch { + // Never fatal: the caller still has the default thumbnail to serve. } -); +} /** POST /api/reload-insertable-thumbnail/insertable/:insertableId */ thumbnailRoutes.post( @@ -215,7 +327,7 @@ thumbnailRoutes.post( } const thumbnails = await uploadThumbnails( - c.env.THUMBNAILS, + c.env.BLOB, onshapeApi, elementPath, row.microversionId @@ -224,7 +336,8 @@ thumbnailRoutes.post( await db .update(insertables) .set({ - thumbnailUrls: thumbnails, + smallThumbnailUrl: thumbnails.small, + largeThumbnailUrl: thumbnails.large, buildIssues: clearBuildIssue( row.buildIssues, BuildIssueType.THUMBNAIL_FAILED @@ -270,7 +383,7 @@ thumbnailRoutes.post( }; const thumbnails = await uploadDocumentThumbnails( - c.env.THUMBNAILS, + c.env.BLOB, onshapeApi, instancePath ); @@ -278,7 +391,8 @@ thumbnailRoutes.post( await db .update(group) .set({ - thumbnailUrls: thumbnails, + smallThumbnailUrl: thumbnails.small, + largeThumbnailUrl: thumbnails.large, buildIssues: clearBuildIssue( row.buildIssues, BuildIssueType.THUMBNAIL_FAILED diff --git a/src/frontend/api-utils/access-level.tsx b/src/frontend/api-utils/access-level.tsx index db02b456b..7db66d572 100644 --- a/src/frontend/api-utils/access-level.tsx +++ b/src/frontend/api-utils/access-level.tsx @@ -66,7 +66,6 @@ interface RequireAccessLevelProps extends PropsWithChildren { useMaxAccessLevel?: boolean; } -/** Renders children only when the access-level requirement is met. */ export function RequireAccessLevel(props: RequireAccessLevelProps) { const accessData = useAccessData(); const requiredAccessLevel = props.accessLevel ?? AccessLevel.EDITOR; @@ -88,7 +87,6 @@ export function RequireAccessLevel(props: RequireAccessLevelProps) { return null; } -/** Renders children only when the caller is signed in to Onshape. */ export function RequireSignIn(props: PropsWithChildren) { return useIsSignedIn() ? props.children : null; } diff --git a/src/frontend/api-utils/api.ts b/src/frontend/api-utils/api.ts index b34cd1e9e..34b97717e 100644 --- a/src/frontend/api-utils/api.ts +++ b/src/frontend/api-utils/api.ts @@ -5,6 +5,8 @@ import { type PostOptions } from "../common/utils"; import { HandledError } from "./errors"; +import { THUMBNAIL_FALLBACK_HEADER } from "../../shared/thumbnails"; +import { HttpStatus } from "http-status-ts"; function getUrl( path: string, @@ -18,9 +20,6 @@ function getUrl( return "/api" + path + `?${searchParams}`; } -/** - * Makes a post request to a backend /api route. - */ export async function apiPost( path: string, options?: PostOptions @@ -37,9 +36,6 @@ interface QueryOptionsWithCacheId extends QueryOptions { cacheId?: string | number; } -/** - * Makes a get request to a backend /api route. - */ export async function apiGet( path: string, options?: QueryOptionsWithCacheId @@ -49,39 +45,69 @@ export async function apiGet( }).then(handleResponse); } -export async function apiGetRawImage( - url: string, - signal?: AbortSignal -): Promise { - return fetch(url, { - signal - }).then(handleImageResponse); -} - /** - * Makes a get request for an image to a backend /api route. - * Returns a local url for the image. + * Gets a response formatted as a raw string from a backend /api route. */ -export async function apiGetImage( +export async function apiGetText( path: string, options?: QueryOptionsWithCacheId +): Promise { + const response = await fetch( + getUrl(path, options?.query, options?.cacheId), + { signal: options?.signal } + ); + if (response.status === HttpStatus.NOT_FOUND) { + return null; + } + if (!response.ok) { + throw new Error("Network response failed."); + } + return await response.text(); +} + +/** + * Fetching here surfaces failures as a rejected query and warms the browser + * cache. Returns the url, not a blob url, which has no safe moment to revoke. + */ +export async function loadImage( + url: string, + signal?: AbortSignal ): Promise { - return fetch(getUrl(path, options?.query, options?.cacheId), { - signal: options?.signal - }).then(handleImageResponse); + return (await loadImageResult(url, signal)).url; +} + +export interface LoadedImage { + url: string; + /** True when the worker served a stand-in rather than what was asked for. */ + isFallback: boolean; } -async function handleImageResponse(response: Response) { +/** {@link loadImage}, also reporting whether the worker stood something in. */ +export async function loadImageResult( + url: string, + signal?: AbortSignal +): Promise { + const response = await fetch(url, { signal }); if (!response.ok) { throw new Error("Network response failed."); } - const blob = await response.blob(); - return URL.createObjectURL(blob); + return { + url, + isFallback: response.headers.has(THUMBNAIL_FALLBACK_HEADER) + }; +} + +/** {@link loadImage} for a backend /api route. */ +export async function loadApiImage( + path: string, + options?: QueryOptionsWithCacheId +): Promise { + return loadImage( + getUrl(path, options?.query, options?.cacheId), + options?.signal + ); } -/** - * Makes a delete request to a backend /api route. - */ export async function apiDelete( path: string, options?: QueryOptions diff --git a/src/frontend/api-utils/errors.ts b/src/frontend/api-utils/errors.ts index 787938be4..eda404896 100644 --- a/src/frontend/api-utils/errors.ts +++ b/src/frontend/api-utils/errors.ts @@ -14,9 +14,6 @@ export class HandledError extends Error { } } -/** - * Returns a function that handles app errors. - */ export function getAppErrorHandler(defaultMessage: string, toastId?: string) { return (error: Error) => handleAppError(error, defaultMessage, toastId); } @@ -34,8 +31,5 @@ export function handleAppError( } return; } - // else if (error instanceof NoError) { - // return; - // } showErrorToast(defaultMessage, toastKey); } diff --git a/src/frontend/api-utils/messages.ts b/src/frontend/api-utils/messages.ts index 7023a438e..6e97fe1d7 100644 --- a/src/frontend/api-utils/messages.ts +++ b/src/frontend/api-utils/messages.ts @@ -1,10 +1,6 @@ /** - * Code for working with the Onshape Client Messaging API. - * - * See also: + * The Onshape Client Messaging API, for a right-panel extension (not a tab one). * https://onshape-public.github.io/docs/app-dev/clientmessaging/ - * - * (Note we are an Element right panel extension and not an Element tab extension). */ import { useSearch } from "@tanstack/react-router"; diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index a6be44103..99371f00c 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/cards/build-status.tsx @@ -4,6 +4,7 @@ import { Group, HoverCard, Loader, + ScrollArea, Stack, Switch, Text, @@ -41,23 +42,32 @@ import { InsertableBuildStatus } from "../../shared/api-models"; import { getVendorName, Vendor } from "../../shared/types"; +import { + ConfigurationParameter, + ParameterType +} from "../../shared/configuration-models"; +import { + AUTO_INDEX_THRESHOLD, + type ConfigurationCount, + countConfigurations, + IndexingBand, + isIndexedParameter, + MAX_PART_NUMBER_CONFIGURATIONS +} from "../../shared/configuration-combinations"; import { FontWeight, IconColor, IconSize } from "../common/style-constants"; import { RequireAccessLevel } from "../api-utils/access-level"; import { useBuildStatusQuery, useJobStatusQuery } from "../queries"; import { useSetVisibilityMutation, useToggleInsertAndFastenMutation, - useTogglePartNumberSearchMutation, + useIndexConfigurationsMutation, useToggleSortOrderMutation } from "./card-hooks"; -/** - * The value of a read-only "parsed" row. A discriminated union so `StateValue` - * can render each kind appropriately (a check/cross for booleans, badges for - * vendors). - */ +/** Discriminated so `StateValue` renders each kind its own way. */ export type StateRowValue = | { kind: "bool"; value: boolean } + | { kind: "text"; text: string; dimmed?: boolean } | { kind: "vendors"; vendors: Vendor[] }; /** @@ -72,9 +82,8 @@ function getInsertableBuildIssues( } /** - * Returns the build issues for a group, combining stored build-time issues with - * the live "no unhidden insertables" check (computed here since visibility is - * per-insertable state in the same build-status response). + * Stored issues plus the live "no unhidden insertables" check, which needs the + * per-insertable visibility in the same response. */ function useGroupBuildIssues( groupStatus: GroupBuildStatus | undefined, @@ -193,9 +202,8 @@ interface BuildStatusBadgeProps { } /** - * Dismisses the surrounding build-status hover card. Used by controls that open - * a modal, since `HoverCard` only closes on mouse-leave — never fired when a - * modal overlay simply covers the dropdown, leaving it stranded behind. + * 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); @@ -486,18 +494,42 @@ export function InsertableStatusBadge({ issues={getInsertableBuildIssues(insertable)} lastLoadedAt={insertable.lastLoadedAt} hoverMenu={ - <> - - - + } /> ); } +/** Enumerates configurations once, for every row of the card that needs it. */ +function InsertableHoverMenu({ + insertableId, + status +}: { + insertableId: string; + status: InsertableBuildStatus; +}): ReactNode { + const configurationCount = useConfigurationCount(status); + return ( + <> + + + + + ); +} + /** Build-status badge pre-wired for a group (includes live visibility check). */ export function GroupStatusBadge({ groupId, @@ -531,12 +563,14 @@ function SectionHeader({ children }: { children: ReactNode }): ReactNode { ); } -/** A label (+ description) and on/off Switch row for an editable admin flag. */ -function SwitchRow(props: { +/** + * 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. + */ +function ControlRow(props: { label: string; description?: string; - checked: boolean; - onToggle: () => void; + control: ReactNode; }): ReactNode { return ( @@ -546,23 +580,43 @@ function SwitchRow(props: { {props.description} - + {props.control} ); } +/** A label (+ description) and on/off Switch row for an editable admin flag. */ +function SwitchRow(props: { + label: string; + description?: string; + checked: boolean; + onToggle: () => void; +}): ReactNode { + return ( + + } + /> + ); +} + /** The editable admin toggles for an insertable. */ function InsertableAdminSection({ insertableId, - status + status, + configurationCount }: { insertableId: string; status: InsertableBuildStatus; + configurationCount: ConfigurationCount; }): ReactNode { return ( @@ -575,9 +629,10 @@ function InsertableAdminSection({ insertableId={insertableId} supportsFasten={status.supportsFasten} /> - ); @@ -618,24 +673,74 @@ function FastenSwitch({ ); } -function PartNumberSwitch({ +/** + * 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({ insertableId, - searchPartNumbers + status, + band }: { insertableId: string; - searchPartNumbers: boolean; + status: InsertableBuildStatus; + band: IndexingBand; }): ReactNode { - const mutation = useTogglePartNumberSearchMutation(insertableId); + 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 ( - mutation.mutate(!searchPartNumbers)} + ); } +/** + * 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({ + severity, + tooltip +}: { + severity: BuildIssueSeverity | null; + tooltip: string; +}): ReactNode { + return ( + + + + ); +} + /** The editable admin toggles for a group. */ function GroupAdminSection({ groupId, @@ -658,11 +763,38 @@ function GroupAdminSection({ ); } +/** + * Enumerated rather than stored: the same shared routine the load path uses, + * and it only runs when a hover card opens. + */ +function useConfigurationCount( + status: InsertableBuildStatus +): ConfigurationCount { + const parameters = status.configuration?.parameters; + return useMemo(() => countConfigurations(parameters ?? []), [parameters]); +} + +/** Open-ended past the cap, where enumeration stops before reaching a total. */ +function configurationCountValue(count: number | null): StateRowValue { + if (count === null) { + return { + kind: "text", + text: `Over ${MAX_PART_NUMBER_CONFIGURATIONS}` + }; + } + if (count === 0) { + return { kind: "text", text: "None", dimmed: true }; + } + return { kind: "text", text: count.toLocaleString() }; +} + /** The read-only auto-detected facts for an insertable. */ function InsertableParsedSection({ - status + status, + count }: { status: InsertableBuildStatus; + count: number | null; }): ReactNode { return ( <> @@ -674,14 +806,138 @@ function InsertableParsedSection({ value={{ kind: "vendors", vendors: status.vendors }} /> ); } +/** Each parameter's name, the type it takes, and whether indexing varies it. */ +function ConfigurationSection({ + parameters +}: { + parameters?: ConfigurationParameter[]; +}): ReactNode { + if (!parameters || parameters.length === 0) return null; + return ( + <> + + + Configurations + + + {parameters.map((parameter) => ( + + {parameter.name} + + + + + + ))} + + + + + ); +} + +/** Why a parameter is or isn't varied when indexing, shown on hover. */ +function getIndexedDescription(parameter: ConfigurationParameter): string { + if (isIndexedParameter(parameter)) { + return "Varied when indexing part numbers, so it multiplies this insertable's configuration count."; + } + if ( + parameter.type === ParameterType.QUANTITY || + parameter.type === ParameterType.STRING + ) { + return "Quantity and text parameters are never varied — they stay at their Onshape default."; + } + return "Excluded from properties, so it stays at its default instead of multiplying the configuration count."; +} + +/** Whether indexing varies this parameter — the lever on the configuration count. */ +function IndexedBadge({ + parameter +}: { + parameter: ConfigurationParameter; +}): ReactNode { + const isIndexed = isIndexedParameter(parameter); + return ( + + + {isIndexed ? "Indexed" : "Not indexed"} + + + ); +} + +/** + * 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. + */ +function ParameterTypeBadge({ + parameter +}: { + parameter: ConfigurationParameter; +}): ReactNode { + const isEnum = parameter.type === ParameterType.ENUM; + const label = isEnum + ? `${getParameterTypeLabel(parameter.type)} (${parameter.options.length})` + : getParameterTypeLabel(parameter.type); + + const badge = ( + + {label} + + ); + if (!isEnum || parameter.options.length === 0) { + return badge; + } + return ( + option.name).join(", ")} + multiline + maw={260} + withArrow + events={{ hover: true, focus: true, touch: true }} + > + {badge} + + ); +} + +/** The short label for a parameter's type, shown as a badge. */ +function getParameterTypeLabel(type: ParameterType): string { + switch (type) { + case ParameterType.ENUM: + return "Enum"; + case ParameterType.BOOLEAN: + return "Boolean"; + case ParameterType.QUANTITY: + return "Quantity"; + case ParameterType.STRING: + return "Text"; + } +} + /** A read-only label/value row in the "Parsed" section. */ function ParsedRow({ label, @@ -708,6 +964,14 @@ function StateValue({ value }: { value: StateRowValue }): ReactNode { ); } + if (value.kind === "text") { + return ( + + {value.text} + + ); + } + if (value.vendors.length === 0) { return ( diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index 05880902e..2355c64d5 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -1,4 +1,4 @@ -import { Group, Menu, Table, Text } from "@mantine/core"; +import { Group, Menu, Stack, Table, Text } from "@mantine/core"; import { IconExternalLink, IconEyeOff, @@ -9,11 +9,11 @@ import { } from "@tabler/icons-react"; import { IconColor, IconSize } from "../common/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../common/url"; -import { PropsWithChildren, ReactNode, useCallback } from "react"; +import { Fragment, PropsWithChildren, ReactNode, useCallback } from "react"; import { AppContextMenu, MenuButton } from "../app-common/app-menu"; -import { SearchHit } from "../search/search"; -import { SearchHitTitle } from "../search/search-results"; -import { CardThumbnail } from "../insert/thumbnail"; +import { type Position, SearchHit } from "../search/search"; +import { HighlightedText, SearchHitTitle } from "../search/search-results"; +import { CardThumbnail, type ThumbnailTarget } from "../insert/thumbnail"; import { ConfigurablePath, InstancePath } from "../../shared/onshape-path"; import { openCannotDeriveAssemblyAlert } from "../app/alerts"; import { @@ -22,7 +22,7 @@ import { } from "../insert/insert-hooks"; import { InsertableOut } from "../../shared/api-models"; import { ElementType } from "../../shared/types"; -import { ThumbnailUrls } from "../../shared/types"; + import { ParameterValues } from "../../shared/configuration-models"; import { useSearch } from "@tanstack/react-router"; import { RequireAccessLevel } from "../api-utils/access-level"; @@ -129,13 +129,23 @@ interface CardTitleProps { */ title: string; searchHit?: SearchHit; - thumbnailUrls?: ThumbnailUrls; + smallThumbnailUrl?: string; + largeThumbnailUrl?: string; + /** Set to show a specific configuration's thumbnail instead of the default. */ + thumbnailTarget?: ThumbnailTarget; /** Optional build-status badge rendered after the title. */ buildStatusBadge?: ReactNode; } export function CardTitle(props: CardTitleProps) { - const { searchHit, title, thumbnailUrls, buildStatusBadge } = props; + const { + searchHit, + title, + smallThumbnailUrl, + largeThumbnailUrl, + thumbnailTarget, + buildStatusBadge + } = props; const disabled = props.disabled ?? false; const isHidden = props.showHiddenTag ?? false; @@ -146,12 +156,50 @@ export function CardTitle(props: CardTitleProps) { cardTitle = title; } + // The hit's best-matching configuration, minus a name repeating the title. + // Each carries its own positions, so a part-number hit underlines there too. + const details = searchHit + ? ( + [ + [searchHit.partNumber, searchHit.partNumberPositions], + [searchHit.partName, searchHit.partNamePositions] + ] as const + ).filter( + (detail): detail is [string, Position[] | undefined] => + !!detail[0] && detail[0].toLowerCase() !== title.toLowerCase() + ) + : []; + return ( - - - {cardTitle} - + + {/* Shrinks to truncate, but never grows: the build status badge and + hidden tag belong beside the name, not at the row's edge. */} + + + {cardTitle} + + {details.length > 0 && ( + + {details.map(([text, positions], index) => ( + + {index > 0 && " · "} + + + ))} + + )} + + {buildStatusBadge} + {/* After the badge: toggling visibility would otherwise shift the + badge, dragging its open hover card out from under the cursor. */} {isHidden && ( )} - {buildStatusBadge} ); } diff --git a/src/frontend/cards/card-hooks.ts b/src/frontend/cards/card-hooks.ts index 97f90d4f4..740d2af25 100644 --- a/src/frontend/cards/card-hooks.ts +++ b/src/frontend/cards/card-hooks.ts @@ -39,10 +39,10 @@ export function useSetVisibilityMutation( const closeCard = useCloseBuildCard(); const mutation = useMutation({ - mutationKey: ["set-element-visibility", ...insertableIds], + mutationKey: ["set-insertable-visibility", ...insertableIds], mutationFn: async () => { return apiPost( - "/set-element-visibility" + toLibraryPath(libraryId), + "/set-insertable-visibility" + toLibraryPath(libraryId), { body: { insertableIds, @@ -166,40 +166,39 @@ export function useToggleInsertAndFastenMutation(insertableId: string) { }); } -/** Toggles part-number search indexing for an insertable (a slow Onshape call). */ -export function useTogglePartNumberSearchMutation(insertableId: string) { +/** Forces part-number indexing for an insertable (a slow Onshape call). */ +export function useIndexConfigurationsMutation(insertableId: string) { const key = useBuildStatusKey(); const refreshLibrary = useRefreshLibrary(); - const toastId = `part-number-search-${insertableId}`; + const toastId = `index-configurations-${insertableId}`; return useMutation({ - mutationKey: ["toggle-part-number-search", insertableId], - mutationFn: (searchPartNumbers: boolean) => - apiPost( - "/toggle-part-number-search" + toInsertablePath(insertableId), - { body: { searchPartNumbers } } - ), - onMutate: (searchPartNumbers) => { + mutationKey: ["index-configurations", insertableId], + mutationFn: (indexConfigurations: boolean) => + apiPost("/index-configurations" + toInsertablePath(insertableId), { + body: { indexConfigurations } + }), + onMutate: (indexConfigurations) => { showLoadingToast( - searchPartNumbers - ? "Enabling part number search..." - : "Disabling part number search...", + indexConfigurations + ? "Forcing part number indexing..." + : "Disabling forced part number indexing...", toastId ); return patchQuery(key, (status) => { const insertable = status.insertables[insertableId]; if (insertable) - insertable.searchPartNumbers = searchPartNumbers; + insertable.indexConfigurations = indexConfigurations; }); }, - onSuccess: (_result, searchPartNumbers) => + onSuccess: (_result, indexConfigurations) => showSuccessToast( - searchPartNumbers - ? "Enabled part number search." - : "Disabled part number search.", + indexConfigurations + ? "Forced part number indexing." + : "Disabled forced part number indexing.", toastId ), onError: getAppErrorHandler( - "Unexpectedly failed to update part number search.", + "Unexpectedly failed to update part number indexing.", toastId ), onSettled: refreshLibrary diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index 9e710df4b..58eb60d0f 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -1,3 +1,4 @@ +import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { Menu } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; import { @@ -76,7 +77,17 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { disabled={isAssemblyInPartStudio} searchHit={searchHit} title={insertable.name} - thumbnailUrls={insertable.thumbnailUrls} + smallThumbnailUrl={insertable.smallThumbnailUrl} + largeThumbnailUrl={insertable.largeThumbnailUrl} + thumbnailTarget={{ + elementId: insertable.elementId, + microversionId: insertable.microversionId, + canonicalConfiguration: encodeCanonicalConfiguration( + searchHit?.configuration ?? {} + ), + // A cold search would otherwise start a render per row. + warm: false + }} showHiddenTag={!insertable.isVisible} buildStatusBadge={ } diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index 582df0c49..da18b8cef 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/favorites/favorite-menu.tsx @@ -1,8 +1,8 @@ -import { Button, Group } from "@mantine/core"; +import { Button, Group, Stack, Text } from "@mantine/core"; import { modals } from "@mantine/modals"; import { IconDeviceFloppy } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; -import { ReactNode, useState } from "react"; +import { FontWeight, IconSize } from "../common/style-constants"; +import { ReactNode, useEffect, useState } from "react"; import { useRouter } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../api-utils/api"; @@ -12,7 +12,11 @@ import { ConfigurationWrapper } from "../insert/configurations"; import { type FavoritesData } from "../../shared/api-models"; import { HeartIcon } from "./favorite-button"; import { queryClient } from "../query-client"; -import { ParameterValues } from "../../shared/configuration-models"; +import { + ParameterValues, + SearchRecord +} from "../../shared/configuration-models"; +import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { favoritesQueryKey, useFavoritesQuery, @@ -31,31 +35,60 @@ interface OpenFavoriteMenuProps { export function openFavoriteMenu(props: OpenFavoriteMenuProps) { const { favoriteId, insertableName, defaultConfiguration } = props; + // Minted here so the content can update the header as the selection changes. + const modalId = crypto.randomUUID(); modals.open({ - title: ( - - - {insertableName} - - ), + modalId, + title: , size: 500, centered: true, children: ( ) }); } +/** The element's name, and what the saved configuration produces beneath it. */ +function FavoriteMenuTitle({ + name, + record +}: { + name: string; + record?: SearchRecord; +}): ReactNode { + const details = record + ? [record.partNumber, record.name].filter( + (value): value is string => !!value && value !== name + ) + : []; + return ( + + + + {name} + {details.length > 0 && ( + + {details.join(" · ")} + + )} + + + ); +} + interface FavoriteMenuContentProps { favoriteId: string; + /** The modal this renders in, so the header can track the selection. */ + modalId: string; defaultConfiguration?: ParameterValues; } function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { - const { favoriteId, defaultConfiguration } = props; + const { favoriteId, modalId, defaultConfiguration } = props; const router = useRouter(); const libraryId = useLibraryId(); @@ -66,14 +99,40 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { const [configuration, setConfiguration] = useState< ParameterValues | undefined >(defaultConfiguration); + // Reported by ConfigurationWrapper; addresses this selection's thumbnail. + // Undefined until it reports, which is what gates saving. + const [canonicalConfiguration, setCanonicalConfiguration] = useState< + ParameterValues | undefined + >(undefined); + const [record, setRecord] = useState(undefined); + + const favorite = favoritesData?.favorites[favoriteId]; + const insertable = + favorite && insertables + ? insertables[favorite.insertableId] + : undefined; + + const insertableName = insertable?.name; + useEffect(() => { + if (insertableName === undefined) { + return; + } + modals.updateModal({ + modalId, + title: + }); + }, [modalId, insertableName, record]); const setDefaultConfigurationMutation = useMutation({ mutationKey: ["set-default-configuration"], mutationFn: async () => { + // Canonical, so it addresses the thumbnail the favorites row asks + // for; Onshape applies defaults for what it omits. return apiPost("/default-configuration/" + favoriteId, { - body: { defaultConfiguration: configuration } + body: { defaultConfiguration: canonicalConfiguration } }); }, + onMutate: async () => { const queryKey = favoritesQueryKey(libraryId); await queryClient.cancelQueries({ queryKey }); @@ -81,7 +140,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { queryKey, getQueryUpdater((data: FavoritesData) => { const fav = data.favorites[favoriteId]; - if (fav) fav.defaultConfiguration = configuration; + if (fav) fav.defaultConfiguration = canonicalConfiguration; return data; }) ); @@ -98,11 +157,6 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { onSettled: refreshFavorites }); - const favorite = favoritesData?.favorites[favoriteId]; - const insertable = - favorite && insertables - ? insertables[favorite.insertableId] - : undefined; if (!insertable) { return null; } @@ -119,11 +173,16 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { <>