From cef15347bf324f5811203ece258fbadd799ded04 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:05:55 +0000 Subject: [PATCH 01/23] Store rich configuration records and auto-index by heuristic Replace the part-number map with a ConfigurationRecord per probed configuration (part number, name, description, material, vendor), so search, the UI, and future build checks can read what each configuration produces without re-querying Onshape. - Unify part-studio (/parts) and assembly (element metadata) probing into a single record parser; assemblies now read the metadata property bag in one call instead of the assembly definition. - Enumeration skips isCosmetic ("exclude from properties") parameters. - Index automatically when an insertable has a non-custom vendor and fewer than 100 non-cosmetic configurations; the former search toggle becomes a force-index override. Vendor parts over the auto line raise a new MANY_CONFIGURATIONS warning; 512 remains the hard cap. - Search derives its part-number map from records (first-wins over enumeration order keeps the latest revision). - Migration 0004 renames search_part_numbers -> force_index, drops default_part_number, and replaces configurations.part_numbers with records. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF --- drizzle/0002_configuration_records.sql | 16 + drizzle/meta/0002_snapshot.json | 524 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package-lock.json | 48 -- src/__test_utils__/insertable-fixtures.ts | 3 +- src/backend/library-data.ts | 48 +- src/backend/load/load-insertable.test.ts | 95 +++- src/backend/load/load-insertable.ts | 80 +-- src/backend/onshape-api/endpoints/metadata.ts | 52 +- src/backend/onshape-api/onshape-types.ts | 20 +- .../parse/parse-configuration-records.test.ts | 349 ++++++++++++ .../parse/parse-configuration-records.ts | 421 ++++++++++++++ src/backend/parse/parse-part-number.test.ts | 439 --------------- src/backend/parse/parse-part-number.ts | 396 ------------- src/backend/routes/build-status.ts | 4 +- src/backend/routes/insertables.test.ts | 55 +- src/backend/routes/insertables.ts | 131 +++-- src/frontend/cards/build-status.tsx | 14 +- src/frontend/cards/card-hooks.ts | 27 +- src/frontend/search/search.test.ts | 56 +- src/frontend/search/search.ts | 12 +- src/shared/api-models.ts | 2 +- src/shared/build-issues.ts | 5 + src/shared/configuration-combinations.test.ts | 12 + src/shared/configuration-combinations.ts | 25 +- src/shared/configuration-models.ts | 30 +- src/shared/schema.ts | 21 +- src/shared/search.ts | 31 +- 28 files changed, 1788 insertions(+), 1135 deletions(-) create mode 100644 drizzle/0002_configuration_records.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/backend/parse/parse-configuration-records.test.ts create mode 100644 src/backend/parse/parse-configuration-records.ts delete mode 100644 src/backend/parse/parse-part-number.test.ts delete mode 100644 src/backend/parse/parse-part-number.ts 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/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/_journal.json b/drizzle/meta/_journal.json index 9650307b5..8a5bbe09d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1786201159430, "tag": "0001_sad_arclight", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1786201159431, + "tag": "0002_configuration_records", + "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__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index 811fd460f..9fefa20ab 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -41,10 +41,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/backend/library-data.ts b/src/backend/library-data.ts index e9d2bf482..13541dcdc 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -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,10 +44,22 @@ export async function getLibraryOut( .where(eq(insertables.libraryId, libraryId)) .orderBy(asc(insertables.sortOrder)) .all(), - db.select({ id: configurations.id }).from(configurations).all() + db + .select({ + id: configurations.id, + parameters: configurations.parameters + }) + .from(configurations) + .all() ]); - const configSet = new Set(allConfigurations.map((c) => c.id)); + // A row can exist just to hold records for a non-configurable insertable, so + // "configurable" keys on having parameters, not on the row existing. + const configSet = new Set( + allConfigurations + .filter((c) => c.parameters.length > 0) + .map((c) => c.id) + ); const groupsOut: Groups = {}; for (const group of allGroups) { @@ -162,11 +174,11 @@ export async function rebuildSearchDb( db: Db, libraryId: LibraryId ): Promise { - const [libraryData, partNumberMap] = await Promise.all([ + const [libraryData, recordsMap] = await Promise.all([ getLibraryOut(db, libraryId), - getPartNumberMap(db, libraryId) + getRecordsMap(db, libraryId) ]); - const searchDb = JSON.stringify(buildSearchDb(libraryData, partNumberMap)); + const searchDb = JSON.stringify(buildSearchDb(libraryData, recordsMap)); await db .insert(libraries) .values({ id: libraryId, searchDb }) @@ -175,32 +187,28 @@ export async function rebuildSearchDb( } /** - * 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/load-insertable.test.ts b/src/backend/load/load-insertable.test.ts index 5d596b57b..6d7da8fd0 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, + forceIndex: 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, + forceIndex: 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,75 @@ describe("saveInsertable", () => { // Preserved. isVisible: true, supportsFasten: true, - searchPartNumbers: true, + forceIndex: 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` off the row without re-checking whether the + // insertable is still indexed, so a reload with neither parameters nor + // records must drop the row rather than leave stale records behind. + 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..fa9ae2039 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, @@ -40,8 +45,6 @@ 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,7 +54,8 @@ 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. */ + forceIndex: boolean; } /** @@ -75,8 +79,14 @@ export async function loadInsertable( const isOpenComposite = await computeOpenCompositeStep(ctx, target); - const partNumberResult = flags.searchPartNumbers - ? await loadPartNumbers( + const { shouldIndex, manyConfigurations } = decideIndexing( + vendors, + parameters, + flags.forceIndex + ); + + const recordsResult = shouldIndex + ? await loadConfigurationRecords( ctx, insertableId, elementPath, @@ -84,7 +94,7 @@ export async function loadInsertable( parameters, isOpenComposite ) - : NO_PART_NUMBERS; + : NO_RECORDS; const thumbnailUrls = await uploadThumbnailsStep( ctx, @@ -98,20 +108,23 @@ export async function loadInsertable( ) ); + let buildIssues = addBuildIssue( + checkInsertable({ vendors, thumbnailUrls }), + ...recordsResult.buildIssues + ); + if (manyConfigurations) { + buildIssues = addBuildIssue(buildIssues, { + type: BuildIssueType.MANY_CONFIGURATIONS + }); + } + 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,12 +144,12 @@ function readFlagsStep( const row = await getDb(ctx.env.DB) .select({ supportsFasten: insertables.supportsFasten, - searchPartNumbers: insertables.searchPartNumbers + forceIndex: insertables.forceIndex }) .from(insertables) .where(eq(insertables.id, insertableId)) .get(); - return row ?? { supportsFasten: false, searchPartNumbers: false }; + return row ?? { supportsFasten: false, forceIndex: false }; }); } @@ -158,9 +171,9 @@ function parseConfigurationStep( /** * 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. + * configuration. Runs on every load, not just under indexing, so the insert + * path always requests the right part types. Assemblies are never composites, + * so they skip the fetch. */ function computeOpenCompositeStep( ctx: LoadContext, @@ -213,7 +226,6 @@ export async function saveInsertable( vendors: parsed.vendors, thumbnailUrls: parsed.thumbnailUrls, fastenInfo: parsed.fastenInfo, - defaultPartNumber: parsed.defaultPartNumber, isOpenComposite: parsed.isOpenComposite, buildIssues: parsed.buildIssues, lastLoadedAt: Date.now() @@ -232,7 +244,7 @@ export async function saveInsertable( // one keeps the user's choices, since `set` omits these. isVisible: false, supportsFasten: false, - searchPartNumbers: false, + forceIndex: false, ...reloaded }) .onConflictDoUpdate({ @@ -240,12 +252,14 @@ export async function saveInsertable( set: reloaded }); + // Keep a configurations row whenever there's config data to store — the + // parameters a configurable insertable exposes, the records an indexed one + // produced, or both. A non-configurable, non-indexed insertable needs neither. 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/onshape-api/endpoints/metadata.ts b/src/backend/onshape-api/endpoints/metadata.ts index 959b0085f..49499b3a5 100644 --- a/src/backend/onshape-api/endpoints/metadata.ts +++ b/src/backend/onshape-api/endpoints/metadata.ts @@ -1,50 +1,18 @@ 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); + return client.get(apiPath("metadata", elementPath, toElementApiPath), { + query: encoded ? { configuration: encoded } : {} }); } diff --git a/src/backend/onshape-api/onshape-types.ts b/src/backend/onshape-api/onshape-types.ts index 2e122d3e4..bbe1ba932 100644 --- a/src/backend/onshape-api/onshape-types.ts +++ b/src/backend/onshape-api/onshape-types.ts @@ -295,9 +295,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/parse-configuration-records.test.ts b/src/backend/parse/parse-configuration-records.test.ts new file mode 100644 index 000000000..777692b33 --- /dev/null +++ b/src/backend/parse/parse-configuration-records.test.ts @@ -0,0 +1,349 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +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, Vendor } 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}`) + ) + ]; +} + +describe("decideIndexing", () => { + it.each([ + // A vendor part below the auto line indexes on its own. + { + vendors: [Vendor.AM], + configs: 99, + force: false, + index: true, + many: false + }, + // At the line it waits, and is flagged so an admin can trim or force it. + { + vendors: [Vendor.AM], + configs: 100, + force: false, + index: false, + many: true + }, + // Force overrides the count, and clears the flag. + { + vendors: [Vendor.AM], + configs: 100, + force: true, + index: true, + many: false + }, + // Past the hard cap behaves like any over-threshold vendor part. + { + vendors: [Vendor.AM], + configs: 600, + force: false, + index: false, + many: true + }, + { + vendors: [Vendor.AM], + configs: 600, + force: true, + index: true, + many: false + }, + // No vendor: never auto-eligible, never flagged, but still forceable. + { vendors: [], configs: 50, force: false, index: false, many: false }, + { vendors: [], configs: 50, force: true, index: true, many: false } + ])( + "vendors=$vendors configs=$configs force=$force", + ({ vendors, configs, force, index, many }) => { + expect( + decideIndexing(vendors, paramsWithConfigs(configs), force) + ).toEqual({ shouldIndex: index, manyConfigurations: many }); + } + ); +}); + +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"])], + false + ); + + expect(result.buildIssues).toEqual([]); + expect(result.records.map((r) => r.partNumber)).toEqual([ + "PN-default", + "PN-a1", + "PN-a2" + ]); + }); + + 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"])], + 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"])], + true + ); + + expect(result.buildIssues).toEqual([ + { type: BuildIssueType.UNSTABLE_COMPOSITE } + ]); + }); + + it("flags capped enumeration but still records the default", async () => { + const spy = mockParts(() => [ + { partId: "p", partNumber: "PN-default" } + ]); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.PART_STUDIO, + paramsWithConfigs(600), + false + ); + + expect(result.buildIssues).toEqual([ + { type: BuildIssueType.TOO_MANY_CONFIGURATIONS } + ]); + expect(result.records).toHaveLength(1); + expect(result.records[0].partNumber).toBe("PN-default"); + // Only the default probe; the combinations are never fetched. + 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, + [], + 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..9b4d8202b --- /dev/null +++ b/src/backend/parse/parse-configuration-records.ts @@ -0,0 +1,421 @@ +/** + * Configuration records: probing an insertable's configurations for the metadata + * we store (part number, name, description, material, vendor), and folding the + * probes into the `ConfigurationRecord[]` a load persists. + * + * A part studio reads the typed `/parts` response; an assembly reads the element + * metadata property bag. Either way, one probe produces one `ConfigurationRecord`. + * Every probed configuration is kept — search dedupes to a per-part-number map + * itself (`buildSearchDb`), and build checks may want the ones search drops. + * + * Like `parseFastenInfo`, the 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, type Vendor } from "../../shared/types"; +import { + ParameterValues, + ConfigurationParameter, + ConfigurationRecord +} from "../../shared/configuration-models"; +import { + addBuildIssue, + type BuildIssue, + BuildIssueType +} from "../../shared/build-issues"; +import { + AUTO_INDEX_THRESHOLD, + enumerateConfigurations +} from "../../shared/configuration-combinations"; +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 +]; + +/** Whether to index an insertable, and how to flag it if we don't. */ +export interface IndexingDecision { + /** Index when forced, or a vendor part below the auto threshold. */ + shouldIndex: boolean; + /** Vendor part over the threshold and not forced: warrants a warning. */ + manyConfigurations: boolean; +} + +/** + * Decides whether an insertable's part numbers get indexed. A vendor part with + * few enough combinations indexes on its own; anything else waits for the force + * flag, and a vendor part held back only by its count is flagged so an admin can + * trim it or force it. + */ +export function decideIndexing( + vendors: Vendor[], + parameters: ConfigurationParameter[], + forceIndex: boolean +): IndexingDecision { + const { configurations, capped } = enumerateConfigurations(parameters); + const overThreshold = + capped || configurations.length >= AUTO_INDEX_THRESHOLD; + const autoEligible = vendors.length > 0 && !overThreshold; + const shouldIndex = forceIndex || autoEligible; + return { + shouldIndex, + manyConfigurations: vendors.length > 0 && overThreshold && !shouldIndex + }; +} + +/** 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: whether the + * studio is an open composite, and which part carries the record to store. 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; +} + +/** + * Builds a record from a part studio's parts for one configuration. + * + * The part to read is the studio's single part, or its composite when it's an + * open composite; more than one candidate means that choice was arbitrary, which + * `hasMultipleParts` flags. `isOpenComposite` is the studio's expected state + * (from its default configuration): a configuration that loses its composite is + * an `UNSTABLE_COMPOSITE`, and we store no part for it rather than a stray one. + */ +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[], + isOpenComposite: boolean +): Promise { + const defaultRecord = await probeConfiguration( + client, + elementPath, + elementType, + {}, + isOpenComposite + ); + const { batches, capped } = planBatches(parameters); + + const batchRecords: ConfigurationRecord[][] = []; + for (const batch of batches) { + batchRecords.push( + await fetchBatch( + client, + elementPath, + elementType, + batch, + isOpenComposite + ) + ); + } + return toResult(defaultRecord, batchRecords, capped); +} + +/** + * Indexes an insertable's configuration records 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 list; the stored row keeps its previous records. + */ +export async function loadConfigurationRecords( + ctx: LoadContext, + insertableId: string, + elementPath: ElementPath, + elementType: ElementType, + parameters: ConfigurationParameter[], + 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, capped } = planBatches(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, capped); +} + +/** + * Splits an insertable's configuration combinations into the batches to fetch. + * + * An insertable with nothing to vary — no parameters, or only cosmetic, quantity, + * and string ones — enumerates to a single empty configuration, which the default + * probe already asked for. Dropping it leaves no batches rather than probing the + * defaults twice. + */ +function planBatches(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 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 into the stored result, in enumeration + * order (the default first). `MULTIPLE_PARTS` is raised when any configuration + * resolved to more than one part, `UNSTABLE_COMPOSITE` when an open composite + * lost its composite in some configuration. + */ +function toResult( + defaultRecord: ConfigurationRecord, + batches: ConfigurationRecord[][], + capped: boolean +): ConfigurationRecordsResult { + const records = [defaultRecord, ...batches.flat()]; + + let buildIssues: BuildIssue[] = []; + if (capped) { + buildIssues = addBuildIssue(buildIssues, { + type: BuildIssueType.TOO_MANY_CONFIGURATIONS + }); + } + 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-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/routes/build-status.ts b/src/backend/routes/build-status.ts index 3cd2a5183..aae65deb8 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, + forceIndex: insertables.forceIndex, 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, + forceIndex: ins.forceIndex, vendors: ins.vendors, configuration: config ? { diff --git a/src/backend/routes/insertables.test.ts b/src/backend/routes/insertables.test.ts index 8ee0ec69c..adad32531 100644 --- a/src/backend/routes/insertables.test.ts +++ b/src/backend/routes/insertables.test.ts @@ -1,7 +1,7 @@ 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 { configurations, insertables } from "../../shared/schema"; import { ElementType } from "../../shared/types"; import { BuildIssueType } from "../../shared/build-issues"; import { @@ -32,6 +32,15 @@ function readInsertable(insertableId: string) { .get(); } +/** Reads back an insertable's configuration row, if any. */ +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 = { @@ -121,7 +130,7 @@ describe("insertable routes", () => { ); }); - it("POST /toggle-part-number-search indexes and enables the flag", async () => { + it("POST /toggle-part-number-search indexes and forces the flag on", async () => { await seedPartStudio(db); vi.spyOn(PartsEndpoints, "getParts").mockResolvedValue([ { partId: "p", partNumber: "PN-123" } @@ -129,14 +138,27 @@ describe("insertable routes", () => { const res = await createTestApp().request( `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: true }), + jsonRequest("POST", { forceIndex: 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?.forceIndex).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 () => { @@ -147,7 +169,7 @@ describe("insertable routes", () => { const res = await createTestApp().request( `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: true }), + jsonRequest("POST", { forceIndex: true }), env ); // Surfaced to the client rather than silently enabling. @@ -156,34 +178,37 @@ 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?.forceIndex).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 () => { + // Turning force off on a part with no vendor drops it below the auto-index + // heuristic, so its records and configuration row go away. + it("POST /toggle-part-number-search clears the data when forcing off", async () => { await seedPartStudio(db); 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 }), + jsonRequest("POST", { forceIndex: true }), env ); spy.mockClear(); const res = await createTestApp().request( `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: false }), + jsonRequest("POST", { forceIndex: false }), env ); expect(res.status).toBe(200); - // Disabling needs no Onshape calls. + // A part with no vendor isn't 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?.forceIndex).toBe(false); + expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); // The route merges into the row's stored issues, so it has to clear the ones @@ -205,7 +230,7 @@ describe("insertable routes", () => { const res = await createTestApp().request( `/api/toggle-part-number-search/insertable/${TEST_PART_STUDIO_ID}`, - jsonRequest("POST", { searchPartNumbers: true }), + jsonRequest("POST", { forceIndex: true }), env ); expect(res.status).toBe(200); diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index bd0f49673..cc4bea057 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -13,13 +13,15 @@ import { type ConfigurationParameter } 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 { parseVendors } from "../parse/parse-vendors"; import { DerivedFeature } from "../onshape-api/objects/derive-feature"; import { addPartStudioFeature } from "../onshape-api/endpoints/part-studios"; import { @@ -33,7 +35,11 @@ import { 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 { + addBuildIssue, + clearBuildIssue, + BuildIssueType +} from "../../shared/build-issues"; export const insertableRoutes = getApp(); @@ -102,7 +108,7 @@ insertableRoutes.post( 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<{ forceIndex: boolean }>(); const row = await db .select({ @@ -111,6 +117,7 @@ insertableRoutes.post( versionId: insertables.versionId, elementId: insertables.elementId, elementType: insertables.elementType, + name: insertables.name, isOpenComposite: insertables.isOpenComposite, buildIssues: insertables.buildIssues }) @@ -122,81 +129,107 @@ 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 vendors = parseVendors(row.name, parameters); + const { shouldIndex, manyConfigurations } = decideIndexing( + vendors, + parameters, + body.forceIndex + ); + + // Index before committing anything: if this throws, nothing is written. + // The error reaches the client via the app's onError handler. + const indexed = shouldIndex + ? await indexRecords(await c.var.getOnshapeApi(), { + documentId: row.documentId, + versionId: row.versionId, + elementId: row.elementId, + elementType: row.elementType, + isOpenComposite: row.isOpenComposite, + parameters }) - : NO_PART_NUMBERS; + : NO_RECORDS; + + // Clear first, so an issue the reindex resolved (or that disabling makes + // moot) doesn't stick around. + let buildIssues = addBuildIssue( + clearBuildIssue(row.buildIssues, ...INDEXING_ISSUE_TYPES), + ...indexed.buildIssues + ); + if (manyConfigurations) { + buildIssues = addBuildIssue(buildIssues, { + type: BuildIssueType.MANY_CONFIGURATIONS + }); + } + + // 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 - ) - }) + .set({ forceIndex: body.forceIndex, 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 ]); await bumpLibraryVersion(db, row.libraryId); - // Part numbers live in the search index, so rebuild it now. + // Records feed 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. + * Indexes an insertable's configuration records for the toggle route. 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[]; } -): 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.isOpenComposite ); } diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index a6be44103..1a03ce46e 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/cards/build-status.tsx @@ -577,7 +577,7 @@ function InsertableAdminSection({ /> ); @@ -620,18 +620,18 @@ function FastenSwitch({ function PartNumberSwitch({ insertableId, - searchPartNumbers + forceIndex }: { insertableId: string; - searchPartNumbers: boolean; + forceIndex: boolean; }): ReactNode { const mutation = useTogglePartNumberSearchMutation(insertableId); return ( mutation.mutate(!searchPartNumbers)} + label="Force part number indexing" + description="Index configurations even below the auto threshold" + checked={forceIndex} + onToggle={() => mutation.mutate(!forceIndex)} /> ); } diff --git a/src/frontend/cards/card-hooks.ts b/src/frontend/cards/card-hooks.ts index 97f90d4f4..906289dae 100644 --- a/src/frontend/cards/card-hooks.ts +++ b/src/frontend/cards/card-hooks.ts @@ -166,40 +166,39 @@ export function useToggleInsertAndFastenMutation(insertableId: string) { }); } -/** Toggles part-number search indexing for an insertable (a slow Onshape call). */ +/** Forces part-number indexing for an insertable (a slow Onshape call). */ export function useTogglePartNumberSearchMutation(insertableId: string) { const key = useBuildStatusKey(); const refreshLibrary = useRefreshLibrary(); const toastId = `part-number-search-${insertableId}`; return useMutation({ mutationKey: ["toggle-part-number-search", insertableId], - mutationFn: (searchPartNumbers: boolean) => + mutationFn: (forceIndex: boolean) => apiPost( "/toggle-part-number-search" + toInsertablePath(insertableId), - { body: { searchPartNumbers } } + { body: { forceIndex } } ), - onMutate: (searchPartNumbers) => { + onMutate: (forceIndex) => { showLoadingToast( - searchPartNumbers - ? "Enabling part number search..." - : "Disabling part number search...", + forceIndex + ? "Forcing part number indexing..." + : "Disabling forced part number indexing...", toastId ); return patchQuery(key, (status) => { const insertable = status.insertables[insertableId]; - if (insertable) - insertable.searchPartNumbers = searchPartNumbers; + if (insertable) insertable.forceIndex = forceIndex; }); }, - onSuccess: (_result, searchPartNumbers) => + onSuccess: (_result, forceIndex) => showSuccessToast( - searchPartNumbers - ? "Enabled part number search." - : "Disabled part number search.", + forceIndex + ? "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/search/search.test.ts b/src/frontend/search/search.test.ts index 2d3abec42..376bcbf18 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/search/search.test.ts @@ -3,7 +3,27 @@ import { buildSearchDb, processTerm, tokenize } from "../../shared/search"; import { doSearch } from "./search"; import { LibraryOut } from "../../shared/api-models"; import { ElementType, ThumbnailUrls } from "../../shared/types"; -import { PartNumberMap } from "../../shared/configuration-models"; +import { + ConfigurationRecord, + ParameterValues +} from "../../shared/configuration-models"; + +/** Builds a configuration record carrying just a part number + configuration. */ +function record( + partNumber: string, + configuration: ParameterValues +): ConfigurationRecord { + return { + configuration, + partNumber, + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + }; +} describe("processTerm", () => { it("should process camelCase", () => { @@ -81,15 +101,15 @@ function library(): LibraryOut { } describe("doSearch part-number matching", () => { - const partNumberMap: Record = { - i1: { - "217-2600": { length: "short" }, - "217-2601": { length: "long" } - } + const recordsMap: Record = { + i1: [ + record("217-2600", { length: "short" }), + record("217-2601", { length: "long" }) + ] }; it("matches a part number and returns its configuration", () => { - const searchDb = buildSearchDb(library(), partNumberMap); + const searchDb = buildSearchDb(library(), recordsMap); const { hits } = doSearch( searchDb, "217-2601", @@ -103,7 +123,7 @@ describe("doSearch part-number matching", () => { }); it("does not attach a configuration for a name match", () => { - const searchDb = buildSearchDb(library(), partNumberMap); + const searchDb = buildSearchDb(library(), recordsMap); const { hits } = doSearch( searchDb, "Bracket", @@ -114,4 +134,24 @@ describe("doSearch part-number matching", () => { expect(hits).toHaveLength(1); expect(hits[0].configuration).toBeUndefined(); }); + + // Older revisions share a part number with the latest, which enumerates + // first. First-wins folding must keep that latest configuration. + it("resolves a shared part number to the latest (first-listed) configuration", () => { + const searchDb = buildSearchDb(library(), { + i1: [ + record("217-2600", { version: "latest" }), + record("217-2600", { version: "older" }) + ] + }); + const { hits } = doSearch( + searchDb, + "217-2600", + undefined, + undefined, + true + ); + expect(hits).toHaveLength(1); + expect(hits[0].configuration).toEqual({ version: "latest" }); + }); }); diff --git a/src/frontend/search/search.ts b/src/frontend/search/search.ts index b1d666638..6800222e9 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/search/search.ts @@ -1,10 +1,7 @@ import MiniSearch, { SearchResult as MiniSearchResult } from "minisearch"; import { Vendor } from "../../shared/types"; -import { SearchDocument } from "../../shared/search"; -import { - ParameterValues, - PartNumberMap -} from "../../shared/configuration-models"; +import { SearchDocument, PartNumberMap } from "../../shared/search"; +import { ParameterValues } from "../../shared/configuration-models"; /** * A user facing name to use for elements currently being filtered/searched on. @@ -159,8 +156,9 @@ function matchedConfiguration( /** * Picks the configuration whose part number best matches the query, preferring - * an exact match, then a prefix, then a substring. First-wins on ties (the map - * is ordered default-first). + * an exact match, then a prefix, then a substring. First-wins on ties; the map + * is in enumeration order, so a tie resolves to the latest option (the first the + * insertable declares) — see PartNumberMap. */ function findPartNumberConfig( query: string, diff --git a/src/shared/api-models.ts b/src/shared/api-models.ts index 6ccc74fcb..7bd280127 100644 --- a/src/shared/api-models.ts +++ b/src/shared/api-models.ts @@ -50,7 +50,7 @@ export interface InsertableBuildStatus { elementType: ElementType; isVisible: boolean; supportsFasten: boolean; - searchPartNumbers: boolean; + forceIndex: boolean; vendors: Vendor[]; configuration?: ConfigurationBuildStatus; /** When this insertable was last successfully loaded (epoch ms); null if never. */ diff --git a/src/shared/build-issues.ts b/src/shared/build-issues.ts index cd6c4af63..316244ce5 100644 --- a/src/shared/build-issues.ts +++ b/src/shared/build-issues.ts @@ -21,6 +21,7 @@ export enum BuildIssueType { NO_VENDORS = "no-vendors", NO_UNHIDDEN_INSERTABLES = "no-unhidden-insertables", TOO_MANY_CONFIGURATIONS = "too-many-configurations", + MANY_CONFIGURATIONS = "many-configurations", MULTIPLE_PARTS = "multiple-parts", UNSTABLE_COMPOSITE = "unstable-composite", INSERTABLES_FAILED = "insertables-failed", @@ -40,6 +41,7 @@ export type BuildIssue = | BuildIssueOf | BuildIssueOf | BuildIssueOf + | BuildIssueOf | BuildIssueOf | BuildIssueOf | BuildIssueOf @@ -58,6 +60,8 @@ export function getIssueDescription(issue: BuildIssue): string { return "No unhidden insertables"; case BuildIssueType.TOO_MANY_CONFIGURATIONS: return "Too many configurations to index part numbers"; + case BuildIssueType.MANY_CONFIGURATIONS: + return "Too many configurations to index automatically"; case BuildIssueType.MULTIPLE_PARTS: return "This part studio has more than one part"; case BuildIssueType.UNSTABLE_COMPOSITE: @@ -81,6 +85,7 @@ export function getIssueSeverity(issue: BuildIssue): BuildIssueSeverity { return BuildIssueSeverity.ERROR; case BuildIssueType.NO_THUMBNAIL_TAB: case BuildIssueType.TOO_MANY_CONFIGURATIONS: + case BuildIssueType.MANY_CONFIGURATIONS: return BuildIssueSeverity.WARNING; case BuildIssueType.NO_VENDORS: return BuildIssueSeverity.INFO; diff --git a/src/shared/configuration-combinations.test.ts b/src/shared/configuration-combinations.test.ts index 258a10597..8ff495293 100644 --- a/src/shared/configuration-combinations.test.ts +++ b/src/shared/configuration-combinations.test.ts @@ -73,6 +73,18 @@ describe("enumerateConfigurations", () => { expect(configurations).toEqual([{ A: "a1" }, { A: "a2" }]); }); + it("ignores cosmetic parameters", () => { + const params: ConfigurationParameter[] = [ + enumParam("A", ["a1", "a2"]), + enumParam("C", ["c1", "c2"], { isCosmetic: true }), + boolParam("B") + ]; + const { configurations } = enumerateConfigurations(params); + // C ("exclude from properties") rides its default, so only A and B vary. + expect(configurations).toHaveLength(4); + expect(configurations.every((c) => !("C" in c))).toBe(true); + }); + it("skips a parameter hidden by its visibility condition", () => { const params: ConfigurationParameter[] = [ boolParam("A"), diff --git a/src/shared/configuration-combinations.ts b/src/shared/configuration-combinations.ts index 3cce9b82b..2d5393d70 100644 --- a/src/shared/configuration-combinations.ts +++ b/src/shared/configuration-combinations.ts @@ -17,6 +17,14 @@ import { evaluateCondition, getVisibleOptions } from "./configuration-utils"; */ export const MAX_PART_NUMBER_CONFIGURATIONS = 512; +/** + * Below this many combinations, a vendor insertable is indexed automatically on + * load. At or above it, indexing waits for an admin to force it on (after + * trimming the count via "exclude from properties"); see the `MANY_CONFIGURATIONS` + * build issue. + */ +export const AUTO_INDEX_THRESHOLD = 100; + export interface EnumerateResult { /** The enumerated configurations, or empty when `capped`. */ configurations: ParameterValues[]; @@ -28,9 +36,15 @@ export interface EnumerateResult { * Returns the cartesian product of an insertable's enum and boolean parameter * values, pruning combinations hidden by visibility conditions. * - * Parameters are folded in list order, evaluating each parameter's (and each - * enum option's) visibility against the partial configuration built so far — - * matching how Onshape structures configurations top-to-bottom. + * Parameters are folded in list order, and each parameter's values are appended + * in the order Onshape declares them (first option first). That order is + * load-bearing: part-number search dedupes configurations first-wins, so a part + * number shared across an enum's options resolves to the first-listed — the + * latest revision, by Onshape convention. + * + * Each parameter's (and each enum option's) visibility is evaluated against the + * partial configuration built so far, matching how Onshape structures + * configurations top-to-bottom. */ export function enumerateConfigurations( parameters: ConfigurationParameter[], @@ -46,6 +60,11 @@ export function enumerateConfigurations( // Quantity and string parameters ride on their Onshape defaults. continue; } + if (parameter.isCosmetic) { + // "Exclude from properties": doesn't change the part's identity, so + // it rides its default rather than multiplying the configuration count. + continue; + } const next: ParameterValues[] = []; for (const configuration of configurations) { diff --git a/src/shared/configuration-models.ts b/src/shared/configuration-models.ts index fb7115fc9..19d7860a9 100644 --- a/src/shared/configuration-models.ts +++ b/src/shared/configuration-models.ts @@ -122,22 +122,32 @@ export interface QuantityParameter extends ConfigurationParameterBase { export type ParameterValues = Record; /** - * Maps a part number to the single (canonical) parameter values which produce - * it. - * - * Keyed by part number because search looks parts up by number, and because - * many parameter values can resolve to the same part number (e.g. parameters - * that don't affect the part); keying by number dedupes them inherently. + * Everything a single checked configuration resolves to. One is stored per + * configuration we probe, so search, the UI, and future build checks can read + * back what each configuration produces without re-querying Onshape. */ -export type PartNumberMap = Record; +export interface ConfigurationRecord { + /** The parameter values that produce it; empty means the element's defaults. */ + configuration: ParameterValues; + partNumber: string | null; + name: string | null; + description: string | null; + /** Material display name, e.g. "6061 Aluminum". */ + material: string | null; + vendor: string | null; + /** True when a part studio resolved to more than one part. */ + hasMultipleParts: boolean; + /** True when an open composite lost its composite in this configuration. */ + isUnstableComposite: boolean; +} /** - * An insertable's configuration: the parameters it exposes and the part numbers - * they resolve to. Mirrors the `configurations` row. + * An insertable's configuration: the parameters it exposes and a record for each + * configuration we probed. Mirrors the `configurations` row. */ export interface Configuration { parameters: ConfigurationParameter[]; - partNumbers: PartNumberMap; + records: ConfigurationRecord[]; } /** diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 3907122f3..351340fdc 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -12,7 +12,7 @@ import { ThumbnailUrls } from "./types"; import { ParameterValues, ConfigurationParameter, - PartNumberMap + ConfigurationRecord } from "./configuration-models"; import { BuildIssue } from "./build-issues"; @@ -84,14 +84,11 @@ export const insertables = sqliteTable("insertables", { supportsFasten: integer("supports_fasten", { mode: "boolean" }) .notNull() .default(false), - // Whether this insertable's part numbers are indexed for search. - searchPartNumbers: integer("search_part_numbers", { mode: "boolean" }) + // Forces part-number indexing on, overriding the vendor + configuration-count + // heuristic. User-owned; preserved across reloads. + forceIndex: integer("force_index", { mode: "boolean" }) .notNull() .default(false), - // Part number of the default configuration. The sole source of part - // numbers for non-configurable insertables (which have no - // `configurations` row); null when part-number search is off. - defaultPartNumber: text("default_part_number"), versionId: text("version_id").notNull(), sortOrder: integer("sort_order").notNull().default(0), vendors: text("vendors", { mode: "json" }) @@ -124,12 +121,12 @@ export const configurations = sqliteTable("configurations", { .$type() .notNull() .default([]), - // Deduped map of part number -> the configuration that produces it, used - // for part-number search. Empty unless part-number search is enabled. - partNumbers: text("part_numbers", { mode: "json" }) - .$type() + // One record per configuration we probed (part number + metadata). Empty + // unless the insertable is indexed. Search dedupes these to a part-number map. + records: text("records", { mode: "json" }) + .$type() .notNull() - .default({}), + .default([]), buildIssues: text("build_issues", { mode: "json" }) .$type() .notNull() diff --git a/src/shared/search.ts b/src/shared/search.ts index 8cce6c81c..cf46c3127 100644 --- a/src/shared/search.ts +++ b/src/shared/search.ts @@ -6,10 +6,18 @@ import MiniSearch, { Options } from "minisearch"; import { LibraryOut } from "./api-models"; import { Vendor } from "./types"; -import { PartNumberMap } from "./configuration-models"; +import { ConfigurationRecord, ParameterValues } from "./configuration-models"; const deliminator = "^"; +/** + * Part number -> the (canonical) configuration that produces it. Derived from an + * insertable's `ConfigurationRecord[]` at index-build time: many configurations + * can share a part number, so this collapses them first-wins — and records come + * in enumeration order (latest option first), so search launches the latest. + */ +export type PartNumberMap = Record; + /** * Adds spaces to a given string so prefix matching is more efficient. */ @@ -74,9 +82,24 @@ export const SEARCH_OPTIONS: Options = { processTerm }; +/** + * Folds an insertable's records into the search map: part number -> the first + * configuration that produces it. First-wins over enumeration order keeps the + * latest option (see {@link PartNumberMap}). + */ +function toPartNumberMap(records: ConfigurationRecord[]): PartNumberMap { + const partNumberConfigs: PartNumberMap = {}; + for (const record of records) { + if (record.partNumber) { + partNumberConfigs[record.partNumber] ??= record.configuration; + } + } + return partNumberConfigs; +} + export function buildSearchDb( libraryData: LibraryOut, - partNumberMap: Record = {} + recordsMap: Record = {} ): MiniSearch { const searchDb = new MiniSearch(SEARCH_OPTIONS); @@ -86,7 +109,9 @@ export function buildSearchDb( .filter((element) => !!element) .map((element) => { const parentGroup = libraryData.groups[element.groupId]; - const partNumberConfigs = partNumberMap[element.id] ?? {}; + const partNumberConfigs = toPartNumberMap( + recordsMap[element.id] ?? [] + ); return { id: element.id, groupId: element.groupId, From 8890dab6b4ee3de8c458c93d5349d080acd3f7ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:06:01 +0000 Subject: [PATCH 02/23] Show configuration parameters in the build-status card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Configuration section to the editor build-status hover card that lists each configuration parameter with its type and whether it is excluded from properties (isCosmetic) — the flag that keeps a parameter from multiplying the indexed configuration count, so editors can see at a glance which parameters to mark cosmetic to bring a part under the auto-index line. Also fix the "Configurable" state row to key on the parameter count rather than on a configuration row existing: an indexed non-configurable insertable now carries a row that holds only records, and would otherwise report as configurable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF --- src/frontend/cards/build-status.tsx | 82 ++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index 1a03ce46e..53ca8c11e 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,6 +42,10 @@ import { InsertableBuildStatus } from "../../shared/api-models"; import { getVendorName, Vendor } from "../../shared/types"; +import { + ConfigurationParameter, + ParameterType +} from "../../shared/configuration-models"; import { FontWeight, IconColor, IconSize } from "../common/style-constants"; import { RequireAccessLevel } from "../api-utils/access-level"; import { useBuildStatusQuery, useJobStatusQuery } from "../queries"; @@ -492,6 +497,9 @@ export function InsertableStatusBadge({ status={insertable} /> + } /> @@ -675,13 +683,85 @@ function InsertableParsedSection({ /> 0 + }} /> ); } +/** + * Lists the insertable's configuration parameters: each parameter's name, its + * type, and whether it's excluded from properties (which keeps it from + * multiplying the indexed configuration count). Renders nothing when the + * insertable has no parameters. + */ +function ConfigurationSection({ + parameters +}: { + parameters?: ConfigurationParameter[]; +}): ReactNode { + if (!parameters || parameters.length === 0) return null; + return ( + <> + + + Configuration + + + {parameters.map((parameter) => ( + + {parameter.name} + + {parameter.isCosmetic && ( + + Excluded + + )} + + {getParameterTypeLabel(parameter.type)} + + + + ))} + + + + + ); +} + +/** 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, From f4d22a9859394061019b78fea685884e971f9cb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:12:52 +0000 Subject: [PATCH 03/23] Search by part number and part name, with live per-config display Extend search beyond part numbers to the configuration records' part names, surface the matched part number + name in results and (live) in the insert menu, normalize numbers/fractions, and move the search index out of D1 into R2. - Index part names alongside part numbers. `SearchDocument` now carries a `partNames` field and a stored `records` list (`toSearchRecords` dedupes to distinct (part number, name) pairs). A hit picks its single best-matching record (`findBestRecord`, exact -> prefix -> substring) by part number or name, falling back to the default record for a plain title match, and annotates the hit with that record's number/name/config. - Canonicalize numbers in `tokenize`: fractions, mixed numbers, and decimals collapse to a 2-dp decimal at both index and query time, so `.5`, `1/2`, and `0.50` all match. Thread specs (`10-32`) and part numbers (`217-2600`) are left untouched. `normalizeForMatch` keeps the record lookup in step with what the index matched. - Show the part number + name on result rows (a dimmed secondary line) and live in the insert menu, where `findRecordForConfiguration` maps the selected parameters to their record. `/configuration/:id` now returns the insertable's records for this. - Move the serialized MiniSearch index from the D1 `libraries.search_db` column to R2 (`SEARCH_INDEX` bucket), stored gzipped and streamed with `Content-Encoding: gzip`; removes the D1 per-value size ceiling as the index grows with record count. Migration 0003 drops the column. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- drizzle/0003_drop_search_db.sql | 1 + drizzle/meta/0003_snapshot.json | 482 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/__test_utils__/seed.ts | 5 +- src/backend/app.ts | 2 + src/backend/library-data.ts | 33 +- src/backend/load/workflows.ts | 2 +- src/backend/routes/configurations.test.ts | 2 +- src/backend/routes/configurations.ts | 9 +- src/backend/routes/groups.ts | 2 +- src/backend/routes/insertables.ts | 2 +- src/backend/routes/library.test.ts | 35 +- src/backend/routes/library.ts | 31 +- src/frontend/api-utils/api.ts | 22 + src/frontend/cards/card-components.tsx | 24 +- src/frontend/insert/configurations.tsx | 52 ++- src/frontend/queries.ts | 20 +- src/frontend/search/search.test.ts | 73 +++- src/frontend/search/search.ts | 80 ++-- src/shared/configuration-models.ts | 16 + src/shared/configuration-utils.test.ts | 50 +++ src/shared/configuration-utils.ts | 28 ++ src/shared/schema.ts | 6 +- src/shared/search.ts | 128 ++++-- worker-configuration.d.ts | 3 + wrangler.jsonc | 12 + 26 files changed, 996 insertions(+), 131 deletions(-) create mode 100644 drizzle/0003_drop_search_db.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/shared/configuration-utils.test.ts 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/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/_journal.json b/drizzle/meta/_journal.json index 8a5bbe09d..f83c2ff41 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1786201159431, "tag": "0002_configuration_records", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1786511719906, + "tag": "0003_drop_search_db", + "breakpoints": true } ] } diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index faaf34d84..bb2e210e9 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -66,10 +66,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/backend/app.ts b/src/backend/app.ts index e15d6a675..0f6224d3a 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -11,6 +11,8 @@ export interface AppBindings { KV: KVNamespace; ASSETS: Fetcher; THUMBNAILS: R2Bucket; + /** Serialized MiniSearch index per library, keyed by {@link searchIndexKey}. */ + SEARCH_INDEX: R2Bucket; LOAD_LIBRARY_WORKFLOW: Workflow; ADD_GROUP_WORKFLOW: Workflow; ADMIN_TEAM: string; diff --git a/src/backend/library-data.ts b/src/backend/library-data.ts index 13541dcdc..322403244 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -166,23 +166,46 @@ export async function bumpLibraryVersion( }); } +/** The R2 object key holding a library's serialized MiniSearch index. */ +export function searchIndexKey(libraryId: LibraryId): string { + return `search-index/${libraryId}.json`; +} + +/** Gzips a string to bytes (R2 stores it pre-compressed; the browser inflates). */ +async function gzip(text: string): Promise { + const compressed = new Response(text).body!.pipeThrough( + new CompressionStream("gzip") + ); + return new Response(compressed).arrayBuffer(); +} + /** * Rebuilds the serialized MiniSearch index for a library from its current - * groups/insertables and stores it on the `libraries` row in D1. + * groups/insertables and stores it, gzipped, in R2. Bump `cacheVersion` + * alongside so clients refetch under a new URL. */ export async function rebuildSearchDb( + bucket: R2Bucket, db: Db, libraryId: LibraryId ): Promise { + const start = Date.now(); const [libraryData, recordsMap] = await Promise.all([ getLibraryOut(db, libraryId), getRecordsMap(db, libraryId) ]); const searchDb = JSON.stringify(buildSearchDb(libraryData, recordsMap)); - await db - .insert(libraries) - .values({ id: libraryId, searchDb }) - .onConflictDoUpdate({ target: libraries.id, set: { searchDb } }); + const compressed = await gzip(searchDb); + await bucket.put(searchIndexKey(libraryId), compressed, { + httpMetadata: { + contentType: "application/json", + contentEncoding: "gzip" + } + }); + console.log( + `Rebuilt search index for ${libraryId}: ${searchDb.length} B json, ` + + `${compressed.byteLength} B gzip, ${Date.now() - start} ms` + ); return searchDb; } diff --git a/src/backend/load/workflows.ts b/src/backend/load/workflows.ts index 48b21ac52..2e79bae06 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/load/workflows.ts @@ -228,6 +228,6 @@ async function finalizeLibrary( libraryId: LibraryId ): Promise { const db = getDb(env.DB); - await rebuildSearchDb(db, libraryId); + await rebuildSearchDb(env.SEARCH_INDEX, db, libraryId); await bumpLibraryVersion(db, libraryId); } 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/groups.ts b/src/backend/routes/groups.ts index 7d7d1d962..c8f957d42 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/routes/groups.ts @@ -245,7 +245,7 @@ groupRoutes.delete( .where(and(eq(group.id, groupId), eq(group.libraryId, libraryId))); await bumpLibraryVersion(db, libraryId); - await rebuildSearchDb(db, libraryId); + await rebuildSearchDb(c.env.SEARCH_INDEX, db, libraryId); return c.json({ success: true }); } ); diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index cc4bea057..71f0c01a2 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -198,7 +198,7 @@ insertableRoutes.post( await bumpLibraryVersion(db, row.libraryId); // Records feed the search index, so rebuild it now. - await rebuildSearchDb(db, row.libraryId); + await rebuildSearchDb(c.env.SEARCH_INDEX, db, row.libraryId); return c.json({ success: true }); } ); diff --git a/src/backend/routes/library.test.ts b/src/backend/routes/library.test.ts index b928b473d..ebfbdfcd5 100644 --- a/src/backend/routes/library.test.ts +++ b/src/backend/routes/library.test.ts @@ -12,14 +12,24 @@ 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"; const db = getDb(env.DB); +/** Inflates a gzip stream to text (the browser does this transparently). */ +async function inflate(body: ReadableStream): Promise { + return new Response( + body.pipeThrough(new DecompressionStream("gzip")) + ).text(); +} + describe("library routes", () => { beforeEach(async () => { await resetDb(db); + // The R2 bucket persists across tests in this file; clear the index. + await env.SEARCH_INDEX.delete(searchIndexKey(TEST_LIBRARY_ID)); }); it("GET /library-data returns groups and insertables", async () => { @@ -43,8 +53,10 @@ describe("library routes", () => { ); }); - it("GET /search-db returns a serialized search index", async () => { - await seedLibrary(db); + it("GET /search-db streams the library's gzipped index from R2", async () => { + await seedTestData(db); + await seedConfiguration(db, TEST_PART_STUDIO_ID); + await rebuildSearchDb(env.SEARCH_INDEX, db, TEST_LIBRARY_ID); const app = createTestApp(); const res = await app.request( @@ -53,10 +65,23 @@ describe("library routes", () => { env ); expect(res.status).toBe(200); + expect(res.headers.get("Content-Encoding")).toBe("gzip"); - const body: { searchDb: string } = await res.json(); - expect(typeof body.searchDb).toBe("string"); - expect(body.searchDb.length).toBeGreaterThan(0); + // The body is the serialized MiniSearch index (a JSON object). + const parsed = JSON.parse(await inflate(res.body!)); + expect(parsed.documentCount).toBeGreaterThan(0); + }); + + it("GET /search-db 404s when the library has no index", async () => { + await seedLibrary(db); + const app = createTestApp(); + + const res = await app.request( + `/api/search-db/library/${TEST_LIBRARY_ID}`, + jsonRequest("GET"), + env + ); + expect(res.status).toBe(404); }); it("GET /library-version returns the library's version", async () => { diff --git a/src/backend/routes/library.ts b/src/backend/routes/library.ts index 7190462b1..6683f5c0c 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,25 @@ libraryRoutes.get( } ); -/** GET /api/search-db/library/:libraryId?v=:cacheVersion */ +/** + * GET /api/search-db/library/:libraryId?v=:cacheVersion + * + * Streams the library's serialized MiniSearch index from R2. + */ 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.SEARCH_INDEX.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/frontend/api-utils/api.ts b/src/frontend/api-utils/api.ts index b34cd1e9e..3a018d09a 100644 --- a/src/frontend/api-utils/api.ts +++ b/src/frontend/api-utils/api.ts @@ -49,6 +49,28 @@ export async function apiGet( }).then(handleResponse); } +/** + * Gets a plain-text body from a backend /api route (e.g. the search index blob, + * served pre-gzipped and decompressed transparently by the browser). Returns + * null on 404 so a missing resource is a graceful empty state, not an error. + */ +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 === 404) { + return null; + } + if (!response.ok) { + throw new Error("Network response failed."); + } + return response.text(); +} + export async function apiGetRawImage( url: string, signal?: AbortSignal diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index 05880902e..edb7ad274 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, @@ -146,12 +146,28 @@ export function CardTitle(props: CardTitleProps) { cardTitle = title; } + // The part number + name of the hit's best-matching configuration, dropping + // a name that just repeats the title. + const details = searchHit + ? [searchHit.partNumber, searchHit.partName].filter( + (value): value is string => + !!value && value.toLowerCase() !== title.toLowerCase() + ) + : []; + return ( - - {cardTitle} - + + + {cardTitle} + + {details.length > 0 && ( + + {details.join(" · ")} + + )} + {isHidden && ( + <> + + + + ); +} + +/** + * The part number + name the currently-selected configuration produces. + * Recomputes on every configuration change, so it updates live as the user + * changes selections. Renders nothing when the selection has no indexed record. + */ +function RecordSummary({ + records, + configuration +}: { + records: SearchRecord[]; + configuration: ParameterValues; +}): ReactNode { + const record = findRecordForConfiguration(configuration, records); + const details = record + ? [record.partNumber, record.name].filter( + (value): value is string => !!value + ) + : []; + if (details.length === 0) { + return null; + } + return ( + + + {details.join(" · ")} + + ); } diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts index 520e337d0..cf5d56f84 100644 --- a/src/frontend/queries.ts +++ b/src/frontend/queries.ts @@ -6,7 +6,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; -import { apiGet } from "./api-utils/api"; +import { apiGet, apiGetText } from "./api-utils/api"; import { type FavoritesData, type LibraryBuildStatus, @@ -117,13 +117,17 @@ export function searchDbQueryKey(libraryId: LibraryId, cacheVersion: number) { export function getSearchDbQuery(libraryId: LibraryId, cacheVersion: number) { return queryOptions({ queryKey: searchDbQueryKey(libraryId, cacheVersion), - queryFn: async () => - apiGet("/search-db/library/" + libraryId, { - cacheId: cacheVersion - }).then((result: { searchDb: string | null }) => { - if (!result.searchDb) return null; - return MiniSearch.loadJSON(result.searchDb, SEARCH_OPTIONS); - }), + queryFn: async () => { + const searchDb = await apiGetText( + "/search-db/library/" + libraryId, + { + cacheId: cacheVersion + } + ); + return searchDb + ? MiniSearch.loadJSON(searchDb, SEARCH_OPTIONS) + : null; + }, staleTime: Infinity, gcTime: Infinity }); diff --git a/src/frontend/search/search.test.ts b/src/frontend/search/search.test.ts index 376bcbf18..58527ca85 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/search/search.test.ts @@ -8,15 +8,16 @@ import { ParameterValues } from "../../shared/configuration-models"; -/** Builds a configuration record carrying just a part number + configuration. */ +/** Builds a configuration record carrying a part number, name, + configuration. */ function record( - partNumber: string, - configuration: ParameterValues + partNumber: string | null, + configuration: ParameterValues, + name: string | null = null ): ConfigurationRecord { return { configuration, partNumber, - name: null, + name, description: null, material: null, vendor: null, @@ -58,6 +59,25 @@ describe("tokenize", () => { "Contact" ]); }); + + it("canonicalizes fractions and decimals to a 2-dp decimal", () => { + expect(tokenize("1/2")).toEqual(["0.5"]); + expect(tokenize(".5")).toEqual(["0.5"]); + expect(tokenize("0.50")).toEqual(["0.5"]); + expect(tokenize("3/4")).toEqual(["0.75"]); + expect(tokenize("1-1/2")).toEqual(["1.5"]); + expect(tokenize("1.5")).toEqual(["1.5"]); + expect(tokenize("1/3")).toEqual(["0.33"]); + }); + + it("leaves thread specs and part numbers untouched", () => { + expect(tokenize("10-32")).toEqual(["10", "32"]); + expect(tokenize("217-2600")).toEqual(["217", "2600"]); + }); + + it("canonicalizes a fraction inside a name", () => { + expect(tokenize("1/2 Bearing")).toEqual(["0.5", "Bearing"]); + }); }); const thumbnailUrls = {} as ThumbnailUrls; @@ -122,7 +142,7 @@ describe("doSearch part-number matching", () => { expect(hits[0].configuration).toEqual({ length: "long" }); }); - it("does not attach a configuration for a name match", () => { + it("attaches the default (first) record for a title match", () => { const searchDb = buildSearchDb(library(), recordsMap); const { hits } = doSearch( searchDb, @@ -132,7 +152,9 @@ describe("doSearch part-number matching", () => { true ); expect(hits).toHaveLength(1); - expect(hits[0].configuration).toBeUndefined(); + // The insertable's own name matched, so the row shows its default config. + expect(hits[0].configuration).toEqual({ length: "short" }); + expect(hits[0].partNumber).toBe("217-2600"); }); // Older revisions share a part number with the latest, which enumerates @@ -155,3 +177,42 @@ describe("doSearch part-number matching", () => { expect(hits[0].configuration).toEqual({ version: "latest" }); }); }); + +describe("doSearch name matching", () => { + const recordsMap: Record = { + i1: [ + record("217-2600", { length: "short" }, "1/2 Bearing"), + record("217-2601", { length: "long" }, "3/4 Bearing") + ] + }; + + it("matches a part name, returning its number, name, and configuration", () => { + const searchDb = buildSearchDb(library(), recordsMap); + const { hits } = doSearch( + searchDb, + "3/4 bearing", + undefined, + undefined, + true + ); + expect(hits).toHaveLength(1); + expect(hits[0].partName).toBe("3/4 Bearing"); + expect(hits[0].partNumber).toBe("217-2601"); + expect(hits[0].configuration).toEqual({ length: "long" }); + }); + + it("finds a fractional name by its decimal forms (.5, 0.5, 1/2)", () => { + const searchDb = buildSearchDb(library(), recordsMap); + for (const query of [".5", "0.5", "1/2"]) { + const { hits } = doSearch( + searchDb, + query, + undefined, + undefined, + true + ); + const hit = hits.find((h) => h.id === "i1"); + expect(hit?.partName).toBe("1/2 Bearing"); + } + }); +}); diff --git a/src/frontend/search/search.ts b/src/frontend/search/search.ts index 6800222e9..f800b505c 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/search/search.ts @@ -1,7 +1,10 @@ import MiniSearch, { SearchResult as MiniSearchResult } from "minisearch"; import { Vendor } from "../../shared/types"; -import { SearchDocument, PartNumberMap } from "../../shared/search"; -import { ParameterValues } from "../../shared/configuration-models"; +import { SearchDocument, normalizeForMatch } from "../../shared/search"; +import { + ParameterValues, + SearchRecord +} from "../../shared/configuration-models"; /** * A user facing name to use for elements currently being filtered/searched on. @@ -31,10 +34,12 @@ export interface SearchHit { id: string; positions: Position[]; /** - * When the hit matched on a part number, the configuration that produces - * that part number, used to pre-fill the insert menu. + * The best-matching configuration for this hit, used to pre-fill the insert + * menu — its part number, name, and the parameter values that produce it. */ configuration?: ParameterValues; + partNumber?: string; + partName?: string; } export interface FilterResult { @@ -121,14 +126,13 @@ export function doSearch( document ); + const record = matchedRecord(miniSearchResult, document, query); return { id: document.id, positions, - configuration: matchedConfiguration( - miniSearchResult, - document, - query - ) + configuration: record?.configuration, + partNumber: record?.partNumber ?? undefined, + partName: record?.name ?? undefined }; }) .slice(0, 50); // Limit to 50 results @@ -137,45 +141,53 @@ export function doSearch( } /** - * If the result matched on the part-number field, returns the configuration - * that produces the best-matching part number so the insert menu can launch it. + * Picks the single best-matching record for a hit: the one whose part number + * matched (when the hit matched the part-number field), else whose name matched + * (part-name field), else the default record (`records[0]`) for a pure title + * match — so every result row can show a part number + name. */ -function matchedConfiguration( +function matchedRecord( result: MiniSearchResult, document: SearchDocument, query: string -): ParameterValues | undefined { - const matchedPartNumber = Object.values(result.match).some((fields) => - fields.includes("partNumbers") - ); - if (!matchedPartNumber) { - return undefined; +): SearchRecord | undefined { + const matchedFields = Object.values(result.match).flat(); + if (matchedFields.includes("partNumbers")) { + return findBestRecord(query, document.records, (r) => r.partNumber); } - return findPartNumberConfig(query, document.partNumberConfigs); + if (matchedFields.includes("partNames")) { + return findBestRecord(query, document.records, (r) => r.name); + } + // Pure title (or group) match: show the default configuration's record. + return document.records[0]; } /** - * Picks the configuration whose part number best matches the query, preferring - * an exact match, then a prefix, then a substring. First-wins on ties; the map - * is in enumeration order, so a tie resolves to the latest option (the first the - * insertable declares) — see PartNumberMap. + * Picks the record whose selected value (part number or name) best matches the + * query, preferring an exact match, then a prefix, then a substring. First-wins + * on ties; records are in enumeration order, so a tie resolves to the latest + * option (the first the insertable declares). */ -function findPartNumberConfig( +function findBestRecord( query: string, - partNumberConfigs: PartNumberMap -): ParameterValues | undefined { - const keys = Object.keys(partNumberConfigs); - const normalizedQuery = query.trim().toLowerCase(); - if (keys.length === 0 || normalizedQuery === "") { + records: SearchRecord[], + selector: (record: SearchRecord) => string | null +): SearchRecord | undefined { + const normalizedQuery = normalizeForMatch(query.trim()); + if (records.length === 0 || normalizedQuery === "") { return undefined; } - const match = - keys.find((key) => key.toLowerCase() === normalizedQuery) ?? - keys.find((key) => key.toLowerCase().startsWith(normalizedQuery)) ?? - keys.find((key) => key.toLowerCase().includes(normalizedQuery)); + // Canonicalize the same way the index did, so a fraction/decimal query lines + // up with the stored original (e.g. `.5` matches a `"1/2 Bearing"` name). + const value = (record: SearchRecord) => + normalizeForMatch(selector(record) ?? ""); - return match ? partNumberConfigs[match] : undefined; + return ( + records.find((r) => value(r) === normalizedQuery) ?? + records.find((r) => value(r).startsWith(normalizedQuery)) ?? + records.find((r) => value(r).includes(normalizedQuery)) + ); } /** diff --git a/src/shared/configuration-models.ts b/src/shared/configuration-models.ts index 19d7860a9..3ddac9952 100644 --- a/src/shared/configuration-models.ts +++ b/src/shared/configuration-models.ts @@ -71,6 +71,22 @@ interface AlwaysShownVisibilityCondition { export interface ConfigurationResult { // defaultConfiguration: string; parameters: ConfigurationParameter[]; + /** The insertable's search records, so the insert menu can show the part + * number + name of the selected configuration. Empty when not indexed. */ + records: SearchRecord[]; +} + +/** + * The slice of a {@link ConfigurationRecord} search needs: what a configuration + * produces (part number + name) and the configuration that produces it. Kept + * MiniSearch-free so both the search index and the `/configuration` route can + * share it. + */ +export interface SearchRecord { + partNumber: string | null; + name: string | null; + /** The (enumerated) parameter values that produce it; empty for the default. */ + configuration: ParameterValues; } export type ConfigurationParameter = diff --git a/src/shared/configuration-utils.test.ts b/src/shared/configuration-utils.test.ts new file mode 100644 index 000000000..009987ed6 --- /dev/null +++ b/src/shared/configuration-utils.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { findRecordForConfiguration } from "./configuration-utils"; +import { SearchRecord } from "./configuration-models"; + +function rec( + configuration: Record, + partNumber = "PN" +): SearchRecord { + return { partNumber, name: null, configuration }; +} + +describe("findRecordForConfiguration", () => { + it("returns the record whose enumerated values match the selection", () => { + const records = [ + rec({ size: "s" }, "PN-S"), + rec({ size: "l" }, "PN-L") + ]; + // The selection also carries a non-enumerated (quantity) param, ignored. + expect( + findRecordForConfiguration({ size: "l", qty: "3" }, records) + ?.partNumber + ).toBe("PN-L"); + }); + + it("prefers the most specific match when several apply", () => { + const records = [rec({}, "default"), rec({ size: "l" }, "PN-L")]; + expect( + findRecordForConfiguration({ size: "l" }, records)?.partNumber + ).toBe("PN-L"); + }); + + it("falls back to a shorter key-set when a parameter is hidden", () => { + const records = [ + rec({ mode: "a", detail: "x" }, "A-X"), + // `detail` is hidden when mode=b, so this record omits it. + rec({ mode: "b" }, "B") + ]; + expect( + findRecordForConfiguration({ mode: "b", detail: "x" }, records) + ?.partNumber + ).toBe("B"); + }); + + it("returns undefined when nothing matches", () => { + const records = [rec({ size: "s" }, "PN-S")]; + expect( + findRecordForConfiguration({ size: "l" }, records) + ).toBeUndefined(); + }); +}); diff --git a/src/shared/configuration-utils.ts b/src/shared/configuration-utils.ts index 5b5a018d2..4aba1c70a 100644 --- a/src/shared/configuration-utils.ts +++ b/src/shared/configuration-utils.ts @@ -5,11 +5,39 @@ import { OptionVisibilityType, ConfigurationParameter, ParameterType, + SearchRecord, VisibilityCondition, VisibilityType } from "./configuration-models"; import { LogicalOp } from "./configuration-enums"; +/** + * Finds the record a (full) configuration selection produces. Records are keyed + * only by the enumerated parameters (enum/boolean, non-cosmetic — see + * `enumerateConfigurations`), so a record matches when every value in its + * `configuration` equals the user's selection for that key. When several match + * (a parameter hidden in some configs yields shorter key-sets), the most + * specific — the record with the most keys — wins. + */ +export function findRecordForConfiguration( + configuration: ParameterValues, + records: SearchRecord[] +): SearchRecord | undefined { + let best: SearchRecord | undefined; + let bestKeys = -1; + for (const record of records) { + const keys = Object.keys(record.configuration); + const matches = keys.every( + (key) => configuration[key] === record.configuration[key] + ); + if (matches && keys.length > bestKeys) { + best = record; + bestKeys = keys.length; + } + } + return best; +} + export function evaluateCondition( condition: VisibilityCondition | undefined, configuration: Record, diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 351340fdc..7566e3808 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -18,9 +18,9 @@ import { BuildIssue } from "./build-issues"; export const libraries = sqliteTable("libraries", { id: text("id").primaryKey(), - cacheVersion: integer("cache_version").notNull().default(0), - // Serialized MiniSearch index, rebuilt by the backend when a document loads. - searchDb: text("search_db") + cacheVersion: integer("cache_version").notNull().default(0) + // The serialized MiniSearch index now lives in R2 (see rebuildSearchDb), + // keyed by library id, rather than in a D1 column. }); export const group = sqliteTable( diff --git a/src/shared/search.ts b/src/shared/search.ts index cf46c3127..c519562bf 100644 --- a/src/shared/search.ts +++ b/src/shared/search.ts @@ -6,18 +6,10 @@ import MiniSearch, { Options } from "minisearch"; import { LibraryOut } from "./api-models"; import { Vendor } from "./types"; -import { ConfigurationRecord, ParameterValues } from "./configuration-models"; +import { ConfigurationRecord, SearchRecord } from "./configuration-models"; const deliminator = "^"; -/** - * Part number -> the (canonical) configuration that produces it. Derived from an - * insertable's `ConfigurationRecord[]` at index-build time: many configurations - * can share a part number, so this collapses them first-wins — and records come - * in enumeration order (latest option first), so search launches the latest. - */ -export type PartNumberMap = Record; - /** * Adds spaces to a given string so prefix matching is more efficient. */ @@ -41,10 +33,57 @@ export function processTerm(term: string): string[] { return Array.from(new Set(terms)); } +// A mixed number, simple fraction, or decimal (incl. leading-dot). Alternatives +// are ordered longest-first so `1-1/2` is consumed whole, not as `1` + `1/2`. +const NUMERIC_PATTERN = /(\d+)-(\d+)\/(\d+)|(\d+)\/(\d+)|\d*\.\d+|\d+\.\d*/g; + +/** + * Rewrites numbers/fractions to a single 2-dp decimal so `.5`, `1/2`, and `0.50` + * all become `"0.5"`. Applied at both index and query time (via `tokenize`), so + * the canonical form matches on both sides without keeping the raw fragments — + * which would only add noise (`2` weakly matching `1/2`) and index size. Thread + * specs and part numbers (`10-32`, `217-2600`) contain no fraction/decimal and + * are left untouched. + */ +function canonicalizeNumbers(text: string): string { + return text.replace( + NUMERIC_PATTERN, + (match, mixedWhole, mixedNum, mixedDen, fracNum, fracDen) => { + let value: number; + if (mixedWhole !== undefined) { + value = + Number(mixedWhole) + Number(mixedNum) / Number(mixedDen); + } else if (fracNum !== undefined) { + value = Number(fracNum) / Number(fracDen); + } else { + value = Number(match); + } + if (!Number.isFinite(value)) { + return match; + } + return String(Math.round(value * 100) / 100); + } + ); +} + +/** + * Canonicalizes a string for direct (non-tokenized) comparison — same number + * canonicalization as the index, lowercased — so exact/prefix/substring checks + * against a stored part number or name agree with what the index matched (e.g. a + * `.5` query lines up with a stored `"1/2 Bearing"`). + */ +export function normalizeForMatch(text: string): string { + return canonicalizeNumbers(text).toLowerCase(); +} + export function tokenize(text: string): string[] { - // Don't lowercase so we can use casing for term splitting + // Canonicalize fractions/decimals before splitting (they span `/` and `-`, + // which the split would otherwise break apart). Don't lowercase — casing is + // needed by processTerm's camelCase splitting. // Remove -, (, ), ", ', #, &, /, and whitespace - return text.split(/[-()"'#&\s^/]+/).filter(Boolean); + return canonicalizeNumbers(text) + .split(/[-()"'#&\s^/]+/) + .filter(Boolean); } export interface SearchDocument { @@ -54,16 +93,20 @@ export interface SearchDocument { vendors: Vendor[]; name: string; groupName: string; - // Space-joined part numbers (the searchable field); empty when the + // Space-joined, deduped part numbers (a searchable field); empty when the // insertable has no indexed part numbers. partNumbers: string; - // Part number -> the configuration that produces it, used to launch the - // matched configuration. Stored, not indexed. - partNumberConfigs: PartNumberMap; + // Space-joined, deduped configuration (part) names (a searchable field); + // empty when the insertable has no indexed records. + partNames: string; + // One entry per distinct (part number, name) the insertable produces, in + // enumeration order. Stored, not indexed — used to pick the best-matching + // configuration for a hit and to launch it in the insert menu. + records: SearchRecord[]; } export const SEARCH_OPTIONS: Options = { - fields: ["name", "groupName", "partNumbers"], + fields: ["name", "groupName", "partNumbers", "partNames"], storeFields: [ "id", "groupId", @@ -71,10 +114,12 @@ export const SEARCH_OPTIONS: Options = { "vendors", "name", "groupName", - "partNumberConfigs" + "records" ], searchOptions: { - boost: { groupName: 0.5 }, + // The insertable's own title leads; part names and the group name are + // weaker signals, so a title match outranks them. + boost: { partNames: 0.7, groupName: 0.5 }, prefix: true }, // Custom tokenizer to split on special characters @@ -82,19 +127,41 @@ export const SEARCH_OPTIONS: Options = { processTerm }; +/** Joins the distinct non-null values with spaces (a searchable field's form). */ +function uniqueJoin(values: (string | null)[]): string { + return Array.from( + new Set(values.filter((value): value is string => !!value)) + ).join(" "); +} + /** - * Folds an insertable's records into the search map: part number -> the first - * configuration that produces it. First-wins over enumeration order keeps the - * latest option (see {@link PartNumberMap}). + * Distills an insertable's records to the slice search needs, in enumeration + * order (default-first, latest-option-first), keeping the first occurrence of + * each distinct (part number, name) pair and dropping records with neither. + * First-wins keeps the latest revision, as {@link ConfigurationRecord} ordering + * intends. */ -function toPartNumberMap(records: ConfigurationRecord[]): PartNumberMap { - const partNumberConfigs: PartNumberMap = {}; +export function toSearchRecords( + records: ConfigurationRecord[] +): SearchRecord[] { + const seen = new Set(); + const searchRecords: SearchRecord[] = []; for (const record of records) { - if (record.partNumber) { - partNumberConfigs[record.partNumber] ??= record.configuration; + if (!record.partNumber && !record.name) { + continue; } + const key = JSON.stringify([record.partNumber, record.name]); + if (seen.has(key)) { + continue; + } + seen.add(key); + searchRecords.push({ + partNumber: record.partNumber, + name: record.name, + configuration: record.configuration + }); } - return partNumberConfigs; + return searchRecords; } export function buildSearchDb( @@ -109,9 +176,7 @@ export function buildSearchDb( .filter((element) => !!element) .map((element) => { const parentGroup = libraryData.groups[element.groupId]; - const partNumberConfigs = toPartNumberMap( - recordsMap[element.id] ?? [] - ); + const records = toSearchRecords(recordsMap[element.id] ?? []); return { id: element.id, groupId: element.groupId, @@ -119,8 +184,9 @@ export function buildSearchDb( vendors: element.vendors, name: element.name, groupName: parentGroup.name, - partNumbers: Object.keys(partNumberConfigs).join(" "), - partNumberConfigs + partNumbers: uniqueJoin(records.map((r) => r.partNumber)), + partNames: uniqueJoin(records.map((r) => r.name)), + records }; }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index aaf86f578..8e739c5cb 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -4,6 +4,7 @@ interface __BaseEnv_Env { KV: KVNamespace; THUMBNAILS: R2Bucket; + SEARCH_INDEX: R2Bucket; DB: D1Database; ASSETS: Fetcher; ADMIN_TEAM: "6a62e6efcc21741bea57362c" | "5b620150b2190f0fca90ec10"; @@ -32,6 +33,7 @@ declare namespace Cloudflare { interface CertEnv { KV: KVNamespace; THUMBNAILS: R2Bucket; + SEARCH_INDEX: R2Bucket; DB: D1Database; ASSETS: Fetcher; ADMIN_TEAM: "6a62e6efcc21741bea57362c"; @@ -56,6 +58,7 @@ declare namespace Cloudflare { interface ProductionEnv { KV: KVNamespace; THUMBNAILS: R2Bucket; + SEARCH_INDEX: R2Bucket; DB: D1Database; ASSETS: Fetcher; ADMIN_TEAM: "5b620150b2190f0fca90ec10"; diff --git a/wrangler.jsonc b/wrangler.jsonc index eb595899f..4f0d7765b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -50,6 +50,10 @@ { "binding": "THUMBNAILS", "bucket_name": "frc-design-app-dev-thumbnails" + }, + { + "binding": "SEARCH_INDEX", + "bucket_name": "frc-design-app-dev-search-index" } ], "workflows": [ @@ -106,6 +110,10 @@ { "binding": "THUMBNAILS", "bucket_name": "frc-design-app-cert-thumbnails" + }, + { + "binding": "SEARCH_INDEX", + "bucket_name": "frc-design-app-cert-search-index" } ], "workflows": [ @@ -152,6 +160,10 @@ { "binding": "THUMBNAILS", "bucket_name": "frc-thumbnails-production" + }, + { + "binding": "SEARCH_INDEX", + "bucket_name": "frc-search-index-production" } ], "workflows": [ From 91b6c0898f1182ab04f8655642860806289645ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:46:40 +0000 Subject: [PATCH 04/23] Underline only the typed prefix in search highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefix search matches a whole indexed term from a shorter query — typing "mot" matches "motor" — and highlighting underlined that entire term, so a match was all-or-nothing. Use MiniSearch's `queryTerms` (what was typed) alongside `match` (the document terms that matched) to underline just the longest query term each matched term starts with. Searching "maxsp" now underlines "MAXSp" of "MAXSpline" rather than all of it; an exact match still underlines the whole term, and overlapping ranges are merged as before. Also escape terms used to locate matches: they can now carry regex metacharacters (a canonicalized "1.5" would otherwise also underline the "125" in "1.5 x 125 Spacer"). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- src/frontend/search/search.test.ts | 52 ++++++++++++++++++++++++++++-- src/frontend/search/search.ts | 32 +++++++++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/frontend/search/search.test.ts b/src/frontend/search/search.test.ts index 58527ca85..0fbc2ebfe 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/search/search.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildSearchDb, processTerm, tokenize } from "../../shared/search"; -import { doSearch } from "./search"; +import { doSearch, type Position } from "./search"; import { LibraryOut } from "../../shared/api-models"; import { ElementType, ThumbnailUrls } from "../../shared/types"; import { @@ -82,7 +82,7 @@ describe("tokenize", () => { const thumbnailUrls = {} as ThumbnailUrls; -function library(): LibraryOut { +function library(name = "Bracket"): LibraryOut { return { groupOrder: ["g1"], groups: { @@ -108,7 +108,7 @@ function library(): LibraryOut { instanceType: "v", elementId: "e1" }, - name: "Bracket", + name, microversionId: "mv1", isVisible: true, supportsFasten: false, @@ -178,6 +178,52 @@ describe("doSearch part-number matching", () => { }); }); +describe("doSearch highlighting", () => { + /** The characters `positions` underline, merged the way applyRanges does. */ + function highlighted(text: string, positions: Position[]): string { + const covered = new Set(); + for (const { start, length } of positions) { + for (let i = start; i < start + length; i++) { + covered.add(i); + } + } + return [...text].filter((_, i) => covered.has(i)).join(""); + } + + function highlightFor(name: string, query: string): string { + const { hits } = doSearch( + buildSearchDb(library(name)), + query, + undefined, + undefined, + true + ); + expect(hits).toHaveLength(1); + return highlighted(name, hits[0].positions); + } + + it("underlines only the typed prefix, not the whole matched term", () => { + expect(highlightFor("Bracket", "brack")).toBe("Brack"); + }); + + it("underlines the whole term for an exact match", () => { + expect(highlightFor("Bracket", "bracket")).toBe("Bracket"); + }); + + it("underlines a prefix spanning camelCase sub-terms", () => { + expect(highlightFor("MAXSpline", "maxsp")).toBe("MAXSp"); + }); + + it("underlines a prefix of a later word", () => { + expect(highlightFor("Motor Mount", "mou")).toBe("Mou"); + }); + + it("matches terms literally rather than as patterns", () => { + // An unescaped "1.5" would also underline the "125". + expect(highlightFor("1.5 x 125 Spacer", "1.5")).toBe("1.5"); + }); +}); + describe("doSearch name matching", () => { const recordsMap: Record = { i1: [ diff --git a/src/frontend/search/search.ts b/src/frontend/search/search.ts index f800b505c..1724311d3 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/search/search.ts @@ -190,6 +190,26 @@ function findBestRecord( ); } +/** Escapes a term so it matches literally (terms can carry `.`, `(`, and friends). */ +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * How much of a matched term to underline: the longest query term it starts + * with — what the user actually typed — so a prefix search underlines only the + * typed prefix. Falls back to the whole term if none is a prefix. + */ +function matchedPrefixLength(term: string, queryTerms: string[]): number { + let length = 0; + for (const queryTerm of queryTerms) { + if (term.startsWith(queryTerm) && queryTerm.length > length) { + length = queryTerm.length; + } + } + return length || term.length; +} + /** * Generate highlight positions for matched terms in the document. * Based on approach from https://github.com/lucaong/minisearch/issues/37 @@ -198,8 +218,9 @@ function generateHighlightPositions( result: MiniSearchResult, document: SearchDocument ): Position[] { - // Terms is an array of values in name (or spacedName) which matched - // e.g., if search is "mot w", then terms could be ["motor", "WCP"] + // `match` is keyed by the document terms that matched, `queryTerms` by what + // was typed: searching "mot" matches the term "motor", and we underline just + // its "mot". Overlapping ranges are merged when they're applied. const name = document.name.toLowerCase(); @@ -210,11 +231,14 @@ function generateHighlightPositions( if (!matchedFields.includes("name")) { continue; } - const matchedLocations = name.matchAll(new RegExp(`(${term})`, "gi")); + const length = matchedPrefixLength(term, result.queryTerms); + const matchedLocations = name.matchAll( + new RegExp(escapeRegExp(term), "g") + ); for (const match of matchedLocations) { positions.push({ start: match.index, - length: term.length + length }); } } From 8d37bd74a8799a3dd10d229d0e40600d96afc3cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 17:44:45 +0000 Subject: [PATCH 05/23] Store configuration thumbnails in R2, keyed canonically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thumbnails are the slowest thing we ask Onshape for — a render can need polling and take minutes — and until now only one existed per element, its default configuration. A favorite pinned to a configuration and a search hit that matched one both showed the default image. Configuration thumbnails are now produced at runtime and stored in R2, addressed by a canonical configuration so equivalent selections share one object. - Add canonicalizeConfiguration: drops parameters hidden by a visibility condition and values matching the parameter default (Onshape applies those anyway, so an all-defaults selection reduces to the default thumbnail), normalizes quantity expressions through the evaluator, and emits in declaration order rather than object-key order. input-parser moves to src/shared so both sides can evaluate expressions. - New R2 key scheme splits `thumbnails/default/` (never expires — it is what everything falls back to) from `thumbnails/config/`, which carries a ~90-day lifecycle rule. Keys pin the microversion, so objects are immutable rather than overwritten in place. - The serving route falls back to the default thumbnail for a configuration we haven't rendered, cached briefly so the real one can take over; `warm=1` also starts a ThumbnailWorkflow, whose instance id is derived from the configuration so concurrent requests for the same thumbnail collapse onto one render. The insert menu's live proxy stores what it already proxied, warming the cache at no added latency. - Rows and hovers now render the same configuration. Favorites and the insert menu warm eagerly; search rows only serve what is cached, so a cold search can't start a render per row. - Replace the generic thumbnail_urls JSON map with explicit small/large columns, and trim ThumbnailSize to the two stored sizes (the unused 600x340 is gone; the insert preview moves to the stored 300x300). Migration 0004. - Fix an unguarded items[0] in getThumbnailId (a configuration matching nothing threw a TypeError) and a per-size try/catch that never caught, since it returned the promise without awaiting it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- docs/REFERENCE.md | 27 +- drizzle/0004_explicit_thumbnail_urls.sql | 13 + drizzle/meta/0004_snapshot.json | 496 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/__test_utils__/configuration-fixtures.ts | 34 +- src/backend/app.ts | 3 + src/backend/index.ts | 6 +- src/backend/library-data.ts | 6 +- src/backend/load/load-group.ts | 6 +- src/backend/load/load-insertable.ts | 3 +- src/backend/load/load-steps.ts | 20 +- src/backend/load/workflows.ts | 60 ++- .../onshape-api/endpoints/thumbnails.ts | 24 +- src/backend/parse/build-checks.test.ts | 6 +- src/backend/routes/thumbnails.test.ts | 121 +++-- src/backend/routes/thumbnails.ts | 295 ++++++++--- src/frontend/cards/card-components.tsx | 24 +- src/frontend/cards/insertable-card.tsx | 13 +- src/frontend/favorites/favorite-card.tsx | 12 +- src/frontend/favorites/favorite-menu.tsx | 6 +- src/frontend/groups/group-card.tsx | 3 +- src/frontend/insert/configurations.tsx | 51 +- src/frontend/insert/insert-menu.tsx | 5 + src/frontend/insert/thumbnail.tsx | 72 ++- src/frontend/search/search.test.ts | 6 +- src/shared/api-models.ts | 8 +- src/shared/configuration-utils.test.ts | 91 +++- src/shared/configuration-utils.ts | 165 +++++- .../insert => shared}/input-parser.test.ts | 2 +- .../insert => shared}/input-parser.ts | 6 +- src/shared/schema.ts | 11 +- src/shared/thumbnails.ts | 89 ++++ src/shared/types.ts | 22 +- worker-configuration.d.ts | 15 + wrangler.jsonc | 15 + 35 files changed, 1492 insertions(+), 251 deletions(-) create mode 100644 drizzle/0004_explicit_thumbnail_urls.sql create mode 100644 drizzle/meta/0004_snapshot.json rename src/{frontend/insert => shared}/input-parser.test.ts (98%) rename src/{frontend/insert => shared}/input-parser.ts (99%) create mode 100644 src/shared/thumbnails.ts diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index e36470bdb..3a1a6cc54 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -43,7 +43,18 @@ KV serves as a cheap, lightweight way to persist user data across multiple Cloud 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. -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`. +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 render can require polling and take minutes. 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?v=&c=`. + +Two sizes are stored per configuration (`70x40` for list rows, `300x300` for the hover card and insert preview), always generated as a pair so a row and its hover never disagree. Keys are split by prefix: + +| Prefix | Holds | Lifecycle | +| --------------------- | ------------------------------------------------- | --------------------- | +| `thumbnails/default/` | Each element's default-configuration thumbnails | **Never expires** | +| `thumbnails/config/` | One entry per canonical configuration we rendered | Expire after ~90 days | + +**Operational requirement:** the `config/` prefix needs an R2 **lifecycle rule** to expire objects (R2 lifecycle rules are configured per bucket in the dashboard or via the API — they cannot be expressed in `wrangler.jsonc`). The `default/` prefix must be left alone: every configuration falls back to it while its own render is pending or after it expires. + +Configuration thumbnails are produced at runtime rather than indexed at load. A request for one that doesn't exist yet serves the default thumbnail with a short cache lifetime (so the real one can take over as soon as it lands), and — when the request asks to `warm` — starts a `ThumbnailWorkflow` to render it. The workflow's instance id is derived from the configuration, so concurrent requests for the same thumbnail collapse onto a single render. Cache keys use a _canonical_ configuration (`canonicalizeConfiguration` in `src/shared/configuration-utils.ts`), which drops hidden and default-valued parameters and normalizes quantity expressions, so equivalent selections share one stored object. ### Workflows — Document Sync (`c.env.LOAD_DOCUMENT_WORKFLOW`) @@ -94,13 +105,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** | Part and group thumbnail images | Defaults indefinite; per-configuration ~90 days | 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 | — | — | ## Codebase Map 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/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/_journal.json b/drizzle/meta/_journal.json index f83c2ff41..8e796160c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1786511719906, "tag": "0003_drop_search_db", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1786511719907, + "tag": "0004_explicit_thumbnail_urls", + "breakpoints": true } ] } diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index 690c04f67..ca9e020df 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -9,8 +9,11 @@ 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 +45,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/backend/app.ts b/src/backend/app.ts index 0f6224d3a..cd998eeee 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,5 +1,6 @@ import { type Context, type MiddlewareHandler, Hono } from "hono"; import type { AddGroupParams, LoadLibraryParams } from "./load/workflows"; +import type { ThumbnailParams } from "../shared/thumbnails"; import { LibraryId, type AccessLevel } from "../shared/types"; import { type OAuthApi } from "./onshape-api/onshape-api"; import z from "zod"; @@ -15,6 +16,8 @@ export interface AppBindings { SEARCH_INDEX: 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. */ 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 322403244..0d59dc37a 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -79,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 }; } @@ -103,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; diff --git a/src/backend/load/load-group.ts b/src/backend/load/load-group.ts index 9bfe13d8a..29964169f 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; @@ -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. diff --git a/src/backend/load/load-insertable.ts b/src/backend/load/load-insertable.ts index fa9ae2039..61dc5bdba 100644 --- a/src/backend/load/load-insertable.ts +++ b/src/backend/load/load-insertable.ts @@ -224,7 +224,8 @@ 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, isOpenComposite: parsed.isOpenComposite, buildIssues: parsed.buildIssues, diff --git a/src/backend/load/load-steps.ts b/src/backend/load/load-steps.ts index a789b7627..870051b6d 100644 --- a/src/backend/load/load-steps.ts +++ b/src/backend/load/load-steps.ts @@ -36,6 +36,17 @@ export const ONSHAPE_STEP_RETRIES = { delay: onshapeRetryDelay }; +/** + * Retries for a step waiting on an Onshape render: wait out a rate limit, + * otherwise poll, since Onshape renders thumbnails asynchronously. + */ +export const THUMBNAIL_STEP_RETRIES = { + limit: 3, + delay: (retry: RetryDelayInput) => + rateLimitDelay(retry.error) ?? + (retry.ctx.attempt === 1 ? "10 seconds" : "5 minutes") +}; + /** * 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 @@ -50,14 +61,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 2e79bae06..ce84191ec 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/load/workflows.ts @@ -15,7 +15,9 @@ 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 type { ThumbnailParams } from "../../shared/thumbnails"; +import { uploadConfigurationThumbnails } from "../routes/thumbnails"; import { type GroupTarget, type LoadContext, @@ -25,6 +27,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; @@ -231,3 +234,58 @@ async function finalizeLibrary( await rebuildSearchDb(env.SEARCH_INDEX, db, libraryId); await bumpLibraryVersion(db, libraryId); } + +/** + * Renders and stores one configuration's thumbnails outside a request, since + * Onshape can take minutes and a Worker request cannot wait that long. Until it + * finishes, requests fall back to the element's default thumbnail. + */ +export class ThumbnailWorkflow extends WorkflowEntrypoint< + AppBindings, + ThumbnailParams +> { + async run( + event: WorkflowEvent, + step: WorkflowStep + ): Promise { + const { elementId, microversionId, configuration } = event.payload; + + const elementPath = await step.do("resolve-element", async () => { + const row = await getDb(this.env.DB) + .select({ + documentId: insertables.documentId, + versionId: insertables.versionId + }) + .from(insertables) + .where(eq(insertables.elementId, elementId)) + .get(); + if (!row) { + throw new Error(`No insertable for element ${elementId}`); + } + return { + documentId: row.documentId, + instanceId: row.versionId, + instanceType: "v" as const, + elementId + }; + }); + + await step.do( + "render-thumbnails", + { retries: THUMBNAIL_STEP_RETRIES }, + async () => + uploadConfigurationThumbnails( + this.env.THUMBNAILS, + await getOnshapeApiFromContext({ + env: this.env, + sessionId: "", + step, + limit: createLimiter(1) + }), + elementPath, + microversionId, + configuration + ) + ); + } +} diff --git a/src/backend/onshape-api/endpoints/thumbnails.ts b/src/backend/onshape-api/endpoints/thumbnails.ts index f489e473e..a4b0dcf16 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); @@ -79,7 +72,12 @@ 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 Error("Onshape returned no insertable for the configuration"); + } + return thumbnailId; } /** @@ -90,7 +88,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/parse/build-checks.test.ts b/src/backend/parse/build-checks.test.ts index 165429f16..dbd3c117f 100644 --- a/src/backend/parse/build-checks.test.ts +++ b/src/backend/parse/build-checks.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; -import { ThumbnailSize, ThumbnailUrls, Vendor } from "../../shared/types"; +import { ThumbnailUrls, Vendor } from "../../shared/types"; import { BuildIssueType } from "../../shared/build-issues"; import { checkGroup, checkInsertable } from "./build-checks"; const THUMBNAILS: ThumbnailUrls = { - [ThumbnailSize.TINY]: "/api/thumbnail/tiny/x", - [ThumbnailSize.STANDARD]: "/api/thumbnail/standard/x" + small: "/api/thumbnail/70x40/x", + large: "/api/thumbnail/300x300/x" }; /** A group with nothing wrong; each test spreads in the one fault it checks. */ diff --git a/src/backend/routes/thumbnails.test.ts b/src/backend/routes/thumbnails.test.ts index 96b6f851f..d5388358a 100644 --- a/src/backend/routes/thumbnails.test.ts +++ b/src/backend/routes/thumbnails.test.ts @@ -1,62 +1,115 @@ import { env } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; import { createTestApp, jsonRequest } from "../../__test_utils__"; +import { ThumbnailSize } from "../../shared/types"; +import { + thumbnailConfigurationKey, + thumbnailKey, + thumbnailUrl +} from "../../shared/thumbnails"; -// 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 CONFIGURATION = "size=l"; -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); - }); +function get(url: string) { + return createTestApp().request(url, jsonRequest("GET"), env); +} - it("GET /thumbnail/:size/:elementId serves a stored thumbnail from R2", async () => { +describe("thumbnail serving", () => { + it("serves a stored thumbnail, cached immutably", async () => { const elementId = "stored-element"; await env.THUMBNAILS.put( - `thumbnails/${SIZE}/${elementId}`, + 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 + }) ); expect(res.status).toBe(200); expect(await res.text()).toBe("gif-bytes"); - expect(res.headers.get("Cache-Control")).toBe( - "public, max-age=31536000, immutable" - ); + expect(res.headers.get("Cache-Control")).toContain("immutable"); }); 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 - ); + const res = await get("/api/thumbnail-id/d/doc/v/ver/e/elem"); // 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); }); - 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("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("404s when neither the configuration nor the default exists", async () => { + const res = await get( + thumbnailUrl({ + elementId: "does-not-exist", + microversionId: MICROVERSION, + size: SIZE + }) ); 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.THUMBNAILS.put( + thumbnailKey(elementId, MICROVERSION, SIZE), + "default-bytes" + ); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + configuration: CONFIGURATION + }) + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe("default-bytes"); + expect(res.headers.get("Cache-Control")).not.toContain("immutable"); + }); + + it("prefers the configuration's own thumbnail once it exists", async () => { + const elementId = "configured-element"; + await env.THUMBNAILS.put( + thumbnailKey(elementId, MICROVERSION, SIZE), + "default-bytes" + ); + await env.THUMBNAILS.put( + thumbnailKey( + elementId, + MICROVERSION, + SIZE, + thumbnailConfigurationKey(CONFIGURATION) + ), + "configured-bytes" + ); + + const res = await get( + thumbnailUrl({ + elementId, + microversionId: MICROVERSION, + size: SIZE, + configuration: CONFIGURATION + }) + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe("configured-bytes"); + expect(res.headers.get("Cache-Control")).toContain("immutable"); + }); }); diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index a1390de12..56740bd46 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -15,73 +15,135 @@ 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_CACHE_TTL, + THUMBNAIL_FALLBACK_CACHE_TTL, + type ThumbnailParams, + thumbnailConfigurationKey, + thumbnailKey, + thumbnailUrl, + thumbnailWorkflowId +} from "../../shared/thumbnails"; +import { DEFAULT_CONFIGURATION_KEY } from "../../shared/configuration-utils"; import { OnshapeApi } from "../onshape-api/onshape-api"; +import type { AppBindings } from "../app"; 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: `public, max-age=${THUMBNAIL_CACHE_TTL}, immutable` + }, + customMetadata: metadata + }); } +/** + * Renders and stores an element's default-configuration thumbnails, in both + * sizes. Throws if Onshape hasn't rendered them yet, which is what 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 + }), + large: thumbnailUrl({ + elementId, + microversionId, + size: ThumbnailSize.LARGE + }) }; } +/** + * Renders and stores one configuration's thumbnails, in both sizes, so a row and + * its hover never disagree. Uses the two-stage id flow, the only Onshape path + * that takes a configuration; both calls can fail while Onshape renders, which + * is what the caller's retries are for. + */ +export async function uploadConfigurationThumbnails( + bucket: R2Bucket, + onshapeApi: OnshapeApi, + elementPath: ElementPath, + microversionId: string, + configuration: string +): Promise { + const thumbnailId = await getThumbnailId( + onshapeApi, + elementPath, + configuration + ); + const [small, large] = await Promise.all([ + getThumbnailFromId(onshapeApi, thumbnailId, ThumbnailSize.SMALL), + getThumbnailFromId(onshapeApi, thumbnailId, ThumbnailSize.LARGE) + ]); + + const configurationKey = thumbnailConfigurationKey(configuration); + const { elementId } = elementPath; + await Promise.all( + ( + [ + [ThumbnailSize.SMALL, small], + [ThumbnailSize.LARGE, large] + ] as const + ).map(([size, thumbnail]) => + putThumbnail( + bucket, + thumbnailKey(elementId, microversionId, size, configurationKey), + thumbnail, + { microversionId, configuration } + ) + ) + ); +} + /** * Uploads document-level thumbnails using the document's designated thumbnail element. */ @@ -123,45 +185,128 @@ export async function uploadDocumentThumbnails( export const thumbnailRoutes = getApp(); -/** GET /api/thumbnail/:size/:elementId?v=:microversionId — static from R2 */ -thumbnailRoutes.get( - "/thumbnail/:size/:elementId", - cacheMiddleware(CachePolicy.PUBLIC_CACHE), - async (c) => { - const size = c.req.param("size"); - const elementId = c.req.param("elementId"); +/** + * GET /api/thumbnail/:size/:elementId?v=&c=&warm= + * + * Serves a stored thumbnail. `c` is the encoded canonical configuration (absent + * for the default), `v` the microversion — both are part of the key, so a hit is + * immutable. A configuration we haven't rendered falls back to the element's + * default thumbnail, cached only briefly so the real one can take over as soon + * as it lands; with `warm=1` the miss also kicks off that render. + */ +thumbnailRoutes.get("/thumbnail/:size/:elementId", async (c) => { + const size = c.req.param("size") as ThumbnailSize; + const elementId = c.req.param("elementId"); + const microversionId = c.req.query("v"); + if (!microversionId) { + return c.json({ error: "v (microversionId) required" }, 400); + } + const configuration = c.req.query("c"); + const configurationKey = thumbnailConfigurationKey(configuration); - const obj = await c.env.THUMBNAILS.get(r2Key(size, elementId)); - if (!obj) return c.notFound(); + const object = await c.env.THUMBNAILS.get( + thumbnailKey(elementId, microversionId, size, configurationKey) + ); + if (object) { + return thumbnailResponse(object, THUMBNAIL_CACHE_TTL); + } - 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 (c.req.query("warm") === "1" && configuration) { + await warmConfigurationThumbnail(c.env, { + elementId, + microversionId, + configuration + }); + } - 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.THUMBNAILS.get( + thumbnailKey(elementId, microversionId, size) + ); + if (!fallback) { + return c.notFound(); + } + return thumbnailResponse(fallback, THUMBNAIL_FALLBACK_CACHE_TTL); +}); + +function thumbnailResponse(object: R2ObjectBody, maxAge: number): Response { + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set( + "Cache-Control", + maxAge === THUMBNAIL_CACHE_TTL + ? `public, max-age=${maxAge}, immutable` + : `public, max-age=${maxAge}` + ); + return new Response(object.body, { headers }); +} + +/** + * Starts rendering a configuration's thumbnails, if nobody already is. The + * configuration key doubles as the workflow instance id, so concurrent requests + * for the same configuration collapse onto one run — a duplicate id is rejected, + * which is exactly the outcome we want. + */ +async function warmConfigurationThumbnail( + env: AppBindings, + params: ThumbnailParams +): Promise { + try { + await env.THUMBNAIL_WORKFLOW.create({ + id: thumbnailWorkflowId(params), + params }); + } catch { + // Already rendering (or the workflow couldn't start) — the caller still + // has the default thumbnail to serve, so this is never fatal. } -); +} + +/** + * GET /api/thumbnail?size=X&thumbnailId=Y — live preview thumbnail from Onshape. + * + * With `elementId`, `v`, and `c`, the bytes are also stored under that + * configuration's key on the way out: the insert menu keeps its responsive + * two-stage flow and warms the cache for free, with no added latency. + */ +thumbnailRoutes.get("/thumbnail", requireSignInMiddleware, async (c) => { + const onshapeApi = await c.var.getOnshapeApi(); + const size = (c.req.query("size") as ThumbnailSize) ?? ThumbnailSize.LARGE; + const thumbnailId = c.req.query("thumbnailId"); + if (!thumbnailId) return c.json({ error: "thumbnailId required" }, 400); + + const buffer = await getThumbnailFromId(onshapeApi, thumbnailId, size); + + const elementId = c.req.query("elementId"); + const microversionId = c.req.query("v"); + const configuration = c.req.query("c"); + if (elementId && microversionId && configuration) { + c.executionCtx.waitUntil( + putThumbnail( + c.env.THUMBNAILS, + thumbnailKey( + elementId, + microversionId, + size, + thumbnailConfigurationKey(configuration) + ), + buffer, + { microversionId, configuration } + ) + ); + } + + return new Response(buffer, { + headers: { + "Content-Type": "image/gif", + "Cache-Control": `public, max-age=${THUMBNAIL_CACHE_TTL}, immutable` + } + }); +}); /** GET /api/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId */ thumbnailRoutes.get( @@ -224,7 +369,8 @@ thumbnailRoutes.post( await db .update(insertables) .set({ - thumbnailUrls: thumbnails, + smallThumbnailUrl: thumbnails.small, + largeThumbnailUrl: thumbnails.large, buildIssues: clearBuildIssue( row.buildIssues, BuildIssueType.THUMBNAIL_FAILED @@ -278,7 +424,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/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index edb7ad274..a338bb362 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -13,7 +13,7 @@ import { 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 { 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; @@ -157,7 +167,11 @@ export function CardTitle(props: CardTitleProps) { return ( - + {cardTitle} diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index 9e710df4b..c7f886634 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -1,3 +1,4 @@ +import { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; 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, + configuration: 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..e1f47cb50 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/favorites/favorite-menu.tsx @@ -8,6 +8,7 @@ import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../api-utils/api"; import { showErrorToast, showSuccessToast } from "../common/notifications"; import { PreviewImageCard } from "../insert/thumbnail"; +import { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; import { ConfigurationWrapper } from "../insert/configurations"; import { type FavoritesData } from "../../shared/api-models"; import { HeartIcon } from "./favorite-button"; @@ -121,7 +122,10 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { path={insertable.path} microversionId={insertable.microversionId} configuration={configuration} - thumbnailUrls={insertable.thumbnailUrls} + microversionId={insertable.microversionId} + canonicalConfiguration={encodeCanonicalConfiguration( + configuration ?? {} + )} /> ): ReactNode { ); } -/** Display precision used when the document's units aren't available. */ -const DEFAULT_QUANTITY_PRECISION = 3; - -function getEvaluateOptions( - parameter: QuantityParameter, - unitInfo: UnitInfo -): EvaluateOptions { - const quantityType = parameter.quantityType; - const minAndMax = { - min: valueWithUnits(parameter.min, parameter.unit), - max: valueWithUnits(parameter.max, parameter.unit) - }; - // Fall back to the parameter's own unit when the document's isn't available. - if (quantityType === QuantityType.LENGTH) { - return { - quantityType, - displayPrecision: - unitInfo.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: unitInfo.lengthUnit ?? parameter.unit, - ...minAndMax - }; - } else if (quantityType === QuantityType.ANGLE) { - return { - quantityType, - displayPrecision: - unitInfo.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: unitInfo.angleUnit ?? parameter.unit, - ...minAndMax - }; - } else if (quantityType == QuantityType.REAL) { - return { - quantityType, - displayPrecision: - unitInfo.realPrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: Unit.UNITLESS, - ...minAndMax - }; - } - return { - quantityType: QuantityType.INTEGER, - displayPrecision: 0, - displayUnit: Unit.UNITLESS, - ...minAndMax - }; -} - function QuantityInput(props: ParameterProps): ReactNode { // This parameter doesn't actually use value since it manages it's state internally const { parameter, value, onValueChange, unitInfo } = props; diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index 9525ffab3..e249b0218 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -21,6 +21,7 @@ import { InsertableMenuItems } from "../cards/insertable-card"; import { ConfigurationWrapper } from "./configurations"; import { useInsertMutation } from "./insert-hooks"; import { ParameterValues } from "../../shared/configuration-models"; +import { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; import { useFavoritesQuery } from "../queries"; import { useUiState } from "../api-utils/ui-state"; import { notifications } from "@mantine/notifications"; @@ -104,6 +105,10 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { microversionId={insertable.microversionId} configuration={configuration} thumbnailUrls={insertable.thumbnailUrls} + microversionId={insertable.microversionId} + canonicalConfiguration={encodeCanonicalConfiguration( + configuration ?? {} + )} /> {parameters} diff --git a/src/frontend/insert/thumbnail.tsx b/src/frontend/insert/thumbnail.tsx index 093ee8232..f2e672280 100644 --- a/src/frontend/insert/thumbnail.tsx +++ b/src/frontend/insert/thumbnail.tsx @@ -8,6 +8,7 @@ import { IconHelp } from "@tabler/icons-react"; import { ComponentPropsWithRef, ReactNode } from "react"; import { ParameterValues } from "../../shared/configuration-models"; import { encodeConfigurationForQuery } from "../../shared/configuration-utils"; +import { thumbnailUrl } from "../../shared/thumbnails"; import { getConfigurationMatchKey } from "../queries"; import { SectionError } from "../app-common/app-zero-state"; import { useTargetElementType } from "./insert-hooks"; @@ -30,15 +31,40 @@ function getHeightAndWidth( }; } +/** + * Where to read a configuration's thumbnails from. Rows only know a + * configuration, not whether it has been rendered yet — the route falls back to + * the element's default thumbnail until it has. + */ +export interface ThumbnailTarget { + elementId: string; + microversionId: string; + /** The encoded canonical configuration; empty means the element default. */ + configuration: string; + /** + * Whether a miss should start rendering this configuration. Surfaces where + * the user chose the configuration warm it; search results don't, since one + * cold search would otherwise kick off a render per row. + */ + warm: boolean; +} + interface CardThumbnailProps { - thumbnailUrls?: ThumbnailUrls; + smallThumbnailUrl?: string; + largeThumbnailUrl?: string; + /** Set to show a specific configuration rather than the element default. */ + target?: ThumbnailTarget; } /** - * Thumbnail component used in lists. + * Thumbnail component used in lists, with a larger one on hover. Both sizes come + * from the same configuration, so the two never disagree. */ export function CardThumbnail(props: CardThumbnailProps): ReactNode { - const { thumbnailUrls } = props; + const { smallThumbnailUrl, largeThumbnailUrl, target } = props; + + const urlFor = (size: ThumbnailSize, stored?: string) => + target?.configuration ? thumbnailUrl({ ...target, size }) : stored; return ( @@ -132,11 +155,20 @@ interface PreviewImageProps { configuration?: ParameterValues; /** Stored thumbnail, shown instead of the live preview when not signed in. */ thumbnailUrls?: ThumbnailUrls; + /** With the canonical configuration, lets the fetch also warm the R2 cache. */ + canonicalConfiguration?: string; } export function PreviewImage(props: PreviewImageProps): ReactNode { - const { path, microversionId, configuration, thumbnailUrls } = props; - const size = ThumbnailSize.SMALL; + const { + path, + microversionId, + configuration, + thumbnailUrls, + canonicalConfiguration + } = props; + // A stored size, so the bytes this fetch returns are worth caching. + const size = ThumbnailSize.LARGE; const isSignedIn = useIsSignedIn(); const isConnected = useIsConnectedToOnshape(); const isFetchingConfiguration = @@ -172,7 +204,19 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { return; } return apiGetImage("/thumbnail", { - query: { size, thumbnailId }, + query: { + size, + thumbnailId, + // Let the worker store what it proxies, so this render is + // cached for the rows that show the same configuration. + ...(canonicalConfiguration + ? { + elementId: path.elementId, + v: microversionId, + c: canonicalConfiguration + } + : {}) + }, cacheId: microversionId, signal }); @@ -200,7 +244,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { if (!isSignedIn) { return ( diff --git a/src/frontend/search/search.test.ts b/src/frontend/search/search.test.ts index 0fbc2ebfe..7138b542b 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/search/search.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildSearchDb, processTerm, tokenize } from "../../shared/search"; import { doSearch, type Position } from "./search"; import { LibraryOut } from "../../shared/api-models"; -import { ElementType, ThumbnailUrls } from "../../shared/types"; +import { ElementType } from "../../shared/types"; import { ConfigurationRecord, ParameterValues @@ -80,8 +80,6 @@ describe("tokenize", () => { }); }); -const thumbnailUrls = {} as ThumbnailUrls; - function library(name = "Bracket"): LibraryOut { return { groupOrder: ["g1"], @@ -91,7 +89,6 @@ function library(name = "Bracket"): LibraryOut { documentId: "d1", path: { documentId: "d1", instanceId: "v1", instanceType: "v" }, name: "Group", - thumbnailUrls, insertableOrder: ["i1"] } }, @@ -113,7 +110,6 @@ function library(name = "Bracket"): LibraryOut { isVisible: true, supportsFasten: false, elementType: ElementType.PART_STUDIO, - thumbnailUrls, vendors: [] } } diff --git a/src/shared/api-models.ts b/src/shared/api-models.ts index 7bd280127..77fb6271a 100644 --- a/src/shared/api-models.ts +++ b/src/shared/api-models.ts @@ -3,7 +3,7 @@ import { ParameterValues, ConfigurationParameter } from "./configuration-models"; -import { ElementType, LibraryId, ThumbnailUrls, Vendor } from "./types"; +import { ElementType, LibraryId, Vendor } from "./types"; import { BuildIssue } from "./build-issues"; export interface InsertableOut { @@ -18,7 +18,8 @@ export interface InsertableOut { isVisible: boolean; supportsFasten: boolean; elementType: ElementType; - thumbnailUrls?: ThumbnailUrls; + smallThumbnailUrl?: string; + largeThumbnailUrl?: string; configurationId?: string; vendors: Vendor[]; } @@ -28,7 +29,8 @@ export interface GroupOut { documentId: string; path: InstancePath; name: string; - thumbnailUrls?: ThumbnailUrls; + smallThumbnailUrl?: string; + largeThumbnailUrl?: string; insertableOrder: string[]; } diff --git a/src/shared/configuration-utils.test.ts b/src/shared/configuration-utils.test.ts index 009987ed6..989c3c1a9 100644 --- a/src/shared/configuration-utils.test.ts +++ b/src/shared/configuration-utils.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "vitest"; -import { findRecordForConfiguration } from "./configuration-utils"; -import { SearchRecord } from "./configuration-models"; +import { + DEFAULT_CONFIGURATION_KEY, + canonicalizeConfiguration, + configurationKey, + encodeCanonicalConfiguration, + findRecordForConfiguration +} from "./configuration-utils"; +import { SearchRecord, VisibilityType } from "./configuration-models"; +import { + boolParam, + enumParam, + quantityParam, + TEST_UNIT_INFO +} from "../__test_utils__/configuration-fixtures"; function rec( configuration: Record, @@ -48,3 +60,78 @@ describe("findRecordForConfiguration", () => { ).toBeUndefined(); }); }); + +describe("canonicalizeConfiguration", () => { + const size = enumParam("size", ["s", "l"]); + const flag = boolParam("flag"); + const length = quantityParam("length"); + + function canon( + configuration: Record, + parameters = [size, flag, length] + ) { + return canonicalizeConfiguration( + configuration, + parameters, + TEST_UNIT_INFO + ); + } + + it("drops values that match the parameter default", () => { + // "s" and "false" are the defaults, so Onshape renders them anyway. + expect(canon({ size: "s", flag: "false", length: "1 in" })).toEqual({}); + }); + + it("keeps only what differs from the defaults", () => { + expect(canon({ size: "l", flag: "false" })).toEqual({ size: "l" }); + }); + + it("emits parameters in declaration order, not object order", () => { + const a = canon({ flag: "true", size: "l" }); + const b = canon({ size: "l", flag: "true" }); + expect(Object.keys(a)).toEqual(["size", "flag"]); + expect(encodeCanonicalConfiguration(a)).toBe( + encodeCanonicalConfiguration(b) + ); + expect(configurationKey(a)).toBe(configurationKey(b)); + }); + + it("collapses equivalent quantity spellings", () => { + const keys = ["2in", "2 in", "(1 + 1) in"].map((value) => + configurationKey(canon({ length: value })) + ); + expect(new Set(keys).size).toBe(1); + // ...and it is not the default key, since 2 in != the 1 in default. + expect(keys[0]).not.toBe(configurationKey({})); + }); + + it("drops a parameter hidden by its visibility condition", () => { + const hidden = enumParam("hidden", ["x", "y"], { + condition: { + type: VisibilityType.EQUAL, + id: "size", + value: "s" + } + }); + // size=l hides `hidden`, so its value can't affect the render. + expect(canon({ size: "l", hidden: "y" }, [size, hidden])).toEqual({ + size: "l" + }); + }); + + it("ignores parameters that aren't set", () => { + expect(canon({ size: "l" })).toEqual({ size: "l" }); + }); +}); + +describe("configurationKey", () => { + it("maps an empty configuration to the default key", () => { + expect(configurationKey({})).toBe(DEFAULT_CONFIGURATION_KEY); + }); + + it("gives different configurations different keys", () => { + expect(configurationKey({ a: "1" })).not.toBe( + configurationKey({ a: "2" }) + ); + }); +}); diff --git a/src/shared/configuration-utils.ts b/src/shared/configuration-utils.ts index 4aba1c70a..92e7f38ca 100644 --- a/src/shared/configuration-utils.ts +++ b/src/shared/configuration-utils.ts @@ -5,11 +5,18 @@ import { OptionVisibilityType, ConfigurationParameter, ParameterType, + QuantityParameter, SearchRecord, + UnitInfo, VisibilityCondition, VisibilityType } from "./configuration-models"; -import { LogicalOp } from "./configuration-enums"; +import { LogicalOp, QuantityType, Unit } from "./configuration-enums"; +import { + type EvaluateOptions, + evaluateExpression, + valueWithUnits +} from "./input-parser"; /** * Finds the record a (full) configuration selection produces. Records are keyed @@ -139,3 +146,159 @@ export function getVisibleOptions( validOptionsSet.has(option.id) ); } + +/** Display precision used when the document's units aren't available. */ +const DEFAULT_QUANTITY_PRECISION = 3; + +/** + * The evaluation settings for a quantity parameter: its own bounds, plus the + * document's display unit and precision, falling back to the parameter's own. + */ +export function getEvaluateOptions( + parameter: QuantityParameter, + unitInfo: UnitInfo +): EvaluateOptions { + const quantityType = parameter.quantityType; + const minAndMax = { + min: valueWithUnits(parameter.min, parameter.unit), + max: valueWithUnits(parameter.max, parameter.unit) + }; + if (quantityType === QuantityType.LENGTH) { + return { + quantityType, + displayPrecision: + unitInfo.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo.lengthUnit ?? parameter.unit, + ...minAndMax + }; + } else if (quantityType === QuantityType.ANGLE) { + return { + quantityType, + displayPrecision: + unitInfo.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo.angleUnit ?? parameter.unit, + ...minAndMax + }; + } else if (quantityType === QuantityType.REAL) { + return { + quantityType, + displayPrecision: + unitInfo.realPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: Unit.UNITLESS, + ...minAndMax + }; + } + return { + quantityType: QuantityType.INTEGER, + displayPrecision: 0, + displayUnit: Unit.UNITLESS, + ...minAndMax + }; +} + +/** Normalizes one parameter's raw value to its canonical spelling. */ +function canonicalizeValue( + parameter: ConfigurationParameter, + value: string, + unitInfo: UnitInfo +): string { + if (parameter.type === ParameterType.QUANTITY) { + // "1in", "1 in", and "(0.5 + 0.5) in" are the same configuration; the + // evaluated, rounded display form is the one spelling of it. An + // unparseable value can't be normalized, so it rides as-is. + const result = evaluateExpression( + value, + getEvaluateOptions(parameter, unitInfo) + ); + return result.hasError ? value.trim() : result.displayExpression; + } + if (parameter.type === ParameterType.BOOLEAN) { + return value.trim().toLowerCase(); + } + return value.trim(); +} + +/** + * Reduces a configuration to the one spelling shared by every selection that + * renders the same thing, so thumbnails of equivalent configurations resolve to + * a single cache entry. + * + * Parameters are emitted in declaration order (object key order is not + * meaningful); values are normalized per type; parameters hidden by a + * visibility condition are dropped, as are values matching the parameter's + * default — Onshape applies the default for anything omitted, so an + * all-defaults selection canonicalizes to `{}`, which is the default thumbnail. + */ +export function canonicalizeConfiguration( + configuration: ParameterValues, + parameters: ConfigurationParameter[], + unitInfo: UnitInfo +): ParameterValues { + const canonical: ParameterValues = {}; + for (const parameter of parameters) { + const value = configuration[parameter.id]; + if (value === undefined) { + continue; + } + // Onshape doesn't apply a hidden parameter, so it can't change the render. + if ( + !evaluateCondition(parameter.condition, configuration, parameters) + ) { + continue; + } + const canonicalValue = canonicalizeValue(parameter, value, unitInfo); + const canonicalDefault = canonicalizeValue( + parameter, + parameter.default, + unitInfo + ); + if (canonicalValue === canonicalDefault) { + continue; + } + canonical[parameter.id] = canonicalValue; + } + return canonical; +} + +/** The canonical configuration as one string; empty means the element default. */ +export function encodeCanonicalConfiguration( + canonical: ParameterValues +): string { + return Object.entries(canonical) + .map(([id, value]) => `${id}=${value}`) + .join(";"); +} + +/** + * A short, stable key for a canonical configuration, used in thumbnail URLs and + * R2 keys — a configuration string is unbounded and holds arbitrary characters. + * Not a security boundary: it only has to avoid collisions within one element, + * so a fast 53-bit hash is plenty (and, unlike SubtleCrypto, is synchronous). + */ +export function configurationKey(canonical: ParameterValues): string { + return configurationKeyFor(encodeCanonicalConfiguration(canonical)); +} + +/** {@link configurationKey}, for an already-encoded canonical configuration. */ +export function configurationKeyFor(encoded: string): string { + if (encoded === "") { + return DEFAULT_CONFIGURATION_KEY; + } + // cyrb53 + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < encoded.length; i++) { + const ch = encoded.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0); + return hash.toString(36); +} + +/** The key for an element's default configuration (what everything falls back to). */ +export const DEFAULT_CONFIGURATION_KEY = "default"; diff --git a/src/frontend/insert/input-parser.test.ts b/src/shared/input-parser.test.ts similarity index 98% rename from src/frontend/insert/input-parser.test.ts rename to src/shared/input-parser.test.ts index afa5b1421..73e5ac616 100644 --- a/src/frontend/insert/input-parser.test.ts +++ b/src/shared/input-parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { QuantityType, Unit } from "../../shared/configuration-enums"; +import { QuantityType, Unit } from "./configuration-enums"; import { evaluateExpression, EvaluateOptions, diff --git a/src/frontend/insert/input-parser.ts b/src/shared/input-parser.ts similarity index 99% rename from src/frontend/insert/input-parser.ts rename to src/shared/input-parser.ts index e56719d5a..301d83e13 100644 --- a/src/frontend/insert/input-parser.ts +++ b/src/shared/input-parser.ts @@ -11,11 +11,7 @@ import { seq, tok } from "typescript-parsec"; -import { - getUnitDisplayStr, - QuantityType, - Unit -} from "../../shared/configuration-enums"; +import { getUnitDisplayStr, QuantityType, Unit } from "./configuration-enums"; class ParseError extends Error { constructor(message: string) { diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 7566e3808..0b01db19b 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -8,7 +8,6 @@ import { Theme, Vendor } from "./types"; -import { ThumbnailUrls } from "./types"; import { ParameterValues, ConfigurationParameter, @@ -41,9 +40,8 @@ export const group = sqliteTable( .notNull() .default(false), sortOrder: integer("sort_order").notNull().default(0), - thumbnailUrls: text("thumbnail_urls", { - mode: "json" - }).$type(), + smallThumbnailUrl: text("small_thumbnail_url"), + largeThumbnailUrl: text("large_thumbnail_url"), // Build-time issues flagged by the build checker, recomputed on reload. buildIssues: text("build_issues", { mode: "json" }) .$type() @@ -95,9 +93,8 @@ export const insertables = sqliteTable("insertables", { .$type() .notNull() .default([]), - thumbnailUrls: text("thumbnail_urls", { - mode: "json" - }).$type(), + smallThumbnailUrl: text("small_thumbnail_url"), + largeThumbnailUrl: text("large_thumbnail_url"), fastenInfo: text("fasten_info", { mode: "json" }).$type(), diff --git a/src/shared/thumbnails.ts b/src/shared/thumbnails.ts new file mode 100644 index 000000000..ab6aa2084 --- /dev/null +++ b/src/shared/thumbnails.ts @@ -0,0 +1,89 @@ +/** + * Thumbnail addressing, shared so the client builds exactly the URLs the worker + * serves, and so R2 keys have one definition. + */ +import { + DEFAULT_CONFIGURATION_KEY, + configurationKeyFor +} from "./configuration-utils"; +import { ThumbnailSize } from "./types"; + +/** A stored thumbnail never changes, since its key pins the microversion. */ +export const THUMBNAIL_CACHE_TTL = 30 * 24 * 3600; + +/** + * How long a fallback (the default configuration standing in for one we haven't + * rendered yet) may be cached. Short on purpose: the real thumbnail can land at + * any moment, and an `immutable` fallback would pin the wrong image for a month. + */ +export const THUMBNAIL_FALLBACK_CACHE_TTL = 60; + +/** + * The R2 key for a thumbnail. Default-configuration thumbnails live under their + * own prefix because everything falls back to them, so only the `config/` prefix + * carries an expiry lifecycle rule. + */ +export function thumbnailKey( + elementId: string, + microversionId: string, + size: ThumbnailSize, + configurationKey: string = DEFAULT_CONFIGURATION_KEY +): string { + if (configurationKey === DEFAULT_CONFIGURATION_KEY) { + return `thumbnails/default/${elementId}/${microversionId}/${size}`; + } + return `thumbnails/config/${elementId}/${microversionId}/${configurationKey}/${size}`; +} + +export interface ThumbnailUrlOptions { + elementId: string; + microversionId: string; + size: ThumbnailSize; + /** The encoded canonical configuration; omit or empty for the default. */ + configuration?: string; + /** Whether a miss should kick off generating this configuration. */ + warm?: boolean; +} + +/** The app URL serving a thumbnail; `v` busts caches when the document changes. */ +export function thumbnailUrl({ + elementId, + microversionId, + size, + configuration, + warm +}: ThumbnailUrlOptions): string { + const query = new URLSearchParams({ v: microversionId }); + if (configuration) { + query.set("c", configuration); + if (warm) { + query.set("warm", "1"); + } + } + return `/api/thumbnail/${size}/${elementId}?${query}`; +} + +/** The key identifying a configuration within an element's thumbnails. */ +export function thumbnailConfigurationKey(configuration?: string): string { + return configuration + ? configurationKeyFor(configuration) + : DEFAULT_CONFIGURATION_KEY; +} + +/** What identifies one configuration's thumbnails to render. */ +export interface ThumbnailParams { + elementId: string; + microversionId: string; + /** The encoded canonical configuration; never empty (defaults load eagerly). */ + configuration: string; +} + +/** + * The workflow instance id for a configuration's render. Deterministic so two + * requests for the same thumbnail collapse onto one run: Cloudflare rejects a + * duplicate instance id, which is the coalescing we want. + */ +export function thumbnailWorkflowId(params: ThumbnailParams): string { + const configurationKey = thumbnailConfigurationKey(params.configuration); + return `thumbnail-${params.elementId}-${params.microversionId}-${configurationKey}`; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index c4473f0a2..e882962a3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -72,11 +72,19 @@ export function getVendorName(vendor: Vendor) { return "West Coast Products"; } } +/** + * The two thumbnail sizes we generate and store, as the `WxH` Onshape wants. + * SMALL fills list rows; LARGE fills the hover card and the insert preview. + */ export enum ThumbnailSize { - STANDARD = "300x300", - LARGE = "600x340", - SMALL = "300x170", - TINY = "70x40" + SMALL = "70x40", + LARGE = "300x300" +} + +/** An element's two stored thumbnail URLs, produced (and stored) as a pair. */ +export interface ThumbnailUrls { + small: string; + large: string; } export enum Theme { SYSTEM = "system", @@ -103,9 +111,9 @@ export interface AccessData { signedIn: boolean; } -export interface ThumbnailUrls { - [ThumbnailSize.TINY]: string; - [ThumbnailSize.STANDARD]: string; +export interface ContextData { + accessData: AccessData; + settings: Settings; } export enum LibraryId { FRC_DESIGN_LIB = "frc-design-lib", diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 8e739c5cb..a9dc8a87f 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -25,6 +25,11 @@ interface __BaseEnv_Env { import("./src/backend/index").AddGroupWorkflow["run"] >[0]["payload"] >; + THUMBNAIL_WORKFLOW: Workflow< + Parameters< + import("./src/backend/index").ThumbnailWorkflow["run"] + >[0]["payload"] + >; } declare namespace Cloudflare { interface GlobalProps { @@ -54,6 +59,11 @@ declare namespace Cloudflare { import("./src/backend/index").AddGroupWorkflow["run"] >[0]["payload"] >; + THUMBNAIL_WORKFLOW: Workflow< + Parameters< + import("./src/backend/index").ThumbnailWorkflow["run"] + >[0]["payload"] + >; } interface ProductionEnv { KV: KVNamespace; @@ -79,6 +89,11 @@ declare namespace Cloudflare { import("./src/backend/index").AddGroupWorkflow["run"] >[0]["payload"] >; + THUMBNAIL_WORKFLOW: Workflow< + Parameters< + import("./src/backend/index").ThumbnailWorkflow["run"] + >[0]["payload"] + >; } interface Env extends __BaseEnv_Env {} } diff --git a/wrangler.jsonc b/wrangler.jsonc index 4f0d7765b..4f5fbbf89 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -66,6 +66,11 @@ "name": "add-group-workflow", "binding": "ADD_GROUP_WORKFLOW", "class_name": "AddGroupWorkflow" + }, + { + "name": "thumbnail-workflow", + "binding": "THUMBNAIL_WORKFLOW", + "class_name": "ThumbnailWorkflow" } ], /** @@ -126,6 +131,11 @@ "name": "add-group-workflow-cert", "binding": "ADD_GROUP_WORKFLOW", "class_name": "AddGroupWorkflow" + }, + { + "name": "thumbnail-workflow-cert", + "binding": "THUMBNAIL_WORKFLOW", + "class_name": "ThumbnailWorkflow" } ], "vars": { @@ -176,6 +186,11 @@ "name": "add-group-workflow-production", "binding": "ADD_GROUP_WORKFLOW", "class_name": "AddGroupWorkflow" + }, + { + "name": "thumbnail-workflow-production", + "binding": "THUMBNAIL_WORKFLOW", + "class_name": "ThumbnailWorkflow" } ], "vars": { From 8abb6389202f41ea5a5016b766b72c938bd7a1d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:50:25 +0000 Subject: [PATCH 06/23] Canonicalize thumbnail configurations everywhere; drop blob URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on review. Three things. Records now store their canonical configuration, computed at parse time where the parameters are already in hand. Previously each surface encoded its own configuration as-is, so a thumbnail warmed from the insert menu was not the one a search row asked for. `enumerateConfigurations` omits cosmetic, quantity, and string parameters while the insert menu holds the user's whole selection; canonicalizing both drops the values left at their default, and what remains differs only when the render genuinely does. The insert menu and favorite editor get the canonical form from ConfigurationWrapper, which is the only place with both the parameters and the document's units, and a favorite now stores it — Onshape applies defaults for whatever is omitted, so it inserts the same thing. `canonicalizeConfiguration` takes `unitInfo` optionally, since it is only needed to evaluate quantity expressions and enumerated records never carry them. Image fetches no longer mint object URLs. `handleImageResponse` created one per fetch and nothing ever revoked it, so every thumbnail — and every refetch of one — held a Blob until the page unloaded. The query now validates the URL and returns it, which both keeps the existing loading, error, and retry behavior and leaves the response in the browser cache for the `` that follows. Revoking was not an option: a cached query can share a URL across rows, so freeing it on one unmount could blank another. Also revert the REFERENCE.md edit; the R2 lifecycle note belongs in the PR. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- docs/REFERENCE.md | 27 +++------ .../parse/parse-configuration-records.ts | 20 +++++-- src/frontend/api-utils/api.ts | 41 +++++++------- src/frontend/favorites/favorite-menu.tsx | 15 +++-- src/frontend/insert/configurations.tsx | 28 +++++++++- src/frontend/insert/insert-menu.tsx | 7 ++- src/frontend/insert/thumbnail.tsx | 6 +- src/shared/configuration-utils.test.ts | 56 ++++++++++++++++++- src/shared/configuration-utils.ts | 10 +++- 9 files changed, 153 insertions(+), 57 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 3a1a6cc54..e36470bdb 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -43,18 +43,7 @@ KV serves as a cheap, lightweight way to persist user data across multiple Cloud 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. -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 render can require polling and take minutes. 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?v=&c=`. - -Two sizes are stored per configuration (`70x40` for list rows, `300x300` for the hover card and insert preview), always generated as a pair so a row and its hover never disagree. Keys are split by prefix: - -| Prefix | Holds | Lifecycle | -| --------------------- | ------------------------------------------------- | --------------------- | -| `thumbnails/default/` | Each element's default-configuration thumbnails | **Never expires** | -| `thumbnails/config/` | One entry per canonical configuration we rendered | Expire after ~90 days | - -**Operational requirement:** the `config/` prefix needs an R2 **lifecycle rule** to expire objects (R2 lifecycle rules are configured per bucket in the dashboard or via the API — they cannot be expressed in `wrangler.jsonc`). The `default/` prefix must be left alone: every configuration falls back to it while its own render is pending or after it expires. - -Configuration thumbnails are produced at runtime rather than indexed at load. A request for one that doesn't exist yet serves the default thumbnail with a short cache lifetime (so the real one can take over as soon as it lands), and — when the request asks to `warm` — starts a `ThumbnailWorkflow` to render it. The workflow's instance id is derived from the configuration, so concurrent requests for the same thumbnail collapse onto a single render. Cache keys use a _canonical_ configuration (`canonicalizeConfiguration` in `src/shared/configuration-utils.ts`), which drops hidden and default-valued parameters and normalizes quantity expressions, so equivalent selections share one stored object. +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`. ### Workflows — Document Sync (`c.env.LOAD_DOCUMENT_WORKFLOW`) @@ -105,13 +94,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 | Defaults indefinite; per-configuration ~90 days | 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** | 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 | — | — | ## Codebase Map diff --git a/src/backend/parse/parse-configuration-records.ts b/src/backend/parse/parse-configuration-records.ts index 9b4d8202b..32012d46a 100644 --- a/src/backend/parse/parse-configuration-records.ts +++ b/src/backend/parse/parse-configuration-records.ts @@ -28,6 +28,7 @@ import { AUTO_INDEX_THRESHOLD, enumerateConfigurations } from "../../shared/configuration-combinations"; +import { canonicalizeConfiguration } from "../../shared/configuration-utils"; import { getParts } from "../onshape-api/endpoints/parts"; import { getElementMetadata } from "../onshape-api/endpoints/metadata"; import type { @@ -262,7 +263,7 @@ export async function parseConfigurationRecords( ) ); } - return toResult(defaultRecord, batchRecords, capped); + return toResult(defaultRecord, batchRecords, capped, parameters); } /** @@ -313,7 +314,7 @@ export async function loadConfigurationRecords( ) ); } - return toResult(defaultRecord, batchRecords, capped); + return toResult(defaultRecord, batchRecords, capped, parameters); } /** @@ -396,9 +397,20 @@ async function fetchBatch( function toResult( defaultRecord: ConfigurationRecord, batches: ConfigurationRecord[][], - capped: boolean + capped: boolean, + parameters: ConfigurationParameter[] ): ConfigurationRecordsResult { - const records = [defaultRecord, ...batches.flat()]; + // Store the canonical configuration, so a record addresses the same thumbnail + // the insert menu does for the same selection. Enumeration already omits + // cosmetic, quantity, and string parameters, and canonicalizing drops the + // ones left at their default. + const records = [defaultRecord, ...batches.flat()].map((record) => ({ + ...record, + configuration: canonicalizeConfiguration( + record.configuration, + parameters + ) + })); let buildIssues: BuildIssue[] = []; if (capped) { diff --git a/src/frontend/api-utils/api.ts b/src/frontend/api-utils/api.ts index 3a018d09a..92bfdcaf0 100644 --- a/src/frontend/api-utils/api.ts +++ b/src/frontend/api-utils/api.ts @@ -71,34 +71,35 @@ export async function apiGetText( return response.text(); } -export async function apiGetRawImage( +/** + * Checks that an image URL resolves, and returns it to render. + * + * Fetching it here both surfaces failures as a rejected query (so callers keep + * their loading, error, and retry behavior) and puts the response in the browser + * cache, so the `` that follows is served from it. Returning the URL rather + * than an object URL matters: a blob URL lives until the page unloads, and + * nothing can safely revoke one that a cached query may still be sharing. + */ +export async function loadImage( url: string, signal?: AbortSignal ): Promise { - return fetch(url, { - signal - }).then(handleImageResponse); + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error("Network response failed."); + } + return url; } -/** - * Makes a get request for an image to a backend /api route. - * Returns a local url for the image. - */ -export async function apiGetImage( +/** {@link loadImage} for a backend /api route. */ +export async function loadApiImage( path: string, options?: QueryOptionsWithCacheId ): Promise { - return fetch(getUrl(path, options?.query, options?.cacheId), { - signal: options?.signal - }).then(handleImageResponse); -} - -async function handleImageResponse(response: Response) { - if (!response.ok) { - throw new Error("Network response failed."); - } - const blob = await response.blob(); - return URL.createObjectURL(blob); + return loadImage( + getUrl(path, options?.query, options?.cacheId), + options?.signal + ); } /** diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index e1f47cb50..28540c86e 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/favorites/favorite-menu.tsx @@ -8,12 +8,12 @@ import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../api-utils/api"; import { showErrorToast, showSuccessToast } from "../common/notifications"; import { PreviewImageCard } from "../insert/thumbnail"; -import { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; 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 { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; import { favoritesQueryKey, useFavoritesQuery, @@ -67,12 +67,18 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { const [configuration, setConfiguration] = useState< ParameterValues | undefined >(defaultConfiguration); + // Reported by ConfigurationWrapper; addresses this selection's thumbnail. + const [canonicalConfiguration, setCanonicalConfiguration] = + useState({}); const setDefaultConfigurationMutation = useMutation({ mutationKey: ["set-default-configuration"], mutationFn: async () => { + // Store the canonical form: Onshape applies defaults for whatever + // it omits, so it inserts the same thing, and it addresses the same + // thumbnail the favorites row asks for. return apiPost("/default-configuration/" + favoriteId, { - body: { defaultConfiguration: configuration } + body: { defaultConfiguration: canonicalConfiguration } }); }, onMutate: async () => { @@ -82,7 +88,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; }) ); @@ -124,10 +130,11 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { configuration={configuration} microversionId={insertable.microversionId} canonicalConfiguration={encodeCanonicalConfiguration( - configuration ?? {} + canonicalConfiguration )} /> ; + /** + * Reports the selection's canonical form, which addresses its thumbnail and + * is what a favorite stores. Only this component has the parameters and + * units needed to compute it. + */ + onCanonicalConfiguration?: (canonical: ParameterValues) => void; } export function ConfigurationWrapper(props: ConfigurationWrapperProps) { - const { configurationId, microversionId, configuration, setConfiguration } = - props; + const { + configurationId, + microversionId, + configuration, + setConfiguration, + onCanonicalConfiguration + } = props; const query = useQuery({ queryKey: getConfigurationKey(configurationId, microversionId), @@ -95,6 +107,18 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { setConfiguration(defaultConfiguration); }, [query.data, configuration, setConfiguration]); + const parameters = query.data?.parameters; + // Reruns when the document's units arrive, so an early canonical form + // computed against the fallback units is replaced rather than kept. + useEffect(() => { + if (!parameters || !configuration) { + return; + } + onCanonicalConfiguration?.( + canonicalizeConfiguration(configuration, parameters, unitInfo) + ); + }, [parameters, unitInfo, configuration, onCanonicalConfiguration]); + if (query.isPending || !configuration) { return (
diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index e249b0218..d692d8858 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -73,6 +73,10 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { const [configuration, setConfiguration] = useState< ParameterValues | undefined >(props.defaultConfiguration); + // Reported by ConfigurationWrapper, which has the parameters and units the + // canonical form needs. Empty means the element's default configuration. + const [canonicalConfiguration, setCanonicalConfiguration] = + useState({}); useEffect(() => { if (!isSignedIn) { @@ -94,6 +98,7 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { microversionId={insertable.microversionId} configuration={configuration} setConfiguration={setConfiguration} + onCanonicalConfiguration={setCanonicalConfiguration} /> ); } @@ -107,7 +112,7 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { thumbnailUrls={insertable.thumbnailUrls} microversionId={insertable.microversionId} canonicalConfiguration={encodeCanonicalConfiguration( - configuration ?? {} + canonicalConfiguration )} /> {parameters} diff --git a/src/frontend/insert/thumbnail.tsx b/src/frontend/insert/thumbnail.tsx index f2e672280..1b4fe9ddf 100644 --- a/src/frontend/insert/thumbnail.tsx +++ b/src/frontend/insert/thumbnail.tsx @@ -1,5 +1,5 @@ import { useIsFetching, useQuery } from "@tanstack/react-query"; -import { apiGet, apiGetImage, apiGetRawImage } from "../api-utils/api"; +import { apiGet, loadApiImage, loadImage } from "../api-utils/api"; import { ThumbnailUrls, ThumbnailSize, ElementType } from "../../shared/types"; import { ElementPath, toElementApiPath } from "../../shared/onshape-path"; import { Box, Card, Center, HoverCard, Loader } from "@mantine/core"; @@ -113,7 +113,7 @@ function Thumbnail(props: ThumbnailProps): ReactNode { if (url === undefined) { throw new Error("Tried to get thumbnail with no URL"); } - return apiGetRawImage(url, signal); + return loadImage(url, signal); }, retry: 1, enabled: url !== undefined @@ -203,7 +203,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { // Shouldn't happen due to enabled guard return; } - return apiGetImage("/thumbnail", { + return loadApiImage("/thumbnail", { query: { size, thumbnailId, diff --git a/src/shared/configuration-utils.test.ts b/src/shared/configuration-utils.test.ts index 989c3c1a9..628819aef 100644 --- a/src/shared/configuration-utils.test.ts +++ b/src/shared/configuration-utils.test.ts @@ -6,7 +6,11 @@ import { encodeCanonicalConfiguration, findRecordForConfiguration } from "./configuration-utils"; -import { SearchRecord, VisibilityType } from "./configuration-models"; +import { + ParameterValues, + SearchRecord, + VisibilityType +} from "./configuration-models"; import { boolParam, enumParam, @@ -124,6 +128,56 @@ describe("canonicalizeConfiguration", () => { }); }); +// The two sides that address a thumbnail derive their configuration differently: +// an indexed record comes from `enumerateConfigurations` (visible, non-cosmetic +// enum/boolean values only, defaults included), while the insert menu holds the +// user's whole selection. Canonicalizing both is what makes them agree. +describe("canonical keys agree across surfaces", () => { + const size = enumParam("size", ["s", "l"]); + const flag = boolParam("flag"); + const finish = enumParam("finish", ["matte", "gloss"], { + isCosmetic: true + }); + const length = quantityParam("length"); + const parameters = [size, flag, finish, length]; + + it("keys an enumerated record and the equivalent selection alike", () => { + // What indexing stores: enumerated values, no cosmetic/quantity params. + const record = canonicalizeConfiguration( + { size: "l", flag: "false" }, + parameters + ); + // What the insert menu holds: everything, including the defaults. + const selection = canonicalizeConfiguration( + { size: "l", flag: "false", finish: "matte", length: "1 in" }, + parameters, + TEST_UNIT_INFO + ); + expect(configurationKey(record)).toBe(configurationKey(selection)); + }); + + it("keys a non-default cosmetic or quantity value differently", () => { + // Enumeration never varies these, but they do change what renders, so + // the selection must not collide with the enumerated record. + const record = canonicalizeConfiguration({ size: "l" }, parameters); + const selections: ParameterValues[] = [ + { size: "l", finish: "gloss" }, + { size: "l", length: "2 in" } + ]; + for (const selection of selections) { + expect( + configurationKey( + canonicalizeConfiguration( + selection, + parameters, + TEST_UNIT_INFO + ) + ) + ).not.toBe(configurationKey(record)); + } + }); +}); + describe("configurationKey", () => { it("maps an empty configuration to the default key", () => { expect(configurationKey({})).toBe(DEFAULT_CONFIGURATION_KEY); diff --git a/src/shared/configuration-utils.ts b/src/shared/configuration-utils.ts index 92e7f38ca..626ceb767 100644 --- a/src/shared/configuration-utils.ts +++ b/src/shared/configuration-utils.ts @@ -200,9 +200,9 @@ export function getEvaluateOptions( function canonicalizeValue( parameter: ConfigurationParameter, value: string, - unitInfo: UnitInfo + unitInfo?: UnitInfo ): string { - if (parameter.type === ParameterType.QUANTITY) { + if (parameter.type === ParameterType.QUANTITY && unitInfo) { // "1in", "1 in", and "(0.5 + 0.5) in" are the same configuration; the // evaluated, rounded display form is the one spelling of it. An // unparseable value can't be normalized, so it rides as-is. @@ -228,11 +228,15 @@ function canonicalizeValue( * visibility condition are dropped, as are values matching the parameter's * default — Onshape applies the default for anything omitted, so an * all-defaults selection canonicalizes to `{}`, which is the default thumbnail. + * + * `unitInfo` is only needed to evaluate quantity expressions; omit it where the + * configuration can't contain them (enumerated records hold enum and boolean + * values only), and those values ride through trimmed. */ export function canonicalizeConfiguration( configuration: ParameterValues, parameters: ConfigurationParameter[], - unitInfo: UnitInfo + unitInfo?: UnitInfo ): ParameterValues { const canonical: ParameterValues = {}; for (const parameter of parameters) { From 6971f8a7e5eb00f12befb46004a7efae3ff424e0 Mon Sep 17 00:00:00 2001 From: Alex Kempen Date: Thu, 13 Aug 2026 21:14:22 -0500 Subject: [PATCH 07/23] Continue fixing bugs --- src/backend/library-data.ts | 22 +-- src/backend/load/job-tracker.test.ts | 51 ++++- src/backend/load/job-tracker.ts | 35 +++- src/backend/load/load-insertable.ts | 24 +-- src/backend/load/load-steps.test.ts | 73 +++++++ src/backend/load/load-steps.ts | 39 +++- .../parse/parse-configuration-records.test.ts | 49 +++-- .../parse/parse-configuration-records.ts | 53 +++-- src/backend/routes/build-status.test.ts | 21 +- src/backend/routes/build-status.ts | 9 +- src/backend/routes/groups.test.ts | 27 +-- src/backend/routes/groups.ts | 16 +- src/backend/routes/insertables.ts | 24 +-- src/backend/routes/library.test.ts | 18 +- src/backend/routes/library.ts | 6 +- src/frontend/cards/build-status.tsx | 183 +++++++++++++++--- src/frontend/cards/card-components.tsx | 4 +- src/frontend/groups/add-group-menu.tsx | 8 +- src/frontend/queries.ts | 58 +++++- .../settings/reload-groups-button.tsx | 12 +- src/shared/api-models.ts | 16 ++ src/shared/build-issues.ts | 8 +- src/shared/configuration-combinations.test.ts | 90 ++++++++- src/shared/configuration-combinations.ts | 76 +++++++- 24 files changed, 732 insertions(+), 190 deletions(-) create mode 100644 src/backend/load/load-steps.test.ts diff --git a/src/backend/library-data.ts b/src/backend/library-data.ts index 0d59dc37a..cd7cb907a 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -173,17 +173,9 @@ export function searchIndexKey(libraryId: LibraryId): string { return `search-index/${libraryId}.json`; } -/** Gzips a string to bytes (R2 stores it pre-compressed; the browser inflates). */ -async function gzip(text: string): Promise { - const compressed = new Response(text).body!.pipeThrough( - new CompressionStream("gzip") - ); - return new Response(compressed).arrayBuffer(); -} - /** * Rebuilds the serialized MiniSearch index for a library from its current - * groups/insertables and stores it, gzipped, in R2. Bump `cacheVersion` + * groups/insertables and stores it in R2 as plain JSON. Bump `cacheVersion` * alongside so clients refetch under a new URL. */ export async function rebuildSearchDb( @@ -197,16 +189,12 @@ export async function rebuildSearchDb( getRecordsMap(db, libraryId) ]); const searchDb = JSON.stringify(buildSearchDb(libraryData, recordsMap)); - const compressed = await gzip(searchDb); - await bucket.put(searchIndexKey(libraryId), compressed, { - httpMetadata: { - contentType: "application/json", - contentEncoding: "gzip" - } + await bucket.put(searchIndexKey(libraryId), searchDb, { + httpMetadata: { contentType: "application/json" } }); console.log( - `Rebuilt search index for ${libraryId}: ${searchDb.length} B json, ` + - `${compressed.byteLength} B gzip, ${Date.now() - start} ms` + `Rebuilt search index for ${libraryId}: ` + + `${searchDb.length} B, ${Date.now() - start} ms` ); return searchDb; } diff --git a/src/backend/load/job-tracker.test.ts b/src/backend/load/job-tracker.test.ts index 714b2c894..01e2584eb 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,30 @@ 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); + expect(status.runningForMs).toBeGreaterThanOrEqual(30_000); + expect(status.runningForMs).toBeLessThan(40_000); + }); + + it("reports no age when a job predates start-time tracking", async () => { + mockJobs([{ id: "r1", kind: "reload" }], "running"); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + running: true + }); }); it("appends a tracked job under the library key with a TTL", async () => { @@ -70,9 +100,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..9912c4b1c 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,11 @@ export type JobKind = "reload" | "add-group"; interface TrackedJob { id: string; kind: JobKind; + /** + * Epoch ms the job was created. Absent on entries written before this was + * tracked, which are by definition old. + */ + startedAt?: number; } function jobsKey(libraryId: LibraryId): string { @@ -74,12 +80,31 @@ 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( +/** + * Whether any load job (reload or add-group) is running for this library, and + * how long the oldest one has been going — clients use the age to decide how + * often to check back. + */ +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 startTimes = jobs + .map((job) => job.startedAt) + .filter((startedAt): startedAt is number => startedAt !== undefined); + // An untimed entry predates start-time tracking, so its true age is unknown + // and certainly not recent; report no age and let the client poll slowly. + if (startTimes.length < jobs.length) { + return { running: true }; + } + return { + running: true, + runningForMs: Date.now() - Math.min(...startTimes) + }; } /** Records a newly-created job, pruning any that have since finished. */ @@ -90,7 +115,7 @@ 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 }); diff --git a/src/backend/load/load-insertable.ts b/src/backend/load/load-insertable.ts index 61dc5bdba..17e997e60 100644 --- a/src/backend/load/load-insertable.ts +++ b/src/backend/load/load-insertable.ts @@ -4,11 +4,7 @@ import type { Configuration, ConfigurationParameter } from "../../shared/configuration-models"; -import { - addBuildIssue, - type BuildIssue, - BuildIssueType -} from "../../shared/build-issues"; +import { addBuildIssue, type BuildIssue } from "../../shared/build-issues"; import { ElementType, type FastenInfo, @@ -79,13 +75,9 @@ export async function loadInsertable( const isOpenComposite = await computeOpenCompositeStep(ctx, target); - const { shouldIndex, manyConfigurations } = decideIndexing( - vendors, - parameters, - flags.forceIndex - ); + const indexing = decideIndexing(vendors, parameters, flags.forceIndex); - const recordsResult = shouldIndex + const recordsResult = indexing.shouldIndex ? await loadConfigurationRecords( ctx, insertableId, @@ -108,15 +100,11 @@ export async function loadInsertable( ) ); - let buildIssues = addBuildIssue( + const buildIssues = addBuildIssue( checkInsertable({ vendors, thumbnailUrls }), - ...recordsResult.buildIssues + ...recordsResult.buildIssues, + ...indexing.buildIssues ); - if (manyConfigurations) { - buildIssues = addBuildIssue(buildIssues, { - type: BuildIssueType.MANY_CONFIGURATIONS - }); - } const parsed: ParsedInsertable = { vendors, diff --git a/src/backend/load/load-steps.test.ts b/src/backend/load/load-steps.test.ts new file mode 100644 index 000000000..c5e3b5c1e --- /dev/null +++ b/src/backend/load/load-steps.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; +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. Doubling + // from four seconds keeps the common case — a render that finishes in a few + // seconds — from sitting unnoticed behind a long first wait. + 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("stops doubling at fifteen minutes", () => { + expect(thumbnailDelay(8)).toEqual("512 seconds"); + expect(thumbnailDelay(9)).toEqual("900 seconds"); + expect(thumbnailDelay(50)).toEqual("900 seconds"); + }); + + // The waits sum to a little over half an hour, which is how long a render + // gets before it's recorded as a build issue instead. + it("polls for about half an hour 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(1920); + }); + + // 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 870051b6d..67a72ca42 100644 --- a/src/backend/load/load-steps.ts +++ b/src/backend/load/load-steps.ts @@ -36,15 +36,42 @@ 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; + +/** The ceiling the doubling stops at. */ +const THUMBNAIL_MAX_DELAY_SECONDS = 15 * 60; + /** - * Retries for a step waiting on an Onshape render: wait out a rate limit, - * otherwise poll, since Onshape renders thumbnails asynchronously. + * Retry delay for a step waiting on an Onshape render. Onshape renders + * thumbnails asynchronously and gives no signal when one lands, so the step + * polls: a plain doubling from four seconds up to a fifteen-minute ceiling. + * + * Starting tight is the point — most renders land within seconds, and a long + * first wait leaves them sitting finished but unnoticed. + * + * A rate limit still overrides the curve. Onshape says how long to wait, and + * asking again sooner only earns another 429. */ +function thumbnailRetryDelay(input: RetryDelayInput): `${number} seconds` { + const rateLimited = rateLimitDelay(input.error); + if (rateLimited) { + return rateLimited; + } + const seconds = Math.min( + THUMBNAIL_BASE_DELAY_SECONDS * 2 ** (input.ctx.attempt - 1), + THUMBNAIL_MAX_DELAY_SECONDS + ); + return `${seconds} seconds`; +} + export const THUMBNAIL_STEP_RETRIES = { - limit: 3, - delay: (retry: RetryDelayInput) => - rateLimitDelay(retry.error) ?? - (retry.ctx.attempt === 1 ? "10 seconds" : "5 minutes") + // 10 attempts: waits of 4s, 8s, 16s … 512s, then the 15 minute ceiling, for + // roughly half an hour of polling before a render is given up on. Chosen to + // hold that window steady against the starting delay: a longer first wait + // covers the same span in fewer attempts. + limit: 10, + delay: thumbnailRetryDelay }; /** diff --git a/src/backend/parse/parse-configuration-records.test.ts b/src/backend/parse/parse-configuration-records.test.ts index 777692b33..247ed789d 100644 --- a/src/backend/parse/parse-configuration-records.test.ts +++ b/src/backend/parse/parse-configuration-records.test.ts @@ -43,56 +43,71 @@ function paramsWithConfigs(count: number): ConfigurationParameter[] { ]; } +const MANY = [{ type: BuildIssueType.MANY_CONFIGURATIONS }]; +const TOO_MANY = [{ type: BuildIssueType.TOO_MANY_CONFIGURATIONS }]; + describe("decideIndexing", () => { it.each([ // A vendor part below the auto line indexes on its own. { vendors: [Vendor.AM], - configs: 99, + configs: 127, force: false, index: true, - many: false + issues: [] }, - // At the line it waits, and is flagged so an admin can trim or force it. + // At the line it waits, flagged so an admin can trim or enable it. { vendors: [Vendor.AM], - configs: 100, + configs: 128, force: false, index: false, - many: true + issues: MANY }, - // Force overrides the count, and clears the flag. + // Enabling it overrides the count, and clears the flag. { vendors: [Vendor.AM], - configs: 100, + configs: 128, force: true, index: true, - many: false + issues: [] }, - // Past the hard cap behaves like any over-threshold vendor part. + // Past the hard cap there is nothing to enumerate, so enabling it can't + // help — it stays unindexed and flagged either way. { vendors: [Vendor.AM], configs: 600, force: false, index: false, - many: true + issues: TOO_MANY }, { vendors: [Vendor.AM], configs: 600, force: true, - index: true, - many: false + index: false, + issues: TOO_MANY }, - // No vendor: never auto-eligible, never flagged, but still forceable. - { vendors: [], configs: 50, force: false, index: false, many: false }, - { vendors: [], configs: 50, force: true, index: true, many: false } + // No vendor: never auto-eligible, never flagged, but still enableable. + { vendors: [], configs: 50, force: false, index: false, issues: [] }, + { vendors: [], configs: 50, force: true, index: true, issues: [] }, + // Nor flagged for a count it was never going to index against... + { vendors: [], configs: 200, force: false, index: false, issues: [] }, + { vendors: [], configs: 600, force: false, index: false, issues: [] }, + // ...unless an admin enabled it and is owed the reason it did nothing. + { + vendors: [], + configs: 600, + force: true, + index: false, + issues: TOO_MANY + } ])( "vendors=$vendors configs=$configs force=$force", - ({ vendors, configs, force, index, many }) => { + ({ vendors, configs, force, index, issues }) => { expect( decideIndexing(vendors, paramsWithConfigs(configs), force) - ).toEqual({ shouldIndex: index, manyConfigurations: many }); + ).toEqual({ shouldIndex: index, buildIssues: issues }); } ); }); diff --git a/src/backend/parse/parse-configuration-records.ts b/src/backend/parse/parse-configuration-records.ts index 32012d46a..913f21ed7 100644 --- a/src/backend/parse/parse-configuration-records.ts +++ b/src/backend/parse/parse-configuration-records.ts @@ -25,8 +25,10 @@ import { BuildIssueType } from "../../shared/build-issues"; import { - AUTO_INDEX_THRESHOLD, - enumerateConfigurations + countConfigurations, + enumerateConfigurations, + IndexingBand, + isIndexingEnabled } from "../../shared/configuration-combinations"; import { canonicalizeConfiguration } from "../../shared/configuration-utils"; import { getParts } from "../onshape-api/endpoints/parts"; @@ -69,32 +71,47 @@ export const INDEXING_ISSUE_TYPES = [ /** Whether to index an insertable, and how to flag it if we don't. */ export interface IndexingDecision { - /** Index when forced, or a vendor part below the auto threshold. */ + /** Index when enabled by an admin, or a vendor part under the auto threshold. */ shouldIndex: boolean; - /** Vendor part over the threshold and not forced: warrants a warning. */ - manyConfigurations: boolean; + /** The limit issues this decision raises, if any. */ + buildIssues: BuildIssue[]; } /** - * Decides whether an insertable's part numbers get indexed. A vendor part with - * few enough combinations indexes on its own; anything else waits for the force - * flag, and a vendor part held back only by its count is flagged so an admin can - * trim it or force it. + * Decides whether an insertable's part numbers get indexed, by which limit its + * configuration count falls under. A vendor part with few enough combinations + * indexes on its own; past the auto threshold indexing waits to be enabled by + * hand; past the hard cap there is nothing to index at all, since enumeration + * stops there — so forcing it on cannot help. + * + * Only an indexing candidate is flagged: a vendor part, or one an admin has + * explicitly enabled and is owed an explanation for. */ export function decideIndexing( vendors: Vendor[], parameters: ConfigurationParameter[], forceIndex: boolean ): IndexingDecision { - const { configurations, capped } = enumerateConfigurations(parameters); - const overThreshold = - capped || configurations.length >= AUTO_INDEX_THRESHOLD; - const autoEligible = vendors.length > 0 && !overThreshold; - const shouldIndex = forceIndex || autoEligible; - return { - shouldIndex, - manyConfigurations: vendors.length > 0 && overThreshold && !shouldIndex - }; + const { band } = countConfigurations(parameters); + const isVendorPart = vendors.length > 0; + const isCandidate = isVendorPart || forceIndex; + const shouldIndex = isIndexingEnabled(isVendorPart, band, forceIndex); + + if (band === IndexingBand.EXCEEDED) { + return { + shouldIndex, + buildIssues: isCandidate + ? [{ type: BuildIssueType.TOO_MANY_CONFIGURATIONS }] + : [] + }; + } + if (band === IndexingBand.MANUAL && isVendorPart && !forceIndex) { + return { + shouldIndex, + buildIssues: [{ type: BuildIssueType.MANY_CONFIGURATIONS }] + }; + } + return { shouldIndex, buildIssues: [] }; } /** Trims a raw metadata value; a missing or blank one becomes `null`. */ diff --git a/src/backend/routes/build-status.test.ts b/src/backend/routes/build-status.test.ts index 68ad2505a..c1e6d77c0 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, @@ -11,6 +11,7 @@ import { seedPartStudio } from "../../__test_utils__"; import { getDb } from "../db"; +import * as JobTracker from "../load/job-tracker"; import type { LibraryBuildStatus } from "../../shared/api-models"; const db = getDb(env.DB); @@ -20,6 +21,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 +75,21 @@ describe("GET /build-status", () => { const body: LibraryBuildStatus = await res.json(); expect(body.groups[TEST_GROUP_ID].lastLoadedAt).toBeNull(); }); + + // Clients start their job-status poll off this flag, so an idle library + // never polls at all. + it.each([true, false])("reports jobRunning=%s", async (running) => { + await seedPartStudio(db); + vi.spyOn(JobTracker, "getJobStatus").mockResolvedValue({ running }); + + const res = await createTestApp().request( + `/api/build-status/library/${TEST_LIBRARY_ID}`, + { method: "GET" }, + env + ); + expect(res.status).toBe(200); + + const body: LibraryBuildStatus = await res.json(); + expect(body.jobRunning).toBe(running); + }); }); diff --git a/src/backend/routes/build-status.ts b/src/backend/routes/build-status.ts index aae65deb8..ee1db80df 100644 --- a/src/backend/routes/build-status.ts +++ b/src/backend/routes/build-status.ts @@ -9,6 +9,7 @@ import { import { getDb } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; import { group, insertables, configurations } from "../../shared/schema"; +import { getJobStatus } from "../load/job-tracker"; import { type LibraryBuildStatus, type GroupBuildStatus, @@ -26,7 +27,10 @@ buildStatusRoutes.get( const libraryId = getLibraryParam(c); const db = getDb(c.env.DB); - const [allGroups, allInsertables] = await Promise.all([ + const [jobStatus, allGroups, allInsertables] = await Promise.all([ + // Piggybacked so an idle library never needs a job-status poll: this + // is what tells a loading client whether there's a job to watch. + getJobStatus(c.env, libraryId), db .select({ id: group.id, @@ -106,7 +110,8 @@ buildStatusRoutes.get( return c.json({ groups: groupsOut, - insertables: insertablesOut + insertables: insertablesOut, + jobRunning: jobStatus.running } satisfies LibraryBuildStatus); } ); diff --git a/src/backend/routes/groups.test.ts b/src/backend/routes/groups.test.ts index 557c2fd76..baaa94cf7 100644 --- a/src/backend/routes/groups.test.ts +++ b/src/backend/routes/groups.test.ts @@ -193,19 +193,22 @@ 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}`, - sessionRequest("GET"), - env - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ running }); - // Polled for live state, so it must never be served from a cache. - expect(res.headers.get("Cache-Control")).toBe("private, no-store"); - }); + const res = await createTestApp().request( + `/api/job-status/library/${TEST_LIBRARY_ID}`, + sessionRequest("GET"), + env + ); + expect(res.status).toBe(200); + 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"); + } + ); }); describe("POST /group", () => { diff --git a/src/backend/routes/groups.ts b/src/backend/routes/groups.ts index c8f957d42..e460a2f1f 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,14 +51,18 @@ groupRoutes.post( } ); -/** GET /api/job-status/library/:libraryId */ +/** + * GET /api/job-status/library/:libraryId + * + * Only polled while a job is known to be running — the build status is what + * tells a freshly loaded client there's something to watch. + */ 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))); } ); diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index 71f0c01a2..dc3128b78 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -35,11 +35,7 @@ import { 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, - BuildIssueType -} from "../../shared/build-issues"; +import { addBuildIssue, clearBuildIssue } from "../../shared/build-issues"; export const insertableRoutes = getApp(); @@ -138,15 +134,11 @@ insertableRoutes.post( .get() )?.parameters ?? []; const vendors = parseVendors(row.name, parameters); - const { shouldIndex, manyConfigurations } = decideIndexing( - vendors, - parameters, - body.forceIndex - ); + const indexing = decideIndexing(vendors, parameters, body.forceIndex); // Index before committing anything: if this throws, nothing is written. // The error reaches the client via the app's onError handler. - const indexed = shouldIndex + const indexed = indexing.shouldIndex ? await indexRecords(await c.var.getOnshapeApi(), { documentId: row.documentId, versionId: row.versionId, @@ -159,15 +151,11 @@ insertableRoutes.post( // Clear first, so an issue the reindex resolved (or that disabling makes // moot) doesn't stick around. - let buildIssues = addBuildIssue( + const buildIssues = addBuildIssue( clearBuildIssue(row.buildIssues, ...INDEXING_ISSUE_TYPES), - ...indexed.buildIssues + ...indexed.buildIssues, + ...indexing.buildIssues ); - if (manyConfigurations) { - buildIssues = addBuildIssue(buildIssues, { - type: BuildIssueType.MANY_CONFIGURATIONS - }); - } // Keep a configurations row while there's parameters or records to hold; // a non-configurable insertable that stops indexing loses its row. diff --git a/src/backend/routes/library.test.ts b/src/backend/routes/library.test.ts index ebfbdfcd5..ad5c02689 100644 --- a/src/backend/routes/library.test.ts +++ b/src/backend/routes/library.test.ts @@ -18,13 +18,6 @@ import { LibraryId } from "../../shared/types"; const db = getDb(env.DB); -/** Inflates a gzip stream to text (the browser does this transparently). */ -async function inflate(body: ReadableStream): Promise { - return new Response( - body.pipeThrough(new DecompressionStream("gzip")) - ).text(); -} - describe("library routes", () => { beforeEach(async () => { await resetDb(db); @@ -53,7 +46,7 @@ describe("library routes", () => { ); }); - it("GET /search-db streams the library's gzipped index from R2", async () => { + 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.SEARCH_INDEX, db, TEST_LIBRARY_ID); @@ -65,10 +58,15 @@ describe("library routes", () => { env ); expect(res.status).toBe(200); - expect(res.headers.get("Content-Encoding")).toBe("gzip"); + // Serving a pre-compressed body under a hand-set Content-Encoding gets + // it compressed a second time by the runtime, leaving the client with a + // gzip stream after it inflates once. Wire compression is the + // platform's job, so this response must claim no encoding of its own. + 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 = JSON.parse(await inflate(res.body!)); + const parsed: { documentCount: number } = await res.json(); expect(parsed.documentCount).toBeGreaterThan(0); }); diff --git a/src/backend/routes/library.ts b/src/backend/routes/library.ts index 6683f5c0c..88d6f45f3 100644 --- a/src/backend/routes/library.ts +++ b/src/backend/routes/library.ts @@ -42,7 +42,11 @@ libraryRoutes.get( /** * GET /api/search-db/library/:libraryId?v=:cacheVersion * - * Streams the library's serialized MiniSearch index from R2. + * Streams the library's serialized MiniSearch index from R2 as plain JSON. + * + * Never serve this pre-compressed with a hand-set `Content-Encoding`: the + * runtime treats any body it did not encode itself as identity and compresses + * it again, so the client inflates once and is left holding a gzip stream. */ libraryRoutes.get( "/search-db" + libraryRoute(), diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index 53ca8c11e..aa01ce29f 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/cards/build-status.tsx @@ -46,6 +46,13 @@ import { ConfigurationParameter, ParameterType } from "../../shared/configuration-models"; +import { + AUTO_INDEX_THRESHOLD, + type ConfigurationCount, + countConfigurations, + IndexingBand, + 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"; @@ -63,6 +70,7 @@ import { */ export type StateRowValue = | { kind: "bool"; value: boolean } + | { kind: "text"; text: string; dimmed?: boolean } | { kind: "vendors"; vendors: Vendor[] }; /** @@ -539,12 +547,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 ( @@ -554,16 +564,34 @@ 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, @@ -583,10 +611,7 @@ function InsertableAdminSection({ insertableId={insertableId} supportsFasten={status.supportsFasten} /> - + ); } @@ -626,24 +651,87 @@ function FastenSwitch({ ); } -function PartNumberSwitch({ +/** + * The indexing control: a switch only where turning indexing on is the admin's + * call to make, and an icon saying why not otherwise. + * + * Indexing needs a vendor to attribute parts to and few enough configurations + * to enumerate. Past the hard cap it can't run at all, and under the auto + * threshold a vendor insertable already indexes on load, leaving nothing to + * decide. Only the band in between is a choice. + */ +function IndexingRow({ insertableId, - forceIndex + status }: { insertableId: string; - forceIndex: boolean; + status: InsertableBuildStatus; }): ReactNode { + const { band } = useConfigurationCount(status); const mutation = useTogglePartNumberSearchMutation(insertableId); + + let control: ReactNode; + if (band === IndexingBand.EXCEEDED) { + control = ( + + ); + } else if (status.vendors.length === 0) { + control = ( + + ); + } else if (band === IndexingBand.AUTOMATIC) { + control = ( + + ); + } else { + control = ( + mutation.mutate(!status.forceIndex)} + withThumbIndicator={false} + /> + ); + } + return ( - mutation.mutate(!forceIndex)} + ); } +/** + * Stands in for the indexing switch where there is nothing to toggle. Reuses the + * build-check severity icons, so the state reads the same as the callouts above + * it — a green check when indexing is already on, otherwise the severity of what + * is holding it back. + */ +function IndexingIcon({ + severity, + tooltip +}: { + severity: BuildIssueSeverity | null; + tooltip: string; +}): ReactNode { + return ( + + + + ); +} + /** The editable admin toggles for a group. */ function GroupAdminSection({ groupId, @@ -666,12 +754,44 @@ function GroupAdminSection({ ); } +/** + * An insertable's configuration count and which indexing limit it falls under. + * Enumerated here rather than stored: it's the same shared routine the load path + * uses, capped at {@link MAX_PART_NUMBER_CONFIGURATIONS}, and only runs when a + * hover card opens. + */ +function useConfigurationCount( + status: InsertableBuildStatus +): ConfigurationCount { + const parameters = status.configuration?.parameters; + return useMemo(() => countConfigurations(parameters ?? []), [parameters]); +} + +/** + * Renders a configuration count: "None" for a non-configurable insertable, + * matching how the vendors row reads when there are none, and an open-ended + * label 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: InsertableBuildStatus; }): ReactNode { + const { count } = useConfigurationCount(status); return ( <> @@ -682,15 +802,8 @@ function InsertableParsedSection({ value={{ kind: "vendors", vendors: status.vendors }} /> 0 - }} + label="Configurations" + value={configurationCountValue(count)} /> @@ -788,6 +901,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 a338bb362..068ed2959 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -172,7 +172,9 @@ export function CardTitle(props: CardTitleProps) { largeThumbnailUrl={largeThumbnailUrl} target={thumbnailTarget} /> - + {/* Shrinks to truncate, but never grows: the hidden tag and build + status badge belong beside the name, not at the row's edge. */} + {cardTitle} diff --git a/src/frontend/groups/add-group-menu.tsx b/src/frontend/groups/add-group-menu.tsx index 259d5252c..3de8a7939 100644 --- a/src/frontend/groups/add-group-menu.tsx +++ b/src/frontend/groups/add-group-menu.tsx @@ -11,6 +11,7 @@ import { showInfoToast, showLoadingToast } from "../common/notifications"; import { queryClient } from "../query-client"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; import { jobStatusQueryKey } from "../queries"; +import { type JobStatus } from "../../shared/api-models"; function openAddGroupMenu(selectedGroupId?: string) { modals.open({ @@ -48,8 +49,11 @@ function AddGroupMenuContent(props: AddGroupMenuContentProps): ReactNode { ), onSuccess: () => { showInfoToast("Adding document...", "add-group"); - void queryClient.invalidateQueries({ - queryKey: jobStatusQueryKey(libraryId) + // Starts the job poll, which stays idle until something is known to + // be running, and shows the spinner without waiting for a request. + queryClient.setQueryData(jobStatusQueryKey(libraryId), { + running: true, + runningForMs: 0 }); } }); diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts index cf5d56f84..8090024de 100644 --- a/src/frontend/queries.ts +++ b/src/frontend/queries.ts @@ -9,6 +9,7 @@ import { import { apiGet, apiGetText } from "./api-utils/api"; import { type FavoritesData, + type JobStatus, type LibraryBuildStatus, type LibraryOut } from "../shared/api-models"; @@ -205,13 +206,56 @@ export function jobStatusQueryKey(libraryId: LibraryId) { return ["job-status", libraryId]; } -/** Whether a library-load job is running; polled so indicators stay live. */ -export function getJobStatusQuery(libraryId: LibraryId, enabled = true) { - return queryOptions<{ running: boolean }>({ +/** + * How often to re-check a running job, by how long it has been running. A + * just-started job is checked often so the UI reacts promptly, then the cadence + * backs off — a full reload runs for hours and doesn't warrant a request every + * few seconds for all of it. + */ +const FASTEST_POLL_MS = 3_000; +const POLL_STEPS = [ + { untilMs: 15_000, intervalMs: FASTEST_POLL_MS }, + { untilMs: 75_000, intervalMs: 5_000 } +]; +const SLOWEST_POLL_MS = 10_000; + +function jobPollInterval(runningForMs: number): number { + const step = POLL_STEPS.find(({ untilMs }) => runningForMs < untilMs); + return step?.intervalMs ?? SLOWEST_POLL_MS; +} + +/** + * Whether a library-load job is running, polled so indicators stay live. + * + * An idle library isn't polled at all. Polling starts when there's known to be + * something to watch — either the build status reported a job already running + * when the app loaded (`jobRunningAtLoad`), or starting one seeded `running` + * here directly — and stops again as soon as a check comes back not-running. + * `canPoll` is the caller's own gate: the route is editor-only. + */ +export function getJobStatusQuery( + libraryId: LibraryId, + jobRunningAtLoad: boolean, + canPoll: boolean +) { + return queryOptions({ queryKey: jobStatusQueryKey(libraryId), queryFn: () => apiGet("/job-status/library/" + libraryId), - refetchInterval: 10_000, - enabled + enabled: (query) => + canPoll && + (jobRunningAtLoad || (query.state.data?.running ?? false)), + // Every status badge observes this query, so rows mounting as the user + // scrolls would otherwise each trigger a fetch. The poll is the only + // thing that should set the pace. + staleTime: FASTEST_POLL_MS, + refetchInterval: (query) => { + const status = query.state.data; + if (!status?.running) { + return false; + } + // An unknown age means the job predates age tracking, so it is old. + return jobPollInterval(status.runningForMs ?? Infinity); + } }); } @@ -222,9 +266,13 @@ export function getJobStatusQuery(libraryId: LibraryId, enabled = true) { export function useJobStatusQuery() { const libraryId = useLibraryId(); const { signedIn, currentAccessLevel } = useAccessData(); + // Already fetched by the build-status consumers; reused here rather than + // spending a separate request just to learn whether to start polling. + const jobRunningAtLoad = useBuildStatusQuery().data?.jobRunning ?? false; return useQuery( getJobStatusQuery( libraryId, + jobRunningAtLoad, signedIn && hasEditorAccess(currentAccessLevel) ) ); diff --git a/src/frontend/settings/reload-groups-button.tsx b/src/frontend/settings/reload-groups-button.tsx index 9bc17b1d3..1d013a41e 100644 --- a/src/frontend/settings/reload-groups-button.tsx +++ b/src/frontend/settings/reload-groups-button.tsx @@ -10,6 +10,7 @@ import { queryClient } from "../query-client"; import { getAppErrorHandler } from "../api-utils/errors"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; import { jobStatusQueryKey } from "../queries"; +import { type JobStatus } from "../../shared/api-models"; interface ReloadGroupsButtonProps { reloadAll?: boolean; @@ -29,10 +30,13 @@ export function ReloadGroupsButton(props: ReloadGroupsButtonProps): ReactNode { }, onError: getAppErrorHandler("Failed to reload documents!"), onSuccess: (data) => { - // Show the running spinner immediately rather than on the next poll; - // the navbar watcher refreshes and reports completion when it finishes. - void queryClient.invalidateQueries({ - queryKey: jobStatusQueryKey(libraryId) + // Seeding the status (rather than invalidating) both shows the + // spinner immediately and starts the poll, which stays idle until + // something is known to be running. The navbar watcher refreshes + // and reports completion when it finishes. + queryClient.setQueryData(jobStatusQueryKey(libraryId), { + running: true, + runningForMs: 0 }); showInfoToast( data.status === "already-running" diff --git a/src/shared/api-models.ts b/src/shared/api-models.ts index 77fb6271a..9847a438b 100644 --- a/src/shared/api-models.ts +++ b/src/shared/api-models.ts @@ -59,9 +59,25 @@ export interface InsertableBuildStatus { lastLoadedAt: number | null; } +/** Whether a library-load job is running, and how long it has been going. */ +export interface JobStatus { + running: boolean; + /** + * Milliseconds since the oldest running job started. Absent when nothing is + * running, or for jobs tracked before this was recorded. + */ + runningForMs?: number; +} + export interface LibraryBuildStatus { groups: Record; insertables: Record; + /** + * Whether a load job was running when this was fetched. Clients poll job + * status only once this says there is something to watch, so an idle + * library costs no polling at all. + */ + jobRunning: boolean; } export type Insertables = Record; diff --git a/src/shared/build-issues.ts b/src/shared/build-issues.ts index 316244ce5..7efab6b37 100644 --- a/src/shared/build-issues.ts +++ b/src/shared/build-issues.ts @@ -4,6 +4,10 @@ * workflow) and are stored on the group/insertable; a few are computed live in * the frontend when they depend on per-user state (e.g. access level). */ +import { + AUTO_INDEX_THRESHOLD, + MAX_PART_NUMBER_CONFIGURATIONS +} from "./configuration-combinations"; export enum BuildIssueSeverity { /** A potential issue that is usually fine, e.g. no vendors parsed. */ @@ -59,9 +63,9 @@ export function getIssueDescription(issue: BuildIssue): string { case BuildIssueType.NO_UNHIDDEN_INSERTABLES: return "No unhidden insertables"; case BuildIssueType.TOO_MANY_CONFIGURATIONS: - return "Too many configurations to index part numbers"; + return `Over the ${MAX_PART_NUMBER_CONFIGURATIONS} configuration limit, so part numbers cannot be indexed`; case BuildIssueType.MANY_CONFIGURATIONS: - return "Too many configurations to index automatically"; + return `Over ${AUTO_INDEX_THRESHOLD} configurations, so part number indexing must be enabled manually`; case BuildIssueType.MULTIPLE_PARTS: return "This part studio has more than one part"; case BuildIssueType.UNSTABLE_COMPOSITE: diff --git a/src/shared/configuration-combinations.test.ts b/src/shared/configuration-combinations.test.ts index 8ff495293..785d7a95b 100644 --- a/src/shared/configuration-combinations.test.ts +++ b/src/shared/configuration-combinations.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { enumerateConfigurations } from "./configuration-combinations"; +import { + AUTO_INDEX_THRESHOLD, + countConfigurations, + enumerateConfigurations, + IndexingBand, + isIndexingEnabled, + MAX_PART_NUMBER_CONFIGURATIONS +} from "./configuration-combinations"; import { OptionVisibilityType, ConfigurationParameter, @@ -135,3 +142,84 @@ describe("enumerateConfigurations", () => { expect(configurations).toEqual([]); }); }); + +describe("countConfigurations", () => { + /** A single enum whose N options enumerate to N configurations. */ + function paramsWithConfigs(count: number): ConfigurationParameter[] { + return [ + enumParam( + "A", + Array.from({ length: count }, (_, i) => `o${i}`) + ) + ]; + } + + it("counts an insertable with nothing to vary as having none", () => { + expect(countConfigurations([])).toEqual({ + count: 0, + band: IndexingBand.AUTOMATIC + }); + }); + + // Cosmetic and quantity parameters ride their defaults rather than + // multiplying the count, so they leave nothing to vary either. + it("ignores parameters that don't vary the build", () => { + const cosmetic = { ...enumParam("A", ["x", "y"]), isCosmetic: true }; + expect(countConfigurations([cosmetic])).toEqual({ + count: 0, + band: IndexingBand.AUTOMATIC + }); + }); + + it.each([ + { configs: AUTO_INDEX_THRESHOLD - 1, band: IndexingBand.AUTOMATIC }, + { configs: AUTO_INDEX_THRESHOLD, band: IndexingBand.MANUAL }, + { + configs: MAX_PART_NUMBER_CONFIGURATIONS, + band: IndexingBand.MANUAL + } + ])( + "puts $configs configurations in the $band band", + ({ configs, band }) => { + expect(countConfigurations(paramsWithConfigs(configs))).toEqual({ + count: configs, + band + }); + } + ); + + it("reports no count past the cap, where enumeration stops", () => { + expect( + countConfigurations( + paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS + 1) + ) + ).toEqual({ count: null, band: IndexingBand.EXCEEDED }); + }); +}); + +describe("isIndexingEnabled", () => { + it.each([ + // A vendor insertable under the threshold indexes without being asked. + { vendor: true, band: IndexingBand.AUTOMATIC, force: false, on: true }, + // A custom one never does, until an admin says so. + { + vendor: false, + band: IndexingBand.AUTOMATIC, + force: false, + on: false + }, + { vendor: false, band: IndexingBand.AUTOMATIC, force: true, on: true }, + // Past the threshold it waits to be enabled, vendor or not. + { vendor: true, band: IndexingBand.MANUAL, force: false, on: false }, + { vendor: true, band: IndexingBand.MANUAL, force: true, on: true }, + { vendor: false, band: IndexingBand.MANUAL, force: false, on: false }, + // Past the cap there is nothing to enumerate, so enabling changes nothing. + { vendor: true, band: IndexingBand.EXCEEDED, force: true, on: false }, + { vendor: true, band: IndexingBand.EXCEEDED, force: false, on: false } + ])( + "vendor=$vendor band=$band force=$force -> $on", + ({ vendor, band, force, on }) => { + expect(isIndexingEnabled(vendor, band, force)).toBe(on); + } + ); +}); diff --git a/src/shared/configuration-combinations.ts b/src/shared/configuration-combinations.ts index 2d5393d70..9bca2d6da 100644 --- a/src/shared/configuration-combinations.ts +++ b/src/shared/configuration-combinations.ts @@ -19,11 +19,83 @@ export const MAX_PART_NUMBER_CONFIGURATIONS = 512; /** * Below this many combinations, a vendor insertable is indexed automatically on - * load. At or above it, indexing waits for an admin to force it on (after + * load. At or above it, indexing waits for an admin to turn it on (after * trimming the count via "exclude from properties"); see the `MANY_CONFIGURATIONS` * build issue. */ -export const AUTO_INDEX_THRESHOLD = 100; +export const AUTO_INDEX_THRESHOLD = 128; + +/** Where a configuration count sits relative to the two indexing limits. */ +export enum IndexingBand { + /** Under {@link AUTO_INDEX_THRESHOLD}: a vendor insertable indexes on load. */ + AUTOMATIC = "automatic", + /** Up to {@link MAX_PART_NUMBER_CONFIGURATIONS}: an admin must enable it. */ + MANUAL = "manual", + /** Over {@link MAX_PART_NUMBER_CONFIGURATIONS}: cannot be indexed at all. */ + EXCEEDED = "exceeded" +} + +/** + * Whether an insertable's part numbers end up indexed: automatically for a + * vendor insertable under the auto threshold, by hand wherever an admin enables + * it, and never past the cap — enumeration stops there, so there is nothing to + * index however the flag is set. + * + * Shared with the admin card, so what it reports can't drift from what the load + * path actually does. + */ +export function isIndexingEnabled( + hasVendor: boolean, + band: IndexingBand, + forceIndex: boolean +): boolean { + switch (band) { + case IndexingBand.EXCEEDED: + return false; + case IndexingBand.MANUAL: + return forceIndex; + case IndexingBand.AUTOMATIC: + return hasVendor || forceIndex; + } +} + +export interface ConfigurationCount { + /** + * The number of combinations, `0` when there is nothing to vary, or `null` + * past the cap — enumeration stops there, so the true total is unknown. + */ + count: number | null; + band: IndexingBand; +} + +/** + * Counts an insertable's configuration combinations and classifies what that + * count means for part-number indexing. Shared so the load path and the admin + * UI can't disagree about which limit an insertable falls under. + */ +export function countConfigurations( + parameters: ConfigurationParameter[] +): ConfigurationCount { + const { configurations, capped } = enumerateConfigurations(parameters); + if (capped) { + return { count: null, band: IndexingBand.EXCEEDED }; + } + // An insertable with nothing to vary enumerates to the single default + // configuration, which isn't a configuration of its own: a non-configurable + // insertable has none. + const count = configurations.some( + (configuration) => Object.keys(configuration).length > 0 + ) + ? configurations.length + : 0; + return { + count, + band: + count >= AUTO_INDEX_THRESHOLD + ? IndexingBand.MANUAL + : IndexingBand.AUTOMATIC + }; +} export interface EnumerateResult { /** The enumerated configurations, or empty when `capped`. */ From c2abe92e3f0a5b4f5324c77ee974d54666829b11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 13:35:42 +0000 Subject: [PATCH 08/23] Show indexing and enum values per configuration parameter The card listed each parameter's type but not the thing an admin is actually looking for: which parameters drive the configuration count, and so which ones to exclude to bring an insertable under the auto-index threshold. - Each parameter now carries an Indexed / Not indexed badge whose tooltip gives the reason: it is varied, it is excluded from properties, or it is a quantity or text parameter, which are never varied. - Extract `isIndexedParameter` and use it inside `enumerateConfigurations` rather than duplicating the rule, so what the card reports cannot drift from what enumeration varies. A test pins the two together. - An enum's type badge shows its option count and lists the options on hover, which is where its share of that count comes from. - Rename the section to Configurations and give both badges the same size/variant; the type badge was the odd one out. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- src/frontend/cards/build-status.tsx | 98 +++++++++++++++---- src/shared/configuration-combinations.test.ts | 49 ++++++++++ src/shared/configuration-combinations.ts | 36 +++++-- 3 files changed, 156 insertions(+), 27 deletions(-) diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index aa01ce29f..d541e83fb 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/cards/build-status.tsx @@ -51,6 +51,7 @@ import { type ConfigurationCount, countConfigurations, IndexingBand, + isIndexedParameter, MAX_PART_NUMBER_CONFIGURATIONS } from "../../shared/configuration-combinations"; import { FontWeight, IconColor, IconSize } from "../common/style-constants"; @@ -811,9 +812,8 @@ function InsertableParsedSection({ } /** - * Lists the insertable's configuration parameters: each parameter's name, its - * type, and whether it's excluded from properties (which keeps it from - * multiplying the indexed configuration count). Renders nothing when the + * Lists the insertable's configuration parameters: each parameter's name, the + * type it takes, and whether indexing varies it. Renders nothing when the * insertable has no parameters. */ function ConfigurationSection({ @@ -826,7 +826,7 @@ function ConfigurationSection({ <> - Configuration + Configurations {parameters.map((parameter) => ( @@ -838,19 +838,8 @@ function ConfigurationSection({ > {parameter.name} - {parameter.isCosmetic && ( - - Excluded - - )} - - {getParameterTypeLabel(parameter.type)} - + + ))} @@ -861,6 +850,81 @@ function ConfigurationSection({ ); } +/** 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) { diff --git a/src/shared/configuration-combinations.test.ts b/src/shared/configuration-combinations.test.ts index 785d7a95b..61369b140 100644 --- a/src/shared/configuration-combinations.test.ts +++ b/src/shared/configuration-combinations.test.ts @@ -4,6 +4,7 @@ import { countConfigurations, enumerateConfigurations, IndexingBand, + isIndexedParameter, isIndexingEnabled, MAX_PART_NUMBER_CONFIGURATIONS } from "./configuration-combinations"; @@ -223,3 +224,51 @@ describe("isIndexingEnabled", () => { } ); }); + +describe("isIndexedParameter", () => { + it("varies enum and boolean parameters", () => { + expect(isIndexedParameter(enumParam("a", ["x", "y"]))).toBe(true); + expect(isIndexedParameter(boolParam("b"))).toBe(true); + }); + + it("never varies quantity or text parameters", () => { + expect(isIndexedParameter(quantityParam("q"))).toBe(false); + const text: StringParameter = { + id: "s", + name: "s", + default: "", + isCosmetic: false, + type: ParameterType.STRING + }; + expect(isIndexedParameter(text)).toBe(false); + }); + + it("does not vary a parameter excluded from properties", () => { + expect( + isIndexedParameter(enumParam("a", ["x", "y"], { isCosmetic: true })) + ).toBe(false); + }); + + // The card reports indexing off this helper, so it has to describe exactly + // what enumeration varies. + it("matches the keys enumeration actually varies", () => { + const parameters = [ + enumParam("varied", ["x", "y"]), + boolParam("flag"), + enumParam("cosmetic", ["x", "y"], { isCosmetic: true }), + quantityParam("length") + ]; + const { configurations } = enumerateConfigurations(parameters); + const enumeratedKeys = new Set( + configurations.flatMap((configuration) => + Object.keys(configuration) + ) + ); + expect([...enumeratedKeys].sort()).toEqual( + parameters + .filter(isIndexedParameter) + .map((parameter) => parameter.id) + .sort() + ); + }); +}); diff --git a/src/shared/configuration-combinations.ts b/src/shared/configuration-combinations.ts index 9bca2d6da..a6cb6cff8 100644 --- a/src/shared/configuration-combinations.ts +++ b/src/shared/configuration-combinations.ts @@ -5,7 +5,9 @@ */ import { ParameterValues, + BooleanParameter, ConfigurationParameter, + EnumParameter, ParameterType } from "./configuration-models"; import { evaluateCondition, getVisibleOptions } from "./configuration-utils"; @@ -97,6 +99,29 @@ export function countConfigurations( }; } +/** + * Whether a parameter is varied when indexing part numbers, and so multiplies an + * insertable's configuration count. + * + * Only enum and boolean parameters are varied — quantity and text ones ride + * their Onshape defaults — and "exclude from properties" opts a parameter out, + * which is how an insertable is trimmed back under the auto-index threshold. + * + * Shared with the admin card, so what it reports can't drift from what + * {@link enumerateConfigurations} actually varies. + */ +export function isIndexedParameter( + parameter: ConfigurationParameter +): parameter is EnumParameter | BooleanParameter { + if ( + parameter.type !== ParameterType.ENUM && + parameter.type !== ParameterType.BOOLEAN + ) { + return false; + } + return !parameter.isCosmetic; +} + export interface EnumerateResult { /** The enumerated configurations, or empty when `capped`. */ configurations: ParameterValues[]; @@ -125,16 +150,7 @@ export function enumerateConfigurations( let configurations: ParameterValues[] = [{}]; for (const parameter of parameters) { - if ( - parameter.type !== ParameterType.ENUM && - parameter.type !== ParameterType.BOOLEAN - ) { - // Quantity and string parameters ride on their Onshape defaults. - continue; - } - if (parameter.isCosmetic) { - // "Exclude from properties": doesn't change the part's identity, so - // it rides its default rather than multiplying the configuration count. + if (!isIndexedParameter(parameter)) { continue; } From ed23934dd6efb386e547795cd5db532196a8c939 Mon Sep 17 00:00:00 2001 From: Alex Kempen Date: Sun, 16 Aug 2026 21:42:52 -0500 Subject: [PATCH 09/23] Propery save db as text --- AGENTS.md | 8 +- src/backend/app.ts | 17 +- src/backend/library-data.ts | 5 +- src/backend/load/workflows.ts | 10 +- .../parse/parse-configuration-records.test.ts | 40 +- .../parse/parse-configuration-records.ts | 30 +- src/backend/parse/parse-vendors.test.ts | 10 + src/backend/routes/groups.test.ts | 31 ++ src/backend/routes/groups.ts | 2 + src/backend/routes/insertables.test.ts | 10 +- src/backend/routes/library.ts | 2 +- src/backend/routes/thumbnails.test.ts | 124 ++++- src/backend/routes/thumbnails.ts | 255 +++++----- src/frontend/api-utils/api.ts | 9 +- src/frontend/cards/build-status.tsx | 23 +- src/frontend/cards/card-components.tsx | 8 +- src/frontend/cards/insertable-card.tsx | 4 +- src/frontend/favorites/favorite-card.tsx | 4 +- src/frontend/favorites/favorite-menu.tsx | 10 +- src/frontend/favorites/favorites-list.tsx | 4 +- src/frontend/insert/configurations.tsx | 22 +- src/frontend/insert/insert-menu.tsx | 6 +- src/frontend/insert/thumbnail.tsx | 79 ++- src/frontend/queries.ts | 9 +- src/frontend/routeTree.gen.ts | 448 +++++++++--------- src/frontend/search/search-results.tsx | 12 +- src/frontend/search/search.test.ts | 15 + src/frontend/search/search.ts | 17 +- src/shared/canonical-configuration.test.ts | 189 ++++++++ src/shared/canonical-configuration.ts | 129 +++++ src/shared/configuration-combinations.test.ts | 34 +- src/shared/configuration-combinations.ts | 26 +- src/shared/configuration-utils.test.ts | 145 +----- src/shared/configuration-utils.ts | 117 +---- src/shared/thumbnails.ts | 59 +-- src/shared/types.ts | 9 + 36 files changed, 1079 insertions(+), 843 deletions(-) create mode 100644 src/shared/canonical-configuration.test.ts create mode 100644 src/shared/canonical-configuration.ts 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/src/backend/app.ts b/src/backend/app.ts index cd998eeee..d34f9b2a8 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -29,6 +29,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; @@ -101,6 +103,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, @@ -124,7 +131,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/library-data.ts b/src/backend/library-data.ts index cd7cb907a..86764ed04 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -189,9 +189,8 @@ export async function rebuildSearchDb( getRecordsMap(db, libraryId) ]); const searchDb = JSON.stringify(buildSearchDb(libraryData, recordsMap)); - await bucket.put(searchIndexKey(libraryId), searchDb, { - httpMetadata: { contentType: "application/json" } - }); + // Store as a plain string in R2 + await bucket.put(searchIndexKey(libraryId), searchDb); console.log( `Rebuilt search index for ${libraryId}: ` + `${searchDb.length} B, ${Date.now() - start} ms` diff --git a/src/backend/load/workflows.ts b/src/backend/load/workflows.ts index ce84191ec..41f93bae2 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/load/workflows.ts @@ -236,9 +236,8 @@ async function finalizeLibrary( } /** - * Renders and stores one configuration's thumbnails outside a request, since - * Onshape can take minutes and a Worker request cannot wait that long. Until it - * finishes, requests fall back to the element's default thumbnail. + * 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, @@ -248,7 +247,8 @@ export class ThumbnailWorkflow extends WorkflowEntrypoint< event: WorkflowEvent, step: WorkflowStep ): Promise { - const { elementId, microversionId, configuration } = event.payload; + const { elementId, microversionId, canonicalConfiguration } = + event.payload; const elementPath = await step.do("resolve-element", async () => { const row = await getDb(this.env.DB) @@ -284,7 +284,7 @@ export class ThumbnailWorkflow extends WorkflowEntrypoint< }), elementPath, microversionId, - configuration + canonicalConfiguration ) ); } diff --git a/src/backend/parse/parse-configuration-records.test.ts b/src/backend/parse/parse-configuration-records.test.ts index 247ed789d..8ede58e82 100644 --- a/src/backend/parse/parse-configuration-records.test.ts +++ b/src/backend/parse/parse-configuration-records.test.ts @@ -88,15 +88,43 @@ describe("decideIndexing", () => { index: false, issues: TOO_MANY }, - // No vendor: never auto-eligible, never flagged, but still enableable. - { vendors: [], configs: 50, force: false, index: false, issues: [] }, - { vendors: [], configs: 50, force: true, index: true, issues: [] }, + // No recognized vendor is not a reason to skip indexing: the heuristic + // misses plenty of real vendor parts, so these behave like any other. + { vendors: [], configs: 127, force: false, index: true, issues: [] }, + { vendors: [], configs: 128, force: false, index: false, issues: MANY }, + // Custom: never auto-eligible, never flagged, but still enableable. + { + vendors: [Vendor.CUSTOM], + configs: 50, + force: false, + index: false, + issues: [] + }, + { + vendors: [Vendor.CUSTOM], + configs: 50, + force: true, + index: true, + issues: [] + }, // Nor flagged for a count it was never going to index against... - { vendors: [], configs: 200, force: false, index: false, issues: [] }, - { vendors: [], configs: 600, force: false, index: false, issues: [] }, + { + vendors: [Vendor.CUSTOM], + configs: 200, + force: false, + index: false, + issues: [] + }, + { + vendors: [Vendor.CUSTOM], + configs: 600, + force: false, + index: false, + issues: [] + }, // ...unless an admin enabled it and is owed the reason it did nothing. { - vendors: [], + vendors: [Vendor.CUSTOM], configs: 600, force: true, index: false, diff --git a/src/backend/parse/parse-configuration-records.ts b/src/backend/parse/parse-configuration-records.ts index 913f21ed7..d28f4152c 100644 --- a/src/backend/parse/parse-configuration-records.ts +++ b/src/backend/parse/parse-configuration-records.ts @@ -13,7 +13,7 @@ */ import { OnshapeApi } from "../onshape-api/onshape-api"; import { ElementPath } from "../../shared/onshape-path"; -import { ElementType, type Vendor } from "../../shared/types"; +import { ElementType, type Vendor, isCustomPart } from "../../shared/types"; import { ParameterValues, ConfigurationParameter, @@ -30,7 +30,7 @@ import { IndexingBand, isIndexingEnabled } from "../../shared/configuration-combinations"; -import { canonicalizeConfiguration } from "../../shared/configuration-utils"; +import { canonicalizeConfiguration } from "../../shared/canonical-configuration"; import { getParts } from "../onshape-api/endpoints/parts"; import { getElementMetadata } from "../onshape-api/endpoints/metadata"; import type { @@ -71,21 +71,15 @@ export const INDEXING_ISSUE_TYPES = [ /** Whether to index an insertable, and how to flag it if we don't. */ export interface IndexingDecision { - /** Index when enabled by an admin, or a vendor part under the auto threshold. */ + /** Index when enabled by an admin, or a non-custom part under the threshold. */ shouldIndex: boolean; /** The limit issues this decision raises, if any. */ buildIssues: BuildIssue[]; } /** - * Decides whether an insertable's part numbers get indexed, by which limit its - * configuration count falls under. A vendor part with few enough combinations - * indexes on its own; past the auto threshold indexing waits to be enabled by - * hand; past the hard cap there is nothing to index at all, since enumeration - * stops there — so forcing it on cannot help. - * - * Only an indexing candidate is flagged: a vendor part, or one an admin has - * explicitly enabled and is owed an explanation for. + * Past the hard cap forcing it on cannot help, since enumeration stops there. + * Only a candidate is flagged — an admin who enabled it is owed the reason. */ export function decideIndexing( vendors: Vendor[], @@ -93,9 +87,9 @@ export function decideIndexing( forceIndex: boolean ): IndexingDecision { const { band } = countConfigurations(parameters); - const isVendorPart = vendors.length > 0; - const isCandidate = isVendorPart || forceIndex; - const shouldIndex = isIndexingEnabled(isVendorPart, band, forceIndex); + const isCustom = isCustomPart(vendors); + const isCandidate = !isCustom || forceIndex; + const shouldIndex = isIndexingEnabled(isCustom, band, forceIndex); if (band === IndexingBand.EXCEEDED) { return { @@ -105,7 +99,7 @@ export function decideIndexing( : [] }; } - if (band === IndexingBand.MANUAL && isVendorPart && !forceIndex) { + if (band === IndexingBand.MANUAL && !isCustom && !forceIndex) { return { shouldIndex, buildIssues: [{ type: BuildIssueType.MANY_CONFIGURATIONS }] @@ -417,10 +411,8 @@ function toResult( capped: boolean, parameters: ConfigurationParameter[] ): ConfigurationRecordsResult { - // Store the canonical configuration, so a record addresses the same thumbnail - // the insert menu does for the same selection. Enumeration already omits - // cosmetic, quantity, and string parameters, and canonicalizing drops the - // ones left at their default. + // 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( diff --git a/src/backend/parse/parse-vendors.test.ts b/src/backend/parse/parse-vendors.test.ts index 54f3a61e0..d8fbb1c32 100644 --- a/src/backend/parse/parse-vendors.test.ts +++ b/src/backend/parse/parse-vendors.test.ts @@ -88,4 +88,14 @@ describe("parseVendors", () => { ]; expect(parseVendors("Generic Part", parameters)).toEqual([]); }); + + // Custom is the one vendor that blocks indexing, so the name has to reach 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/groups.test.ts b/src/backend/routes/groups.test.ts index baaa94cf7..863ff9a2a 100644 --- a/src/backend/routes/groups.test.ts +++ b/src/backend/routes/groups.test.ts @@ -12,7 +12,10 @@ import { seedGroup, seedTestData } from "../../__test_utils__"; +import MiniSearch from "minisearch"; import { getDb } from "../db"; +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"; @@ -67,6 +70,34 @@ 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-element-visibility rebuilds the search index (isVisible=%s)", + async (isVisible) => { + await seedTestData(db); + + const res = await createTestApp().request( + `/api/set-element-visibility/library/${TEST_LIBRARY_ID}`, + jsonRequest("POST", { + insertableIds: [TEST_PART_STUDIO_ID], + isVisible + }), + env + ); + expect(res.status).toBe(200); + + const object = await env.SEARCH_INDEX.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); diff --git a/src/backend/routes/groups.ts b/src/backend/routes/groups.ts index e460a2f1f..fd341285d 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/routes/groups.ts @@ -101,6 +101,8 @@ groupRoutes.post( ); await bumpLibraryVersion(db, libraryId); + // Search filters on isVisible, so a stale index hides these from results. + await rebuildSearchDb(c.env.SEARCH_INDEX, db, libraryId); return c.json({ success: true }); } ); diff --git a/src/backend/routes/insertables.test.ts b/src/backend/routes/insertables.test.ts index adad32531..d03a370a0 100644 --- a/src/backend/routes/insertables.test.ts +++ b/src/backend/routes/insertables.test.ts @@ -13,6 +13,8 @@ import { jsonRequest, resetDb, seedAssembly, + seedGroup, + seedInsertable, seedPartStudio } from "../../__test_utils__"; import { getDb } from "../db"; @@ -183,10 +185,12 @@ describe("insertable routes", () => { expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); - // Turning force off on a part with no vendor drops it below the auto-index + // Turning force off on a custom part drops it below the auto-index // heuristic, so its records and configuration row go away. it("POST /toggle-part-number-search clears the data when forcing off", async () => { - await seedPartStudio(db); + await seedGroup(db); + // The route re-parses vendors from the name, so that is what marks it custom. + await seedInsertable(db, { name: "Custom Bracket" }); const spy = vi .spyOn(PartsEndpoints, "getParts") .mockResolvedValue([{ partId: "p", partNumber: "PN-123" }]); @@ -203,7 +207,7 @@ describe("insertable routes", () => { env ); expect(res.status).toBe(200); - // A part with no vendor isn't auto-eligible, so nothing is re-indexed. + // A custom part isn't auto-eligible, so nothing is re-indexed. expect(spy).not.toHaveBeenCalled(); const row = await readInsertable(TEST_PART_STUDIO_ID); diff --git a/src/backend/routes/library.ts b/src/backend/routes/library.ts index 88d6f45f3..7f6a78475 100644 --- a/src/backend/routes/library.ts +++ b/src/backend/routes/library.ts @@ -42,7 +42,7 @@ libraryRoutes.get( /** * GET /api/search-db/library/:libraryId?v=:cacheVersion * - * Streams the library's serialized MiniSearch index from R2 as plain JSON. + * Streams the library's serialized MiniSearch index from R2 as a plain string. * * Never serve this pre-compressed with a hand-set `Content-Encoding`: the * runtime treats any body it did not encode itself as identity and compresses diff --git a/src/backend/routes/thumbnails.test.ts b/src/backend/routes/thumbnails.test.ts index d5388358a..012b1dec4 100644 --- a/src/backend/routes/thumbnails.test.ts +++ b/src/backend/routes/thumbnails.test.ts @@ -1,23 +1,30 @@ 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 { - thumbnailConfigurationKey, + THUMBNAIL_FALLBACK_CACHE_TTL, thumbnailKey, - thumbnailUrl + thumbnailUrl, + thumbnailWorkflowId } from "../../shared/thumbnails"; +import { + DEFAULT_CANONICAL_CONFIGURATION, + canonicalConfigurationKey +} from "../../shared/canonical-configuration"; const SIZE = ThumbnailSize.LARGE; const MICROVERSION = "mv-1"; /** A configuration whose key differs from the default's. */ -const CONFIGURATION = "size=l"; +const CANONICAL_CONFIGURATION = "size=l"; function get(url: string) { return createTestApp().request(url, jsonRequest("GET"), env); } describe("thumbnail serving", () => { + afterEach(() => vi.restoreAllMocks()); + it("serves a stored thumbnail, cached immutably", async () => { const elementId = "stored-element"; await env.THUMBNAILS.put( @@ -29,12 +36,25 @@ describe("thumbnail serving", () => { thumbnailUrl({ elementId, microversionId: MICROVERSION, - size: SIZE + size: SIZE, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION }) ); expect(res.status).toBe(200); expect(await res.text()).toBe("gif-bytes"); - expect(res.headers.get("Cache-Control")).toContain("immutable"); + expect(res.headers.get("Cache-Control")).toBe( + "public, max-age=31536000, immutable" + ); + }); + + 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("does not demand a version from a url that is already immutable", async () => { @@ -54,7 +74,8 @@ describe("thumbnail serving", () => { thumbnailUrl({ elementId: "does-not-exist", microversionId: MICROVERSION, - size: SIZE + size: SIZE, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION }) ); expect(res.status).toBe(404); @@ -76,12 +97,14 @@ describe("thumbnail serving", () => { elementId, microversionId: MICROVERSION, size: SIZE, - configuration: CONFIGURATION + canonicalConfiguration: CANONICAL_CONFIGURATION }) ); expect(res.status).toBe(200); expect(await res.text()).toBe("default-bytes"); - expect(res.headers.get("Cache-Control")).not.toContain("immutable"); + 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 () => { @@ -95,7 +118,7 @@ describe("thumbnail serving", () => { elementId, MICROVERSION, SIZE, - thumbnailConfigurationKey(CONFIGURATION) + canonicalConfigurationKey(CANONICAL_CONFIGURATION) ), "configured-bytes" ); @@ -105,7 +128,7 @@ describe("thumbnail serving", () => { elementId, microversionId: MICROVERSION, size: SIZE, - configuration: CONFIGURATION + canonicalConfiguration: CANONICAL_CONFIGURATION }) ); expect(res.status).toBe(200); @@ -113,3 +136,82 @@ describe("thumbnail serving", () => { 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.THUMBNAILS.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 + }); + expect(new URL(url, "http://x").searchParams.get("warm")).toBe("true"); + }); + + 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 + }) + ); + + expect(res.status).toBe(200); + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: thumbnailWorkflowId({ + elementId, + microversionId: MICROVERSION, + canonicalConfiguration: CANONICAL_CONFIGURATION + }) + }) + ); + }); + + // 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); + }); +}); diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index 56740bd46..bec51c493 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -1,11 +1,14 @@ 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"; @@ -24,15 +27,17 @@ import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; import { ThumbnailSize, ThumbnailUrls } from "../../shared/types"; import { - THUMBNAIL_CACHE_TTL, THUMBNAIL_FALLBACK_CACHE_TTL, type ThumbnailParams, - thumbnailConfigurationKey, thumbnailKey, thumbnailUrl, thumbnailWorkflowId } from "../../shared/thumbnails"; -import { DEFAULT_CONFIGURATION_KEY } from "../../shared/configuration-utils"; +import { + DEFAULT_CANONICAL_CONFIGURATION, + DEFAULT_CONFIGURATION_KEY, + canonicalConfigurationKey +} from "../../shared/canonical-configuration"; import { OnshapeApi } from "../onshape-api/onshape-api"; import type { AppBindings } from "../app"; import { BuildIssueType, clearBuildIssue } from "../../shared/build-issues"; @@ -47,17 +52,13 @@ async function putThumbnail( await bucket.put(key, thumbnail, { httpMetadata: { contentType: "image/gif", - cacheControl: `public, max-age=${THUMBNAIL_CACHE_TTL}, immutable` + cacheControl: immutableCacheControl(CachePolicy.PUBLIC_CACHE) }, customMetadata: metadata }); } -/** - * Renders and stores an element's default-configuration thumbnails, in both - * sizes. Throws if Onshape hasn't rendered them yet, which is what drives the - * load step's retries. - */ +/** Throws until Onshape has rendered them, which drives the load step's retries. */ export async function uploadThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, @@ -92,40 +93,40 @@ export async function uploadThumbnails( small: thumbnailUrl({ elementId, microversionId, - size: ThumbnailSize.SMALL + size: ThumbnailSize.SMALL, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION }), large: thumbnailUrl({ elementId, microversionId, - size: ThumbnailSize.LARGE + size: ThumbnailSize.LARGE, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION }) }; } /** - * Renders and stores one configuration's thumbnails, in both sizes, so a row and - * its hover never disagree. Uses the two-stage id flow, the only Onshape path - * that takes a configuration; both calls can fail while Onshape renders, which - * is what the caller's retries are for. + * 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, - configuration: string + canonicalConfiguration: string ): Promise { const thumbnailId = await getThumbnailId( onshapeApi, elementPath, - configuration + canonicalConfiguration ); const [small, large] = await Promise.all([ getThumbnailFromId(onshapeApi, thumbnailId, ThumbnailSize.SMALL), getThumbnailFromId(onshapeApi, thumbnailId, ThumbnailSize.LARGE) ]); - const configurationKey = thumbnailConfigurationKey(configuration); + const configurationKey = canonicalConfigurationKey(canonicalConfiguration); const { elementId } = elementPath; await Promise.all( ( @@ -138,15 +139,13 @@ export async function uploadConfigurationThumbnails( bucket, thumbnailKey(elementId, microversionId, size, configurationKey), thumbnail, - { microversionId, configuration } + { microversionId, canonicalConfiguration } ) ) ); } -/** - * Uploads document-level thumbnails using the document's designated thumbnail element. - */ +/** Falls back to the first element when the document designates no thumbnail. */ export async function uploadDocumentThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, @@ -185,72 +184,82 @@ export async function uploadDocumentThumbnails( export const thumbnailRoutes = getApp(); +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) +}); + /** - * GET /api/thumbnail/:size/:elementId?v=&c=&warm= - * - * Serves a stored thumbnail. `c` is the encoded canonical configuration (absent - * for the default), `v` the microversion — both are part of the key, so a hit is - * immutable. A configuration we haven't rendered falls back to the element's - * default thumbnail, cached only briefly so the real one can take over as soon - * as it lands; with `warm=1` the miss also kicks off that render. + * 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", async (c) => { - const size = c.req.param("size") as ThumbnailSize; - const elementId = c.req.param("elementId"); - const microversionId = c.req.query("v"); - if (!microversionId) { - return c.json({ error: "v (microversionId) required" }, 400); - } - const configuration = c.req.query("c"); - const configurationKey = thumbnailConfigurationKey(configuration); +thumbnailRoutes.get( + "/thumbnail/:size/:elementId", + cacheMiddleware(CachePolicy.PUBLIC_CACHE), + zValidator("param", storedThumbnailParams), + zValidator("query", storedThumbnailQuery), + async (c) => { + const { size, elementId } = c.req.valid("param"); + const { + v: microversionId, + c: canonicalConfiguration, + warm + } = c.req.valid("query"); + const configurationKey = canonicalConfigurationKey( + canonicalConfiguration + ); - const object = await c.env.THUMBNAILS.get( - thumbnailKey(elementId, microversionId, size, configurationKey) - ); - if (object) { - return thumbnailResponse(object, THUMBNAIL_CACHE_TTL); - } + const object = await c.env.THUMBNAILS.get( + thumbnailKey(elementId, microversionId, size, configurationKey) + ); + if (object) { + return thumbnailResponse(object); + } - if (configurationKey === DEFAULT_CONFIGURATION_KEY) { - return c.notFound(); - } + if (configurationKey === DEFAULT_CONFIGURATION_KEY) { + return c.notFound(); + } - if (c.req.query("warm") === "1" && configuration) { - await warmConfigurationThumbnail(c.env, { - elementId, - microversionId, - configuration - }); - } + if (warm) { + await warmConfigurationThumbnail(c.env, { + elementId, + microversionId, + canonicalConfiguration + }); + } - // Stand in with the default configuration until the real render lands. - const fallback = await c.env.THUMBNAILS.get( - thumbnailKey(elementId, microversionId, size) - ); - if (!fallback) { - return c.notFound(); + // Stand in with the default configuration until the real render lands. + const fallback = await c.env.THUMBNAILS.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); + return thumbnailResponse(fallback); } - return thumbnailResponse(fallback, THUMBNAIL_FALLBACK_CACHE_TTL); -}); +); -function thumbnailResponse(object: R2ObjectBody, maxAge: number): Response { +function thumbnailResponse(object: R2ObjectBody): Response { const headers = new Headers(); object.writeHttpMetadata(headers); - headers.set( - "Cache-Control", - maxAge === THUMBNAIL_CACHE_TTL - ? `public, max-age=${maxAge}, immutable` - : `public, max-age=${maxAge}` - ); return new Response(object.body, { headers }); } -/** - * Starts rendering a configuration's thumbnails, if nobody already is. The - * configuration key doubles as the workflow instance id, so concurrent requests - * for the same configuration collapse onto one run — a duplicate id is rejected, - * which is exactly the outcome we want. - */ +/** Concurrent requests collapse onto one run: Cloudflare rejects a duplicate id. */ async function warmConfigurationThumbnail( env: AppBindings, params: ThumbnailParams @@ -261,52 +270,66 @@ async function warmConfigurationThumbnail( params }); } catch { - // Already rendering (or the workflow couldn't start) — the caller still - // has the default thumbnail to serve, so this is never fatal. + // Never fatal: the caller still has the default thumbnail to serve. } } +const liveThumbnailQuery = z.object({ + thumbnailId: z.string().min(1), + size: z.enum(ThumbnailSize).default(ThumbnailSize.LARGE), + /** With `v` and `c`, the bytes are stored under that configuration's key. */ + elementId: z.string().optional(), + v: z.string().optional(), + c: canonicalConfigurationQuery +}); + /** - * GET /api/thumbnail?size=X&thumbnailId=Y — live preview thumbnail from Onshape. - * - * With `elementId`, `v`, and `c`, the bytes are also stored under that - * configuration's key on the way out: the insert menu keeps its responsive - * two-stage flow and warms the cache for free, with no added latency. + * GET /api/thumbnail?size=X&thumbnailId=Y — live from Onshape. With `elementId`, + * `v`, and `c` it also stores the bytes, warming the cache at no added latency. */ -thumbnailRoutes.get("/thumbnail", requireSignInMiddleware, async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const size = (c.req.query("size") as ThumbnailSize) ?? ThumbnailSize.LARGE; - const thumbnailId = c.req.query("thumbnailId"); - if (!thumbnailId) return c.json({ error: "thumbnailId required" }, 400); - - const buffer = await getThumbnailFromId(onshapeApi, thumbnailId, size); - - const elementId = c.req.query("elementId"); - const microversionId = c.req.query("v"); - const configuration = c.req.query("c"); - if (elementId && microversionId && configuration) { - c.executionCtx.waitUntil( - putThumbnail( - c.env.THUMBNAILS, - thumbnailKey( - elementId, - microversionId, - size, - thumbnailConfigurationKey(configuration) - ), - buffer, - { microversionId, configuration } - ) - ); - } - - return new Response(buffer, { - headers: { - "Content-Type": "image/gif", - "Cache-Control": `public, max-age=${THUMBNAIL_CACHE_TTL}, immutable` +thumbnailRoutes.get( + "/thumbnail", + requireSignInMiddleware, + // The thumbnailId names immutable content, so there is no `?v=` to bust. + cacheMiddleware(CachePolicy.PUBLIC_CACHE, { versioned: false }), + zValidator("query", liveThumbnailQuery), + async (c) => { + const onshapeApi = await c.var.getOnshapeApi(); + const { + thumbnailId, + size, + elementId, + v: microversionId, + c: canonicalConfiguration + } = c.req.valid("query"); + + const buffer = await getThumbnailFromId(onshapeApi, thumbnailId, size); + + if ( + elementId && + microversionId && + canonicalConfiguration !== DEFAULT_CANONICAL_CONFIGURATION + ) { + c.executionCtx.waitUntil( + putThumbnail( + c.env.THUMBNAILS, + thumbnailKey( + elementId, + microversionId, + size, + canonicalConfigurationKey(canonicalConfiguration) + ), + buffer, + { microversionId, canonicalConfiguration } + ) + ); } - }); -}); + + return new Response(buffer, { + headers: { "Content-Type": "image/gif" } + }); + } +); /** GET /api/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId */ thumbnailRoutes.get( diff --git a/src/frontend/api-utils/api.ts b/src/frontend/api-utils/api.ts index 92bfdcaf0..c99773d63 100644 --- a/src/frontend/api-utils/api.ts +++ b/src/frontend/api-utils/api.ts @@ -5,6 +5,7 @@ import { type PostOptions } from "../common/utils"; import { HandledError } from "./errors"; +import { HttpStatus } from "http-status-ts"; function getUrl( path: string, @@ -50,9 +51,7 @@ export async function apiGet( } /** - * Gets a plain-text body from a backend /api route (e.g. the search index blob, - * served pre-gzipped and decompressed transparently by the browser). Returns - * null on 404 so a missing resource is a graceful empty state, not an error. + * Gets a response formatted as a raw string from a backend /api route. */ export async function apiGetText( path: string, @@ -62,13 +61,13 @@ export async function apiGetText( getUrl(path, options?.query, options?.cacheId), { signal: options?.signal } ); - if (response.status === 404) { + if (response.status === HttpStatus.NOT_FOUND) { return null; } if (!response.ok) { throw new Error("Network response failed."); } - return response.text(); + return await response.text(); } /** diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index d541e83fb..4d856523d 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/cards/build-status.tsx @@ -41,7 +41,7 @@ import { GroupBuildStatus, InsertableBuildStatus } from "../../shared/api-models"; -import { getVendorName, Vendor } from "../../shared/types"; +import { getVendorName, isCustomPart, Vendor } from "../../shared/types"; import { ConfigurationParameter, ParameterType @@ -653,13 +653,8 @@ function FastenSwitch({ } /** - * The indexing control: a switch only where turning indexing on is the admin's - * call to make, and an icon saying why not otherwise. - * - * Indexing needs a vendor to attribute parts to and few enough configurations - * to enumerate. Past the hard cap it can't run at all, and under the auto - * threshold a vendor insertable already indexes on load, leaving nothing to - * decide. Only the band in between is a choice. + * 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, @@ -679,18 +674,18 @@ function IndexingRow({ tooltip={`Over the ${MAX_PART_NUMBER_CONFIGURATIONS} configuration limit, so there is nothing to index. Exclude parameters from properties to bring the count down.`} /> ); - } else if (status.vendors.length === 0) { + } else if (isCustomPart(status.vendors)) { control = ( ); } else if (band === IndexingBand.AUTOMATIC) { control = ( ); } else { @@ -714,10 +709,8 @@ function IndexingRow({ } /** - * Stands in for the indexing switch where there is nothing to toggle. Reuses the - * build-check severity icons, so the state reads the same as the callouts above - * it — a green check when indexing is already on, otherwise the severity of what - * is holding it back. + * 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, diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index 068ed2959..268f88aa1 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -172,8 +172,8 @@ export function CardTitle(props: CardTitleProps) { largeThumbnailUrl={largeThumbnailUrl} target={thumbnailTarget} /> - {/* Shrinks to truncate, but never grows: the hidden tag and build - status badge belong beside the name, not at the row's edge. */} + {/* Shrinks to truncate, but never grows: the build status badge and + hidden tag belong beside the name, not at the row's edge. */} {cardTitle} @@ -184,6 +184,9 @@ export function CardTitle(props: CardTitleProps) { )} + {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/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index c7f886634..58eb60d0f 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -1,4 +1,4 @@ -import { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; +import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { Menu } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; import { @@ -82,7 +82,7 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { thumbnailTarget={{ elementId: insertable.elementId, microversionId: insertable.microversionId, - configuration: encodeCanonicalConfiguration( + canonicalConfiguration: encodeCanonicalConfiguration( searchHit?.configuration ?? {} ), // A cold search would otherwise start a render per row. diff --git a/src/frontend/favorites/favorite-card.tsx b/src/frontend/favorites/favorite-card.tsx index 21dcb34d7..c661b6588 100644 --- a/src/frontend/favorites/favorite-card.tsx +++ b/src/frontend/favorites/favorite-card.tsx @@ -1,4 +1,4 @@ -import { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; +import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { ReactNode } from "react"; import { InsertableOut, Favorite } from "../../shared/api-models"; import { useMutation } from "@tanstack/react-query"; @@ -77,7 +77,7 @@ export function FavoriteCard(props: FavoriteCardProps): ReactNode { thumbnailTarget={{ elementId: insertable.elementId, microversionId: insertable.microversionId, - configuration: encodeCanonicalConfiguration( + canonicalConfiguration: encodeCanonicalConfiguration( favorite.defaultConfiguration ?? {} ), warm: true diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index 28540c86e..afdf9fe8a 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/favorites/favorite-menu.tsx @@ -13,7 +13,7 @@ import { type FavoritesData } from "../../shared/api-models"; import { HeartIcon } from "./favorite-button"; import { queryClient } from "../query-client"; import { ParameterValues } from "../../shared/configuration-models"; -import { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; +import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { favoritesQueryKey, useFavoritesQuery, @@ -74,9 +74,8 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { const setDefaultConfigurationMutation = useMutation({ mutationKey: ["set-default-configuration"], mutationFn: async () => { - // Store the canonical form: Onshape applies defaults for whatever - // it omits, so it inserts the same thing, and it addresses the same - // thumbnail the favorites row asks for. + // 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: canonicalConfiguration } }); @@ -126,8 +125,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { <> ; - } else if (!searchDbQuery.data) { + } else if (searchDbQuery.isError) { return ; + } else if (!searchDbQuery.data) { + return ; } const favoriteInsertableIds = new Set( Object.values(favoritesQuery.data.favorites).map( diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/insert/configurations.tsx index 58d4f470c..9f0cec8f1 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/insert/configurations.tsx @@ -33,13 +33,13 @@ import { SearchRecord } from "../../shared/configuration-models"; import { - canonicalizeConfiguration, evaluateCondition, findRecordForConfiguration, getEvaluateOptions, getOption, getVisibleOptions } from "../../shared/configuration-utils"; +import { canonicalizeConfiguration } from "../../shared/canonical-configuration"; import { handleBooleanChange } from "../common/utils"; import { formatValueWithUnits, @@ -57,11 +57,12 @@ interface ConfigurationWrapperProps { configuration?: ParameterValues; setConfiguration: Dispatch; /** - * Reports the selection's canonical form, which addresses its thumbnail and - * is what a favorite stores. Only this component has the parameters and - * units needed to compute it. + * Reported here because only this component has the parameters and units + * canonicalizing needs. */ - onCanonicalConfiguration?: (canonical: ParameterValues) => void; + onCanonicalConfiguration?: ( + canonicalConfiguration: ParameterValues + ) => void; } export function ConfigurationWrapper(props: ConfigurationWrapperProps) { @@ -146,9 +147,8 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { } /** - * The part number + name the currently-selected configuration produces. - * Recomputes on every configuration change, so it updates live as the user - * changes selections. Renders nothing when the selection has no indexed record. + * The part number + name the selection produces, live as it changes. Renders + * nothing when the selection has no indexed record. */ function RecordSummary({ records, @@ -280,10 +280,8 @@ interface InputLabelProps { } /** - * A label displayed to the left of a parameter input. - * - * The label is given the height of an input so it stays aligned with the input - * itself rather than drifting when the input grows to show an error message. + * Given an input's height so it stays aligned rather than drifting when the + * input grows to show an error message. */ function InputLabel(props: InputLabelProps) { const { label, htmlFor, children } = props; diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index d692d8858..4b9cbb074 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -21,7 +21,7 @@ import { InsertableMenuItems } from "../cards/insertable-card"; import { ConfigurationWrapper } from "./configurations"; import { useInsertMutation } from "./insert-hooks"; import { ParameterValues } from "../../shared/configuration-models"; -import { encodeCanonicalConfiguration } from "../../shared/configuration-utils"; +import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { useFavoritesQuery } from "../queries"; import { useUiState } from "../api-utils/ui-state"; import { notifications } from "@mantine/notifications"; @@ -107,9 +107,7 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { <> - target?.configuration ? thumbnailUrl({ ...target, size }) : stored; + target && + target.canonicalConfiguration !== DEFAULT_CANONICAL_CONFIGURATION + ? thumbnailUrl({ ...target, size }) + : stored; return ( { heightAndWidth: HeightAndWidth; } -/** - * A generic thumbnail component. - */ function Thumbnail(props: ThumbnailProps): ReactNode { const { url, heightAndWidth, spinnerSize, ...centerProps } = props; const imageQuery = useQuery({ queryKey: ["storage-thumbnail", url], - queryFn: async ({ signal }) => { + queryFn: ({ signal }) => { if (url === undefined) { throw new Error("Tried to get thumbnail with no URL"); } @@ -151,22 +142,17 @@ export function PreviewImageCard(props: PreviewImageProps): ReactNode { interface PreviewImageProps { path: ElementPath; - microversionId: string; - configuration?: ParameterValues; + /** The selection to preview; Onshape applies defaults for what it omits. */ + canonicalConfiguration: string; + /** Lets the fetch also warm the R2 cache for this configuration. */ + microversionId?: string; /** Stored thumbnail, shown instead of the live preview when not signed in. */ - thumbnailUrls?: ThumbnailUrls; - /** With the canonical configuration, lets the fetch also warm the R2 cache. */ - canonicalConfiguration?: string; + largeThumbnailUrl?: string; } export function PreviewImage(props: PreviewImageProps): ReactNode { - const { - path, - microversionId, - configuration, - thumbnailUrls, - canonicalConfiguration - } = props; + const { path, canonicalConfiguration, microversionId, largeThumbnailUrl } = + props; // A stored size, so the bytes this fetch returns are worth caching. const size = ThumbnailSize.LARGE; const isSignedIn = useIsSignedIn(); @@ -175,17 +161,18 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { useIsFetching({ queryKey: getConfigurationMatchKey() }) > 0; const targetElementType = useTargetElementType(); - // Thumbnail id generation with queries is really unreliable - // The standard Onshape API for it appears to be broken/bugged - // So we use an undocumented alternate workflow where insertables returns an id - // However, the id can take a while to update, so we have to poll the endpoint while waiting for it to load + // Onshape's configured-thumbnail query is broken, so we use the undocumented + // insertables id flow — and poll, since the id lags behind the configuration. const thumbnailIdQuery = useQuery({ - queryKey: ["thumbnail", "id", toElementApiPath(path), configuration], + queryKey: [ + "thumbnail", + "id", + toElementApiPath(path), + canonicalConfiguration + ], queryFn: async ({ signal }) => { return apiGet("/thumbnail-id" + toElementApiPath(path), { - query: { - configuration: encodeConfigurationForQuery(configuration) - }, + query: { configuration: canonicalConfiguration }, signal }).then((value) => value.thumbnailId as string); }, @@ -198,7 +185,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { const thumbnailQuery = useQuery({ queryKey: ["thumbnail", thumbnailId], - queryFn: async ({ signal }) => { + queryFn: ({ signal }) => { if (!thumbnailId) { // Shouldn't happen due to enabled guard return; @@ -207,9 +194,9 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { query: { size, thumbnailId, - // Let the worker store what it proxies, so this render is - // cached for the rows that show the same configuration. - ...(canonicalConfiguration + // Let the worker store what it proxies, warming matching rows. + ...(microversionId && + canonicalConfiguration !== DEFAULT_CANONICAL_CONFIGURATION ? { elementId: path.elementId, v: microversionId, @@ -244,7 +231,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { if (!isSignedIn) { return ( diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts index 8090024de..7f55fca1a 100644 --- a/src/frontend/queries.ts +++ b/src/frontend/queries.ts @@ -120,14 +120,15 @@ export function getSearchDbQuery(libraryId: LibraryId, cacheVersion: number) { queryKey: searchDbQueryKey(libraryId, cacheVersion), queryFn: async () => { const searchDb = await apiGetText( - "/search-db/library/" + libraryId, + "/search-db" + toLibraryPath(libraryId), { cacheId: cacheVersion } ); - return searchDb - ? MiniSearch.loadJSON(searchDb, SEARCH_OPTIONS) - : null; + if (!searchDb) { + return null; + } + return MiniSearch.loadJSON(searchDb, SEARCH_OPTIONS); }, staleTime: Infinity, gcTime: Infinity diff --git a/src/frontend/routeTree.gen.ts b/src/frontend/routeTree.gen.ts index dea2f3a0d..9d46791e8 100644 --- a/src/frontend/routeTree.gen.ts +++ b/src/frontend/routeTree.gen.ts @@ -8,269 +8,269 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as AppRouteRouteImport } from './routes/app/route' -import { Route as IndexRouteImport } from './routes/index' -import { Route as PagesSafariErrorRouteImport } from './routes/_pages/safari-error' -import { Route as PagesLicenseRouteImport } from './routes/_pages/license' -import { Route as PagesGrantDeniedRouteImport } from './routes/_pages/grant-denied' -import { Route as PagesCookieErrorRouteImport } from './routes/_pages/cookie-error' -import { Route as PagesBetaCompleteRouteImport } from './routes/_pages/beta-complete' -import { Route as AppLibraryLibraryIdRouteRouteImport } from './routes/app/library/$libraryId/route' -import { Route as AppLibraryLibraryIdIndexRouteImport } from './routes/app/library/$libraryId/index' -import { Route as AppLibraryLibraryIdGroupsGroupIdRouteImport } from './routes/app/library/$libraryId/groups/$groupId' +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as AppRouteRouteImport } from "./routes/app/route"; +import { Route as IndexRouteImport } from "./routes/index"; +import { Route as PagesSafariErrorRouteImport } from "./routes/_pages/safari-error"; +import { Route as PagesLicenseRouteImport } from "./routes/_pages/license"; +import { Route as PagesGrantDeniedRouteImport } from "./routes/_pages/grant-denied"; +import { Route as PagesCookieErrorRouteImport } from "./routes/_pages/cookie-error"; +import { Route as PagesBetaCompleteRouteImport } from "./routes/_pages/beta-complete"; +import { Route as AppLibraryLibraryIdRouteRouteImport } from "./routes/app/library/$libraryId/route"; +import { Route as AppLibraryLibraryIdIndexRouteImport } from "./routes/app/library/$libraryId/index"; +import { Route as AppLibraryLibraryIdGroupsGroupIdRouteImport } from "./routes/app/library/$libraryId/groups/$groupId"; const AppRouteRoute = AppRouteRouteImport.update({ - id: '/app', - path: '/app', - getParentRoute: () => rootRouteImport, -} as any) + id: "/app", + path: "/app", + getParentRoute: () => rootRouteImport +} as any); const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, -} as any) + id: "/", + path: "/", + getParentRoute: () => rootRouteImport +} as any); const PagesSafariErrorRoute = PagesSafariErrorRouteImport.update({ - id: '/_pages/safari-error', - path: '/safari-error', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/safari-error", + path: "/safari-error", + getParentRoute: () => rootRouteImport +} as any); const PagesLicenseRoute = PagesLicenseRouteImport.update({ - id: '/_pages/license', - path: '/license', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/license", + path: "/license", + getParentRoute: () => rootRouteImport +} as any); const PagesGrantDeniedRoute = PagesGrantDeniedRouteImport.update({ - id: '/_pages/grant-denied', - path: '/grant-denied', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/grant-denied", + path: "/grant-denied", + getParentRoute: () => rootRouteImport +} as any); const PagesCookieErrorRoute = PagesCookieErrorRouteImport.update({ - id: '/_pages/cookie-error', - path: '/cookie-error', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/cookie-error", + path: "/cookie-error", + getParentRoute: () => rootRouteImport +} as any); const PagesBetaCompleteRoute = PagesBetaCompleteRouteImport.update({ - id: '/_pages/beta-complete', - path: '/beta-complete', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/beta-complete", + path: "/beta-complete", + getParentRoute: () => rootRouteImport +} as any); const AppLibraryLibraryIdRouteRoute = - AppLibraryLibraryIdRouteRouteImport.update({ - id: '/library/$libraryId', - path: '/library/$libraryId', - getParentRoute: () => AppRouteRoute, - } as any) + AppLibraryLibraryIdRouteRouteImport.update({ + id: "/library/$libraryId", + path: "/library/$libraryId", + getParentRoute: () => AppRouteRoute + } as any); const AppLibraryLibraryIdIndexRoute = - AppLibraryLibraryIdIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppLibraryLibraryIdRouteRoute, - } as any) + AppLibraryLibraryIdIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => AppLibraryLibraryIdRouteRoute + } as any); const AppLibraryLibraryIdGroupsGroupIdRoute = - AppLibraryLibraryIdGroupsGroupIdRouteImport.update({ - id: '/groups/$groupId', - path: '/groups/$groupId', - getParentRoute: () => AppLibraryLibraryIdRouteRoute, - } as any) + AppLibraryLibraryIdGroupsGroupIdRouteImport.update({ + id: "/groups/$groupId", + path: "/groups/$groupId", + getParentRoute: () => AppLibraryLibraryIdRouteRoute + } as any); export interface FileRoutesByFullPath { - '/': typeof IndexRoute - '/app': typeof AppRouteRouteWithChildren - '/beta-complete': typeof PagesBetaCompleteRoute - '/cookie-error': typeof PagesCookieErrorRoute - '/grant-denied': typeof PagesGrantDeniedRoute - '/license': typeof PagesLicenseRoute - '/safari-error': typeof PagesSafariErrorRoute - '/app/library/$libraryId': typeof AppLibraryLibraryIdRouteRouteWithChildren - '/app/library/$libraryId/': typeof AppLibraryLibraryIdIndexRoute - '/app/library/$libraryId/groups/$groupId': typeof AppLibraryLibraryIdGroupsGroupIdRoute + "/": typeof IndexRoute; + "/app": typeof AppRouteRouteWithChildren; + "/beta-complete": typeof PagesBetaCompleteRoute; + "/cookie-error": typeof PagesCookieErrorRoute; + "/grant-denied": typeof PagesGrantDeniedRoute; + "/license": typeof PagesLicenseRoute; + "/safari-error": typeof PagesSafariErrorRoute; + "/app/library/$libraryId": typeof AppLibraryLibraryIdRouteRouteWithChildren; + "/app/library/$libraryId/": typeof AppLibraryLibraryIdIndexRoute; + "/app/library/$libraryId/groups/$groupId": typeof AppLibraryLibraryIdGroupsGroupIdRoute; } export interface FileRoutesByTo { - '/': typeof IndexRoute - '/app': typeof AppRouteRouteWithChildren - '/beta-complete': typeof PagesBetaCompleteRoute - '/cookie-error': typeof PagesCookieErrorRoute - '/grant-denied': typeof PagesGrantDeniedRoute - '/license': typeof PagesLicenseRoute - '/safari-error': typeof PagesSafariErrorRoute - '/app/library/$libraryId': typeof AppLibraryLibraryIdIndexRoute - '/app/library/$libraryId/groups/$groupId': typeof AppLibraryLibraryIdGroupsGroupIdRoute + "/": typeof IndexRoute; + "/app": typeof AppRouteRouteWithChildren; + "/beta-complete": typeof PagesBetaCompleteRoute; + "/cookie-error": typeof PagesCookieErrorRoute; + "/grant-denied": typeof PagesGrantDeniedRoute; + "/license": typeof PagesLicenseRoute; + "/safari-error": typeof PagesSafariErrorRoute; + "/app/library/$libraryId": typeof AppLibraryLibraryIdIndexRoute; + "/app/library/$libraryId/groups/$groupId": typeof AppLibraryLibraryIdGroupsGroupIdRoute; } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/': typeof IndexRoute - '/app': typeof AppRouteRouteWithChildren - '/_pages/beta-complete': typeof PagesBetaCompleteRoute - '/_pages/cookie-error': typeof PagesCookieErrorRoute - '/_pages/grant-denied': typeof PagesGrantDeniedRoute - '/_pages/license': typeof PagesLicenseRoute - '/_pages/safari-error': typeof PagesSafariErrorRoute - '/app/library/$libraryId': typeof AppLibraryLibraryIdRouteRouteWithChildren - '/app/library/$libraryId/': typeof AppLibraryLibraryIdIndexRoute - '/app/library/$libraryId/groups/$groupId': typeof AppLibraryLibraryIdGroupsGroupIdRoute + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/app": typeof AppRouteRouteWithChildren; + "/_pages/beta-complete": typeof PagesBetaCompleteRoute; + "/_pages/cookie-error": typeof PagesCookieErrorRoute; + "/_pages/grant-denied": typeof PagesGrantDeniedRoute; + "/_pages/license": typeof PagesLicenseRoute; + "/_pages/safari-error": typeof PagesSafariErrorRoute; + "/app/library/$libraryId": typeof AppLibraryLibraryIdRouteRouteWithChildren; + "/app/library/$libraryId/": typeof AppLibraryLibraryIdIndexRoute; + "/app/library/$libraryId/groups/$groupId": typeof AppLibraryLibraryIdGroupsGroupIdRoute; } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/app' - | '/beta-complete' - | '/cookie-error' - | '/grant-denied' - | '/license' - | '/safari-error' - | '/app/library/$libraryId' - | '/app/library/$libraryId/' - | '/app/library/$libraryId/groups/$groupId' - fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/app' - | '/beta-complete' - | '/cookie-error' - | '/grant-denied' - | '/license' - | '/safari-error' - | '/app/library/$libraryId' - | '/app/library/$libraryId/groups/$groupId' - id: - | '__root__' - | '/' - | '/app' - | '/_pages/beta-complete' - | '/_pages/cookie-error' - | '/_pages/grant-denied' - | '/_pages/license' - | '/_pages/safari-error' - | '/app/library/$libraryId' - | '/app/library/$libraryId/' - | '/app/library/$libraryId/groups/$groupId' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: + | "/" + | "/app" + | "/beta-complete" + | "/cookie-error" + | "/grant-denied" + | "/license" + | "/safari-error" + | "/app/library/$libraryId" + | "/app/library/$libraryId/" + | "/app/library/$libraryId/groups/$groupId"; + fileRoutesByTo: FileRoutesByTo; + to: + | "/" + | "/app" + | "/beta-complete" + | "/cookie-error" + | "/grant-denied" + | "/license" + | "/safari-error" + | "/app/library/$libraryId" + | "/app/library/$libraryId/groups/$groupId"; + id: + | "__root__" + | "/" + | "/app" + | "/_pages/beta-complete" + | "/_pages/cookie-error" + | "/_pages/grant-denied" + | "/_pages/license" + | "/_pages/safari-error" + | "/app/library/$libraryId" + | "/app/library/$libraryId/" + | "/app/library/$libraryId/groups/$groupId"; + fileRoutesById: FileRoutesById; } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute - AppRouteRoute: typeof AppRouteRouteWithChildren - PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute - PagesCookieErrorRoute: typeof PagesCookieErrorRoute - PagesGrantDeniedRoute: typeof PagesGrantDeniedRoute - PagesLicenseRoute: typeof PagesLicenseRoute - PagesSafariErrorRoute: typeof PagesSafariErrorRoute + IndexRoute: typeof IndexRoute; + AppRouteRoute: typeof AppRouteRouteWithChildren; + PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute; + PagesCookieErrorRoute: typeof PagesCookieErrorRoute; + PagesGrantDeniedRoute: typeof PagesGrantDeniedRoute; + PagesLicenseRoute: typeof PagesLicenseRoute; + PagesSafariErrorRoute: typeof PagesSafariErrorRoute; } -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/app': { - id: '/app' - path: '/app' - fullPath: '/app' - preLoaderRoute: typeof AppRouteRouteImport - parentRoute: typeof rootRouteImport +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/app": { + id: "/app"; + path: "/app"; + fullPath: "/app"; + preLoaderRoute: typeof AppRouteRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/safari-error": { + id: "/_pages/safari-error"; + path: "/safari-error"; + fullPath: "/safari-error"; + preLoaderRoute: typeof PagesSafariErrorRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/license": { + id: "/_pages/license"; + path: "/license"; + fullPath: "/license"; + preLoaderRoute: typeof PagesLicenseRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/grant-denied": { + id: "/_pages/grant-denied"; + path: "/grant-denied"; + fullPath: "/grant-denied"; + preLoaderRoute: typeof PagesGrantDeniedRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/cookie-error": { + id: "/_pages/cookie-error"; + path: "/cookie-error"; + fullPath: "/cookie-error"; + preLoaderRoute: typeof PagesCookieErrorRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/beta-complete": { + id: "/_pages/beta-complete"; + path: "/beta-complete"; + fullPath: "/beta-complete"; + preLoaderRoute: typeof PagesBetaCompleteRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/app/library/$libraryId": { + id: "/app/library/$libraryId"; + path: "/library/$libraryId"; + fullPath: "/app/library/$libraryId"; + preLoaderRoute: typeof AppLibraryLibraryIdRouteRouteImport; + parentRoute: typeof AppRouteRoute; + }; + "/app/library/$libraryId/": { + id: "/app/library/$libraryId/"; + path: "/"; + fullPath: "/app/library/$libraryId/"; + preLoaderRoute: typeof AppLibraryLibraryIdIndexRouteImport; + parentRoute: typeof AppLibraryLibraryIdRouteRoute; + }; + "/app/library/$libraryId/groups/$groupId": { + id: "/app/library/$libraryId/groups/$groupId"; + path: "/groups/$groupId"; + fullPath: "/app/library/$libraryId/groups/$groupId"; + preLoaderRoute: typeof AppLibraryLibraryIdGroupsGroupIdRouteImport; + parentRoute: typeof AppLibraryLibraryIdRouteRoute; + }; } - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/safari-error': { - id: '/_pages/safari-error' - path: '/safari-error' - fullPath: '/safari-error' - preLoaderRoute: typeof PagesSafariErrorRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/license': { - id: '/_pages/license' - path: '/license' - fullPath: '/license' - preLoaderRoute: typeof PagesLicenseRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/grant-denied': { - id: '/_pages/grant-denied' - path: '/grant-denied' - fullPath: '/grant-denied' - preLoaderRoute: typeof PagesGrantDeniedRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/cookie-error': { - id: '/_pages/cookie-error' - path: '/cookie-error' - fullPath: '/cookie-error' - preLoaderRoute: typeof PagesCookieErrorRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/beta-complete': { - id: '/_pages/beta-complete' - path: '/beta-complete' - fullPath: '/beta-complete' - preLoaderRoute: typeof PagesBetaCompleteRouteImport - parentRoute: typeof rootRouteImport - } - '/app/library/$libraryId': { - id: '/app/library/$libraryId' - path: '/library/$libraryId' - fullPath: '/app/library/$libraryId' - preLoaderRoute: typeof AppLibraryLibraryIdRouteRouteImport - parentRoute: typeof AppRouteRoute - } - '/app/library/$libraryId/': { - id: '/app/library/$libraryId/' - path: '/' - fullPath: '/app/library/$libraryId/' - preLoaderRoute: typeof AppLibraryLibraryIdIndexRouteImport - parentRoute: typeof AppLibraryLibraryIdRouteRoute - } - '/app/library/$libraryId/groups/$groupId': { - id: '/app/library/$libraryId/groups/$groupId' - path: '/groups/$groupId' - fullPath: '/app/library/$libraryId/groups/$groupId' - preLoaderRoute: typeof AppLibraryLibraryIdGroupsGroupIdRouteImport - parentRoute: typeof AppLibraryLibraryIdRouteRoute - } - } } interface AppLibraryLibraryIdRouteRouteChildren { - AppLibraryLibraryIdIndexRoute: typeof AppLibraryLibraryIdIndexRoute - AppLibraryLibraryIdGroupsGroupIdRoute: typeof AppLibraryLibraryIdGroupsGroupIdRoute + AppLibraryLibraryIdIndexRoute: typeof AppLibraryLibraryIdIndexRoute; + AppLibraryLibraryIdGroupsGroupIdRoute: typeof AppLibraryLibraryIdGroupsGroupIdRoute; } const AppLibraryLibraryIdRouteRouteChildren: AppLibraryLibraryIdRouteRouteChildren = - { - AppLibraryLibraryIdIndexRoute: AppLibraryLibraryIdIndexRoute, - AppLibraryLibraryIdGroupsGroupIdRoute: - AppLibraryLibraryIdGroupsGroupIdRoute, - } + { + AppLibraryLibraryIdIndexRoute: AppLibraryLibraryIdIndexRoute, + AppLibraryLibraryIdGroupsGroupIdRoute: + AppLibraryLibraryIdGroupsGroupIdRoute + }; const AppLibraryLibraryIdRouteRouteWithChildren = - AppLibraryLibraryIdRouteRoute._addFileChildren( - AppLibraryLibraryIdRouteRouteChildren, - ) + AppLibraryLibraryIdRouteRoute._addFileChildren( + AppLibraryLibraryIdRouteRouteChildren + ); interface AppRouteRouteChildren { - AppLibraryLibraryIdRouteRoute: typeof AppLibraryLibraryIdRouteRouteWithChildren + AppLibraryLibraryIdRouteRoute: typeof AppLibraryLibraryIdRouteRouteWithChildren; } const AppRouteRouteChildren: AppRouteRouteChildren = { - AppLibraryLibraryIdRouteRoute: AppLibraryLibraryIdRouteRouteWithChildren, -} + AppLibraryLibraryIdRouteRoute: AppLibraryLibraryIdRouteRouteWithChildren +}; const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( - AppRouteRouteChildren, -) + AppRouteRouteChildren +); const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, - AppRouteRoute: AppRouteRouteWithChildren, - PagesBetaCompleteRoute: PagesBetaCompleteRoute, - PagesCookieErrorRoute: PagesCookieErrorRoute, - PagesGrantDeniedRoute: PagesGrantDeniedRoute, - PagesLicenseRoute: PagesLicenseRoute, - PagesSafariErrorRoute: PagesSafariErrorRoute, -} + IndexRoute: IndexRoute, + AppRouteRoute: AppRouteRouteWithChildren, + PagesBetaCompleteRoute: PagesBetaCompleteRoute, + PagesCookieErrorRoute: PagesCookieErrorRoute, + PagesGrantDeniedRoute: PagesGrantDeniedRoute, + PagesLicenseRoute: PagesLicenseRoute, + PagesSafariErrorRoute: PagesSafariErrorRoute +}; export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() + ._addFileChildren(rootRouteChildren) + ._addFileTypes(); diff --git a/src/frontend/search/search-results.tsx b/src/frontend/search/search-results.tsx index c5a694a36..cf1c30014 100644 --- a/src/frontend/search/search-results.tsx +++ b/src/frontend/search/search-results.tsx @@ -24,13 +24,13 @@ export function SearchResults(props: SearchResultsProps): ReactNode { const accessData = useAccessData(); if (searchDbQuery.isPending || libraryQuery.isPending) { - return ; - } else if ( - searchDbQuery.isError || - libraryQuery.isError || - !searchDbQuery.data - ) { + return ; + } else if (libraryQuery.isError) { return ; + } else if (searchDbQuery.isError) { + return ; + } else if (!searchDbQuery.data) { + return ; } const insertables = libraryQuery.data.insertables; const searchResults = doSearch( diff --git a/src/frontend/search/search.test.ts b/src/frontend/search/search.test.ts index 7138b542b..46343ab6b 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/search/search.test.ts @@ -138,6 +138,21 @@ describe("doSearch part-number matching", () => { expect(hits[0].configuration).toEqual({ length: "long" }); }); + // "Bracket 217" matches the part-number field on "217", but no single record + // matches the whole query — the row must still show a part number. + it("falls back to the default record when no one record matches the query", () => { + const searchDb = buildSearchDb(library(), recordsMap); + const { hits } = doSearch( + searchDb, + "Bracket 217", + undefined, + undefined, + true + ); + expect(hits).toHaveLength(1); + expect(hits[0].partNumber).toBe("217-2600"); + }); + it("attaches the default (first) record for a title match", () => { const searchDb = buildSearchDb(library(), recordsMap); const { hits } = doSearch( diff --git a/src/frontend/search/search.ts b/src/frontend/search/search.ts index 1724311d3..dd12b69d0 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/search/search.ts @@ -152,14 +152,15 @@ function matchedRecord( query: string ): SearchRecord | undefined { const matchedFields = Object.values(result.match).flat(); - if (matchedFields.includes("partNumbers")) { - return findBestRecord(query, document.records, (r) => r.partNumber); - } - if (matchedFields.includes("partNames")) { - return findBestRecord(query, document.records, (r) => r.name); - } - // Pure title (or group) match: show the default configuration's record. - return document.records[0]; + const byNumber = matchedFields.includes("partNumbers") + ? findBestRecord(query, document.records, (r) => r.partNumber) + : undefined; + const byName = matchedFields.includes("partNames") + ? findBestRecord(query, document.records, (r) => r.name) + : undefined; + // A multi-term query can match the field without any one record matching the + // whole query, so fall back rather than leaving the row with no record. + return byNumber ?? byName ?? document.records[0]; } /** diff --git a/src/shared/canonical-configuration.test.ts b/src/shared/canonical-configuration.test.ts new file mode 100644 index 000000000..d81e8934d --- /dev/null +++ b/src/shared/canonical-configuration.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_CANONICAL_CONFIGURATION, + DEFAULT_CONFIGURATION_KEY, + canonicalConfigurationKey, + canonicalizeConfiguration, + decodeCanonicalConfiguration, + encodeCanonicalConfiguration +} from "./canonical-configuration"; +import { ParameterValues, VisibilityType } from "./configuration-models"; +import { + boolParam, + enumParam, + quantityParam, + TEST_UNIT_INFO +} from "../__test_utils__/configuration-fixtures"; + +/** The key of an already-canonical selection, which is what callers compare. */ +function keyOf(canonicalConfiguration: ParameterValues): string { + return canonicalConfigurationKey( + encodeCanonicalConfiguration(canonicalConfiguration) + ); +} + +describe("canonicalizeConfiguration", () => { + const size = enumParam("size", ["s", "l"]); + const flag = boolParam("flag"); + const length = quantityParam("length"); + + function canon( + configuration: Record, + parameters = [size, flag, length] + ) { + return canonicalizeConfiguration( + configuration, + parameters, + TEST_UNIT_INFO + ); + } + + it("drops values that match the parameter default", () => { + // "s" and "false" are the defaults, so Onshape renders them anyway. + expect(canon({ size: "s", flag: "false", length: "1 in" })).toEqual({}); + }); + + it("keeps only what differs from the defaults", () => { + expect(canon({ size: "l", flag: "false" })).toEqual({ size: "l" }); + }); + + it("emits parameters in declaration order, not object order", () => { + const a = canon({ flag: "true", size: "l" }); + const b = canon({ size: "l", flag: "true" }); + expect(Object.keys(a)).toEqual(["size", "flag"]); + expect(encodeCanonicalConfiguration(a)).toBe( + encodeCanonicalConfiguration(b) + ); + expect(keyOf(a)).toBe(keyOf(b)); + }); + + it("collapses equivalent quantity spellings", () => { + const keys = ["2in", "2 in", "(1 + 1) in"].map((value) => + keyOf(canon({ length: value })) + ); + expect(new Set(keys).size).toBe(1); + // ...and it is not the default key, since 2 in != the 1 in default. + expect(keys[0]).not.toBe(keyOf({})); + }); + + it("drops a parameter hidden by its visibility condition", () => { + const hidden = enumParam("hidden", ["x", "y"], { + condition: { + type: VisibilityType.EQUAL, + id: "size", + value: "s" + } + }); + // size=l hides `hidden`, so its value can't affect the render. + expect(canon({ size: "l", hidden: "y" }, [size, hidden])).toEqual({ + size: "l" + }); + }); + + it("ignores parameters that aren't set", () => { + expect(canon({ size: "l" })).toEqual({ size: "l" }); + }); +}); + +// An indexed record holds enumerated values only; the insert menu holds the whole +// selection. Canonicalizing both is what makes the two agree on a thumbnail. +describe("canonical keys agree across surfaces", () => { + const size = enumParam("size", ["s", "l"]); + const flag = boolParam("flag"); + const finish = enumParam("finish", ["matte", "gloss"], { + isCosmetic: true + }); + const length = quantityParam("length"); + const parameters = [size, flag, finish, length]; + + it("keys an enumerated record and the equivalent selection alike", () => { + // What indexing stores: enumerated values, no cosmetic/quantity params. + const record = canonicalizeConfiguration( + { size: "l", flag: "false" }, + parameters + ); + // What the insert menu holds: everything, including the defaults. + const selection = canonicalizeConfiguration( + { size: "l", flag: "false", finish: "matte", length: "1 in" }, + parameters, + TEST_UNIT_INFO + ); + expect(keyOf(record)).toBe(keyOf(selection)); + }); + + it("keys a non-default cosmetic or quantity value differently", () => { + // Enumeration never varies these, but they do change what renders, so + // the selection must not collide with the enumerated record. + const record = canonicalizeConfiguration({ size: "l" }, parameters); + const selections: ParameterValues[] = [ + { size: "l", finish: "gloss" }, + { size: "l", length: "2 in" } + ]; + for (const selection of selections) { + expect( + keyOf( + canonicalizeConfiguration( + selection, + parameters, + TEST_UNIT_INFO + ) + ) + ).not.toBe(keyOf(record)); + } + }); +}); + +describe("canonicalConfigurationKey", () => { + it("maps an empty configuration to the default key", () => { + expect(keyOf({})).toBe(DEFAULT_CONFIGURATION_KEY); + }); + + it("gives different configurations different keys", () => { + expect(keyOf({ a: "1" })).not.toBe(keyOf({ a: "2" })); + }); +}); + +describe("decodeCanonicalConfiguration", () => { + it("round-trips an encoded configuration", () => { + const canonicalConfiguration: ParameterValues = { + size: "l", + flag: "true" + }; + expect( + decodeCanonicalConfiguration( + encodeCanonicalConfiguration(canonicalConfiguration) + ) + ).toEqual(canonicalConfiguration); + }); + + it("reads the default as an empty selection", () => { + expect( + decodeCanonicalConfiguration(DEFAULT_CANONICAL_CONFIGURATION) + ).toEqual({}); + }); + + it("keeps a value containing the separator character", () => { + // Only the first `=` splits, so an expression-ish value survives. + const canonicalConfiguration: ParameterValues = { expr: "a=b" }; + expect( + decodeCanonicalConfiguration( + encodeCanonicalConfiguration(canonicalConfiguration) + ) + ).toEqual(canonicalConfiguration); + }); + + it("round-trips a canonicalized selection", () => { + const size = enumParam("size", ["s", "l"]); + const length = quantityParam("length"); + const canonicalConfiguration = canonicalizeConfiguration( + { size: "l", length: "2 in" }, + [size, length], + TEST_UNIT_INFO + ); + expect( + decodeCanonicalConfiguration( + encodeCanonicalConfiguration(canonicalConfiguration) + ) + ).toEqual(canonicalConfiguration); + }); +}); diff --git a/src/shared/canonical-configuration.ts b/src/shared/canonical-configuration.ts new file mode 100644 index 000000000..062390c1f --- /dev/null +++ b/src/shared/canonical-configuration.ts @@ -0,0 +1,129 @@ +/** + * A `canonicalConfiguration` addresses a render — thumbnails, R2 keys, search + * records. Only insert/derive needs the user's literal `configuration`. + */ +import { + type ConfigurationParameter, + ParameterType, + type ParameterValues, + type UnitInfo +} from "./configuration-models"; +import { evaluateCondition, getEvaluateOptions } from "./configuration-utils"; +import { evaluateExpression } from "./input-parser"; + +/** The element default, which is what an empty canonical configuration encodes. */ +export const DEFAULT_CANONICAL_CONFIGURATION = ""; + +/** The key for an element's default configuration (what everything falls back to). */ +export const DEFAULT_CONFIGURATION_KEY = "default"; + +/** Normalizes one parameter's raw value to its canonical spelling. */ +function canonicalizeValue( + parameter: ConfigurationParameter, + value: string, + unitInfo?: UnitInfo +): string { + if (parameter.type === ParameterType.QUANTITY && unitInfo) { + // "1in", "1 in", and "(0.5 + 0.5) in" are one configuration; the rounded + // display form is its single spelling. Unparseable values ride as-is. + const result = evaluateExpression( + value, + getEvaluateOptions(parameter, unitInfo) + ); + return result.hasError ? value.trim() : result.displayExpression; + } + if (parameter.type === ParameterType.BOOLEAN) { + return value.trim().toLowerCase(); + } + return value.trim(); +} + +/** + * Reduces a configuration to the one spelling every equivalent selection shares, + * so their thumbnails resolve to one cache entry. Drops defaults and hidden values. + */ +export function canonicalizeConfiguration( + configuration: ParameterValues, + parameters: ConfigurationParameter[], + unitInfo?: UnitInfo +): ParameterValues { + const canonicalConfiguration: ParameterValues = {}; + for (const parameter of parameters) { + const value = configuration[parameter.id]; + if (value === undefined) { + continue; + } + // Onshape doesn't apply a hidden parameter, so it can't change the render. + if ( + !evaluateCondition(parameter.condition, configuration, parameters) + ) { + continue; + } + const canonicalValue = canonicalizeValue(parameter, value, unitInfo); + const canonicalDefault = canonicalizeValue( + parameter, + parameter.default, + unitInfo + ); + if (canonicalValue === canonicalDefault) { + continue; + } + canonicalConfiguration[parameter.id] = canonicalValue; + } + return canonicalConfiguration; +} + +/** Encodes canonical values for a url, an R2 key, or Onshape's `configuration`. */ +export function encodeCanonicalConfiguration( + canonicalConfiguration: ParameterValues +): string { + return Object.entries(canonicalConfiguration) + .map(([id, value]) => `${id}=${value}`) + .join(";"); +} + +/** + * The inverse of {@link encodeCanonicalConfiguration}. Only the first `=` splits, + * so a value may hold one; neither side escapes `;`, which no value contains. + */ +export function decodeCanonicalConfiguration( + canonicalConfiguration: string +): ParameterValues { + const values: ParameterValues = {}; + if (canonicalConfiguration === DEFAULT_CANONICAL_CONFIGURATION) { + return values; + } + for (const entry of canonicalConfiguration.split(";")) { + const separator = entry.indexOf("="); + if (separator !== -1) { + values[entry.slice(0, separator)] = entry.slice(separator + 1); + } + } + return values; +} + +/** + * A short, stable key for a url or R2 key, since a configuration is unbounded. + * Only avoids collisions within one element, so a fast sync hash is plenty. + */ +export function canonicalConfigurationKey( + canonicalConfiguration: string +): string { + if (canonicalConfiguration === DEFAULT_CANONICAL_CONFIGURATION) { + return DEFAULT_CONFIGURATION_KEY; + } + // cyrb53 + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < canonicalConfiguration.length; i++) { + const ch = canonicalConfiguration.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0); + return hash.toString(36); +} diff --git a/src/shared/configuration-combinations.test.ts b/src/shared/configuration-combinations.test.ts index 61369b140..eb85eb24c 100644 --- a/src/shared/configuration-combinations.test.ts +++ b/src/shared/configuration-combinations.test.ts @@ -200,27 +200,23 @@ describe("countConfigurations", () => { describe("isIndexingEnabled", () => { it.each([ - // A vendor insertable under the threshold indexes without being asked. - { vendor: true, band: IndexingBand.AUTOMATIC, force: false, on: true }, - // A custom one never does, until an admin says so. - { - vendor: false, - band: IndexingBand.AUTOMATIC, - force: false, - on: false - }, - { vendor: false, band: IndexingBand.AUTOMATIC, force: true, on: true }, - // Past the threshold it waits to be enabled, vendor or not. - { vendor: true, band: IndexingBand.MANUAL, force: false, on: false }, - { vendor: true, band: IndexingBand.MANUAL, force: true, on: true }, - { vendor: false, band: IndexingBand.MANUAL, force: false, on: false }, + // Any non-custom insertable under the threshold indexes without being + // asked — including one whose vendor the heuristic never recognized. + { custom: false, band: IndexingBand.AUTOMATIC, force: false, on: true }, + // A custom part never does, until an admin says so. + { custom: true, band: IndexingBand.AUTOMATIC, force: false, on: false }, + { custom: true, band: IndexingBand.AUTOMATIC, force: true, on: true }, + // Past the threshold it waits to be enabled, custom or not. + { custom: false, band: IndexingBand.MANUAL, force: false, on: false }, + { custom: false, band: IndexingBand.MANUAL, force: true, on: true }, + { custom: true, band: IndexingBand.MANUAL, force: false, on: false }, // Past the cap there is nothing to enumerate, so enabling changes nothing. - { vendor: true, band: IndexingBand.EXCEEDED, force: true, on: false }, - { vendor: true, band: IndexingBand.EXCEEDED, force: false, on: false } + { custom: false, band: IndexingBand.EXCEEDED, force: true, on: false }, + { custom: false, band: IndexingBand.EXCEEDED, force: false, on: false } ])( - "vendor=$vendor band=$band force=$force -> $on", - ({ vendor, band, force, on }) => { - expect(isIndexingEnabled(vendor, band, force)).toBe(on); + "custom=$custom band=$band force=$force -> $on", + ({ custom, band, force, on }) => { + expect(isIndexingEnabled(custom, band, force)).toBe(on); } ); }); diff --git a/src/shared/configuration-combinations.ts b/src/shared/configuration-combinations.ts index a6cb6cff8..24fba9bbc 100644 --- a/src/shared/configuration-combinations.ts +++ b/src/shared/configuration-combinations.ts @@ -20,16 +20,14 @@ import { evaluateCondition, getVisibleOptions } from "./configuration-utils"; export const MAX_PART_NUMBER_CONFIGURATIONS = 512; /** - * Below this many combinations, a vendor insertable is indexed automatically on - * load. At or above it, indexing waits for an admin to turn it on (after - * trimming the count via "exclude from properties"); see the `MANY_CONFIGURATIONS` - * build issue. + * At or above this, indexing waits for an admin, who can trim the count back + * with "exclude from properties"; see the `MANY_CONFIGURATIONS` build issue. */ export const AUTO_INDEX_THRESHOLD = 128; /** Where a configuration count sits relative to the two indexing limits. */ export enum IndexingBand { - /** Under {@link AUTO_INDEX_THRESHOLD}: a vendor insertable indexes on load. */ + /** Under {@link AUTO_INDEX_THRESHOLD}: a non-custom insertable indexes on load. */ AUTOMATIC = "automatic", /** Up to {@link MAX_PART_NUMBER_CONFIGURATIONS}: an admin must enable it. */ MANUAL = "manual", @@ -38,16 +36,11 @@ export enum IndexingBand { } /** - * Whether an insertable's part numbers end up indexed: automatically for a - * vendor insertable under the auto threshold, by hand wherever an admin enables - * it, and never past the cap — enumeration stops there, so there is nothing to - * index however the flag is set. - * - * Shared with the admin card, so what it reports can't drift from what the load - * path actually does. + * Shared with the admin card, so it can't drift from what the load path does. + * Custom parts are the one exclusion: team-made, so there is no metadata to parse. */ export function isIndexingEnabled( - hasVendor: boolean, + isCustom: boolean, band: IndexingBand, forceIndex: boolean ): boolean { @@ -57,7 +50,7 @@ export function isIndexingEnabled( case IndexingBand.MANUAL: return forceIndex; case IndexingBand.AUTOMATIC: - return hasVendor || forceIndex; + return !isCustom || forceIndex; } } @@ -82,9 +75,8 @@ export function countConfigurations( if (capped) { return { count: null, band: IndexingBand.EXCEEDED }; } - // An insertable with nothing to vary enumerates to the single default - // configuration, which isn't a configuration of its own: a non-configurable - // insertable has none. + // The lone default that nothing-to-vary enumerates to is not a configuration + // of its own: a non-configurable insertable has none. const count = configurations.some( (configuration) => Object.keys(configuration).length > 0 ) diff --git a/src/shared/configuration-utils.test.ts b/src/shared/configuration-utils.test.ts index 628819aef..009987ed6 100644 --- a/src/shared/configuration-utils.test.ts +++ b/src/shared/configuration-utils.test.ts @@ -1,22 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - DEFAULT_CONFIGURATION_KEY, - canonicalizeConfiguration, - configurationKey, - encodeCanonicalConfiguration, - findRecordForConfiguration -} from "./configuration-utils"; -import { - ParameterValues, - SearchRecord, - VisibilityType -} from "./configuration-models"; -import { - boolParam, - enumParam, - quantityParam, - TEST_UNIT_INFO -} from "../__test_utils__/configuration-fixtures"; +import { findRecordForConfiguration } from "./configuration-utils"; +import { SearchRecord } from "./configuration-models"; function rec( configuration: Record, @@ -64,128 +48,3 @@ describe("findRecordForConfiguration", () => { ).toBeUndefined(); }); }); - -describe("canonicalizeConfiguration", () => { - const size = enumParam("size", ["s", "l"]); - const flag = boolParam("flag"); - const length = quantityParam("length"); - - function canon( - configuration: Record, - parameters = [size, flag, length] - ) { - return canonicalizeConfiguration( - configuration, - parameters, - TEST_UNIT_INFO - ); - } - - it("drops values that match the parameter default", () => { - // "s" and "false" are the defaults, so Onshape renders them anyway. - expect(canon({ size: "s", flag: "false", length: "1 in" })).toEqual({}); - }); - - it("keeps only what differs from the defaults", () => { - expect(canon({ size: "l", flag: "false" })).toEqual({ size: "l" }); - }); - - it("emits parameters in declaration order, not object order", () => { - const a = canon({ flag: "true", size: "l" }); - const b = canon({ size: "l", flag: "true" }); - expect(Object.keys(a)).toEqual(["size", "flag"]); - expect(encodeCanonicalConfiguration(a)).toBe( - encodeCanonicalConfiguration(b) - ); - expect(configurationKey(a)).toBe(configurationKey(b)); - }); - - it("collapses equivalent quantity spellings", () => { - const keys = ["2in", "2 in", "(1 + 1) in"].map((value) => - configurationKey(canon({ length: value })) - ); - expect(new Set(keys).size).toBe(1); - // ...and it is not the default key, since 2 in != the 1 in default. - expect(keys[0]).not.toBe(configurationKey({})); - }); - - it("drops a parameter hidden by its visibility condition", () => { - const hidden = enumParam("hidden", ["x", "y"], { - condition: { - type: VisibilityType.EQUAL, - id: "size", - value: "s" - } - }); - // size=l hides `hidden`, so its value can't affect the render. - expect(canon({ size: "l", hidden: "y" }, [size, hidden])).toEqual({ - size: "l" - }); - }); - - it("ignores parameters that aren't set", () => { - expect(canon({ size: "l" })).toEqual({ size: "l" }); - }); -}); - -// The two sides that address a thumbnail derive their configuration differently: -// an indexed record comes from `enumerateConfigurations` (visible, non-cosmetic -// enum/boolean values only, defaults included), while the insert menu holds the -// user's whole selection. Canonicalizing both is what makes them agree. -describe("canonical keys agree across surfaces", () => { - const size = enumParam("size", ["s", "l"]); - const flag = boolParam("flag"); - const finish = enumParam("finish", ["matte", "gloss"], { - isCosmetic: true - }); - const length = quantityParam("length"); - const parameters = [size, flag, finish, length]; - - it("keys an enumerated record and the equivalent selection alike", () => { - // What indexing stores: enumerated values, no cosmetic/quantity params. - const record = canonicalizeConfiguration( - { size: "l", flag: "false" }, - parameters - ); - // What the insert menu holds: everything, including the defaults. - const selection = canonicalizeConfiguration( - { size: "l", flag: "false", finish: "matte", length: "1 in" }, - parameters, - TEST_UNIT_INFO - ); - expect(configurationKey(record)).toBe(configurationKey(selection)); - }); - - it("keys a non-default cosmetic or quantity value differently", () => { - // Enumeration never varies these, but they do change what renders, so - // the selection must not collide with the enumerated record. - const record = canonicalizeConfiguration({ size: "l" }, parameters); - const selections: ParameterValues[] = [ - { size: "l", finish: "gloss" }, - { size: "l", length: "2 in" } - ]; - for (const selection of selections) { - expect( - configurationKey( - canonicalizeConfiguration( - selection, - parameters, - TEST_UNIT_INFO - ) - ) - ).not.toBe(configurationKey(record)); - } - }); -}); - -describe("configurationKey", () => { - it("maps an empty configuration to the default key", () => { - expect(configurationKey({})).toBe(DEFAULT_CONFIGURATION_KEY); - }); - - it("gives different configurations different keys", () => { - expect(configurationKey({ a: "1" })).not.toBe( - configurationKey({ a: "2" }) - ); - }); -}); diff --git a/src/shared/configuration-utils.ts b/src/shared/configuration-utils.ts index 626ceb767..b25994a8e 100644 --- a/src/shared/configuration-utils.ts +++ b/src/shared/configuration-utils.ts @@ -12,11 +12,7 @@ import { VisibilityType } from "./configuration-models"; import { LogicalOp, QuantityType, Unit } from "./configuration-enums"; -import { - type EvaluateOptions, - evaluateExpression, - valueWithUnits -} from "./input-parser"; +import { type EvaluateOptions, valueWithUnits } from "./input-parser"; /** * Finds the record a (full) configuration selection produces. Records are keyed @@ -195,114 +191,3 @@ export function getEvaluateOptions( ...minAndMax }; } - -/** Normalizes one parameter's raw value to its canonical spelling. */ -function canonicalizeValue( - parameter: ConfigurationParameter, - value: string, - unitInfo?: UnitInfo -): string { - if (parameter.type === ParameterType.QUANTITY && unitInfo) { - // "1in", "1 in", and "(0.5 + 0.5) in" are the same configuration; the - // evaluated, rounded display form is the one spelling of it. An - // unparseable value can't be normalized, so it rides as-is. - const result = evaluateExpression( - value, - getEvaluateOptions(parameter, unitInfo) - ); - return result.hasError ? value.trim() : result.displayExpression; - } - if (parameter.type === ParameterType.BOOLEAN) { - return value.trim().toLowerCase(); - } - return value.trim(); -} - -/** - * Reduces a configuration to the one spelling shared by every selection that - * renders the same thing, so thumbnails of equivalent configurations resolve to - * a single cache entry. - * - * Parameters are emitted in declaration order (object key order is not - * meaningful); values are normalized per type; parameters hidden by a - * visibility condition are dropped, as are values matching the parameter's - * default — Onshape applies the default for anything omitted, so an - * all-defaults selection canonicalizes to `{}`, which is the default thumbnail. - * - * `unitInfo` is only needed to evaluate quantity expressions; omit it where the - * configuration can't contain them (enumerated records hold enum and boolean - * values only), and those values ride through trimmed. - */ -export function canonicalizeConfiguration( - configuration: ParameterValues, - parameters: ConfigurationParameter[], - unitInfo?: UnitInfo -): ParameterValues { - const canonical: ParameterValues = {}; - for (const parameter of parameters) { - const value = configuration[parameter.id]; - if (value === undefined) { - continue; - } - // Onshape doesn't apply a hidden parameter, so it can't change the render. - if ( - !evaluateCondition(parameter.condition, configuration, parameters) - ) { - continue; - } - const canonicalValue = canonicalizeValue(parameter, value, unitInfo); - const canonicalDefault = canonicalizeValue( - parameter, - parameter.default, - unitInfo - ); - if (canonicalValue === canonicalDefault) { - continue; - } - canonical[parameter.id] = canonicalValue; - } - return canonical; -} - -/** The canonical configuration as one string; empty means the element default. */ -export function encodeCanonicalConfiguration( - canonical: ParameterValues -): string { - return Object.entries(canonical) - .map(([id, value]) => `${id}=${value}`) - .join(";"); -} - -/** - * A short, stable key for a canonical configuration, used in thumbnail URLs and - * R2 keys — a configuration string is unbounded and holds arbitrary characters. - * Not a security boundary: it only has to avoid collisions within one element, - * so a fast 53-bit hash is plenty (and, unlike SubtleCrypto, is synchronous). - */ -export function configurationKey(canonical: ParameterValues): string { - return configurationKeyFor(encodeCanonicalConfiguration(canonical)); -} - -/** {@link configurationKey}, for an already-encoded canonical configuration. */ -export function configurationKeyFor(encoded: string): string { - if (encoded === "") { - return DEFAULT_CONFIGURATION_KEY; - } - // cyrb53 - let h1 = 0xdeadbeef; - let h2 = 0x41c6ce57; - for (let i = 0; i < encoded.length; i++) { - const ch = encoded.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); - h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); - h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); - const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0); - return hash.toString(36); -} - -/** The key for an element's default configuration (what everything falls back to). */ -export const DEFAULT_CONFIGURATION_KEY = "default"; diff --git a/src/shared/thumbnails.ts b/src/shared/thumbnails.ts index ab6aa2084..2a06ccf49 100644 --- a/src/shared/thumbnails.ts +++ b/src/shared/thumbnails.ts @@ -1,28 +1,15 @@ -/** - * Thumbnail addressing, shared so the client builds exactly the URLs the worker - * serves, and so R2 keys have one definition. - */ +/** Thumbnail addressing, shared so the client builds the urls the worker serves. */ import { + DEFAULT_CANONICAL_CONFIGURATION, DEFAULT_CONFIGURATION_KEY, - configurationKeyFor -} from "./configuration-utils"; + canonicalConfigurationKey +} from "./canonical-configuration"; import { ThumbnailSize } from "./types"; -/** A stored thumbnail never changes, since its key pins the microversion. */ -export const THUMBNAIL_CACHE_TTL = 30 * 24 * 3600; - -/** - * How long a fallback (the default configuration standing in for one we haven't - * rendered yet) may be cached. Short on purpose: the real thumbnail can land at - * any moment, and an `immutable` fallback would pin the wrong image for a month. - */ +/** Short on purpose: the real render can land at any moment and must take over. */ export const THUMBNAIL_FALLBACK_CACHE_TTL = 60; -/** - * The R2 key for a thumbnail. Default-configuration thumbnails live under their - * own prefix because everything falls back to them, so only the `config/` prefix - * carries an expiry lifecycle rule. - */ +/** Defaults get their own prefix: everything falls back to them, so they never expire. */ export function thumbnailKey( elementId: string, microversionId: string, @@ -39,8 +26,8 @@ export interface ThumbnailUrlOptions { elementId: string; microversionId: string; size: ThumbnailSize; - /** The encoded canonical configuration; omit or empty for the default. */ - configuration?: string; + /** Empty (the default) serves the element's own thumbnail. */ + canonicalConfiguration: string; /** Whether a miss should kick off generating this configuration. */ warm?: boolean; } @@ -50,40 +37,30 @@ export function thumbnailUrl({ elementId, microversionId, size, - configuration, + canonicalConfiguration, warm }: ThumbnailUrlOptions): string { const query = new URLSearchParams({ v: microversionId }); - if (configuration) { - query.set("c", configuration); + if (canonicalConfiguration !== DEFAULT_CANONICAL_CONFIGURATION) { + query.set("c", canonicalConfiguration); if (warm) { - query.set("warm", "1"); + query.set("warm", "true"); } } return `/api/thumbnail/${size}/${elementId}?${query}`; } -/** The key identifying a configuration within an element's thumbnails. */ -export function thumbnailConfigurationKey(configuration?: string): string { - return configuration - ? configurationKeyFor(configuration) - : DEFAULT_CONFIGURATION_KEY; -} - /** What identifies one configuration's thumbnails to render. */ export interface ThumbnailParams { elementId: string; microversionId: string; - /** The encoded canonical configuration; never empty (defaults load eagerly). */ - configuration: string; + /** Never the default, which loads eagerly with the element. */ + canonicalConfiguration: string; } -/** - * The workflow instance id for a configuration's render. Deterministic so two - * requests for the same thumbnail collapse onto one run: Cloudflare rejects a - * duplicate instance id, which is the coalescing we want. - */ +/** Deterministic, so duplicate requests collapse onto one run: Cloudflare + * rejects a repeated instance id, which is the coalescing we want. */ export function thumbnailWorkflowId(params: ThumbnailParams): string { - const configurationKey = thumbnailConfigurationKey(params.configuration); - return `thumbnail-${params.elementId}-${params.microversionId}-${configurationKey}`; + const key = canonicalConfigurationKey(params.canonicalConfiguration); + return `thumbnail-${params.elementId}-${params.microversionId}-${key}`; } diff --git a/src/shared/types.ts b/src/shared/types.ts index e882962a3..fadb9e4bd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -34,6 +34,8 @@ export function isWithinAccessLevel( export enum Vendor { AM = "AM", + /** A team-made part, which is never indexed; see `isIndexingEnabled`. */ + CUSTOM = "Custom", LAI = "LAI", MCM = "MCM", REDUX = "Redux", @@ -45,6 +47,11 @@ export enum Vendor { WCP = "WCP" } +/** Custom parts have no vendor metadata to parse, so they are never indexed. */ +export function isCustomPart(vendors: Vendor[]): boolean { + return vendors.includes(Vendor.CUSTOM); +} + /** * Gets the full name of a vendor. */ @@ -52,6 +59,8 @@ export function getVendorName(vendor: Vendor) { switch (vendor) { case Vendor.AM: return "AndyMark"; + case Vendor.CUSTOM: + return "Custom"; case Vendor.LAI: return "Last Anvil Innovations"; case Vendor.MCM: From 44590672e15b31af6f7b58c4caf2a168088b4904 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 05:04:31 +0000 Subject: [PATCH 10/23] Insert-menu header, target path in the body, version consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Insert menu header.** The part number and name the selected configuration produces moved out from under the preview image and into the modal's header, under the element's name — all three are shown, since the element name is how the part was found and the part number and name are what gets inserted. The header is updated rather than rendered, so it follows the selection as the configuration changes. ConfigurationWrapper reports the matched record upward instead of rendering it, and the favorite editor gets the same header rather than losing the display. **Insert target.** Both insert routes took the tab being inserted into as URL path segments, reassembled with non-null assertions and an unchecked cast to the instance type. A missing or unrecognized piece became a nonsense Onshape URL and an opaque failure rather than a complaint. They now take the target as one typed object in the body, validated at the boundary, and the path guards check the instance type against its literals rather than trusting whatever arrived. **Stale version ids.** A tab whose microversion hasn't changed is skipped by a reload, so it never reaches saveInsertable and kept the version id it was last loaded at, while the group row advanced to the new one. The geometry is identical, but that id is what insertion and every document link are built from, so a skipped insertable now moves forward with its group. Also give the stored search index its content type back: it is served straight from R2 via writeHttpMetadata, so dropping it from the put left the response untyped. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- src/backend/library-data.ts | 8 ++- src/backend/load/load-group.test.ts | 33 ++++++++++ src/backend/load/load-group.ts | 13 ++++ src/backend/routes/insertables.test.ts | 38 +++++++++++- src/backend/routes/insertables.ts | 50 ++++++++------- src/frontend/favorites/favorite-menu.tsx | 78 +++++++++++++++++++----- src/frontend/insert/configurations.tsx | 46 ++++---------- src/frontend/insert/insert-hooks.ts | 22 ++++--- src/frontend/insert/insert-menu.tsx | 62 +++++++++++++++++-- src/shared/onshape-path.ts | 11 +++- 10 files changed, 267 insertions(+), 94 deletions(-) diff --git a/src/backend/library-data.ts b/src/backend/library-data.ts index 86764ed04..1e5573616 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -189,8 +189,12 @@ export async function rebuildSearchDb( getRecordsMap(db, libraryId) ]); const searchDb = JSON.stringify(buildSearchDb(libraryData, recordsMap)); - // Store as a plain string in R2 - await bucket.put(searchIndexKey(libraryId), searchDb); + // Stored as a plain, uncompressed string: encoding it here would leave the + // runtime compressing an already-compressed body. The type still travels + // with the object, so the serving route reports it via writeHttpMetadata. + await bucket.put(searchIndexKey(libraryId), searchDb, { + httpMetadata: { contentType: "application/json" } + }); console.log( `Rebuilt search index for ${libraryId}: ` + `${searchDb.length} B, ${Date.now() - start} ms` diff --git a/src/backend/load/load-group.test.ts b/src/backend/load/load-group.test.ts index 74a504580..ce42a6773 100644 --- a/src/backend/load/load-group.test.ts +++ b/src/backend/load/load-group.test.ts @@ -220,6 +220,39 @@ describe("loadGroup", () => { expect(rows.map((row) => row.elementId).sort()).toEqual(["e1", "e2"]); }); + // A tab whose microversion is unchanged is skipped, so it never reaches + // saveInsertable. Its stored version still has to move with the group's: + // 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 29964169f..3fe301954 100644 --- a/src/backend/load/load-group.ts +++ b/src/backend/load/load-group.ts @@ -169,6 +169,19 @@ async function saveGroup( const writes: BatchItem<"sqlite">[] = [ db.update(group).set(parsed).where(eq(group.id, target.groupId)) ]; + if (!hasFailedInsertables) { + // A tab whose microversion didn't change is skipped, so it never went + // through saveInsertable and still points at the previous version. + // Its geometry is identical, but the stale id is what insertion and + // every document link are built from, so move the whole group forward + // together with the group row. + 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. diff --git a/src/backend/routes/insertables.test.ts b/src/backend/routes/insertables.test.ts index d03a370a0..1dee202cc 100644 --- a/src/backend/routes/insertables.test.ts +++ b/src/backend/routes/insertables.test.ts @@ -44,7 +44,6 @@ function readConfig(insertableId: string) { } // 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", @@ -81,8 +80,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, @@ -102,6 +102,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 @@ -109,8 +140,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, diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index dc3128b78..5d9dfbad9 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -7,7 +7,7 @@ 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, isElementPath } from "../../shared/onshape-path"; import { type ParameterValues, type ConfigurationParameter @@ -222,29 +222,40 @@ function indexRecords( ); } -/** POST /api/add-to-part-studio/insertable/:insertableId/d/:documentId/:instanceType/:instanceId/e/:elementId */ +/** + * Reads the tab being inserted into out of the request body. + * + * It rides in the body rather than the URL so the whole path — including which + * kind of instance the id refers to — arrives as one typed object. Validated + * here because a half-built path (a missing id, or an instance type Onshape + * doesn't know) otherwise reaches Onshape as a nonsense URL and comes back as + * an opaque failure. + */ +function readTargetPath(targetPath: unknown): ElementPath { + if (!isElementPath(targetPath)) { + throw new HTTPException(HttpStatus.BAD_REQUEST, { + message: "A valid target path is required" + }); + } + return targetPath; +} + +/** 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, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); const body = await c.req.json<{ + targetPath: unknown; 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 targetPath = readTargetPath(body.targetPath); const db = getDb(c.env.DB); const sourcePath = await getInsertableElementPath(db, insertableId); @@ -293,29 +304,22 @@ 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, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); const body = await c.req.json<{ + targetPath: unknown; 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 targetPath = readTargetPath(body.targetPath); const db = getDb(c.env.DB); diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index afdf9fe8a..40c642c92 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,10 @@ 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, @@ -32,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(); @@ -70,6 +102,24 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { // Reported by ConfigurationWrapper; addresses this selection's thumbnail. const [canonicalConfiguration, setCanonicalConfiguration] = useState({}); + 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"], @@ -104,11 +154,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; } @@ -133,6 +178,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { /> void; + /** Reports the record the selection produces, for the menu's header. */ + onRecord?: (record: SearchRecord | undefined) => void; } export function ConfigurationWrapper(props: ConfigurationWrapperProps) { @@ -71,7 +73,8 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { microversionId, configuration, setConfiguration, - onCanonicalConfiguration + onCanonicalConfiguration, + onRecord } = props; const query = useQuery({ @@ -120,6 +123,14 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { ); }, [parameters, unitInfo, configuration, onCanonicalConfiguration]); + const records = query.data?.records; + useEffect(() => { + if (!records || !configuration) { + return; + } + onRecord?.(findRecordForConfiguration(configuration, records)); + }, [records, configuration, onRecord]); + if (query.isPending || !configuration) { return (
@@ -132,10 +143,6 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { return ( <> - !!value - ) - : []; - if (details.length === 0) { - return null; - } - return ( - - - {details.join(" · ")} - - - ); -} - interface ConfigurationParameterProps { configurationResult: ConfigurationResult; configuration: ParameterValues; diff --git a/src/frontend/insert/insert-hooks.ts b/src/frontend/insert/insert-hooks.ts index eaf3cc072..7031a93b5 100644 --- a/src/frontend/insert/insert-hooks.ts +++ b/src/frontend/insert/insert-hooks.ts @@ -3,7 +3,7 @@ import { useSearch } from "@tanstack/react-router"; import { apiPost } from "../api-utils/api"; import { InsertableOut } from "../../shared/api-models"; import { ElementType } from "../../shared/types"; -import { toElementApiPath } from "../../shared/onshape-path"; +import { type ElementPath } from "../../shared/onshape-path"; import { showLoadingToast, showSuccessToast } from "../common/notifications"; import { queryClient } from "../query-client"; import { getAppErrorHandler } from "../api-utils/errors"; @@ -35,9 +35,19 @@ export function useInsertMutation( let endpoint: string; let body: Record; + // The tab being inserted into, sent whole so the instance type + // travels with its id rather than being reassembled from the URL. + const targetPath: ElementPath = { + documentId: search.documentId, + instanceId: search.instanceId, + instanceType: search.instanceType, + elementId: search.elementId + }; + if (search.elementType == ElementType.ASSEMBLY) { endpoint = "/add-to-assembly"; body = { + targetPath, configuration, isFavorite: insertArgs.isFavorite, isQuickInsert: insertArgs.isQuickInsert ?? false, @@ -47,6 +57,7 @@ export function useInsertMutation( } else { endpoint = "/add-to-part-studio"; body = { + targetPath, configuration, isFavorite: insertArgs.isFavorite, isQuickInsert: insertArgs.isQuickInsert ?? false, @@ -56,12 +67,9 @@ export function useInsertMutation( await queryClient.cancelQueries({ queryKey: ["thumbnail"] }); showLoadingToast(`Inserting ${insertable.name}...`, toastId); - return apiPost( - endpoint + - toInsertablePath(insertable.id) + - toElementApiPath(search), - { body } - ); + return apiPost(endpoint + toInsertablePath(insertable.id), { + body + }); }, onError: getAppErrorHandler( `Unexpectedly failed to insert ${insertable.name}.`, diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index 4b9cbb074..e00586a36 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -5,9 +5,9 @@ import { InsertableOut } from "../../shared/api-models"; import { ElementType } from "../../shared/types"; -import { Button, Checkbox, Group } from "@mantine/core"; +import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; import { IconInfoCircle, IconPlus } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { FontWeight, IconSize } from "../common/style-constants"; import { modals } from "@mantine/modals"; import { useIsFetching } from "@tanstack/react-query"; import { PreviewImageCard } from "./thumbnail"; @@ -20,7 +20,10 @@ import { MenuButton } from "../app-common/app-menu"; import { InsertableMenuItems } from "../cards/insertable-card"; import { ConfigurationWrapper } from "./configurations"; import { useInsertMutation } from "./insert-hooks"; -import { ParameterValues } from "../../shared/configuration-models"; +import { + ParameterValues, + SearchRecord +} from "../../shared/configuration-models"; import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { useFavoritesQuery } from "../queries"; import { useUiState } from "../api-utils/ui-state"; @@ -37,8 +40,12 @@ interface OpenInsertMenuProps { export function openInsertMenu(props: OpenInsertMenuProps) { const { insertable, defaultConfiguration } = props; let didInsert = false; - const id = modals.open({ - title: insertable.name, + // Minted here so the content can address the modal it lives in, which is + // what lets the header follow the selected configuration. + const id = crypto.randomUUID(); + modals.open({ + modalId: id, + title: , size: 500, centered: true, onClose: () => { @@ -49,6 +56,7 @@ export function openInsertMenu(props: OpenInsertMenuProps) { children: ( { didInsert = true; @@ -59,14 +67,45 @@ export function openInsertMenu(props: OpenInsertMenuProps) { }); } +/** + * The menu's header: the element's name, and under it what the selected + * configuration actually produces. Both are shown — the element name is how the + * part was found, the part number and name are what gets inserted. + */ +function InsertMenuTitle({ + 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 InsertMenuContentProps { insertable: InsertableOut; + /** The modal this renders in, so the header can track the selection. */ + modalId: string; defaultConfiguration?: ParameterValues; onInsert: () => void; } function InsertMenuContent(props: InsertMenuContentProps): ReactNode { - const { insertable, onInsert } = props; + const { insertable, modalId, onInsert } = props; const favorites = useFavoritesQuery().data?.favorites; const isSignedIn = useIsSignedIn(); @@ -77,6 +116,16 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { // canonical form needs. Empty means the element's default configuration. const [canonicalConfiguration, setCanonicalConfiguration] = useState({}); + const [record, setRecord] = useState(undefined); + + // The title lives in the modal's chrome, so it's updated rather than + // rendered: the header follows the configuration as the user changes it. + useEffect(() => { + modals.updateModal({ + modalId, + title: + }); + }, [modalId, insertable.name, record]); useEffect(() => { if (!isSignedIn) { @@ -99,6 +148,7 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { configuration={configuration} setConfiguration={setConfiguration} onCanonicalConfiguration={setCanonicalConfiguration} + onRecord={setRecord} /> ); } diff --git a/src/shared/onshape-path.ts b/src/shared/onshape-path.ts index 36a76b836..dd01c8914 100644 --- a/src/shared/onshape-path.ts +++ b/src/shared/onshape-path.ts @@ -2,6 +2,8 @@ import { ParameterValues } from "./configuration-models"; export type InstanceType = "w" | "v" | "m"; +const INSTANCE_TYPES: readonly InstanceType[] = ["w", "v", "m"]; + export interface DocumentPath { documentId: string; } @@ -35,14 +37,17 @@ export function isDocumentPath(path: any): path is DocumentPath { export function isInstancePath(path: any): path is InstancePath { return ( isDocumentPath(path) && - (path as InstancePath).instanceId !== undefined && - (path as InstancePath).instanceType !== undefined + typeof (path as InstancePath).instanceId === "string" && + // Checked against the literals: an unrecognized instance type builds a + // path Onshape rejects, which is worth catching at the boundary. + INSTANCE_TYPES.includes((path as InstancePath).instanceType) ); } export function isElementPath(path: any): path is ElementPath { return ( - isInstancePath(path) && (path as ElementPath).elementId !== undefined + isInstancePath(path) && + typeof (path as ElementPath).elementId === "string" ); } From 6c2ea58dead45a444ce36c33a8c33f18d2c5d719 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 12:30:30 +0000 Subject: [PATCH 11/23] Validate insert bodies with zValidator Replaces the hand-rolled target-path guard with a zod schema on each route, matching how the group and thumbnail routes already validate. The whole body is checked, not just the path: the flags get explicit defaults rather than arriving as undefined, and a bad request is rejected before any of the handler runs. `InstanceType` and the runtime list validators check against are now one `as const` definition, so the schema's enum can't drift from the type. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- src/backend/routes/insertables.ts | 75 +++++++++++++++---------------- src/shared/onshape-path.ts | 6 ++- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index 5d9dfbad9..b38f40c9e 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -1,17 +1,16 @@ 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, isElementPath } from "../../shared/onshape-path"; -import { - type ParameterValues, - type ConfigurationParameter -} from "../../shared/configuration-models"; +import { type ElementPath, INSTANCE_TYPES } from "../../shared/onshape-path"; +import { type ConfigurationParameter } from "../../shared/configuration-models"; import { INDEXING_ISSUE_TYPES, NO_RECORDS, @@ -223,39 +222,45 @@ function indexRecords( } /** - * Reads the tab being inserted into out of the request body. - * - * It rides in the body rather than the URL so the whole path — including which - * kind of instance the id refers to — arrives as one typed object. Validated - * here because a half-built path (a missing id, or an instance type Onshape - * doesn't know) otherwise reaches Onshape as a nonsense URL and comes back as - * an opaque failure. + * The tab being inserted into. It rides in the body rather than the URL so the + * whole path — including which kind of instance the id refers to — arrives as + * one object, and a half-built one is rejected here rather than reaching + * Onshape as a nonsense URL. */ -function readTargetPath(targetPath: unknown): ElementPath { - if (!isElementPath(targetPath)) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "A valid target path is required" - }); - } - return targetPath; -} +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(), requireSignInMiddleware, + zValidator("json", addToPartStudioBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); - const body = await c.req.json<{ - targetPath: unknown; - configuration: ParameterValues | undefined; - useMateConnector: boolean; - isFavorite: boolean; - isQuickInsert: boolean; - }>(); - - const targetPath = readTargetPath(body.targetPath); + const body = c.req.valid("json"); + const { targetPath } = body; const db = getDb(c.env.DB); const sourcePath = await getInsertableElementPath(db, insertableId); @@ -308,18 +313,12 @@ insertableRoutes.post( insertableRoutes.post( "/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<{ - targetPath: unknown; - configuration: ParameterValues | undefined; - fasten: boolean; - isFavorite: boolean; - isQuickInsert: boolean; - }>(); - - const targetPath = readTargetPath(body.targetPath); + const body = c.req.valid("json"); + const { targetPath } = body; const db = getDb(c.env.DB); diff --git a/src/shared/onshape-path.ts b/src/shared/onshape-path.ts index dd01c8914..fad4b1816 100644 --- a/src/shared/onshape-path.ts +++ b/src/shared/onshape-path.ts @@ -1,8 +1,10 @@ import { ParameterValues } from "./configuration-models"; -export type InstanceType = "w" | "v" | "m"; +/** The instance kinds an Onshape path can address, as one definition: the type + * and the runtime list validators check against both derive from it. */ +export const INSTANCE_TYPES = ["w", "v", "m"] as const; -const INSTANCE_TYPES: readonly InstanceType[] = ["w", "v", "m"]; +export type InstanceType = (typeof INSTANCE_TYPES)[number]; export interface DocumentPath { documentId: string; From ec0b16103bfc2100be21fe3db341cddd0c075680 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 12:40:07 +0000 Subject: [PATCH 12/23] Highlight the matched part number and name too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row shows the matched configuration's part number and name under the title, but only the title was underlined — a query that hit a part number gave no indication of where. `generateHighlightPositions` now takes the text and field to search rather than assuming the document's name, so a hit carries positions for the part number and name alongside the title's. `HighlightedText` is split out of `SearchHitTitle` so the detail line can render each part with its own matches, and the two are joined as elements rather than a string. A title-only match underlines nothing in the detail line: the record shown there is the default one, which the query never matched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- src/frontend/cards/card-components.tsx | 30 +++++++++---- src/frontend/search/search-results.tsx | 13 +++++- src/frontend/search/search.test.ts | 45 +++++++++++++++++++ src/frontend/search/search.ts | 62 ++++++++++++++++---------- 4 files changed, 118 insertions(+), 32 deletions(-) diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index 268f88aa1..4a682a873 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -9,10 +9,10 @@ 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 { 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"; @@ -157,11 +157,17 @@ export function CardTitle(props: CardTitleProps) { } // The part number + name of the hit's best-matching configuration, dropping - // a name that just repeats the title. + // a name that just repeats the title. Each carries its own match positions, + // so a query that hit the part number underlines it there too. const details = searchHit - ? [searchHit.partNumber, searchHit.partName].filter( - (value): value is string => - !!value && value.toLowerCase() !== title.toLowerCase() + ? ( + [ + [searchHit.partNumber, searchHit.partNumberPositions], + [searchHit.partName, searchHit.partNamePositions] + ] as const + ).filter( + (detail): detail is [string, Position[] | undefined] => + !!detail[0] && detail[0].toLowerCase() !== title.toLowerCase() ) : []; @@ -180,7 +186,15 @@ export function CardTitle(props: CardTitleProps) { {details.length > 0 && ( - {details.join(" · ")} + {details.map(([text, positions], index) => ( + + {index > 0 && " · "} + + + ))} )} diff --git a/src/frontend/search/search-results.tsx b/src/frontend/search/search-results.tsx index cf1c30014..ca54af57b 100644 --- a/src/frontend/search/search-results.tsx +++ b/src/frontend/search/search-results.tsx @@ -89,7 +89,18 @@ interface SearchHitTitleProps { */ export function SearchHitTitle(props: SearchHitTitleProps): ReactNode { const { title, searchHit } = props; - return <>{applyRanges(title, searchHit.positions)}; + return ; +} + +/** Underlines wherever the query matched inside `text`. */ +export function HighlightedText({ + text, + positions +}: { + text: string; + positions?: Position[]; +}): ReactNode { + return <>{applyRanges(text, positions ?? [])}; } function applyRanges(str: string, ranges: Position[]) { diff --git a/src/frontend/search/search.test.ts b/src/frontend/search/search.test.ts index 46343ab6b..5e3772e37 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/search/search.test.ts @@ -233,6 +233,51 @@ describe("doSearch highlighting", () => { // An unescaped "1.5" would also underline the "125". expect(highlightFor("1.5 x 125 Spacer", "1.5")).toBe("1.5"); }); + + // The row shows the matched configuration's part number and name beneath + // the title, so the query has to be underlined there too. + describe("of the matched record", () => { + const recordsMap: Record = { + i1: [record("217-2600", { length: "short" }, "Long Bearing")] + }; + + function hitFor(query: string) { + const { hits } = doSearch( + buildSearchDb(library(), recordsMap), + query, + undefined, + undefined, + true + ); + expect(hits).toHaveLength(1); + return hits[0]; + } + + it("underlines the typed prefix of the part number", () => { + const hit = hitFor("217"); + expect( + highlighted(hit.partNumber!, hit.partNumberPositions ?? []) + ).toBe("217"); + }); + + it("underlines the typed prefix of the part name", () => { + const hit = hitFor("bear"); + expect( + highlighted(hit.partName!, hit.partNamePositions ?? []) + ).toBe("Bear"); + }); + + // A title match shows the default record, but nothing in it matched. + it("underlines nothing when only the title matched", () => { + const hit = hitFor("bracket"); + expect( + highlighted(hit.partNumber!, hit.partNumberPositions ?? []) + ).toBe(""); + expect( + highlighted(hit.partName!, hit.partNamePositions ?? []) + ).toBe(""); + }); + }); }); describe("doSearch name matching", () => { diff --git a/src/frontend/search/search.ts b/src/frontend/search/search.ts index dd12b69d0..f881fa44a 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/search/search.ts @@ -40,6 +40,9 @@ export interface SearchHit { configuration?: ParameterValues; partNumber?: string; partName?: string; + /** Where the query matched inside `partNumber` / `partName`, for underlining. */ + partNumberPositions?: Position[]; + partNamePositions?: Position[]; } export interface FilterResult { @@ -121,18 +124,33 @@ export function doSearch( const document = searchDb.getStoredFields( miniSearchResult.id ) as unknown as SearchDocument; - const positions = generateHighlightPositions( - miniSearchResult, - document - ); - const record = matchedRecord(miniSearchResult, document, query); + const partNumber = record?.partNumber ?? undefined; + const partName = record?.name ?? undefined; return { id: document.id, - positions, + positions: generateHighlightPositions( + miniSearchResult, + document.name, + "name" + ), configuration: record?.configuration, - partNumber: record?.partNumber ?? undefined, - partName: record?.name ?? undefined + partNumber, + partName, + partNumberPositions: partNumber + ? generateHighlightPositions( + miniSearchResult, + partNumber, + "partNumbers" + ) + : undefined, + partNamePositions: partName + ? generateHighlightPositions( + miniSearchResult, + partName, + "partNames" + ) + : undefined }; }) .slice(0, 50); // Limit to 50 results @@ -212,35 +230,33 @@ function matchedPrefixLength(term: string, queryTerms: string[]): number { } /** - * Generate highlight positions for matched terms in the document. + * Where a field's matched terms appear in a piece of text, for underlining it. * Based on approach from https://github.com/lucaong/minisearch/issues/37 + * + * `match` is keyed by the document terms that matched, `queryTerms` by what was + * typed: searching "mot" matches the term "motor", and we underline just its + * "mot". Overlapping ranges are merged when they're applied. A term the index + * rewrote (a fraction canonicalized to a decimal) has no literal place in the + * text, so it simply contributes nothing. */ function generateHighlightPositions( result: MiniSearchResult, - document: SearchDocument + text: string, + field: string ): Position[] { - // `match` is keyed by the document terms that matched, `queryTerms` by what - // was typed: searching "mot" matches the term "motor", and we underline just - // its "mot". Overlapping ranges are merged when they're applied. - - const name = document.name.toLowerCase(); - + const haystack = text.toLowerCase(); const positions: Position[] = []; for (const [term, matchedFields] of Object.entries(result.match)) { - // Only include terms that matched something in the name field - if (!matchedFields.includes("name")) { + if (!matchedFields.includes(field)) { continue; } const length = matchedPrefixLength(term, result.queryTerms); - const matchedLocations = name.matchAll( + const matchedLocations = haystack.matchAll( new RegExp(escapeRegExp(term), "g") ); for (const match of matchedLocations) { - positions.push({ - start: match.index, - length - }); + positions.push({ start: match.index, length }); } } From 850772dffb4b72a33aeea1114fd6a12d5f1bddd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 12:51:29 +0000 Subject: [PATCH 13/23] Use one R2 bucket for thumbnails and search indexes The two buckets held disjoint key prefixes and were never addressed together, so a single BLOB binding costs nothing and halves the provisioning. Lifecycle rules are per-prefix, so thumbnails/config/ can still expire while thumbnails/default/ and search-index/ do not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- src/backend/app.ts | 9 ++++++--- src/backend/load/load-group.ts | 2 +- src/backend/load/load-insertable.ts | 2 +- src/backend/load/workflows.ts | 4 ++-- src/backend/routes/groups.test.ts | 4 +--- src/backend/routes/groups.ts | 4 ++-- src/backend/routes/insertables.ts | 2 +- src/backend/routes/library.test.ts | 5 +++-- src/backend/routes/library.ts | 2 +- src/backend/routes/thumbnails.test.ts | 10 +++++----- src/backend/routes/thumbnails.ts | 10 +++++----- worker-configuration.d.ts | 9 +++------ wrangler.jsonc | 24 ++++++------------------ 13 files changed, 37 insertions(+), 50 deletions(-) diff --git a/src/backend/app.ts b/src/backend/app.ts index d34f9b2a8..8dd80976d 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -11,9 +11,12 @@ export interface AppBindings { DB: D1Database; KV: KVNamespace; ASSETS: Fetcher; - THUMBNAILS: R2Bucket; - /** Serialized MiniSearch index per library, keyed by {@link searchIndexKey}. */ - SEARCH_INDEX: R2Bucket; + /** + * Everything we store as a blob: thumbnails under `thumbnails/`, each + * library's serialized search index under `search-index/`. One bucket, since + * the prefixes already keep them apart and lifecycle rules are per-prefix. + */ + BLOB: R2Bucket; LOAD_LIBRARY_WORKFLOW: Workflow; ADD_GROUP_WORKFLOW: Workflow; /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ diff --git a/src/backend/load/load-group.ts b/src/backend/load/load-group.ts index 3fe301954..9c35bc892 100644 --- a/src/backend/load/load-group.ts +++ b/src/backend/load/load-group.ts @@ -84,7 +84,7 @@ export async function loadGroup( `document-thumbnail-${groupId}`, async () => uploadDocumentThumbnails( - ctx.env.THUMBNAILS, + ctx.env.BLOB, await getOnshapeApiFromContext(ctx), versionPath ) diff --git a/src/backend/load/load-insertable.ts b/src/backend/load/load-insertable.ts index 17e997e60..eb1169eab 100644 --- a/src/backend/load/load-insertable.ts +++ b/src/backend/load/load-insertable.ts @@ -93,7 +93,7 @@ export async function loadInsertable( `thumbnail-${insertableId}`, async () => uploadThumbnails( - ctx.env.THUMBNAILS, + ctx.env.BLOB, await getOnshapeApiFromContext(ctx), elementPath, target.microversionId diff --git a/src/backend/load/workflows.ts b/src/backend/load/workflows.ts index 41f93bae2..5a6f954b4 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/load/workflows.ts @@ -231,7 +231,7 @@ async function finalizeLibrary( libraryId: LibraryId ): Promise { const db = getDb(env.DB); - await rebuildSearchDb(env.SEARCH_INDEX, db, libraryId); + await rebuildSearchDb(env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); } @@ -275,7 +275,7 @@ export class ThumbnailWorkflow extends WorkflowEntrypoint< { retries: THUMBNAIL_STEP_RETRIES }, async () => uploadConfigurationThumbnails( - this.env.THUMBNAILS, + this.env.BLOB, await getOnshapeApiFromContext({ env: this.env, sessionId: "", diff --git a/src/backend/routes/groups.test.ts b/src/backend/routes/groups.test.ts index 863ff9a2a..b05d7d881 100644 --- a/src/backend/routes/groups.test.ts +++ b/src/backend/routes/groups.test.ts @@ -87,9 +87,7 @@ describe("group admin routes", () => { ); expect(res.status).toBe(200); - const object = await env.SEARCH_INDEX.get( - searchIndexKey(TEST_LIBRARY_ID) - ); + const object = await env.BLOB.get(searchIndexKey(TEST_LIBRARY_ID)); const indexed = MiniSearch.loadJSON( await object!.text(), SEARCH_OPTIONS diff --git a/src/backend/routes/groups.ts b/src/backend/routes/groups.ts index fd341285d..854135a0e 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/routes/groups.ts @@ -102,7 +102,7 @@ groupRoutes.post( await bumpLibraryVersion(db, libraryId); // Search filters on isVisible, so a stale index hides these from results. - await rebuildSearchDb(c.env.SEARCH_INDEX, db, libraryId); + await rebuildSearchDb(c.env.BLOB, db, libraryId); return c.json({ success: true }); } ); @@ -247,7 +247,7 @@ groupRoutes.delete( .where(and(eq(group.id, groupId), eq(group.libraryId, libraryId))); await bumpLibraryVersion(db, libraryId); - await rebuildSearchDb(c.env.SEARCH_INDEX, db, libraryId); + await rebuildSearchDb(c.env.BLOB, db, libraryId); return c.json({ success: true }); } ); diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index b38f40c9e..53b322dfa 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -185,7 +185,7 @@ insertableRoutes.post( await bumpLibraryVersion(db, row.libraryId); // Records feed the search index, so rebuild it now. - await rebuildSearchDb(c.env.SEARCH_INDEX, db, row.libraryId); + await rebuildSearchDb(c.env.BLOB, db, row.libraryId); return c.json({ success: true }); } ); diff --git a/src/backend/routes/library.test.ts b/src/backend/routes/library.test.ts index ad5c02689..b73543569 100644 --- a/src/backend/routes/library.test.ts +++ b/src/backend/routes/library.test.ts @@ -22,7 +22,7 @@ describe("library routes", () => { beforeEach(async () => { await resetDb(db); // The R2 bucket persists across tests in this file; clear the index. - await env.SEARCH_INDEX.delete(searchIndexKey(TEST_LIBRARY_ID)); + await env.BLOB.delete(searchIndexKey(TEST_LIBRARY_ID)); }); it("GET /library-data returns groups and insertables", async () => { @@ -49,7 +49,7 @@ describe("library routes", () => { 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.SEARCH_INDEX, db, TEST_LIBRARY_ID); + await rebuildSearchDb(env.BLOB, db, TEST_LIBRARY_ID); const app = createTestApp(); const res = await app.request( @@ -111,6 +111,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 7f6a78475..4c378ba17 100644 --- a/src/backend/routes/library.ts +++ b/src/backend/routes/library.ts @@ -54,7 +54,7 @@ libraryRoutes.get( async (c) => { const libraryId = getLibraryParam(c); - const object = await c.env.SEARCH_INDEX.get(searchIndexKey(libraryId)); + const object = await c.env.BLOB.get(searchIndexKey(libraryId)); if (!object) { return c.notFound(); } diff --git a/src/backend/routes/thumbnails.test.ts b/src/backend/routes/thumbnails.test.ts index 012b1dec4..7948ca92e 100644 --- a/src/backend/routes/thumbnails.test.ts +++ b/src/backend/routes/thumbnails.test.ts @@ -27,7 +27,7 @@ describe("thumbnail serving", () => { it("serves a stored thumbnail, cached immutably", async () => { const elementId = "stored-element"; - await env.THUMBNAILS.put( + await env.BLOB.put( thumbnailKey(elementId, MICROVERSION, SIZE), "gif-bytes" ); @@ -87,7 +87,7 @@ describe("thumbnail serving", () => { // 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.THUMBNAILS.put( + await env.BLOB.put( thumbnailKey(elementId, MICROVERSION, SIZE), "default-bytes" ); @@ -109,11 +109,11 @@ describe("thumbnail serving", () => { it("prefers the configuration's own thumbnail once it exists", async () => { const elementId = "configured-element"; - await env.THUMBNAILS.put( + await env.BLOB.put( thumbnailKey(elementId, MICROVERSION, SIZE), "default-bytes" ); - await env.THUMBNAILS.put( + await env.BLOB.put( thumbnailKey( elementId, MICROVERSION, @@ -142,7 +142,7 @@ describe("warming a configuration's thumbnail", () => { /** Seeds only the default, so a configuration request always misses. */ async function seedDefaultOnly(elementId: string) { - await env.THUMBNAILS.put( + await env.BLOB.put( thumbnailKey(elementId, MICROVERSION, SIZE), "default-bytes" ); diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index bec51c493..1c0c6a2e8 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -221,7 +221,7 @@ thumbnailRoutes.get( canonicalConfiguration ); - const object = await c.env.THUMBNAILS.get( + const object = await c.env.BLOB.get( thumbnailKey(elementId, microversionId, size, configurationKey) ); if (object) { @@ -241,7 +241,7 @@ thumbnailRoutes.get( } // Stand in with the default configuration until the real render lands. - const fallback = await c.env.THUMBNAILS.get( + const fallback = await c.env.BLOB.get( thumbnailKey(elementId, microversionId, size) ); if (!fallback) { @@ -312,7 +312,7 @@ thumbnailRoutes.get( ) { c.executionCtx.waitUntil( putThumbnail( - c.env.THUMBNAILS, + c.env.BLOB, thumbnailKey( elementId, microversionId, @@ -383,7 +383,7 @@ thumbnailRoutes.post( } const thumbnails = await uploadThumbnails( - c.env.THUMBNAILS, + c.env.BLOB, onshapeApi, elementPath, row.microversionId @@ -439,7 +439,7 @@ thumbnailRoutes.post( }; const thumbnails = await uploadDocumentThumbnails( - c.env.THUMBNAILS, + c.env.BLOB, onshapeApi, instancePath ); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index a9dc8a87f..8aee94736 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -3,8 +3,7 @@ // Runtime types generated with workerd@1.20260714.1 2026-05-14 nodejs_compat interface __BaseEnv_Env { KV: KVNamespace; - THUMBNAILS: R2Bucket; - SEARCH_INDEX: R2Bucket; + BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; ADMIN_TEAM: "6a62e6efcc21741bea57362c" | "5b620150b2190f0fca90ec10"; @@ -37,8 +36,7 @@ declare namespace Cloudflare { } interface CertEnv { KV: KVNamespace; - THUMBNAILS: R2Bucket; - SEARCH_INDEX: R2Bucket; + BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; ADMIN_TEAM: "6a62e6efcc21741bea57362c"; @@ -67,8 +65,7 @@ declare namespace Cloudflare { } interface ProductionEnv { KV: KVNamespace; - THUMBNAILS: R2Bucket; - SEARCH_INDEX: R2Bucket; + BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; ADMIN_TEAM: "5b620150b2190f0fca90ec10"; diff --git a/wrangler.jsonc b/wrangler.jsonc index 4f5fbbf89..0018d5c01 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -48,12 +48,8 @@ ], "r2_buckets": [ { - "binding": "THUMBNAILS", - "bucket_name": "frc-design-app-dev-thumbnails" - }, - { - "binding": "SEARCH_INDEX", - "bucket_name": "frc-design-app-dev-search-index" + "binding": "BLOB", + "bucket_name": "frc-design-app-dev-blob" } ], "workflows": [ @@ -113,12 +109,8 @@ ], "r2_buckets": [ { - "binding": "THUMBNAILS", - "bucket_name": "frc-design-app-cert-thumbnails" - }, - { - "binding": "SEARCH_INDEX", - "bucket_name": "frc-design-app-cert-search-index" + "binding": "BLOB", + "bucket_name": "frc-design-app-cert-blob" } ], "workflows": [ @@ -168,12 +160,8 @@ ], "r2_buckets": [ { - "binding": "THUMBNAILS", - "bucket_name": "frc-thumbnails-production" - }, - { - "binding": "SEARCH_INDEX", - "bucket_name": "frc-search-index-production" + "binding": "BLOB", + "bucket_name": "frc-blob-production" } ], "workflows": [ From 9d970dfd9a601bb7c9c0d6cf6fe08e7c0d9e39aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 12:57:39 +0000 Subject: [PATCH 14/23] Build the build-check thumbnail fixture from thumbnailUrl The hardcoded urls predated the microversion query param, so the fixture no longer looked like anything uploadThumbnails returns. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- src/backend/parse/build-checks.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/backend/parse/build-checks.test.ts b/src/backend/parse/build-checks.test.ts index dbd3c117f..4f691b896 100644 --- a/src/backend/parse/build-checks.test.ts +++ b/src/backend/parse/build-checks.test.ts @@ -1,13 +1,25 @@ import { describe, expect, it } from "vitest"; -import { ThumbnailUrls, Vendor } from "../../shared/types"; +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"; +/** What uploadThumbnails returns: the element's default configuration. */ const THUMBNAILS: ThumbnailUrls = { - small: "/api/thumbnail/70x40/x", - large: "/api/thumbnail/300x300/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, From fc4016f2fad30ca422b841ddce68ca3ac380cf4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 13:39:08 +0000 Subject: [PATCH 15/23] Fix review findings in the thumbnail and configuration paths The thumbnail workflow authenticated with an empty session id, so every warm render 401'd before reaching Onshape; it now carries the requesting session like the other two workflows do. Boolean and string inputs read the raw configuration value, which canonicalization now omits whenever it equals the default, so a default-true checkbox rendered unchecked. Both fall back to the parameter default, as the enum input already did. Also gates the favorite save on a reported canonical configuration, rebuilds the search index before bumping the version so an immutable url cannot pin a stale index, and restores includeComputedProperties on the metadata probe indexing runs per configuration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ATK594PtgVSufvSHa24VVp --- src/backend/app.ts | 9 ++-- src/backend/load/load-steps.ts | 12 ++---- src/backend/load/workflows.ts | 13 ++++-- src/backend/onshape-api/endpoints/metadata.ts | 8 +++- .../parse/parse-configuration-records.ts | 20 +++------ src/backend/routes/groups.ts | 7 ++-- src/backend/routes/insertables.ts | 5 ++- src/backend/routes/thumbnails.test.ts | 41 +++++++++++++++++-- src/backend/routes/thumbnails.ts | 12 +++--- src/frontend/api-utils/api.ts | 10 ++--- src/frontend/favorites/favorite-menu.tsx | 12 ++++-- src/frontend/insert/configurations.tsx | 13 +++--- src/frontend/search/search.ts | 12 ++---- src/shared/configuration-combinations.ts | 29 ++++--------- src/shared/search.ts | 9 ++-- 15 files changed, 117 insertions(+), 95 deletions(-) diff --git a/src/backend/app.ts b/src/backend/app.ts index 8dd80976d..0e72132bd 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,6 +1,9 @@ import { type Context, type MiddlewareHandler, Hono } from "hono"; -import type { AddGroupParams, LoadLibraryParams } from "./load/workflows"; -import type { ThumbnailParams } from "../shared/thumbnails"; +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"; @@ -20,7 +23,7 @@ export interface AppBindings { LOAD_LIBRARY_WORKFLOW: Workflow; ADD_GROUP_WORKFLOW: Workflow; /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ - THUMBNAIL_WORKFLOW: Workflow; + THUMBNAIL_WORKFLOW: Workflow; ADMIN_TEAM: string; ACCESS_LEVEL_OVERRIDE?: string; /** Testing-only: treat requests as signed in with a fake user. Not for production. */ diff --git a/src/backend/load/load-steps.ts b/src/backend/load/load-steps.ts index 67a72ca42..3f9c30b16 100644 --- a/src/backend/load/load-steps.ts +++ b/src/backend/load/load-steps.ts @@ -43,15 +43,9 @@ const THUMBNAIL_BASE_DELAY_SECONDS = 4; const THUMBNAIL_MAX_DELAY_SECONDS = 15 * 60; /** - * Retry delay for a step waiting on an Onshape render. Onshape renders - * thumbnails asynchronously and gives no signal when one lands, so the step - * polls: a plain doubling from four seconds up to a fifteen-minute ceiling. - * - * Starting tight is the point — most renders land within seconds, and a long - * first wait leaves them sitting finished but unnoticed. - * - * A rate limit still overrides the curve. Onshape says how long to wait, and - * asking again sooner only earns another 429. + * Onshape gives no signal when a render lands, so the step polls: doubling from + * four seconds to a fifteen-minute ceiling. Starting tight is the point, since + * most renders land within seconds. A rate limit overrides the curve. */ function thumbnailRetryDelay(input: RetryDelayInput): `${number} seconds` { const rateLimited = rateLimitDelay(input.error); diff --git a/src/backend/load/workflows.ts b/src/backend/load/workflows.ts index 5a6f954b4..2daeb6579 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/load/workflows.ts @@ -235,19 +235,24 @@ async function finalizeLibrary( await bumpLibraryVersion(db, libraryId); } +/** The render to run, plus the session whose Onshape tokens it runs under. */ +export interface ThumbnailWorkflowParams extends ThumbnailParams { + 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, - ThumbnailParams + ThumbnailWorkflowParams > { async run( - event: WorkflowEvent, + event: WorkflowEvent, step: WorkflowStep ): Promise { - const { elementId, microversionId, canonicalConfiguration } = + const { elementId, microversionId, canonicalConfiguration, sessionId } = event.payload; const elementPath = await step.do("resolve-element", async () => { @@ -278,7 +283,7 @@ export class ThumbnailWorkflow extends WorkflowEntrypoint< this.env.BLOB, await getOnshapeApiFromContext({ env: this.env, - sessionId: "", + sessionId, step, limit: createLimiter(1) }), diff --git a/src/backend/onshape-api/endpoints/metadata.ts b/src/backend/onshape-api/endpoints/metadata.ts index 49499b3a5..8d366be93 100644 --- a/src/backend/onshape-api/endpoints/metadata.ts +++ b/src/backend/onshape-api/endpoints/metadata.ts @@ -12,7 +12,13 @@ export function getElementMetadata( 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: encoded ? { configuration: encoded } : {} + query }); } diff --git a/src/backend/parse/parse-configuration-records.ts b/src/backend/parse/parse-configuration-records.ts index d28f4152c..b0915be83 100644 --- a/src/backend/parse/parse-configuration-records.ts +++ b/src/backend/parse/parse-configuration-records.ts @@ -158,13 +158,9 @@ export function computeOpenComposite(parts: OnshapePart[]): boolean { } /** - * Builds a record from a part studio's parts for one configuration. - * - * The part to read is the studio's single part, or its composite when it's an - * open composite; more than one candidate means that choice was arbitrary, which - * `hasMultipleParts` flags. `isOpenComposite` is the studio's expected state - * (from its default configuration): a configuration that loses its composite is - * an `UNSTABLE_COMPOSITE`, and we store no part for it rather than a stray one. + * The part read is the studio's single part, or its composite when open; more + * candidates mean an arbitrary choice, which `hasMultipleParts` flags. A + * configuration losing the composite its default has stores no part at all. */ export function parsePartStudioRecord( parts: OnshapePart[], @@ -278,13 +274,9 @@ export async function parseConfigurationRecords( } /** - * Indexes an insertable's configuration records 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 list; the stored row keeps its previous records. + * One durable step per batch, so a rate-limited retry re-fetches only that + * batch; batches run sequentially since insertables already load in parallel. An + * exhausted batch throws rather than saving a half-built list. */ export async function loadConfigurationRecords( ctx: LoadContext, diff --git a/src/backend/routes/groups.ts b/src/backend/routes/groups.ts index 854135a0e..f520f2767 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/routes/groups.ts @@ -100,9 +100,10 @@ groupRoutes.post( ) ); - await bumpLibraryVersion(db, libraryId); - // Search filters on isVisible, so a stale index hides these from results. + // 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 }); } ); @@ -246,8 +247,8 @@ groupRoutes.delete( .delete(group) .where(and(eq(group.id, groupId), eq(group.libraryId, libraryId))); - await bumpLibraryVersion(db, libraryId); await rebuildSearchDb(c.env.BLOB, db, libraryId); + await bumpLibraryVersion(db, libraryId); return c.json({ success: true }); } ); diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index 53b322dfa..bb886942b 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -183,9 +183,10 @@ insertableRoutes.post( configWrite ]); - await bumpLibraryVersion(db, row.libraryId); - // Records feed the search index, so rebuild it now. + // Records feed the search index; rebuild before the bump makes the + // /search-db url immutable, or a stale index gets pinned for a year. await rebuildSearchDb(c.env.BLOB, db, row.libraryId); + await bumpLibraryVersion(db, row.libraryId); return c.json({ success: true }); } ); diff --git a/src/backend/routes/thumbnails.test.ts b/src/backend/routes/thumbnails.test.ts index 7948ca92e..e00902259 100644 --- a/src/backend/routes/thumbnails.test.ts +++ b/src/backend/routes/thumbnails.test.ts @@ -18,8 +18,17 @@ const MICROVERSION = "mv-1"; /** A configuration whose key differs from the default's. */ const CANONICAL_CONFIGURATION = "size=l"; -function get(url: string) { - return createTestApp().request(url, jsonRequest("GET"), env); +const SESSION_ID = "test-session"; + +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", () => { @@ -173,7 +182,8 @@ describe("warming a configuration's thumbnail", () => { size: SIZE, canonicalConfiguration: CANONICAL_CONFIGURATION, warm: true - }) + }), + SESSION_ID ); expect(res.status).toBe(200); @@ -183,9 +193,32 @@ describe("warming a configuration's thumbnail", () => { elementId, microversionId: MICROVERSION, canonicalConfiguration: CANONICAL_CONFIGURATION - }) + }), + // The render runs later, so it needs a session to authenticate. + params: expect.objectContaining({ 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 }) ); + + 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 diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index 1c0c6a2e8..9638b7d72 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -39,7 +39,8 @@ import { canonicalConfigurationKey } from "../../shared/canonical-configuration"; import { OnshapeApi } from "../onshape-api/onshape-api"; -import type { AppBindings } from "../app"; +import type { AppContext } from "../app"; +import { getSessionId } from "../auth"; import { BuildIssueType, clearBuildIssue } from "../../shared/build-issues"; /** Stores one rendered thumbnail, tagging it with what produced it. */ @@ -233,7 +234,7 @@ thumbnailRoutes.get( } if (warm) { - await warmConfigurationThumbnail(c.env, { + await warmConfigurationThumbnail(c, { elementId, microversionId, canonicalConfiguration @@ -261,13 +262,14 @@ function thumbnailResponse(object: R2ObjectBody): Response { /** Concurrent requests collapse onto one run: Cloudflare rejects a duplicate id. */ async function warmConfigurationThumbnail( - env: AppBindings, + c: AppContext, params: ThumbnailParams ): Promise { try { - await env.THUMBNAIL_WORKFLOW.create({ + // The render runs later, under this caller's Onshape tokens. + await c.env.THUMBNAIL_WORKFLOW.create({ id: thumbnailWorkflowId(params), - params + params: { ...params, sessionId: getSessionId(c) } }); } catch { // Never fatal: the caller still has the default thumbnail to serve. diff --git a/src/frontend/api-utils/api.ts b/src/frontend/api-utils/api.ts index c99773d63..d6ded45a4 100644 --- a/src/frontend/api-utils/api.ts +++ b/src/frontend/api-utils/api.ts @@ -71,13 +71,9 @@ export async function apiGetText( } /** - * Checks that an image URL resolves, and returns it to render. - * - * Fetching it here both surfaces failures as a rejected query (so callers keep - * their loading, error, and retry behavior) and puts the response in the browser - * cache, so the `` that follows is served from it. Returning the URL rather - * than an object URL matters: a blob URL lives until the page unloads, and - * nothing can safely revoke one that a cached query may still be sharing. + * Checks that an image url resolves, returning it to render. Fetching here + * surfaces failures as a rejected query and warms the browser cache. Returns the + * url, not an object url: a shared blob url has no safe moment to be revoked. */ export async function loadImage( url: string, diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index 40c642c92..1402868d1 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/favorites/favorite-menu.tsx @@ -100,8 +100,10 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { ParameterValues | undefined >(defaultConfiguration); // Reported by ConfigurationWrapper; addresses this selection's thumbnail. - const [canonicalConfiguration, setCanonicalConfiguration] = - useState({}); + // 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]; @@ -130,6 +132,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { body: { defaultConfiguration: canonicalConfiguration } }); }, + onMutate: async () => { const queryKey = favoritesQueryKey(libraryId); await queryClient.cancelQueries({ queryKey }); @@ -173,7 +176,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { largeThumbnailUrl={insertable.largeThumbnailUrl} microversionId={insertable.microversionId} canonicalConfiguration={encodeCanonicalConfiguration( - canonicalConfiguration + canonicalConfiguration ?? {} )} />