From 26d30cfd02be390e3546a63fda62ad86708eccac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 02:24:07 +0000 Subject: [PATCH 01/88] Run tests without bindings in Node Only a test that needs D1, R2, KV or a workflow pays for a Workers runtime and a migrated database; it says so with a .worker.test.ts name. The suite drops from about 88s to 53s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- AGENTS.md | 7 +++++++ src/__test_utils__/configuration-fixtures.ts | 2 +- .../{growth.test.ts => growth.worker.test.ts} | 0 .../{rollups.test.ts => rollups.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 ...{tracking.test.ts => tracking.worker.test.ts} | 0 .../{guards.test.ts => guards.worker.test.ts} | 0 ...-auth.test.ts => request-auth.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../library/{db.test.ts => db.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{context.test.ts => context.worker.test.ts} | 0 ...racker.test.ts => job-tracker.worker.test.ts} | 0 ...d-group.test.ts => load-group.worker.test.ts} | 0 ...le.test.ts => load-insertable.worker.test.ts} | 0 ...> parse-configuration-records.worker.test.ts} | 0 ...orkflows.test.ts => workflows.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 ...econcile.test.ts => reconcile.worker.test.ts} | 0 .../{reload.test.ts => reload.worker.test.ts} | 0 ...{renderer.test.ts => renderer.worker.test.ts} | 0 .../{routes.test.ts => routes.worker.test.ts} | 0 .../{errors.test.ts => errors.worker.test.ts} | 0 vitest.config.ts | 16 +++++++++++----- 31 files changed, 19 insertions(+), 6 deletions(-) rename src/backend/features/analytics/{growth.test.ts => growth.worker.test.ts} (100%) rename src/backend/features/analytics/{rollups.test.ts => rollups.worker.test.ts} (100%) rename src/backend/features/analytics/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/analytics/{tracking.test.ts => tracking.worker.test.ts} (100%) rename src/backend/features/auth/{guards.test.ts => guards.worker.test.ts} (100%) rename src/backend/features/auth/{request-auth.test.ts => request-auth.worker.test.ts} (100%) rename src/backend/features/auth/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/build-checker/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/configurations/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/entry/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/favorites/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/insert-location/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/library/{db.test.ts => db.worker.test.ts} (100%) rename src/backend/features/library/groups/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/library/insertables/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/library/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/load/{context.test.ts => context.worker.test.ts} (100%) rename src/backend/features/load/{job-tracker.test.ts => job-tracker.worker.test.ts} (100%) rename src/backend/features/load/{load-group.test.ts => load-group.worker.test.ts} (100%) rename src/backend/features/load/{load-insertable.test.ts => load-insertable.worker.test.ts} (100%) rename src/backend/features/load/{parse-configuration-records.test.ts => parse-configuration-records.worker.test.ts} (100%) rename src/backend/features/load/{workflows.test.ts => workflows.worker.test.ts} (100%) rename src/backend/features/settings/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/features/thumbnails/{reconcile.test.ts => reconcile.worker.test.ts} (100%) rename src/backend/features/thumbnails/{reload.test.ts => reload.worker.test.ts} (100%) rename src/backend/features/thumbnails/{renderer.test.ts => renderer.worker.test.ts} (100%) rename src/backend/features/thumbnails/{routes.test.ts => routes.worker.test.ts} (100%) rename src/backend/lib/{errors.test.ts => errors.worker.test.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index 0132cff93..e4bef563a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,13 @@ Raw text lives only inside the input a user is typing into. Don't add a third form: if something needs a different view of a selection, it wants a function in `selection.ts`, not a new shape. +# Tests + +`npm test` runs two Vitest projects. A backend test that needs bindings (D1, +R2, KV, Workflows) is named `*.worker.test.ts` and runs in the Workers runtime +against a freshly migrated D1; that setup costs far more than most tests, so +everything else — pure backend logic and the frontend — runs in Node. + # Running the app Onshape launches the app at `/init`, which needs a real Onshape session and diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index f08e172ef..75c7fc499 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -1,6 +1,6 @@ /** * Import directly, not through `__test_utils__/index.ts`: the barrel reaches - * `cloudflare:workers`, which `src/shared`'s node-project tests cannot resolve. + * `cloudflare:workers`, which the node project's tests cannot resolve. */ import { ParameterType, diff --git a/src/backend/features/analytics/growth.test.ts b/src/backend/features/analytics/growth.worker.test.ts similarity index 100% rename from src/backend/features/analytics/growth.test.ts rename to src/backend/features/analytics/growth.worker.test.ts diff --git a/src/backend/features/analytics/rollups.test.ts b/src/backend/features/analytics/rollups.worker.test.ts similarity index 100% rename from src/backend/features/analytics/rollups.test.ts rename to src/backend/features/analytics/rollups.worker.test.ts diff --git a/src/backend/features/analytics/routes.test.ts b/src/backend/features/analytics/routes.worker.test.ts similarity index 100% rename from src/backend/features/analytics/routes.test.ts rename to src/backend/features/analytics/routes.worker.test.ts diff --git a/src/backend/features/analytics/tracking.test.ts b/src/backend/features/analytics/tracking.worker.test.ts similarity index 100% rename from src/backend/features/analytics/tracking.test.ts rename to src/backend/features/analytics/tracking.worker.test.ts diff --git a/src/backend/features/auth/guards.test.ts b/src/backend/features/auth/guards.worker.test.ts similarity index 100% rename from src/backend/features/auth/guards.test.ts rename to src/backend/features/auth/guards.worker.test.ts diff --git a/src/backend/features/auth/request-auth.test.ts b/src/backend/features/auth/request-auth.worker.test.ts similarity index 100% rename from src/backend/features/auth/request-auth.test.ts rename to src/backend/features/auth/request-auth.worker.test.ts diff --git a/src/backend/features/auth/routes.test.ts b/src/backend/features/auth/routes.worker.test.ts similarity index 100% rename from src/backend/features/auth/routes.test.ts rename to src/backend/features/auth/routes.worker.test.ts diff --git a/src/backend/features/build-checker/routes.test.ts b/src/backend/features/build-checker/routes.worker.test.ts similarity index 100% rename from src/backend/features/build-checker/routes.test.ts rename to src/backend/features/build-checker/routes.worker.test.ts diff --git a/src/backend/features/configurations/routes.test.ts b/src/backend/features/configurations/routes.worker.test.ts similarity index 100% rename from src/backend/features/configurations/routes.test.ts rename to src/backend/features/configurations/routes.worker.test.ts diff --git a/src/backend/features/entry/routes.test.ts b/src/backend/features/entry/routes.worker.test.ts similarity index 100% rename from src/backend/features/entry/routes.test.ts rename to src/backend/features/entry/routes.worker.test.ts diff --git a/src/backend/features/favorites/routes.test.ts b/src/backend/features/favorites/routes.worker.test.ts similarity index 100% rename from src/backend/features/favorites/routes.test.ts rename to src/backend/features/favorites/routes.worker.test.ts diff --git a/src/backend/features/insert-location/routes.test.ts b/src/backend/features/insert-location/routes.worker.test.ts similarity index 100% rename from src/backend/features/insert-location/routes.test.ts rename to src/backend/features/insert-location/routes.worker.test.ts diff --git a/src/backend/features/library/db.test.ts b/src/backend/features/library/db.worker.test.ts similarity index 100% rename from src/backend/features/library/db.test.ts rename to src/backend/features/library/db.worker.test.ts diff --git a/src/backend/features/library/groups/routes.test.ts b/src/backend/features/library/groups/routes.worker.test.ts similarity index 100% rename from src/backend/features/library/groups/routes.test.ts rename to src/backend/features/library/groups/routes.worker.test.ts diff --git a/src/backend/features/library/insertables/routes.test.ts b/src/backend/features/library/insertables/routes.worker.test.ts similarity index 100% rename from src/backend/features/library/insertables/routes.test.ts rename to src/backend/features/library/insertables/routes.worker.test.ts diff --git a/src/backend/features/library/routes.test.ts b/src/backend/features/library/routes.worker.test.ts similarity index 100% rename from src/backend/features/library/routes.test.ts rename to src/backend/features/library/routes.worker.test.ts diff --git a/src/backend/features/load/context.test.ts b/src/backend/features/load/context.worker.test.ts similarity index 100% rename from src/backend/features/load/context.test.ts rename to src/backend/features/load/context.worker.test.ts diff --git a/src/backend/features/load/job-tracker.test.ts b/src/backend/features/load/job-tracker.worker.test.ts similarity index 100% rename from src/backend/features/load/job-tracker.test.ts rename to src/backend/features/load/job-tracker.worker.test.ts diff --git a/src/backend/features/load/load-group.test.ts b/src/backend/features/load/load-group.worker.test.ts similarity index 100% rename from src/backend/features/load/load-group.test.ts rename to src/backend/features/load/load-group.worker.test.ts diff --git a/src/backend/features/load/load-insertable.test.ts b/src/backend/features/load/load-insertable.worker.test.ts similarity index 100% rename from src/backend/features/load/load-insertable.test.ts rename to src/backend/features/load/load-insertable.worker.test.ts diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.worker.test.ts similarity index 100% rename from src/backend/features/load/parse-configuration-records.test.ts rename to src/backend/features/load/parse-configuration-records.worker.test.ts diff --git a/src/backend/features/load/workflows.test.ts b/src/backend/features/load/workflows.worker.test.ts similarity index 100% rename from src/backend/features/load/workflows.test.ts rename to src/backend/features/load/workflows.worker.test.ts diff --git a/src/backend/features/settings/routes.test.ts b/src/backend/features/settings/routes.worker.test.ts similarity index 100% rename from src/backend/features/settings/routes.test.ts rename to src/backend/features/settings/routes.worker.test.ts diff --git a/src/backend/features/thumbnails/reconcile.test.ts b/src/backend/features/thumbnails/reconcile.worker.test.ts similarity index 100% rename from src/backend/features/thumbnails/reconcile.test.ts rename to src/backend/features/thumbnails/reconcile.worker.test.ts diff --git a/src/backend/features/thumbnails/reload.test.ts b/src/backend/features/thumbnails/reload.worker.test.ts similarity index 100% rename from src/backend/features/thumbnails/reload.test.ts rename to src/backend/features/thumbnails/reload.worker.test.ts diff --git a/src/backend/features/thumbnails/renderer.test.ts b/src/backend/features/thumbnails/renderer.worker.test.ts similarity index 100% rename from src/backend/features/thumbnails/renderer.test.ts rename to src/backend/features/thumbnails/renderer.worker.test.ts diff --git a/src/backend/features/thumbnails/routes.test.ts b/src/backend/features/thumbnails/routes.worker.test.ts similarity index 100% rename from src/backend/features/thumbnails/routes.test.ts rename to src/backend/features/thumbnails/routes.worker.test.ts diff --git a/src/backend/lib/errors.test.ts b/src/backend/lib/errors.worker.test.ts similarity index 100% rename from src/backend/lib/errors.test.ts rename to src/backend/lib/errors.worker.test.ts diff --git a/vitest.config.ts b/vitest.config.ts index d88075f64..7534f7385 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,21 +9,27 @@ import { alias } from "./vite.config"; // test Worker would make FORCE_SIGNED_IN rewrite what the auth tests assert. process.env.CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV = "false"; +/** + * Only tests that need bindings pay for a Workers runtime and a migrated D1 + * per file, which costs far more than the tests themselves; they say so by + * their name. Everything else runs in Node. + */ +const WORKER_TESTS = "src/backend/**/*.worker.test.ts"; + export default defineConfig({ test: { projects: [ { resolve: { alias }, - // Frontend logic needs no bindings, so it runs in a fast Node environment. test: { name: "node", environment: "node", - include: ["src/frontend/**/*.test.ts"] + include: ["src/**/*.test.ts"], + exclude: [WORKER_TESTS] } }, { - // Backend tests run in the Workers runtime with real, per-test - // isolated D1/R2/KV bindings from wrangler.jsonc. + // Real, per-test isolated D1/R2/KV bindings from wrangler.jsonc. resolve: { alias }, plugins: [ cloudflareTest(async () => { @@ -39,7 +45,7 @@ export default defineConfig({ ], test: { name: "backend", - include: ["src/backend/**/*.test.ts"], + include: [WORKER_TESTS], setupFiles: ["./src/__test_utils__/apply-migrations.ts"] } } From 2f833dbfc889764f98b6669dc7769c9fdd9591c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 02:40:10 +0000 Subject: [PATCH 02/88] Keep a selection as it was entered; keys only name thumbnails A quantity now stays the expression that was typed, (2 + 3) in, through the url, favorites and the insert, and reaches Onshape that way instead of as a canonical base-unit value. Keys are derived from a selection only to address its thumbnail: stored records carry their enumerated values, build issues the values they blame, and the Onshape endpoints take a configuration map. Also fixes: - a favorite's Open document and Copy link dropped its configuration - Restore after cancelling reopened the configuration the menu opened with - favorites never showed the element's own part number, their records missing the insertable's part data - the insert menu header fell back to the default part number when the matching record had been folded away as a search duplicate Old rows upgrade on read (configurations/legacy.ts). The search index moves to a versioned key and is rebuilt on a miss, and immutable API urls carry a response-shape suffix so cached responses in the old shape are not reused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- AGENTS.md | 37 +-- src/__test_utils__/configuration-fixtures.ts | 20 +- src/backend/db/schema.ts | 38 +-- .../features/analytics/parameter-usage.ts | 9 +- src/backend/features/analytics/tracking.ts | 9 +- .../features/build-checker/issues.test.ts | 29 ++- src/backend/features/build-checker/issues.ts | 38 ++- .../features/configurations/contract.ts | 45 ++-- src/backend/features/configurations/legacy.ts | 79 +++++++ src/backend/features/configurations/routes.ts | 4 +- .../configurations/routes.worker.test.ts | 1 + .../features/configurations/selection.test.ts | 177 +++++++------- .../features/configurations/selection.ts | 216 ++++++++++-------- .../features/configurations/utils.test.ts | 49 +--- src/backend/features/configurations/utils.ts | 72 ++---- src/backend/features/favorites/routes.ts | 61 ++--- .../features/favorites/routes.worker.test.ts | 78 +++++-- src/backend/features/library/db.ts | 33 +-- .../features/library/insertables/routes.ts | 9 +- src/backend/features/library/routes.ts | 13 +- .../features/library/routes.worker.test.ts | 11 +- src/backend/features/load/load-insertable.ts | 5 +- .../load/load-insertable.worker.test.ts | 9 +- .../load/parse-configuration-records.ts | 127 +++++----- ...parse-configuration-records.worker.test.ts | 50 ++-- .../features/load/parse-configuration.test.ts | 5 +- .../features/load/parse-configuration.ts | 29 +-- src/backend/features/load/parse-vendors.ts | 4 +- src/backend/features/search/build.test.ts | 94 +++++--- src/backend/features/search/build.ts | 31 ++- src/backend/features/search/records.ts | 52 +++-- src/backend/features/thumbnails/renderer.ts | 3 +- src/backend/lib/onshape/endpoints/metadata.ts | 13 +- src/backend/lib/onshape/endpoints/parts.ts | 18 +- .../lib/onshape/endpoints/thumbnails.ts | 17 +- .../lib/onshape/objects/derive-feature.ts | 7 +- src/backend/lib/onshape/path.ts | 21 +- .../components/open-document-items.tsx | 9 +- .../build-status/components/issues.tsx | 15 +- .../favorites/components/favorite-button.tsx | 12 +- .../favorites/components/favorite-card.tsx | 5 +- .../favorites/components/favorite-menu.tsx | 46 ++-- .../features/favorites/open-favorite-menu.tsx | 4 +- src/frontend/features/favorites/queries.ts | 31 ++- .../insert/components/configurations.tsx | 73 +++--- .../insert/components/insert-menu.tsx | 52 +++-- .../insert/components/quick-insert-items.tsx | 4 +- .../features/insert/open-insert-menu.tsx | 32 +-- .../features/insert/parameter-value.ts | 6 +- .../features/insert/quantity-box.test.ts | 19 +- src/frontend/features/insert/quantity-box.ts | 10 +- src/frontend/features/insert/queries.ts | 5 +- .../features/insert/restore-insert-menu.ts | 19 +- .../library/components/insertable-card.tsx | 23 +- src/frontend/features/search/search.test.ts | 91 +++++--- src/frontend/features/search/search.ts | 10 +- src/frontend/lib/api-client.ts | 9 +- src/frontend/lib/app-params.ts | 16 +- src/frontend/lib/ui-state.ts | 5 +- src/frontend/lib/url.test.ts | 6 +- src/frontend/lib/url.tsx | 25 +- 61 files changed, 1117 insertions(+), 923 deletions(-) create mode 100644 src/backend/features/configurations/legacy.ts diff --git a/AGENTS.md b/AGENTS.md index e4bef563a..0d1625a2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,21 +70,28 @@ file, so a new one has to be added there or its tables generate no migration. A configuration takes exactly two forms, and `features/configurations/selection.ts` is the only place either is built: -- A **selection** (`Selection`) is what someone picked: every parameter - the insertable declares, each value canonically spelled (base units, trimmed, - lowercase booleans). `toSelection` makes one out of whatever arrived — a partial map - from a search hit, a stored favorite, a request body — and every boundary - calls it. Parameter defaults are canonical too, from `parse-configuration`, so - nothing has to canonicalize one to compare against it. -- A **`ConfigurationKey`** is that selection's identity: what it overrides, - encoded as `id=value;id=value`, with hidden parameters left out. It addresses - a render — R2 keys, thumbnail urls, stored records, Onshape itself — and - `ELEMENT_DEFAULT_KEY` (the empty string) is a selection that overrides - nothing. - -Raw text lives only inside the input a user is typing into. Don't add a third -form: if something needs a different view of a selection, it wants a function in -`selection.ts`, not a new shape. +- A **selection** (`Selection`) is what someone picked: every parameter the + insertable declares, each value **as it was entered**. A quantity is the + expression that was typed — `(2 + 3) in`, not `0.127 m` — and a quantity's + default is spelled in its own unit (`1 in`). `toSelection` makes one out of + whatever arrived — a search hit's values, a stored favorite, a request body, + the url — and every boundary calls it. The selection is what is stored, what + the url carries, and what Onshape is sent (`onshapeOverrides`), so a derived + feature shows the expression that was typed. +- A **`ConfigurationKey`** is derived from a selection for one purpose: naming + its thumbnail. It holds what the selection overrides, canonically spelled + (base units, hidden parameters left out), so selections rendering the same + part share a render. `DEFAULT_CONFIGURATION_KEY` (the empty string) overrides + nothing. Never store a key in place of the selection it came from, and never + send one to Onshape as a configuration outside thumbnails. + +Anything that needs values compared or counted — analytics, "is this the +default" — goes through `canonicalValue`/`canonicalValues`, never through a key. +Don't add a third form: if something needs a different view of a selection, it +wants a function in `selection.ts`, not a new shape. + +Rows written before selections kept expressions are upgraded on read by +`configurations/legacy.ts`; a reload rewrites them in the current shape. # Tests diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index 75c7fc499..693094155 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -11,7 +11,7 @@ import { type UnitInfo } from "@backend/features/configurations/contract"; import { QuantityType, Unit } from "@backend/features/configurations/enums"; -import { canonicalizeValue } from "@backend/features/configurations/selection"; +import { quantityDefault } from "@backend/features/configurations/selection"; /** Builds an enum parameter whose options are named after their ids. */ export function enumParam( @@ -55,19 +55,18 @@ export function boolParam(id: string): BooleanParameter { } /** - * A length quantity parameter defaulting to 1 inch, canonically spelled — the - * form `parseOnshapeConfiguration` stores, so tests compare like for like. + * A length quantity parameter defaulting to 1 inch, its default spelled the way + * `parseOnshapeConfiguration` stores one: from `defaultValue` and `unit`. */ export function quantityParam( id: string, - extra: Partial = {} + extra: Omit, "default"> = {} ): QuantityParameter { - const parameter: QuantityParameter = { + const parameter = { id, name: id, - default: "1 in", isCosmetic: false, - type: ParameterType.QUANTITY, + type: ParameterType.QUANTITY as const, quantityType: QuantityType.LENGTH, defaultValue: 1, min: 0, @@ -75,10 +74,7 @@ export function quantityParam( unit: Unit.INCH, ...extra }; - return { - ...parameter, - default: canonicalizeValue(parameter, parameter.default) - }; + return { ...parameter, default: quantityDefault(parameter) }; } /** Document units: inches to 4 decimals, degrees to 3. */ @@ -95,7 +91,7 @@ export function configurationRecord( overrides: Partial = {} ): ConfigurationRecord { return { - configurationKey: "", + values: {}, hasMultipleParts: false, isOpenComposite: false, ...overrides diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 345b54a6d..1a441c2e9 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -18,6 +18,19 @@ import { PartMetadata } from "../features/configurations/contract"; import { BuildIssue, knownBuildIssues } from "../features/build-checker/issues"; +import { + upgradeParameters, + upgradeRecords +} from "../features/configurations/legacy"; + +/** A JSON column whose stored rows may predate its current shape. */ +function upgradedJson(upgrade: (stored: T) => T) { + return customType<{ data: T; driverData: string }>({ + dataType: () => "text", + toDriver: (value) => JSON.stringify(value), + fromDriver: (value) => upgrade(JSON.parse(value) as T) + }); +} /** * Build-time issues flagged by the build checker, recomputed on reload. Declared @@ -27,17 +40,10 @@ import { BuildIssue, knownBuildIssues } from "../features/build-checker/issues"; * deploy last loaded the row, so it can still name a check that has since been * removed. The next write of the row drops it for good. */ -const buildIssuesColumn = customType<{ - data: BuildIssue[]; - driverData: string; -}>({ - dataType: () => "text", - toDriver: (issues) => JSON.stringify(issues), - fromDriver: (value) => knownBuildIssues(JSON.parse(value) as BuildIssue[]) -}); - const buildIssues = () => - buildIssuesColumn("build_issues").notNull().default([]); + upgradedJson(knownBuildIssues)("build_issues") + .notNull() + .default([]); /** The pair Onshape renders for a group or an insertable; null until rendered. */ const thumbnailUrls = () => ({ @@ -154,14 +160,14 @@ export const configurations = sqliteTable("configurations", { insertableId: text("insertable_id") .primaryKey() .references(() => insertables.id, { onDelete: "cascade" }), - parameters: text("parameters", { mode: "json" }) - .$type() + parameters: upgradedJson(upgradeParameters)( + "parameters" + ) .notNull() .default([]), // One record per indexed configuration. Empty unless the insertable is // indexed; the element's own metadata lives on `insertables.partMetadata`. - records: text("records", { mode: "json" }) - .$type() + records: upgradedJson(upgradeRecords)("records") .notNull() .default([]) }); @@ -200,8 +206,8 @@ export const favorites = sqliteTable( insertableId: text("insertable_id") .notNull() .references(() => insertables.id, { onDelete: "cascade" }), - // The selection the favorite opens with, whole and canonical like - // every stored one. Null for an insertable with nothing to configure. + // The selection the favorite opens with, whole and as it was entered. + // Null for an insertable with nothing to configure. defaultSelection: text("default_selection", { mode: "json" }).$type(), diff --git a/src/backend/features/analytics/parameter-usage.ts b/src/backend/features/analytics/parameter-usage.ts index 66f69459f..d5246995f 100644 --- a/src/backend/features/analytics/parameter-usage.ts +++ b/src/backend/features/analytics/parameter-usage.ts @@ -7,7 +7,7 @@ import { type ConfigurationParameter } from "../configurations/contract"; import { toParameterInstances } from "../configurations/instances"; -import { formatValue } from "../configurations/selection"; +import { canonicalValue, formatValue } from "../configurations/selection"; import type { ConfigurationParameterUsage, ConfigurationValueUsage @@ -55,7 +55,12 @@ export function buildParameterUsage( isImplicitDefault: true }) })) - : toFreeFormValues(counts, parameter, parameter.default); + : // Counted canonically, so the default is looked up that way too. + toFreeFormValues( + counts, + parameter, + canonicalValue(parameter, parameter.default) + ); return { parameterId: parameter.id, diff --git a/src/backend/features/analytics/tracking.ts b/src/backend/features/analytics/tracking.ts index a55145f31..39c9c0aa0 100644 --- a/src/backend/features/analytics/tracking.ts +++ b/src/backend/features/analytics/tracking.ts @@ -12,7 +12,7 @@ import { type ConfigurationParameter, type Selection } from "../configurations/contract"; -import { appliedValues } from "../configurations/selection"; +import { canonicalValues } from "../configurations/selection"; import { toDayKey } from "./day"; export interface InsertEvent { @@ -96,15 +96,16 @@ export async function trackAppOpen( } /** - * What Onshape applied for a selection: the values no condition hid. Null when - * the insertable has nothing to configure, which is what the log records. + * What Onshape applied for a selection: the values no condition hid, spelled + * canonically so "5 in" and "(2 + 3) in" count as one value. Null when the + * insertable has nothing to configure, which is what the log records. */ function appliedSelection( selection: Selection | undefined, parameters: ConfigurationParameter[] ): Selection | null { if (!selection || parameters.length === 0) return null; - return appliedValues(selection, parameters); + return canonicalValues(selection, parameters); } /** What every logged event carries; its kind fills in the rest. */ diff --git a/src/backend/features/build-checker/issues.test.ts b/src/backend/features/build-checker/issues.test.ts index a6a1864b4..c3fe424c2 100644 --- a/src/backend/features/build-checker/issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -6,7 +6,7 @@ import { BuildIssueSeverity, BuildIssueType, clearBuildIssue, - getIssueConfigurationKey, + getIssueConfiguration, getIssueDescription, getMaxSeverity, knownBuildIssues @@ -23,20 +23,20 @@ const issue = (severity: BuildIssueSeverity): BuildIssue => ({ type: TYPE_BY_SEVERITY[severity] }); -describe("getIssueConfigurationKey", () => { +describe("getIssueConfiguration", () => { it("names the configuration an issue blames", () => { expect( - getIssueConfigurationKey({ + getIssueConfiguration({ type: BuildIssueType.UNSTABLE_COMPOSITE, - configurationKey: "size=large", + values: { size: "large" }, configurationCount: 1 }) - ).toBe("size=large"); + ).toEqual({ size: "large" }); }); it("names none where the element itself is at fault", () => { expect( - getIssueConfigurationKey({ type: BuildIssueType.MULTIPLE_PARTS }) + getIssueConfiguration({ type: BuildIssueType.MULTIPLE_PARTS }) ).toBeUndefined(); }); }); @@ -50,7 +50,7 @@ describe("getIssueDescription", () => { expect( getIssueDescription({ type: BuildIssueType.CONFIGURATION_MULTIPLE_PARTS, - configurationKey: "size=large", + values: { size: "large" }, configurationCount: count }) ).toBe(expected); @@ -67,6 +67,21 @@ describe("knownBuildIssues", () => { ).toEqual([{ type: BuildIssueType.LOAD_FAILED }]); }); + it("reads a configuration blamed by its key as its values", () => { + const stored = { + type: BuildIssueType.UNSTABLE_COMPOSITE, + configurationKey: "size=large", + configurationCount: 2 + } as unknown as BuildIssue; + expect(knownBuildIssues([stored])).toEqual([ + { + type: BuildIssueType.UNSTABLE_COMPOSITE, + values: { size: "large" }, + configurationCount: 2 + } + ]); + }); + it("keeps every type it knows", () => { // Only the type is read, so the ones carrying a configuration stand up // bare here rather than being built twice. diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index 0d625d82d..deee56822 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -6,7 +6,8 @@ import { AUTO_INDEX_THRESHOLD, MAX_PART_NUMBER_CONFIGURATIONS } from "../configurations/combinations"; -import type { ConfigurationKey } from "../configurations/contract"; +import type { PartialSelection } from "../configurations/contract"; +import { decodeConfiguration } from "../configurations/utils"; export enum BuildIssueSeverity { /** A potential issue that is usually fine, e.g. no vendors parsed. */ @@ -47,8 +48,8 @@ interface BuildIssueOf { interface ConfigurationBuildIssueOf< T extends ConfigurationIssueType > extends BuildIssueOf { - /** The first offender, which the build card links out to. */ - configurationKey: ConfigurationKey; + /** The first offender's values, which the build card links out to. */ + values: PartialSelection; /** How many configurations raise it, that first one included. */ configurationCount: number; } @@ -78,11 +79,11 @@ export type BuildIssue = */ export function toConfigurationIssue( type: ConfigurationIssueType, - offenders: { configurationKey: ConfigurationKey }[] + offenders: { values: PartialSelection }[] ): BuildIssue { return { type, - configurationKey: offenders[0].configurationKey, + values: offenders[0].values, configurationCount: offenders.length }; } @@ -91,10 +92,10 @@ export function toConfigurationIssue( * The configuration an issue blames, or undefined where the element itself is * at fault and there is nothing narrower to open. */ -export function getIssueConfigurationKey( +export function getIssueConfiguration( issue: BuildIssue -): ConfigurationKey | undefined { - return "configurationKey" in issue ? issue.configurationKey : undefined; +): PartialSelection | undefined { + return "values" in issue ? issue.values : undefined; } const BUILD_ISSUE_TYPES = new Set(Object.values(BuildIssueType)); @@ -102,10 +103,27 @@ const BUILD_ISSUE_TYPES = new Set(Object.values(BuildIssueType)); /** * Drops issues this deploy has no check for. A stored array was written by * whichever deploy last loaded the row, so it can name a type since removed from - * `BuildIssueType`, which has no severity or description to render. + * `BuildIssueType`, which has no severity or description to render — or blame a + * configuration by its key, as issues did before they carried its values. */ export function knownBuildIssues(issues: BuildIssue[]): BuildIssue[] { - return issues.filter((issue) => BUILD_ISSUE_TYPES.has(issue.type)); + return issues + .filter((issue) => BUILD_ISSUE_TYPES.has(issue.type)) + .map(upgradeIssue); +} + +/** An issue written when a blamed configuration was named by its key. */ +function upgradeIssue(issue: BuildIssue): BuildIssue { + if (!("configurationKey" in issue)) { + return issue; + } + const { configurationKey, ...rest } = issue as BuildIssue & { + configurationKey: string; + }; + return { + ...rest, + values: decodeConfiguration(configurationKey) + } as BuildIssue; } /** A human-readable description of a build issue, shown to editors. */ diff --git a/src/backend/features/configurations/contract.ts b/src/backend/features/configurations/contract.ts index 1d57b3807..7e4d9d08d 100644 --- a/src/backend/features/configurations/contract.ts +++ b/src/backend/features/configurations/contract.ts @@ -70,13 +70,14 @@ interface AlwaysShownVisibilityCondition { export interface ConfigurationResult { parameters: ConfigurationParameter[]; - /** The insertable's search records, so the insert menu can show the part - * number + name of the selected configuration. Empty when not indexed. */ + /** Every record probed, so the insert menu can show the part number and + * name of the selected configuration. Empty when not indexed. */ records: SearchRecord[]; } /** - * The slice of a {@link ConfigurationRecord} search needs. MiniSearch-free, so + * A {@link ConfigurationRecord} as a client reads it: what the part is called, + * where to buy it, and the configuration that produces it. MiniSearch-free, so * the index and the `/configuration` route can share it. */ export interface SearchRecord { @@ -84,10 +85,9 @@ export interface SearchRecord { name?: string; /** The vendor's page for this part, when one can be resolved. */ url?: string; - /** - * The key of the selection producing it, so it names the same render the - * insert menu asks for; empty for the element's own defaults. - */ + /** The enumerated values producing it; empty for the element's defaults. */ + values: PartialSelection; + /** Those values' key, which is only for naming its thumbnail. */ configurationKey: ConfigurationKey; } @@ -134,20 +134,23 @@ export interface QuantityParameter extends ConfigurationParameterBase { } /** - * One selection, keyed by parameter id: always complete, always canonical. - * `toSelection` is what makes one; nothing else may claim to. + * What someone picked, keyed by parameter id: every declared parameter, each + * value as it was entered — a quantity is the expression typed, "(2 + 3) in", + * never the number it evaluates to. `toSelection` is what makes one. */ export type Selection = Record; /** * A selection still being built: enumeration names only what it varies, and a - * search hit only what it overrides. `toSelection` is what makes one whole. + * search hit only what it records. `toSelection` is what makes one whole. */ export type PartialSelection = Partial; /** - * A selection's identity: what it overrides, which is what addresses a render. - * {@link DEFAULT_CONFIGURATION_KEY} — empty — overrides nothing, and so is the default. + * A selection's identity, for addressing its thumbnail and nothing else: what + * it overrides, canonically spelled, so two selections rendering the same part + * share one render. Never stored in place of the selection it came from. + * {@link DEFAULT_CONFIGURATION_KEY} — empty — overrides nothing. */ export type ConfigurationKey = string; @@ -175,19 +178,13 @@ export interface PartMetadata { isOpenComposite: boolean; } -/** - * {@link PartMetadata} for one selection, as it came back from Onshape — keyed - * by the selection as written rather than as it is stored. - */ -export interface ProbedRecord extends PartMetadata { - /** The selection probed, before it is keyed for storage. */ - selection: Selection; -} - -/** A probe as it is stored. Kept only for an indexed insertable. */ +/** What one probe came back with, and the enumerated values it probed. */ export interface ConfigurationRecord extends PartMetadata { - /** The key of the selection that produces it. */ - configurationKey: ConfigurationKey; + /** + * The enum and boolean values enumeration chose; every other parameter was + * at its default. Empty for the element's own defaults. + */ + values: PartialSelection; } /** diff --git a/src/backend/features/configurations/legacy.ts b/src/backend/features/configurations/legacy.ts new file mode 100644 index 000000000..47b287cf3 --- /dev/null +++ b/src/backend/features/configurations/legacy.ts @@ -0,0 +1,79 @@ +/** + * Reads configuration data stored before selections kept what was entered. + * Rows then held keys where they now hold values, and spelled quantities in + * base units; a reload of the row's group rewrites it in the current shape, so + * each of these can go once every library has been reloaded since. + */ +import { + type ConfigurationParameter, + type ConfigurationRecord, + ParameterType, + type Selection +} from "./contract"; +import { canonicalValue, quantityDefault } from "./selection"; +import { decodeConfiguration } from "./utils"; +import { evaluateBaseValue, formatValueInUnit } from "./input-parser"; + +/** A record stored under the key of what it probed, rather than the values. */ +interface LegacyRecord extends Omit { + configurationKey: string; +} + +/** + * A record's values. A legacy key named only enums and booleans, which it + * spelled as entered, so decoding it gives the values back exactly. + */ +export function upgradeRecords( + records: (ConfigurationRecord | LegacyRecord)[] +): ConfigurationRecord[] { + return records.map((record) => { + if ("values" in record) { + return record; + } + const { configurationKey, ...rest } = record; + return { ...rest, values: decodeConfiguration(configurationKey) }; + }); +} + +/** A quantity's default in its own unit, where it used to be in base units. */ +export function upgradeParameters( + parameters: ConfigurationParameter[] +): ConfigurationParameter[] { + return parameters.map((parameter) => + parameter.type === ParameterType.QUANTITY + ? { ...parameter, default: quantityDefault(parameter) } + : parameter + ); +} + +/** + * A stored selection's quantities in their parameter's unit, where they were + * saved in base units: "0.0508 m" reads back as "2 in". Only a value spelled + * exactly as a base-unit value was is touched, so an expression somebody typed + * is left as they typed it. + */ +export function upgradeSelection( + selection: Selection, + parameters: ConfigurationParameter[] +): Selection { + const upgraded = { ...selection }; + for (const parameter of parameters) { + const value = upgraded[parameter.id]; + if ( + parameter.type !== ParameterType.QUANTITY || + value === undefined || + canonicalValue(parameter, value) !== value + ) { + continue; + } + const base = evaluateBaseValue( + value, + parameter.quantityType, + parameter.unit + ); + if (base !== undefined) { + upgraded[parameter.id] = formatValueInUnit(base, parameter.unit); + } + } + return upgraded; +} diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 428f7ad6e..141cd18b2 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -54,10 +54,12 @@ configurationRoutes.get( ); } + const parameters = config.parameters ?? []; const result: ConfigurationResult = { - parameters: config.parameters ?? [], + parameters, records: toSearchRecords( toRecords(config.partMetadata, config.records ?? []), + parameters, config.vendors ) }; diff --git a/src/backend/features/configurations/routes.worker.test.ts b/src/backend/features/configurations/routes.worker.test.ts index e288da922..965c0b49d 100644 --- a/src/backend/features/configurations/routes.worker.test.ts +++ b/src/backend/features/configurations/routes.worker.test.ts @@ -69,6 +69,7 @@ describe("configuration routes", () => { partNumber: "WCP-0405", name: "2x1 Tube", url: "https://wcproducts.com/products/wcp-0405", + values: {}, configurationKey: "" } ] diff --git a/src/backend/features/configurations/selection.test.ts b/src/backend/features/configurations/selection.test.ts index fff18af34..38bee3b9b 100644 --- a/src/backend/features/configurations/selection.test.ts +++ b/src/backend/features/configurations/selection.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_CONFIGURATION_KEY, VisibilityType } from "./contract"; import { appliedValues, + canonicalValues, + findRecord, formatValue, - fromKey, + onshapeOverrides, toKey, - toSelection + toSelection, + toShortestConfiguration } from "./selection"; import { QuantityType, Unit } from "./enums"; import { decodeConfiguration } from "./utils"; @@ -20,28 +23,31 @@ const flag = boolParam("flag"); const length = quantityParam("length"); const parameters = [size, flag, length]; -/** What every boundary does: whatever arrived, made whole and canonical. */ +/** What every boundary does: whatever arrived, made whole. */ function select(values: Record, params = parameters) { return toSelection(values, params); } describe("toSelection", () => { - it("names every parameter, whatever it was given", () => { + it("names every parameter, defaults in their own unit", () => { expect(select({ size: "l" })).toEqual({ size: "l", flag: "false", - length: "0.0254 m" + length: "1 in" }); }); - it("spells equivalent values the same way", () => { - for (const value of ["2in", "2 in", "50.8 mm", "(1 + 1) in"]) { - expect(select({ length: value }).length).toBe("0.0508 m"); - } + it("keeps an expression as it was entered", () => { + expect(select({ length: "(2 + 3) in" }).length).toBe("(2 + 3) in"); + expect(select({ length: " 50.8 mm " }).length).toBe("50.8 mm"); + }); + + it("spells a checkbox the one way Onshape does", () => { + expect(select({ flag: "TRUE" }).flag).toBe("true"); }); - it("keeps an unparseable value as typed", () => { - expect(select({ length: "#value" }).length).toBe("#value"); + it("drops what the parameters do not declare", () => { + expect(select({ gone: "x" })).not.toHaveProperty("gone"); }); it("is unchanged by a second pass", () => { @@ -61,23 +67,11 @@ describe("toKey", () => { ); }); - it("names parameters in declaration order, not object order", () => { - const key = toKey(select({ flag: "true", size: "l" }), parameters); - expect(key).toBe("size=l;flag=true"); - expect(toKey(select({ size: "l", flag: "true" }), parameters)).toBe( - key - ); - }); - - it("drops a parameter the selection hides", () => { - const hidden = enumParam("hidden", ["x", "y"], { - condition: { type: VisibilityType.EQUAL, id: "size", value: "s" } - }); - const params = [size, hidden]; - // size=l hides `hidden`, so its value cannot affect the render. - expect(toKey(select({ size: "l", hidden: "y" }, params), params)).toBe( - "size=l" + it("keys every spelling of one value alike", () => { + const keys = ["2in", "2 in", "50.8 mm", "(1 + 1) in"].map((value) => + toKey(select({ length: value }), parameters) ); + expect(new Set(keys).size).toBe(1); }); it("keys a value equal to the default in another unit as no override", () => { @@ -90,19 +84,31 @@ describe("toKey", () => { it("ignores a difference below the parser's tolerance", () => { expect( toKey(select({ length: "0.02540000000001 m" }), parameters) - ).toBe(toKey(select({ length: "1 in" }), parameters)); + ).toBe(DEFAULT_CONFIGURATION_KEY); + }); + + it("names parameters in declaration order, not object order", () => { + const key = toKey(select({ flag: "true", size: "l" }), parameters); + expect(key).toBe("size=l;flag=true"); + }); + + it("drops a parameter the selection hides", () => { + const hidden = enumParam("hidden", ["x", "y"], { + condition: { type: VisibilityType.EQUAL, id: "size", value: "s" } + }); + const params = [size, hidden]; + expect(toKey(select({ size: "l", hidden: "y" }, params), params)).toBe( + "size=l" + ); }); it("spells an angle in radians", () => { const angle = quantityParam("angle", { quantityType: QuantityType.ANGLE, unit: Unit.DEGREE, - default: "0 deg", defaultValue: 0, max: 360 }); - // Read back out of the key rather than sliced off it, so this stays - // about the spelling and not about how a key encodes one. const key = toKey(select({ angle: "180 deg" }, [angle]), [angle]); const spelled = decodeConfiguration(key).angle; expect(spelled).toMatch(/ rad$/); @@ -110,30 +116,33 @@ describe("toKey", () => { }); }); -describe("fromKey", () => { - it("round-trips every key toKey produces", () => { - const cases: Record[] = [ - { size: "l" }, - { size: "l", flag: "true", length: "2 in" }, - { size: "s" } - ]; - for (const values of cases) { - const key = toKey(select(values), parameters); - expect(toKey(fromKey(key, parameters), parameters)).toBe(key); - } +describe("onshapeOverrides", () => { + it("sends a quantity as it was entered, not as its value", () => { + expect( + onshapeOverrides(select({ length: "(2 + 3) in" }), parameters) + ).toEqual({ length: "(2 + 3) in" }); }); - it("fills what the key leaves unnamed", () => { - expect(fromKey("size=l", parameters)).toEqual(select({ size: "l" })); + it("leaves out a value that is the default however it is spelled", () => { + expect( + onshapeOverrides(select({ length: "25.4 mm" }), parameters) + ).toEqual({}); + }); +}); + +describe("toShortestConfiguration", () => { + it("names the first applied parameter at its value", () => { + expect(toShortestConfiguration(select({}), parameters)).toEqual({ + size: "s" + }); }); }); describe("appliedValues", () => { - const hidden = boolParam("reinforced"); const params = [ size, { - ...hidden, + ...boolParam("reinforced"), condition: { type: VisibilityType.EQUAL as const, id: "size", @@ -156,8 +165,43 @@ describe("appliedValues", () => { }); }); +describe("canonicalValues", () => { + // Analytics counts these, where one value typed two ways is one value. + it("spells two expressions of one value alike", () => { + expect(canonicalValues(select({ length: "5 in" }), parameters)).toEqual( + canonicalValues(select({ length: "(2 + 3) in" }), parameters) + ); + }); +}); + +describe("findRecord", () => { + const own = { values: {} }; + const large = { values: { size: "l" } }; + const largeFlagged = { values: { size: "l", flag: "true" } }; + const records = [own, large, largeFlagged]; + + it("picks the record naming the most of the selection", () => { + expect(findRecord(select({ size: "l", flag: "true" }), records)).toBe( + largeFlagged + ); + expect(findRecord(select({ size: "l" }), records)).toBe(large); + }); + + // A record omits what its enumeration hid, so the selection's value for + // that parameter says nothing either way. + it("matches a record that omits a parameter it hid", () => { + const b = { values: { size: "l" } }; + expect(findRecord({ size: "l", hidden: "x" }, [own, b])).toBe(b); + }); + + it("falls back to the element's own record", () => { + expect(findRecord(select({}), records)).toBe(own); + }); +}); + describe("formatValue", () => { - it("reads a quantity back in the unit its parameter declares", () => { + it("reads a quantity evaluated, in the unit its parameter declares", () => { + expect(formatValue(length, "(1 + 1) in")).toBe("2 in"); expect(formatValue(length, "0.0508 m")).toBe("2 in"); }); @@ -168,45 +212,6 @@ describe("formatValue", () => { it("leaves everything else as stored", () => { expect(formatValue(size, "l")).toBe("l"); - // An option id needs the options to be named, which this does not have. expect(formatValue(flag, "unset")).toBe("unset"); }); }); - -// An indexed record varies enumerated parameters only, while the insert menu -// holds the whole selection. One key is what makes the two agree on a render. -describe("the surfaces agree", () => { - const finish = enumParam("finish", ["matte", "gloss"], { - isCosmetic: true - }); - const all = [size, flag, finish, length]; - - it("spells an enumerated record and the equivalent selection alike", () => { - expect(toKey(select({ size: "l", flag: "false" }, all), all)).toBe( - toKey( - select( - { - size: "l", - flag: "false", - finish: "matte", - length: "1 in" - }, - all - ), - all - ) - ); - }); - - it("spells a non-default cosmetic or quantity value differently", () => { - // Enumeration never varies these, but they do change what renders. - const record = toKey(select({ size: "l" }, all), all); - const cases: Record[] = [ - { size: "l", finish: "gloss" }, - { size: "l", length: "2 in" } - ]; - for (const values of cases) { - expect(toKey(select(values, all), all)).not.toBe(record); - } - }); -}); diff --git a/src/backend/features/configurations/selection.ts b/src/backend/features/configurations/selection.ts index 5c00f5cc5..e4cc9fd81 100644 --- a/src/backend/features/configurations/selection.ts +++ b/src/backend/features/configurations/selection.ts @@ -1,52 +1,47 @@ /** * The two forms a configuration takes, and the only place either is built. - * Canonicalizing is lossy: "2 + 3 in" survives only in the input it was typed into. + * + * A selection is what someone picked, spelled as they picked it, and it is what + * Onshape is sent and what gets stored. A key is derived from one only to name + * its thumbnail: it canonicalizes, which loses the expression that was typed. */ import { type ConfigurationKey, type ConfigurationParameter, + type ConfigurationRecord, ParameterType, type PartialSelection, type QuantityParameter, type Selection } from "./contract"; +import { getUnitDisplayStr } from "./enums"; import { DEFAULT_QUANTITY_PRECISION, - decodeConfiguration, encodeConfiguration, evaluateCondition } from "./utils"; import { evaluateBaseValue, formatBaseValue, - formatValueInUnit, formatValueWithUnits } from "./input-parser"; -/** Normalizes one parameter's raw value to its canonical spelling. */ -export function canonicalizeValue( - parameter: ConfigurationParameter, - value: string +/** + * A quantity's default as Onshape declares it, in the parameter's own unit: + * "1 in". What a quantity's `default` is spelled as. + */ +export function quantityDefault( + parameter: Pick ): string { - if (parameter.type === ParameterType.QUANTITY) { - // "1in", "1 in" and "25.4 mm" are one configuration: the parser reads - // them to one base value. Unparseable values ride as typed. - const base = evaluateBaseValue( - value, - parameter.quantityType, - parameter.unit - ); - return base === undefined ? value.trim() : formatBaseValue(base); - } - if (parameter.type === ParameterType.BOOLEAN) { - return value.trim().toLowerCase(); - } - return value.trim(); + const abbreviation = getUnitDisplayStr(parameter.unit); + const value = String(parameter.defaultValue); + return abbreviation ? `${value} ${abbreviation}` : value; } /** - * Every declared parameter, canonically spelled and in parameter order. Filled - * from the defaults, so a partial map — a search hit's overrides — comes whole. + * Every declared parameter, and nothing else. What arrived is kept as it was + * entered, except that a checkbox is spelled the one way Onshape spells it and + * a quantity loses surrounding whitespace; what is missing takes its default. */ export function toSelection( values: PartialSelection, @@ -54,10 +49,14 @@ export function toSelection( ): Selection { const selection: Selection = {}; for (const parameter of parameters) { - selection[parameter.id] = canonicalizeValue( - parameter, - values[parameter.id] ?? parameter.default - ); + const value = values[parameter.id] ?? parameter.default; + if (parameter.type === ParameterType.BOOLEAN) { + selection[parameter.id] = value.trim().toLowerCase(); + } else if (parameter.type === ParameterType.QUANTITY) { + selection[parameter.id] = value.trim(); + } else { + selection[parameter.id] = value; + } } return selection; } @@ -83,8 +82,58 @@ export function appliedValues( return values; } -/** What a selection changes from the element's own defaults, and nothing else. */ -function overriddenValues( +/** + * One spelling per value: a quantity in base units, so "1in", "1 in" and + * "25.4 mm" agree. An unparseable quantity keeps its own spelling. + */ +export function canonicalValue( + parameter: ConfigurationParameter, + value: string +): string { + if (parameter.type !== ParameterType.QUANTITY) { + return value; + } + const base = evaluateBaseValue( + value, + parameter.quantityType, + parameter.unit + ); + return base === undefined ? value : formatBaseValue(base); +} + +/** + * The applied values, canonically spelled: what two selections are compared by, + * and what analytics counts, where "5 in" and "(2 + 3) in" are one value. + */ +export function canonicalValues( + selection: Selection, + parameters: ConfigurationParameter[] +): Selection { + const applied = appliedValues(selection, parameters); + const values: Selection = {}; + for (const parameter of parameters) { + const value = applied[parameter.id]; + if (value !== undefined) { + values[parameter.id] = canonicalValue(parameter, value); + } + } + return values; +} + +/** Whether a value is the parameter's default, however either is spelled. */ +function isDefault(parameter: ConfigurationParameter, value: string): boolean { + return ( + canonicalValue(parameter, value) === + canonicalValue(parameter, parameter.default) + ); +} + +/** + * What Onshape is told: only what the selection changes from the element's + * defaults, each value as it was entered, so a typed "(2 + 3) in" reaches + * Onshape as that. Empty for the element's defaults. + */ +export function onshapeOverrides( selection: Selection, parameters: ConfigurationParameter[] ): Selection { @@ -92,7 +141,7 @@ function overriddenValues( const overrides: Selection = {}; for (const parameter of parameters) { const value = values[parameter.id]; - if (value !== undefined && value !== parameter.default) { + if (value !== undefined && !isDefault(parameter, value)) { overrides[parameter.id] = value; } } @@ -100,90 +149,76 @@ function overriddenValues( } /** - * A selection's identity: what it overrides, encoded. Two selections that - * render the same thing key the same, and so share a cache entry. + * A selection's thumbnail identity: what it overrides, canonically spelled. + * Two selections that render the same part key the same. */ export function toKey( selection: Selection, parameters: ConfigurationParameter[] ): ConfigurationKey { - return encodeConfiguration(overriddenValues(selection, parameters)); -} - -/** - * Values encoded the way Onshape is told them, quantities in their own unit. - * Never a key and never stored as one: a key is an identity, so it stays in - * base units where two equal values spell alike, while this is only ever read - * by Onshape, which would rather be told "1.5 in". - */ -function encodeForOnshape( - values: Selection, - parameters: ConfigurationParameter[] -): string { - const spelled: Selection = {}; + const overrides = onshapeOverrides(selection, parameters); + const canonical: Selection = {}; for (const parameter of parameters) { - const value = values[parameter.id]; - if (value === undefined) { - continue; + const value = overrides[parameter.id]; + if (value !== undefined) { + canonical[parameter.id] = canonicalValue(parameter, value); } - spelled[parameter.id] = - parameter.type === ParameterType.QUANTITY - ? toExpression(parameter, value) - : value; } - return encodeConfiguration(spelled); -} - -/** The overrides an insert hands Onshape: the short form, empty for defaults. */ -export function toOnshapeConfiguration( - selection: Selection, - parameters: ConfigurationParameter[] -): string { - return encodeForOnshape( - overriddenValues(selection, parameters), - parameters - ); + return encodeConfiguration(canonical); } /** * The shortest configuration that is not empty: the first parameter the * selection applies, at the value it applies. Onshape fills the rest in from the - * element's own defaults, so it names the same render "" does — for a caller - * that must hand Onshape a configuration but cannot hand it "". + * element's own defaults, so it names the same part no overrides do — for a + * caller that must hand Onshape a configuration but cannot hand it an empty one. * * Itself empty only when a condition hides every parameter the element has. */ export function toShortestConfiguration( selection: Selection, parameters: ConfigurationParameter[] -): string { +): Selection { const values = appliedValues(selection, parameters); const first = parameters.find( (parameter) => values[parameter.id] !== undefined ); - return first === undefined ? "" : encodeForOnshape(values, [first]); + return first === undefined ? {} : { [first.id]: values[first.id] }; } -/** The selection a key names: its overrides, over the parameters' defaults. */ -export function fromKey( - key: ConfigurationKey, - parameters: ConfigurationParameter[] -): Selection { - return toSelection(decodeConfiguration(key), parameters); +/** + * The record a selection produces. Records name only what enumeration varied, + * so several can match — the element's own, naming nothing, always does — and + * the one naming the most wins. + */ +export function findRecord>( + selection: Selection, + records: T[] +): T | undefined { + let best: T | undefined; + let bestNamed = -1; + for (const record of records) { + const named = Object.entries(record.values); + const matches = named.every(([id, value]) => selection[id] === value); + if (matches && named.length > bestNamed) { + best = record; + bestNamed = named.length; + } + } + return best; } /** - * A value as a person reads it: a quantity in the unit its parameter declares - * rather than the base unit it is stored in, and a checkbox as its state. An - * enum's value is its option id, which only its own options can name, so the - * caller holding them spells that one. + * A value as a person reads it: a quantity evaluated, in the unit its parameter + * declares, and a checkbox as its state. An enum's value is its option id, + * which only its own options can name, so the caller holding them spells that. */ export function formatValue( parameter: ConfigurationParameter, value: string ): string { if (parameter.type === ParameterType.BOOLEAN) { - // Anything else was not written by `canonicalizeValue`, so it rides as + // Anything else was not written by `toSelection`, so it rides as // stored rather than being read as a "No". if (value === "true") return "Yes"; if (value === "false") return "No"; @@ -205,24 +240,3 @@ export function formatValue( DEFAULT_QUANTITY_PRECISION ); } - -/** - * What Onshape is handed for a quantity: the parameter's own unit, so a derived - * feature reads "1.5 in" rather than the "0.0381 meter" a selection stores. - * Both name the same value — this is the one a person recognizes as theirs. - * - * Not the expression that was typed: that lives in the input and nowhere else, - * so "2 + 3 in" arrives here as "5 in". Unlike {@link formatValue} it keeps - * every decimal, being the value Onshape builds from rather than a label. - */ -export function toExpression( - parameter: QuantityParameter, - value: string -): string { - const base = evaluateBaseValue( - value, - parameter.quantityType, - parameter.unit - ); - return base === undefined ? value : formatValueInUnit(base, parameter.unit); -} diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts index 95ee47a9f..ba2abbb3a 100644 --- a/src/backend/features/configurations/utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -4,15 +4,12 @@ import { encodeConfiguration, encodeQueryConfiguration, evaluateCondition, - findRecordForConfiguration, getPartUrl, - getVisibleOptions, - toQueryConfiguration + getVisibleOptions } from "./utils"; import { OptionVisibilityType, PartMetadata, - SearchRecord, VisibilityType, type ConfigurationParameter } from "./contract"; @@ -20,43 +17,6 @@ import { LogicalOp } from "./enums"; import { Vendor } from "../library/vendors"; import { enumParam } from "../../../__test_utils__/configuration-fixtures"; -function rec(configurationKey: string, partNumber = "PN"): SearchRecord { - return { partNumber, configurationKey }; -} - -describe("findRecordForConfiguration", () => { - it("returns the record whose enumerated values match the selection", () => { - const records = [rec("size=s", "PN-S"), rec("size=l", "PN-L")]; - // The selection also carries a non-enumerated (quantity) param, ignored. - expect( - findRecordForConfiguration("size=l;qty=3", records)?.partNumber - ).toBe("PN-L"); - }); - - it("prefers the most specific match when several apply", () => { - const records = [rec("", "default"), rec("size=l", "PN-L")]; - expect(findRecordForConfiguration("size=l", records)?.partNumber).toBe( - "PN-L" - ); - }); - - it("falls back to a less specific record when a parameter is hidden", () => { - const records = [ - rec("mode=a;detail=x", "A-X"), - // `detail` is hidden when mode=b, so this record omits it. - rec("mode=b", "B") - ]; - expect( - findRecordForConfiguration("mode=b;detail=x", records)?.partNumber - ).toBe("B"); - }); - - it("returns undefined when nothing matches", () => { - const records = [rec("size=s", "PN-S")]; - expect(findRecordForConfiguration("size=l", records)).toBeUndefined(); - }); -}); - describe("configuration text", () => { it("encodes the empty configuration as the empty string", () => { expect(encodeConfiguration({})).toBe(""); @@ -135,13 +95,6 @@ describe("encodeQueryConfiguration", () => { decodeConfiguration(encodeQueryConfiguration(configuration)) ).toEqual(configuration); }); - - it("rewrites a key into the query form", () => { - expect(toQueryConfiguration("length=0.0508%20m;size=l")).toBe( - "length=0.0508 m;size=l" - ); - expect(toQueryConfiguration("")).toBe(""); - }); }); function metadata(fields: Partial): PartMetadata { diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index 7b22d943e..c43ee11e4 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -1,10 +1,8 @@ import { - type ConfigurationKey, type ConfigurationRecord, type PartMetadata, type PartialSelection, Selection, - DEFAULT_CONFIGURATION_KEY, EnumOption, EnumParameter, OptionVisibilityCondition, @@ -12,7 +10,6 @@ import { ConfigurationParameter, ParameterType, QuantityParameter, - SearchRecord, UnitInfo, VisibilityCondition, VisibilityType @@ -26,28 +23,6 @@ import { import { LogicalOp, QuantityType, Unit } from "./enums"; import { type EvaluateOptions, valueWithUnits } from "./input-parser"; -/** - * The record a selection produces. Several can match, since records name only - * enumerated parameters, so the most specific wins. - */ -export function findRecordForConfiguration( - configurationKey: ConfigurationKey, - records: SearchRecord[] -): SearchRecord | undefined { - const selected = new Set(splitConfiguration(configurationKey)); - let best: SearchRecord | undefined; - let bestNamed = -1; - for (const record of records) { - const named = splitConfiguration(record.configurationKey); - const matches = named.every((assignment) => selected.has(assignment)); - if (matches && named.length > bestNamed) { - best = record; - bestNamed = named.length; - } - } - return best; -} - /** * Whether a parameter is shown. Takes a partial selection: visibility is what * enumeration consults while a combination is still being built up. @@ -130,19 +105,22 @@ export function getPartUrl( * assignment. {@link decodeConfiguration} is the other half, and `utils.test.ts` * pins the round trip. * - * This is the form a key is stored and addressed by, and the form a request body - * carries, where nothing escapes it a second time. A query parameter is escaped - * again in transport, so it takes {@link encodeQueryConfiguration} instead. + * This is the form a key takes, and the form a request body carries, where + * nothing escapes it a second time. A query parameter is escaped again in + * transport, so it takes {@link encodeQueryConfiguration} instead. */ -export function encodeConfiguration(configuration?: Selection): string { - if (!configuration) { - return ""; - } - return Object.entries(configuration) +export function encodeConfiguration(configuration?: PartialSelection): string { + return assignments(configuration) .map(([id, value]) => `${id}=${encodeURIComponent(value)}`) .join(";"); } +function assignments(configuration?: PartialSelection): [string, string][] { + return Object.entries(configuration ?? {}).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ); +} + /** The characters the text form is structured by, which a value must not spell. */ const QUERY_ESCAPES: Record = { "%": "%25", @@ -164,29 +142,20 @@ function escapeForQuery(value: string): string { * which is no quantity. The structural three still keep a typed `;` from ending * an assignment, and `decodeConfiguration` reads this form back too. */ -export function encodeQueryConfiguration(configuration?: Selection): string { - if (!configuration) { - return ""; - } - return Object.entries(configuration) +export function encodeQueryConfiguration( + configuration?: PartialSelection +): string { + return assignments(configuration) .map(([id, value]) => `${id}=${escapeForQuery(value)}`) .join(";"); } -/** The same, for a configuration already encoded as a key. */ -export function toQueryConfiguration(configurationKey: string): string { - return encodeQueryConfiguration(decodeConfiguration(configurationKey)); -} - /** The assignments a configuration text names, each still `id=value`. */ function splitConfiguration(configuration: string): string[] { return configuration.split(";").filter((assignment) => assignment !== ""); } -/** - * The values a configuration text names. A key names only what it overrides, so - * what it omits is the parameter's own default — `fromKey` fills those in. - */ +/** The values a configuration text names, in either encoding. */ export function decodeConfiguration(configuration: string): Selection { const values: Selection = {}; for (const assignment of splitConfiguration(configuration)) { @@ -207,10 +176,6 @@ export function getOption( return options.find((option) => option.id === optionId); } -/** - * The enum options the selection leaves visible, by the parameter's own option - * conditions. Partial for the same reason {@link evaluateCondition} is. - */ /** The options one condition controls, listed or spanned. */ function getControlledOptionIds( optionCondition: OptionVisibilityCondition, @@ -328,8 +293,5 @@ export function toRecords( records: ConfigurationRecord[] ): ConfigurationRecord[] { if (!partMetadata) return records; - return [ - { ...partMetadata, configurationKey: DEFAULT_CONFIGURATION_KEY }, - ...records - ]; + return [{ ...partMetadata, values: {} }, ...records]; } diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index daeddc72b..1f92a6914 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -12,14 +12,14 @@ import { import { type Db, getDb } from "../../db/client"; import { chunkForInArray } from "../../db/chunk"; import { users, favorites, configurations, insertables } from "../../db/schema"; -import { toKey, toSelection } from "../configurations/selection"; +import { findRecord, toKey, toSelection } from "../configurations/selection"; +import { upgradeSelection } from "../configurations/legacy"; import { MAX_FAVORITES, type Favorite, type FavoritesData } from "./contract"; import { - DEFAULT_CONFIGURATION_KEY, type ConfigurationParameter, type SearchRecord } from "../configurations/contract"; -import { findRecordForConfiguration } from "../configurations/utils"; +import { toRecords } from "../configurations/utils"; import { toSearchRecords } from "../search/records"; import type { LibraryId } from "../library/library-id"; import { z } from "zod"; @@ -63,8 +63,8 @@ async function getFavorites( .orderBy(asc(favorites.sortOrder)) .all(); - // Keyed here rather than stored: the parameters a selection is canonical - // against move with the library, and only the row is ours to keep. + // Keyed here rather than stored: a reload can move the defaults a key is + // measured against, and only the selection is the favorite's own. const configurationsById = await getConfigurations( db, rows.map((row) => row.insertableId) @@ -75,27 +75,29 @@ async function getFavorites( for (const row of rows) { const { parameters = [], records = [] } = configurationsById.get(row.insertableId) ?? {}; - const stored = row.defaultSelection ?? undefined; // Made whole on the way out as well as in: a row written before a // parameter existed still has to answer as a selection. - const defaultSelection = stored - ? toSelection(stored, parameters) + const defaultSelection = row.defaultSelection + ? toSelection( + upgradeSelection(row.defaultSelection, parameters), + parameters + ) : undefined; - // A favorite storing no selection opens on the element's own defaults, - // which is what the empty key names — so it resolves the right record - // while the field itself stays absent, as the contract has it. - const configurationKey = defaultSelection - ? toKey(defaultSelection, parameters) - : DEFAULT_CONFIGURATION_KEY; const fav: Favorite = { id: row.id, insertableId: row.insertableId, libraryId, defaultSelection, - configurationKey: defaultSelection ? configurationKey : undefined, + configurationKey: defaultSelection + ? toKey(defaultSelection, parameters) + : undefined, // The record this favorite's own selection produces, so a row can - // never show a part number belonging to another configuration. - record: findRecordForConfiguration(configurationKey, records) + // never show a part number belonging to another configuration. No + // selection is the element's defaults, which match its own record. + record: findRecord( + defaultSelection ?? toSelection({}, parameters), + records + ) }; favoritesOut[row.id] = fav; favoriteOrder.push(row.id); @@ -114,12 +116,12 @@ async function getParametersFor( ); } -/** The parameters of each insertable named, for keying against. */ /** What a favorite's insertable contributes to the response. */ interface InsertableConfiguration { - /** What a stored selection is made whole and canonical against. */ + /** What a stored selection is made whole against. */ parameters: ConfigurationParameter[]; - /** What each configuration is called, for the one this favorite names. */ + /** What each configuration is called — the element's own among them — + * for the one this favorite names. */ records: SearchRecord[]; } @@ -141,6 +143,7 @@ async function getConfigurations( .select({ insertableId: insertables.id, vendors: insertables.vendors, + partMetadata: insertables.partMetadata, parameters: configurations.parameters, records: configurations.records }) @@ -154,13 +157,17 @@ async function getConfigurations( ) ); return new Map( - reads.flat().map((row) => [ - row.insertableId, - { - parameters: row.parameters ?? [], - records: toSearchRecords(row.records ?? [], row.vendors) - } - ]) + reads.flat().map((row) => { + const parameters = row.parameters ?? []; + const records = toRecords(row.partMetadata, row.records ?? []); + return [ + row.insertableId, + { + parameters, + records: toSearchRecords(records, parameters, row.vendors) + } + ]; + }) ); } diff --git a/src/backend/features/favorites/routes.worker.test.ts b/src/backend/features/favorites/routes.worker.test.ts index e2e896688..d70f2e78f 100644 --- a/src/backend/features/favorites/routes.worker.test.ts +++ b/src/backend/features/favorites/routes.worker.test.ts @@ -8,6 +8,13 @@ import { beforeEach, describe, expect, it } from "vitest"; import { configurations, favorites, insertables } from "../../db/schema"; import { ElementType } from "../../lib/onshape/element-type"; import { MAX_FAVORITES } from "./contract"; +import { + configurationRecord, + quantityParam +} from "../../../__test_utils__/configuration-fixtures"; + +const partMetadata = (partNumber: string) => + configurationRecord({ partNumber }); import { ApiErrorKind } from "../../lib/api-error"; import { TEST_ASSEMBLY_ID, @@ -186,14 +193,14 @@ describe("favorites routes", () => { // first would answer with the default instead. records: [ { - configurationKey: "", + values: { boolean: "true" }, partNumber: "WCP-2222", name: "Default", hasMultipleParts: false, isOpenComposite: false }, { - configurationKey: "boolean=false", + values: { boolean: "false" }, partNumber: "WCP-1111", name: "Plain", hasMultipleParts: false, @@ -220,25 +227,24 @@ describe("favorites routes", () => { }); // A favorite saved with no selection of its own opens on the element's - // defaults, so that is the record it has to name. - it("resolves the default record for a favorite with no selection", async () => { + // defaults, whose part data lives on the insertable rather than among + // the configurations' records. + it("resolves the element's own record for a favorite with no selection", async () => { await seedPartStudio(db); await seedConfiguration(db); + await db + .update(insertables) + .set({ partMetadata: partMetadata("WCP-2222") }) + .where(eq(insertables.id, TEST_PART_STUDIO_ID)); await db .update(configurations) .set({ records: [ { - configurationKey: "boolean=false", + values: { boolean: "false" }, partNumber: "WCP-1111", hasMultipleParts: false, isOpenComposite: false - }, - { - configurationKey: "", - partNumber: "WCP-2222", - hasMultipleParts: false, - isOpenComposite: false } ] }) @@ -256,9 +262,13 @@ describe("favorites routes", () => { }); // An insertable with nothing to configure has no configurations row at - // all; the join has to answer that as no record rather than throwing. - it("has no record for an insertable with no configuration", async () => { + // all, but still has part data of its own to show. + it("resolves the record of an insertable with no configuration", async () => { await seedPartStudio(db); + await db + .update(insertables) + .set({ partMetadata: partMetadata("WCP-3333") }) + .where(eq(insertables.id, TEST_PART_STUDIO_ID)); await seedFavorite(db, TEST_PART_STUDIO_ID); const res = await createTestApp().request( @@ -267,7 +277,33 @@ describe("favorites routes", () => { env ); expect(res.status).toBe(200); - expect(soleFavorite(await res.json()).record).toBeUndefined(); + expect(soleFavorite(await res.json()).record?.partNumber).toBe( + "WCP-3333" + ); + }); + + // Saved before selections kept what was entered, in base units. + it("reads a legacy base-unit quantity back in its parameter's unit", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + await db + .update(configurations) + .set({ parameters: [quantityParam("length")] }) + .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); + const favoriteId = await seedFavorite(db, TEST_PART_STUDIO_ID); + await db + .update(favorites) + .set({ defaultSelection: { length: "0.0508 m" } }) + .where(eq(favorites.id, favoriteId)); + + const res = await createTestApp().request( + favoritesUrl, + jsonRequest("GET"), + env + ); + expect(soleFavorite(await res.json()).defaultSelection).toEqual({ + length: "2 in" + }); }); it("only returns the current user's favorites", async () => { @@ -563,5 +599,19 @@ describe("favorites routes", () => { boolean: "true" }); }); + + // What the favorite opens with is what was typed, not what it evaluates to. + it("keeps a quantity as the expression it was entered as", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + await db + .update(configurations) + .set({ parameters: [quantityParam("length")] }) + .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); + + expect(await post({ length: "(2 + 3) in" })).toEqual({ + length: "(2 + 3) in" + }); + }); }); }); diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index b7af13222..2c5723d7c 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -10,9 +10,8 @@ import { } from "../../db/schema"; import { LibraryId } from "./library-id"; import { InsertableOut, LibraryOut, Insertables, Groups } from "./contract"; -import { ConfigurationRecord } from "../configurations/contract"; import { toRecords } from "../configurations/utils"; -import { buildSearchDb } from "../search/build"; +import { buildSearchDb, type IndexedConfiguration } from "../search/build"; /** * Assembles the full `LibraryOut` (groups + insertables, in sort order) for a @@ -182,9 +181,13 @@ export async function bumpLibraryVersion( }); } -/** The R2 object key holding a library's serialized MiniSearch index. */ +/** + * The R2 object key holding a library's serialized MiniSearch index. Versioned + * by the shape of what it stores: an index written in an older shape is left + * behind rather than read, and the route rebuilds a missing one. + */ export function searchIndexKey(libraryId: LibraryId): string { - return `search-index/${libraryId}.json`; + return `search-index/v2/${libraryId}.json`; } /** Rebuilds a library's search index into R2; bump `cacheVersion` alongside. */ @@ -193,11 +196,11 @@ export async function rebuildSearchDb( db: Db, libraryId: LibraryId ): Promise { - const [libraryData, recordsMap] = await Promise.all([ + const [libraryData, indexed] = await Promise.all([ getLibraryOut(db, libraryId), - getRecordsMap(db, libraryId) + getIndexedConfigurations(db, libraryId) ]); - const searchDb = JSON.stringify(buildSearchDb(libraryData, recordsMap)); + const searchDb = JSON.stringify(buildSearchDb(libraryData, indexed)); // Uncompressed: encoding here would leave the runtime compressing an // already-compressed body. await bucket.put(searchIndexKey(libraryId), searchDb, { @@ -207,17 +210,19 @@ export async function rebuildSearchDb( } /** - * The records `buildSearchDb` dedupes: an element's own part data plus one per - * indexed configuration. Left joined — an unconfigurable element has no row. + * What `buildSearchDb` indexes: an element's own part data plus one record per + * indexed configuration, and the parameters those are read against. Left + * joined — an unconfigurable element has no row. */ -async function getRecordsMap( +async function getIndexedConfigurations( db: Db, libraryId: LibraryId -): Promise> { +): Promise> { const rows = await db .select({ id: insertables.id, partMetadata: insertables.partMetadata, + parameters: configurations.parameters, records: configurations.records }) .from(insertables) @@ -228,12 +233,12 @@ async function getRecordsMap( .where(eq(insertables.libraryId, libraryId)) .all(); - const recordsMap: Record = {}; + const indexed: Record = {}; for (const row of rows) { const records = toRecords(row.partMetadata, row.records ?? []); if (records.length > 0) { - recordsMap[row.id] = records; + indexed[row.id] = { parameters: row.parameters ?? [], records }; } } - return recordsMap; + return indexed; } diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 9a01930a2..55a130980 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -35,10 +35,11 @@ import { } from "../../../lib/onshape/endpoints/assemblies"; import { PartType } from "../../../lib/onshape/endpoints/documents"; import { + onshapeOverrides, toShortestConfiguration, - toOnshapeConfiguration, toSelection } from "../../configurations/selection"; +import { encodeConfiguration } from "../../configurations/utils"; import { fastenMate } from "../../../lib/onshape/objects/assembly-features"; import { parseFastenInfo } from "../../load/parse-fasten"; import { getFastenQuery } from "./fasten-query"; @@ -382,7 +383,7 @@ insertableRoutes.post( // default to every parameter left out, so this inserts the same thing — // and a whole selection can outrun the configuration Onshape accepts. let configuration = selection - ? toOnshapeConfiguration(selection, parameters) + ? encodeConfiguration(onshapeOverrides(selection, parameters)) : undefined; // Except a part studio at its defaults, which Onshape refuses to insert @@ -395,7 +396,9 @@ insertableRoutes.post( configuration === "" && row.elementType === ElementType.PART_STUDIO ) { - configuration = toShortestConfiguration(selection, parameters); + configuration = encodeConfiguration( + toShortestConfiguration(selection, parameters) + ); } // Resolved here rather than sent by the client: the marker moves diff --git a/src/backend/features/library/routes.ts b/src/backend/features/library/routes.ts index 134da21a3..81500690b 100644 --- a/src/backend/features/library/routes.ts +++ b/src/backend/features/library/routes.ts @@ -4,7 +4,7 @@ import { getApp } from "../../lib/context"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { getDb } from "../../db/client"; import { libraries } from "../../db/schema"; -import { getLibraryOut, searchIndexKey } from "./db"; +import { getLibraryOut, rebuildSearchDb, searchIndexKey } from "./db"; export const libraryRoutes = getApp(); @@ -47,7 +47,16 @@ libraryRoutes.get( const object = await c.env.BLOB.get(searchIndexKey(libraryId)); if (!object) { - return c.notFound(); + // Never built in the shape this deploy reads; build it now rather + // than waiting on the next load. + const searchDb = await rebuildSearchDb( + c.env.BLOB, + getDb(c.env.DB), + libraryId + ); + return new Response(searchDb, { + headers: { "Content-Type": "application/json" } + }); } const headers = new Headers(); diff --git a/src/backend/features/library/routes.worker.test.ts b/src/backend/features/library/routes.worker.test.ts index 6758599e8..a182174f4 100644 --- a/src/backend/features/library/routes.worker.test.ts +++ b/src/backend/features/library/routes.worker.test.ts @@ -102,8 +102,10 @@ describe("library routes", () => { expect(parsed.documentCount).toBeGreaterThan(0); }); - it("GET /search-db 404s when the library has no index", async () => { - await seedLibrary(db); + // An index written in an older shape sits under an older key, so a miss + // is what every library looks like the first time a deploy reads it. + it("GET /search-db builds the index when there is none", async () => { + await seedTestData(db); const app = createTestApp(); const res = await app.request( @@ -111,7 +113,10 @@ describe("library routes", () => { jsonRequest("GET"), env ); - expect(res.status).toBe(404); + expect(res.status).toBe(200); + expect( + await env.BLOB.head(searchIndexKey(TEST_LIBRARY_ID)) + ).not.toBeNull(); }); it("GET /library-version returns the library's version", async () => { diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index 7240eed29..304da9445 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -3,8 +3,7 @@ import { type Db, getDb } from "../../db/client"; import { type Configuration, type PartMetadata, - type ConfigurationParameter, - DEFAULT_CONFIGURATION_KEY + type ConfigurationParameter } from "../configurations/contract"; import { addBuildIssue, @@ -243,7 +242,7 @@ function readPartsStep( const parts = await getParts( await getOnshapeApiFromContext(ctx), elementPath, - DEFAULT_CONFIGURATION_KEY + {} ); return { isOpenComposite: computeOpenComposite(parts), diff --git a/src/backend/features/load/load-insertable.worker.test.ts b/src/backend/features/load/load-insertable.worker.test.ts index dc1436c67..b3cc9bd56 100644 --- a/src/backend/features/load/load-insertable.worker.test.ts +++ b/src/backend/features/load/load-insertable.worker.test.ts @@ -39,8 +39,8 @@ function readInsertable() { const partMetadata = (partNumber?: string): PartMetadata => configurationRecord({ partNumber }); -const record = (partNumber?: string, configurationKey = "") => - configurationRecord({ partNumber, configurationKey }); +const record = (partNumber?: string, values = {}) => + configurationRecord({ partNumber, values }); describe("saveInsertable", () => { beforeEach(async () => { @@ -108,7 +108,10 @@ describe("saveInsertable", () => { }); it("writes the computed configuration records", async () => { - const records = [record("PN-1", "p=v1"), record("PN-2", "p=v2")]; + const records = [ + record("PN-1", { p: "v1" }), + record("PN-2", { p: "v2" }) + ]; await saveInsertable( db, insertableTarget(), diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index ee86001bd..31dc602fe 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -7,12 +7,10 @@ import { parseRecordVendor } from "./parse-vendors"; import { ElementPath } from "../../lib/onshape/path"; import { ElementType } from "../../lib/onshape/element-type"; import { - Selection, - ConfigurationParameter, - DEFAULT_CONFIGURATION_KEY, - PartMetadata, - ConfigurationRecord, - ProbedRecord + type ConfigurationParameter, + type ConfigurationRecord, + type PartialSelection, + type PartMetadata } from "../configurations/contract"; import { addBuildIssue, @@ -25,7 +23,7 @@ import { IndexingBand, isIndexingEnabled } from "../configurations/combinations"; -import { toKey, toSelection } from "../configurations/selection"; +import { onshapeOverrides, toSelection } from "../configurations/selection"; import { getParts } from "../../lib/onshape/endpoints/parts"; import { getElementMetadata } from "../../lib/onshape/endpoints/metadata"; import type { @@ -74,7 +72,7 @@ interface IndexingDecision { /** The limit issues this decision raises, if any. */ buildIssues: BuildIssue[]; /** The combinations to probe, already enumerated by the count. */ - configurations: Selection[]; + configurations: PartialSelection[]; } /** Past the hard cap forcing it on cannot help, since enumeration stops there. */ @@ -89,13 +87,7 @@ export function decideIndexing( return { shouldIndex: true, buildIssues: [], configurations: [] }; } - const counted = countConfigurations(parameters); - const band = counted.band; - // Enumeration names only what varies; every probe past here is a whole - // selection, so nothing downstream has to wonder which it holds. - const configurations = counted.configurations.map((partial) => - toSelection(partial, parameters) - ); + const { band, configurations } = countConfigurations(parameters); const shouldIndex = isIndexingEnabled(band, indexConfigurations); if (band === IndexingBand.EXCEEDED) { @@ -158,22 +150,22 @@ export function computeOpenComposite(parts: OnshapePart[]): boolean { */ export function parsePartStudioRecord( parts: OnshapePart[], - selection: Selection, + values: PartialSelection, isOpenComposite: boolean -): ProbedRecord { +): ConfigurationRecord { const evaluation = evaluateParts(parts); // An element that is an open composite everywhere else has no part to read // in a configuration that loses it; toResult raises the build issue. if (isOpenComposite && !evaluation.isOpenComposite) { return { - selection, + values, hasMultipleParts: false, isOpenComposite: false }; } const part = evaluation.partToUse; return { - selection, + values, partNumber: clean(part?.partNumber), name: clean(part?.name), description: clean(part?.description), @@ -207,11 +199,11 @@ function readMetadataValue(value: unknown): string | undefined { /** Builds a record from an assembly's element metadata for one configuration. */ export function parseAssemblyRecord( metadata: OnshapeMetadataObject, - selection: Selection -): ProbedRecord { + values: PartialSelection +): ConfigurationRecord { // An assembly is never a composite, so it reads nothing about one. - const record: ProbedRecord = { - selection, + const record: ConfigurationRecord = { + values, hasMultipleParts: false, isOpenComposite: false }; @@ -240,15 +232,15 @@ export interface ProbeTarget { */ type ProbeRunner = ( name: string, - read: () => Promise -) => Promise; + read: () => Promise +) => Promise; async function indexRecords( getClient: () => Promise, run: ProbeRunner, target: ProbeTarget, parameters: ConfigurationParameter[], - configurations: Selection[] + configurations: PartialSelection[] ): Promise { // The element's own defaults, probed as a batch of one so every read the // runner sees has the same shape. @@ -257,7 +249,7 @@ async function indexRecords( ); const batches = planBatches(configurations, parameters); - const batchRecords: ProbedRecord[][] = []; + const batchRecords: ConfigurationRecord[][] = []; for (const [index, batch] of batches.entries()) { batchRecords.push( await run(`batch-${index}`, async () => @@ -273,7 +265,7 @@ export function parseConfigurationRecords( client: OnshapeApi, target: ProbeTarget, parameters: ConfigurationParameter[], - configurations: Selection[] + configurations: PartialSelection[] ): Promise { return indexRecords( () => Promise.resolve(client), @@ -293,7 +285,7 @@ export function loadConfigurationRecords( insertableId: string, target: ProbeTarget, parameters: ConfigurationParameter[], - configurations: Selection[] + configurations: PartialSelection[] ): Promise { return indexRecords( () => getOnshapeApiFromContext(ctx), @@ -314,45 +306,48 @@ export function loadConfigurationRecords( * default probe already covers. */ function planBatches( - configurations: Selection[], + configurations: PartialSelection[], parameters: ConfigurationParameter[] -): Selection[][] { - // Canonicalizing to the default means landing on the default probe's record, - // so drop every all-defaults combination, not just the empty one. +): PartialSelection[][] { + // A combination overriding nothing is the default probe again, so drop + // every all-defaults one, not just the empty one. const toFetch = configurations.filter( - (selection) => - toKey(selection, parameters) !== DEFAULT_CONFIGURATION_KEY + (values) => Object.keys(overridesOf(values, parameters)).length > 0 ); - const batches: Selection[][] = []; + const batches: PartialSelection[][] = []; for (let i = 0; i < toFetch.length; i += BATCH_SIZE) { batches.push(toFetch.slice(i, i + BATCH_SIZE)); } return batches; } -/** - * Reads the record Onshape reports for an element in a given configuration. - * Asks by key rather than by whole selection, so the probe and the record it is - * stored under name the same thing, and neither carries what it never overrode. - */ +/** What enumerated values change from the element's defaults. */ +function overridesOf( + values: PartialSelection, + parameters: ConfigurationParameter[] +) { + return onshapeOverrides(toSelection(values, parameters), parameters); +} + +/** Reads the record Onshape reports for an element in one configuration. */ async function probeConfiguration( client: OnshapeApi, target: ProbeTarget, parameters: ConfigurationParameter[], - selection: Selection -): Promise { + values: PartialSelection +): Promise { const { elementPath, elementType, isOpenComposite } = target; - const configurationKey = toKey(selection, parameters); + const configuration = overridesOf(values, parameters); if (elementType === ElementType.ASSEMBLY) { return parseAssemblyRecord( - await getElementMetadata(client, elementPath, configurationKey), - selection + await getElementMetadata(client, elementPath, configuration), + values ); } return parsePartStudioRecord( - await getParts(client, elementPath, configurationKey), - selection, + await getParts(client, elementPath, configuration), + values, isOpenComposite ); } @@ -362,12 +357,12 @@ async function fetchBatch( client: OnshapeApi, target: ProbeTarget, parameters: ConfigurationParameter[], - batch: Selection[] -): Promise { - const records: ProbedRecord[] = []; - for (const selection of batch) { + batch: PartialSelection[] +): Promise { + const records: ConfigurationRecord[] = []; + for (const values of batch) { records.push( - await probeConfiguration(client, target, parameters, selection) + await probeConfiguration(client, target, parameters, values) ); } return records; @@ -375,23 +370,23 @@ async function fetchBatch( /** Onshape's vendor when a part carries one, otherwise the parsed one. */ function resolveVendor( - record: ProbedRecord, + record: ConfigurationRecord, parameters: ConfigurationParameter[] ): string | undefined { return ( record.vendor ?? - parseRecordVendor(record.name, record.selection, parameters) + parseRecordVendor(record.name, record.values, parameters) ); } /** Folds the default probe and every batch together, the default first. */ function toResult( - defaultRecord: ProbedRecord, - batches: ProbedRecord[][], + defaultRecord: ConfigurationRecord, + batches: ConfigurationRecord[][], parameters: ConfigurationParameter[] ): ConfigurationRecordsResult { // The element's own probe describes the element, not a configuration of it, - // so it sheds the (empty) configuration that produced it. + // so it sheds the (empty) values that produced it. const partMetadata: PartMetadata = { partNumber: defaultRecord.partNumber, name: defaultRecord.name, @@ -402,18 +397,10 @@ function toResult( isOpenComposite: defaultRecord.isOpenComposite }; - // Canonical, so a record addresses the thumbnail the insert menu does. The - // selection is dropped rather than stored: the key already says what it held. - const records: ConfigurationRecord[] = batches.flat().map((probe) => { - const { selection, ...record } = probe; - return { - ...record, - // Read before keying, which names only overrides — and so drops a - // default vendor option. - vendor: resolveVendor(probe, parameters), - configurationKey: toKey(selection, parameters) - }; - }); + const records: ConfigurationRecord[] = batches.flat().map((record) => ({ + ...record, + vendor: resolveVendor(record, parameters) + })); // A capped insertable never reaches here: decideIndexing turns indexing off // past the cap, and raises CONFIGURATION_LIMIT_EXCEEDED itself. diff --git a/src/backend/features/load/parse-configuration-records.worker.test.ts b/src/backend/features/load/parse-configuration-records.worker.test.ts index 2a02d7030..731aa04d8 100644 --- a/src/backend/features/load/parse-configuration-records.worker.test.ts +++ b/src/backend/features/load/parse-configuration-records.worker.test.ts @@ -8,11 +8,10 @@ import type { } from "../../lib/onshape/types"; import { ElementPath } from "../../lib/onshape/path"; import { - DEFAULT_CONFIGURATION_KEY, - Selection, - ConfigurationParameter + type ConfigurationParameter, + type PartialSelection, + type Selection } from "../configurations/contract"; -import { decodeConfiguration } from "../configurations/utils"; import { enumParam, paramsWithConfigs @@ -94,7 +93,7 @@ describe("parsePartStudioRecord", () => { false ) ).toEqual({ - selection: { size: "L" }, + values: { size: "L" }, partNumber: "217-2600", name: "Bracket", description: "A bracket", @@ -140,7 +139,7 @@ describe("parsePartStudioRecord", () => { true ) ).toEqual({ - selection: { size: "S" }, + values: { size: "S" }, hasMultipleParts: false, // The composite it was expected to resolve to is gone. isOpenComposite: false @@ -149,7 +148,7 @@ describe("parsePartStudioRecord", () => { it("returns an all-null record for an empty response", () => { expect(parsePartStudioRecord([], { A: "a1" }, false)).toEqual({ - selection: { A: "a1" }, + values: { A: "a1" }, hasMultipleParts: false, isOpenComposite: false }); @@ -170,7 +169,7 @@ describe("parseAssemblyRecord", () => { ] }; expect(parseAssemblyRecord(metadata, { q: "1" })).toEqual({ - selection: { q: "1" }, + values: { q: "1" }, partNumber: "AM-1234", name: "Gearbox", description: "A gearbox", @@ -184,24 +183,21 @@ describe("parseAssemblyRecord", () => { /** * Mocks the parts endpoint, deriving a studio's parts from what the probe - * overrode — a key names that alone, the defaults being left out of it. + * overrode — only that is sent, Onshape filling in the defaults. */ function mockParts(partsFor: (overrides: Selection) => OnshapePart[]) { return vi .spyOn(PartsEndpoints, "getParts") - .mockImplementation((_client, _path, configurationKey) => - Promise.resolve(partsFor(decodeConfiguration(configurationKey))) + .mockImplementation((_client, _path, configuration) => + Promise.resolve(partsFor(configuration)) ); } -/** - * The combinations the load would probe: whole selections, which is what - * `decideIndexing` makes of what enumeration names. - */ +/** The combinations the load would probe, as enumeration names them. */ function probeSelections( parameters: ConfigurationParameter[], elementType: ElementType = ElementType.PART_STUDIO -): Selection[] { +): PartialSelection[] { return decideIndexing(elementType, parameters, true).configurations; } @@ -238,18 +234,14 @@ describe("parseConfigurationRecords", () => { expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a2"]); }); - // A stored record is addressed by its key; carrying the selection it was - // probed with would put a second copy of that in every row. - it("stores the key alone, not the selection behind it", async () => { + it("stores the values each record was probed with", async () => { mockParts(() => [{ partId: "p", partNumber: "PN" }]); const result = await probeRecords([enumParam("A", ["a1", "a2"])]); - expect(result.records).not.toHaveLength(0); - for (const record of result.records) { - expect(record).not.toHaveProperty("selection"); - expect(record.configurationKey).toBe("A=a2"); - } + expect(result.records.map((record) => record.values)).toEqual([ + { A: "a2" } + ]); }); it("fills the vendor Onshape leaves unset, per configuration", async () => { @@ -305,7 +297,7 @@ describe("parseConfigurationRecords", () => { expect(result.buildIssues).toEqual([ { type: BuildIssueType.CONFIGURATION_MULTIPLE_PARTS, - configurationKey: "A=a2", + values: { A: "a2" }, configurationCount: 2 } ]); @@ -347,7 +339,7 @@ describe("parseConfigurationRecords", () => { expect(result.buildIssues).toEqual([ { type: BuildIssueType.UNSTABLE_COMPOSITE, - configurationKey: "A=a2", + values: { A: "a2" }, configurationCount: 1 } ]); @@ -385,10 +377,6 @@ describe("parseConfigurationRecords", () => { hasMultipleParts: false, isOpenComposite: false }); - expect(spy).toHaveBeenCalledWith( - CLIENT, - PATH, - DEFAULT_CONFIGURATION_KEY - ); + expect(spy).toHaveBeenCalledWith(CLIENT, PATH, {}); }); }); diff --git a/src/backend/features/load/parse-configuration.test.ts b/src/backend/features/load/parse-configuration.test.ts index 996291461..93e261f49 100644 --- a/src/backend/features/load/parse-configuration.test.ts +++ b/src/backend/features/load/parse-configuration.test.ts @@ -205,9 +205,8 @@ describe("parseOnshapeConfiguration", () => { const length = parameters[3]; if (length.type !== ParameterType.QUANTITY) throw new Error("expected QUANTITY"); - // Canonical, like every value it will be compared against; the - // numeric form below is what the input seeds its display from. - expect(length.default).toBe("0.0254 m"); + // In its own unit, as Onshape declares it and a person would type it. + expect(length.default).toBe("1 in"); expect(length.defaultValue).toBe(1); expect(length.min).toBe(0); expect(length.max).toBe(100000); diff --git a/src/backend/features/load/parse-configuration.ts b/src/backend/features/load/parse-configuration.ts index 52bdb7f6c..f743d447c 100644 --- a/src/backend/features/load/parse-configuration.ts +++ b/src/backend/features/load/parse-configuration.ts @@ -7,8 +7,7 @@ import { type VisibilityCondition, VisibilityType } from "../configurations/contract"; -import { getUnitDisplayStr } from "../configurations/enums"; -import { canonicalizeValue } from "../configurations/selection"; +import { quantityDefault } from "../configurations/selection"; import { type OnshapeConfigurationResponse, type OnshapeEnumOptionVisibilityConditionList, @@ -155,29 +154,21 @@ export function parseOnshapeConfiguration( }); } else if (parameter.btType === OnshapeParameterType.QUANTITY) { const range = parameter.rangeAndDefault; - const unit = range.units; - const val = range.defaultValue; - - const abbr = getUnitDisplayStr(unit); - const defaultStr = abbr ? `${val} ${abbr}` : String(val); - - parameters.push({ + const quantity = { ...base, - type: ParameterType.QUANTITY, + type: ParameterType.QUANTITY as const, quantityType: parameter.quantityType, - default: defaultStr, - defaultValue: val, + defaultValue: range.defaultValue, min: range.minValue, max: range.maxValue, - unit + unit: range.units + }; + parameters.push({ + ...quantity, + default: quantityDefault(quantity) }); } } - // Canonical from here on, so a default is spelled the way a chosen value - // is and nothing downstream has to canonicalize one to compare them. - return parameters.map((parameter) => ({ - ...parameter, - default: canonicalizeValue(parameter, parameter.default) - })); + return parameters; } diff --git a/src/backend/features/load/parse-vendors.ts b/src/backend/features/load/parse-vendors.ts index 72a22e829..3aee8e068 100644 --- a/src/backend/features/load/parse-vendors.ts +++ b/src/backend/features/load/parse-vendors.ts @@ -2,7 +2,7 @@ import { Vendor, parseVendor } from "../library/vendors"; import { ParameterType, type ConfigurationParameter, - type Selection + type PartialSelection } from "../configurations/contract"; /** A vendor named by one of a text's words, as its code or as its whole name. */ @@ -44,7 +44,7 @@ export function parseVendors( */ export function parseRecordVendor( partName: string | undefined, - selection: Selection, + selection: PartialSelection, parameters: ConfigurationParameter[] ): Vendor | undefined { for (const param of parameters) { diff --git a/src/backend/features/search/build.test.ts b/src/backend/features/search/build.test.ts index affa18077..d386e5d93 100644 --- a/src/backend/features/search/build.test.ts +++ b/src/backend/features/search/build.test.ts @@ -1,14 +1,22 @@ import { describe, expect, it } from "vitest"; import { buildSearchDb } from "./build"; -import { toSearchRecords } from "./records"; +import { distinctRecords, toSearchRecords } from "./records"; import { LibraryOut } from "../library/contract"; import { ElementType } from "../../lib/onshape/element-type"; import { Vendor } from "../library/vendors"; -import { configurationRecord as record } from "../../../__test_utils__/configuration-fixtures"; +import { + enumParam, + configurationRecord as record +} from "../../../__test_utils__/configuration-fixtures"; +import { type ConfigurationRecord } from "../configurations/contract"; + +/** Records of an element with nothing to configure. */ +const unconfigured = (records: ConfigurationRecord[], vendors?: Vendor[]) => + toSearchRecords(records, [], vendors); describe("toSearchRecords", () => { it("drops a part number that only repeats the name", () => { - const [result] = toSearchRecords([ + const [result] = unconfigured([ record({ partNumber: "Spacer", name: "Spacer" }) ]); expect(result.partNumber).toBeUndefined(); @@ -16,14 +24,14 @@ describe("toSearchRecords", () => { }); it("ignores case and surrounding space when comparing the two", () => { - const [result] = toSearchRecords([ + const [result] = unconfigured([ record({ partNumber: " spacer ", name: "Spacer" }) ]); expect(result.partNumber).toBeUndefined(); }); it("will not link a repeated part number to a vendor", () => { - const [result] = toSearchRecords( + const [result] = unconfigured( [record({ partNumber: "Bearing", name: "Bearing" })], [Vendor.WCP] ); @@ -31,7 +39,7 @@ describe("toSearchRecords", () => { }); it("keeps a part number that says something the name does not", () => { - const [result] = toSearchRecords( + const [result] = unconfigured( [record({ partNumber: "WCP-1025", name: "Gearbox" })], [Vendor.WCP] ); @@ -41,25 +49,25 @@ describe("toSearchRecords", () => { it("keeps a record that is left with only a name", () => { expect( - toSearchRecords([record({ partNumber: "Spacer", name: "Spacer" })]) + unconfigured([record({ partNumber: "Spacer", name: "Spacer" })]) ).toHaveLength(1); }); it("drops a record with neither", () => { - expect(toSearchRecords([record({})])).toHaveLength(0); + expect(unconfigured([record({})])).toHaveLength(0); }); // The placeholder an admin writes in identifies nothing, so it is dropped // here rather than indexed and shown. it("drops a placeholder part number", () => { - const [result] = toSearchRecords([ + const [result] = unconfigured([ record({ partNumber: "N/A", name: "Spacer" }) ]); expect(result).toMatchObject({ partNumber: undefined, name: "Spacer" }); }); it("will not link a placeholder to a vendor", () => { - const [result] = toSearchRecords( + const [result] = unconfigured( [record({ partNumber: "N/A", name: "Spacer" })], [Vendor.WCP] ); @@ -67,16 +75,34 @@ describe("toSearchRecords", () => { }); it("drops a record the placeholder leaves with nothing", () => { - expect(toSearchRecords([record({ partNumber: "N/A" })])).toEqual([]); + expect(unconfigured([record({ partNumber: "N/A" })])).toEqual([]); }); + // Only thumbnails read the key; the values are what open the menu. + it("carries a record's values, and their key for its thumbnail", () => { + const size = enumParam("size", ["s", "l"]); + const [result] = toSearchRecords( + [record({ name: "Gear", values: { size: "l" } })], + [size] + ); + expect(result).toMatchObject({ + values: { size: "l" }, + configurationKey: "size=l" + }); + }); +}); + +describe("distinctRecords", () => { it("keeps the first of a repeated (number, name)", () => { - expect( - toSearchRecords([ - record({ partNumber: "WCP-1025", name: "Gear" }), - record({ partNumber: "WCP-1025", name: "Gear" }) - ]) - ).toHaveLength(1); + const [first, second] = unconfigured([ + record({ + partNumber: "WCP-1025", + name: "Gear", + values: { a: "1" } + }), + record({ partNumber: "WCP-1025", name: "Gear", values: { a: "2" } }) + ]); + expect(distinctRecords([first, second])).toEqual([first]); }); }); @@ -125,13 +151,15 @@ describe("buildSearchDb", () => { it("keeps a placeholder part number out of the index and the records", () => { const db = buildSearchDb(library("Spacer"), { - i1: [ - record({ - partNumber: "N/A", - name: "Spacer", - configurationKey: "" - }) - ] + i1: { + parameters: [], + records: [ + record({ + partNumber: "N/A", + name: "Spacer" + }) + ] + } }); expect(db.search("n/a")).toEqual([]); expect(stored(db).records).toEqual([ @@ -142,14 +170,16 @@ describe("buildSearchDb", () => { // The vendor is a resolution fallback, not something to match against. it("never searches the vendor", () => { const db = buildSearchDb(library("Spacer", [Vendor.WCP]), { - i1: [ - record({ - partNumber: "WCP-1025", - name: "Spacer", - vendor: "WestCoast Products", - configurationKey: "" - }) - ] + i1: { + parameters: [], + records: [ + record({ + partNumber: "WCP-1025", + name: "Spacer", + vendor: "WestCoast Products" + }) + ] + } }); expect(db.search("westcoast")).toEqual([]); expect(stored(db).records).toEqual([ diff --git a/src/backend/features/search/build.ts b/src/backend/features/search/build.ts index 97a882fef..f05a49b97 100644 --- a/src/backend/features/search/build.ts +++ b/src/backend/features/search/build.ts @@ -4,9 +4,18 @@ */ import MiniSearch from "minisearch"; import { LibraryOut } from "../library/contract"; -import { ConfigurationRecord, SearchRecord } from "../configurations/contract"; +import { + type ConfigurationParameter, + type ConfigurationRecord +} from "../configurations/contract"; import { SEARCH_OPTIONS, type SearchDocument } from "./contract"; -import { toSearchRecords } from "./records"; +import { distinctRecords, toSearchRecords } from "./records"; + +/** What indexing one insertable's configurations needs. */ +export interface IndexedConfiguration { + parameters: ConfigurationParameter[]; + records: ConfigurationRecord[]; +} /** Joins the distinct non-null values with spaces (a searchable field's form). */ function uniqueJoin(values: (string | undefined)[]): string { @@ -17,7 +26,7 @@ function uniqueJoin(values: (string | undefined)[]): string { export function buildSearchDb( libraryData: LibraryOut, - recordsMap: Record = {} + configurations: Record = {} ): MiniSearch { const searchDb = new MiniSearch(SEARCH_OPTIONS); @@ -27,9 +36,13 @@ export function buildSearchDb( .filter((element) => !!element) .map((element) => { const parentGroup = libraryData.groups[element.groupId]; - const records = toSearchRecords( - recordsMap[element.id] ?? [], - element.vendors + const configuration = configurations[element.id]; + const records = distinctRecords( + toSearchRecords( + configuration?.records ?? [], + configuration?.parameters ?? [], + element.vendors + ) ); return { id: element.id, @@ -39,11 +52,9 @@ export function buildSearchDb( name: element.name, groupName: parentGroup.name, partNumbers: uniqueJoin( - records.map((record: SearchRecord) => record.partNumber) - ), - partNames: uniqueJoin( - records.map((record: SearchRecord) => record.name) + records.map((record) => record.partNumber) ), + partNames: uniqueJoin(records.map((record) => record.name)), records }; }); diff --git a/src/backend/features/search/records.ts b/src/backend/features/search/records.ts index 3d1bf990e..1b460452e 100644 --- a/src/backend/features/search/records.ts +++ b/src/backend/features/search/records.ts @@ -4,10 +4,11 @@ * scoring can answer for anything holding records. */ import { + type ConfigurationParameter, type ConfigurationRecord, - DEFAULT_CONFIGURATION_KEY, - SearchRecord + type SearchRecord } from "../configurations/contract"; +import { toKey, toSelection } from "../configurations/selection"; import { getPartUrl } from "../configurations/utils"; import { meaningfulPartNumber } from "../configurations/part-number"; import { Vendor } from "../library/vendors"; @@ -20,8 +21,7 @@ import { import { PART_NAME_FIELD, PART_NUMBER_FIELD } from "./fields"; /** - * The element's own defaults first. `toKey` leaves out whatever a selection does - * not override, so that record is the one keyed by the empty string. + * The element's own defaults first: the record naming no values. * * Records arrive in the order `enumerateConfigurations` produced them, which is * option declaration order — the default lands wherever Onshape happens to @@ -29,7 +29,7 @@ import { PART_NAME_FIELD, PART_NUMBER_FIELD } from "./fields"; */ function defaultFirst(records: SearchRecord[]): SearchRecord[] { const index = records.findIndex( - (record) => record.configurationKey === DEFAULT_CONFIGURATION_KEY + (record) => Object.keys(record.values).length === 0 ); if (index <= 0) { return records; @@ -180,32 +180,48 @@ function findBestRecord( } /** - * First of each distinct (part number, name) in enumeration order, which keeps - * the latest revision. One identifying nothing is dropped before the index sees it. + * Records as a client reads them, one per probe. One identifying nothing is + * dropped, having nothing to show. */ export function toSearchRecords( records: ConfigurationRecord[], + parameters: ConfigurationParameter[], vendors: Vendor[] = [] ): SearchRecord[] { - const seen = new Set(); const searchRecords: SearchRecord[] = []; - for (const raw of records) { - const partNumber = meaningfulPartNumber(raw.partNumber, raw.name); - const name = clean(raw.name); + for (const record of records) { + const partNumber = meaningfulPartNumber(record.partNumber, record.name); + const name = clean(record.name); if (!partNumber && !name) { continue; } - const key = JSON.stringify([partNumber, name]); - if (seen.has(key)) { - continue; - } - seen.add(key); searchRecords.push({ partNumber, name, - url: getPartUrl({ ...raw, partNumber }, vendors), - configurationKey: raw.configurationKey + url: getPartUrl({ ...record, partNumber }, vendors), + values: record.values, + configurationKey: toKey( + toSelection(record.values, parameters), + parameters + ) }); } return searchRecords; } + +/** + * First of each distinct (part number, name) in enumeration order, which keeps + * the latest revision. What the index stores, which only picks a hit's record; + * something showing the record of a particular selection wants them all. + */ +export function distinctRecords(records: SearchRecord[]): SearchRecord[] { + const seen = new Set(); + return records.filter((record) => { + const key = JSON.stringify([record.partNumber, record.name]); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} diff --git a/src/backend/features/thumbnails/renderer.ts b/src/backend/features/thumbnails/renderer.ts index 78ccceb35..98f307366 100644 --- a/src/backend/features/thumbnails/renderer.ts +++ b/src/backend/features/thumbnails/renderer.ts @@ -35,6 +35,7 @@ import { NoSuchConfigurationError } from "../../lib/onshape/endpoints/thumbnails"; import { type ConfigurationKey } from "../configurations/contract"; +import { decodeConfiguration } from "../configurations/utils"; import { PREFERRED_SIZE, RenderSource, ThumbnailSize } from "./contract"; import { thumbnailKey } from "./keys"; import { putThumbnail } from "./store"; @@ -382,7 +383,7 @@ export class ThumbnailRenderer extends DurableObject { const thumbnailId = await getThumbnailId( onshapeApi, elementPath, - request.configurationKey + decodeConfiguration(request.configurationKey) ); this.ctx.storage.sql.exec( "UPDATE jobs SET thumbnailId = ? WHERE request = ?", diff --git a/src/backend/lib/onshape/endpoints/metadata.ts b/src/backend/lib/onshape/endpoints/metadata.ts index cff8bbcfd..0f93736d7 100644 --- a/src/backend/lib/onshape/endpoints/metadata.ts +++ b/src/backend/lib/onshape/endpoints/metadata.ts @@ -1,24 +1,25 @@ import { OnshapeApi } from "../client"; import { ElementPath, toElementApiPath } from "../path"; import { apiPath } from "../api-path"; -import { type ConfigurationKey } from "../../../features/configurations/contract"; -import { toQueryConfiguration } from "../../../features/configurations/utils"; +import { type Selection } from "../../../features/configurations/contract"; +import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import type { OnshapeMetadataObject } from "../types"; /** Returns an element's metadata properties for a given configuration. */ export function getElementMetadata( client: OnshapeApi, elementPath: ElementPath, - configurationKey: ConfigurationKey + configuration: Selection ): Promise { // Computed properties are expensive and unused, and indexing probes this // once per configuration. const query: Record = { includeComputedProperties: "false" }; - // The query form, not the key: this is escaped again on its way out. - if (configurationKey) { - query.configuration = toQueryConfiguration(configurationKey); + // The query form: this is escaped again on its way out. + const encoded = encodeQueryConfiguration(configuration); + if (encoded) { + query.configuration = encoded; } return client.get(apiPath("metadata", elementPath, toElementApiPath), { query diff --git a/src/backend/lib/onshape/endpoints/parts.ts b/src/backend/lib/onshape/endpoints/parts.ts index 4f77fa253..6044356cd 100644 --- a/src/backend/lib/onshape/endpoints/parts.ts +++ b/src/backend/lib/onshape/endpoints/parts.ts @@ -1,20 +1,22 @@ import { OnshapeApi } from "../client"; import { ElementPath, toElementApiPath } from "../path"; import { apiPath } from "../api-path"; -import { type ConfigurationKey } from "../../../features/configurations/contract"; -import { toQueryConfiguration } from "../../../features/configurations/utils"; +import { type Selection } from "../../../features/configurations/contract"; +import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import type { OnshapePart } from "../types"; -/** Returns the parts of a part studio for a given configuration. */ +/** + * Returns the parts of a part studio, configured by what `configuration` + * changes from the element's defaults. + */ export function getParts( client: OnshapeApi, elementPath: ElementPath, - configurationKey: ConfigurationKey + configuration: Selection ): Promise { + // The query form: this is escaped again on its way out. + const encoded = encodeQueryConfiguration(configuration); return client.get(apiPath("parts", elementPath, toElementApiPath), { - // The query form, not the key: this is escaped again on its way out. - query: configurationKey - ? { configuration: toQueryConfiguration(configurationKey) } - : {} + query: encoded ? { configuration: encoded } : {} }); } diff --git a/src/backend/lib/onshape/endpoints/thumbnails.ts b/src/backend/lib/onshape/endpoints/thumbnails.ts index 520ed8fff..14f1008c6 100644 --- a/src/backend/lib/onshape/endpoints/thumbnails.ts +++ b/src/backend/lib/onshape/endpoints/thumbnails.ts @@ -1,5 +1,5 @@ -import { type ConfigurationKey } from "../../../features/configurations/contract"; -import { toQueryConfiguration } from "../../../features/configurations/utils"; +import { type Selection } from "../../../features/configurations/contract"; +import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import { OnshapeApi } from "../client"; import { assertInstanceType } from "../assertions"; import { ElementPath, toElementApiPath, toInstanceApiPath } from "../path"; @@ -21,10 +21,14 @@ export function getElementThumbnail( /** The configuration matches no insertable, so retrying can only fail again. */ export class NoSuchConfigurationError extends Error {} +/** + * The id Onshape renders a configured element's thumbnail under: fixed for an + * element and configuration, and asking for its bytes is what starts a render. + */ export async function getThumbnailId( client: OnshapeApi, elementPath: ElementPath, - configurationKey?: ConfigurationKey + configuration: Selection ): Promise { const query = new URLSearchParams({ includeParts: "true", @@ -32,9 +36,10 @@ export async function getThumbnailId( includeCompositeParts: "true", elementId: elementPath.elementId }); - // The query form, not the key: this is escaped again on its way out. - if (configurationKey) { - query.set("configuration", toQueryConfiguration(configurationKey)); + // The query form: this is escaped again on its way out. + const encoded = encodeQueryConfiguration(configuration); + if (encoded) { + query.set("configuration", encoded); } const insertables = await client.get( diff --git a/src/backend/lib/onshape/objects/derive-feature.ts b/src/backend/lib/onshape/objects/derive-feature.ts index c950955f3..19621e5ab 100644 --- a/src/backend/lib/onshape/objects/derive-feature.ts +++ b/src/backend/lib/onshape/objects/derive-feature.ts @@ -3,7 +3,6 @@ import { type Selection, type ConfigurationParameter } from "../../../features/configurations/contract"; -import { toExpression } from "../../../features/configurations/selection"; import { type ElementPath } from "../path"; const PART_STUDIO_QUERY = @@ -67,9 +66,9 @@ export class DerivedFeature { return { btType: "BTMParameterQuantity-147", parameterId: parameter.id, - // In the parameter's own unit: the feature dialog shows - // this expression, and "0.0381 meter" is not what was asked for. - expression: toExpression(parameter, value) + // As it was entered: the feature dialog shows this, so a + // typed "(2 + 3) in" should read that way there too. + expression: value }; case ParameterType.BOOLEAN: return { diff --git a/src/backend/lib/onshape/path.ts b/src/backend/lib/onshape/path.ts index 3eb49f779..bd84f48ea 100644 --- a/src/backend/lib/onshape/path.ts +++ b/src/backend/lib/onshape/path.ts @@ -1,5 +1,3 @@ -import { Selection } from "../../features/configurations/contract"; - /** The instance kinds an Onshape path can address, as one definition: the type * and the runtime list validators check against both derive from it. */ export const INSTANCE_TYPES = ["w", "v", "m"] as const; @@ -19,8 +17,6 @@ export interface ElementPath extends InstancePath { elementId: string; } -/** Represents a part inside a Part Studio. */ - /** The version-pinned tab a stored insertable row addresses. */ export function toElementPath(row: { documentId: string; @@ -35,10 +31,6 @@ export function toElementPath(row: { }; } -export interface ConfigurablePath extends ElementPath { - selection: Selection; -} - function isDocumentPath(path: unknown): path is DocumentPath { return ( typeof path === "object" && @@ -64,15 +56,6 @@ export function isElementPath(path: unknown): path is ElementPath { ); } -export function isConfigurablePath( - path: DocumentPath -): path is ConfigurablePath { - return ( - isElementPath(path) && - (path as ConfigurablePath).selection !== undefined - ); -} - export function toDocumentApiPath(path: DocumentPath): string { return `/d/${path.documentId}`; } @@ -102,9 +85,7 @@ function toInstanceTypeKey(instanceType: InstanceType): InstanceTypeKey { * Returns the named-ID object that Onshape API bodies/query params expect, * e.g. `{ documentId, workspaceId }` rather than the `/d/.../w/...` path form. */ -function toInstanceApiObject( - path: InstancePath -): Record { +function toInstanceApiObject(path: InstancePath): Record { return { documentId: path.documentId, [toInstanceTypeKey(path.instanceType)]: path.instanceId diff --git a/src/frontend/components/open-document-items.tsx b/src/frontend/components/open-document-items.tsx index a94a69662..485f0b39a 100644 --- a/src/frontend/components/open-document-items.tsx +++ b/src/frontend/components/open-document-items.tsx @@ -2,21 +2,24 @@ import { Menu } from "@mantine/core"; import { ArrowSquareOutIcon, LinkIcon } from "@phosphor-icons/react"; import { ReactNode } from "react"; import { - ConfigurablePath, DocumentPath, + ElementPath, InstancePath } from "@backend/lib/onshape/path"; +import { type PartialSelection } from "@backend/features/configurations/contract"; import { IconSize, StatusColor } from "../lib/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../lib/url"; interface OpenDocumentItemsProps { /** Any Onshape path; a shell group's stops at the document. */ - path: DocumentPath | InstancePath | ConfigurablePath; + path: DocumentPath | InstancePath | ElementPath; + /** The configuration to open an element in; its defaults when absent. */ + selection?: PartialSelection; } /** The menu items for reaching an element's Onshape document. */ export function OpenDocumentItems(props: OpenDocumentItemsProps): ReactNode { - const url = makeUrl(props.path); + const url = makeUrl(props.path, props.selection); return ( <> - + ); diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 4de9a4700..8b2bd763d 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -10,12 +10,15 @@ import { FloppyDiskIcon } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { ReactNode, useState } from "react"; import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; -import { ConfigurationWrapper } from "../../insert/components/configurations"; +import { + ConfigurationWrapper, + type SelectionReport +} from "../../insert/components/configurations"; import { FavoriteIcon } from "./favorite-button"; import { DEFAULT_CONFIGURATION_KEY, - Selection, - SearchRecord + type PartialSelection, + Selection } from "@backend/features/configurations/contract"; import { useFavoritesQuery, @@ -29,7 +32,7 @@ interface FavoriteMenuContentProps { /** The modal this renders in, so the header can track the selection. */ modalId: string; /** What the favorite opens with today. */ - initialSelection?: Selection; + initialSelection?: PartialSelection; } export function FavoriteMenuContent( @@ -42,13 +45,12 @@ export function FavoriteMenuContent( const insertables = libraryQuery.data?.insertables; const favoritesData = favoritesQuery.data; - const [selection, setSelection] = useState( - initialSelection - ); - // Reported by ConfigurationWrapper; names this selection's thumbnail. - // Undefined until it reports, which is what gates saving. - const [configurationKey, setConfigurationKey] = useState(); - const [record, setRecord] = useState(undefined); + const [selection, setSelection] = useState< + PartialSelection | Selection | undefined + >(initialSelection); + // Undefined until the panel settles the selection, which gates saving: + // saving before then would store nothing, wiping the favorite's selection. + const [report, setReport] = useState(); const favorite = favoritesData?.favorites[favoriteId]; const insertable = @@ -58,15 +60,12 @@ export function FavoriteMenuContent( useMenuTitle(modalId, { name: insertable?.name, - record, + record: report?.record, icon: }); - const setDefaultConfigurationMutation = useSetDefaultConfigurationMutation( - favoriteId, - selection, - configurationKey - ); + const setDefaultConfigurationMutation = + useSetDefaultConfigurationMutation(favoriteId); if (!insertable) { return null; @@ -89,14 +88,13 @@ export function FavoriteMenuContent( microversionId={insertable.microversionId} largeThumbnailUrl={insertable.largeThumbnailUrl} configurationKey={ - configurationKey ?? DEFAULT_CONFIGURATION_KEY + report?.configurationKey ?? DEFAULT_CONFIGURATION_KEY } /> } - // Saving before the wrapper reports would store nothing, - // wiping the favorite's selection. - disabled={configurationKey === undefined} + disabled={report === undefined} onClick={() => { - setDefaultConfigurationMutation.mutate(); + if (report) { + setDefaultConfigurationMutation.mutate(report); + } modals.closeAll(); }} > diff --git a/src/frontend/features/favorites/open-favorite-menu.tsx b/src/frontend/features/favorites/open-favorite-menu.tsx index 2456e44bd..3a0257c78 100644 --- a/src/frontend/features/favorites/open-favorite-menu.tsx +++ b/src/frontend/features/favorites/open-favorite-menu.tsx @@ -1,5 +1,5 @@ import { openAppModal } from "../../components/open-app-modal"; -import { type Selection } from "@backend/features/configurations/contract"; +import { type PartialSelection } from "@backend/features/configurations/contract"; import { FavoriteMenuContent } from "./components/favorite-menu"; import { MenuTitle } from "../../components/app-title"; import { FavoriteIcon } from "./components/favorite-button"; @@ -9,7 +9,7 @@ interface OpenFavoriteMenuProps { favoriteId: string; insertableName: string; /** What the favorite opens with today. */ - selection?: Selection; + selection?: PartialSelection; } export function openFavoriteMenu(props: OpenFavoriteMenuProps) { diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts index df71c4071..425ffc492 100644 --- a/src/frontend/features/favorites/queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -59,27 +59,24 @@ export function useFavorite(insertableId: string): Favorite | undefined { : undefined; } -/** - * Saves what the favorite opens with. Takes its key too, so the - * cached row names the right thumbnail before the refetch answers. - */ -export function useSetDefaultConfigurationMutation( - favoriteId: string, - selection: Selection | undefined, - configurationKey: ConfigurationKey | undefined -) { +/** What a favorite is saved to open with, and the thumbnail that names. */ +interface DefaultConfiguration { + selection: Selection; + /** So the cached row names the right thumbnail before the refetch. */ + configurationKey: ConfigurationKey; +} + +/** Saves what the favorite opens with. */ +export function useSetDefaultConfigurationMutation(favoriteId: string) { const libraryId = useLibraryId(); const refreshFavorites = useRefreshFavorites(); return useMutation({ mutationKey: ["set-default-selection"], - mutationFn: async () => { - // The whole selection, not its key: the key names only what the - // selection overrides, and the favorite opens on all of it. - return apiPost("/default-selection" + toFavoritePath(favoriteId), { - body: { selection: selection } - }); - }, - onMutate: async () => { + mutationFn: async ({ selection }: DefaultConfiguration) => + apiPost("/default-selection" + toFavoritePath(favoriteId), { + body: { selection } + }), + onMutate: async ({ selection, configurationKey }) => { const queryKey = favoritesQueryKey(libraryId); await queryClient.cancelQueries({ queryKey }); queryClient.setQueryData( diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 99d4a20f6..22df58ebd 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -16,6 +16,7 @@ import { useState } from "react"; import { + type PartialSelection, Selection, type ConfigurationKey, ConfigurationResult, @@ -31,12 +32,12 @@ import { } from "@backend/features/configurations/contract"; import { evaluateCondition, - findRecordForConfiguration, getEvaluateOptions, getVisibleOptions } from "@backend/features/configurations/utils"; import { - canonicalizeValue, + findRecord, + onshapeOverrides, toKey, toSelection } from "@backend/features/configurations/selection"; @@ -53,18 +54,28 @@ import { } from "../parameter-value"; import { seedFrom } from "../quantity-box"; +/** + * What the panel settled a selection into, reported because only the panel + * has the parameters each of these is measured against. + */ +export interface SelectionReport { + /** Whole, and settled against the parameters' conditions. */ + selection: Selection; + /** Only what differs from the element's defaults, as entered. */ + overrides: Selection; + /** Names the selection's thumbnail. */ + configurationKey: ConfigurationKey; + /** The part the selection produces, for the menu's header. */ + record: SearchRecord | undefined; +} + interface ConfigurationWrapperProps { insertableId: string; microversionId: string; - selection?: Selection; + /** Partial until the parameters load: a search hit names only its own. */ + selection?: PartialSelection; setSelection: Dispatch; - /** - * Reported here because only this component has the parameters the key is - * measured against. - */ - onConfigurationKey?: (configurationKey: ConfigurationKey) => void; - /** Reports the record the selection produces, for the menu's header. */ - onRecord?: (record: SearchRecord | undefined) => void; + onReport?: (report: SelectionReport) => void; /** * A row was moved, as against the panel settling the selection on load. * Any interaction counts, including picking what was already picked. @@ -72,24 +83,22 @@ interface ConfigurationWrapperProps { onEdit?: () => void; } -/** Reports the selection's key, and the record it resolves to. */ function useReportSelection( - parameters: ConfigurationParameter[] | undefined, - records: SearchRecord[] | undefined, + result: ConfigurationResult | undefined, selection: Selection | undefined, - onConfigurationKey?: (configurationKey: ConfigurationKey) => void, - onRecord?: (record: SearchRecord | undefined) => void + onReport?: (report: SelectionReport) => void ) { useEffect(() => { - if (!parameters || !selection) { + if (!result || !selection) { return; } - const configurationKey = toKey(selection, parameters); - onConfigurationKey?.(configurationKey); - if (records) { - onRecord?.(findRecordForConfiguration(configurationKey, records)); - } - }, [parameters, records, selection, onConfigurationKey, onRecord]); + onReport?.({ + selection, + overrides: onshapeOverrides(selection, result.parameters), + configurationKey: toKey(selection, result.parameters), + record: findRecord(selection, result.records) + }); + }, [result, selection, onReport]); } export function ConfigurationWrapper( @@ -100,8 +109,7 @@ export function ConfigurationWrapper( microversionId, selection, setSelection, - onConfigurationKey, - onRecord, + onReport, onEdit } = props; @@ -138,13 +146,7 @@ export function ConfigurationWrapper( } }, [whole, selection, setSelection]); - useReportSelection( - parameters, - query.data?.records, - whole, - onConfigurationKey, - onRecord - ); + useReportSelection(query.data, whole, onReport); // The rows' own writes, as against the settle above: same selection, but // only this one is somebody configuring the part. @@ -394,11 +396,10 @@ function QuantityInput(props: ParameterProps): ReactNode { expression: result.expression, display: result.displayExpression }); - // Canonical, so the menu holds a selection like everywhere else; - // `expression` keeps what was typed for as long as this input lives. - const canonical = canonicalizeValue(parameter, result.expression); - setEmitted(canonical); - onValueChange(canonical); + // The expression, not its value: it is what Onshape is sent, so a + // typed "(2 + 3) in" reaches the derived feature as that. + setEmitted(result.expression); + onValueChange(result.expression); }; return ( diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 83ac0fabd..e72847be1 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -22,7 +22,7 @@ import { FavoriteButton } from "../../favorites/components/favorite-button"; import { MenuButton } from "../../../components/app-menu"; import { GetAppCallout } from "../../../components/get-app"; import { InsertableMenuItems } from "../../library/components/insertable-card"; -import { ConfigurationWrapper } from "./configurations"; +import { ConfigurationWrapper, type SelectionReport } from "./configurations"; import { useConfigurationQuery, useInsertMutation, @@ -31,9 +31,10 @@ import { import { type ConfigurationKey, DEFAULT_CONFIGURATION_KEY, - Selection, - SearchRecord + type PartialSelection, + Selection } from "@backend/features/configurations/contract"; +import { encodeConfiguration } from "@backend/features/configurations/utils"; import { useFavorite } from "../../favorites/queries"; import { useGetUiState, updateUiState } from "../../../lib/ui-state"; import { RequireSignIn } from "../../auth/access-level"; @@ -45,31 +46,35 @@ interface InsertMenuContentProps { insertable: InsertableOut; /** The modal this renders in, so the header can track the selection. */ modalId: string; - initialSelection?: Selection; - /** That selection's key, so the preview and the url have it before the - * parameters load and the panel reports its own. */ + initialSelection?: PartialSelection; + /** That selection's key, so the preview has it before the parameters load + * and the panel reports its own. */ initialConfigurationKey?: ConfigurationKey; + /** Every selection the menu settles on, the last being what it closed on. */ + onSelectionChange?: (selection: Selection) => void; onInsert: () => void; source: InsertSource; } export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { - const { insertable, modalId, onInsert, source } = props; + const { insertable, modalId, onSelectionChange, onInsert, source } = props; const favorite = useFavorite(insertable.id); useThumbnailWaitTip(); - const [selection, setSelection] = useState(props.initialSelection); - // Reported by ConfigurationWrapper, which has the parameters the key is - // measured against. Empty means the element's own defaults. - const [configurationKey, setConfigurationKey] = useState( - props.initialConfigurationKey ?? DEFAULT_CONFIGURATION_KEY - ); + const [selection, setSelection] = useState< + PartialSelection | Selection | undefined + >(props.initialSelection); + // Undefined until the panel settles the selection against its parameters. + const [report, setReport] = useState(); + const configurationKey = + report?.configurationKey ?? + props.initialConfigurationKey ?? + DEFAULT_CONFIGURATION_KEY; // What the preview stops following for a signed-out caller. const [isEdited, setIsEdited] = useState(false); // Whether an insert would be one a right-click could have done: cleared // by an edit below, and by the menu having been up long enough to read. const [canShowQuickInsertTip, setCanShowQuickInsertTip] = useState(true); - const [record, setRecord] = useState(undefined); // A part with no parameters has one record — the element's own part data — // which no ConfigurationWrapper is mounted to report, but the title wants. const soleRecord = useConfigurationQuery( @@ -80,7 +85,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { useMenuTitle(modalId, { name: insertable.name, - record: record ?? soleRecord + record: report?.record ?? soleRecord }); useSignInPreviewTip(isEdited); @@ -92,8 +97,14 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { // What the url carries, so a relaunch reopens the configuration on screen // rather than the one the menu was opened with. useEffect(() => { - updateUiState({ openConfigurationKey: configurationKey || undefined }); - }, [configurationKey]); + if (report) { + updateUiState({ + openConfiguration: + encodeConfiguration(report.overrides) || undefined + }); + onSelectionChange?.(report.selection); + } + }, [report, onSelectionChange]); useEffect(() => { const timer = setTimeout( @@ -111,8 +122,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { microversionId={insertable.microversionId} selection={selection} setSelection={setSelection} - onConfigurationKey={setConfigurationKey} - onRecord={setRecord} + onReport={setReport} onEdit={onEdit} /> ); @@ -149,7 +159,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { interface InsertMenuFooterProps { insertable: InsertableOut; favorite: Favorite | undefined; - selection?: Selection; + selection?: PartialSelection; configurationKey: ConfigurationKey; /** Whether an insert now is worth pointing out a right-click for. */ canShowQuickInsertTip: boolean; @@ -208,7 +218,7 @@ interface InsertButtonsProps { /** Whether an insert now is worth pointing out a right-click for. */ canShowQuickInsertTip: boolean; insertable: InsertableOut; - selection?: Selection; + selection?: PartialSelection; isFavorite: boolean; onInsert: () => void; source: InsertSource; diff --git a/src/frontend/features/insert/components/quick-insert-items.tsx b/src/frontend/features/insert/components/quick-insert-items.tsx index ed37a48de..e1ecf7194 100644 --- a/src/frontend/features/insert/components/quick-insert-items.tsx +++ b/src/frontend/features/insert/components/quick-insert-items.tsx @@ -2,7 +2,7 @@ import { Menu } from "@mantine/core"; import { PlusIcon } from "@phosphor-icons/react"; import { ReactNode, useCallback } from "react"; import { InsertableOut } from "@backend/features/library/contract"; -import { Selection } from "@backend/features/configurations/contract"; +import { type PartialSelection } from "@backend/features/configurations/contract"; import { ElementType } from "@backend/lib/onshape/element-type"; import { InsertSource } from "@backend/features/analytics/usage"; import { IconSize, StatusColor } from "../../../lib/style-constants"; @@ -15,7 +15,7 @@ import { useInsertMutation } from "../queries"; interface QuickInsertItemsProps { insertable: InsertableOut; - selection?: Selection; + selection?: PartialSelection; isFavorite: boolean; source: InsertSource; } diff --git a/src/frontend/features/insert/open-insert-menu.tsx b/src/frontend/features/insert/open-insert-menu.tsx index ebecd58fd..1b80e9cd2 100644 --- a/src/frontend/features/insert/open-insert-menu.tsx +++ b/src/frontend/features/insert/open-insert-menu.tsx @@ -4,8 +4,9 @@ import { openAppModal } from "../../components/open-app-modal"; import type { InsertableOut } from "@backend/features/library/contract"; import { type ConfigurationKey, - type Selection + type PartialSelection } from "@backend/features/configurations/contract"; +import { encodeConfiguration } from "@backend/features/configurations/utils"; import { updateUiState } from "../../lib/ui-state"; import { @@ -19,9 +20,10 @@ import { InsertSource } from "@backend/features/analytics/usage"; interface OpenInsertMenuProps { insertable: InsertableOut; - initialSelection?: Selection; - /** That selection's key, when the caller knows it; the menu reports its - * own once the parameters load, which is what keeps the url current. */ + /** Partial for a search hit or a link, which name only some parameters. */ + initialSelection?: PartialSelection; + /** That selection's key, when the caller knows it, so the preview need not + * wait on the parameters loading. */ configurationKey?: ConfigurationKey; /** The favorite this was opened from, so a relaunch can reopen it as one. */ favoriteId?: string; @@ -31,7 +33,7 @@ interface OpenInsertMenuProps { /** Nothing is open, which is what closing the menu leaves behind. */ const NO_OPEN_MENU = { openInsertableId: undefined, - openConfigurationKey: undefined, + openConfiguration: undefined, openFavoriteId: undefined }; @@ -44,11 +46,16 @@ export function openInsertMenu(props: OpenInsertMenuProps) { source } = props; let didInsert = false; + // What the menu shows when it closes, for the restore toast to reopen. + let lastSelection = initialSelection; // Recorded rather than merely rendered: the url mirrors this, and a // relaunch — an Onshape tab switch among them — reopens what it names. + // The menu narrows it to its overrides once the parameters load. updateUiState({ openInsertableId: insertable.id, - openConfigurationKey: configurationKey, + openConfiguration: initialSelection + ? encodeConfiguration(initialSelection) || undefined + : undefined, openFavoriteId: favoriteId }); // Minted here so the content can address the modal it lives in, which is @@ -61,7 +68,7 @@ export function openInsertMenu(props: OpenInsertMenuProps) { onClose: () => { updateUiState(NO_OPEN_MENU); if (!didInsert) { - showRestoreToast(insertable, source, initialSelection); + showRestoreToast(insertable, source, lastSelection); } }, children: ( @@ -70,6 +77,9 @@ export function openInsertMenu(props: OpenInsertMenuProps) { modalId={id} initialSelection={initialSelection} initialConfigurationKey={configurationKey} + onSelectionChange={(selection) => { + lastSelection = selection; + }} source={source} onInsert={() => { didInsert = true; @@ -80,15 +90,11 @@ export function openInsertMenu(props: OpenInsertMenuProps) { }); } -/** - * Both are shown: the element name is how the part was found, the part number - * and name are what gets inserted. - */ - +/** Offers the menu back, configured the way it was closed. */ function showRestoreToast( insertable: InsertableOut, source: InsertSource, - selection?: Selection + selection?: PartialSelection ) { const restoreButton: NotificationAction = { text: "Restore", diff --git a/src/frontend/features/insert/parameter-value.ts b/src/frontend/features/insert/parameter-value.ts index af0e5686a..de0d4be7e 100644 --- a/src/frontend/features/insert/parameter-value.ts +++ b/src/frontend/features/insert/parameter-value.ts @@ -2,6 +2,7 @@ import { type ConfigurationParameter, type EnumOption, ParameterType, + type PartialSelection, type Selection } from "@backend/features/configurations/contract"; import { @@ -72,7 +73,10 @@ function normalizeOnce( return next; } -export function sameSelection(a: Selection | undefined, b: Selection): boolean { +export function sameSelection( + a: PartialSelection | undefined, + b: Selection +): boolean { if (!a) return false; const keys = Object.keys(b); return ( diff --git a/src/frontend/features/insert/quantity-box.test.ts b/src/frontend/features/insert/quantity-box.test.ts index 5c9e8da27..2b17ac869 100644 --- a/src/frontend/features/insert/quantity-box.test.ts +++ b/src/frontend/features/insert/quantity-box.test.ts @@ -5,7 +5,6 @@ import { } from "@backend/features/configurations/contract"; import { QuantityType, Unit } from "@backend/features/configurations/enums"; import { getEvaluateOptions } from "@backend/features/configurations/utils"; -import { canonicalizeValue } from "@backend/features/configurations/selection"; import { seedFrom } from "./quantity-box"; const SHAFT_LENGTH: QuantityParameter = { @@ -25,25 +24,21 @@ const SHAFT_LENGTH: QuantityParameter = { const STANDALONE = getEvaluateOptions(SHAFT_LENGTH, {}); describe("seedFrom", () => { - // A selection is canonically in base units, which is not a spelling anyone - // wants handed to them in the box when they click into it. - it("opens a canonical value for editing in the parameter's own unit", () => { - const canonical = canonicalizeValue(SHAFT_LENGTH, SHAFT_LENGTH.default); - expect(canonical).toBe("1.1938 m"); - - expect(seedFrom(canonical, SHAFT_LENGTH, STANDALONE)).toEqual({ - expression: "47 in", + // The box edits what was typed and shows what it evaluates to. + it("opens an expression for editing as it was entered", () => { + expect(seedFrom("(40 + 7) in", SHAFT_LENGTH, STANDALONE)).toEqual({ + expression: "(40 + 7) in", display: "47 in" }); }); - it("renders in the document's unit when there is one", () => { + it("shows the value in the document's unit when there is one", () => { const metric = getEvaluateOptions(SHAFT_LENGTH, { lengthUnit: Unit.MILLIMETER, lengthPrecision: 1 }); - expect(seedFrom("1.1938 m", SHAFT_LENGTH, metric)).toEqual({ - expression: "1193.8 mm", + expect(seedFrom("47 in", SHAFT_LENGTH, metric)).toEqual({ + expression: "47 in", display: "1193.8 mm" }); }); diff --git a/src/frontend/features/insert/quantity-box.ts b/src/frontend/features/insert/quantity-box.ts index e8ddaf3af..c41d254a0 100644 --- a/src/frontend/features/insert/quantity-box.ts +++ b/src/frontend/features/insert/quantity-box.ts @@ -16,12 +16,8 @@ export interface QuantityBox { } /** - * What the box shows for a value, and the error if it does not evaluate. - * - * A seeded value was never typed here, so the display spelling is what it opens - * for editing: a selection is canonically in base units, and nobody wants to - * edit a 47 inch shaft as "1.1938 m". Only a submitted expression is kept as - * typed, and only for as long as that input lives. + * What the box shows for a value, and the error if it does not evaluate: the + * expression to edit while focused, and what it evaluates to otherwise. */ export function seedFrom( value: string | undefined, @@ -46,7 +42,7 @@ export function seedFrom( errorMessage: result.errorMessage } : { - expression: result.displayExpression, + expression: result.expression, display: result.displayExpression }; } diff --git a/src/frontend/features/insert/queries.ts b/src/frontend/features/insert/queries.ts index 285c34399..0200ee35b 100644 --- a/src/frontend/features/insert/queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -7,7 +7,7 @@ import { import { apiGet, apiPost } from "../../lib/api-client"; import { type ConfigurationResult, - Selection, + type PartialSelection, type UnitInfo } from "@backend/features/configurations/contract"; import { type ElementPath, InstancePath } from "@backend/lib/onshape/path"; @@ -93,7 +93,8 @@ export function useIsFetchingConfiguration( export function useInsertMutation( insertable: InsertableOut, - selection: Selection | undefined, + /** Partial is fine: the server makes it whole. */ + selection: PartialSelection | undefined, insertArgs: InsertArgs ) { const target = useTargetElement(); diff --git a/src/frontend/features/insert/restore-insert-menu.ts b/src/frontend/features/insert/restore-insert-menu.ts index 7456ff179..cc0c434f5 100644 --- a/src/frontend/features/insert/restore-insert-menu.ts +++ b/src/frontend/features/insert/restore-insert-menu.ts @@ -19,7 +19,7 @@ let restored = false; * turns the stored id back into a part. */ export function useRestoreInsertMenu(): void { - const { openInsertableId, openConfigurationKey, openFavoriteId } = + const { openInsertableId, openConfiguration, openFavoriteId } = useGetUiState(); const libraryQuery = useLibraryQuery(); const favoritesQuery = useFavoritesQuery(); @@ -54,7 +54,7 @@ export function useRestoreInsertMenu(): void { // or a link from a library this caller is not in. updateUiState({ openInsertableId: undefined, - openConfigurationKey: undefined, + openConfiguration: undefined, openFavoriteId: undefined }); return; @@ -66,19 +66,22 @@ export function useRestoreInsertMenu(): void { ? favoritesQuery.data?.favorites[openFavoriteId] : undefined; + // What the url names wins over the favorite's own: it is what was on + // screen, which an edit can have moved off the favorite's selection. openInsertMenu({ insertable, - initialSelection: openConfigurationKey - ? decodeConfiguration(openConfigurationKey) - : favorite?.defaultSelection, - configurationKey: - openConfigurationKey ?? favorite?.configurationKey, + ...(openConfiguration + ? { initialSelection: decodeConfiguration(openConfiguration) } + : { + initialSelection: favorite?.defaultSelection, + configurationKey: favorite?.configurationKey + }), favoriteId: favorite?.id, source: favorite ? InsertSource.FAVORITES : InsertSource.BROWSE }); }, [ openInsertableId, - openConfigurationKey, + openConfiguration, openFavoriteId, libraryQuery.isSuccess, libraryQuery.data, diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index cc69e0132..b6825cc47 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -1,11 +1,10 @@ -import { decodeConfiguration } from "@backend/features/configurations/utils"; import { PropsWithChildren, ReactNode } from "react"; import { Favorite } from "@backend/features/favorites/contract"; import { InsertableOut } from "@backend/features/library/contract"; import { type ConfigurationKey, DEFAULT_CONFIGURATION_KEY, - Selection + type PartialSelection } from "@backend/features/configurations/contract"; import { FavoriteButton, @@ -36,7 +35,9 @@ import { InsertSource } from "@backend/features/analytics/usage"; * own `SearchHit`, which a card has no other reason to know about. */ interface InsertableMatch extends RowMatch { - /** The key of the selection it names, for the thumbnail and the menu. */ + /** The values of the configuration it names, for the menu. */ + values?: PartialSelection; + /** Their key, for the thumbnail. */ configurationKey?: ConfigurationKey; } @@ -67,11 +68,8 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { return null; } - // What the hit names, for inserting and for prefilling the menu; its key - // is what names the thumbnail. - const hitSelection = match?.configurationKey - ? decodeConfiguration(match.configurationKey) - : undefined; + // What the hit names, for inserting and for prefilling the menu. + const hitSelection = match?.values; const openMenu = () => { props.onClick?.(); @@ -148,8 +146,8 @@ interface InsertableMenuItemsProps { insertable: InsertableOut; inInsertMenu?: boolean; /** What quick insert inserts and "Open document" opens: a search hit's - * selection on a card, the selected one inside the insert menu. */ - selection?: Selection; + * values on a card, the selected configuration inside the insert menu. */ + selection?: PartialSelection; /** That selection's key, so favoriting can name its thumbnail. */ configurationKey?: ConfigurationKey; source: InsertSource; @@ -191,7 +189,10 @@ export function InsertableMenuItems( - + configurationRecord({ partNumber, configurationKey, name }); +) => configurationRecord({ partNumber, values, name }); + +/** An index of elements with records but no parameters, which matching ignores. */ +const buildSearchDb = ( + libraryOut: LibraryOut, + records: Record = {} +) => + buildIndex( + libraryOut, + Object.fromEntries( + Object.entries(records).map(([id, list]) => [ + id, + { parameters: [], records: list } + ]) + ) + ); /** Hidden insertables shown: these tests are about matching, not visibility. */ const search = (searchDb: MiniSearch, query: string) => @@ -63,8 +78,8 @@ function library(name = "Bracket"): LibraryOut { describe("doSearch part-number matching", () => { const recordsMap: Record = { i1: [ - record("217-2600", "length=short"), - record("217-2601", "length=long") + record("217-2600", { length: "short" }), + record("217-2601", { length: "long" }) ] }; @@ -73,7 +88,7 @@ describe("doSearch part-number matching", () => { const { hits } = search(searchDb, "217-2601"); expect(hits).toHaveLength(1); expect(hits[0].id).toBe("i1"); - expect(hits[0].configurationKey).toBe("length=long"); + expect(hits[0].values).toEqual({ length: "long" }); }); // "Bracket 217" matches the part-number field on "217", but no single record @@ -92,7 +107,7 @@ describe("doSearch part-number matching", () => { const searchDb = buildSearchDb(library(), recordsMap); const { hits } = search(searchDb, "Bracket"); expect(hits).toHaveLength(1); - expect(hits[0].configurationKey).toBe("length=short"); + expect(hits[0].values).toEqual({ length: "short" }); expect(hits[0].partNumber).toBe("217-2600"); }); @@ -101,13 +116,13 @@ describe("doSearch part-number matching", () => { 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") + record("217-2600", { version: "latest" }), + record("217-2600", { version: "older" }) ] }); const { hits } = search(searchDb, "217-2600"); expect(hits).toHaveLength(1); - expect(hits[0].configurationKey).toBe("version=latest"); + expect(hits[0].values).toEqual({ version: "latest" }); }); }); @@ -117,28 +132,28 @@ describe("doSearch part-number matching", () => { describe("doSearch configuration matching", () => { const searchDb = buildSearchDb(library("MAXSpline Gear"), { i1: [ - record("WCP-1235", "teeth=24", "24T MAXSpline Gear"), - record("WCP-1236", "teeth=36", "36T MAXSpline Gear"), - record("WCP-1234", "", "12T MAXSpline Gear") + record("WCP-1235", { teeth: "24" }, "24T MAXSpline Gear"), + record("WCP-1236", { teeth: "36" }, "36T MAXSpline Gear"), + record("WCP-1234", {}, "12T MAXSpline Gear") ] }); it("picks the configuration a term of the query names", () => { const { hits } = search(searchDb, "maxspline 24t"); - expect(hits[0].configurationKey).toBe("teeth=24"); + expect(hits[0].values).toEqual({ teeth: "24" }); expect(hits[0].partNumber).toBe("WCP-1235"); }); it("picks it from the distinguishing term alone", () => { const { hits } = search(searchDb, "36t"); - expect(hits[0].configurationKey).toBe("teeth=36"); + expect(hits[0].values).toEqual({ teeth: "36" }); }); // Every record's name carries the whole query, so they tie — and the tie // has to go to the configuration the insert menu opens with. it("keeps the default when no term distinguishes a configuration", () => { const { hits } = search(searchDb, "maxspline gear"); - expect(hits[0].configurationKey).toBe(""); + expect(hits[0].values).toEqual({}); expect(hits[0].partNumber).toBe("WCP-1234"); }); @@ -146,13 +161,13 @@ describe("doSearch configuration matching", () => { // pick — the row falls back, and the default is what it must fall back to. it("falls back to the default on a title-only match", () => { const { hits } = search(searchDb, "maxspline"); - expect(hits[0].configurationKey).toBe(""); + expect(hits[0].values).toEqual({}); expect(hits[0].partNumber).toBe("WCP-1234"); }); it("lets a part number typed in full outrank a looser name match", () => { const { hits } = search(searchDb, "WCP-1236"); - expect(hits[0].configurationKey).toBe("teeth=36"); + expect(hits[0].values).toEqual({ teeth: "36" }); }); }); @@ -208,9 +223,9 @@ describe("doSearch inch sizes", () => { describe("doSearch size matching", () => { const searchDb = buildSearchDb(library("Hex Standoff"), { i1: [ - record("TTB-0016-025", "length=0.25 in", '0.25" Hex Standoff'), - record("TTB-0016-050", "length=0.5 in", '0.5" Hex Standoff'), - record("TTB-0016-100", "length=1 in", '1" Hex Standoff') + record("TTB-0016-025", { length: "0.25 in" }, '0.25" Hex Standoff'), + record("TTB-0016-050", { length: "0.5 in" }, '0.5" Hex Standoff'), + record("TTB-0016-100", { length: "1 in" }, '1" Hex Standoff') ] }); @@ -219,7 +234,7 @@ describe("doSearch size matching", () => { (query) => { const { hits } = search(searchDb, query); expect(hits[0].partName).toBe('1" Hex Standoff'); - expect(hits[0].configurationKey).toBe("length=1 in"); + expect(hits[0].values).toEqual({ length: "1 in" }); } ); @@ -239,7 +254,7 @@ describe("doSearch size matching", () => { // part is stored as both spellings and either one finds it. describe("doSearch measurements", () => { const searchDb = buildSearchDb(library("MotionX Hub"), { - i1: [record("WCP-1", "", ".196 ID x SplineXL OD")] + i1: [record("WCP-1", {}, ".196 ID x SplineXL OD")] }); it.each([".196", ".19", ".2", "0.19"])("finds the part by %s", (query) => { @@ -250,7 +265,7 @@ describe("doSearch measurements", () => { // Results arrive as the caller types, and the first keystroke is one letter. describe("doSearch single letters", () => { const searchDb = buildSearchDb(library("Hex Standoff"), { - i1: [record("TTB-0016", "", "Standoff")] + i1: [record("TTB-0016", {}, "Standoff")] }); it.each(["h", "s", "t"])("answers a typed %s", (query) => { @@ -263,8 +278,8 @@ describe("doSearch single letters", () => { describe("doSearch part numbers", () => { const searchDb = buildSearchDb(library("Hex Standoff"), { i1: [ - record("WCP-1025", "", "Standoff"), - record("TTB-0016-5/32", "size=small", "Small Standoff") + record("WCP-1025", {}, "Standoff"), + record("TTB-0016-5/32", { size: "small" }, "Small Standoff") ] }); @@ -295,7 +310,7 @@ describe("doSearch part numbers", () => { // than every part an admin left it on. it("returns nothing for the placeholder", () => { const withPlaceholders = buildSearchDb(library("Spacer"), { - i1: [record("N/A", "", "Spacer")] + i1: [record("N/A", {}, "Spacer")] }); expect(search(withPlaceholders, "n/a").hits).toEqual([]); }); @@ -348,7 +363,7 @@ describe("doSearch highlighting", () => { // 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")] + i1: [record("217-2600", { length: "short" }, "Long Bearing")] }; function hitFor(query: string) { @@ -372,7 +387,7 @@ describe("doSearch highlighting", () => { it("underlines a leading-zero segment of the part number", () => { const { hits } = search( buildSearchDb(library(), { - i1: [record("TTB-0016-5/32", "size=small")] + i1: [record("TTB-0016-5/32", { size: "small" })] }), "TTB-0016" ); @@ -407,8 +422,8 @@ describe("doSearch highlighting", () => { describe("doSearch name matching", () => { const recordsMap: Record = { i1: [ - record("217-2600", "length=short", "1/2 Bearing"), - record("217-2601", "length=long", "3/4 Bearing") + record("217-2600", { length: "short" }, "1/2 Bearing"), + record("217-2601", { length: "long" }, "3/4 Bearing") ] }; @@ -418,7 +433,7 @@ describe("doSearch name matching", () => { expect(hits).toHaveLength(1); expect(hits[0].partName).toBe("3/4 Bearing"); expect(hits[0].partNumber).toBe("217-2601"); - expect(hits[0].configurationKey).toBe("length=long"); + expect(hits[0].values).toEqual({ length: "long" }); }); it("finds a fractional name by its decimal forms (.5, 0.5, 1/2)", () => { @@ -437,8 +452,8 @@ describe("doSearch name matching", () => { describe("doSearch without configuration matching", () => { const searchDb = buildSearchDb(library("MAXSpline Gear"), { i1: [ - record("WCP-1235", "teeth=24", "24T MAXSpline Gear"), - record("WCP-1234", "", "12T MAXSpline Gear") + record("WCP-1235", { teeth: "24" }, "24T MAXSpline Gear"), + record("WCP-1234", {}, "12T MAXSpline Gear") ] }); @@ -470,7 +485,7 @@ describe("doSearch without configuration matching", () => { // that agrees with what inserting it would produce. it("still shows the default record on a name match", () => { const { hits } = titleOnly("maxspline"); - expect(hits[0].configurationKey).toBe(""); + expect(hits[0].values).toEqual({}); expect(hits[0].partNumber).toBe("WCP-1234"); }); @@ -481,6 +496,6 @@ describe("doSearch without configuration matching", () => { showHidden: true }); expect(hits).toHaveLength(1); - expect(hits[0].configurationKey).toBe("teeth=24"); + expect(hits[0].values).toEqual({ teeth: "24" }); }); }); diff --git a/src/frontend/features/search/search.ts b/src/frontend/features/search/search.ts index c10b9752f..a5bf3d6ad 100644 --- a/src/frontend/features/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -6,7 +6,10 @@ import { SearchDocument } from "@backend/features/search/contract"; import { matchedRecord } from "@backend/features/search/records"; -import { type ConfigurationKey } from "@backend/features/configurations/contract"; +import { + type ConfigurationKey, + type PartialSelection +} from "@backend/features/configurations/contract"; /** As many results as a list is worth scrolling. */ const MAX_HITS = 50; @@ -22,8 +25,10 @@ export interface SearchHit { positions: Position[]; /** * The best-matching record for this hit, used to pre-fill the insert menu — - * its part number, name, and the key of the selection producing it. + * its part number, name, and the values producing it. */ + values?: PartialSelection; + /** Those values' key, for the row's thumbnail. */ configurationKey?: ConfigurationKey; partNumber?: string; partName?: string; @@ -156,6 +161,7 @@ export function doSearch(args: SearchArgs): SearchResult { document.name, "name" ), + values: record?.values, configurationKey: record?.configurationKey, partNumber, partName, diff --git a/src/frontend/lib/api-client.ts b/src/frontend/lib/api-client.ts index 5a5d2189e..262714beb 100644 --- a/src/frontend/lib/api-client.ts +++ b/src/frontend/lib/api-client.ts @@ -7,6 +7,13 @@ import { import { fromApiErrorBody, ImageLoadError } from "./errors"; import { HttpStatus } from "http-status-ts"; +/** + * Bumped when the shape of an immutably cached response changes. Those are + * cached for a year against their `v`, so without this a browser would keep + * reading the old shape until the content itself next changed. + */ +const RESPONSE_SHAPE = 2; + function getUrl( path: string, query?: URLSearchParamsInit, @@ -14,7 +21,7 @@ function getUrl( ): string { const searchParams = createSearchParams(query); if (cacheId !== undefined) { - searchParams.append("v", cacheId.toString()); + searchParams.append("v", `${cacheId}.${RESPONSE_SHAPE}`); } return "/api" + path + `?${searchParams}`; } diff --git a/src/frontend/lib/app-params.ts b/src/frontend/lib/app-params.ts index df2560dca..94946c224 100644 --- a/src/frontend/lib/app-params.ts +++ b/src/frontend/lib/app-params.ts @@ -17,7 +17,7 @@ export const AppParamsType = z.object({ q: z.string().optional().catch(undefined), /** The insertable whose insert menu is open. */ part: z.string().optional().catch(undefined), - /** Its configuration; absent for the element's own defaults. */ + /** What its configuration overrides; absent for the element's defaults. */ config: z.string().optional().catch(undefined), /** The favorite the menu was opened from, when it was opened from one. */ favorite: z.string().optional().catch(undefined) @@ -47,7 +47,7 @@ export function adoptAppParams(params: AppParams): void { // with it — including when they are absent, which is a plain part. ...(params.part !== undefined && { openInsertableId: params.part, - openConfigurationKey: params.config, + openConfiguration: params.config, openFavoriteId: params.favorite }) }); @@ -56,12 +56,8 @@ export function adoptAppParams(params: AppParams): void { /** Writes the stored state back to the url, whenever it changes. */ export function useAppParamMirror(): void { const navigate = useNavigate(); - const { - searchQuery, - openInsertableId, - openConfigurationKey, - openFavoriteId - } = useGetUiState(); + const { searchQuery, openInsertableId, openConfiguration, openFavoriteId } = + useGetUiState(); useEffect(() => { void navigate({ @@ -75,7 +71,7 @@ export function useAppParamMirror(): void { // noise in a url somebody is about to copy. q: searchQuery || undefined, part: openInsertableId, - config: openConfigurationKey || undefined, + config: openConfiguration || undefined, favorite: openFavoriteId }) }); @@ -83,7 +79,7 @@ export function useAppParamMirror(): void { navigate, searchQuery, openInsertableId, - openConfigurationKey, + openConfiguration, openFavoriteId ]); } diff --git a/src/frontend/lib/ui-state.ts b/src/frontend/lib/ui-state.ts index f5c89271a..7bd26111e 100644 --- a/src/frontend/lib/ui-state.ts +++ b/src/frontend/lib/ui-state.ts @@ -46,8 +46,9 @@ const LocalStateSchema = z.object({ /** The insertable whose insert menu is open, and what it is configured to. * Written as the menu opens and closes, so a relaunch can reopen it. */ openInsertableId: z.string().optional(), - /** Absent for the element's own defaults, which is the empty key. */ - openConfigurationKey: z.string().optional(), + /** What its configuration changes from the element's defaults, encoded + * as `id=value;id=value`; absent for the defaults themselves. */ + openConfiguration: z.string().optional(), /** Set when the menu was opened from a favorite rather than a row. */ openFavoriteId: z.string().optional() }); diff --git a/src/frontend/lib/url.test.ts b/src/frontend/lib/url.test.ts index 6e8feb616..6b7f50ea7 100644 --- a/src/frontend/lib/url.test.ts +++ b/src/frontend/lib/url.test.ts @@ -21,9 +21,9 @@ describe("makeUrl", () => { // Once, by the url: a quantity that reaches Onshape as `%2520m` is the // value `0.381%20m`, which is no quantity. it("escapes a configuration once", () => { - const url = makeUrl({ - ...element, - selection: { Effective_Length: "0.381 m", List_7A7: "Hex" } + const url = makeUrl(element, { + Effective_Length: "0.381 m", + List_7A7: "Hex" }); expect(url).toBe( diff --git a/src/frontend/lib/url.tsx b/src/frontend/lib/url.tsx index 8a9f82e85..e0c563713 100644 --- a/src/frontend/lib/url.tsx +++ b/src/frontend/lib/url.tsx @@ -3,10 +3,9 @@ import { InstancePath, ElementPath, isInstancePath, - isElementPath, - ConfigurablePath, - isConfigurablePath + isElementPath } from "@backend/lib/onshape/path"; +import { type PartialSelection } from "@backend/features/configurations/contract"; import { encodeQueryConfiguration } from "@backend/features/configurations/utils"; import { notifications } from "@mantine/notifications"; import { LinkIcon } from "@phosphor-icons/react"; @@ -22,11 +21,14 @@ export const APP_STORE_URL = */ export const SETUP_URL = "/setup"; -export function makeUrl(path: ConfigurablePath): string; -export function makeUrl(path: ElementPath): string; -export function makeUrl(path: InstancePath): string; -export function makeUrl(path: DocumentPath): string; -export function makeUrl(path: DocumentPath): string { +/** + * The Onshape url for a path. A configuration applies only to an element, and + * only what it names changes: Onshape fills in the rest from the defaults. + */ +export function makeUrl( + path: DocumentPath | InstancePath | ElementPath, + configuration?: PartialSelection +): string { let url = `https://cad.onshape.com/documents/${path.documentId}`; if (isInstancePath(path)) { url += `/${path.instanceType}/${path.instanceId}`; @@ -34,12 +36,11 @@ export function makeUrl(path: DocumentPath): string { if (isElementPath(path)) { url += `/e/${path.elementId}`; } - if (isConfigurablePath(path)) { + const encoded = encodeQueryConfiguration(configuration); + if (isElementPath(path) && encoded) { // Onshape's own parameter, so it keeps Onshape's name. The query form, // this escape being the one layer Onshape unwraps. - url += - "?configuration=" + - encodeURIComponent(encodeQueryConfiguration(path.selection)); + url += "?configuration=" + encodeURIComponent(encoded); } return url; } From bd56576531b6b9ca7c6d04805748b77ae3dfa152 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:59:06 +0000 Subject: [PATCH 03/88] Render configuration thumbnails in a workflow again The per-user Durable Object queue, with its preemption, poll schedule and invalid-key memo, is gone. On a miss the route resolves Onshape's thumbnail id once and starts a RenderThumbnailWorkflow named by a hash of what it renders, handing it that id and both R2 keys; later polls find the same instance and spend nothing. The workflow only asks for the bytes until they land and stores them. A configuration Onshape cannot resolve answers 422 at once, before any workflow starts. wrangler.jsonc deletes the ThumbnailRenderer class in a v2 migration, which discards its stored queue. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 33 +- src/backend/features/load/steps.ts | 2 +- src/backend/features/thumbnails/contract.ts | 20 +- .../features/thumbnails/render-workflow.ts | 97 +++ .../thumbnails/render-workflow.worker.test.ts | 60 ++ src/backend/features/thumbnails/render.ts | 171 +++++ src/backend/features/thumbnails/renderer.ts | 684 ------------------ .../thumbnails/renderer.worker.test.ts | 487 ------------- src/backend/features/thumbnails/routes.ts | 51 +- .../features/thumbnails/routes.worker.test.ts | 211 +++--- src/backend/features/thumbnails/store.ts | 7 +- src/backend/index.ts | 2 +- src/backend/lib/context.ts | 6 +- .../library/components/insertable-card.tsx | 4 +- .../thumbnails/components/thumbnail.tsx | 27 +- worker-configuration.d.ts | 9 +- wrangler.jsonc | 52 +- 17 files changed, 489 insertions(+), 1434 deletions(-) create mode 100644 src/backend/features/thumbnails/render-workflow.ts create mode 100644 src/backend/features/thumbnails/render-workflow.worker.test.ts create mode 100644 src/backend/features/thumbnails/render.ts delete mode 100644 src/backend/features/thumbnails/renderer.ts delete mode 100644 src/backend/features/thumbnails/renderer.worker.test.ts diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 05e998d85..fc55b65d6 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -43,10 +43,10 @@ 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. One bucket holds everything the app stores as a blob, kept apart by key prefix: -| Prefix | What it holds | Lifetime | -| --------------- | ------------------------------------------------- | -------------------------------- | -| `thumbnails/` | Rendered thumbnails, by element and configuration | Kept until reconciled; see below | -| `search-index/` | Each library's serialized MiniSearch index | Rewritten on every index rebuild | +| Prefix | What it holds | Lifetime | +| --------------- | ------------------------------------------------------------------------- | ------------------------------------------------- | +| `thumbnails/` | Rendered thumbnails, by element and configuration | Kept until reconciled; see below | +| `search-index/` | Each library's serialized MiniSearch index, under a version for its shape | Rewritten on every index rebuild; built on a miss | Onshape can generate preview thumbnails for parts and assemblies, but fetching them from Onshape on every page load would be slow and eat into API rate limits — a single render can require polling and take minutes. Instead, every thumbnail we ever fetch from Onshape lands in R2 and is served from there afterwards. @@ -68,24 +68,25 @@ Nothing expires on a timer: there is no R2 lifecycle rule, and renders are meant - An empty live set deletes nothing: a library really can have no elements, but so can a read that failed. - A run scans at most 50 pages of 1,000. A bucket larger than that is finished by the next reload. -Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&configurationKey=&renderThumbnail=&insertableId=`: +Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&configurationKey=&renderSource=&insertableId=`: - **Hit** — streamed from R2 as immutable, cacheable for a year. -- **Miss** — the element's default is served instead as `no-store`, with an `X-Thumbnail-Fallback` header so the client knows to keep checking. A cacheable fallback would pin the wrong image after the real one landed. -- **Miss with `renderThumbnail=true`** — the miss also starts a `ThumbnailWorkflow` to render the configuration, resolving the element from `insertableId`. Surfaces where the user picked the configuration (the insert menu, favorites) ask for this; search rows do not, so one cold search cannot start a render per row. -- **Neither exists** — 404, and the client renders a placeholder. - -All rendering happens inside the workflow, which keeps Onshape's thumbnail id server-side. There is no HTTP path that proxies an Onshape thumbnail directly. +- **Miss** — 404, uncached, so a render landing later is not shadowed. A configuration miss is never answered with the element's default: that would show a part the caller did not ask for. The client shows the default itself while it waits. +- **Miss with `renderSource` and `insertableId`** — the route also starts a `RenderThumbnailWorkflow` for the configuration (`features/thumbnails/render.ts`). The insert menu and favorite rows ask for this; search rows do not, so one cold search cannot start a render per row. + - The instance id is a hash of the element, microversion and key, so every poll for one render finds the same instance and starts nothing new. + - Only when there is no instance does the route spend an Onshape call, resolving the thumbnail id once. Onshape having no insertable for the configuration answers 422 at once, which the client shows as a configuration that failed to regenerate. + - The workflow is handed that id and both R2 keys, the size the asking surface shows first leading, and asks Onshape for the bytes until they land (404 means still rendering), for about a minute. + - A finished instance found on a miss left no bytes behind, so it is restarted. ### Workflows — Background Jobs -Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. The two load workflows live in `src/backend/features/load/workflows.ts`; the thumbnail one lives with the feature it serves, in `src/backend/features/thumbnails/workflow.ts`: +Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. The two load workflows live in `src/backend/features/load/workflows.ts`; the thumbnail one lives with the feature it serves, in `src/backend/features/thumbnails/render-workflow.ts`: -| Binding | Class | What it does | -| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | -| `LOAD_LIBRARY_WORKFLOW` | `LoadLibraryWorkflow` | Reloads every group whose document has a new version, then rebuilds the search index | -| `ADD_GROUP_WORKFLOW` | `AddGroupWorkflow` | Adds an Onshape document to a library and loads it | -| `THUMBNAIL_WORKFLOW` | `ThumbnailWorkflow` | Renders one configuration's thumbnails and stores them in R2 | +| Binding | Class | What it does | +| --------------------------- | ------------------------- | ------------------------------------------------------------------------------------ | +| `LOAD_LIBRARY_WORKFLOW` | `LoadLibraryWorkflow` | Reloads every group whose document has a new version, then rebuilds the search index | +| `ADD_GROUP_WORKFLOW` | `AddGroupWorkflow` | Adds an Onshape document to a library and loads it | +| `RENDER_THUMBNAIL_WORKFLOW` | `RenderThumbnailWorkflow` | Waits out one configuration's render and stores both sizes in R2 | Loading a group means walking the document structure, downloading metadata for every part and assembly, probing each indexed configuration, generating thumbnails, and writing it all to D1 — far too long for a single HTTP request. The request kicks the workflow off and returns immediately. diff --git a/src/backend/features/load/steps.ts b/src/backend/features/load/steps.ts index ff4e5a425..0d3094a5e 100644 --- a/src/backend/features/load/steps.ts +++ b/src/backend/features/load/steps.ts @@ -34,7 +34,7 @@ const RATE_LIMIT_JITTER_SECONDS = 20; * longer an `OnshapeRateLimitError`, so an `instanceof` here answered false for * every real 429 and quietly handed back the curve below instead. */ -function rateLimitDelay(error: Error): `${number} seconds` | null { +export function rateLimitDelay(error: Error): `${number} seconds` | null { const retryAfterSeconds = readRetryAfterSeconds(error); if (retryAfterSeconds === null) { return null; diff --git a/src/backend/features/thumbnails/contract.ts b/src/backend/features/thumbnails/contract.ts index 62ca58ecb..0b329aaac 100644 --- a/src/backend/features/thumbnails/contract.ts +++ b/src/backend/features/thumbnails/contract.ts @@ -13,26 +13,18 @@ export interface ThumbnailUrls { large: string; } -/** - * Which surface asked for a render, which is what orders it in the queue every - * request has to join. See `ThumbnailRenderer` for why there is a queue. - */ +/** Which surface asked for a render, which decides the size stored first. */ export enum RenderSource { - /** The only source that may take the render thread off a job holding it. */ INSERT_MENU = "insert", - /** A row in a list — favorites, search results. Waits its turn. */ - ROW = "row", - /** A library load, which nobody is waiting on. */ - LOAD = "load" + /** A row in a list — favorites, search results. */ + ROW = "row" } /** - * The size each surface shows first. Both are always queued, so a row and the - * hover card it opens cannot disagree, but they are separate renders and only - * one runs at a time — so which goes first is worth getting right. + * The size each surface shows first. Both are always stored, so a row and the + * hover card it opens cannot disagree, but the one on screen goes first. */ export const PREFERRED_SIZE: Record = { [RenderSource.INSERT_MENU]: ThumbnailSize.LARGE, - [RenderSource.ROW]: ThumbnailSize.SMALL, - [RenderSource.LOAD]: ThumbnailSize.SMALL + [RenderSource.ROW]: ThumbnailSize.SMALL }; diff --git a/src/backend/features/thumbnails/render-workflow.ts b/src/backend/features/thumbnails/render-workflow.ts new file mode 100644 index 000000000..e28a07af1 --- /dev/null +++ b/src/backend/features/thumbnails/render-workflow.ts @@ -0,0 +1,97 @@ +/** + * Renders a configuration's thumbnails. Onshape renders one when its bytes are + * first asked for, answering 404 until they are ready, so the workflow asks + * until they land and stores them; the route that started it serves them. + * + * An element's own thumbnail never comes through here: Onshape renders those + * when a document is saved, and a load fetches them directly. + */ +import { + WorkflowEntrypoint, + type WorkflowEvent, + type WorkflowStep +} from "cloudflare:workers"; +import type { AppBindings } from "../../lib/context"; +import { getThumbnailFromId } from "../../lib/onshape/endpoints/thumbnails"; +import { getOnshapeApiFromSessionId } from "../auth/request-auth"; +import { rateLimitDelay } from "../load/steps"; +import { type ConfigurationKey } from "../configurations/contract"; +import { ThumbnailSize } from "./contract"; +import { putThumbnail } from "./store"; + +/** One stored size: where it goes, and what to ask Onshape for. */ +export interface RenderTarget { + size: ThumbnailSize; + /** The R2 key, which is also what the route serves the size from. */ + key: string; +} + +export interface RenderThumbnailParams { + /** Resolved once by the route; fixed for an element and configuration. */ + thumbnailId: string; + /** Both sizes, the one the asking surface shows first leading. */ + targets: RenderTarget[]; + /** Tagged onto each stored object, for telling later what it depicts. */ + microversionId: string; + configurationKey: ConfigurationKey; + /** Whose Onshape tokens the render is asked for under. */ + sessionId: string; +} + +/** + * How long a render is waited on: a minute, at a steady five seconds a try — + * renders normally land well inside that, and one that does not is abandoned + * rather than left spending the account's allocation. A rate limit waits out + * whatever Onshape says instead. + */ +const RENDER_RETRIES = { + limit: 12, + delay: (input: { error: Error }) => + rateLimitDelay(input.error) ?? ("5 seconds" as const), + backoff: "constant" as const +}; + +export class RenderThumbnailWorkflow extends WorkflowEntrypoint< + AppBindings, + RenderThumbnailParams +> { + async run( + event: WorkflowEvent, + step: WorkflowStep + ): Promise { + const { targets } = event.payload; + // One at a time: the second size is the same render, so it lands as + // soon as the first has. + for (const target of targets) { + await step.do( + `store-${target.size}`, + { retries: RENDER_RETRIES }, + () => storeRender(this.env, event.payload, target) + ); + } + } +} + +/** One try at a size; throws while Onshape is still rendering it. */ +async function storeRender( + env: AppBindings, + params: RenderThumbnailParams, + target: RenderTarget +): Promise { + if (await env.BLOB.head(target.key)) { + return; + } + const onshapeApi = await getOnshapeApiFromSessionId( + env.KV, + params.sessionId + ); + const thumbnail = await getThumbnailFromId( + onshapeApi, + params.thumbnailId, + target.size + ); + await putThumbnail(env.BLOB, target.key, thumbnail, { + microversionId: params.microversionId, + configurationKey: params.configurationKey + }); +} diff --git a/src/backend/features/thumbnails/render-workflow.worker.test.ts b/src/backend/features/thumbnails/render-workflow.worker.test.ts new file mode 100644 index 000000000..437be308b --- /dev/null +++ b/src/backend/features/thumbnails/render-workflow.worker.test.ts @@ -0,0 +1,60 @@ +import { env } from "cloudflare:workers"; +import { introspectWorkflowInstance } from "cloudflare:test"; +import { afterEach, expect, it, vi } from "vitest"; +import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; +import * as RequestAuth from "../auth/request-auth"; +import { OnshapeApiError, type OAuthApi } from "../../lib/onshape/client"; +import { ThumbnailSize } from "./contract"; +import { thumbnailKey } from "./keys"; + +afterEach(() => vi.restoreAllMocks()); + +const key = (size: ThumbnailSize) => thumbnailKey("e1", "mv1", size, "a=1"); + +// Onshape answers 404 until the render lands, so that is waited out rather +// than treated as a failure. +it("stores both sizes once Onshape has rendered them", async () => { + vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockResolvedValue( + {} as OAuthApi + ); + const fetch = vi + .spyOn(ThumbnailEndpoints, "getThumbnailFromId") + .mockRejectedValueOnce(new OnshapeApiError("rendering", 404)) + .mockResolvedValue(new TextEncoder().encode("gif").buffer); + + await using instance = await introspectWorkflowInstance( + env.RENDER_THUMBNAIL_WORKFLOW, + "render-test" + ); + await instance.modify(async (m) => { + await m.disableRetryDelays(); + }); + await env.RENDER_THUMBNAIL_WORKFLOW.create({ + id: "render-test", + params: { + thumbnailId: "thumbnail-id", + targets: [ + { size: ThumbnailSize.LARGE, key: key(ThumbnailSize.LARGE) }, + { size: ThumbnailSize.SMALL, key: key(ThumbnailSize.SMALL) } + ], + microversionId: "mv1", + configurationKey: "a=1", + sessionId: "session" + } + }); + await instance.waitForStatus("complete"); + + // The id it was handed, never one it resolved again itself. + expect(fetch).toHaveBeenCalledWith( + expect.anything(), + "thumbnail-id", + ThumbnailSize.LARGE + ); + for (const size of Object.values(ThumbnailSize)) { + const stored = await env.BLOB.get(key(size)); + expect(stored?.customMetadata).toEqual({ + microversionId: "mv1", + configurationKey: "a=1" + }); + } +}); diff --git a/src/backend/features/thumbnails/render.ts b/src/backend/features/thumbnails/render.ts new file mode 100644 index 000000000..546b837b7 --- /dev/null +++ b/src/backend/features/thumbnails/render.ts @@ -0,0 +1,171 @@ +/** + * Starts a configuration's render, once. The route calls this on every miss — + * a client polls it until the bytes land — so asking again while a render is + * under way has to cost nothing. + */ +import { eq } from "drizzle-orm"; +import type { AppContext } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { insertables } from "../../db/schema"; +import { toElementPath } from "../../lib/onshape/path"; +import { + getThumbnailId, + NoSuchConfigurationError +} from "../../lib/onshape/endpoints/thumbnails"; +import { getSessionId } from "../auth/session"; +import { type ConfigurationKey } from "../configurations/contract"; +import { decodeConfiguration } from "../configurations/utils"; +import { PREFERRED_SIZE, RenderSource, ThumbnailSize } from "./contract"; +import { thumbnailKey } from "./keys"; +import type { RenderTarget } from "./render-workflow"; + +export interface RenderRequest { + /** What the element path is resolved from, since the caller has only this. */ + insertableId: string; + elementId: string; + microversionId: string; + configurationKey: ConfigurationKey; +} + +/** + * What asking did: a render is coming, or it cannot — Onshape has no + * insertable for the configuration, so the caller can say so at once. + */ +export type RenderOutcome = "rendering" | "no-such-configuration"; + +/** Statuses of an instance still working towards its bytes. */ +const ACTIVE = new Set([ + "queued", + "running", + "waiting", + "paused", + "waitingForPause" +]); + +export async function requestRender( + c: AppContext, + request: RenderRequest, + source: RenderSource +): Promise { + // First, so a caller with no session to render under spends nothing. + const sessionId = getSessionId(c); + const workflow = c.env.RENDER_THUMBNAIL_WORKFLOW; + const id = await instanceId(request); + + const existing = await findInstance(workflow, id); + if (existing) { + const { status } = await existing.status(); + // Only a miss asks, so a finished instance left no bytes behind: its + // render never came, or what it stored has since been deleted. + if (!ACTIVE.has(status)) { + await existing.restart(); + } + return "rendering"; + } + + // Resolved here rather than in the workflow: it is one quick call, and a + // configuration Onshape cannot resolve is worth saying so about now. + let thumbnailId: string; + try { + thumbnailId = await getThumbnailId( + await c.var.getOnshapeApi(), + await elementPathOf(c, request.insertableId), + decodeConfiguration(request.configurationKey) + ); + } catch (error) { + if (error instanceof NoSuchConfigurationError) { + return "no-such-configuration"; + } + throw error; + } + + try { + await workflow.create({ + id, + params: { + thumbnailId, + targets: renderTargets(request, source), + microversionId: request.microversionId, + configurationKey: request.configurationKey, + sessionId + } + }); + } catch (error) { + // Two polls racing to start the same render; the other one won. + if (!(await findInstance(workflow, id))) { + throw error; + } + } + return "rendering"; +} + +/** Both sizes, the one the asking surface shows first leading. */ +function renderTargets( + request: RenderRequest, + source: RenderSource +): RenderTarget[] { + const preferred = PREFERRED_SIZE[source]; + return Object.values(ThumbnailSize) + .sort((a, b) => Number(b === preferred) - Number(a === preferred)) + .map((size) => ({ + size, + key: thumbnailKey( + request.elementId, + request.microversionId, + size, + request.configurationKey + ) + })); +} + +/** + * Named by what it renders, so every poll for one render finds the same + * instance. Hashed: a configuration can run past the 100 characters an id + * allows, and spells characters an id may not contain. + */ +async function instanceId(request: RenderRequest): Promise { + const subject = [ + request.elementId, + request.microversionId, + request.configurationKey + ].join("\n"); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(subject) + ); + return ( + "render-" + + [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + ); +} + +/** Undefined for an id no instance holds, which `get` answers by throwing. */ +async function findInstance( + workflow: Workflow, + id: string +): Promise { + try { + return await workflow.get(id); + } catch { + return undefined; + } +} + +/** Read rather than passed in: a request can carry a version it has moved past. */ +async function elementPathOf(c: AppContext, insertableId: string) { + const row = await getDb(c.env.DB) + .select({ + documentId: insertables.documentId, + versionId: insertables.versionId, + elementId: insertables.elementId + }) + .from(insertables) + .where(eq(insertables.id, insertableId)) + .get(); + if (!row) { + throw new NoSuchConfigurationError(`No insertable ${insertableId}`); + } + return toElementPath(row); +} diff --git a/src/backend/features/thumbnails/renderer.ts b/src/backend/features/thumbnails/renderer.ts deleted file mode 100644 index 98f307366..000000000 --- a/src/backend/features/thumbnails/renderer.ts +++ /dev/null @@ -1,684 +0,0 @@ -/** - * The one place that asks Onshape to render a configuration. - * - * Onshape runs one such render per user at a time. Asking for one it has not - * got answers 404 and starts rendering it; asking for a *different* one - * abandons that render and starts the new one. So a caller that interleaves two - * finishes neither, however long it polls. Onshape does not document this — it - * is read off renders that either land in under a minute or never. - * - * Hence one object per user, and a job that has started rendering holds the - * render thread: every pass polls that one key until it lands. Only the insert - * menu may take the thread off a job, since that is the one surface where - * somebody is watching a particular render happen. - * - * An element's own thumbnail does not come through here at all. Onshape renders - * those when a document is saved, so reading one starts nothing and cannot - * displace a render in flight — a load fetches them itself, in parallel. - */ -import { DurableObject } from "cloudflare:workers"; -import { HttpStatus } from "http-status-ts"; -import { eq } from "drizzle-orm"; -import type { AppBindings } from "../../lib/context"; -import { getDb } from "../../db/client"; -import { insertables } from "../../db/schema"; -import { - OnshapeApiError, - OnshapeRateLimitError, - type OnshapeApi -} from "../../lib/onshape/client"; -import { getOnshapeApiFromSessionId } from "../auth/request-auth"; -import type { ElementPath } from "../../lib/onshape/path"; -import { - getThumbnailFromId, - getThumbnailId, - NoSuchConfigurationError -} from "../../lib/onshape/endpoints/thumbnails"; -import { type ConfigurationKey } from "../configurations/contract"; -import { decodeConfiguration } from "../configurations/utils"; -import { PREFERRED_SIZE, RenderSource, ThumbnailSize } from "./contract"; -import { thumbnailKey } from "./keys"; -import { putThumbnail } from "./store"; - -/** A configuration's render, the kind Onshape only does one of at a time. */ -export interface ThumbnailRequest { - /** What the element path is resolved from, since the caller has only this. */ - insertableId: string; - elementId: string; - microversionId: string; - configurationKey: ConfigurationKey; -} - -/** Both stored sizes, which a request always queues and drops together. */ -const SIZES = [ThumbnailSize.SMALL, ThumbnailSize.LARGE] as const; - -/** Where each source sits in the queue; lower goes first. */ -const SOURCE_RANK: Record = { - [RenderSource.INSERT_MENU]: 0, - [RenderSource.ROW]: 10, - [RenderSource.LOAD]: 20 -}; - -/** - * How often the render holding the thread is asked about, by how long it has - * held it. Polling the same render does not disturb it, so the first minute — - * where they normally land — is worth about ten calls. Past that they are - * outliers, and the backoff stops spending the account's allocation on them. - */ -const POLL_SCHEDULE = [ - { withinMs: 60_000, everyMs: 6_000 }, - { withinMs: 3 * 60_000, everyMs: 20_000 }, - { withinMs: Infinity, everyMs: 60_000 } -] as const; - -/** - * The most thread time one render can ever cost. Well past the two minutes an - * outlier takes, and hard: a render that reaches it is abandoned rather than - * retried, so one bad one cannot hold up the queue behind it for longer. - */ -const RENDER_TIMEOUT_MS = 5 * 60_000; - -/** - * Failures before a job is dropped. Being preempted is not one — that is the - * queue's doing, not the job's. - */ -const MAX_FAILURES = 3; - -/** How long a failed job waits before it is worth the thread again. */ -const RETRY_DELAY_MS = 30_000; - -/** How long a job stays queued; past it a later request asks again. */ -const JOB_TTL_MS = 60 * 60_000; - -/** - * How long a configuration Onshape had no insertable for stays remembered. - * The answer only changes when the document does, and a reload moves the - * microversion, which keys a different render — so this is really only a floor - * on how often a mistyped configuration is worth asking about again. - */ -const INVALID_TTL_MS = 60 * 60_000; - -/** Bounds one alarm, not the queue. */ -const ALARM_BUDGET_MS = 20_000; - -/** - * Bumped to throw away every queue. A job carries the shape the code that - * queued it expected, so a deploy that changes that shape leaves work behind - * which can only fail quietly. Raising this drops it the next time each object - * is touched. - */ -const QUEUE_GENERATION = 3; - -/** A row of the queue, as stored; the index signature is what `sql.exec` reads it as. */ -interface JobRow extends Record { - key: string; - request: string; - source: string; - /** Sort order: the source's rank, plus one when this is its second size. */ - queueRank: number; - /** When this job may next be touched. */ - dueAt: number; - expiresAt: number; - failures: number; - /** Resolved once per job and kept, since it cannot change. */ - thumbnailId: string | null; - /** When this job took the render thread; null while it is only queued. */ - startedAt: number | null; -} - -/** What one call to Onshape came back with, which is all the loop needs. */ -type Attempt = - | { outcome: "stored" } - /** Onshape is rendering it; this job holds the thread until it lands. */ - | { outcome: "rendering" } - | { outcome: "failed"; error: unknown } - /** Everything must wait; Onshape said how long. */ - | { outcome: "rate-limited"; retryAfterSeconds: number }; - -/** - * What {@link ThumbnailRenderer.enqueue} did with a request. A configuration - * Onshape has no insertable for is not queued at all — the caller is told, so - * it can say the configuration is what is wrong rather than waiting out a - * render that was never going to happen. - */ -export type EnqueueOutcome = "queued" | "no-such-configuration"; - -/** One entry of {@link ThumbnailRenderer.queued}. */ -export interface QueuedJob { - key: string; - source: string; - /** Whether this is the job currently holding the render thread. */ - rendering: boolean; -} - -/** - * `#private` rather than the `private` used elsewhere in the codebase, and not - * as a matter of taste: a Durable Object's prototype methods are its RPC - * surface, and TypeScript's `private` is erased, so a `private drain()` is - * callable by anything holding a stub (checked against the runtime, not just - * the docs). Only `enqueue` and `queued` are meant to be. - */ -export class ThumbnailRenderer extends DurableObject { - /** - * Keeps two alarms from overlapping, which would put two Onshape calls in - * the air. Whether the runtime already rules that out is not something I - * could find documented — the input gate is open across non-storage I/O, - * so an `enqueue` landing mid-drain can set an alarm for now. - */ - #running = false; - - constructor(ctx: DurableObjectState, env: AppBindings) { - super(ctx, env); - // Not under `blockConcurrencyWhile`: SQLite storage is synchronous, so - // no request can arrive on a half-built schema. - ctx.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS jobs ( - key TEXT PRIMARY KEY, - request TEXT NOT NULL, - source TEXT NOT NULL, - queueRank INTEGER NOT NULL, - dueAt INTEGER NOT NULL, - expiresAt INTEGER NOT NULL, - failures INTEGER NOT NULL DEFAULT 0, - thumbnailId TEXT, - startedAt INTEGER - ); - CREATE INDEX IF NOT EXISTS jobs_queue ON jobs (queueRank, dueAt); - CREATE TABLE IF NOT EXISTS invalid ( - key TEXT PRIMARY KEY, - expiresAt INTEGER NOT NULL - ); - `); - } - - /** - * Throws away a queue an older deploy left behind. Checked on the way in - * rather than in the constructor: an object already warm would otherwise - * keep working its stale queue until something happened to evict it. - */ - #purgeStaleQueue(): void { - if ( - this.ctx.storage.kv.get("generation") === QUEUE_GENERATION - ) { - return; - } - this.ctx.storage.sql.exec("DELETE FROM jobs"); - this.ctx.storage.kv.put("generation", QUEUE_GENERATION); - } - - /** - * Queues both sizes and returns; callers read the bytes back out of R2. - * A configuration an earlier attempt found no insertable for is declined - * instead — see {@link EnqueueOutcome}. - * - * A job is named by the R2 key it will write, so asking twice is asking - * once and a client may poll as fast as it likes. A repeat does refresh the - * session and improve the rank, but never moves the next attempt earlier — - * that is what let a polling client reset a render's backoff endlessly. - */ - async enqueue( - request: ThumbnailRequest, - sessionId: string, - source: RenderSource - ): Promise { - this.#purgeStaleQueue(); - - const keys = SIZES.map((size) => keyOf(request, size)); - if (this.#isInvalid(keys)) { - return "no-such-configuration"; - } - - const now = Date.now(); - // Any live session for this user can render any of these jobs, so the - // newest one wins — which is what keeps a job queued under a session - // that has since expired renderable. - this.ctx.storage.kv.put("sessionId", sessionId); - - for (const size of SIZES) { - this.ctx.storage.sql.exec( - `INSERT INTO jobs - (key, request, source, queueRank, dueAt, expiresAt) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (key) DO UPDATE SET - source = CASE WHEN excluded.queueRank < jobs.queueRank - THEN excluded.source ELSE jobs.source END, - queueRank = MIN(jobs.queueRank, excluded.queueRank)`, - keyOf(request, size), - JSON.stringify(request), - source, - rankOf(source, size), - now, - now + JOB_TTL_MS - ); - } - - if (source === RenderSource.INSERT_MENU) { - this.#preemptFor(keys); - } - await this.#scheduleNext(); - return "queued"; - } - - /** - * The queue in the order it will run, the held render first. Exposed - * because the order is what this object is for: it is how to tell someone - * how far down they are, and how to see what a stalled queue is stalled on. - */ - queued(): QueuedJob[] { - return this.ctx.storage.sql - .exec( - `SELECT * FROM jobs - ORDER BY startedAt IS NULL, queueRank, dueAt` - ) - .toArray() - .map((job) => ({ - key: job.key, - source: job.source, - rendering: job.startedAt !== null - })); - } - - async alarm(): Promise { - if (this.#running) return; - this.#purgeStaleQueue(); - this.#running = true; - try { - await this.#drain(); - } finally { - this.#running = false; - await this.#scheduleNext(); - } - } - - /** - * One Onshape call per pass. While a job holds the render thread every pass - * polls that one key — asking about anything else abandons its render. - */ - async #drain(): Promise { - const until = Date.now() + ALARM_BUDGET_MS; - - while (Date.now() < until) { - this.#dropExpired(); - - const rendering = this.#renderingJob(); - if (rendering) { - // Not due yet — and nothing else may run in the meantime, so - // there is nothing to do but let the alarm come back. - if (rendering.dueAt > Date.now()) return; - // Terminal, not a strike. Retrying would take the thread for - // another five minutes on a render that has already shown it - // is not coming, with the whole queue waiting behind it. - if (this.#outOfRenderTime(rendering)) { - this.#finish(rendering.key); - continue; - } - this.#record(rendering, await this.#poll(rendering)); - continue; - } - - const next = this.#nextQueuedJob(); - if (!next) return; - this.#record(next, await this.#start(next)); - } - } - - /** - * Takes the render thread for a job: checks R2 first, since another caller - * may have stored these bytes since it was queued, and only then asks - * Onshape — which is the call that starts the render. - */ - async #start(job: JobRow): Promise { - try { - if (await this.env.BLOB.head(job.key)) { - return { outcome: "stored" }; - } - return await this.#fetch(job); - } catch (error) { - return classify(error); - } - } - - /** - * Asks again about the render this job started. No R2 check — these bytes - * arrive only by way of this call, so looking first answers nothing. - */ - async #poll(job: JobRow): Promise { - try { - return await this.#fetch(job); - } catch (error) { - return classify(error); - } - } - - /** The one Onshape call, and the store when it comes back with bytes. */ - async #fetch(job: JobRow): Promise { - const request = requestOf(job); - const onshapeApi = await this.#onshapeApi(); - const thumbnail = await getThumbnailFromId( - onshapeApi, - await this.#thumbnailId(job, request, onshapeApi), - sizeOf(job.key) - ); - - await putThumbnail(this.env.BLOB, job.key, thumbnail, { - microversionId: request.microversionId, - configurationKey: request.configurationKey - }); - return { outcome: "stored" }; - } - - /** - * The render's id, written to both sizes at once: it is fixed for an - * element and configuration, so asking per size or per poll only spends a - * call to be told the same thing. - */ - async #thumbnailId( - job: JobRow, - request: ThumbnailRequest, - onshapeApi: OnshapeApi - ): Promise { - if (job.thumbnailId) return job.thumbnailId; - - const elementPath = await this.#elementPath(request.insertableId); - const thumbnailId = await getThumbnailId( - onshapeApi, - elementPath, - decodeConfiguration(request.configurationKey) - ); - this.ctx.storage.sql.exec( - "UPDATE jobs SET thumbnailId = ? WHERE request = ?", - thumbnailId, - job.request - ); - return thumbnailId; - } - - /** Read rather than passed in: a request can carry a version it has moved past. */ - async #elementPath(insertableId: string): Promise { - const row = await getDb(this.env.DB) - .select({ - documentId: insertables.documentId, - versionId: insertables.versionId, - elementId: insertables.elementId - }) - .from(insertables) - .where(eq(insertables.id, insertableId)) - .get(); - if (!row) { - throw new NoSuchConfigurationError(`No insertable ${insertableId}`); - } - return { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v", - elementId: row.elementId - }; - } - - async #onshapeApi(): Promise { - const sessionId = this.ctx.storage.kv.get("sessionId"); - if (!sessionId) { - throw new Error("No session to render under"); - } - return getOnshapeApiFromSessionId(this.env.KV, sessionId); - } - - /** Writes back what one call decided: done, keep polling, or give up. */ - #record(job: JobRow, attempt: Attempt): void { - switch (attempt.outcome) { - case "stored": - this.#finish(job.key); - return; - - case "rendering": - // Takes the thread: nothing else may be asked of Onshape until - // this lands, or Onshape abandons it. - this.ctx.storage.sql.exec( - `UPDATE jobs - SET startedAt = COALESCE(startedAt, ?), dueAt = ? - WHERE key = ?`, - Date.now(), - Date.now() + pollDelay(job), - job.key - ); - return; - - case "rate-limited": - // The account is being throttled, not this job, so the render - // keeps its hold: waiting is not the same as switching away. - this.#delayAll(attempt.retryAfterSeconds * 1000); - return; - - case "failed": - // A configuration Onshape has no insertable for, or one that - // left the library: retrying asks the same question. - if (attempt.error instanceof NoSuchConfigurationError) { - this.#markInvalid(job); - return; - } - this.#fail(job); - return; - } - } - - /** - * Drops both of a request's sizes and remembers why. Neither can render — - * the id they would both have rendered from is what Onshape had nothing - * for — so the sibling is dropped rather than left to spend a call - * learning the same thing. - */ - #markInvalid(job: JobRow): void { - const request = requestOf(job); - for (const size of SIZES) { - const key = keyOf(request, size); - this.ctx.storage.sql.exec( - `INSERT INTO invalid (key, expiresAt) VALUES (?, ?) - ON CONFLICT (key) DO UPDATE SET expiresAt = excluded.expiresAt`, - key, - Date.now() + INVALID_TTL_MS - ); - this.#finish(key); - } - } - - /** Whether either size of a request is one already marked invalid. */ - #isInvalid(keys: string[]): boolean { - const placeholders = keys.map(() => "?").join(", "); - return ( - this.ctx.storage.sql - .exec<{ - count: number; - }>( - `SELECT COUNT(*) AS count FROM invalid - WHERE key IN (${placeholders}) AND expiresAt > ?`, - ...keys, - Date.now() - ) - .one().count > 0 - ); - } - - /** Gives the thread back after a job broke, and drops it once it keeps doing so. */ - #fail(job: JobRow): void { - if (job.failures + 1 >= MAX_FAILURES) { - this.#finish(job.key); - return; - } - this.ctx.storage.sql.exec( - `UPDATE jobs - SET startedAt = NULL, failures = failures + 1, dueAt = ? - WHERE key = ?`, - Date.now() + RETRY_DELAY_MS, - job.key - ); - } - - /** - * Hands the render thread to the insert menu, the only surface that may - * take it: somebody is watching that render, and the alternative is making - * them wait out everything else queued. - * - * The job that loses it is requeued rather than dropped — its render is - * gone, so it starts over at its turn. `dueAt` moves to now, putting it - * behind its waiting siblings, which is also what stops a user flipping - * between two configurations from bouncing one pair forever. - */ - #preemptFor(keys: string[]): void { - const rendering = this.#renderingJob(); - // The insert menu polling its own render must not interrupt it, which - // is the exact thrash this all exists to avoid. - if (!rendering || keys.includes(rendering.key)) { - return; - } - this.ctx.storage.sql.exec( - "UPDATE jobs SET startedAt = NULL, dueAt = ? WHERE key = ?", - Date.now(), - rendering.key - ); - } - - #finish(key: string): void { - this.ctx.storage.sql.exec("DELETE FROM jobs WHERE key = ?", key); - } - - /** The job holding the render thread, if one does. */ - #renderingJob(): JobRow | undefined { - return this.ctx.storage.sql - .exec("SELECT * FROM jobs WHERE startedAt IS NOT NULL") - .toArray()[0]; - } - - #nextQueuedJob(): JobRow | undefined { - return this.ctx.storage.sql - .exec( - `SELECT * FROM jobs WHERE startedAt IS NULL AND dueAt <= ? - ORDER BY queueRank, dueAt LIMIT 1`, - Date.now() - ) - .toArray()[0]; - } - - /** Only a render that actually started can run out of time holding the thread. */ - #outOfRenderTime(job: JobRow): boolean { - return ( - job.startedAt !== null && - Date.now() - job.startedAt > RENDER_TIMEOUT_MS - ); - } - - /** A render nothing came back for; a later request queues it again. */ - #dropExpired(): void { - this.ctx.storage.sql.exec( - "DELETE FROM jobs WHERE expiresAt <= ?", - Date.now() - ); - this.ctx.storage.sql.exec( - "DELETE FROM invalid WHERE expiresAt <= ?", - Date.now() - ); - } - - #delayAll(delayMs: number): void { - this.ctx.storage.sql.exec( - "UPDATE jobs SET dueAt = MAX(dueAt, ?)", - Date.now() + delayMs - ); - } - - /** - * Wakes for the next thing that can actually run. While a job holds the - * thread that is its next poll: waking for a queued job whose turn cannot - * come would spin the alarm against a due time already in the past. - */ - async #scheduleNext(): Promise { - const rendering = this.#renderingJob(); - const next = - rendering?.dueAt ?? - this.ctx.storage.sql - .exec<{ - dueAt: number | null; - }>("SELECT MIN(dueAt) AS dueAt FROM jobs") - .one().dueAt; - - if (next === null || next === undefined) { - await this.ctx.storage.deleteAlarm(); - return; - } - await this.ctx.storage.setAlarm(next); - } -} - -/** A 404 is Onshape rendering in the background, which is the normal case. */ -function classify(error: unknown): Attempt { - if (error instanceof OnshapeRateLimitError) { - return { - outcome: "rate-limited", - retryAfterSeconds: error.retryAfterSeconds - }; - } - // A 406 reads the same way: it means Onshape wanted to answer with - // something the request's Accept header excluded, and the only thing it has - // to say about a render that is not ready is a JSON error. - if ( - error instanceof OnshapeApiError && - (error.status === HttpStatus.NOT_FOUND || - error.status === HttpStatus.NOT_ACCEPTABLE) - ) { - return { outcome: "rendering" }; - } - return { outcome: "failed", error }; -} - -function requestOf(job: JobRow): ThumbnailRequest { - return JSON.parse(job.request) as ThumbnailRequest; -} - -/** The R2 key a request writes at one size, which is also the job's name. */ -function keyOf(request: ThumbnailRequest, size: ThumbnailSize): string { - return thumbnailKey( - request.elementId, - request.microversionId, - size, - request.configurationKey - ); -} - -/** Every key ends in the size it stores; see `thumbnailKey`. */ -function sizeOf(key: string): ThumbnailSize { - return key.slice(key.lastIndexOf("/") + 1) as ThumbnailSize; -} - -/** The size a surface shows first goes first; its other size follows. */ -function rankOf(source: RenderSource, size: ThumbnailSize): number { - return SOURCE_RANK[source] + (size === PREFERRED_SIZE[source] ? 0 : 1); -} - -/** How long to wait before asking about a render again; see POLL_SCHEDULE. */ -function pollDelay(job: JobRow): number { - // A job on its first 404 has not been stamped yet, so it reads as zero. - const heldMs = Date.now() - (job.startedAt ?? Date.now()); - const step = POLL_SCHEDULE.find(({ withinMs }) => heldMs < withinMs); - // The schedule ends in an unbounded step, so this only satisfies the types. - return step?.everyMs ?? POLL_SCHEDULE[POLL_SCHEDULE.length - 1].everyMs; -} - -/** Who a render runs as: the queue it joins, and the tokens it uses. */ -export interface Renderer { - /** The Onshape user whose one render thread this queue is for. */ - userId: string; - sessionId: string; -} - -/** Queues a render with the object holding this user's Onshape render thread. */ -export function requestThumbnails( - env: AppBindings, - { userId, sessionId }: Renderer, - request: ThumbnailRequest, - source: RenderSource -): Promise { - return env.THUMBNAIL_RENDERER.getByName(userId).enqueue( - request, - sessionId, - source - ); -} diff --git a/src/backend/features/thumbnails/renderer.worker.test.ts b/src/backend/features/thumbnails/renderer.worker.test.ts deleted file mode 100644 index 6a4389b25..000000000 --- a/src/backend/features/thumbnails/renderer.worker.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -import { env } from "cloudflare:workers"; -import { runDurableObjectAlarm, runInDurableObject } from "cloudflare:test"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getDb } from "../../db/client"; -import { resetDb, seedGroup } from "../../../__test_utils__"; -import { - insertableTarget, - parsedInsertable -} from "../../../__test_utils__/insertable-fixtures"; -import { saveInsertable } from "../load/load-insertable"; -import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; -import { - OnshapeApiError, - OnshapeRateLimitError -} from "../../lib/onshape/client"; -import { RenderSource, ThumbnailSize } from "./contract"; -import { thumbnailKey } from "./keys"; -import type { ThumbnailRequest } from "./renderer"; - -const MICROVERSION = "mv-1"; -const SESSION_ID = "renderer-session"; -const CONFIGURATION = "size=l"; - -/** - * A fresh object and a fresh element per test: the queue is per object, but R2 - * is one bucket, and a key another test stored is a key this one skips. - */ -let testId = ""; -const renderer = () => env.THUMBNAIL_RENDERER.getByName(testId); -const elementIdFor = (name: string) => `${testId}-${name}`; - -/** - * A render of one configuration. The element is per test and the configuration - * names the render, so two of these are two different renders competing. - */ -const request = (name = "e", configurationKey = CONFIGURATION) => - ({ - insertableId: testId, - elementId: elementIdFor(name), - microversionId: MICROVERSION, - configurationKey - }) satisfies ThumbnailRequest; - -const keyFor = ( - name: string, - size: ThumbnailSize, - configuration = CONFIGURATION -) => thumbnailKey(elementIdFor(name), MICROVERSION, size, configuration); - -const bothKeys = (name: string, configuration = CONFIGURATION) => - [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => - keyFor(name, size, configuration) - ); - -/** The stored insertable a render resolves its element path from. */ -async function seedInsertableRow() { - const db = getDb(env.DB); - await resetDb(db); - await seedGroup(db); - await saveInsertable( - db, - insertableTarget({ insertableId: testId }), - parsedInsertable() - ); -} - -/** Stands in for a session, which is what the renderer resolves tokens from. */ -async function seedSession() { - await env.KV.put( - `tokens:${SESSION_ID}`, - JSON.stringify({ - accessToken: "token", - refreshToken: "refresh", - expiresAt: Date.now() + 3_600_000, - userId: "renderer-user" - }) - ); -} - -/** - * Onshape's answer for a render, by the id it was asked about. The id is - * derived from the configuration so two renders are told apart. - */ -function mockRenders(answer: (thumbnailId: string) => Promise) { - vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockImplementation( - (_client, _path, configurationKey) => - Promise.resolve(`tid-${configurationKey}`) - ); - return vi - .spyOn(ThumbnailEndpoints, "getThumbnailFromId") - .mockImplementation((_client, thumbnailId) => answer(thumbnailId)); -} - -const stillRendering = () => - Promise.reject(new OnshapeApiError("Onshape API error 404: nope", 404)); - -const notAcceptable = () => - Promise.reject(new OnshapeApiError("Onshape API error 406: nope", 406)); - -const rendered = () => Promise.resolve(new ArrayBuffer(4)); - -/** - * For tests that only read the queue's order. It keeps jobs queued — a 404 is - * "rendering" and would take the thread, which sorts that job first — and keeps - * the runtime from reaching the real Onshape while they look. - */ -const notAsked = () => Promise.reject(new Error("not asked in this test")); - -/** - * Drives the queue with its own alarm until `reached` holds, without waiting on - * wall time. Enqueuing schedules an alarm for now, so the runtime may already - * be draining; each pass yields, which lets one in flight finish, and runs the - * next alarm if one is scheduled. - * - * Deliberately no timers. The pool runs files in parallel and promises nothing - * about how long a drain takes, so anything paced by the clock is both slower - * and a coin toss. - */ -async function drainUntil( - reached: () => boolean | Promise -): Promise { - for (let pass = 0; pass < 50; pass++) { - if (await reached()) return; - await runDurableObjectAlarm(renderer()); - } - throw new Error("the renderer never reached the expected state"); -} - -/** Runs several more alarms, so "nothing changed" means it had the chance to. */ -async function settle(): Promise { - for (let pass = 0; pass < 5; pass++) { - await runDurableObjectAlarm(renderer()); - } -} - -/** The key holding the render thread, once one does. */ -async function renderingKey(): Promise { - await drainUntil(async () => - (await renderer().queued()).some((job) => job.rendering) - ); - const jobs = await renderer().queued(); - return jobs.find((job) => job.rendering)!.key; -} - -/** The soonest any job may next be touched. */ -async function soonestDueAt(): Promise { - return runInDurableObject( - renderer(), - (_instance, state) => - state.storage.sql - .exec<{ - dueAt: number | null; - }>("SELECT MIN(dueAt) AS dueAt FROM jobs") - .one().dueAt ?? 0 - ); -} - -async function queueOf(count: number) { - await drainUntil(async () => (await renderer().queued()).length === count); - return renderer().queued(); -} - -/** What Onshape was asked about one configuration, in order. */ -function callsFor( - calls: ReturnType, - configuration: string -): unknown[] { - return calls.mock.calls.filter( - (call) => call[1] === `tid-${configuration}` - ); -} - -describe("ThumbnailRenderer", () => { - beforeEach(async () => { - testId = `t${crypto.randomUUID()}`; - await seedSession(); - await seedInsertableRow(); - }); - - afterEach(() => vi.restoreAllMocks()); - - it("queues both sizes of one request", async () => { - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - expect((await renderer().queued()).map((job) => job.key)).toEqual( - expect.arrayContaining(bothKeys("e")) - ); - }); - - // A client polls the route, and every poll re-enqueues; without this the - // queue would grow a job per poll. - it("names a job by its key, so enqueuing twice enqueues once", async () => { - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - expect(await renderer().queued()).toHaveLength(2); - }); - - it("stores what Onshape rendered and drops the job", async () => { - mockRenders(rendered); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - expect(await queueOf(0)).toEqual([]); - for (const key of bothKeys("e")) { - expect(await env.BLOB.head(key)).not.toBeNull(); - } - }); - - // The whole point of the object: asking Onshape for a different thumbnail - // abandons the render in flight, so a job that has started one is polled to - // the exclusion of everything else. - it("holds the render thread until its own render lands", async () => { - // Two configurations of one element: two renders, and Onshape will - // only ever finish the second. - const calls = mockRenders((thumbnailId) => - thumbnailId === "tid-size=l" ? stillRendering() : rendered() - ); - await renderer().enqueue( - request("e", "size=l"), - SESSION_ID, - RenderSource.LOAD - ); - await renderer().enqueue( - request("e", "size=s"), - SESSION_ID, - RenderSource.LOAD - ); - - const held = await renderingKey(); - // More passes, so "never asked about" means the queue had every chance - // to ask and did not. - await settle(); - - expect(held).toContain(encodeURIComponent("size=l")); - // The job behind it was due the whole time and never asked about: - // asking would have abandoned the render in flight. - expect(callsFor(calls, "size=s")).toEqual([]); - }); - - // Nobody is waiting on any one thumbnail of a load, so it yields to the - // configuration someone is watching a spinner for. - it("runs what someone is watching before what a load queued", async () => { - mockRenders(notAsked); - await renderer().enqueue( - request("loaded"), - SESSION_ID, - RenderSource.LOAD - ); - await renderer().enqueue( - request("watched"), - SESSION_ID, - RenderSource.ROW - ); - - const queued = await renderer().queued(); - expect( - queued.slice(0, 2).every((job) => job.key.includes("watched")) - ).toBe(true); - }); - - // A row shows the small one and reveals the large on hover; the insert menu - // shows only the large. They are separate renders, and only one may run. - it("runs the size its surface shows first", async () => { - mockRenders(notAsked); - await renderer().enqueue(request("row"), SESSION_ID, RenderSource.ROW); - expect((await renderer().queued())[0].key).toBe( - keyFor("row", ThumbnailSize.SMALL) - ); - - testId = `t${crypto.randomUUID()}`; - await renderer().enqueue( - request("menu"), - SESSION_ID, - RenderSource.INSERT_MENU - ); - expect((await renderer().queued())[0].key).toBe( - keyFor("menu", ThumbnailSize.LARGE) - ); - }); - - // Somebody is watching this one render; the alternative is making them wait - // out a library load. - it("takes the render thread for the insert menu", async () => { - mockRenders(stillRendering); - await renderer().enqueue( - request("loaded"), - SESSION_ID, - RenderSource.LOAD - ); - expect(await renderingKey()).toContain(elementIdFor("loaded")); - - await renderer().enqueue( - request("watched"), - SESSION_ID, - RenderSource.INSERT_MENU - ); - - await drainUntil(async () => - (await renderer().queued()).some( - (job) => - job.rendering && job.key.includes(elementIdFor("watched")) - ) - ); - // The load's render is gone, not its job: it starts over when its turn - // comes round again. - expect((await renderer().queued()).map((job) => job.key)).toEqual( - expect.arrayContaining(bothKeys("loaded")) - ); - }); - - // The insert menu polls its own render every couple of seconds. Treating - // that as a reason to restart it is the exact thrash this all prevents. - it("does not interrupt the render the insert menu is waiting on", async () => { - mockRenders(stillRendering); - await renderer().enqueue( - request("watched"), - SESSION_ID, - RenderSource.INSERT_MENU - ); - const held = await renderingKey(); - - await renderer().enqueue( - request("watched"), - SESSION_ID, - RenderSource.INSERT_MENU - ); - - await runInDurableObject(renderer(), (_instance, state) => { - const started = state.storage.sql - .exec<{ - key: string; - }>("SELECT key FROM jobs WHERE startedAt IS NOT NULL") - .toArray(); - expect(started).toHaveLength(1); - expect(started[0].key).toBe(held); - }); - }); - - // A render an earlier load already stored, or the other size of a pair. - it("checks R2 before taking the thread, not on every poll", async () => { - for (const key of bothKeys("stored")) { - await env.BLOB.put(key, "bytes"); - } - const calls = mockRenders(rendered); - await renderer().enqueue( - request("stored"), - SESSION_ID, - RenderSource.LOAD - ); - - expect(await queueOf(0)).toEqual([]); - expect(callsFor(calls, "stored")).toEqual([]); - }); - - // Onshape answers a thumbnail it has not rendered with a JSON error, which - // it cannot send when the request rules that content type out. Read as a - // failure it drops the job after three strikes, and the render never lands. - it("treats a 406 as a render still running, not a failure", async () => { - mockRenders(notAcceptable); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - const held = await renderingKey(); - expect(held).toContain(elementIdFor("e")); - // Still queued rather than dropped, and holding the thread. - expect(await renderer().queued()).toHaveLength(2); - }); - - // Onshape is pushing back on the account, not on this render, and waiting - // is not the same as switching to another one. - it("waits out a rate limit without giving up the thread", async () => { - mockRenders(() => - Promise.reject(new OnshapeRateLimitError("slow down", 30)) - ); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - - // Waits for the stand-down itself. Waiting on the queue length instead - // waits for nothing — both jobs are queued the moment `enqueue` - // returns, so the assertion would race the drain that rate-limits them. - await drainUntil( - async () => (await soonestDueAt()) > Date.now() + 20_000 - ); - // Both still queued: a rate limit is not a reason to drop anything. - expect(await renderer().queued()).toHaveLength(2); - }); - - // Retrying asks Onshape the same question for the same answer. - it("drops a configuration Onshape has no insertable for", async () => { - vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockRejectedValue( - new ThumbnailEndpoints.NoSuchConfigurationError("no such thing") - ); - - await renderer().enqueue( - request(), - SESSION_ID, - RenderSource.INSERT_MENU - ); - - expect(await queueOf(0)).toEqual([]); - }); - - // What the client polls for: it is waiting on a spinner that can only run - // out, and the wording it shows depends on knowing the difference. - it("tells a later request that the configuration has no insertable", async () => { - const ids = vi - .spyOn(ThumbnailEndpoints, "getThumbnailId") - .mockRejectedValue( - new ThumbnailEndpoints.NoSuchConfigurationError("no such thing") - ); - - await renderer().enqueue( - request(), - SESSION_ID, - RenderSource.INSERT_MENU - ); - await queueOf(0); - - expect( - await renderer().enqueue( - request(), - SESSION_ID, - RenderSource.INSERT_MENU - ) - ).toBe("no-such-configuration"); - // Nothing requeued, and the sibling size never spent a call of its own - // learning what the first already found out. - expect(await renderer().queued()).toEqual([]); - expect(ids).toHaveBeenCalledTimes(1); - }); - - it("queues a configuration it has nothing against", async () => { - mockRenders(stillRendering); - - expect( - await renderer().enqueue(request(), SESSION_ID, RenderSource.ROW) - ).toBe("queued"); - }); - - it("abandons a render that runs out of thread time", async () => { - mockRenders(stillRendering); - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - const held = await renderingKey(); - - // Backdated past the limit rather than waited out: what is under test - // is that reaching it ends the job, not how long reaching it takes. - await runInDurableObject(renderer(), (_instance, state) => { - state.storage.sql.exec( - `UPDATE jobs SET startedAt = 0, dueAt = ? - WHERE startedAt IS NOT NULL`, - Date.now() - ); - }); - - const gone = async () => - !(await renderer().queued()).some((job) => job.key === held); - await drainUntil(gone); - - // Dropped outright rather than requeued for another hold on the thread. - await settle(); - expect(await gone()).toBe(true); - }); - - it("throws away a queue left by an older generation", async () => { - await renderer().enqueue(request(), SESSION_ID, RenderSource.LOAD); - expect(await renderer().queued()).toHaveLength(2); - - await runInDurableObject(renderer(), (_instance, state) => { - state.storage.kv.put("generation", 0); - }); - // Anything that touches the queue is what notices. - await renderer().enqueue( - request("after"), - SESSION_ID, - RenderSource.LOAD - ); - - // Only what was queued after the purge. - expect( - (await renderer().queued()).every((job) => - job.key.includes(elementIdFor("after")) - ) - ).toBe(true); - }); - - it("sets no alarm when there is nothing queued", async () => { - expect(await runDurableObjectAlarm(renderer())).toBe(false); - }); -}); diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index e9036f5ad..e9e23d9f0 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -3,18 +3,12 @@ import { HttpStatus } from "http-status-ts"; import { handledError } from "../../lib/api-error"; import { validate } from "../../lib/validate"; import { CachePolicy, setCache } from "../../lib/cache"; -import { getApp, type AppContext } from "../../lib/context"; +import { getApp } from "../../lib/context"; import { RenderSource, ThumbnailSize } from "./contract"; import { thumbnailKey } from "./keys"; import { DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; - -import { - type EnqueueOutcome, - requestThumbnails, - type ThumbnailRequest -} from "./renderer"; -import { getSessionId } from "../auth/session"; +import { requestRender } from "./render"; import { requireEditorMiddleware } from "../auth/guards"; import { getDb } from "../../db/client"; import { reloadGroupThumbnail, reloadInsertableThumbnail } from "./reload"; @@ -29,11 +23,7 @@ const storedThumbnailParams = z.object({ /** Absent means the element default, which is what `""` encodes. */ const configurationKeyQuery = z.string().default(DEFAULT_CONFIGURATION_KEY); -/** - * Only the two sources a client can legitimately be. The insert menu may take - * the render thread from whatever holds it, so this is not free-form. - */ -const renderSourceQuery = z.enum([RenderSource.INSERT_MENU, RenderSource.ROW]); +const renderSourceQuery = z.enum(RenderSource); const storedThumbnailQuery = z.object({ /** The microversion, part of the key — which is what makes a hit immutable. */ @@ -82,7 +72,7 @@ thumbnailRoutes.get( renderSource && insertableId ) { - const outcome = await queueConfigurationRender( + const outcome = await requestRender( c, { insertableId, @@ -91,7 +81,11 @@ thumbnailRoutes.get( microversionId }, renderSource - ); + ).catch(() => { + // Never fatal — no session to render under, most often; the + // caller just keeps missing. + return undefined; + }); if (outcome === "no-such-configuration") { return noSuchConfiguration(); } @@ -127,33 +121,6 @@ function thumbnailResponse(object: R2ObjectBody): Response { return new Response(object.body, { headers }); } -/** - * Queues the render and returns; the client polls this route until the bytes - * land. Polling is free — the queue names a job by the key it will write, so - * asking twice is asking once and never disturbs a render already running. - */ -async function queueConfigurationRender( - c: AppContext, - request: ThumbnailRequest, - source: RenderSource -): Promise { - try { - // Read first, so a caller with no session queues nothing: the render - // runs later, under this caller's Onshape tokens. - const sessionId = getSessionId(c); - const userId = await c.var.getUserId(); - return await requestThumbnails( - c.env, - { userId, sessionId }, - request, - source - ); - } catch { - // Never fatal: the caller just gets a miss until the render lands. - return undefined; - } -} - const reloadThumbnailBody = z.object({ /** Exactly one: a group's own thumbnail, or one element's. */ groupId: z.string().min(1).optional(), diff --git a/src/backend/features/thumbnails/routes.worker.test.ts b/src/backend/features/thumbnails/routes.worker.test.ts index a2b622a9a..30b92eb7d 100644 --- a/src/backend/features/thumbnails/routes.worker.test.ts +++ b/src/backend/features/thumbnails/routes.worker.test.ts @@ -1,7 +1,15 @@ import { env } from "cloudflare:workers"; -import { runDurableObjectAlarm } from "cloudflare:test"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTestApp, jsonRequest } from "../../../__test_utils__"; +import { introspectWorkflow } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TEST_PART_STUDIO_ID, + createTestApp, + jsonRequest, + resetDb, + seedPartStudio +} from "../../../__test_utils__"; +import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; +import { getDb } from "../../db/client"; import { RenderSource, ThumbnailSize } from "./contract"; import { parseThumbnailKey, @@ -19,6 +27,8 @@ const CANONICAL_CONFIGURATION = "size=l"; const SESSION_ID = "test-session"; const INSERTABLE_ID = "test-insertable"; +const db = getDb(env.DB); + function get(url: string, sessionId?: string) { const init = jsonRequest("GET"); if (sessionId) { @@ -248,9 +258,8 @@ describe("thumbnail serving", () => { }); }); -describe("queueing a configuration's thumbnail", () => { - /** The queue the route enqueues on; `createTestApp` signs in as this user. */ - const queue = () => env.THUMBNAIL_RENDERER.getByName("test-user"); +describe("rendering a configuration's thumbnail", () => { + afterEach(() => vi.restoreAllMocks()); /** Seeds only the default, so a configuration request always misses. */ async function seedDefaultOnly(elementId: string) { @@ -260,46 +269,43 @@ describe("queueing a configuration's thumbnail", () => { ); } - function renderUrl(renderSource: RenderSource, elementId: string) { + function renderUrl(elementId: string, renderSource = RenderSource.ROW) { return thumbnailUrl({ elementId, microversionId: MICROVERSION, size: SIZE, configurationKey: CANONICAL_CONFIGURATION, renderSource, - insertableId: INSERTABLE_ID + insertableId: TEST_PART_STUDIO_ID }); } - async function getRender( - elementId: string, - renderSource = RenderSource.ROW - ) { - return get(renderUrl(renderSource, elementId), SESSION_ID); + /** Onshape resolving the configuration to a render id. */ + function mockThumbnailId() { + return vi + .spyOn(ThumbnailEndpoints, "getThumbnailId") + .mockResolvedValue("thumbnail-id"); } - /** What the queue holds for one element, whatever else is in it. */ - async function queuedFor(elementId: string) { - const jobs = await queue().queued(); - return jobs.filter((job) => job.key.includes(elementId)); + /** Workflows started while `run` is called, with their steps stubbed out. */ + async function startedDuring(run: () => Promise) { + await using workflows = await introspectWorkflow( + env.RENDER_THUMBNAIL_WORKFLOW + ); + await workflows.modifyAll(async (m) => { + for (const size of Object.values(ThumbnailSize)) { + await m.mockStepResult({ name: `store-${size}` }, null); + } + }); + await run(); + return (await workflows.get()).length; } - it("passes the source the validator accepts", () => { - const url = thumbnailUrl({ - elementId: "any", - microversionId: MICROVERSION, - size: SIZE, - configurationKey: CANONICAL_CONFIGURATION, - renderSource: RenderSource.INSERT_MENU, - insertableId: INSERTABLE_ID - }); - expect(new URL(url, "http://x").searchParams.get("renderSource")).toBe( - "insert" - ); + beforeEach(async () => { + await resetDb(db); + await seedPartStudio(db); }); - // Without one there is nothing to resolve the element from, so the render - // is simply not requested. it("omits the source when no insertable is named", () => { const url = thumbnailUrl({ elementId: "any", @@ -313,120 +319,69 @@ describe("queueing a configuration's thumbnail", () => { ).toBeNull(); }); - // Both, so the row and the hover card it opens never disagree. - it("queues both sizes on a miss", async () => { + // Polling is how the client waits, so asking twice has to be asking once + // — and cost Onshape one call, not one per poll. + it("starts one render on a miss, however often it is asked", async () => { await seedDefaultOnly("warm-element"); + const thumbnailId = mockThumbnailId(); - const res = await getRender("warm-element"); - - expect(res.status).toBe(404); - expect((await queuedFor("warm-element")).map((job) => job.key)).toEqual( - expect.arrayContaining([ - thumbnailKey( - "warm-element", - MICROVERSION, - ThumbnailSize.SMALL, - CANONICAL_CONFIGURATION - ), - thumbnailKey( - "warm-element", - MICROVERSION, - ThumbnailSize.LARGE, - CANONICAL_CONFIGURATION - ) - ]) - ); - }); - - // Polling is how the client waits, so asking twice has to be asking once. - it("queues nothing new when the same render is asked for again", async () => { - await seedDefaultOnly("repeat-element"); - - await getRender("repeat-element"); - await getRender("repeat-element"); + const started = await startedDuring(async () => { + expect( + (await get(renderUrl("warm-element"), SESSION_ID)).status + ).toBe(404); + await get(renderUrl("warm-element"), SESSION_ID); + }); - expect(await queuedFor("repeat-element")).toHaveLength(2); + expect(started).toBe(1); + expect(thumbnailId).toHaveBeenCalledTimes(1); }); - it("records which surface asked, since that is what orders the queue", async () => { - await seedDefaultOnly("sourced-element"); - - await getRender("sourced-element", RenderSource.INSERT_MENU); - - const jobs = await queuedFor("sourced-element"); - expect( - jobs.every((job) => job.source === RenderSource.INSERT_MENU) - ).toBe(true); - }); - - /** What the renderer resolves its Onshape tokens from. */ - async function seedRendererSession() { - await env.KV.put( - `tokens:${SESSION_ID}`, - JSON.stringify({ - accessToken: "token", - refreshToken: "refresh", - expiresAt: Date.now() + 3_600_000, - userId: "test-user" - }) - ); - } - - /** The route's answer once the queue has had the chance to fail the job. */ - async function statusAfterDraining(elementId: string): Promise { - for (let pass = 0; pass < 20; pass++) { - await runDurableObjectAlarm(queue()); - const { status } = await getRender(elementId); - if (status !== 404) return status; - } - return 404; - } - // A miss is a render still coming; this is one that never will be, and the // client shows different wording for each. - it("answers a render that cannot resolve its element with its own status", async () => { - await seedDefaultOnly("invalid-element"); - await seedRendererSession(); - - // Nothing stores a row for INSERTABLE_ID, so the render has no element - // path to resolve — the terminal failure a bad configuration also takes. - expect((await getRender("invalid-element")).status).toBe(404); + it("answers a configuration Onshape cannot resolve with its own status", async () => { + vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockRejectedValue( + new ThumbnailEndpoints.NoSuchConfigurationError("none") + ); - expect(await statusAfterDraining("invalid-element")).toBe(422); + const started = await startedDuring(async () => { + expect( + (await get(renderUrl("invalid-element"), SESSION_ID)).status + ).toBe(422); + }); + expect(started).toBe(0); }); - it("queues no render when there is no session to run it under", async () => { - await seedDefaultOnly("sessionless-element"); + it("starts nothing when there is no session to render under", async () => { + const thumbnailId = mockThumbnailId(); - const res = await get( - renderUrl(RenderSource.ROW, "sessionless-element") - ); + const started = await startedDuring(async () => { + expect((await get(renderUrl("sessionless-element"))).status).toBe( + 404 + ); + }); - expect(res.status).toBe(404); - expect(await queuedFor("sessionless-element")).toEqual([]); + expect(started).toBe(0); + expect(thumbnailId).not.toHaveBeenCalled(); }); // Search results show many configurations at once; one cold search must not - // queue a render per row against a thread that runs one at a time. - it("queues nothing when no source is named", async () => { - await seedDefaultOnly("cold-element"); - - const res = await get( - thumbnailUrl({ - elementId: "cold-element", - microversionId: MICROVERSION, - size: SIZE, - configurationKey: CANONICAL_CONFIGURATION - }), - SESSION_ID - ); - - expect(res.status).toBe(404); - expect(await queuedFor("cold-element")).toEqual([]); + // start a render per row. + it("starts nothing when no source is named", async () => { + const started = await startedDuring(async () => { + const res = await get( + thumbnailUrl({ + elementId: "cold-element", + microversionId: MICROVERSION, + size: SIZE, + configurationKey: CANONICAL_CONFIGURATION + }), + SESSION_ID + ); + expect(res.status).toBe(404); + }); + expect(started).toBe(0); }); - // A client must not be able to label itself a library load, nor invent a - // surface that outranks the insert menu. it("rejects a source that is not one a client may claim", async () => { const res = await get( `/api/thumbnail/${SIZE}/any?v=${MICROVERSION}&configurationKey=x&renderSource=load` diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts index f4fc7adba..5c41284f8 100644 --- a/src/backend/features/thumbnails/store.ts +++ b/src/backend/features/thumbnails/store.ts @@ -1,6 +1,5 @@ /** - * Where thumbnails live in R2, and how a caller reads one back. Only - * `ThumbnailRenderer` asks Onshape for one; this is the storage either side. + * Where thumbnails live in R2, and how a caller reads one back. */ import { CachePolicy, immutableCacheControl } from "../../lib/cache"; @@ -69,8 +68,8 @@ async function fetchThumbnail( * neither instance will give one up. * * Onshape renders these when a document is saved, so reading one starts no work - * and races nothing — unlike a configuration, which `ThumbnailRenderer` has to - * serialize. A load fetches them directly, several elements at a time. + * and races nothing — unlike a configuration, which `RenderThumbnailWorkflow` + * waits out. A load fetches them directly, several elements at a time. */ export async function uploadThumbnails( bucket: R2Bucket, diff --git a/src/backend/index.ts b/src/backend/index.ts index 169eec74a..b9d32e406 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -7,7 +7,7 @@ export { AddGroupWorkflow, LoadLibraryWorkflow } from "./features/load/workflows"; -export { ThumbnailRenderer } from "./features/thumbnails/renderer"; +export { RenderThumbnailWorkflow } from "./features/thumbnails/render-workflow"; import { createApp } from "./app"; import { productionAuth } from "./features/auth/request-auth"; diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 00251f480..77428cecb 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -3,7 +3,7 @@ import type { AddGroupParams, LoadLibraryParams } from "../features/load/workflows"; -import type { ThumbnailRenderer } from "../features/thumbnails/renderer"; +import type { RenderThumbnailParams } from "../features/thumbnails/render-workflow"; import { type AccessLevel } from "../features/auth/access-level"; import { type OAuthApi } from "./onshape/client"; @@ -15,8 +15,8 @@ export interface AppBindings { BLOB: R2Bucket; LOAD_LIBRARY_WORKFLOW: Workflow; ADD_GROUP_WORKFLOW: Workflow; - /** One per Onshape user; every thumbnail Onshape renders queues here. */ - THUMBNAIL_RENDERER: DurableObjectNamespace; + /** One instance per configuration being rendered; see `requestRender`. */ + RENDER_THUMBNAIL_WORKFLOW: Workflow; ADMIN_TEAM: string; /** Dev-only: the access level granted, bypassing Onshape. */ VITE_ACCESS_LEVEL_OVERRIDE?: string; diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index b6825cc47..8be5bba51 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -94,8 +94,8 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { microversionId: insertable.microversionId, configurationKey: match?.configurationKey ?? DEFAULT_CONFIGURATION_KEY - // No renderSource: a cold search would otherwise queue a render - // per row, against a thread that runs one at a time. + // No renderSource: a cold search would otherwise start a render + // per row. }} /> ); diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index be56eb9b0..92eff66c3 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -58,9 +58,8 @@ interface ThumbnailTarget { /** Empty means the element default. */ configurationKey: ConfigurationKey; /** - * Set where a miss should queue a render: surfaces the user picked the - * configuration on. A search would otherwise queue a render per row, and - * Onshape does one at a time. + * Set where a miss should start a render: surfaces the user picked the + * configuration on. A search would otherwise start one per row. */ renderSource?: RenderSource; /** Only needed to render: what the render resolves the element from. */ @@ -78,8 +77,8 @@ interface CardThumbnailProps { export function CardThumbnail(props: CardThumbnailProps): ReactNode { const { smallThumbnailUrl, largeThumbnailUrl, target } = props; - // Asked for by key whether or not this row may queue one: the route serves - // what is already stored either way, so a row that cannot queue still shows + // Asked for by key whether or not this row may start one: the route serves + // what is already stored either way, so a row that cannot start one still shows // a configuration something else rendered. It costs that row a 404 when // nothing has, and it falls back to the element's own. const configuredTarget = @@ -95,7 +94,7 @@ export function CardThumbnail(props: CardThumbnailProps): ReactNode { const fallbackFor = (stored?: string) => configuredTarget ? stored : undefined; - // Only a row that queued the render has one coming; anything else takes the + // Only a row that started the render has one coming; anything else takes the // miss for the answer rather than polling for a render nobody started. const isRendering = configuredTarget?.renderSource !== undefined; @@ -132,12 +131,10 @@ export function CardThumbnail(props: CardThumbnailProps): ReactNode { } /** - * How long any surface waits out a render before calling it failed. The - * renderer can spend far longer than this on one, and a thumbnail that lands - * afterwards is still stored for the next person to ask — but nobody watches a - * spinner for minutes, and an insert never needed the render in the first place. + * How long any surface waits out a render before calling it failed: as long as + * `RenderThumbnailWorkflow` does, after which nothing more is coming. */ -const RENDER_TIMEOUT_MS = 30_000; +const RENDER_TIMEOUT_MS = 60_000; /** A poll is a worker reading R2, not an Onshape call, so it can be this tight. */ const POLL_INTERVAL_MS = 2_000; @@ -264,9 +261,9 @@ const PREVIEW_SIZE = ThumbnailSize.LARGE; const PREVIEW_SPINNER_SIZE = 36; /** - * Polls for the render the renderer produces. Until it lands the route answers - * 404, so a miss is a rejected query and the retry is the poll; queueing is - * idempotent, so every poll can carry it without disturbing what is running. + * Polls for a configuration's render. Until it lands the route answers 404, so + * a miss is a rejected query and the retry is the poll; starting a render is + * idempotent, so every poll can ask without starting another. */ function usePreviewThumbnail(props: PreviewImageProps, enabled: boolean) { const { path, insertableId, microversionId, configurationKey } = props; @@ -275,8 +272,6 @@ function usePreviewThumbnail(props: PreviewImageProps, enabled: boolean) { microversionId, size: PREVIEW_SIZE, configurationKey, - // The one surface that may take the render thread off whatever else is - // using it: somebody picked this configuration and is watching it load. renderSource: RenderSource.INSERT_MENU, insertableId }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 60b2acf65..0072907a5 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: cb3ebfb19ff7900e177f197869b3908e) +// Generated by Wrangler by running `wrangler types` (hash: 9190a9566875ea509c4ac0cb6d97261b) // Runtime types generated with workerd@1.20260811.1 2026-05-14 nodejs_compat interface __BaseEnv_Env { KV: KVNamespace; @@ -14,14 +14,13 @@ interface __BaseEnv_Env { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - THUMBNAIL_RENDERER: DurableObjectNamespace; LOAD_LIBRARY_WORKFLOW: Workflow[0]['payload']>; ADD_GROUP_WORKFLOW: Workflow[0]['payload']>; + RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/backend/index"); - durableNamespaces: "ThumbnailRenderer"; } interface CertEnv { KV: KVNamespace; @@ -36,9 +35,9 @@ declare namespace Cloudflare { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - THUMBNAIL_RENDERER: DurableObjectNamespace; LOAD_LIBRARY_WORKFLOW: Workflow[0]['payload']>; ADD_GROUP_WORKFLOW: Workflow[0]['payload']>; + RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } interface ProductionEnv { KV: KVNamespace; @@ -53,9 +52,9 @@ declare namespace Cloudflare { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - THUMBNAIL_RENDERER: DurableObjectNamespace; LOAD_LIBRARY_WORKFLOW: Workflow[0]['payload']>; ADD_GROUP_WORKFLOW: Workflow[0]['payload']>; + RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } interface Env extends __BaseEnv_Env {} } diff --git a/wrangler.jsonc b/wrangler.jsonc index 3d2e6b85e..5dd0b3980 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -62,27 +62,23 @@ "name": "add-group-workflow", "binding": "ADD_GROUP_WORKFLOW", "class_name": "AddGroupWorkflow" + }, + { + "name": "render-thumbnail-workflow", + "binding": "RENDER_THUMBNAIL_WORKFLOW", + "class_name": "RenderThumbnailWorkflow" } ], - /** - * One renderer per Onshape user. Onshape appears to run at most one - * thumbnail render per user at a time, so every thumbnail fetch queues here - * and the object is what makes them one at a time. - */ - "durable_objects": { - "bindings": [ - { - "name": "THUMBNAIL_RENDERER", - "class_name": "ThumbnailRenderer" - } - ] - }, - // Inherited by every environment, which is what we want: each gets its own - // objects under the same class. + // Inherited by every environment. v2 deletes the thumbnail render queue, + // replaced by RenderThumbnailWorkflow; the object's stored queue goes with it. "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ThumbnailRenderer"] + }, + { + "tag": "v2", + "deleted_classes": ["ThumbnailRenderer"] } ], /** @@ -139,16 +135,13 @@ "name": "add-group-workflow-cert", "binding": "ADD_GROUP_WORKFLOW", "class_name": "AddGroupWorkflow" + }, + { + "name": "render-thumbnail-workflow-cert", + "binding": "RENDER_THUMBNAIL_WORKFLOW", + "class_name": "RenderThumbnailWorkflow" } ], - "durable_objects": { - "bindings": [ - { - "name": "THUMBNAIL_RENDERER", - "class_name": "ThumbnailRenderer" - } - ] - }, "vars": { "ADMIN_TEAM": "6a62e6efcc21741bea57362c", // Production, not because cert is production, but because @@ -194,16 +187,13 @@ "name": "add-group-workflow-prod", "binding": "ADD_GROUP_WORKFLOW", "class_name": "AddGroupWorkflow" + }, + { + "name": "render-thumbnail-workflow-prod", + "binding": "RENDER_THUMBNAIL_WORKFLOW", + "class_name": "RenderThumbnailWorkflow" } ], - "durable_objects": { - "bindings": [ - { - "name": "THUMBNAIL_RENDERER", - "class_name": "ThumbnailRenderer" - } - ] - }, "vars": { "ADMIN_TEAM": "5b620150b2190f0fca90ec10", "NODE_ENV": "production" From a6e10ad1af5779c8bc9a0a0bebf9da72f8bdaf34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 17:04:07 +0000 Subject: [PATCH 04/88] Lay the configuration panel out on a grid, and size its dropdowns On a narrow panel a long label squeezed its Select to a sliver, and the dropdown, as wide as that sliver, wrapped every option onto several lines. Labels now share a capped column, so controls line up and a long label wraps instead; a dropdown is at least as wide as its input and at most as wide as the screen. The get-app callout's button wraps under its text rather than running off the edge, and clicking into a quantity keeps the whole expression selected in browsers whose mouseup would drop it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/frontend/components/callout.tsx | 15 +-- src/frontend/components/input-row.tsx | 54 ++++------- .../components/configurations.module.css | 25 +++++ .../insert/components/configurations.tsx | 91 ++++++++++++++----- .../settings/components/settings-menu.tsx | 18 ++-- 5 files changed, 127 insertions(+), 76 deletions(-) create mode 100644 src/frontend/features/insert/components/configurations.module.css diff --git a/src/frontend/components/callout.tsx b/src/frontend/components/callout.tsx index 64e39cd6a..b77fd2748 100644 --- a/src/frontend/components/callout.tsx +++ b/src/frontend/components/callout.tsx @@ -1,7 +1,7 @@ import { Alert, Button, Group, Text } from "@mantine/core"; import { InfoIcon } from "@phosphor-icons/react"; import { ReactNode } from "react"; -import { IconSize, NO_SHRINK, StatusColor } from "../lib/style-constants"; +import { IconSize, StatusColor } from "../lib/style-constants"; export interface CalloutAction { /** A verb or a destination, e.g. "Instructions". */ @@ -36,18 +36,19 @@ export function Callout(props: CalloutProps): ReactNode { wrapper: { alignItems: "center" } }} > - - {text} + {/* Wraps rather than squeezing: on a narrow panel the button drops + under the text instead of running off the edge. */} + + + {text} + {action && ( - // Outlined rather than filled, which would shout on a - // note; held at its own width, since the text is what - // gives on a narrow row. + // Outlined rather than filled, which would shout on a note. diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 69a7c027a..08d2c7175 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -23,11 +23,6 @@ export enum FontWeight { BOLD = 700 } -export const BORDER = "1px solid var(--mantine-color-default-border)"; - -/** The corner every box of ours is cut with, matching the theme's default. */ -export const RADIUS = "var(--mantine-radius-sm)"; - /** * The colors state is spoken in, as Mantine names them. Named here rather than * written at each control, so an error looks like an error everywhere. @@ -68,16 +63,6 @@ export function colorVar(color: string, shade: number): string { */ export const MUTED_MARK = `${StatusColor.NEUTRAL}.5`; -/** The same color as a tint to sit content on, e.g. a callout's background. */ -export function statusBackground(color: StatusColor): string { - return `var(--mantine-color-${color}-light)`; -} - -/** A step off the page, for the bars framing it: the navbar's tab row, a - * modal's header and footer. */ -export const FRAME_BACKGROUND = - "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8))"; - /** * A surface for a render to sit on. Onshape renders a part light, on white, so * a white card leaves the thumbnail with no edge to it; light mode steps down @@ -94,15 +79,6 @@ export const RENDER_BACKGROUND = */ export const CONTROL_ICON_COLOR = "var(--mantine-color-gray-light-color)"; -/** - * Text reads as centred on its cap height, a pixel above its line box, so an - * icon centred on that box looks low. `text-box` clips descenders under truncation. - */ -export const TITLE_ICON_NUDGE = { transform: "translateY(-1px)" }; - -/** Holds an icon or badge at its own size beside text that can outgrow the row. */ -export const NO_SHRINK = { flexShrink: 0 }; - /** * Paints an image in the current text color rather than its own. The url needs * quoting: Vite inlines an asset as a data uri, which can contain apostrophes. @@ -127,18 +103,12 @@ export const INPUT_HEIGHT = "36px"; export const NAVBAR_ROW_HEIGHT = 48; /** - * A rule that has to read against {@link FRAME_BACKGROUND} rather than a white - * page, so it takes the same step off the frame in either theme. + * A rule that has to read against the navbar's frame rather than a white page, + * so it takes the same step off the frame in either theme. */ export const NAVBAR_DIVIDER_COLOR = "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))"; -/** - * One height for a section header, set rather than left to the content: an - * accordion is sized by its label, a group header by its menu button. - */ -export const SECTION_HEADER_HEIGHT = 48; - /** The app's primary color as a filled background. */ export enum PrimaryColor { /** The current library's color, e.g. green for FRCDesign. */ diff --git a/src/frontend/lib/styles.module.css b/src/frontend/lib/styles.module.css new file mode 100644 index 000000000..c4b794aa7 --- /dev/null +++ b/src/frontend/lib/styles.module.css @@ -0,0 +1,51 @@ +/* + * Styles more than one feature draws with. A value one component alone uses + * belongs in that component's own module; one handed to a Mantine prop (an + * icon size, a status color) stays in `style-constants.ts`. + */ + +/* The rule between the app's bands: a navbar, a section header, a modal's frame. */ +.dividerBottom { + border-bottom: 1px solid var(--mantine-color-default-border); +} + +.dividerTop { + border-top: 1px solid var(--mantine-color-default-border); +} + +/* A box of ours with its edge drawn, cut like the theme cuts every other. */ +.outlined { + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); +} + +/* A step off the page, for the bars framing it: a navbar, a modal's header and footer. */ +.frame { + background: light-dark( + var(--mantine-color-gray-2), + var(--mantine-color-dark-8) + ); +} + +/* + * One height for every section header, set rather than left to the content: an + * accordion is sized by its label, a group header by its menu button. It never + * shrinks — the list under it gives up its room, not the header naming it. + */ +.sectionHeader { + min-height: rem(48px); + flex-shrink: 0; +} + +/* Holds an icon or badge at its own size beside text that can outgrow the row. */ +.noShrink { + flex-shrink: 0; +} + +/* + * Text reads as centred on its cap height, a pixel above its line box, so an + * icon centred on that box looks low. + */ +.titleIcon { + transform: translateY(-1px); +} diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 930557f9c..70400c6e4 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -12,12 +12,7 @@ import { ArrowUUpLeftIcon, WarningIcon } from "@phosphor-icons/react"; -import { - BORDER, - IconSize, - SECTION_HEADER_HEIGHT, - StatusColor -} from "../../../../../lib/style-constants"; +import { IconSize, StatusColor } from "../../../../../lib/style-constants"; import { ReactNode } from "react"; import { SearchResults } from "../../../../../features/search/components/search-results"; import { InsertSource } from "@backend/features/analytics/usage"; @@ -41,6 +36,7 @@ import { useLibraryQuery } from "../../../../../features/library/queries"; import { useLibraryId } from "../../../../../lib/library"; import { updateUiState, useGetUiState } from "../../../../../lib/ui-state"; import { AppIcon } from "../../../../../components/app-icon"; +import styles from "../../../../../lib/styles.module.css"; export const Route = createFileRoute("/app/library/$libraryId/groups/$groupId")( { @@ -120,11 +116,9 @@ function GroupList(): ReactNode { its list wants and no more, capped at what the main region has left — which is what `min-height` allows it to shrink to. */} {content} @@ -145,7 +139,8 @@ function GroupHeaderRow(props: GroupHeaderRowProps): ReactNode { const header = ( void navigate({ to: "/app/library/$libraryId", @@ -153,14 +148,9 @@ function GroupHeaderRow(props: GroupHeaderRowProps): ReactNode { }) } px="md" - h={SECTION_HEADER_HEIGHT} - // Owned here, as an accordion control owns its own, so the row and - // its divider measure the same as a section header's. It is also - // the one part that does not shrink: the list below gives up its - // room, never the header naming it. - style={{ borderBottom: BORDER, flexShrink: 0 }} + display="flex" > - + } title={group.name} diff --git a/src/frontend/routes/app/library/$libraryId/index.module.css b/src/frontend/routes/app/library/$libraryId/index.module.css new file mode 100644 index 000000000..3523ac6a9 --- /dev/null +++ b/src/frontend/routes/app/library/$libraryId/index.module.css @@ -0,0 +1,16 @@ +/* + * Mantine brightens a control to pure white or black; a section header is a + * title like the group page's, so it reads in the same text color. + */ +.control { + color: var(--mantine-color-text); +} + +/* Its own padding would outgrow the header's height. */ +.label { + padding-block: 0; +} + +.content { + padding: 0; +} diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 3ac39849c..8b389b261 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -2,13 +2,7 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import { Accordion, Badge } from "@mantine/core"; import { AppTitle } from "../../../../components/app-title"; import { BooksIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; -import { - BORDER, - IconSize, - PrimaryColor, - SECTION_HEADER_HEIGHT, - TITLE_ICON_NUDGE -} from "../../../../lib/style-constants"; +import { IconSize, PrimaryColor } from "../../../../lib/style-constants"; import { ReactNode, useState } from "react"; import { GroupCard } from "../../../../features/library/components/group-card"; import { ItemTable } from "../../../../components/item-row"; @@ -30,6 +24,8 @@ import { } from "../../../../lib/library"; import { useGetUiState, updateUiState } from "../../../../lib/ui-state"; import { useVendorFilters } from "../../../../features/settings/components/vendor-filters"; +import styles from "../../../../lib/styles.module.css"; +import classes from "./index.module.css"; export const Route = createFileRoute("/app/library/$libraryId/")({ component: HomeList, @@ -122,20 +118,13 @@ function SectionAccordion(props: SectionAccordionProps): ReactNode { .filter((section) => section.opened) .map((section) => section.value)} onChange={handleChange} - styles={{ - // On the control, so a collapsed section still divides from - // the next one; content closes off an open one. - control: { - borderBottom: BORDER, - minHeight: SECTION_HEADER_HEIGHT, - // Mantine brightens a control to pure white or black; a section header is a title - // like the group page's, so it reads in the same text color. - color: "var(--mantine-color-text)" - }, - // Its own padding would outgrow that height. - label: { paddingBlock: 0 }, - content: { padding: 0, borderBottom: BORDER }, - icon: TITLE_ICON_NUDGE + // The divider is on the control, so a collapsed section still + // divides from the next one; content closes off an open one. + classNames={{ + control: `${classes.control} ${styles.sectionHeader} ${styles.dividerBottom}`, + label: classes.label, + content: `${classes.content} ${styles.dividerBottom}`, + icon: styles.titleIcon }} > {sections.map((section) => ( diff --git a/src/frontend/routes/dashboard/index.tsx b/src/frontend/routes/dashboard/index.tsx index b2c11caeb..99afcfc36 100644 --- a/src/frontend/routes/dashboard/index.tsx +++ b/src/frontend/routes/dashboard/index.tsx @@ -71,7 +71,7 @@ function DashboardOverview(): ReactNode {
- + diff --git a/src/frontend/routes/dashboard/library/$libraryId/part.tsx b/src/frontend/routes/dashboard/library/$libraryId/part.tsx index b937c4cec..c2892ee98 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/part.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/part.tsx @@ -66,7 +66,7 @@ function PartReport(): ReactNode { )} {/* Kept below the report so another part is always one click away. */} - + + {label} diff --git a/src/frontend/routes/dashboard/library/$libraryId/unused.tsx b/src/frontend/routes/dashboard/library/$libraryId/unused.tsx index 455fd6bd0..7b18ed1ca 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/unused.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/unused.tsx @@ -32,7 +32,7 @@ function LowUsage(): ReactNode { return ( - + Low-usage parts @@ -47,7 +47,7 @@ function LowUsage(): ReactNode { )} - + Low-usage configuration options diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index eb2f08776..b8196211b 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -1,4 +1,10 @@ -import { createTheme, type MantineColorsTuple } from "@mantine/core"; +import { + Card, + createTheme, + HoverCard, + type MantineColorsTuple, + Tooltip +} from "@mantine/core"; import { LibraryId } from "@backend/features/library/library-id"; import { FILLED_SHADE } from "./lib/style-constants"; @@ -52,6 +58,23 @@ export function createAppTheme(libraryId: string) { cursorType: "pointer", // Drops the class carrying Mantine's 1px press-down translate, which // nudged every button and icon button down on click. - activeClassName: "" + activeClassName: "", + // How every one of these is drawn here, so a call site names only + // what makes it different. + components: { + Tooltip: Tooltip.extend({ + defaultProps: { withArrow: true, multiline: true, maw: 260 } + }), + HoverCard: HoverCard.extend({ + defaultProps: { + withinPortal: true, + shadow: "md", + withArrow: true + } + }), + Card: Card.extend({ + defaultProps: { withBorder: true, padding: "lg", radius: "md" } + }) + } }); } From 20862822356a2f7c435fa4b0b1c7f764278eb7fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 17:16:35 +0000 Subject: [PATCH 06/88] Add component tests, and report an expression of the wrong kind A jsdom project runs *.test.tsx with Testing Library. The first tests drive the configuration panel (a typed expression is kept and shown evaluated, conditions show and hide rows, the record follows the selection) and the document links a favorite's menu builds. They caught a crash: an angle typed into a length box threw out of the range check instead of being reported. The parser now rejects a result whose kind is not the quantity's, with a message saying which kind it wanted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- AGENTS.md | 6 + package-lock.json | 686 ++++++++++++++++++ package.json | 4 + src/__test_utils__/dom-setup.ts | 33 + src/__test_utils__/render.tsx | 39 + .../configurations/input-parser.test.ts | 6 + .../features/configurations/input-parser.ts | 26 +- .../components/open-document-items.test.tsx | 51 ++ .../insert/components/configurations.test.tsx | 158 ++++ tsconfig.test.json | 1 + vitest.config.ts | 10 + 11 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 src/__test_utils__/dom-setup.ts create mode 100644 src/__test_utils__/render.tsx create mode 100644 src/frontend/components/open-document-items.test.tsx create mode 100644 src/frontend/features/insert/components/configurations.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 0d1625a2a..1aee9ad86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,12 @@ R2, KV, Workflows) is named `*.worker.test.ts` and runs in the Workers runtime against a freshly migrated D1; that setup costs far more than most tests, so everything else — pure backend logic and the frontend — runs in Node. +Component tests are `*.test.tsx` and run in jsdom (the `dom` project). +`renderWithProviders` in `__test_utils__/render.tsx` renders the way the app +does, with a query cache that never fetches: seed what a component reads with +`setQueryData`. Test what a person does and sees — type, click, read the +screen — rather than a component's internals. + # Running the app Onshape launches the app at `/init`, which needs a real Onshape session and diff --git a/package-lock.json b/package-lock.json index 760346b05..c9b8fe405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,9 @@ "@hey-api/openapi-ts": "^0.99.0", "@tanstack/react-router-devtools": "^1.166.13", "@tanstack/router-plugin": "^1.168.2", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/node": "^24.12.4", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", @@ -48,6 +51,7 @@ "eslint-plugin-react-x": "^5.7.8", "globals": "^17.5.0", "husky": "^9.1.7", + "jsdom": "^30.1.1", "lint-staged": "^17.0.5", "postcss": "^8.5.15", "postcss-preset-mantine": "^1.18.0", @@ -60,6 +64,59 @@ "wrangler": "^4.123.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -351,6 +408,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -694,6 +764,146 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -1868,6 +2078,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -3835,6 +4063,68 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -3846,6 +4136,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4429,6 +4726,19 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ansis": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", @@ -4457,6 +4767,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4503,6 +4823,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -4801,6 +5131,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4941,6 +5285,35 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4959,6 +5332,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -5022,6 +5402,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -5055,6 +5445,13 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -5710,6 +6107,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -6308,6 +6718,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/http-status-ts": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-status-ts/-/http-status-ts-2.0.1.tgz", @@ -6463,6 +6886,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -6534,6 +6964,66 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz", + "integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7113,6 +7603,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7123,6 +7623,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -7328,6 +7835,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7595,6 +8115,38 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7831,6 +8383,16 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -7955,6 +8517,19 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8305,6 +8880,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz", + "integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.14" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz", + "integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8754,6 +9375,29 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -8761,6 +9405,31 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8900,6 +9569,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 796549fee..3e49c2f33 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "@hey-api/openapi-ts": "^0.99.0", "@tanstack/react-router-devtools": "^1.166.13", "@tanstack/router-plugin": "^1.168.2", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/node": "^24.12.4", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", @@ -62,6 +65,7 @@ "eslint-plugin-react-x": "^5.7.8", "globals": "^17.5.0", "husky": "^9.1.7", + "jsdom": "^30.1.1", "lint-staged": "^17.0.5", "postcss": "^8.5.15", "postcss-preset-mantine": "^1.18.0", diff --git a/src/__test_utils__/dom-setup.ts b/src/__test_utils__/dom-setup.ts new file mode 100644 index 000000000..def0ed771 --- /dev/null +++ b/src/__test_utils__/dom-setup.ts @@ -0,0 +1,33 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// jsdom lacks what Mantine measures and positions with. +window.matchMedia ??= (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false + }) as MediaQueryList; + +const ignore = () => undefined; + +globalThis.ResizeObserver ??= class { + observe = ignore; + unobserve = ignore; + disconnect = ignore; +}; + +if (!("scrollIntoView" in Element.prototype)) { + Object.assign(Element.prototype, { scrollIntoView: ignore }); +} + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + window.sessionStorage.clear(); +}); diff --git a/src/__test_utils__/render.tsx b/src/__test_utils__/render.tsx new file mode 100644 index 000000000..c86570d01 --- /dev/null +++ b/src/__test_utils__/render.tsx @@ -0,0 +1,39 @@ +/** + * Renders a component the way the app does — themed, with a query cache — + * for the dom project's tests. The cache never refetches on its own, so a test + * seeds what a component reads rather than serving it over a network. + */ +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { createAppTheme } from "@frontend/theme"; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity } + } + }); +} + +export function renderWithProviders( + ui: ReactNode, + queryClient: QueryClient = createTestQueryClient() +) { + return { + queryClient, + ...render( + + {/* `test` turns off transitions and portals, which jsdom + cannot run, and which would hide a dropdown's options. */} + + {ui} + + + ) + }; +} diff --git a/src/backend/features/configurations/input-parser.test.ts b/src/backend/features/configurations/input-parser.test.ts index 700a28114..0a93c89e8 100644 --- a/src/backend/features/configurations/input-parser.test.ts +++ b/src/backend/features/configurations/input-parser.test.ts @@ -61,12 +61,18 @@ describe("evaluateExpression", () => { ["(5 mm) mm", "a unit is applied to something already dimensioned"], ["5 mm * 2 mm", "two units are multiplied"], ["5 mm / 2 mm", "two units are divided"], + // Once threw out of the range check instead of being reported. + ["2 deg", "an angle is no length"], ["-100.001 mm", "it falls below the minimum"], ["100.001 mm", "it rises above the maximum"] ])("rejects %s, since %s", (expression) => { expect(evaluateExpression(expression, LENGTH).hasError).toBe(true); }); + it("rejects a length where an angle is wanted", () => { + expect(evaluateExpression("2 mm", DEGREES).hasError).toBe(true); + }); + // `expression` is what the input redisplays and the menu stores, so it has // to be something this same parser still reads. it.each([ diff --git a/src/backend/features/configurations/input-parser.ts b/src/backend/features/configurations/input-parser.ts index a19c76a6a..953ac6678 100644 --- a/src/backend/features/configurations/input-parser.ts +++ b/src/backend/features/configurations/input-parser.ts @@ -564,6 +564,19 @@ function roundToPrecision(num: number, precision: number): string { return String(Math.round(num * factor) / factor); } +/** What a quantity type measures, and so the only kind a result may be. */ +function expectedType(quantityType: QuantityType): UnitType { + switch (quantityType) { + case QuantityType.LENGTH: + return "length"; + case QuantityType.ANGLE: + return "angle"; + case QuantityType.INTEGER: + case QuantityType.REAL: + return "number"; + } +} + function formatExpression( expr: Expr, value: ValueWithUnits, @@ -585,6 +598,17 @@ function formatExpression( expression = expression + " " + getUnitDisplayStr(displayUnit); } + // "2 deg" in a length parses, but it is no length; comparing it against + // the length bounds below would throw rather than report. + const expected = expectedType(quantityType); + if (value.type !== expected) { + return { + hasError: true, + expression, + errorMessage: `Expected ${expected === "number" ? "a number" : `a ${expected}`}` + }; + } + if (tolerantLessThan(value, options.min)) { return { hasError: true, @@ -685,7 +709,7 @@ export function evaluateBaseValue( ) { value = valueWithUnits(value.value, defaultUnit); } - return value; + return value.type === expectedType(quantityType) ? value : undefined; } export function evaluateExpression( diff --git a/src/frontend/components/open-document-items.test.tsx b/src/frontend/components/open-document-items.test.tsx new file mode 100644 index 000000000..a2e415fb1 --- /dev/null +++ b/src/frontend/components/open-document-items.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Menu } from "@mantine/core"; +import { type ElementPath } from "@backend/lib/onshape/path"; +import { renderWithProviders } from "../../__test_utils__/render"; +import { OpenDocumentItems } from "./open-document-items"; + +const PATH: ElementPath = { + documentId: "doc", + instanceId: "ver", + instanceType: "v", + elementId: "el" +}; + +async function copiedLink(selection?: Record) { + const user = userEvent.setup(); + const writeText = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(undefined); + renderWithProviders( + + + + + + + + + ); + await user.click(screen.getByText("Copy link")); + return decodeURIComponent(writeText.mock.calls[0][0]); +} + +describe("OpenDocumentItems", () => { + afterEach(() => vi.restoreAllMocks()); + + // A favorite's link used to open the part at its defaults. + it("links to the configuration it is given, as it was typed", async () => { + expect(await copiedLink({ size: "large", length: "(2 + 3) in" })).toBe( + "https://cad.onshape.com/documents/doc/v/ver/e/el" + + "?configuration=size=large;length=(2 + 3) in" + ); + }); + + it("links to the element itself without one", async () => { + expect(await copiedLink()).toBe( + "https://cad.onshape.com/documents/doc/v/ver/e/el" + ); + }); +}); diff --git a/src/frontend/features/insert/components/configurations.test.tsx b/src/frontend/features/insert/components/configurations.test.tsx new file mode 100644 index 000000000..e45926b05 --- /dev/null +++ b/src/frontend/features/insert/components/configurations.test.tsx @@ -0,0 +1,158 @@ +import { useState } from "react"; +import { describe, expect, it } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + type ConfigurationResult, + type PartialSelection, + type SearchRecord, + VisibilityType +} from "@backend/features/configurations/contract"; +import { + boolParam, + enumParam, + quantityParam +} from "../../../../__test_utils__/configuration-fixtures"; +import { + createTestQueryClient, + renderWithProviders +} from "../../../../__test_utils__/render"; +import { configurationQueryKey } from "../../../lib/query-keys"; +import { ConfigurationWrapper, type SelectionReport } from "./configurations"; + +const size = enumParam("size", ["small", "large"]); +const length = quantityParam("length"); +const reinforced = { + ...boolParam("reinforced"), + condition: { + type: VisibilityType.EQUAL as const, + id: "size", + value: "large" + } +}; + +function record(values: Record, partNumber: string) { + return { values, partNumber, configurationKey: "" } satisfies SearchRecord; +} + +interface HostProps { + initial: PartialSelection; + onReport: (report: SelectionReport) => void; +} + +/** Holds the panel's selection the way the insert menu does. */ +function Host(props: HostProps) { + const [selection, setSelection] = useState(props.initial); + return ( + + ); +} + +/** Mounts the panel over seeded parameters, collecting what it reports. */ +function renderPanel( + result: ConfigurationResult, + initial: PartialSelection = {} +) { + const reports: SelectionReport[] = []; + const queryClient = createTestQueryClient(); + queryClient.setQueryData(configurationQueryKey("i1", "mv1"), result); + renderWithProviders( + reports.push(report)} />, + queryClient + ); + return { lastReport: () => reports[reports.length - 1] }; +} + +describe("ConfigurationWrapper", () => { + // What was typed is what Onshape is sent, so that is what the panel keeps; + // the key it reports is canonical, for the thumbnail alone. + it("keeps a typed expression, and shows what it evaluates to", async () => { + const user = userEvent.setup(); + const { lastReport } = renderPanel({ + parameters: [length], + records: [] + }); + + const input = await screen.findByLabelText("length"); + await user.clear(input); + await user.type(input, "(2 + 3) in{Enter}"); + + expect(input).toHaveProperty("value", "5 in"); + expect(lastReport().overrides).toEqual({ length: "(2 + 3) in" }); + expect(lastReport().configurationKey).toBe("length=0.127%20m"); + + await user.click(input); + expect(input).toHaveProperty("value", "(2 + 3) in"); + }); + + it("reopens a stored expression as it was typed", async () => { + const user = userEvent.setup(); + renderPanel( + { parameters: [length], records: [] }, + { length: "(1 + 1) in" } + ); + + const input = await screen.findByLabelText("length"); + expect(input).toHaveProperty("value", "2 in"); + await user.click(input); + expect(input).toHaveProperty("value", "(1 + 1) in"); + }); + + it("keeps a bad expression out of the selection, and says why", async () => { + const user = userEvent.setup(); + const { lastReport } = renderPanel({ + parameters: [length], + records: [] + }); + + const input = await screen.findByLabelText("length"); + await user.clear(input); + await user.type(input, "2 deg{Enter}"); + + expect(await screen.findByText("Expected a length")).toBeTruthy(); + expect(lastReport().overrides).toEqual({}); + }); + + it("shows a parameter only while its condition holds", async () => { + const user = userEvent.setup(); + const { lastReport } = renderPanel({ + parameters: [size, reinforced], + records: [] + }); + + await screen.findByLabelText("size"); + expect(screen.queryByLabelText("reinforced")).toBeNull(); + + await user.click(screen.getByLabelText("size")); + await user.click(await screen.findByRole("option", { name: "large" })); + + expect(await screen.findByLabelText("reinforced")).toBeTruthy(); + expect(lastReport().overrides).toEqual({ size: "large" }); + }); + + // The header names the part on screen, so it follows the selection. + it("reports the record the selection produces", async () => { + const user = userEvent.setup(); + const { lastReport } = renderPanel({ + parameters: [size], + records: [ + record({}, "PN-SMALL"), + record({ size: "large" }, "PN-LARGE") + ] + }); + + await screen.findByLabelText("size"); + expect(lastReport().record?.partNumber).toBe("PN-SMALL"); + + await user.click(screen.getByLabelText("size")); + await user.click(await screen.findByRole("option", { name: "large" })); + + expect(lastReport().record?.partNumber).toBe("PN-LARGE"); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 0ebe625ff..0d43e2703 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -16,6 +16,7 @@ "types": [ "vitest/globals", "node", + "vite/client", "@cloudflare/vitest-pool-workers/types", "./worker-configuration.d.ts" ], diff --git a/vitest.config.ts b/vitest.config.ts index 7534f7385..2f9295a31 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,6 +28,16 @@ export default defineConfig({ exclude: [WORKER_TESTS] } }, + { + // Components, rendered into a DOM the way the app renders them. + resolve: { alias }, + test: { + name: "dom", + environment: "jsdom", + include: ["src/frontend/**/*.test.tsx"], + setupFiles: ["./src/__test_utils__/dom-setup.ts"] + } + }, { // Real, per-test isolated D1/R2/KV bindings from wrangler.jsonc. resolve: { alias }, From f30cbc5906a011e622849e5e3b9c78b8b3ee02cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 17:25:32 +0000 Subject: [PATCH 07/88] Keep more tests out of the Workers pool, and reset the database in one trip The probe tests needed the Workers runtime only because the module they test also held its workflow-step variant; that lives with its one caller now, and the limiter in lib/, so both test files run in Node. resetDb, which runs before nearly every worker test, deletes in one batch instead of sixteen round trips. Adds end-to-end checks that an insert hands Onshape the expression that was typed, in a derived feature and in an assembly's configuration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/__test_utils__/seed.ts | 37 ++++++------ .../library/insertables/routes.worker.test.ts | 59 ++++++++++++++++++- src/backend/features/load/context.ts | 31 +--------- .../features/load/load-group.worker.test.ts | 2 +- src/backend/features/load/load-insertable.ts | 32 +++++++++- .../load/load-insertable.worker.test.ts | 3 +- ...ts => parse-configuration-records.test.ts} | 0 .../load/parse-configuration-records.ts | 36 ++--------- .../limiter.test.ts} | 2 +- src/backend/lib/limiter.ts | 29 +++++++++ 10 files changed, 147 insertions(+), 84 deletions(-) rename src/backend/features/load/{parse-configuration-records.worker.test.ts => parse-configuration-records.test.ts} (100%) rename src/backend/{features/load/context.worker.test.ts => lib/limiter.test.ts} (96%) create mode 100644 src/backend/lib/limiter.ts diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 30d603575..6e999c578 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -70,23 +70,26 @@ export const TEST_PARAMETERS: ConfigurationParameter[] = [ * isolated per test *file*, so call this in `beforeEach` to isolate tests. */ export async function resetDb(db: Db): Promise { - await db.delete(favorites); - await db.delete(configurations); - await db.delete(insertables); - await db.delete(groups); - await db.delete(users); - await db.delete(libraries); - // Analytics has no foreign keys, so nothing cascades these away. - await db.delete(events); - await db.delete(dailyMetrics); - await db.delete(dailySourceMetrics); - await db.delete(dailyTargetMetrics); - await db.delete(dailyUserActivity); - await db.delete(insertableStats); - await db.delete(dailyInsertableMetrics); - await db.delete(dailyInsertableUsers); - await db.delete(dailyConfigurationMetrics); - await db.delete(userStats); + // One batch, one round trip: this runs before nearly every test. + await db.batch([ + db.delete(favorites), + db.delete(configurations), + db.delete(insertables), + db.delete(groups), + db.delete(users), + db.delete(libraries), + // Analytics has no foreign keys, so nothing cascades these away. + db.delete(events), + db.delete(dailyMetrics), + db.delete(dailySourceMetrics), + db.delete(dailyTargetMetrics), + db.delete(dailyUserActivity), + db.delete(insertableStats), + db.delete(dailyInsertableMetrics), + db.delete(dailyInsertableUsers), + db.delete(dailyConfigurationMetrics), + db.delete(userStats) + ]); } export async function seedLibrary( diff --git a/src/backend/features/library/insertables/routes.worker.test.ts b/src/backend/features/library/insertables/routes.worker.test.ts index c58d9754f..83f616ded 100644 --- a/src/backend/features/library/insertables/routes.worker.test.ts +++ b/src/backend/features/library/insertables/routes.worker.test.ts @@ -28,7 +28,10 @@ import * as AssemblyEndpoints from "../../../lib/onshape/endpoints/assemblies"; import * as PartsEndpoints from "../../../lib/onshape/endpoints/parts"; import { OnshapeRateLimitError } from "../../../lib/onshape/client"; import { AUTO_INDEX_THRESHOLD } from "../../configurations/combinations"; -import { enumParam } from "../../../../__test_utils__/configuration-fixtures"; +import { + enumParam, + quantityParam +} from "../../../../__test_utils__/configuration-fixtures"; const db = getDb(env.DB); @@ -164,6 +167,34 @@ describe("insertable routes", () => { }); }); + // The feature dialog shows this expression, so it is the one that was typed + // rather than the number it evaluates to, in whatever unit. + it("POST /add-to-part-studio derives with the expression that was typed", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + await db + .update(configurations) + .set({ parameters: [quantityParam("length")] }) + .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); + const spy = vi + .spyOn(PartStudioEndpoints, "addPartStudioFeature") + .mockResolvedValue({ feature: { featureId: "feat-1" } }); + + const res = await createTestApp().request( + `/api/add-to-part-studio/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { + targetPath, + selection: { length: "(2 + 3) in" } + }), + env + ); + expect(res.status).toBe(200); + + expect(JSON.stringify(spy.mock.calls[0][2])).toContain( + '"expression":"(2 + 3) in"' + ); + }); + // A half-built target used to reach Onshape as a nonsense URL and fail // opaquely; the boundary rejects it instead. it.each([ @@ -345,6 +376,32 @@ describe("insertable routes", () => { } ); + it("POST /add-to-assembly sends a quantity as the expression typed", async () => { + await seedAssembly(db); + await seedConfiguration(db, TEST_ASSEMBLY_ID); + await db + .update(configurations) + .set({ parameters: [quantityParam("length")] }) + .where(eq(configurations.insertableId, TEST_ASSEMBLY_ID)); + const spy = vi + .spyOn(AssemblyEndpoints, "addElementToAssembly") + .mockResolvedValue({}); + + await createTestApp().request( + `/api/add-to-assembly/insertable/${TEST_ASSEMBLY_ID}`, + jsonRequest("POST", { + targetPath, + selection: { length: "(2 + 3) in" }, + fasten: false + }), + env + ); + + expect(spy.mock.calls[0][4].configuration).toBe( + "length=(2%20%2B%203)%20in" + ); + }); + /** An assembly that supports insert-and-fasten, and a landed insert to fasten. */ async function seedFastenable() { await seedAssembly(db); diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index c45805ab6..ca6055a3d 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -1,4 +1,5 @@ import type { WorkflowStep } from "cloudflare:workers"; +import { createLimiter, type Limiter } from "../../lib/limiter"; import type { AppBindings } from "../../lib/context"; import { getOnshapeApiFromSessionId } from "../auth/request-auth"; import type { OnshapeApi } from "../../lib/onshape/client"; @@ -21,36 +22,6 @@ import type { ElementPath, InstancePath } from "../../lib/onshape/path"; */ export const LOAD_CONCURRENCY = 15; -/** Runs a task, waiting for a slot when the limiter is full. */ -type Limiter = (task: () => Promise) => Promise; - -/** - * Runs at most `max` tasks at once, queueing the rest in call order, so a - * rate-limit burst only hits the running few. - */ -export function createLimiter(max: number): Limiter { - let active = 0; - const queue: (() => void)[] = []; - - const release = () => { - active--; - const next = queue.shift(); - if (next) next(); - }; - - return async (task: () => Promise): Promise => { - if (active >= max) { - await new Promise((resolve) => queue.push(resolve)); - } - active++; - try { - return await task(); - } finally { - release(); - } - }; -} - /** The runtime plumbing a load runs against. */ export interface LoadContext { env: AppBindings; diff --git a/src/backend/features/load/load-group.worker.test.ts b/src/backend/features/load/load-group.worker.test.ts index 33abb378b..8e0f3c812 100644 --- a/src/backend/features/load/load-group.worker.test.ts +++ b/src/backend/features/load/load-group.worker.test.ts @@ -24,10 +24,10 @@ import { } from "./load-group"; import { LOAD_CONCURRENCY, - createLimiter, type GroupTarget, type LoadContext } from "./context"; +import { createLimiter } from "../../lib/limiter"; import * as LoadCommonModule from "./context"; import { FAKE_STEP, diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index 304da9445..9474e4038 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -3,7 +3,8 @@ import { type Db, getDb } from "../../db/client"; import { type Configuration, type PartMetadata, - type ConfigurationParameter + type ConfigurationParameter, + type PartialSelection } from "../configurations/contract"; import { addBuildIssue, @@ -27,7 +28,9 @@ import { NO_RECORDS, computeOpenComposite, decideIndexing, - loadConfigurationRecords + indexRecords, + type ConfigurationRecordsResult, + type ProbeTarget } from "./parse-configuration-records"; import { type InsertableTarget, @@ -335,3 +338,28 @@ export async function saveInsertable( await db.batch([insertableWrite, configurationWrite]); } + +/** + * One durable step per batch. An exhausted batch throws rather than saving a + * half-built list. + */ +function loadConfigurationRecords( + ctx: LoadContext, + insertableId: string, + target: ProbeTarget, + parameters: ConfigurationParameter[], + configurations: PartialSelection[] +): Promise { + return indexRecords( + () => getOnshapeApiFromContext(ctx), + (name, read) => + ctx.step.do( + `records-${insertableId}-${name}`, + { retries: ONSHAPE_STEP_RETRIES }, + read + ), + target, + parameters, + configurations + ); +} diff --git a/src/backend/features/load/load-insertable.worker.test.ts b/src/backend/features/load/load-insertable.worker.test.ts index b3cc9bd56..9ca5eefa3 100644 --- a/src/backend/features/load/load-insertable.worker.test.ts +++ b/src/backend/features/load/load-insertable.worker.test.ts @@ -22,7 +22,8 @@ import * as ConfigurationEndpoints from "../../lib/onshape/endpoints/configurati import * as PartsEndpoints from "../../lib/onshape/endpoints/parts"; import * as ThumbnailStore from "../thumbnails/store"; import { BuildIssueType } from "../build-checker/issues"; -import { createLimiter, LOAD_CONCURRENCY, type LoadContext } from "./context"; +import { LOAD_CONCURRENCY, type LoadContext } from "./context"; +import { createLimiter } from "../../lib/limiter"; import * as LoadContextModule from "./context"; import { loadInsertable, saveInsertable } from "./load-insertable"; diff --git a/src/backend/features/load/parse-configuration-records.worker.test.ts b/src/backend/features/load/parse-configuration-records.test.ts similarity index 100% rename from src/backend/features/load/parse-configuration-records.worker.test.ts rename to src/backend/features/load/parse-configuration-records.test.ts diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index 31dc602fe..83ff24765 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -30,8 +30,6 @@ import type { OnshapeMetadataObject, OnshapePart } from "../../lib/onshape/types"; -import { type LoadContext, getOnshapeApiFromContext } from "./context"; -import { ONSHAPE_STEP_RETRIES } from "./steps"; import { clean } from "../../lib/text"; /** Configurations fetched per workflow step. */ @@ -225,17 +223,18 @@ export interface ProbeTarget { } /** - * How one Onshape read is run. A request awaits it directly; the workflow wraps - * each in a durable step, so a rate-limited retry re-fetches only that batch. + * How one Onshape read is run. A request awaits it directly; a load wraps each + * in a durable step (`loadConfigurationRecords`), so a rate-limited retry + * re-fetches only that batch. * The client is fetched per read rather than held, since a step that retries * hours later needs a token that has not expired. */ -type ProbeRunner = ( +export type ProbeRunner = ( name: string, read: () => Promise ) => Promise; -async function indexRecords( +export async function indexRecords( getClient: () => Promise, run: ProbeRunner, target: ProbeTarget, @@ -276,31 +275,6 @@ export function parseConfigurationRecords( ); } -/** - * One durable step per batch. An exhausted batch throws rather than saving a - * half-built list. - */ -export function loadConfigurationRecords( - ctx: LoadContext, - insertableId: string, - target: ProbeTarget, - parameters: ConfigurationParameter[], - configurations: PartialSelection[] -): Promise { - return indexRecords( - () => getOnshapeApiFromContext(ctx), - (name, read) => - ctx.step.do( - `records-${insertableId}-${name}`, - { retries: ONSHAPE_STEP_RETRIES }, - read - ), - target, - parameters, - configurations - ); -} - /** * Splits the combinations to fetch into batches, minus anything the separate * default probe already covers. diff --git a/src/backend/features/load/context.worker.test.ts b/src/backend/lib/limiter.test.ts similarity index 96% rename from src/backend/features/load/context.worker.test.ts rename to src/backend/lib/limiter.test.ts index de97b5c46..8999e45b7 100644 --- a/src/backend/features/load/context.worker.test.ts +++ b/src/backend/lib/limiter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createLimiter } from "./context"; +import { createLimiter } from "./limiter"; describe("createLimiter", () => { it("never runs more than `max` tasks at once", async () => { diff --git a/src/backend/lib/limiter.ts b/src/backend/lib/limiter.ts new file mode 100644 index 000000000..406411d53 --- /dev/null +++ b/src/backend/lib/limiter.ts @@ -0,0 +1,29 @@ +/** Runs a task, waiting for a slot when the limiter is full. */ +export type Limiter = (task: () => Promise) => Promise; + +/** + * Runs at most `max` tasks at once, queueing the rest in call order, so a + * rate-limit burst only hits the running few. + */ +export function createLimiter(max: number): Limiter { + let active = 0; + const queue: (() => void)[] = []; + + const release = () => { + active--; + const next = queue.shift(); + if (next) next(); + }; + + return async (task: () => Promise): Promise => { + if (active >= max) { + await new Promise((resolve) => queue.push(resolve)); + } + active++; + try { + return await task(); + } finally { + release(); + } + }; +} From 131f1aee94c5f7387f21fd3d2c5a18539b5e9643 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 17:27:55 +0000 Subject: [PATCH 08/88] Allow for the options an assembly insert may omit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/features/library/insertables/routes.worker.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/features/library/insertables/routes.worker.test.ts b/src/backend/features/library/insertables/routes.worker.test.ts index 83f616ded..2aee6e818 100644 --- a/src/backend/features/library/insertables/routes.worker.test.ts +++ b/src/backend/features/library/insertables/routes.worker.test.ts @@ -397,7 +397,7 @@ describe("insertable routes", () => { env ); - expect(spy.mock.calls[0][4].configuration).toBe( + expect(spy.mock.calls[0][4]?.configuration).toBe( "length=(2%20%2B%203)%20in" ); }); From 6adf9346c21bb631e7fead148b093ba764e57248 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:03:28 +0000 Subject: [PATCH 09/88] Read thumbnails from a workspace branched off the version Onshape sometimes never renders an element's thumbnail in a version, and the document's own workspace, which the load fell back to, drifts from the version the library shows. A load now finds or branches a workspace off the version (named by it, so a retried step or forced reload reuses it), reads every thumbnail from there with no fallback, and stores its id on the group beside the version. Configured renders and the on-demand reload read from the same branch; a group no load has branched yet falls back to the version for renders, and a reload branches one. A fresh branch takes minutes to render, so thumbnail steps retry for about a quarter of an hour, under a limiter of their own so the wait never holds a probe's slot. Once a group moves to a new version, the branches of older versions are deleted, best effort. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 2 + drizzle/0004_thumbnail_workspace.sql | 1 + drizzle/meta/0004_snapshot.json | 1187 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/__test_utils__/insertable-fixtures.ts | 3 +- src/backend/db/schema.ts | 3 + src/backend/features/load/context.ts | 36 +- src/backend/features/load/load-group.ts | 59 +- .../features/load/load-group.worker.test.ts | 77 +- src/backend/features/load/load-insertable.ts | 9 +- .../load/load-insertable.worker.test.ts | 3 +- src/backend/features/load/steps.ts | 28 +- src/backend/features/load/workflows.ts | 11 - src/backend/features/thumbnails/reload.ts | 117 +- .../features/thumbnails/reload.worker.test.ts | 67 +- src/backend/features/thumbnails/render.ts | 30 +- .../features/thumbnails/routes.worker.test.ts | 22 + src/backend/features/thumbnails/store.ts | 40 +- src/backend/features/thumbnails/workspace.ts | 77 ++ .../lib/onshape/endpoints/workspaces.ts | 43 + src/backend/lib/onshape/types.ts | 6 + 21 files changed, 1641 insertions(+), 187 deletions(-) create mode 100644 drizzle/0004_thumbnail_workspace.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/backend/features/thumbnails/workspace.ts create mode 100644 src/backend/lib/onshape/endpoints/workspaces.ts diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index fc55b65d6..3942f2b8e 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -50,6 +50,8 @@ R2 is Cloudflare's blob storage, optimized for unstructured data like images and Onshape can generate preview thumbnails for parts and assemblies, but fetching them from Onshape on every page load would be slow and eat into API rate limits — a single render can require polling and take minutes. Instead, every thumbnail we ever fetch from Onshape lands in R2 and is served from there afterwards. +Every thumbnail is read from a **workspace branched off the version** the library shows, not from the version itself — Onshape sometimes never renders an element's thumbnail in a version — and not from the document's own workspace, which moves on from that version. A load finds or creates the branch by name (`FRCDesignApp thumbnails `, in `features/thumbnails/workspace.ts`), stores its id on the group row beside the version, and deletes the branches of older versions once the group has moved on. A fresh branch has no thumbnails for a few minutes, so a load's thumbnail steps retry for about a quarter of an hour, under a limiter of their own so the wait never holds up probing. + Thumbnails are keyed by whether they are the element's default or a specific configuration: ``` diff --git a/drizzle/0004_thumbnail_workspace.sql b/drizzle/0004_thumbnail_workspace.sql new file mode 100644 index 000000000..de79a1416 --- /dev/null +++ b/drizzle/0004_thumbnail_workspace.sql @@ -0,0 +1 @@ +ALTER TABLE `groups` ADD `thumbnail_workspace_id` text; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..9d34ce3cc --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1187 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "eccc3c71-d808-4e4c-9ece-58a9ad2b81ff", + "prevId": "d8945a24-5643-4675-bf1f-b32cff1dbea2", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_insertable_id_insertables_id_fk": { + "name": "configurations_insertable_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_selection": { + "name": "default_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "favorites_user_id_library_id_insertable_id_unique": { + "name": "favorites_user_id_library_id_insertable_id_unique", + "columns": ["user_id", "library_id", "insertable_id"], + "isUnique": true + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_library_id_libraries_id_fk": { + "name": "favorites_library_id_libraries_id_fk", + "tableFrom": "favorites", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_insertable_id_insertables_id_fk": { + "name": "favorites_insertable_id_insertables_id_fk", + "tableFrom": "favorites", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_workspace_id": { + "name": "thumbnail_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_alphabetically": { + "name": "sort_alphabetically", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groups_document_id_library_id_unique": { + "name": "groups_document_id_library_id_unique", + "columns": ["document_id", "library_id"], + "isUnique": true + } + }, + "foreignKeys": { + "groups_library_id_libraries_id_fk": { + "name": "groups_library_id_libraries_id_fk", + "tableFrom": "groups", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertables": { + "name": "insertables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_type": { + "name": "element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "microversion_id": { + "name": "microversion_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_open_composite": { + "name": "is_open_composite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "supports_fasten": { + "name": "supports_fasten", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vendors": { + "name": "vendors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_metadata": { + "name": "part_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "insertables_group_id_groups_id_fk": { + "name": "insertables_group_id_groups_id_fk", + "tableFrom": "insertables", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "insertables_library_id_libraries_id_fk": { + "name": "insertables_library_id_libraries_id_fk", + "tableFrom": "insertables", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "libraries": { + "name": "libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "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'" + }, + "tab_id": { + "name": "tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_library_id_libraries_id_fk": { + "name": "users_library_id_libraries_id_fk", + "tableFrom": "users", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_configuration_metrics": { + "name": "daily_configuration_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parameter_id": { + "name": "parameter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_configuration_metrics_day_idx": { + "name": "daily_configuration_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { + "columns": [ + "library_id", + "element_id", + "parameter_id", + "value", + "day" + ], + "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_metrics": { + "name": "daily_insertable_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_insertable_metrics_day_idx": { + "name": "daily_insertable_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { + "columns": [ + "library_id", + "element_id", + "day", + "target_element_type" + ], + "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_users": { + "name": "daily_insertable_users", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_users_library_id_element_id_day_user_id_pk": { + "columns": ["library_id", "element_id", "day", "user_id"], + "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_metrics": { + "name": "daily_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fasten_count": { + "name": "fasten_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_metrics_day_library_id_type_pk": { + "columns": ["day", "library_id", "type"], + "name": "daily_metrics_day_library_id_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_source_metrics": { + "name": "daily_source_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_source_metrics_day_library_id_source_pk": { + "columns": ["day", "library_id", "source"], + "name": "daily_source_metrics_day_library_id_source_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_target_metrics": { + "name": "daily_target_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_target_metrics_day_library_id_target_element_type_pk": { + "columns": ["day", "library_id", "target_element_type"], + "name": "daily_target_metrics_day_library_id_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_user_activity": { + "name": "daily_user_activity", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "daily_user_activity_day_idx": { + "name": "daily_user_activity_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_user_activity_day_library_id_user_id_pk": { + "columns": ["day", "library_id", "user_id"], + "name": "daily_user_activity_day_library_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_quick_insert": { + "name": "is_quick_insert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten": { + "name": "fasten", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "events_day_idx": { + "name": "events_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertable_stats": { + "name": "insertable_stats", + "columns": { + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_inserted_at": { + "name": "first_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_inserted_at": { + "name": "last_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "insertable_stats_count_idx": { + "name": "insertable_stats_count_idx", + "columns": ["library_id", "insert_count"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "insertable_stats_library_id_element_id_pk": { + "columns": ["library_id", "element_id"], + "name": "insertable_stats_library_id_element_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_stats_user_id_library_id_pk": { + "columns": ["user_id", "library_id"], + "name": "user_stats_user_id_library_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 43b34b3a1..d48a9bb57 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1790053337692, "tag": "0003_user_tab", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1790186214251, + "tag": "0004_thumbnail_workspace", + "breakpoints": true } ] } diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index 262bd5291..c2e9f1cb6 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -22,8 +22,9 @@ export function insertableTarget( libraryId: TEST_LIBRARY_ID, groupId: TEST_GROUP_ID, elementPath: TEST_PART_STUDIO_PATH, - elementWorkspacePath: { + thumbnailPath: { ...TEST_PART_STUDIO_PATH, + instanceId: "w-thumbnails", instanceType: "w" }, versionCreatedAt: TEST_VERSION_CREATED_AT, diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 1a441c2e9..8174a477b 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -92,6 +92,9 @@ export const groups = sqliteTable( documentId: text("document_id").notNull(), versionId: text("version_id").notNull(), versionCreatedAt: versionCreatedAt(), + // Branched off `versionId` to read its thumbnails from; see + // `thumbnails/workspace.ts`. Null until a load has made one. + thumbnailWorkspaceId: text("thumbnail_workspace_id"), sortAlphabetically: integer("sort_alphabetically", { mode: "boolean" }) .notNull() .default(false), diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index ca6055a3d..9f38cc642 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -22,6 +22,14 @@ import type { ElementPath, InstancePath } from "../../lib/onshape/path"; */ export const LOAD_CONCURRENCY = 15; +/** + * How many thumbnails a load waits on at once. Kept apart from probing: a + * thumbnail in a freshly branched workspace can take minutes to appear, and a + * step waiting that out would otherwise hold a probe's slot the whole time. + * Waiting costs no calls, so this bounds only the bursts between waits. + */ +export const THUMBNAIL_CONCURRENCY = 10; + /** The runtime plumbing a load runs against. */ export interface LoadContext { env: AppBindings; @@ -29,6 +37,8 @@ export interface LoadContext { step: WorkflowStep; /** Bounds concurrent Onshape probing across the whole run. */ limit: Limiter; + /** Bounds concurrent thumbnail reads, apart from probing. */ + thumbnailLimit: Limiter; } export function createLoadContext( @@ -40,7 +50,8 @@ export function createLoadContext( env, sessionId, step, - limit: createLimiter(LOAD_CONCURRENCY) + limit: createLimiter(LOAD_CONCURRENCY), + thumbnailLimit: createLimiter(THUMBNAIL_CONCURRENCY) }; } @@ -57,25 +68,26 @@ export interface GroupTarget { versionPath: InstancePath; /** When Onshape cut `versionPath`'s version. */ versionCreatedAt: Date; - /** - * The document's default workspace. Everything the library shows is pinned - * to the version; this is only where thumbnails are read from, because the - * version form of that endpoint does not reliably return them. - */ - workspacePath: InstancePath; name: string; /** The tab the document renders its thumbnail from, when one is set. */ thumbnailElementId?: string; } +/** A group being loaded, which is when it gets somewhere to read thumbnails. */ +export interface LoadingGroup extends GroupTarget { + /** + * The workspace branched off the version for reading thumbnails, which + * the version form of that endpoint does not reliably return; see + * `thumbnails/workspace.ts`. + */ + thumbnailPath: InstancePath; +} + /** An insertable a load reads, and what the document's tab listing told us. */ export interface InsertableTarget { insertableId: string; - /** - * The same tab in the document's workspace, which its thumbnail falls back - * to when the version will not answer. - */ - elementWorkspacePath: ElementPath; + /** The same tab in the version's thumbnail workspace. */ + thumbnailPath: ElementPath; libraryId: LibraryId; groupId: string; elementPath: ElementPath; diff --git a/src/backend/features/load/load-group.ts b/src/backend/features/load/load-group.ts index 4dcbf092a..833df76d4 100644 --- a/src/backend/features/load/load-group.ts +++ b/src/backend/features/load/load-group.ts @@ -24,8 +24,13 @@ import { type GroupTarget, type InsertableTarget, type LoadContext, + type LoadingGroup, getOnshapeApiFromContext } from "./context"; +import { + deleteStaleThumbnailWorkspaces, + ensureThumbnailWorkspace +} from "../thumbnails/workspace"; import { ONSHAPE_STEP_RETRIES, uploadThumbnailsStep } from "./steps"; interface GroupLoadResult { @@ -46,14 +51,28 @@ interface ParsedGroup { versionId?: string; /** Moves with `versionId`, so the row's date is always that version's. */ versionCreatedAt?: Date; + /** Moves with `versionId` too: it is that version's branch. */ + thumbnailWorkspaceId?: string; } export async function loadGroup( ctx: LoadContext, - target: GroupTarget, + group: GroupTarget, forceReload: boolean ): Promise { - const { groupId, versionPath } = target; + const { groupId, versionPath } = group; + + // Only for a group that loads: a skipped one keeps the branch it has. + const thumbnailPath = await ctx.step.do( + `thumbnail-workspace-${groupId}`, + { retries: ONSHAPE_STEP_RETRIES }, + async () => + ensureThumbnailWorkspace( + await getOnshapeApiFromContext(ctx), + versionPath + ) + ); + const target: LoadingGroup = { ...group, thumbnailPath }; // Read once and derive from it: the loadable tabs, and the element the // group's own thumbnail comes from, which is often not one of them. @@ -105,6 +124,25 @@ export async function loadGroup( }) ); + // Once the row has moved to this version, nothing reads the branches of + // older ones. Never fatal: a leftover branch costs nothing but clutter. + if (failedInsertableIds.length === 0) { + await ctx.step + .do(`delete-stale-workspaces-${groupId}`, async () => + deleteStaleThumbnailWorkspaces( + await getOnshapeApiFromContext(ctx), + versionPath, + thumbnailPath.instanceId + ) + ) + .catch((error: unknown) => { + console.error( + `Failed to delete stale thumbnail workspaces of ${groupId}`, + error + ); + }); + } + return { loadedElements: insertablesToLoad.length - failedInsertableIds.length, deletedElements: removedInsertableIds.length, @@ -146,10 +184,10 @@ async function loadInsertables( */ async function loadDocumentThumbnail( ctx: LoadContext, - target: GroupTarget, + target: LoadingGroup, contents: OnshapeDocumentContents ): Promise { - const { groupId, versionPath, workspacePath } = target; + const { groupId, thumbnailPath } = target; // Never fatal: `checkGroup` already flags a missing thumbnail, and failing // the load over a cosmetic one would lose the group's insertables. @@ -165,8 +203,7 @@ async function loadDocumentThumbnail( uploadThumbnails( ctx.env.BLOB, await getOnshapeApiFromContext(ctx), - { ...versionPath, elementId: element.id }, - { ...workspacePath, elementId: element.id }, + { ...thumbnailPath, elementId: element.id }, element.microversionId ) ); @@ -204,7 +241,7 @@ interface SaveGroupInput { */ async function saveGroup( db: Db, - target: GroupTarget, + target: LoadingGroup, input: SaveGroupInput ): Promise { const { thumbnailUrls, removedInsertableIds } = input; @@ -227,6 +264,7 @@ async function saveGroup( if (!hasFailedInsertables) { parsed.versionId = target.versionPath.instanceId; parsed.versionCreatedAt = target.versionCreatedAt; + parsed.thumbnailWorkspaceId = target.thumbnailPath.instanceId; } const writes: BatchItem<"sqlite">[] = [ @@ -339,7 +377,7 @@ async function fetchStoredInsertables( * transient Onshape failure flagged until someone forced a reload. */ export function selectInsertablesToLoad( - target: GroupTarget, + target: LoadingGroup, insertableTabs: OnshapeElement[], stored: StoredInsertable[], forceReload: boolean @@ -366,10 +404,7 @@ export function selectInsertablesToLoad( groupId: target.groupId, elementPath: { ...target.versionPath, elementId: tab.id }, versionCreatedAt: target.versionCreatedAt, - elementWorkspacePath: { - ...target.workspacePath, - elementId: tab.id - }, + thumbnailPath: { ...target.thumbnailPath, elementId: tab.id }, // OnshapeElementType and the app ElementType share these values. elementType: tab.elementType as unknown as ElementType, name: tab.name, diff --git a/src/backend/features/load/load-group.worker.test.ts b/src/backend/features/load/load-group.worker.test.ts index 8e0f3c812..70abd138e 100644 --- a/src/backend/features/load/load-group.worker.test.ts +++ b/src/backend/features/load/load-group.worker.test.ts @@ -25,8 +25,10 @@ import { import { LOAD_CONCURRENCY, type GroupTarget, - type LoadContext + type LoadContext, + type LoadingGroup } from "./context"; +import * as WorkspaceEndpoints from "../../lib/onshape/endpoints/workspaces"; import { createLimiter } from "../../lib/limiter"; import * as LoadCommonModule from "./context"; import { @@ -40,7 +42,7 @@ import { TEST_VERSION_CREATED_AT } from "../../../__test_utils__"; -const GROUP: GroupTarget = { +const GROUP: LoadingGroup = { libraryId: TEST_LIBRARY_ID, groupId: "group-1", name: "Group", @@ -50,7 +52,7 @@ const GROUP: GroupTarget = { instanceType: "v" }, versionCreatedAt: TEST_VERSION_CREATED_AT, - workspacePath: { + thumbnailPath: { documentId: "doc-1", instanceId: "w-1", instanceType: "w" @@ -238,19 +240,18 @@ const LOADED_TARGET: GroupTarget = { instanceId: "v-2", instanceType: "v" }, - versionCreatedAt: LOADED_VERSION_CREATED_AT, - workspacePath: { - documentId: `doc-${TEST_GROUP_ID}`, - instanceId: "w-2", - instanceType: "w" - } + versionCreatedAt: LOADED_VERSION_CREATED_AT }; +/** What Onshape answers when asked to branch the loaded version. */ +const BRANCH = { id: "w-branch", name: "FRCDesignApp thumbnails v-2" }; + const CTX: LoadContext = { env, sessionId: "test-session", step: FAKE_STEP, - limit: createLimiter(LOAD_CONCURRENCY) + limit: createLimiter(LOAD_CONCURRENCY), + thumbnailLimit: createLimiter(LOAD_CONCURRENCY) }; /** Serves the given tabs as the document's contents, all in one folder. */ @@ -295,6 +296,11 @@ describe("loadGroup", () => { ).mockResolvedValue(MOCK_ONSHAPE_API); // Every part-studio load probes its parts for the open-composite flag. vi.spyOn(PartsEndpoints, "getParts").mockResolvedValue([]); + // No branch yet, so the load makes one; nothing stale to delete. + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([]); + vi.spyOn(WorkspaceEndpoints, "createWorkspace").mockResolvedValue( + BRANCH + ); }); afterEach(() => vi.restoreAllMocks()); @@ -326,9 +332,9 @@ describe("loadGroup", () => { } }); - // Every thumbnail gets both: the version is asked first because that is - // what the library shows, and the workspace is where it falls back to. - it("gives every tab's thumbnail a workspace to fall back to", async () => { + // Onshape sometimes never renders a thumbnail in a version, and the + // document's own workspace drifts from it; a branch of it does neither. + it("reads every thumbnail from a workspace branched off the version", async () => { mockContents([tab("e1"), tab("e2")]); vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( NO_CONFIGURATION @@ -343,14 +349,47 @@ describe("loadGroup", () => { await loadGroup(CTX, LOADED_TARGET, false); - // (bucket, api, elementPath, elementWorkspacePath, microversionId) + expect(WorkspaceEndpoints.createWorkspace).toHaveBeenCalledWith( + MOCK_ONSHAPE_API, + LOADED_TARGET.versionPath, + expect.objectContaining({ versionId: "v-2" }) + ); + // (bucket, api, thumbnailPath, microversionId) for (const elementId of ["e1", "e2"]) { const call = uploaded.mock.calls.find( (args) => args[2].elementId === elementId ); - expect(call?.[2]).toMatchObject({ instanceType: "v", elementId }); - expect(call?.[3]).toMatchObject({ instanceType: "w", elementId }); + expect(call?.[2]).toEqual({ + documentId: `doc-${TEST_GROUP_ID}`, + instanceId: BRANCH.id, + instanceType: "w", + elementId + }); } + // Stored beside the version, for renders and reloads to read from. + expect((await readGroup())?.thumbnailWorkspaceId).toBe(BRANCH.id); + }); + + // A retried step or a forced reload finds the branch by name rather than + // making another; the branches of older versions are cleared away. + it("reuses the version's branch, and deletes older versions' branches", async () => { + mockContents([tab("e1")]); + vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( + NO_CONFIGURATION + ); + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([ + { id: "main", name: "Main" }, + { id: "w-old", name: "FRCDesignApp thumbnails v-1" }, + BRANCH + ]); + const deleted = vi + .spyOn(WorkspaceEndpoints, "deleteWorkspace") + .mockResolvedValue(); + + await loadGroup(CTX, LOADED_TARGET, false); + + expect(WorkspaceEndpoints.createWorkspace).not.toHaveBeenCalled(); + expect(deleted.mock.calls.map((call) => call[2])).toEqual(["w-old"]); }); // The element a group's thumbnail comes from is often not a loadable tab, @@ -375,11 +414,7 @@ describe("loadGroup", () => { const groupCall = uploaded.mock.calls.find( (args) => args[2].elementId === "cover" ); - expect(groupCall?.[2]).toMatchObject({ instanceType: "v" }); - expect(groupCall?.[3]).toMatchObject({ - instanceType: "w", - elementId: "cover" - }); + expect(groupCall?.[2]).toMatchObject({ instanceId: BRANCH.id }); expect(document).not.toHaveBeenCalled(); }); diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index 9474e4038..c9df1b481 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -82,16 +82,12 @@ export async function loadInsertable( ctx: LoadContext, target: InsertableTarget ): Promise { - const { insertableId, elementPath } = target; + const { insertableId } = target; // Bounded, because this is where an insertable's Onshape calls are: an // indexed element probes once per configuration. const probed = await ctx.limit(() => probeInsertable(ctx, target)); - // Fetched here rather than queued: an element's own thumbnail is one - // Onshape already rendered when the document was saved, so reading it - // starts nothing and races nothing. Only a configuration has to queue. - // // Nothing is asked for an empty studio, which renders to nothing at all. const thumbnailUrls = probed.hasParts ? await uploadThumbnailsStep( @@ -101,8 +97,7 @@ export async function loadInsertable( uploadThumbnails( ctx.env.BLOB, await getOnshapeApiFromContext(ctx), - elementPath, - target.elementWorkspacePath, + target.thumbnailPath, target.microversionId ) ) diff --git a/src/backend/features/load/load-insertable.worker.test.ts b/src/backend/features/load/load-insertable.worker.test.ts index 9ca5eefa3..c8abf6b9f 100644 --- a/src/backend/features/load/load-insertable.worker.test.ts +++ b/src/backend/features/load/load-insertable.worker.test.ts @@ -173,7 +173,8 @@ const ctx = (): LoadContext => ({ env, sessionId: "test-session", step: FAKE_STEP, - limit: createLimiter(LOAD_CONCURRENCY) + limit: createLimiter(LOAD_CONCURRENCY), + thumbnailLimit: createLimiter(LOAD_CONCURRENCY) }); describe("loadInsertable", () => { diff --git a/src/backend/features/load/steps.ts b/src/backend/features/load/steps.ts index 0d3094a5e..940088d3c 100644 --- a/src/backend/features/load/steps.ts +++ b/src/backend/features/load/steps.ts @@ -69,23 +69,26 @@ export const ONSHAPE_STEP_RETRIES = { }; /** - * Three tries about ten seconds apart, honouring a rate limit when Onshape - * asks for one. The workspace either has the thumbnail or does not; this only - * covers Onshape still writing one out just after a save. + * Waits out Onshape rendering a thumbnail in a freshly branched workspace, + * which takes minutes: 30, 60, 90 seconds, then two minutes a try, for about a + * quarter of an hour in all. A rate limit waits what Onshape says instead. */ const THUMBNAIL_RETRIES = { - limit: 3, - delay: onshapeRetryDelay, + limit: 10, + delay: (input: RetryDelayInput): `${number} seconds` => + rateLimitDelay(input.error) ?? + `${Math.min(30 * input.ctx.attempt, 120)} seconds`, backoff: CONSTANT_BACKOFF }; /** * Fetches an element's thumbnails and returns where they are stored, or `null` - * when neither the version nor the workspace would give one up — which the - * caller records as a build issue rather than failing the whole load. + * when Onshape never renders them — which the caller records as a build issue + * rather than failing the whole load. * - * Bounded by the run's limiter: these are ordinary Onshape reads that start no - * render, so what caps them is the rate limit rather than anything else. + * Bounded by the run's thumbnail limiter rather than the probing one, and slot + * first, step inside: a step's timeout covers its whole callback, so waiting + * for a slot inside one would count against it. */ export async function uploadThumbnailsStep( ctx: LoadContext, @@ -93,12 +96,7 @@ export async function uploadThumbnailsStep( upload: () => Promise ): Promise { try { - // Slot first, step inside — the order `loadInsertable` already takes. A - // step's timeout covers its whole callback, so acquiring within one - // counted the wait for a slot against it: under a rate limit these spent - // all ten minutes queued behind probes that were themselves backing off, - // and timed out having asked Onshape for nothing. - return await ctx.limit(() => + return await ctx.thumbnailLimit(() => ctx.step.do(name, { retries: THUMBNAIL_RETRIES }, upload) ); } catch { diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 91ecf104e..31cb09b9f 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -222,22 +222,11 @@ async function resolveGroupTarget( instanceId: version.id, instanceType: "v" }; - // Thrown rather than defaulted: every thumbnail in the group is read from - // this workspace, so guessing one would quietly load the wrong document. - if (!document.defaultWorkspace) { - throw new Error(`Document ${documentId} reports no default workspace`); - } - const workspacePath: InstancePath = { - documentId, - instanceId: document.defaultWorkspace.id, - instanceType: "w" - }; return { libraryId: ids.libraryId, groupId: ids.groupId, versionPath, versionCreatedAt: new Date(version.createdAt), - workspacePath, name: document.name, thumbnailElementId: document.documentThumbnailElementId }; diff --git a/src/backend/features/thumbnails/reload.ts b/src/backend/features/thumbnails/reload.ts index ed194d39c..11ae93bc9 100644 --- a/src/backend/features/thumbnails/reload.ts +++ b/src/backend/features/thumbnails/reload.ts @@ -1,9 +1,6 @@ /** - * Re-fetching one stored thumbnail on demand. - * - * A load queues nothing and waits for nothing, so a thumbnail neither instance - * would give up at the time is simply missing until the next load. This is the - * way to ask again for one, without reloading the document it belongs to. + * Re-fetching one stored thumbnail on demand: a thumbnail a load gave up on is + * missing until the next load, and this asks again for just the one. */ import { eq } from "drizzle-orm"; import { type Db } from "../../db/client"; @@ -25,59 +22,73 @@ import { ThumbnailSize, type ThumbnailUrls } from "./contract"; import { thumbnailKey } from "./keys"; import { uploadThumbnails } from "./store"; import { bumpLibraryVersion } from "../library/db"; -import type { OnshapeDocumentInfo } from "../../lib/onshape/types"; +import { ensureThumbnailWorkspace } from "./workspace"; /** What one reload needs to ask Onshape and to name what it stores. */ interface ReloadTarget { - elementPath: ElementPath; - elementWorkspacePath: ElementPath; + /** The element in the version's thumbnail workspace. */ + thumbnailPath: ElementPath; microversionId: string; } /** - * The document's workspace, which is where a thumbnail falls back to. Stored - * rows are pinned to a version and carry no workspace, so reading the document - * is the one thing a reload has to do before it can ask for the picture. + * The group's thumbnail workspace, branching one when a load has not: a group + * last loaded before thumbnails were read from branches has none yet. */ -function workspacePath( - document: OnshapeDocumentInfo, - documentId: string -): InstancePath { - if (!document.defaultWorkspace) { - throw handledError( - "Onshape reports no default workspace for this document.", - HttpStatus.BAD_GATEWAY - ); +async function thumbnailWorkspace( + db: Db, + onshapeApi: OnshapeApi, + group: { id: string; documentId: string; versionId: string }, + stored: string | null +): Promise { + if (stored) { + return { + documentId: group.documentId, + instanceId: stored, + instanceType: "w" + }; } - return { - documentId, - instanceId: document.defaultWorkspace.id, - instanceType: "w" - }; + const workspace = await ensureThumbnailWorkspace(onshapeApi, { + documentId: group.documentId, + instanceId: group.versionId, + instanceType: "v" + }); + await db + .update(groups) + .set({ thumbnailWorkspaceId: workspace.instanceId }) + .where(eq(groups.id, group.id)); + return workspace; } /** * Drops what is stored before fetching, since `uploadThumbnails` skips a size - * the bucket already holds — which is the whole point of asking again. + * the bucket already holds — which is the whole point of asking again. A + * branch made moments ago has nothing rendered yet, which is worth saying. */ async function replaceThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, target: ReloadTarget ): Promise { - const { elementPath, microversionId } = target; + const { thumbnailPath, microversionId } = target; await bucket.delete( [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => - thumbnailKey(elementPath.elementId, microversionId, size) + thumbnailKey(thumbnailPath.elementId, microversionId, size) ) ); - return uploadThumbnails( - bucket, - onshapeApi, - elementPath, - target.elementWorkspacePath, - microversionId - ); + try { + return await uploadThumbnails( + bucket, + onshapeApi, + thumbnailPath, + microversionId + ); + } catch { + throw handledError( + "Onshape has not rendered this thumbnail yet. Try again in a few minutes.", + HttpStatus.SERVICE_UNAVAILABLE + ); + } } /** The row's new urls, and the issue that said it had none. */ @@ -102,31 +113,30 @@ export async function reloadInsertableThumbnail( const row = await db .select({ libraryId: insertables.libraryId, + groupId: insertables.groupId, documentId: insertables.documentId, versionId: insertables.versionId, elementId: insertables.elementId, microversionId: insertables.microversionId, - buildIssues: insertables.buildIssues + buildIssues: insertables.buildIssues, + thumbnailWorkspaceId: groups.thumbnailWorkspaceId }) .from(insertables) + .innerJoin(groups, eq(groups.id, insertables.groupId)) .where(eq(insertables.id, insertableId)) .get(); if (!row) { throw handledError("No such element.", HttpStatus.NOT_FOUND); } - const document = await getDocument(onshapeApi, { - documentId: row.documentId - }); - const workspace = workspacePath(document, row.documentId); + const workspace = await thumbnailWorkspace( + db, + onshapeApi, + { ...row, id: row.groupId }, + row.thumbnailWorkspaceId + ); const urls = await replaceThumbnails(bucket, onshapeApi, { - elementPath: { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v", - elementId: row.elementId - }, - elementWorkspacePath: { ...workspace, elementId: row.elementId }, + thumbnailPath: { ...workspace, elementId: row.elementId }, microversionId: row.microversionId }); @@ -155,7 +165,8 @@ export async function reloadGroupThumbnail( libraryId: groups.libraryId, documentId: groups.documentId, versionId: groups.versionId, - buildIssues: groups.buildIssues + buildIssues: groups.buildIssues, + thumbnailWorkspaceId: groups.thumbnailWorkspaceId }) .from(groups) .where(eq(groups.id, groupId)) @@ -173,7 +184,12 @@ export async function reloadGroupThumbnail( getDocument(onshapeApi, { documentId: row.documentId }), getContents(onshapeApi, versionPath) ]); - const workspace = workspacePath(document, row.documentId); + const workspace = await thumbnailWorkspace( + db, + onshapeApi, + { ...row, id: groupId }, + row.thumbnailWorkspaceId + ); const designated = document.documentThumbnailElementId; const element = designated @@ -187,8 +203,7 @@ export async function reloadGroupThumbnail( } const urls = await replaceThumbnails(bucket, onshapeApi, { - elementPath: { ...versionPath, elementId: element.id }, - elementWorkspacePath: { ...workspace, elementId: element.id }, + thumbnailPath: { ...workspace, elementId: element.id }, microversionId: element.microversionId }); diff --git a/src/backend/features/thumbnails/reload.worker.test.ts b/src/backend/features/thumbnails/reload.worker.test.ts index 1fbadfb39..421400de8 100644 --- a/src/backend/features/thumbnails/reload.worker.test.ts +++ b/src/backend/features/thumbnails/reload.worker.test.ts @@ -2,7 +2,7 @@ import { env } from "cloudflare:workers"; import { eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getDb } from "../../db/client"; -import { insertables } from "../../db/schema"; +import { groups, insertables } from "../../db/schema"; import { MOCK_ONSHAPE_API, resetDb, @@ -21,21 +21,20 @@ import { OnshapeFolderEntryType } from "../../lib/onshape/types"; import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; +import * as WorkspaceEndpoints from "../../lib/onshape/endpoints/workspaces"; import { OnshapeApiError } from "../../lib/onshape/client"; import { ThumbnailSize } from "./contract"; import { thumbnailKey } from "./keys"; import { reloadGroupThumbnail, reloadInsertableThumbnail } from "./reload"; const db = getDb(env.DB); -const WORKSPACE_ID = "w-1"; +/** The branch a load made, stored on the group. */ +const STORED_BRANCH = "w-stored"; -/** Onshape's answer per instance type, so the fallback is observable. */ -function mockThumbnails( - answer: (instanceType: string) => Promise -) { +function mockThumbnails(answer: () => Promise) { return vi .spyOn(ThumbnailEndpoints, "getElementThumbnail") - .mockImplementation((_client, path) => answer(path.instanceType)); + .mockImplementation(answer); } const rendered = () => Promise.resolve(new ArrayBuffer(4)); @@ -60,10 +59,13 @@ describe("reloading a thumbnail", () => { }) .where(eq(insertables.id, target.insertableId)); + await db + .update(groups) + .set({ thumbnailWorkspaceId: STORED_BRANCH }) + .where(eq(groups.id, TEST_GROUP_ID)); vi.spyOn(DocumentEndpoints, "getDocument").mockResolvedValue({ id: "doc", - name: "Doc", - defaultWorkspace: { id: WORKSPACE_ID } + name: "Doc" }); }); @@ -91,13 +93,38 @@ describe("reloading a thumbnail", () => { expect(row?.buildIssues).toEqual([]); }); - // The same fallback a load uses: the version is asked first, and the - // workspace is what actually answers. - it("falls back to the workspace", async () => { - const calls = mockThumbnails((instanceType) => - instanceType === "v" ? missing() : rendered() + // Where a load reads it from, and nowhere else. + it("reads from the group's thumbnail workspace", async () => { + const calls = mockThumbnails(rendered); + + await reloadInsertableThumbnail( + db, + env.BLOB, + MOCK_ONSHAPE_API, + target.insertableId ); + for (const call of calls.mock.calls) { + expect(call[1]).toMatchObject({ + instanceType: "w", + instanceId: STORED_BRANCH + }); + } + }); + + // A group last loaded before thumbnails were read from branches has none. + it("branches a workspace for a group that has none, and keeps it", async () => { + await db + .update(groups) + .set({ thumbnailWorkspaceId: null }) + .where(eq(groups.id, TEST_GROUP_ID)); + vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([]); + vi.spyOn(WorkspaceEndpoints, "createWorkspace").mockResolvedValue({ + id: "w-new", + name: "FRCDesignApp thumbnails" + }); + mockThumbnails(rendered); + await reloadInsertableThumbnail( db, env.BLOB, @@ -105,10 +132,12 @@ describe("reloading a thumbnail", () => { target.insertableId ); - expect( - calls.mock.calls.some((call) => call[1].instanceType === "w") - ).toBe(true); - expect((await readRow())?.buildIssues).toEqual([]); + const group = await db + .select() + .from(groups) + .where(eq(groups.id, TEST_GROUP_ID)) + .get(); + expect(group?.thumbnailWorkspaceId).toBe("w-new"); }); // `uploadThumbnails` skips a size the bucket already holds, which would @@ -132,7 +161,7 @@ describe("reloading a thumbnail", () => { expect(await (await env.BLOB.get(key))?.text()).not.toBe("stale-bytes"); }); - it("leaves the row alone when neither instance has one", async () => { + it("leaves the row alone while Onshape has not rendered one", async () => { mockThumbnails(missing); await expect( diff --git a/src/backend/features/thumbnails/render.ts b/src/backend/features/thumbnails/render.ts index 546b837b7..eb9f7f0da 100644 --- a/src/backend/features/thumbnails/render.ts +++ b/src/backend/features/thumbnails/render.ts @@ -6,8 +6,8 @@ import { eq } from "drizzle-orm"; import type { AppContext } from "../../lib/context"; import { getDb } from "../../db/client"; -import { insertables } from "../../db/schema"; -import { toElementPath } from "../../lib/onshape/path"; +import { groups, insertables } from "../../db/schema"; +import { type ElementPath, toElementPath } from "../../lib/onshape/path"; import { getThumbnailId, NoSuchConfigurationError @@ -153,19 +153,37 @@ async function findInstance( } } -/** Read rather than passed in: a request can carry a version it has moved past. */ -async function elementPathOf(c: AppContext, insertableId: string) { +/** + * Where the element is rendered from: its version's thumbnail workspace (see + * `workspace.ts`), or the version itself for a group no load has branched one + * for yet. Read rather than passed in: a request can carry a version it has + * moved past. + */ +async function elementPathOf( + c: AppContext, + insertableId: string +): Promise { const row = await getDb(c.env.DB) .select({ documentId: insertables.documentId, versionId: insertables.versionId, - elementId: insertables.elementId + elementId: insertables.elementId, + thumbnailWorkspaceId: groups.thumbnailWorkspaceId }) .from(insertables) + .innerJoin(groups, eq(groups.id, insertables.groupId)) .where(eq(insertables.id, insertableId)) .get(); if (!row) { throw new NoSuchConfigurationError(`No insertable ${insertableId}`); } - return toElementPath(row); + if (!row.thumbnailWorkspaceId) { + return toElementPath(row); + } + return { + documentId: row.documentId, + instanceId: row.thumbnailWorkspaceId, + instanceType: "w", + elementId: row.elementId + }; } diff --git a/src/backend/features/thumbnails/routes.worker.test.ts b/src/backend/features/thumbnails/routes.worker.test.ts index 30b92eb7d..348386fb1 100644 --- a/src/backend/features/thumbnails/routes.worker.test.ts +++ b/src/backend/features/thumbnails/routes.worker.test.ts @@ -2,6 +2,7 @@ import { env } from "cloudflare:workers"; import { introspectWorkflow } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + TEST_GROUP_ID, TEST_PART_STUDIO_ID, createTestApp, jsonRequest, @@ -10,6 +11,8 @@ import { } from "../../../__test_utils__"; import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; import { getDb } from "../../db/client"; +import { groups } from "../../db/schema"; +import { eq } from "drizzle-orm"; import { RenderSource, ThumbnailSize } from "./contract"; import { parseThumbnailKey, @@ -336,6 +339,25 @@ describe("rendering a configuration's thumbnail", () => { expect(thumbnailId).toHaveBeenCalledTimes(1); }); + // Where the load read the element's own thumbnail from; a group no load + // has branched yet falls back to the version. + it("renders from the group's thumbnail workspace", async () => { + await db + .update(groups) + .set({ thumbnailWorkspaceId: "w-branch" }) + .where(eq(groups.id, TEST_GROUP_ID)); + const thumbnailId = mockThumbnailId(); + + await startedDuring(async () => { + await get(renderUrl("branched-element"), SESSION_ID); + }); + + expect(thumbnailId.mock.calls[0][1]).toMatchObject({ + instanceId: "w-branch", + instanceType: "w" + }); + }); + // A miss is a render still coming; this is one that never will be, and the // client shows different wording for each. it("answers a configuration Onshape cannot resolve with its own status", async () => { diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts index 5c41284f8..5628a6ee5 100644 --- a/src/backend/features/thumbnails/store.ts +++ b/src/backend/features/thumbnails/store.ts @@ -44,41 +44,20 @@ export async function putThumbnail( const BOTH_SIZES = [ThumbnailSize.SMALL, ThumbnailSize.LARGE]; /** - * One size, the version first and the workspace when it will not answer. + * Stores both sizes of an element's own thumbnail, skipping either the bucket + * already holds, and throws while Onshape has not rendered one yet. * - * Any failure pivots, not just a 404: the version form of this endpoint has - * been unreliable for element thumbnails and the workspace form has not, but - * the version is what the library shows, so it is still asked first. - */ -async function fetchThumbnail( - onshapeApi: OnshapeApi, - elementPath: ElementPath, - elementWorkspacePath: ElementPath, - size: ThumbnailSize -): Promise { - try { - return await getElementThumbnail(onshapeApi, elementPath, size); - } catch { - return getElementThumbnail(onshapeApi, elementWorkspacePath, size); - } -} - -/** - * Stores both sizes, skipping either the bucket already holds, and throws when - * neither instance will give one up. - * - * Onshape renders these when a document is saved, so reading one starts no work - * and races nothing — unlike a configuration, which `RenderThumbnailWorkflow` - * waits out. A load fetches them directly, several elements at a time. + * Read from the version's thumbnail workspace (see `workspace.ts`) and stored + * under the version's microversion, which the branch shares: it is the same + * part, and the version is what the library shows. */ export async function uploadThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, - elementPath: ElementPath, - elementWorkspacePath: ElementPath, + thumbnailPath: ElementPath, microversionId: string ): Promise { - const { elementId } = elementPath; + const { elementId } = thumbnailPath; // One size at a time: an attempt that fails should cost one call rather // than two, and what runs in parallel is elements, not their sizes. @@ -87,10 +66,9 @@ export async function uploadThumbnails( if (await bucket.head(key)) { continue; } - const thumbnail = await fetchThumbnail( + const thumbnail = await getElementThumbnail( onshapeApi, - elementPath, - elementWorkspacePath, + thumbnailPath, size ); await putThumbnail(bucket, key, thumbnail, { diff --git a/src/backend/features/thumbnails/workspace.ts b/src/backend/features/thumbnails/workspace.ts new file mode 100644 index 000000000..5b0b88269 --- /dev/null +++ b/src/backend/features/thumbnails/workspace.ts @@ -0,0 +1,77 @@ +/** + * The workspace thumbnails are read from. + * + * Onshape sometimes never renders an element's thumbnail in a version — a bug + * on their side, as far as we can tell — while a workspace does render it. The + * document's own workspace would, but it moves on from the version the library + * shows, so its pictures can be of a part nobody can insert. So each version + * gets a workspace branched off it, which nobody edits, and every thumbnail of + * that version is read from there. + * + * A fresh branch has no thumbnails yet; Onshape renders them over the next few + * minutes, which is why a load reading them retries for a long while. + */ +import { type OnshapeApi } from "../../lib/onshape/client"; +import { type DocumentPath, type InstancePath } from "../../lib/onshape/path"; +import { + createWorkspace, + deleteWorkspace, + getWorkspaces +} from "../../lib/onshape/endpoints/workspaces"; + +/** Marks a workspace as ours, so cleanup never touches one it did not make. */ +const NAME_PREFIX = "FRCDesignApp thumbnails"; + +/** Named by the version it is branched from, which is what finds it again. */ +function workspaceName(versionId: string): string { + return `${NAME_PREFIX} ${versionId}`; +} + +/** + * The version's thumbnail workspace, branched on first use. Found by name + * rather than created blind, so a retried step or a forced reload reuses the + * branch it already made. + */ +export async function ensureThumbnailWorkspace( + client: OnshapeApi, + versionPath: InstancePath +): Promise { + const name = workspaceName(versionPath.instanceId); + const existing = (await getWorkspaces(client, versionPath)).find( + (workspace) => workspace.name === name + ); + const workspace = + existing ?? + (await createWorkspace(client, versionPath, { + name, + description: + "Made by the FRCDesignApp to read this version's thumbnails from. " + + "Safe to delete; the app branches another when it needs one.", + versionId: versionPath.instanceId + })); + return { + documentId: versionPath.documentId, + instanceId: workspace.id, + instanceType: "w" + }; +} + +/** + * Deletes the thumbnail workspaces of other versions, once nothing reads from + * them: a load that moved the group to a newer version reads from the newer + * one's. Only ours, by name. + */ +export async function deleteStaleThumbnailWorkspaces( + client: OnshapeApi, + documentPath: DocumentPath, + keepWorkspaceId: string +): Promise { + const stale = (await getWorkspaces(client, documentPath)).filter( + (workspace) => + workspace.name.startsWith(NAME_PREFIX) && + workspace.id !== keepWorkspaceId + ); + for (const workspace of stale) { + await deleteWorkspace(client, documentPath, workspace.id); + } +} diff --git a/src/backend/lib/onshape/endpoints/workspaces.ts b/src/backend/lib/onshape/endpoints/workspaces.ts new file mode 100644 index 000000000..f5e4ffaf1 --- /dev/null +++ b/src/backend/lib/onshape/endpoints/workspaces.ts @@ -0,0 +1,43 @@ +import { OnshapeApi } from "../client"; +import { DocumentPath, toDocumentApiPath } from "../path"; +import { apiPath } from "../api-path"; +import { OnshapeWorkspaceInfo } from "../types"; + +/** Every workspace in a document, branches included. */ +export function getWorkspaces( + client: OnshapeApi, + documentPath: DocumentPath +): Promise { + return client.get( + apiPath("documents", documentPath, toDocumentApiPath, { + endRoute: "workspaces" + }) + ); +} + +/** Branches a new workspace off a version. */ +export function createWorkspace( + client: OnshapeApi, + documentPath: DocumentPath, + branch: { name: string; description: string; versionId: string } +): Promise { + return client.post( + apiPath("documents", documentPath, toDocumentApiPath, { + endRoute: "workspaces" + }), + { body: branch } + ); +} + +export function deleteWorkspace( + client: OnshapeApi, + documentPath: DocumentPath, + workspaceId: string +): Promise { + return client.deleteNone( + apiPath("documents", documentPath, toDocumentApiPath, { + endRoute: "workspaces", + endId: workspaceId + }) + ); +} diff --git a/src/backend/lib/onshape/types.ts b/src/backend/lib/onshape/types.ts index 5a5f41654..b8fe0967b 100644 --- a/src/backend/lib/onshape/types.ts +++ b/src/backend/lib/onshape/types.ts @@ -193,6 +193,12 @@ export interface OnshapeDocumentInfo { defaultWorkspace?: { id: string }; } +/** A workspace, as much of Onshape's `BTWorkspaceInfo` as anything reads. */ +export interface OnshapeWorkspaceInfo { + id: string; + name: string; +} + /** A folder (group) node in the document contents tree. */ export interface OnshapeElementGroup { btType: OnshapeFolderEntryType.GROUP; From 177c4300c8151f1f9061fe85c5d17fd09d178c4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:10:53 +0000 Subject: [PATCH 10/88] Name thumbnail branches FRCDesignApp Thumbnails (DO NOT EDIT) Every branch now shares that name, so the version it came from is recorded in its description, which is what finding it again matches. Also tightens this change's comments: the thumbnail limiter's was wrong (a waiting step holds its slot), the branch sharing the version's microversion is marked as an assumption, and comments restating the code are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 2 +- src/backend/db/schema.ts | 4 +- src/backend/features/load/context.ts | 17 ++---- src/backend/features/load/load-group.ts | 9 +-- .../features/load/load-group.worker.test.ts | 19 +++--- src/backend/features/load/steps.ts | 16 +++-- src/backend/features/thumbnails/reload.ts | 11 +--- .../features/thumbnails/reload.worker.test.ts | 3 - src/backend/features/thumbnails/render.ts | 7 +-- .../features/thumbnails/routes.worker.test.ts | 2 - src/backend/features/thumbnails/store.ts | 10 ++-- src/backend/features/thumbnails/workspace.ts | 59 +++++++++---------- .../lib/onshape/endpoints/workspaces.ts | 2 - src/backend/lib/onshape/types.ts | 1 + 14 files changed, 70 insertions(+), 92 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 3942f2b8e..66a914464 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -50,7 +50,7 @@ R2 is Cloudflare's blob storage, optimized for unstructured data like images and Onshape can generate preview thumbnails for parts and assemblies, but fetching them from Onshape on every page load would be slow and eat into API rate limits — a single render can require polling and take minutes. Instead, every thumbnail we ever fetch from Onshape lands in R2 and is served from there afterwards. -Every thumbnail is read from a **workspace branched off the version** the library shows, not from the version itself — Onshape sometimes never renders an element's thumbnail in a version — and not from the document's own workspace, which moves on from that version. A load finds or creates the branch by name (`FRCDesignApp thumbnails `, in `features/thumbnails/workspace.ts`), stores its id on the group row beside the version, and deletes the branches of older versions once the group has moved on. A fresh branch has no thumbnails for a few minutes, so a load's thumbnail steps retry for about a quarter of an hour, under a limiter of their own so the wait never holds up probing. +Every thumbnail is read from a **workspace branched off the version** the library shows, not from the version itself — Onshape sometimes never renders an element's thumbnail in a version — and not from the document's own workspace, which moves on from that version. A load finds or creates the branch (`FRCDesignApp Thumbnails (DO NOT EDIT)`, its version recorded in its description; `features/thumbnails/workspace.ts`), stores its id on the group row beside the version, and deletes the branches of older versions once the group has moved on. A fresh branch has no thumbnails for a few minutes, so a load's thumbnail steps retry for about 17 minutes, under a limiter of their own so the wait never holds up probing. Thumbnails are keyed by whether they are the element's default or a specific configuration: diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 8174a477b..dcd0cd52b 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -92,8 +92,8 @@ export const groups = sqliteTable( documentId: text("document_id").notNull(), versionId: text("version_id").notNull(), versionCreatedAt: versionCreatedAt(), - // Branched off `versionId` to read its thumbnails from; see - // `thumbnails/workspace.ts`. Null until a load has made one. + // Branched off `versionId`; see `thumbnails/workspace.ts`. Null until + // a load has made one. thumbnailWorkspaceId: text("thumbnail_workspace_id"), sortAlphabetically: integer("sort_alphabetically", { mode: "boolean" }) .notNull() diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index 9f38cc642..10550d2f0 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -23,10 +23,9 @@ import type { ElementPath, InstancePath } from "../../lib/onshape/path"; export const LOAD_CONCURRENCY = 15; /** - * How many thumbnails a load waits on at once. Kept apart from probing: a - * thumbnail in a freshly branched workspace can take minutes to appear, and a - * step waiting that out would otherwise hold a probe's slot the whole time. - * Waiting costs no calls, so this bounds only the bursts between waits. + * How many thumbnails a load waits on at once. A separate limiter from + * probing's, because a thumbnail step holds its slot through minutes of + * retries, and on the probing limiter that would stall probes behind it. */ export const THUMBNAIL_CONCURRENCY = 10; @@ -37,7 +36,6 @@ export interface LoadContext { step: WorkflowStep; /** Bounds concurrent Onshape probing across the whole run. */ limit: Limiter; - /** Bounds concurrent thumbnail reads, apart from probing. */ thumbnailLimit: Limiter; } @@ -73,20 +71,15 @@ export interface GroupTarget { thumbnailElementId?: string; } -/** A group being loaded, which is when it gets somewhere to read thumbnails. */ +/** Only a group that loads gets a thumbnail workspace; see `loadGroup`. */ export interface LoadingGroup extends GroupTarget { - /** - * The workspace branched off the version for reading thumbnails, which - * the version form of that endpoint does not reliably return; see - * `thumbnails/workspace.ts`. - */ + /** See `thumbnails/workspace.ts`. */ thumbnailPath: InstancePath; } /** An insertable a load reads, and what the document's tab listing told us. */ export interface InsertableTarget { insertableId: string; - /** The same tab in the version's thumbnail workspace. */ thumbnailPath: ElementPath; libraryId: LibraryId; groupId: string; diff --git a/src/backend/features/load/load-group.ts b/src/backend/features/load/load-group.ts index 833df76d4..a88fa5769 100644 --- a/src/backend/features/load/load-group.ts +++ b/src/backend/features/load/load-group.ts @@ -51,7 +51,7 @@ interface ParsedGroup { versionId?: string; /** Moves with `versionId`, so the row's date is always that version's. */ versionCreatedAt?: Date; - /** Moves with `versionId` too: it is that version's branch. */ + /** Moves with `versionId`: it is that version's branch. */ thumbnailWorkspaceId?: string; } @@ -62,7 +62,8 @@ export async function loadGroup( ): Promise { const { groupId, versionPath } = group; - // Only for a group that loads: a skipped one keeps the branch it has. + // Here rather than when resolving the group, so a skipped group branches + // nothing. const thumbnailPath = await ctx.step.do( `thumbnail-workspace-${groupId}`, { retries: ONSHAPE_STEP_RETRIES }, @@ -124,8 +125,8 @@ export async function loadGroup( }) ); - // Once the row has moved to this version, nothing reads the branches of - // older ones. Never fatal: a leftover branch costs nothing but clutter. + // Only once the row has moved to this version. Never fatal: a leftover + // branch is clutter, not breakage. if (failedInsertableIds.length === 0) { await ctx.step .do(`delete-stale-workspaces-${groupId}`, async () => diff --git a/src/backend/features/load/load-group.worker.test.ts b/src/backend/features/load/load-group.worker.test.ts index 70abd138e..61d438c8d 100644 --- a/src/backend/features/load/load-group.worker.test.ts +++ b/src/backend/features/load/load-group.worker.test.ts @@ -243,8 +243,12 @@ const LOADED_TARGET: GroupTarget = { versionCreatedAt: LOADED_VERSION_CREATED_AT }; -/** What Onshape answers when asked to branch the loaded version. */ -const BRANCH = { id: "w-branch", name: "FRCDesignApp thumbnails v-2" }; +const BRANCH = { + id: "w-branch", + name: "FRCDesignApp Thumbnails (DO NOT EDIT)", + description: + "Made by the FRCDesignApp to read version v-2's thumbnails from." +}; const CTX: LoadContext = { env, @@ -296,7 +300,6 @@ describe("loadGroup", () => { ).mockResolvedValue(MOCK_ONSHAPE_API); // Every part-studio load probes its parts for the open-composite flag. vi.spyOn(PartsEndpoints, "getParts").mockResolvedValue([]); - // No branch yet, so the load makes one; nothing stale to delete. vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([]); vi.spyOn(WorkspaceEndpoints, "createWorkspace").mockResolvedValue( BRANCH @@ -332,8 +335,6 @@ describe("loadGroup", () => { } }); - // Onshape sometimes never renders a thumbnail in a version, and the - // document's own workspace drifts from it; a branch of it does neither. it("reads every thumbnail from a workspace branched off the version", async () => { mockContents([tab("e1"), tab("e2")]); vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( @@ -354,7 +355,6 @@ describe("loadGroup", () => { LOADED_TARGET.versionPath, expect.objectContaining({ versionId: "v-2" }) ); - // (bucket, api, thumbnailPath, microversionId) for (const elementId of ["e1", "e2"]) { const call = uploaded.mock.calls.find( (args) => args[2].elementId === elementId @@ -366,12 +366,9 @@ describe("loadGroup", () => { elementId }); } - // Stored beside the version, for renders and reloads to read from. expect((await readGroup())?.thumbnailWorkspaceId).toBe(BRANCH.id); }); - // A retried step or a forced reload finds the branch by name rather than - // making another; the branches of older versions are cleared away. it("reuses the version's branch, and deletes older versions' branches", async () => { mockContents([tab("e1")]); vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( @@ -379,7 +376,9 @@ describe("loadGroup", () => { ); vi.spyOn(WorkspaceEndpoints, "getWorkspaces").mockResolvedValue([ { id: "main", name: "Main" }, - { id: "w-old", name: "FRCDesignApp thumbnails v-1" }, + // Another version's branch, and a workspace that only shares its name. + { ...BRANCH, id: "w-old", description: "…version v-1's…" }, + { id: "w-lookalike", name: "FRCDesignApp Thumbnails" }, BRANCH ]); const deleted = vi diff --git a/src/backend/features/load/steps.ts b/src/backend/features/load/steps.ts index 940088d3c..1ed59aa76 100644 --- a/src/backend/features/load/steps.ts +++ b/src/backend/features/load/steps.ts @@ -69,9 +69,9 @@ export const ONSHAPE_STEP_RETRIES = { }; /** - * Waits out Onshape rendering a thumbnail in a freshly branched workspace, - * which takes minutes: 30, 60, 90 seconds, then two minutes a try, for about a - * quarter of an hour in all. A rate limit waits what Onshape says instead. + * A freshly branched workspace takes minutes to render its thumbnails. Waits + * 30, 60, 90 seconds, then two minutes a try: about 17 minutes in all. A rate + * limit waits what Onshape asks instead. */ const THUMBNAIL_RETRIES = { limit: 10, @@ -82,13 +82,11 @@ const THUMBNAIL_RETRIES = { }; /** - * Fetches an element's thumbnails and returns where they are stored, or `null` - * when Onshape never renders them — which the caller records as a build issue - * rather than failing the whole load. + * `null` when Onshape never renders them, which the caller records as a build + * issue rather than failing the load. * - * Bounded by the run's thumbnail limiter rather than the probing one, and slot - * first, step inside: a step's timeout covers its whole callback, so waiting - * for a slot inside one would count against it. + * Slot first, step inside: a step's timeout covers its whole callback, so + * waiting for a slot inside one would count against it. */ export async function uploadThumbnailsStep( ctx: LoadContext, diff --git a/src/backend/features/thumbnails/reload.ts b/src/backend/features/thumbnails/reload.ts index 11ae93bc9..aedbbd8f9 100644 --- a/src/backend/features/thumbnails/reload.ts +++ b/src/backend/features/thumbnails/reload.ts @@ -26,15 +26,11 @@ import { ensureThumbnailWorkspace } from "./workspace"; /** What one reload needs to ask Onshape and to name what it stores. */ interface ReloadTarget { - /** The element in the version's thumbnail workspace. */ thumbnailPath: ElementPath; microversionId: string; } -/** - * The group's thumbnail workspace, branching one when a load has not: a group - * last loaded before thumbnails were read from branches has none yet. - */ +/** Branches one for a group last loaded before loads made them. */ async function thumbnailWorkspace( db: Db, onshapeApi: OnshapeApi, @@ -61,9 +57,8 @@ async function thumbnailWorkspace( } /** - * Drops what is stored before fetching, since `uploadThumbnails` skips a size - * the bucket already holds — which is the whole point of asking again. A - * branch made moments ago has nothing rendered yet, which is worth saying. + * Deletes first, since `uploadThumbnails` skips a size the bucket already holds. + * A branch made moments ago has nothing rendered yet, so that failure says so. */ async function replaceThumbnails( bucket: R2Bucket, diff --git a/src/backend/features/thumbnails/reload.worker.test.ts b/src/backend/features/thumbnails/reload.worker.test.ts index 421400de8..2cb87ec0b 100644 --- a/src/backend/features/thumbnails/reload.worker.test.ts +++ b/src/backend/features/thumbnails/reload.worker.test.ts @@ -28,7 +28,6 @@ import { thumbnailKey } from "./keys"; import { reloadGroupThumbnail, reloadInsertableThumbnail } from "./reload"; const db = getDb(env.DB); -/** The branch a load made, stored on the group. */ const STORED_BRANCH = "w-stored"; function mockThumbnails(answer: () => Promise) { @@ -93,7 +92,6 @@ describe("reloading a thumbnail", () => { expect(row?.buildIssues).toEqual([]); }); - // Where a load reads it from, and nowhere else. it("reads from the group's thumbnail workspace", async () => { const calls = mockThumbnails(rendered); @@ -112,7 +110,6 @@ describe("reloading a thumbnail", () => { } }); - // A group last loaded before thumbnails were read from branches has none. it("branches a workspace for a group that has none, and keeps it", async () => { await db .update(groups) diff --git a/src/backend/features/thumbnails/render.ts b/src/backend/features/thumbnails/render.ts index eb9f7f0da..7027459f8 100644 --- a/src/backend/features/thumbnails/render.ts +++ b/src/backend/features/thumbnails/render.ts @@ -154,10 +154,9 @@ async function findInstance( } /** - * Where the element is rendered from: its version's thumbnail workspace (see - * `workspace.ts`), or the version itself for a group no load has branched one - * for yet. Read rather than passed in: a request can carry a version it has - * moved past. + * The version's branch (see `workspace.ts`), or the version for a group no + * load has branched yet. Read rather than passed in: a request can carry a + * version the group has moved past. */ async function elementPathOf( c: AppContext, diff --git a/src/backend/features/thumbnails/routes.worker.test.ts b/src/backend/features/thumbnails/routes.worker.test.ts index 348386fb1..3b045bac3 100644 --- a/src/backend/features/thumbnails/routes.worker.test.ts +++ b/src/backend/features/thumbnails/routes.worker.test.ts @@ -339,8 +339,6 @@ describe("rendering a configuration's thumbnail", () => { expect(thumbnailId).toHaveBeenCalledTimes(1); }); - // Where the load read the element's own thumbnail from; a group no load - // has branched yet falls back to the version. it("renders from the group's thumbnail workspace", async () => { await db .update(groups) diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts index 5628a6ee5..f0ef98370 100644 --- a/src/backend/features/thumbnails/store.ts +++ b/src/backend/features/thumbnails/store.ts @@ -44,12 +44,12 @@ export async function putThumbnail( const BOTH_SIZES = [ThumbnailSize.SMALL, ThumbnailSize.LARGE]; /** - * Stores both sizes of an element's own thumbnail, skipping either the bucket - * already holds, and throws while Onshape has not rendered one yet. + * Stores both sizes, skipping any the bucket already holds; throws while + * Onshape has not rendered one. * - * Read from the version's thumbnail workspace (see `workspace.ts`) and stored - * under the version's microversion, which the branch shares: it is the same - * part, and the version is what the library shows. + * Keyed by the version's microversion though read from its branch: an unedited + * branch should show the same part. That is assumed, not checked against + * Onshape. */ export async function uploadThumbnails( bucket: R2Bucket, diff --git a/src/backend/features/thumbnails/workspace.ts b/src/backend/features/thumbnails/workspace.ts index 5b0b88269..7ae96baef 100644 --- a/src/backend/features/thumbnails/workspace.ts +++ b/src/backend/features/thumbnails/workspace.ts @@ -1,15 +1,11 @@ /** - * The workspace thumbnails are read from. + * Onshape sometimes never renders an element's thumbnail in a version, and the + * document's own workspace drifts from the version the library shows. So each + * loaded version gets a workspace branched off it, which nobody edits, and its + * thumbnails are read from there. * - * Onshape sometimes never renders an element's thumbnail in a version — a bug - * on their side, as far as we can tell — while a workspace does render it. The - * document's own workspace would, but it moves on from the version the library - * shows, so its pictures can be of a part nobody can insert. So each version - * gets a workspace branched off it, which nobody edits, and every thumbnail of - * that version is read from there. - * - * A fresh branch has no thumbnails yet; Onshape renders them over the next few - * minutes, which is why a load reading them retries for a long while. + * A fresh branch has no thumbnails for a few minutes, which is why a load + * reading them retries for a long while. */ import { type OnshapeApi } from "../../lib/onshape/client"; import { type DocumentPath, type InstancePath } from "../../lib/onshape/path"; @@ -18,35 +14,41 @@ import { deleteWorkspace, getWorkspaces } from "../../lib/onshape/endpoints/workspaces"; +import { type OnshapeWorkspaceInfo } from "../../lib/onshape/types"; -/** Marks a workspace as ours, so cleanup never touches one it did not make. */ -const NAME_PREFIX = "FRCDesignApp thumbnails"; +/** Shared by every branch; cleanup deletes nothing without it. */ +const WORKSPACE_NAME = "FRCDesignApp Thumbnails (DO NOT EDIT)"; -/** Named by the version it is branched from, which is what finds it again. */ -function workspaceName(versionId: string): string { - return `${NAME_PREFIX} ${versionId}`; +/** + * The name is the same for every version, so the description is what records + * which one a branch came from. + */ +function workspaceDescription(versionId: string): string { + return `Made by the FRCDesignApp to read version ${versionId}'s thumbnails from.`; +} + +function isOurs(workspace: OnshapeWorkspaceInfo): boolean { + return workspace.name === WORKSPACE_NAME; } /** - * The version's thumbnail workspace, branched on first use. Found by name - * rather than created blind, so a retried step or a forced reload reuses the - * branch it already made. + * Found before it is created, so a retried step or a forced reload reuses the + * branch rather than making another. */ export async function ensureThumbnailWorkspace( client: OnshapeApi, versionPath: InstancePath ): Promise { - const name = workspaceName(versionPath.instanceId); + const description = workspaceDescription(versionPath.instanceId); const existing = (await getWorkspaces(client, versionPath)).find( - (workspace) => workspace.name === name + (workspace) => + isOurs(workspace) && workspace.description === description ); const workspace = existing ?? (await createWorkspace(client, versionPath, { - name, - description: - "Made by the FRCDesignApp to read this version's thumbnails from. " + - "Safe to delete; the app branches another when it needs one.", + name: WORKSPACE_NAME, + description, versionId: versionPath.instanceId })); return { @@ -57,9 +59,8 @@ export async function ensureThumbnailWorkspace( } /** - * Deletes the thumbnail workspaces of other versions, once nothing reads from - * them: a load that moved the group to a newer version reads from the newer - * one's. Only ours, by name. + * Deletes our branches of other versions. Only safe once the group row has + * moved to `keepWorkspaceId`'s version, since renders read the stored branch. */ export async function deleteStaleThumbnailWorkspaces( client: OnshapeApi, @@ -67,9 +68,7 @@ export async function deleteStaleThumbnailWorkspaces( keepWorkspaceId: string ): Promise { const stale = (await getWorkspaces(client, documentPath)).filter( - (workspace) => - workspace.name.startsWith(NAME_PREFIX) && - workspace.id !== keepWorkspaceId + (workspace) => isOurs(workspace) && workspace.id !== keepWorkspaceId ); for (const workspace of stale) { await deleteWorkspace(client, documentPath, workspace.id); diff --git a/src/backend/lib/onshape/endpoints/workspaces.ts b/src/backend/lib/onshape/endpoints/workspaces.ts index f5e4ffaf1..4cfb06771 100644 --- a/src/backend/lib/onshape/endpoints/workspaces.ts +++ b/src/backend/lib/onshape/endpoints/workspaces.ts @@ -3,7 +3,6 @@ import { DocumentPath, toDocumentApiPath } from "../path"; import { apiPath } from "../api-path"; import { OnshapeWorkspaceInfo } from "../types"; -/** Every workspace in a document, branches included. */ export function getWorkspaces( client: OnshapeApi, documentPath: DocumentPath @@ -15,7 +14,6 @@ export function getWorkspaces( ); } -/** Branches a new workspace off a version. */ export function createWorkspace( client: OnshapeApi, documentPath: DocumentPath, diff --git a/src/backend/lib/onshape/types.ts b/src/backend/lib/onshape/types.ts index b8fe0967b..aa1596d21 100644 --- a/src/backend/lib/onshape/types.ts +++ b/src/backend/lib/onshape/types.ts @@ -197,6 +197,7 @@ export interface OnshapeDocumentInfo { export interface OnshapeWorkspaceInfo { id: string; name: string; + description?: string; } /** A folder (group) node in the document contents tree. */ From 3231d8160d74bcb99418ea1f1ae1652d101ec40b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:39:59 +0000 Subject: [PATCH 11/88] Leave build issues stored before they carried values as they are A load recomputes every build issue, so an issue that named its offending configuration by key only needs to last until the next one. It still reads and renders; it just has no configuration to link to until then. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- .../features/build-checker/issues.test.ts | 12 +++------ src/backend/features/build-checker/issues.ts | 25 +++---------------- 2 files changed, 8 insertions(+), 29 deletions(-) diff --git a/src/backend/features/build-checker/issues.test.ts b/src/backend/features/build-checker/issues.test.ts index c3fe424c2..3778d8bf9 100644 --- a/src/backend/features/build-checker/issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -67,19 +67,15 @@ describe("knownBuildIssues", () => { ).toEqual([{ type: BuildIssueType.LOAD_FAILED }]); }); - it("reads a configuration blamed by its key as its values", () => { + it("keeps an issue stored before issues carried values, unlinked", () => { const stored = { type: BuildIssueType.UNSTABLE_COMPOSITE, configurationKey: "size=large", configurationCount: 2 } as unknown as BuildIssue; - expect(knownBuildIssues([stored])).toEqual([ - { - type: BuildIssueType.UNSTABLE_COMPOSITE, - values: { size: "large" }, - configurationCount: 2 - } - ]); + const [kept] = knownBuildIssues([stored]); + expect(kept.type).toBe(BuildIssueType.UNSTABLE_COMPOSITE); + expect(getIssueConfiguration(kept)).toBeUndefined(); }); it("keeps every type it knows", () => { diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index deee56822..2bae006fd 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -7,7 +7,6 @@ import { MAX_PART_NUMBER_CONFIGURATIONS } from "../configurations/combinations"; import type { PartialSelection } from "../configurations/contract"; -import { decodeConfiguration } from "../configurations/utils"; export enum BuildIssueSeverity { /** A potential issue that is usually fine, e.g. no vendors parsed. */ @@ -90,7 +89,8 @@ export function toConfigurationIssue( /** * The configuration an issue blames, or undefined where the element itself is - * at fault and there is nothing narrower to open. + * at fault. Also undefined for an issue stored before issues carried values, + * until the next load rewrites it. */ export function getIssueConfiguration( issue: BuildIssue @@ -103,27 +103,10 @@ const BUILD_ISSUE_TYPES = new Set(Object.values(BuildIssueType)); /** * Drops issues this deploy has no check for. A stored array was written by * whichever deploy last loaded the row, so it can name a type since removed from - * `BuildIssueType`, which has no severity or description to render — or blame a - * configuration by its key, as issues did before they carried its values. + * `BuildIssueType`, which has no severity or description to render. */ export function knownBuildIssues(issues: BuildIssue[]): BuildIssue[] { - return issues - .filter((issue) => BUILD_ISSUE_TYPES.has(issue.type)) - .map(upgradeIssue); -} - -/** An issue written when a blamed configuration was named by its key. */ -function upgradeIssue(issue: BuildIssue): BuildIssue { - if (!("configurationKey" in issue)) { - return issue; - } - const { configurationKey, ...rest } = issue as BuildIssue & { - configurationKey: string; - }; - return { - ...rest, - values: decodeConfiguration(configurationKey) - } as BuildIssue; + return issues.filter((issue) => BUILD_ISSUE_TYPES.has(issue.type)); } /** A human-readable description of a build issue, shown to editors. */ From c0dd00722b218a8a92c8e724d7fdd7a43e5278f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:50:19 +0000 Subject: [PATCH 12/88] Index assemblies too, and decide indexed parameters in the app Onshape's exclude-from-properties flag no longer decides what indexing varies. A parameter is indexed unless its name marks it as never worth varying (a derivation variable, a color or its R, G and B channels together, a tessellation setting) or an admin excluded it with the new per-parameter switch on the build card. Exclusions are user-owned, kept across reloads, and part studios only: Onshape lets no assembly exclude parameters from its properties either, so an assembly indexes every one it can. Assemblies now index like part studios: automatically under the threshold, and past it once an admin enables indexing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- drizzle/0005_excluded_parameters.sql | 1 + drizzle/meta/0005_snapshot.json | 1195 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/__test_utils__/configuration-fixtures.ts | 3 - src/__test_utils__/seed.ts | 1 - src/backend/db/schema.ts | 6 + .../analytics/parameter-usage.test.ts | 4 +- .../features/analytics/routes.worker.test.ts | 1 - .../features/build-checker/contract.ts | 2 + src/backend/features/build-checker/routes.ts | 2 + .../configurations/combinations.test.ts | 96 +- .../features/configurations/combinations.ts | 104 +- .../features/configurations/contract.ts | 2 - .../features/favorites/routes.worker.test.ts | 4 +- .../features/library/insertables/routes.ts | 204 +-- .../library/insertables/routes.worker.test.ts | 50 + src/backend/features/load/load-insertable.ts | 22 +- .../load/parse-configuration-records.test.ts | 47 +- .../load/parse-configuration-records.ts | 22 +- .../features/load/parse-configuration.test.ts | 8 +- .../features/load/parse-configuration.ts | 1 - .../features/load/parse-vendors.test.ts | 4 - src/backend/lib/onshape/types.ts | 1 - .../build-status/components/admin-section.tsx | 18 +- .../build-status/components/build-status.tsx | 4 +- .../components/parsed-section.tsx | 114 +- src/frontend/features/build-status/queries.ts | 44 +- .../features/insert/parameter-value.test.ts | 4 - .../features/insert/quantity-box.test.ts | 1 - 29 files changed, 1735 insertions(+), 237 deletions(-) create mode 100644 drizzle/0005_excluded_parameters.sql create mode 100644 drizzle/meta/0005_snapshot.json diff --git a/drizzle/0005_excluded_parameters.sql b/drizzle/0005_excluded_parameters.sql new file mode 100644 index 000000000..7e4a571b6 --- /dev/null +++ b/drizzle/0005_excluded_parameters.sql @@ -0,0 +1 @@ +ALTER TABLE `insertables` ADD `excluded_parameter_ids` text DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 000000000..d592881d4 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1195 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "04f9ddbb-bdbb-437a-bbc7-4933163255ee", + "prevId": "eccc3c71-d808-4e4c-9ece-58a9ad2b81ff", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_insertable_id_insertables_id_fk": { + "name": "configurations_insertable_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_selection": { + "name": "default_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "favorites_user_id_library_id_insertable_id_unique": { + "name": "favorites_user_id_library_id_insertable_id_unique", + "columns": ["user_id", "library_id", "insertable_id"], + "isUnique": true + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_library_id_libraries_id_fk": { + "name": "favorites_library_id_libraries_id_fk", + "tableFrom": "favorites", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_insertable_id_insertables_id_fk": { + "name": "favorites_insertable_id_insertables_id_fk", + "tableFrom": "favorites", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_workspace_id": { + "name": "thumbnail_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_alphabetically": { + "name": "sort_alphabetically", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groups_document_id_library_id_unique": { + "name": "groups_document_id_library_id_unique", + "columns": ["document_id", "library_id"], + "isUnique": true + } + }, + "foreignKeys": { + "groups_library_id_libraries_id_fk": { + "name": "groups_library_id_libraries_id_fk", + "tableFrom": "groups", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertables": { + "name": "insertables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_type": { + "name": "element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "microversion_id": { + "name": "microversion_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_open_composite": { + "name": "is_open_composite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "supports_fasten": { + "name": "supports_fasten", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "excluded_parameter_ids": { + "name": "excluded_parameter_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vendors": { + "name": "vendors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_metadata": { + "name": "part_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "insertables_group_id_groups_id_fk": { + "name": "insertables_group_id_groups_id_fk", + "tableFrom": "insertables", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "insertables_library_id_libraries_id_fk": { + "name": "insertables_library_id_libraries_id_fk", + "tableFrom": "insertables", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "libraries": { + "name": "libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "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'" + }, + "tab_id": { + "name": "tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_library_id_libraries_id_fk": { + "name": "users_library_id_libraries_id_fk", + "tableFrom": "users", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_configuration_metrics": { + "name": "daily_configuration_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parameter_id": { + "name": "parameter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_configuration_metrics_day_idx": { + "name": "daily_configuration_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { + "columns": [ + "library_id", + "element_id", + "parameter_id", + "value", + "day" + ], + "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_metrics": { + "name": "daily_insertable_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_insertable_metrics_day_idx": { + "name": "daily_insertable_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { + "columns": [ + "library_id", + "element_id", + "day", + "target_element_type" + ], + "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_users": { + "name": "daily_insertable_users", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_users_library_id_element_id_day_user_id_pk": { + "columns": ["library_id", "element_id", "day", "user_id"], + "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_metrics": { + "name": "daily_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fasten_count": { + "name": "fasten_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_metrics_day_library_id_type_pk": { + "columns": ["day", "library_id", "type"], + "name": "daily_metrics_day_library_id_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_source_metrics": { + "name": "daily_source_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_source_metrics_day_library_id_source_pk": { + "columns": ["day", "library_id", "source"], + "name": "daily_source_metrics_day_library_id_source_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_target_metrics": { + "name": "daily_target_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_target_metrics_day_library_id_target_element_type_pk": { + "columns": ["day", "library_id", "target_element_type"], + "name": "daily_target_metrics_day_library_id_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_user_activity": { + "name": "daily_user_activity", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "daily_user_activity_day_idx": { + "name": "daily_user_activity_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_user_activity_day_library_id_user_id_pk": { + "columns": ["day", "library_id", "user_id"], + "name": "daily_user_activity_day_library_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_quick_insert": { + "name": "is_quick_insert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten": { + "name": "fasten", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "events_day_idx": { + "name": "events_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertable_stats": { + "name": "insertable_stats", + "columns": { + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_inserted_at": { + "name": "first_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_inserted_at": { + "name": "last_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "insertable_stats_count_idx": { + "name": "insertable_stats_count_idx", + "columns": ["library_id", "insert_count"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "insertable_stats_library_id_element_id_pk": { + "columns": ["library_id", "element_id"], + "name": "insertable_stats_library_id_element_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_stats_user_id_library_id_pk": { + "columns": ["user_id", "library_id"], + "name": "user_stats_user_id_library_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d48a9bb57..49e5f5718 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1790186214251, "tag": "0004_thumbnail_workspace", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1790196152689, + "tag": "0005_excluded_parameters", + "breakpoints": true } ] } diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index 693094155..fd36ea191 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -23,7 +23,6 @@ export function enumParam( id, name: id, default: optionIds[0], - isCosmetic: false, type: ParameterType.ENUM, options: optionIds.map((optionId) => ({ id: optionId, @@ -49,7 +48,6 @@ export function boolParam(id: string): BooleanParameter { id, name: id, default: "false", - isCosmetic: false, type: ParameterType.BOOLEAN }; } @@ -65,7 +63,6 @@ export function quantityParam( const parameter = { id, name: id, - isCosmetic: false, type: ParameterType.QUANTITY as const, quantityType: QuantityType.LENGTH, defaultValue: 1, diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 6e999c578..16d2a651c 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -60,7 +60,6 @@ export const TEST_PARAMETERS: ConfigurationParameter[] = [ type: ParameterType.BOOLEAN, id: "boolean", name: "Test boolean", - isCosmetic: false, default: "true" } ]; diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index dcd0cd52b..10a1a04f2 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -135,6 +135,12 @@ export const insertables = sqliteTable("insertables", { indexConfigurations: integer("index_configurations", { mode: "boolean" }) .notNull() .default(false), + // Parameters an admin left out of indexing. User-owned; preserved across + // reloads. Part studios only: an assembly indexes every one it can. + excludedParameterIds: text("excluded_parameter_ids", { mode: "json" }) + .$type() + .notNull() + .default([]), versionId: text("version_id").notNull(), versionCreatedAt: versionCreatedAt(), sortOrder: integer("sort_order").notNull().default(0), diff --git a/src/backend/features/analytics/parameter-usage.test.ts b/src/backend/features/analytics/parameter-usage.test.ts index 7e449ceb4..abdf7aa1b 100644 --- a/src/backend/features/analytics/parameter-usage.test.ts +++ b/src/backend/features/analytics/parameter-usage.test.ts @@ -17,7 +17,6 @@ describe("buildParameterUsage", () => { id: "size", name: "Size", default: "medium", - isCosmetic: false, options: [ { id: "small", name: "Small" }, { id: "medium", name: "Medium" }, @@ -160,8 +159,7 @@ describe("buildParameterUsage", () => { type: ParameterType.STRING, id: "label", name: "Label", - default: "none", - isCosmetic: false + default: "none" } ], [{ parameterId: "label", value: "custom", count: 2 }] diff --git a/src/backend/features/analytics/routes.worker.test.ts b/src/backend/features/analytics/routes.worker.test.ts index 197c7b4a7..9312c705a 100644 --- a/src/backend/features/analytics/routes.worker.test.ts +++ b/src/backend/features/analytics/routes.worker.test.ts @@ -766,7 +766,6 @@ describe("analytics routes", () => { type: ParameterType.ENUM, id: "stages", name: "Stages", - isCosmetic: false, default: "one", options: [ { id: "one", name: "1 Stage" }, diff --git a/src/backend/features/build-checker/contract.ts b/src/backend/features/build-checker/contract.ts index 533911228..7deb1ad33 100644 --- a/src/backend/features/build-checker/contract.ts +++ b/src/backend/features/build-checker/contract.ts @@ -24,6 +24,8 @@ export interface InsertableBuildStatus { isVisible: boolean; supportsFasten: boolean; indexConfigurations: boolean; + /** Parameters an admin left out of indexing; see `effectiveExclusions`. */ + excludedParameterIds: string[]; vendors: Vendor[]; configuration?: ConfigurationBuildStatus; /** When Onshape cut the version this insertable is pinned to (epoch ms). */ diff --git a/src/backend/features/build-checker/routes.ts b/src/backend/features/build-checker/routes.ts index bbf4dc0b3..dcbc7ca2a 100644 --- a/src/backend/features/build-checker/routes.ts +++ b/src/backend/features/build-checker/routes.ts @@ -50,6 +50,7 @@ buildStatusRoutes.get( isVisible: insertables.isVisible, supportsFasten: insertables.supportsFasten, indexConfigurations: insertables.indexConfigurations, + excludedParameterIds: insertables.excludedParameterIds, vendors: insertables.vendors, sortOrder: insertables.sortOrder, versionCreatedAt: insertables.versionCreatedAt @@ -105,6 +106,7 @@ buildStatusRoutes.get( isVisible: ins.isVisible, supportsFasten: ins.supportsFasten, indexConfigurations: ins.indexConfigurations, + excludedParameterIds: ins.excludedParameterIds, vendors: ins.vendors, configuration: configMap.get(ins.id), versionCreatedAt: ins.versionCreatedAt?.getTime() ?? null diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index 7b07c85f6..ec23c7a67 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -7,6 +7,7 @@ import { IndexingBand, isIndexedParameter, isIndexingEnabled, + neverIndexedReason, MAX_PART_NUMBER_CONFIGURATIONS } from "./combinations"; import { @@ -29,7 +30,6 @@ function stringParam(id: string): StringParameter { id, name: id, default: "", - isCosmetic: false, type: ParameterType.STRING }; } @@ -70,14 +70,13 @@ describe("enumerateConfigurations", () => { expect(configurations).toEqual([{ A: "a1" }, { A: "a2" }]); }); - it("ignores cosmetic parameters", () => { + it("leaves an excluded parameter at its default", () => { const params: ConfigurationParameter[] = [ enumParam("A", ["a1", "a2"]), - enumParam("C", ["c1", "c2"], { isCosmetic: true }), + enumParam("C", ["c1", "c2"]), boolParam("B") ]; - const { configurations } = enumerateConfigurations(params); - // C ("exclude from properties") rides its default, so only A and B vary. + const { configurations } = enumerateConfigurations(params, ["C"]); expect(configurations).toHaveLength(4); expect(configurations.every((c) => !("C" in c))).toBe(true); }); @@ -127,7 +126,11 @@ describe("enumerateConfigurations", () => { boolParam("B"), boolParam("C") ]; - const { configurations, capped } = enumerateConfigurations(params, 4); + const { configurations, capped } = enumerateConfigurations( + params, + [], + 4 + ); expect(capped).toBe(true); expect(configurations).toEqual([]); }); @@ -141,11 +144,9 @@ describe("countConfigurations", () => { }); }); - // Cosmetic and quantity parameters ride their defaults rather than - // multiplying the count, so they leave nothing to vary either. - it("ignores parameters that don't vary the build", () => { - const cosmetic = enumParam("A", ["x", "y"], { isCosmetic: true }); - expect(countConfigurations([cosmetic])).toMatchObject({ + it("counts an excluded parameter as varying nothing", () => { + const excluded = enumParam("A", ["x", "y"]); + expect(countConfigurations([excluded], ["A"])).toMatchObject({ count: 0, band: IndexingBand.AUTOMATIC }); @@ -182,11 +183,7 @@ describe("countConfigurations", () => { describe("countCombinations", () => { it("counts an insertable with nothing to vary as having none", () => { expect(countCombinations([])).toBe(0); - expect( - countCombinations([ - enumParam("A", ["x", "y"], { isCosmetic: true }) - ]) - ).toBe(0); + expect(countCombinations([enumParam("A", ["x", "y"])], ["A"])).toBe(0); }); it("agrees with countConfigurations under the index cap", () => { @@ -220,7 +217,7 @@ describe("countCombinations", () => { }); it("gives up past its own cap rather than counting forever", () => { - expect(countCombinations(paramsWithConfigs(64), 32)).toBeNull(); + expect(countCombinations(paramsWithConfigs(64), [], 32)).toBeNull(); }); }); @@ -243,31 +240,72 @@ describe("isIndexingEnabled", () => { describe("isIndexedParameter", () => { it("varies enum and boolean parameters", () => { - expect(isIndexedParameter(enumParam("a", ["x", "y"]))).toBe(true); - expect(isIndexedParameter(boolParam("b"))).toBe(true); + 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); - expect(isIndexedParameter(stringParam("s"))).toBe(false); + expect(isIndexedParameter(quantityParam("q"), [])).toBe(false); + expect(isIndexedParameter(stringParam("s"), [])).toBe(false); }); - it("does not vary a parameter excluded from properties", () => { - expect( - isIndexedParameter(enumParam("a", ["x", "y"], { isCosmetic: true })) - ).toBe(false); + it("does not vary a parameter an admin excluded", () => { + expect(isIndexedParameter(enumParam("a", ["x", "y"]), [], ["a"])).toBe( + false + ); + }); + + // They change how a part looks or derives, never which part it is. + it.each([ + "Derivation Variable", + "Color", + "Part colour", + "Tessellation Quality", + "Tesselation quality" + ])("never varies a parameter named %s", (name) => { + const parameter = { ...enumParam("p", ["x", "y"]), name }; + expect(neverIndexedReason(parameter)).toBeDefined(); + expect(isIndexedParameter(parameter, [parameter])).toBe(false); + }); + + const named = (name: string) => ({ ...enumParam(name, ["x", "y"]), name }); + + it("never varies a color channel beside its two siblings", () => { + const channels = ["R", "G", "B"].map(named); + for (const channel of channels) { + expect(neverIndexedReason(channel, channels)).toBeDefined(); + } + }); + + // A lone "B" is as likely a size as a blue. + it("indexes a single-letter parameter with no channel siblings", () => { + const lone = named("B"); + expect(neverIndexedReason(lone, [named("A"), lone])).toBeUndefined(); }); + it.each(["Length", "Bearing", "Gear Ratio", "Colorway"])( + "indexes an ordinary parameter named %s", + (name) => { + const parameter = { ...enumParam("p", ["x", "y"]), name }; + expect(neverIndexedReason(parameter)).toBeUndefined(); + } + ); + // 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 }), + enumParam("excluded", ["x", "y"]), + { ...boolParam("color"), name: "Color" }, quantityParam("length") ]; - const { configurations } = enumerateConfigurations(parameters); + const excluded = ["excluded"]; + const { configurations } = enumerateConfigurations( + parameters, + excluded + ); const enumeratedKeys = new Set( configurations.flatMap((configuration) => Object.keys(configuration) @@ -275,7 +313,9 @@ describe("isIndexedParameter", () => { ); expect([...enumeratedKeys].sort()).toEqual( parameters - .filter(isIndexedParameter) + .filter((parameter) => + isIndexedParameter(parameter, parameters, excluded) + ) .map((parameter) => parameter.id) .sort() ); diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index 84f2a8a29..58b115f77 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -1,6 +1,6 @@ /** - * Enumerates an insertable's configuration combinations. Only enum and boolean - * parameters vary; quantity and string ones ride their Onshape defaults. + * Enumerates an insertable's configuration combinations. Only indexed enum and + * boolean parameters vary; the rest ride their Onshape defaults. */ import { type PartialSelection, @@ -10,6 +10,7 @@ import { ParameterType } from "./contract"; import { evaluateCondition, getVisibleOptions } from "./utils"; +import { ElementType } from "../../lib/onshape/element-type"; /** * The most combinations we enumerate for one insertable; beyond it nothing is @@ -18,8 +19,8 @@ import { evaluateCondition, getVisibleOptions } from "./utils"; export const MAX_PART_NUMBER_CONFIGURATIONS = 512; /** - * At or above this, indexing waits for an admin, who can trim the count back - * with "exclude from properties"; see the `MANUAL_INDEXING_REQUIRED` build issue. + * At or above this, indexing waits for an admin, who can trim the count back by + * excluding parameters; see the `MANUAL_INDEXING_REQUIRED` build issue. */ export const AUTO_INDEX_THRESHOLD = 128; @@ -64,9 +65,13 @@ export interface ConfigurationCount { /** Shared, so the load path and the admin UI agree on which limit applies. */ export function countConfigurations( - parameters: ConfigurationParameter[] + parameters: ConfigurationParameter[], + excludedParameterIds: readonly string[] = [] ): ConfigurationCount { - const { configurations, capped } = enumerateConfigurations(parameters); + const { configurations, capped } = enumerateConfigurations( + parameters, + excludedParameterIds + ); if (capped) { return { count: null, band: IndexingBand.EXCEEDED, configurations: [] }; } @@ -87,20 +92,83 @@ export function countConfigurations( }; } +/** + * Parameters that change how a part looks or is derived, never what it is, so + * varying one only multiplies the count with copies of the same part. Matched + * by name, since Onshape records nothing that says so. + */ +const NEVER_INDEXED: { reason: string; matches: (name: string) => boolean }[] = + [ + { + reason: "A derivation variable", + matches: (name) => name.includes("derivation") + }, + { + reason: "A color", + matches: (name) => /\bcolou?r\b/.test(name) + }, + { + reason: "A tessellation setting", + matches: (name) => /tess?ell?ation/.test(name) + } + ]; + +/** A color's channels, when a part spells one out as three parameters. */ +const COLOR_CHANNELS = [ + ["r", "g", "b"], + ["red", "green", "blue"] +]; + +function normalizedName(parameter: ConfigurationParameter): string { + return parameter.name.trim().toLowerCase(); +} + +/** + * Why a parameter is never indexed, or undefined when it can be. A lone "R" or + * "B" could mean anything, so a channel counts only beside its two siblings. + */ +export function neverIndexedReason( + parameter: ConfigurationParameter, + parameters: ConfigurationParameter[] = [] +): string | undefined { + const name = normalizedName(parameter); + const rule = NEVER_INDEXED.find((entry) => entry.matches(name)); + if (rule) { + return rule.reason; + } + const names = new Set(parameters.map(normalizedName)); + const channels = COLOR_CHANNELS.find( + (set) => set.includes(name) && set.every((entry) => names.has(entry)) + ); + return channels ? "A color channel" : undefined; +} + +/** + * The exclusions that apply. An assembly takes none: Onshape does not let one + * exclude parameters from its properties either, so there is no call to make. + */ +export function effectiveExclusions( + elementType: ElementType, + excludedParameterIds: readonly string[] +): readonly string[] { + return elementType === ElementType.ASSEMBLY ? [] : excludedParameterIds; +} + /** * Whether indexing varies this parameter, and so multiplies the count. Shared * with the admin card so it cannot drift from {@link enumerateConfigurations}. */ export function isIndexedParameter( - parameter: ConfigurationParameter + parameter: ConfigurationParameter, + parameters: ConfigurationParameter[], + excludedParameterIds: readonly string[] = [] ): parameter is EnumParameter | BooleanParameter { - if ( - parameter.type !== ParameterType.ENUM && - parameter.type !== ParameterType.BOOLEAN - ) { - return false; - } - return !parameter.isCosmetic; + return ( + (parameter.type === ParameterType.ENUM || + parameter.type === ParameterType.BOOLEAN) && + neverIndexedReason(parameter, parameters) === undefined && + !excludedParameterIds.includes(parameter.id) + ); } /** @@ -129,10 +197,13 @@ export const MAX_COUNTED_CONFIGURATIONS = 100_000; /** The true count, which runs past the index cap so the admin card can show it. */ export function countCombinations( parameters: ConfigurationParameter[], + excludedParameterIds: readonly string[] = [], cap: number = MAX_COUNTED_CONFIGURATIONS ): number | null { // Depth-first: only the count is wanted, so one path is held rather than all. - const indexed = parameters.filter(isIndexedParameter); + const indexed = parameters.filter((parameter) => + isIndexedParameter(parameter, parameters, excludedParameterIds) + ); let count = 0; let capped = false; @@ -177,12 +248,13 @@ interface EnumerateResult { */ export function enumerateConfigurations( parameters: ConfigurationParameter[], + excludedParameterIds: readonly string[] = [], cap: number = MAX_PART_NUMBER_CONFIGURATIONS ): EnumerateResult { let configurations: PartialSelection[] = [{}]; for (const parameter of parameters) { - if (!isIndexedParameter(parameter)) { + if (!isIndexedParameter(parameter, parameters, excludedParameterIds)) { continue; } diff --git a/src/backend/features/configurations/contract.ts b/src/backend/features/configurations/contract.ts index 7e4d9d08d..b568a2774 100644 --- a/src/backend/features/configurations/contract.ts +++ b/src/backend/features/configurations/contract.ts @@ -101,8 +101,6 @@ interface ConfigurationParameterBase { id: string; name: string; default: string; - /** Parameters excluded from configuration properties. */ - isCosmetic: boolean; condition?: VisibilityCondition; } export interface BooleanParameter extends ConfigurationParameterBase { diff --git a/src/backend/features/favorites/routes.worker.test.ts b/src/backend/features/favorites/routes.worker.test.ts index d70f2e78f..1bdca3705 100644 --- a/src/backend/features/favorites/routes.worker.test.ts +++ b/src/backend/features/favorites/routes.worker.test.ts @@ -65,8 +65,8 @@ async function fillFavorites(howMany: number) { sortOrder: i })); // D1 binds at most 100 parameters per query, and an insertable row spends - // sixteen of them, so these go in small chunks rather than one statement. - for (const chunk of inChunks(rows, 6)) { + // seventeen of them, so these go in small chunks rather than one statement. + for (const chunk of inChunks(rows, 5)) { await db.insert(insertables).values( chunk.map((row) => ({ id: row.insertableId, diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 55a130980..ed955dc68 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -3,7 +3,8 @@ import { handledError, internalError } from "../../../lib/api-error"; import { validate } from "../../../lib/validate"; import { HttpStatus } from "http-status-ts"; import z from "zod"; -import { getApp } from "../../../lib/context"; +import { type AppContext, getApp } from "../../../lib/context"; +import type { BatchItem } from "drizzle-orm/batch"; import { getInsertableParam, insertableRoute } from "../../../lib/route-params"; import { getDb, type Db } from "../../../db/client"; import { @@ -22,6 +23,7 @@ import { INDEXING_ISSUE_TYPES, NO_RECORDS, decideIndexing, + type IndexingSettings, parseConfigurationRecords } from "../../load/parse-configuration-records"; import { ElementType } from "../../../lib/onshape/element-type"; @@ -53,6 +55,10 @@ const setFastenBody = z.object({ supportsFasten: z.boolean() }); const indexConfigurationsBody = z.object({ indexConfigurations: z.boolean() }); +const excludedParametersBody = z.object({ + excludedParameterIds: z.array(z.string()) +}); + insertableRoutes.post( "/toggle-insert-and-fasten" + insertableRoute(), requireEditorMiddleware, @@ -102,101 +108,119 @@ insertableRoutes.post( requireEditorMiddleware, validate("json", indexConfigurationsBody), async (c) => { - const db = getDb(c.env.DB); - const insertableId = getInsertableParam(c); - const body = c.req.valid("json"); - - const row = await db - .select({ - libraryId: insertables.libraryId, - documentId: insertables.documentId, - versionId: insertables.versionId, - elementId: insertables.elementId, - elementType: insertables.elementType, - vendors: insertables.vendors, - isOpenComposite: insertables.isOpenComposite, - buildIssues: insertables.buildIssues - }) - .from(insertables) - .where(eq(insertables.id, insertableId)) - .get(); - if (!row) - throw internalError("Insertable not found", HttpStatus.NOT_FOUND); + const { indexConfigurations } = c.req.valid("json"); + await reindex(c, getInsertableParam(c), { indexConfigurations }); + return c.json({ success: true }); + } +); - const parameters = - ( - await db - .select({ parameters: configurations.parameters }) - .from(configurations) - .where(eq(configurations.insertableId, insertableId)) - .get() - )?.parameters ?? []; - const indexing = decideIndexing( - row.elementType, - parameters, - body.indexConfigurations - ); +/** POST /api/excluded-parameters/insertable/:insertableId */ +insertableRoutes.post( + "/excluded-parameters" + insertableRoute(), + requireEditorMiddleware, + validate("json", excludedParametersBody), + async (c) => { + const { excludedParameterIds } = c.req.valid("json"); + await reindex(c, getInsertableParam(c), { excludedParameterIds }); + return c.json({ success: true }); + } +); - // Index before committing anything: if this throws, nothing is written. - // The error reaches the client via the app's onError handler. - const indexed = indexing.shouldIndex - ? await parseConfigurationRecords( - await c.var.getOnshapeApi(), - { - elementPath: toElementPath(row), - elementType: row.elementType, - isOpenComposite: row.isOpenComposite - }, - parameters, - indexing.configurations - ) - : NO_RECORDS; - - // Clear first, so an issue the reindex resolved (or that disabling makes - // moot) doesn't stick around. - const buildIssues = addBuildIssue( - clearBuildIssue(row.buildIssues, ...INDEXING_ISSUE_TYPES), - ...indexed.buildIssues, - ...indexing.buildIssues +/** + * Re-probes an insertable's configurations under changed indexing settings. + * Probes before committing anything: if that throws, nothing is written. + */ +async function reindex( + c: AppContext, + insertableId: string, + change: Partial +): Promise { + const db = getDb(c.env.DB); + const row = await db + .select({ + libraryId: insertables.libraryId, + documentId: insertables.documentId, + versionId: insertables.versionId, + elementId: insertables.elementId, + elementType: insertables.elementType, + isOpenComposite: insertables.isOpenComposite, + buildIssues: insertables.buildIssues, + indexConfigurations: insertables.indexConfigurations, + excludedParameterIds: insertables.excludedParameterIds, + parameters: configurations.parameters + }) + .from(insertables) + .leftJoin( + configurations, + eq(configurations.insertableId, insertables.id) + ) + .where(eq(insertables.id, insertableId)) + .get(); + if (!row) { + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); + } + if ( + change.excludedParameterIds && + row.elementType === ElementType.ASSEMBLY + ) { + throw handledError( + "An assembly indexes every parameter it can; none can be excluded.", + HttpStatus.BAD_REQUEST ); + } - // A configurations row exists exactly when the insertable is configurable. - const configWrite = - parameters.length > 0 - ? db - .insert(configurations) - .values({ - insertableId, - parameters, - records: indexed.records - }) - .onConflictDoUpdate({ - target: configurations.insertableId, - set: { records: indexed.records } - }) - : db - .delete(configurations) - .where(eq(configurations.insertableId, insertableId)); - - await db.batch([ + const parameters = row.parameters ?? []; + const settings: IndexingSettings = { + indexConfigurations: row.indexConfigurations, + excludedParameterIds: row.excludedParameterIds, + ...change + }; + const indexing = decideIndexing(row.elementType, parameters, settings); + const indexed = indexing.shouldIndex + ? await parseConfigurationRecords( + await c.var.getOnshapeApi(), + { + elementPath: toElementPath(row), + elementType: row.elementType, + isOpenComposite: row.isOpenComposite + }, + parameters, + indexing.configurations + ) + : NO_RECORDS; + + // Cleared first, so an issue the reindex resolved doesn't stick around. + const buildIssues = addBuildIssue( + clearBuildIssue(row.buildIssues, ...INDEXING_ISSUE_TYPES), + ...indexed.buildIssues, + ...indexing.buildIssues + ); + + const writes: BatchItem<"sqlite">[] = [ + db + .update(insertables) + .set({ + ...settings, + partMetadata: indexed.partMetadata, + buildIssues + }) + .where(eq(insertables.id, insertableId)) + ]; + if (parameters.length > 0) { + writes.push( db - .update(insertables) - .set({ - indexConfigurations: body.indexConfigurations, - partMetadata: indexed.partMetadata, - buildIssues - }) - .where(eq(insertables.id, insertableId)), - configWrite - ]); - - // Records feed the search index; rebuild before the bump makes the - // /search-db url immutable, or a stale index gets pinned for a year. - await rebuildSearchDb(c.env.BLOB, db, row.libraryId); - await bumpLibraryVersion(db, row.libraryId); - return c.json({ success: true }); + .update(configurations) + .set({ records: indexed.records }) + .where(eq(configurations.insertableId, insertableId)) + ); } -); + await db.batch([writes[0], ...writes.slice(1)]); + + // 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); +} /** * The tab being inserted into, in the body so the whole path arrives as one diff --git a/src/backend/features/library/insertables/routes.worker.test.ts b/src/backend/features/library/insertables/routes.worker.test.ts index 2aee6e818..abe386381 100644 --- a/src/backend/features/library/insertables/routes.worker.test.ts +++ b/src/backend/features/library/insertables/routes.worker.test.ts @@ -505,6 +505,56 @@ describe("insertable routes", () => { expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); + // Excluding a parameter re-probes without it, so its options stop + // multiplying the records. + it("POST /excluded-parameters stores the exclusion and reindexes without it", async () => { + await seedPartStudio(db); + await db.insert(configurations).values({ + insertableId: TEST_PART_STUDIO_ID, + parameters: [ + enumParam("size", ["s", "l"]), + enumParam("finish", ["matte", "gloss"]) + ] + }); + const parts = vi + .spyOn(PartsEndpoints, "getParts") + .mockResolvedValue([{ partId: "p", partNumber: "PN" }]); + + const res = await createTestApp().request( + `/api/excluded-parameters/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { excludedParameterIds: ["finish"] }), + env + ); + expect(res.status).toBe(200); + + expect( + (await readInsertable(TEST_PART_STUDIO_ID))?.excludedParameterIds + ).toEqual(["finish"]); + // The default, then size=l alone: finish rides its default. + expect(parts).toHaveBeenCalledTimes(2); + expect( + (await readConfig(TEST_PART_STUDIO_ID))?.records.map( + (record) => record.values + ) + ).toEqual([{ size: "l" }]); + }); + + // Onshape lets no assembly exclude parameters from its properties, so + // neither does the app. + it("POST /excluded-parameters refuses an assembly", async () => { + await seedAssembly(db); + + const res = await createTestApp().request( + `/api/excluded-parameters/insertable/${TEST_ASSEMBLY_ID}`, + jsonRequest("POST", { excludedParameterIds: ["size"] }), + env + ); + expect(res.status).toBe(400); + expect( + (await readInsertable(TEST_ASSEMBLY_ID))?.excludedParameterIds + ).toEqual([]); + }); + it("POST /index-configurations leaves the flag off when indexing fails", async () => { await seedPartStudio(db); vi.spyOn(PartsEndpoints, "getParts").mockRejectedValue( diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index c9df1b481..b3a50f44a 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -30,6 +30,7 @@ import { decideIndexing, indexRecords, type ConfigurationRecordsResult, + type IndexingSettings, type ProbeTarget } from "./parse-configuration-records"; import { @@ -56,10 +57,8 @@ export interface ParsedInsertable { } /** The user-owned flags that decide how much of a load runs. */ -interface InsertableFlags { +interface InsertableFlags extends IndexingSettings { supportsFasten: boolean; - /** Forces part-number indexing on, overriding the auto heuristic. */ - indexConfigurations: boolean; } /** @@ -146,11 +145,7 @@ async function probeInsertable( // decides how much of the rest of the load is worth running. const hasParts = !hasBuildIssue(parts.buildIssues, BuildIssueType.NO_PARTS); - const indexing = decideIndexing( - target.elementType, - parameters, - flags.indexConfigurations - ); + const indexing = decideIndexing(target.elementType, parameters, flags); const recordsResult = indexing.shouldIndex ? await loadConfigurationRecords( @@ -189,12 +184,19 @@ function readFlagsStep( const row = await getDb(ctx.env.DB) .select({ supportsFasten: insertables.supportsFasten, - indexConfigurations: insertables.indexConfigurations + indexConfigurations: insertables.indexConfigurations, + excludedParameterIds: insertables.excludedParameterIds }) .from(insertables) .where(eq(insertables.id, insertableId)) .get(); - return row ?? { supportsFasten: false, indexConfigurations: false }; + return ( + row ?? { + supportsFasten: false, + indexConfigurations: false, + excludedParameterIds: [] + } + ); }); } diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts index 731aa04d8..6e77c8fb3 100644 --- a/src/backend/features/load/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -38,6 +38,7 @@ const CLIENT = {} as OnshapeApi; afterEach(() => vi.restoreAllMocks()); +const NO_SETTINGS = { indexConfigurations: false, excludedParameterIds: [] }; const MANY = [{ type: BuildIssueType.MANUAL_INDEXING_REQUIRED }]; const TOO_MANY = [{ type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED }]; @@ -58,7 +59,7 @@ describe("decideIndexing", () => { const { shouldIndex, buildIssues } = decideIndexing( ElementType.PART_STUDIO, paramsWithConfigs(configs), - force + { indexConfigurations: force, excludedParameterIds: [] } ); expect({ shouldIndex, buildIssues }).toEqual({ shouldIndex: index, @@ -66,12 +67,41 @@ describe("decideIndexing", () => { }); }); - // No assembly configures its part properties, so the default probe is the - // whole of it however many combinations the count would have enumerated. - it("probes only the default for an assembly", () => { + it("indexes an assembly the way it does a part studio", () => { + const decision = decideIndexing( + ElementType.ASSEMBLY, + paramsWithConfigs(3), + NO_SETTINGS + ); + expect(decision.shouldIndex).toBe(true); + expect(decision.configurations).toHaveLength(3); + }); + + it("waits on an admin for an assembly past the threshold", () => { + expect( + decideIndexing(ElementType.ASSEMBLY, paramsWithConfigs(128), { + ...NO_SETTINGS, + indexConfigurations: true + }).shouldIndex + ).toBe(true); + }); + + // A part studio's exclusions trim the count; an assembly cannot exclude + // any, so a stray list on one changes nothing. + it("applies exclusions to a part studio but not an assembly", () => { + const parameters = [ + enumParam("A", ["a1", "a2"]), + enumParam("B", ["b1", "b2"]) + ]; + const settings = { ...NO_SETTINGS, excludedParameterIds: ["B"] }; + expect( + decideIndexing(ElementType.PART_STUDIO, parameters, settings) + .configurations + ).toHaveLength(2); expect( - decideIndexing(ElementType.ASSEMBLY, paramsWithConfigs(600), false) - ).toEqual({ shouldIndex: true, buildIssues: [], configurations: [] }); + decideIndexing(ElementType.ASSEMBLY, parameters, settings) + .configurations + ).toHaveLength(4); }); }); @@ -198,7 +228,10 @@ function probeSelections( parameters: ConfigurationParameter[], elementType: ElementType = ElementType.PART_STUDIO ): PartialSelection[] { - return decideIndexing(elementType, parameters, true).configurations; + return decideIndexing(elementType, parameters, { + indexConfigurations: true, + excludedParameterIds: [] + }).configurations; } /** Probes an element the way the load does: its own combinations, in full. */ diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index 83ff24765..15439cbda 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -20,6 +20,7 @@ import { } from "../build-checker/issues"; import { countConfigurations, + effectiveExclusions, IndexingBand, isIndexingEnabled } from "../configurations/combinations"; @@ -73,19 +74,24 @@ interface IndexingDecision { configurations: PartialSelection[]; } +/** What an admin decided about indexing an insertable. */ +export interface IndexingSettings { + /** Index even past the automatic threshold. */ + indexConfigurations: boolean; + excludedParameterIds: string[]; +} + /** Past the hard cap forcing it on cannot help, since enumeration stops there. */ export function decideIndexing( elementType: ElementType, parameters: ConfigurationParameter[], - indexConfigurations: boolean + settings: IndexingSettings ): IndexingDecision { - // No assembly configures its part properties today, so every combination - // would probe back to what the default already says. - if (elementType === ElementType.ASSEMBLY) { - return { shouldIndex: true, buildIssues: [], configurations: [] }; - } - - const { band, configurations } = countConfigurations(parameters); + const { indexConfigurations } = settings; + const { band, configurations } = countConfigurations( + parameters, + effectiveExclusions(elementType, settings.excludedParameterIds) + ); const shouldIndex = isIndexingEnabled(band, indexConfigurations); if (band === IndexingBand.EXCEEDED) { diff --git a/src/backend/features/load/parse-configuration.test.ts b/src/backend/features/load/parse-configuration.test.ts index 93e261f49..630c37d1e 100644 --- a/src/backend/features/load/parse-configuration.test.ts +++ b/src/backend/features/load/parse-configuration.test.ts @@ -27,7 +27,6 @@ const RESPONSE: OnshapeConfigurationResponse = { btType: OnshapeParameterType.BOOLEAN, parameterId: "Show_list", parameterName: "Show list", - isCosmetic: true, defaultValue: true, visibilityCondition: NONE }, @@ -35,7 +34,6 @@ const RESPONSE: OnshapeConfigurationResponse = { btType: OnshapeParameterType.ENUM, parameterId: "Vendor", parameterName: "Vendor", - isCosmetic: false, defaultValue: "Default", options: [ { option: "Default", optionName: "WCP" }, @@ -58,7 +56,6 @@ const RESPONSE: OnshapeConfigurationResponse = { btType: OnshapeParameterType.ENUM, parameterId: "List", parameterName: "List", - isCosmetic: false, defaultValue: "WCP_1", options: [ { option: "Default", optionName: "Always shown" }, @@ -99,7 +96,6 @@ const RESPONSE: OnshapeConfigurationResponse = { btType: OnshapeParameterType.QUANTITY, parameterId: "TTB_Length", parameterName: "TTB Length", - isCosmetic: false, quantityType: QuantityType.LENGTH, rangeAndDefault: { defaultValue: 1, @@ -134,12 +130,11 @@ describe("parseOnshapeConfiguration", () => { ]); }); - it("parses a BOOLEAN parameter and its cosmetic flag", () => { + it("parses a BOOLEAN parameter", () => { expect(parameters[0]).toEqual({ type: ParameterType.BOOLEAN, id: "Show_list", name: "Show list", - isCosmetic: true, default: "true", condition: undefined }); @@ -147,7 +142,6 @@ describe("parseOnshapeConfiguration", () => { it("parses an ENUM parameter with options and a logical condition", () => { const vendor = parameters[1]; - expect(vendor.isCosmetic).toBe(false); if (vendor.type !== ParameterType.ENUM) throw new Error("expected ENUM"); expect(vendor.default).toBe("Default"); diff --git a/src/backend/features/load/parse-configuration.ts b/src/backend/features/load/parse-configuration.ts index f743d447c..99212ab95 100644 --- a/src/backend/features/load/parse-configuration.ts +++ b/src/backend/features/load/parse-configuration.ts @@ -122,7 +122,6 @@ export function parseOnshapeConfiguration( const base = { id: parameter.parameterId, name: parameter.parameterName, - isCosmetic: parameter.isCosmetic, condition: parseVisibilityCondition(parameter.visibilityCondition) }; diff --git a/src/backend/features/load/parse-vendors.test.ts b/src/backend/features/load/parse-vendors.test.ts index d2c63c95b..b3f5450f9 100644 --- a/src/backend/features/load/parse-vendors.test.ts +++ b/src/backend/features/load/parse-vendors.test.ts @@ -49,7 +49,6 @@ describe("parseVendors", () => { type: ParameterType.ENUM as const, id: "vendor", name: "Vendor", - isCosmetic: false, default: "wcp", condition: undefined, optionConditions: [], @@ -70,7 +69,6 @@ describe("parseVendors", () => { type: ParameterType.ENUM as const, id: "vendor", name: "Vendor", - isCosmetic: false, default: "am", condition: undefined, optionConditions: [], @@ -87,7 +85,6 @@ describe("parseVendors", () => { type: ParameterType.QUANTITY as const, id: "length", name: "Length", - isCosmetic: false, default: "10 mm", condition: undefined, quantityType: QuantityType.LENGTH, @@ -116,7 +113,6 @@ const vendorParameter = { id: "vendor", name: "Vendor", default: "wcp", - isCosmetic: false, type: ParameterType.ENUM as const, options: [ { id: "wcp", name: "West Coast Products" }, diff --git a/src/backend/lib/onshape/types.ts b/src/backend/lib/onshape/types.ts index aa1596d21..614a37c03 100644 --- a/src/backend/lib/onshape/types.ts +++ b/src/backend/lib/onshape/types.ts @@ -115,7 +115,6 @@ interface OnshapeQuantityRange { interface OnshapeParameterBase { parameterId: string; parameterName: string; - isCosmetic: boolean; visibilityCondition: OnshapeVisibilityCondition; } diff --git a/src/frontend/features/build-status/components/admin-section.tsx b/src/frontend/features/build-status/components/admin-section.tsx index 6a25edfcf..dbb1b4570 100644 --- a/src/frontend/features/build-status/components/admin-section.tsx +++ b/src/frontend/features/build-status/components/admin-section.tsx @@ -127,25 +127,23 @@ function IndexingRow(props: IndexingRowProps): ReactNode { const mutation = useIndexConfigurationsMutation(insertableId); let control: ReactNode; - if (status.elementType === ElementType.ASSEMBLY) { - control = ( - - ); - } else if (band === IndexingBand.EXCEEDED) { + if (band === IndexingBand.EXCEEDED) { + // Only a part studio's parameters can be excluded to bring it under. + const remedy = + status.elementType === ElementType.ASSEMBLY + ? "" + : " To resolve, exclude parameters from indexing below."; control = ( ); } else if (band === IndexingBand.AUTOMATIC) { control = ( ); } else { diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index 47cbaaf2f..d9c923cee 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -289,9 +289,7 @@ function InsertableHoverMenu(props: InsertableHoverMenuProps): ReactNode { configurationCount={configurationCount} /> - + ); } diff --git a/src/frontend/features/build-status/components/parsed-section.tsx b/src/frontend/features/build-status/components/parsed-section.tsx index ac1407f73..b7c6b9ab6 100644 --- a/src/frontend/features/build-status/components/parsed-section.tsx +++ b/src/frontend/features/build-status/components/parsed-section.tsx @@ -1,5 +1,6 @@ import { Badge, + Checkbox, Divider, Group, ScrollArea, @@ -7,7 +8,7 @@ import { Text, Tooltip } from "@mantine/core"; -import { CheckIcon, FileXIcon, XIcon } from "@phosphor-icons/react"; +import { CheckIcon, ProhibitIcon, XIcon } from "@phosphor-icons/react"; import { ReactNode, useMemo } from "react"; import { InsertableBuildStatus } from "@backend/features/build-checker/contract"; import { getVendorName, Vendor } from "@backend/features/library/vendors"; @@ -19,7 +20,9 @@ import { type ConfigurationCount, countCombinations, countConfigurations, - MAX_COUNTED_CONFIGURATIONS + effectiveExclusions, + MAX_COUNTED_CONFIGURATIONS, + neverIndexedReason } from "@backend/features/configurations/combinations"; import { CATEGORY_COLOR, @@ -28,6 +31,8 @@ import { } from "../../../lib/style-constants"; import { AppIcon } from "../../../components/app-icon"; import { SectionHeader } from "./sections"; +import { useExcludedParametersMutation } from "../queries"; +import { ElementType } from "@backend/lib/onshape/element-type"; import styles from "../../../lib/styles.module.css"; /** Discriminated so `StateValue` renders each kind its own way. */ @@ -43,16 +48,32 @@ type StateRowValue = export function useConfigurationCount( status: InsertableBuildStatus ): ConfigurationCount { + const { elementType, excludedParameterIds } = status; const parameters = status.configuration?.parameters; - return useMemo(() => countConfigurations(parameters ?? []), [parameters]); + return useMemo( + () => + countConfigurations( + parameters ?? [], + effectiveExclusions(elementType, excludedParameterIds) + ), + [parameters, elementType, excludedParameterIds] + ); } /** The true total, which runs past the index cap the band is decided by. */ function useDisplayedConfigurationCount( status: InsertableBuildStatus ): number | null { + const { elementType, excludedParameterIds } = status; const parameters = status.configuration?.parameters; - return useMemo(() => countCombinations(parameters ?? []), [parameters]); + return useMemo( + () => + countCombinations( + parameters ?? [], + effectiveExclusions(elementType, excludedParameterIds) + ), + [parameters, elementType, excludedParameterIds] + ); } /** Open-ended only past the counting cap, which nothing real reaches. */ @@ -101,14 +122,16 @@ export function InsertableParsedSection( const PARAMETER_LIST_MAX_HEIGHT = 220; interface ConfigurationSectionProps { - parameters?: ConfigurationParameter[]; + insertableId: string; + status: InsertableBuildStatus; } /** Each parameter's name, the type it takes, and whether indexing varies it. */ export function ConfigurationSection( props: ConfigurationSectionProps ): ReactNode { - const { parameters } = props; + const { insertableId, status } = props; + const parameters = status.configuration?.parameters; if (!parameters || parameters.length === 0) return null; return ( <> @@ -123,6 +146,8 @@ export function ConfigurationSection( {parameters.map((parameter) => ( ))} @@ -134,48 +159,79 @@ export function ConfigurationSection( } interface ParameterRowProps { + insertableId: string; + status: InsertableBuildStatus; parameter: ConfigurationParameter; } -/** One parameter: its name, and what varies or excludes it. */ +/** One parameter: its name, its type, and whether indexing varies it. */ function ParameterRow(props: ParameterRowProps): ReactNode { const { parameter } = props; return ( {parameter.name} - - + + ); } -interface ExcludedFromPropertiesIconProps { - parameter: ConfigurationParameter; -} - /** - * Onshape's "exclude from affecting configured properties", the lever on the - * count. Part studios only, which Onshape itself enforces. + * Only enums and booleans are enumerated, so only they have anything to say. + * A never-indexed one says why; a part studio's can be excluded by hand, which + * an assembly's cannot. */ -function ExcludedFromPropertiesIcon( - props: ExcludedFromPropertiesIconProps -): ReactNode { - const { parameter } = props; - if (!parameter.isCosmetic) { +function IndexedControl(props: ParameterRowProps): ReactNode { + const { insertableId, status, parameter } = props; + const mutation = useExcludedParametersMutation(insertableId); + + if ( + parameter.type !== ParameterType.ENUM && + parameter.type !== ParameterType.BOOLEAN + ) { + return null; + } + const reason = neverIndexedReason( + parameter, + status.configuration?.parameters + ); + if (reason) { + return ( + + + + ); + } + if (status.elementType === ElementType.ASSEMBLY) { return null; } + + const excluded = status.excludedParameterIds; + const isIndexed = !excluded.includes(parameter.id); return ( - - + + mutation.mutate( + isIndexed + ? [...excluded, parameter.id] + : excluded.filter((id) => id !== parameter.id) + ) + } /> ); diff --git a/src/frontend/features/build-status/queries.ts b/src/frontend/features/build-status/queries.ts index d6f0a28f6..f30e1f863 100644 --- a/src/frontend/features/build-status/queries.ts +++ b/src/frontend/features/build-status/queries.ts @@ -160,7 +160,7 @@ export function useToggleInsertAndFastenMutation(insertableId: string) { } /** - * Toggles part-number indexing for an insertable. The Onshape call behind it + * Toggles indexing for an insertable. The Onshape call behind it * runs long, so the toast reports the switch rather than sitting on the response. */ export function useIndexConfigurationsMutation(insertableId: string) { @@ -176,8 +176,8 @@ export function useIndexConfigurationsMutation(insertableId: string) { onMutate: (indexConfigurations) => { showInfoToast( indexConfigurations - ? "Enabling part indexing" - : "Disabling part indexing", + ? "Enabling indexing" + : "Disabling indexing", { id: toastId } ); return patchQuery(key, (status) => { @@ -189,12 +189,44 @@ export function useIndexConfigurationsMutation(insertableId: string) { onSuccess: (_result, indexConfigurations) => showSuccessToast( indexConfigurations - ? "Part number indexing enabled." - : "Part number indexing disabled.", + ? "Indexing enabled." + : "Indexing disabled.", toastId ), onError: getAppErrorHandler( - "Unexpectedly failed to update part number indexing.", + "Unexpectedly failed to update indexing.", + toastId + ), + onSettled: (_result, error) => + refreshLibrary({ discardPatches: error !== null }) + }); +} + +/** + * Sets which of a part studio's parameters indexing leaves out. Re-probes the + * part like toggling indexing does, so the toast reports the change first. + */ +export function useExcludedParametersMutation(insertableId: string) { + const key = useBuildStatusKey(); + const refreshLibrary = useRefreshLibrary(); + const toastId = `excluded-parameters-${insertableId}`; + return useMutation({ + mutationKey: ["excluded-parameters", insertableId], + mutationFn: (excludedParameterIds: string[]) => + apiPost("/excluded-parameters" + toInsertablePath(insertableId), { + body: { excludedParameterIds } + }), + onMutate: (excludedParameterIds) => { + showInfoToast("Reindexing part", { id: toastId }); + return patchQuery(key, (status) => { + const insertable = status.insertables[insertableId]; + if (insertable) + insertable.excludedParameterIds = excludedParameterIds; + }); + }, + onSuccess: () => showSuccessToast("Part reindexed.", toastId), + onError: getAppErrorHandler( + "Unexpectedly failed to update the indexed parameters.", toastId ), onSettled: (_result, error) => diff --git a/src/frontend/features/insert/parameter-value.test.ts b/src/frontend/features/insert/parameter-value.test.ts index f3c88cb3c..3b39490f3 100644 --- a/src/frontend/features/insert/parameter-value.test.ts +++ b/src/frontend/features/insert/parameter-value.test.ts @@ -14,7 +14,6 @@ const SIZE: ConfigurationParameter = { id: "size", name: "Size", default: "small", - isCosmetic: false, type: ParameterType.ENUM, options: [ { id: "small", name: "Small" }, @@ -28,7 +27,6 @@ const REINFORCED: ConfigurationParameter = { id: "reinforced", name: "Reinforced", default: "false", - isCosmetic: false, type: ParameterType.BOOLEAN, condition: { type: VisibilityType.EQUAL, id: "size", value: "large" } }; @@ -120,7 +118,6 @@ describe("normalizeSelection", () => { id: "material", name: "Material", default: "alu", - isCosmetic: false, type: ParameterType.ENUM, options: [ { id: "alu", name: "Aluminium" }, @@ -159,7 +156,6 @@ describe("normalizeSelection", () => { id: "bolts", name: "Bolts", default: "2", - isCosmetic: false, type: ParameterType.ENUM, options: [ { id: "2", name: "Two" }, diff --git a/src/frontend/features/insert/quantity-box.test.ts b/src/frontend/features/insert/quantity-box.test.ts index 2b17ac869..3b346b6a0 100644 --- a/src/frontend/features/insert/quantity-box.test.ts +++ b/src/frontend/features/insert/quantity-box.test.ts @@ -10,7 +10,6 @@ import { seedFrom } from "./quantity-box"; const SHAFT_LENGTH: QuantityParameter = { id: "Length", name: "Length", - isCosmetic: false, type: ParameterType.QUANTITY, quantityType: QuantityType.LENGTH, default: "47 in", From eeb6be236a58f78d6d1e03b6a52ed848cad7ace6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:12:04 +0000 Subject: [PATCH 13/88] Give parameters roles, and fill derivation variables with a unique value Parameters recognized by name (derivation variable, color and its R/G/B channels, tessellation) now carry a ParameterRole, shown with an icon in the build status tooltip. A text derivation variable is read-only in the insert menu and filled with a fresh UUID on every derive, since Onshape refuses to derive the same part and configuration twice. It is left out of thumbnail keys, analytics values and saved favorites. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/__test_utils__/configuration-fixtures.ts | 5 ++ src/backend/db/schema.ts | 9 +-- .../configurations/combinations.test.ts | 53 ++------------- .../features/configurations/combinations.ts | 60 ++-------------- src/backend/features/configurations/legacy.ts | 6 +- .../features/configurations/roles.test.ts | 55 +++++++++++++++ src/backend/features/configurations/roles.ts | 68 +++++++++++++++++++ .../features/configurations/selection.test.ts | 50 ++++++++++++-- .../features/configurations/selection.ts | 55 +++++++++++++-- src/backend/features/favorites/routes.ts | 28 ++++++-- .../features/favorites/routes.worker.test.ts | 22 +++++- .../features/library/insertables/routes.ts | 9 ++- .../library/insertables/routes.worker.test.ts | 40 ++++++++++- src/frontend/components/parameter-role.tsx | 45 ++++++++++++ .../components/parsed-section.tsx | 44 ++++++------ .../insert/components/configurations.test.tsx | 20 +++++- .../insert/components/configurations.tsx | 60 +++++++++++++--- 17 files changed, 477 insertions(+), 152 deletions(-) create mode 100644 src/backend/features/configurations/roles.test.ts create mode 100644 src/backend/features/configurations/roles.ts create mode 100644 src/frontend/components/parameter-role.tsx diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index fd36ea191..b465e98df 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -8,6 +8,7 @@ import { type ConfigurationRecord, type EnumParameter, type QuantityParameter, + type StringParameter, type UnitInfo } from "@backend/features/configurations/contract"; import { QuantityType, Unit } from "@backend/features/configurations/enums"; @@ -52,6 +53,10 @@ export function boolParam(id: string): BooleanParameter { }; } +export function stringParam(id: string): StringParameter { + return { id, name: id, default: "", type: ParameterType.STRING }; +} + /** * A length quantity parameter defaulting to 1 inch, its default spelled the way * `parseOnshapeConfiguration` stores one: from `defaultValue` and `unit`. diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 10a1a04f2..ef114d6da 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -14,7 +14,7 @@ import { Vendor } from "../features/library/vendors"; import { ConfigurationParameter, ConfigurationRecord, - Selection, + PartialSelection, PartMetadata } from "../features/configurations/contract"; import { BuildIssue, knownBuildIssues } from "../features/build-checker/issues"; @@ -215,11 +215,12 @@ export const favorites = sqliteTable( insertableId: text("insertable_id") .notNull() .references(() => insertables.id, { onDelete: "cascade" }), - // The selection the favorite opens with, whole and as it was entered. - // Null for an insertable with nothing to configure. + // The selection the favorite opens with, as it was entered, less the + // derivation variables each insert fills afresh. Null for an + // insertable with nothing to configure. defaultSelection: text("default_selection", { mode: "json" - }).$type(), + }).$type(), sortOrder: integer("sort_order").notNull().default(0), // Null on rows predating the column: backfilling would draw a cliff // of favorites on a day nobody favorited anything. diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index ec23c7a67..28d51d491 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -7,14 +7,11 @@ import { IndexingBand, isIndexedParameter, isIndexingEnabled, - neverIndexedReason, MAX_PART_NUMBER_CONFIGURATIONS } from "./combinations"; import { OptionVisibilityType, ConfigurationParameter, - ParameterType, - StringParameter, VisibilityCondition, VisibilityType } from "./contract"; @@ -22,18 +19,10 @@ import { boolParam, enumParam, paramsWithConfigs, - quantityParam + quantityParam, + stringParam } from "../../../__test_utils__/configuration-fixtures"; -function stringParam(id: string): StringParameter { - return { - id, - name: id, - default: "", - type: ParameterType.STRING - }; -} - const equals = (id: string, value: string): VisibilityCondition => ({ type: VisibilityType.EQUAL, id, @@ -255,42 +244,12 @@ describe("isIndexedParameter", () => { ); }); - // They change how a part looks or derives, never which part it is. - it.each([ - "Derivation Variable", - "Color", - "Part colour", - "Tessellation Quality", - "Tesselation quality" - ])("never varies a parameter named %s", (name) => { - const parameter = { ...enumParam("p", ["x", "y"]), name }; - expect(neverIndexedReason(parameter)).toBeDefined(); - expect(isIndexedParameter(parameter, [parameter])).toBe(false); - }); - - const named = (name: string) => ({ ...enumParam(name, ["x", "y"]), name }); - - it("never varies a color channel beside its two siblings", () => { - const channels = ["R", "G", "B"].map(named); - for (const channel of channels) { - expect(neverIndexedReason(channel, channels)).toBeDefined(); - } + // A role says how a part is drawn or derived, never which part it is. + it("never varies a parameter with a role", () => { + const color = { ...enumParam("p", ["x", "y"]), name: "Color" }; + expect(isIndexedParameter(color, [color])).toBe(false); }); - // A lone "B" is as likely a size as a blue. - it("indexes a single-letter parameter with no channel siblings", () => { - const lone = named("B"); - expect(neverIndexedReason(lone, [named("A"), lone])).toBeUndefined(); - }); - - it.each(["Length", "Bearing", "Gear Ratio", "Colorway"])( - "indexes an ordinary parameter named %s", - (name) => { - const parameter = { ...enumParam("p", ["x", "y"]), name }; - expect(neverIndexedReason(parameter)).toBeUndefined(); - } - ); - // The card reports indexing off this helper, so it has to describe exactly // what enumeration varies. it("matches the keys enumeration actually varies", () => { diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index 58b115f77..527cec689 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -11,6 +11,7 @@ import { } from "./contract"; import { evaluateCondition, getVisibleOptions } from "./utils"; import { ElementType } from "../../lib/onshape/element-type"; +import { parameterRole } from "./roles"; /** * The most combinations we enumerate for one insertable; beyond it nothing is @@ -92,57 +93,6 @@ export function countConfigurations( }; } -/** - * Parameters that change how a part looks or is derived, never what it is, so - * varying one only multiplies the count with copies of the same part. Matched - * by name, since Onshape records nothing that says so. - */ -const NEVER_INDEXED: { reason: string; matches: (name: string) => boolean }[] = - [ - { - reason: "A derivation variable", - matches: (name) => name.includes("derivation") - }, - { - reason: "A color", - matches: (name) => /\bcolou?r\b/.test(name) - }, - { - reason: "A tessellation setting", - matches: (name) => /tess?ell?ation/.test(name) - } - ]; - -/** A color's channels, when a part spells one out as three parameters. */ -const COLOR_CHANNELS = [ - ["r", "g", "b"], - ["red", "green", "blue"] -]; - -function normalizedName(parameter: ConfigurationParameter): string { - return parameter.name.trim().toLowerCase(); -} - -/** - * Why a parameter is never indexed, or undefined when it can be. A lone "R" or - * "B" could mean anything, so a channel counts only beside its two siblings. - */ -export function neverIndexedReason( - parameter: ConfigurationParameter, - parameters: ConfigurationParameter[] = [] -): string | undefined { - const name = normalizedName(parameter); - const rule = NEVER_INDEXED.find((entry) => entry.matches(name)); - if (rule) { - return rule.reason; - } - const names = new Set(parameters.map(normalizedName)); - const channels = COLOR_CHANNELS.find( - (set) => set.includes(name) && set.every((entry) => names.has(entry)) - ); - return channels ? "A color channel" : undefined; -} - /** * The exclusions that apply. An assembly takes none: Onshape does not let one * exclude parameters from its properties either, so there is no call to make. @@ -155,8 +105,10 @@ export function effectiveExclusions( } /** - * Whether indexing varies this parameter, and so multiplies the count. Shared - * with the admin card so it cannot drift from {@link enumerateConfigurations}. + * Whether indexing varies this parameter, and so multiplies the count. Never + * one with a role: those change how a part is drawn or derived, not which part + * it is. Shared with the admin card so it cannot drift from + * {@link enumerateConfigurations}. */ export function isIndexedParameter( parameter: ConfigurationParameter, @@ -166,7 +118,7 @@ export function isIndexedParameter( return ( (parameter.type === ParameterType.ENUM || parameter.type === ParameterType.BOOLEAN) && - neverIndexedReason(parameter, parameters) === undefined && + parameterRole(parameter, parameters) === undefined && !excludedParameterIds.includes(parameter.id) ); } diff --git a/src/backend/features/configurations/legacy.ts b/src/backend/features/configurations/legacy.ts index 47b287cf3..97cdc0492 100644 --- a/src/backend/features/configurations/legacy.ts +++ b/src/backend/features/configurations/legacy.ts @@ -8,7 +8,7 @@ import { type ConfigurationParameter, type ConfigurationRecord, ParameterType, - type Selection + type PartialSelection } from "./contract"; import { canonicalValue, quantityDefault } from "./selection"; import { decodeConfiguration } from "./utils"; @@ -53,9 +53,9 @@ export function upgradeParameters( * is left as they typed it. */ export function upgradeSelection( - selection: Selection, + selection: PartialSelection, parameters: ConfigurationParameter[] -): Selection { +): PartialSelection { const upgraded = { ...selection }; for (const parameter of parameters) { const value = upgraded[parameter.id]; diff --git a/src/backend/features/configurations/roles.test.ts b/src/backend/features/configurations/roles.test.ts new file mode 100644 index 000000000..d92807e4d --- /dev/null +++ b/src/backend/features/configurations/roles.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { isDerivationVariable, parameterRole, ParameterRole } from "./roles"; +import { + enumParam, + stringParam +} from "../../../__test_utils__/configuration-fixtures"; + +const named = (name: string) => ({ ...enumParam("p", ["x", "y"]), name }); + +describe("parameterRole", () => { + it.each([ + ["Derivation Variable", ParameterRole.DERIVATION_VARIABLE], + ["Color", ParameterRole.COLOR], + ["Part colour", ParameterRole.COLOR], + ["Tessellation Quality", ParameterRole.TESSELLATION], + ["Tesselation quality", ParameterRole.TESSELLATION] + ])("recognizes %s", (name, role) => { + expect(parameterRole(named(name))).toBe(role); + }); + + it.each(["Length", "Bearing", "Gear Ratio", "Colorway"])( + "gives an ordinary parameter named %s no role", + (name) => { + expect(parameterRole(named(name))).toBeUndefined(); + } + ); + + it("recognizes a color channel beside its two siblings", () => { + const channels = ["R", "G", "B"].map(named); + for (const channel of channels) { + expect(parameterRole(channel, channels)).toBe( + ParameterRole.COLOR_CHANNEL + ); + } + }); + + // A lone "B" is as likely a size as a blue. + it("gives a single letter with no channel siblings no role", () => { + const lone = named("B"); + expect(parameterRole(lone, [named("A"), lone])).toBeUndefined(); + }); +}); + +describe("isDerivationVariable", () => { + // Only a text one takes the unique value the app fills in. + it("is a text parameter named for derivation", () => { + expect( + isDerivationVariable({ + ...stringParam("d"), + name: "Derivation Variable" + }) + ).toBe(true); + expect(isDerivationVariable(named("Derivation Variable"))).toBe(false); + }); +}); diff --git a/src/backend/features/configurations/roles.ts b/src/backend/features/configurations/roles.ts new file mode 100644 index 000000000..11a6f016b --- /dev/null +++ b/src/backend/features/configurations/roles.ts @@ -0,0 +1,68 @@ +/** + * Parameters that are about how a part is derived or drawn rather than which + * part it is. Onshape records nothing that says so, so they are recognized by + * name; see `parameterRole`. + */ +import { type ConfigurationParameter, ParameterType } from "./contract"; + +export enum ParameterRole { + /** + * A text parameter a document adds so one part can be derived into a part + * studio more than once: Onshape refuses a second derive of the same + * configuration, and a unique value here makes each one different. + */ + DERIVATION_VARIABLE = "derivation-variable", + COLOR = "color", + /** One of a color's R, G and B, when a part spells a color out as three. */ + COLOR_CHANNEL = "color-channel", + TESSELLATION = "tessellation" +} + +/** A color's channels, when a part spells one out as three parameters. */ +const COLOR_CHANNELS = [ + ["r", "g", "b"], + ["red", "green", "blue"] +]; + +function normalizedName(parameter: ConfigurationParameter): string { + return parameter.name.trim().toLowerCase(); +} + +/** + * The role a parameter plays, if any. A lone "R" or "B" could mean anything, + * so a channel counts only beside its two siblings, which is what `parameters` + * is for. + */ +export function parameterRole( + parameter: ConfigurationParameter, + parameters: ConfigurationParameter[] = [] +): ParameterRole | undefined { + const name = normalizedName(parameter); + if (name.includes("derivation")) { + return ParameterRole.DERIVATION_VARIABLE; + } + if (/\bcolou?r\b/.test(name)) { + return ParameterRole.COLOR; + } + if (/tess?ell?ation/.test(name)) { + return ParameterRole.TESSELLATION; + } + const names = new Set(parameters.map(normalizedName)); + const isChannel = COLOR_CHANNELS.some( + (set) => set.includes(name) && set.every((entry) => names.has(entry)) + ); + return isChannel ? ParameterRole.COLOR_CHANNEL : undefined; +} + +/** + * A derivation variable the app fills in itself. Only a text one: a unique + * value is text, and nothing here knows what one of another type is for. + */ +export function isDerivationVariable( + parameter: ConfigurationParameter +): boolean { + return ( + parameter.type === ParameterType.STRING && + parameterRole(parameter) === ParameterRole.DERIVATION_VARIABLE + ); +} diff --git a/src/backend/features/configurations/selection.test.ts b/src/backend/features/configurations/selection.test.ts index 38bee3b9b..65dd1201e 100644 --- a/src/backend/features/configurations/selection.test.ts +++ b/src/backend/features/configurations/selection.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_CONFIGURATION_KEY, VisibilityType } from "./contract"; +import { + type ConfigurationParameter, + DEFAULT_CONFIGURATION_KEY, + VisibilityType +} from "./contract"; import { appliedValues, canonicalValues, @@ -8,14 +12,17 @@ import { onshapeOverrides, toKey, toSelection, - toShortestConfiguration + toShortestConfiguration, + toStoredSelection, + withDerivationValues } from "./selection"; import { QuantityType, Unit } from "./enums"; import { decodeConfiguration } from "./utils"; import { boolParam, enumParam, - quantityParam + quantityParam, + stringParam } from "../../../__test_utils__/configuration-fixtures"; const size = enumParam("size", ["s", "l"]); @@ -24,7 +31,10 @@ const length = quantityParam("length"); const parameters = [size, flag, length]; /** What every boundary does: whatever arrived, made whole. */ -function select(values: Record, params = parameters) { +function select( + values: Record, + params: ConfigurationParameter[] = parameters +) { return toSelection(values, params); } @@ -215,3 +225,35 @@ describe("formatValue", () => { expect(formatValue(flag, "unset")).toBe("unset"); }); }); + +describe("derivation variables", () => { + const derivation = { ...stringParam("dv"), name: "Derivation Variable" }; + const params: ConfigurationParameter[] = [size, derivation]; + + // Unique to each insert by design, so it must not split one render in two. + it("leaves them out of the key", () => { + expect(toKey(select({ size: "l", dv: "abc" }, params), params)).toBe( + "size=l" + ); + }); + + it("fills a fresh value for every derive", () => { + const selection = select({ size: "l" }, params); + const first = withDerivationValues(selection, params); + const second = withDerivationValues(first, params); + expect(first.dv).not.toBe(derivation.default); + expect(second.dv).not.toBe(first.dv); + }); + + // What the panel does, so the value on screen holds still. + it("keeps a value already filled when asked to", () => { + const filled = withDerivationValues(select({}, params), params); + expect(withDerivationValues(filled, params, true)).toEqual(filled); + }); + + it("leaves them out of what is stored", () => { + expect(toStoredSelection({ size: "l", dv: "abc" }, params)).toEqual({ + size: "l" + }); + }); +}); diff --git a/src/backend/features/configurations/selection.ts b/src/backend/features/configurations/selection.ts index e4cc9fd81..b2194bc62 100644 --- a/src/backend/features/configurations/selection.ts +++ b/src/backend/features/configurations/selection.ts @@ -15,6 +15,7 @@ import { type Selection } from "./contract"; import { getUnitDisplayStr } from "./enums"; +import { isDerivationVariable } from "./roles"; import { DEFAULT_QUANTITY_PRECISION, encodeConfiguration, @@ -103,7 +104,8 @@ export function canonicalValue( /** * The applied values, canonically spelled: what two selections are compared by, - * and what analytics counts, where "5 in" and "(2 + 3) in" are one value. + * and what analytics counts, where "5 in" and "(2 + 3) in" are one value. A + * derivation variable is left out, being unique to one insert by design. */ export function canonicalValues( selection: Selection, @@ -113,7 +115,7 @@ export function canonicalValues( const values: Selection = {}; for (const parameter of parameters) { const value = applied[parameter.id]; - if (value !== undefined) { + if (value !== undefined && !isDerivationVariable(parameter)) { values[parameter.id] = canonicalValue(parameter, value); } } @@ -150,7 +152,8 @@ export function onshapeOverrides( /** * A selection's thumbnail identity: what it overrides, canonically spelled. - * Two selections that render the same part key the same. + * Two selections that render the same part key the same, which a derivation + * variable, unique to each insert, would stop. */ export function toKey( selection: Selection, @@ -160,13 +163,57 @@ export function toKey( const canonical: Selection = {}; for (const parameter of parameters) { const value = overrides[parameter.id]; - if (value !== undefined) { + if (value !== undefined && !isDerivationVariable(parameter)) { canonical[parameter.id] = canonicalValue(parameter, value); } } return encodeConfiguration(canonical); } +/** + * The selection with each derivation variable given a fresh unique value, so + * deriving it cannot collide with an earlier derive of the same part. Onshape + * refuses a second derive of the same part in the same configuration. + * + * `keepFilled` leaves a value that is already set, which is what lets the panel + * show one value rather than a new one every render. + */ +export function withDerivationValues( + selection: Selection, + parameters: ConfigurationParameter[], + keepFilled = false +): Selection { + const next = { ...selection }; + for (const parameter of parameters) { + if (!isDerivationVariable(parameter)) { + continue; + } + const filled = next[parameter.id] !== parameter.default; + if (!(keepFilled && filled)) { + next[parameter.id] = crypto.randomUUID(); + } + } + return next; +} + +/** + * The selection without its derivation variables, for anything kept or shared — + * a favorite, the url. Each insert fills its own, so a kept one would only be + * a stale value to collide with. + */ +export function toStoredSelection( + selection: PartialSelection, + parameters: ConfigurationParameter[] +): PartialSelection { + const stored = { ...selection }; + for (const parameter of parameters) { + if (isDerivationVariable(parameter)) { + delete stored[parameter.id]; + } + } + return stored; +} + /** * The shortest configuration that is not empty: the first parameter the * selection applies, at the value it applies. Onshape fills the rest in from the diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index 1f92a6914..334c740b0 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -12,11 +12,17 @@ import { import { type Db, getDb } from "../../db/client"; import { chunkForInArray } from "../../db/chunk"; import { users, favorites, configurations, insertables } from "../../db/schema"; -import { findRecord, toKey, toSelection } from "../configurations/selection"; +import { + findRecord, + toKey, + toSelection, + toStoredSelection +} from "../configurations/selection"; import { upgradeSelection } from "../configurations/legacy"; import { MAX_FAVORITES, type Favorite, type FavoritesData } from "./contract"; import { type ConfigurationParameter, + type PartialSelection, type SearchRecord } from "../configurations/contract"; import { toRecords } from "../configurations/utils"; @@ -79,7 +85,10 @@ async function getFavorites( // parameter existed still has to answer as a selection. const defaultSelection = row.defaultSelection ? toSelection( - upgradeSelection(row.defaultSelection, parameters), + toStoredSelection( + upgradeSelection(row.defaultSelection, parameters), + parameters + ), parameters ) : undefined; @@ -105,6 +114,17 @@ async function getFavorites( return { favorites: favoritesOut, favoriteOrder }; } +/** + * What a favorite keeps: the selection whole, but without a derivation + * variable, which each insert fills afresh. + */ +function toFavoriteSelection( + selection: PartialSelection, + parameters: ConfigurationParameter[] +): PartialSelection { + return toStoredSelection(toSelection(selection, parameters), parameters); +} + /** One insertable's parameters, for making a selection whole. */ async function getParametersFor( db: Db, @@ -243,7 +263,7 @@ favoriteRoutes.post( libraryId, insertableId, defaultSelection: selection - ? toSelection( + ? toFavoriteSelection( selection, await getParametersFor(db, insertableId) ) @@ -323,7 +343,7 @@ favoriteRoutes.post( await db .update(favorites) .set({ - defaultSelection: toSelection( + defaultSelection: toFavoriteSelection( selection, await getParametersFor(db, row.insertableId) ) diff --git a/src/backend/features/favorites/routes.worker.test.ts b/src/backend/features/favorites/routes.worker.test.ts index 1bdca3705..1179008fa 100644 --- a/src/backend/features/favorites/routes.worker.test.ts +++ b/src/backend/features/favorites/routes.worker.test.ts @@ -10,7 +10,8 @@ import { ElementType } from "../../lib/onshape/element-type"; import { MAX_FAVORITES } from "./contract"; import { configurationRecord, - quantityParam + quantityParam, + stringParam } from "../../../__test_utils__/configuration-fixtures"; const partMetadata = (partNumber: string) => @@ -600,6 +601,25 @@ describe("favorites routes", () => { }); }); + // Each insert fills its own, so a kept one would only be stale. + it("leaves a derivation variable out", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + await db + .update(configurations) + .set({ + parameters: [ + quantityParam("length"), + { ...stringParam("dv"), name: "Derivation Variable" } + ] + }) + .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); + + expect(await post({ length: "2 in", dv: "abc" })).toEqual({ + length: "2 in" + }); + }); + // What the favorite opens with is what was typed, not what it evaluates to. it("keeps a quantity as the expression it was entered as", async () => { await seedPartStudio(db); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index ed955dc68..325f10bb1 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -39,7 +39,8 @@ import { PartType } from "../../../lib/onshape/endpoints/documents"; import { onshapeOverrides, toShortestConfiguration, - toSelection + toSelection, + withDerivationValues } from "../../configurations/selection"; import { encodeConfiguration } from "../../configurations/utils"; import { fastenMate } from "../../../lib/onshape/objects/assembly-features"; @@ -314,11 +315,15 @@ insertableRoutes.post( const sourcePath = toElementPath(row); - const { selection, parameters } = await readSelection( + const { selection: requested, parameters } = await readSelection( db, insertableId, body.selection ); + // Fresh on every derive, whatever the client sent: a restored menu or + // a quick insert would otherwise repeat an earlier derive's value. + const selection = + requested && withDerivationValues(requested, parameters); const feature = new DerivedFeature( row.name, diff --git a/src/backend/features/library/insertables/routes.worker.test.ts b/src/backend/features/library/insertables/routes.worker.test.ts index abe386381..55ebe0fa5 100644 --- a/src/backend/features/library/insertables/routes.worker.test.ts +++ b/src/backend/features/library/insertables/routes.worker.test.ts @@ -30,7 +30,8 @@ import { OnshapeRateLimitError } from "../../../lib/onshape/client"; import { AUTO_INDEX_THRESHOLD } from "../../configurations/combinations"; import { enumParam, - quantityParam + quantityParam, + stringParam } from "../../../../__test_utils__/configuration-fixtures"; const db = getDb(env.DB); @@ -195,6 +196,43 @@ describe("insertable routes", () => { ); }); + // Onshape refuses a second derive of the same configuration, so each derive + // gets its own value, whatever the client sent. + it("POST /add-to-part-studio fills a derivation variable afresh each time", async () => { + await seedPartStudio(db); + await seedConfiguration(db); + await db + .update(configurations) + .set({ + parameters: [ + { ...stringParam("dv"), name: "Derivation Variable" } + ] + }) + .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); + const spy = vi + .spyOn(PartStudioEndpoints, "addPartStudioFeature") + .mockResolvedValue({ feature: { featureId: "feat-1" } }); + + const derive = () => + createTestApp().request( + `/api/add-to-part-studio/insertable/${TEST_PART_STUDIO_ID}`, + jsonRequest("POST", { targetPath, selection: { dv: "stale" } }), + env + ); + await derive(); + await derive(); + + const values = spy.mock.calls.map( + (call) => + /"parameterId":"dv","value":"([^"]*)"/.exec( + JSON.stringify(call[2]) + )?.[1] + ); + expect(values[0]).toBeTruthy(); + expect(values[0]).not.toBe("stale"); + expect(values[1]).not.toBe(values[0]); + }); + // A half-built target used to reach Onshape as a nonsense URL and fail // opaquely; the boundary rejects it instead. it.each([ diff --git a/src/frontend/components/parameter-role.tsx b/src/frontend/components/parameter-role.tsx new file mode 100644 index 000000000..f6002710a --- /dev/null +++ b/src/frontend/components/parameter-role.tsx @@ -0,0 +1,45 @@ +import { Group, Text } from "@mantine/core"; +import { + GitForkIcon, + type Icon, + PaletteIcon, + PolygonIcon, + SwatchesIcon +} from "@phosphor-icons/react"; +import { ReactNode } from "react"; +import { ParameterRole } from "@backend/features/configurations/roles"; +import { IconSize } from "../lib/style-constants"; + +const ROLE_LABELS: Record = { + [ParameterRole.DERIVATION_VARIABLE]: "Derivation variable", + [ParameterRole.COLOR]: "Color", + [ParameterRole.COLOR_CHANNEL]: "Color channel", + [ParameterRole.TESSELLATION]: "Tessellation quality" +}; + +export const ROLE_ICONS: Record = { + [ParameterRole.DERIVATION_VARIABLE]: GitForkIcon, + [ParameterRole.COLOR]: PaletteIcon, + [ParameterRole.COLOR_CHANNEL]: SwatchesIcon, + [ParameterRole.TESSELLATION]: PolygonIcon +}; + +interface ParameterRoleLabelProps { + role: ParameterRole; + /** What follows the role's name, e.g. ", so never indexed." */ + suffix?: string; +} + +/** A role's icon and name, for a tooltip. */ +export function ParameterRoleLabel(props: ParameterRoleLabelProps): ReactNode { + const { role, suffix = "" } = props; + const RoleIcon = ROLE_ICONS[role]; + return ( + + + + {ROLE_LABELS[role] + suffix} + + + ); +} diff --git a/src/frontend/features/build-status/components/parsed-section.tsx b/src/frontend/features/build-status/components/parsed-section.tsx index b7c6b9ab6..a997208cd 100644 --- a/src/frontend/features/build-status/components/parsed-section.tsx +++ b/src/frontend/features/build-status/components/parsed-section.tsx @@ -8,7 +8,12 @@ import { Text, Tooltip } from "@mantine/core"; -import { CheckIcon, ProhibitIcon, XIcon } from "@phosphor-icons/react"; +import { CheckIcon, XIcon } from "@phosphor-icons/react"; +import { parameterRole } from "@backend/features/configurations/roles"; +import { + ParameterRoleLabel, + ROLE_ICONS +} from "../../../components/parameter-role"; import { ReactNode, useMemo } from "react"; import { InsertableBuildStatus } from "@backend/features/build-checker/contract"; import { getVendorName, Vendor } from "@backend/features/library/vendors"; @@ -21,8 +26,7 @@ import { countCombinations, countConfigurations, effectiveExclusions, - MAX_COUNTED_CONFIGURATIONS, - neverIndexedReason + MAX_COUNTED_CONFIGURATIONS } from "@backend/features/configurations/combinations"; import { CATEGORY_COLOR, @@ -179,32 +183,28 @@ function ParameterRow(props: ParameterRowProps): ReactNode { } /** - * Only enums and booleans are enumerated, so only they have anything to say. - * A never-indexed one says why; a part studio's can be excluded by hand, which - * an assembly's cannot. + * A parameter with a role is never indexed and says which role. Otherwise only + * enums and booleans are enumerated: a part studio's can be excluded by hand, + * which an assembly's cannot. */ function IndexedControl(props: ParameterRowProps): ReactNode { const { insertableId, status, parameter } = props; const mutation = useExcludedParametersMutation(insertableId); - if ( - parameter.type !== ParameterType.ENUM && - parameter.type !== ParameterType.BOOLEAN - ) { - return null; - } - const reason = neverIndexedReason( - parameter, - status.configuration?.parameters - ); - if (reason) { + const role = parameterRole(parameter, status.configuration?.parameters); + if (role) { return ( + } events={{ hover: true, focus: true, touch: true }} > ); } + if ( + parameter.type !== ParameterType.ENUM && + parameter.type !== ParameterType.BOOLEAN + ) { + return null; + } if (status.elementType === ElementType.ASSEMBLY) { return null; } diff --git a/src/frontend/features/insert/components/configurations.test.tsx b/src/frontend/features/insert/components/configurations.test.tsx index e45926b05..6f0ac286c 100644 --- a/src/frontend/features/insert/components/configurations.test.tsx +++ b/src/frontend/features/insert/components/configurations.test.tsx @@ -11,7 +11,8 @@ import { import { boolParam, enumParam, - quantityParam + quantityParam, + stringParam } from "../../../../__test_utils__/configuration-fixtures"; import { createTestQueryClient, @@ -155,4 +156,21 @@ describe("ConfigurationWrapper", () => { expect(lastReport().record?.partNumber).toBe("PN-LARGE"); }); + + // Filled in for the person rather than by them, and kept out of the url. + it("fills a derivation variable itself and keeps it read-only", async () => { + const { lastReport } = renderPanel({ + parameters: [ + { ...stringParam("dv"), name: "Derivation Variable" }, + size + ], + records: [] + }); + + const input = await screen.findByLabelText("Derivation Variable"); + expect(input).toHaveProperty("readOnly", true); + expect((input as HTMLInputElement).value).toMatch(/^[0-9a-f-]{36}$/); + expect(screen.getByLabelText("Why this is filled in")).toBeTruthy(); + expect(lastReport().overrides).toEqual({}); + }); }); diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 8bd9ce989..5e93cf513 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -4,8 +4,12 @@ import { type ComboboxProps, Loader, Select, - TextInput + TextInput, + Tooltip } from "@mantine/core"; +import { InfoIcon } from "@phosphor-icons/react"; +import { AppIcon } from "../../../components/app-icon"; +import { StatusColor } from "../../../lib/style-constants"; import { type Dispatch, ReactNode, @@ -39,8 +43,11 @@ import { findRecord, onshapeOverrides, toKey, - toSelection + toSelection, + toStoredSelection, + withDerivationValues } from "@backend/features/configurations/selection"; +import { isDerivationVariable } from "@backend/features/configurations/roles"; import { evaluateExpression } from "@backend/features/configurations/input-parser"; import { useConfigurationQuery, useUnitInfoQuery } from "../queries"; import { SectionNotice } from "../../../components/app-zero-state"; @@ -61,8 +68,11 @@ import { seedFrom } from "../quantity-box"; export interface SelectionReport { /** Whole, and settled against the parameters' conditions. */ selection: Selection; - /** Only what differs from the element's defaults, as entered. */ - overrides: Selection; + /** + * Only what differs from the element's defaults, as entered, and without + * derivation variables: this is what the url keeps. + */ + overrides: PartialSelection; /** Names the selection's thumbnail. */ configurationKey: ConfigurationKey; /** The part the selection produces, for the menu's header. */ @@ -94,7 +104,10 @@ function useReportSelection( } onReport?.({ selection, - overrides: onshapeOverrides(selection, result.parameters), + overrides: toStoredSelection( + onshapeOverrides(selection, result.parameters), + result.parameters + ), configurationKey: toKey(selection, result.parameters), record: findRecord(selection, result.records) }); @@ -128,9 +141,13 @@ export function ConfigurationWrapper( const whole = useMemo( () => parameters - ? normalizeSelection( - toSelection(selection ?? {}, parameters), - parameters + ? withDerivationValues( + normalizeSelection( + toSelection(selection ?? {}, parameters), + parameters + ), + parameters, + true ) : undefined, [parameters, selection] @@ -374,8 +391,35 @@ function BooleanInput(props: ParameterProps): ReactNode { ); } +/** + * Why the field is filled in and fixed, beside it where somebody wondering + * will look. + */ +const DERIVATION_VARIABLE_NOTE = + "Onshape does not allow deriving the same part with the same configuration multiple times into a part studio. To avoid this limitation, Derivation Variable has been populated with a unique value."; + function StringInput(props: ParameterProps): ReactNode { const { parameter, value, onValueChange } = props; + if (isDerivationVariable(parameter)) { + return ( + + + + + } + /> + + ); + } return ( Date: Wed, 23 Sep 2026 21:23:05 +0000 Subject: [PATCH 14/88] Recognize only a text parameter as a derivation variable The role and the fill-in behavior now come from one check, so a non-text parameter named for derivation is an ordinary parameter everywhere rather than never indexed yet still editable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- .../features/configurations/roles.test.ts | 26 +++++++++++-------- src/backend/features/configurations/roles.ts | 17 +++++------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/backend/features/configurations/roles.test.ts b/src/backend/features/configurations/roles.test.ts index d92807e4d..20c802940 100644 --- a/src/backend/features/configurations/roles.test.ts +++ b/src/backend/features/configurations/roles.test.ts @@ -9,7 +9,6 @@ const named = (name: string) => ({ ...enumParam("p", ["x", "y"]), name }); describe("parameterRole", () => { it.each([ - ["Derivation Variable", ParameterRole.DERIVATION_VARIABLE], ["Color", ParameterRole.COLOR], ["Part colour", ParameterRole.COLOR], ["Tessellation Quality", ParameterRole.TESSELLATION], @@ -41,15 +40,20 @@ describe("parameterRole", () => { }); }); -describe("isDerivationVariable", () => { - // Only a text one takes the unique value the app fills in. - it("is a text parameter named for derivation", () => { - expect( - isDerivationVariable({ - ...stringParam("d"), - name: "Derivation Variable" - }) - ).toBe(true); - expect(isDerivationVariable(named("Derivation Variable"))).toBe(false); +describe("derivation variables", () => { + it("recognizes a text parameter named for derivation", () => { + const parameter = { ...stringParam("d"), name: "Derivation Variable" }; + expect(parameterRole(parameter)).toBe( + ParameterRole.DERIVATION_VARIABLE + ); + expect(isDerivationVariable(parameter)).toBe(true); + }); + + // Only a text one can take the unique value the app fills in, so one of + // another type is an ordinary parameter: indexed and editable as usual. + it("gives one of another type no role", () => { + const parameter = named("Derivation Variable"); + expect(parameterRole(parameter)).toBeUndefined(); + expect(isDerivationVariable(parameter)).toBe(false); }); }); diff --git a/src/backend/features/configurations/roles.ts b/src/backend/features/configurations/roles.ts index 11a6f016b..2f03cd475 100644 --- a/src/backend/features/configurations/roles.ts +++ b/src/backend/features/configurations/roles.ts @@ -9,7 +9,8 @@ export enum ParameterRole { /** * A text parameter a document adds so one part can be derived into a part * studio more than once: Onshape refuses a second derive of the same - * configuration, and a unique value here makes each one different. + * configuration, and a unique value here makes each one different. Only a + * text one: the app fills it with a unique value, which is text. */ DERIVATION_VARIABLE = "derivation-variable", COLOR = "color", @@ -38,7 +39,10 @@ export function parameterRole( parameters: ConfigurationParameter[] = [] ): ParameterRole | undefined { const name = normalizedName(parameter); - if (name.includes("derivation")) { + if ( + parameter.type === ParameterType.STRING && + name.includes("derivation") + ) { return ParameterRole.DERIVATION_VARIABLE; } if (/\bcolou?r\b/.test(name)) { @@ -54,15 +58,8 @@ export function parameterRole( return isChannel ? ParameterRole.COLOR_CHANNEL : undefined; } -/** - * A derivation variable the app fills in itself. Only a text one: a unique - * value is text, and nothing here knows what one of another type is for. - */ export function isDerivationVariable( parameter: ConfigurationParameter ): boolean { - return ( - parameter.type === ParameterType.STRING && - parameterRole(parameter) === ParameterRole.DERIVATION_VARIABLE - ); + return parameterRole(parameter) === ParameterRole.DERIVATION_VARIABLE; } From 630db86c85582b95482f864a6152b467f08b9a81 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:41:24 +0000 Subject: [PATCH 15/88] Open Onshape links on the caller's company domain Links are built on the launch's server origin, so a company session opens frcdesign.onshape.com rather than cad.onshape.com. Only an https onshape.com origin is accepted, since the launch is a url anyone can write. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/lib/onshape/api-path.ts | 46 ------------------- .../components/open-document-items.test.tsx | 13 +++++- .../components/open-document-items.tsx | 4 +- .../build-status/components/issues.tsx | 11 ++++- .../features/dashboard/parts-table.tsx | 4 +- src/frontend/lib/onshape-params.ts | 6 +++ src/frontend/lib/url.test.ts | 31 +++++++++++-- src/frontend/lib/url.tsx | 33 +++++++++++-- src/frontend/routes/_pages/beta-complete.tsx | 6 ++- src/frontend/routes/_pages/grant-denied.tsx | 10 ++-- src/frontend/routes/_pages/setup.tsx | 6 ++- .../dashboard/library/$libraryId/part.tsx | 4 +- 12 files changed, 107 insertions(+), 67 deletions(-) delete mode 100644 src/backend/lib/onshape/api-path.ts diff --git a/src/backend/lib/onshape/api-path.ts b/src/backend/lib/onshape/api-path.ts deleted file mode 100644 index dcf9e376c..000000000 --- a/src/backend/lib/onshape/api-path.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DocumentPath } from "./path"; - -interface ApiPathOptions { - endRoute?: string; - endId?: string; - featureId?: string; - /** For the document-level endpoints that take a bare id, without `/d/`. */ - skipDocumentD?: boolean; -} - -/** - * @example - * apiPath("documents", instancePath, toInstanceApiPath, { endRoute: "elements" }) - * // → "/documents/d/{did}/w/{wid}/elements" - */ -export function apiPath( - route: string, - path?: T, - serialize?: (path: T) => string, - options?: ApiPathOptions -): string { - let result = route.startsWith("/") ? route : "/" + route; - - if (path !== undefined) { - if (options?.skipDocumentD) { - result += "/" + path.documentId; - } else if (serialize !== undefined) { - result += serialize(path); - } - } - - if (options?.endRoute !== undefined) { - const end = options.endRoute; - result += end.startsWith("/") ? end : "/" + end; - } - - if (options?.endId !== undefined) { - result += "/" + encodeURIComponent(options.endId); - } - - if (options?.featureId !== undefined) { - result += "/featureId/" + encodeURIComponent(options.featureId); - } - - return result; -} diff --git a/src/frontend/components/open-document-items.test.tsx b/src/frontend/components/open-document-items.test.tsx index a2e415fb1..1a52856af 100644 --- a/src/frontend/components/open-document-items.test.tsx +++ b/src/frontend/components/open-document-items.test.tsx @@ -5,6 +5,7 @@ import { Menu } from "@mantine/core"; import { type ElementPath } from "@backend/lib/onshape/path"; import { renderWithProviders } from "../../__test_utils__/render"; import { OpenDocumentItems } from "./open-document-items"; +import { updateUiState } from "../lib/ui-state"; const PATH: ElementPath = { documentId: "doc", @@ -33,7 +34,10 @@ async function copiedLink(selection?: Record) { } describe("OpenDocumentItems", () => { - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + vi.restoreAllMocks(); + updateUiState({ server: undefined }); + }); // A favorite's link used to open the part at its defaults. it("links to the configuration it is given, as it was typed", async () => { @@ -43,6 +47,13 @@ describe("OpenDocumentItems", () => { ); }); + it("links into the Onshape the panel was launched from", async () => { + updateUiState({ server: "https://frcdesign.onshape.com" }); + expect(await copiedLink()).toBe( + "https://frcdesign.onshape.com/documents/doc/v/ver/e/el" + ); + }); + it("links to the element itself without one", async () => { expect(await copiedLink()).toBe( "https://cad.onshape.com/documents/doc/v/ver/e/el" diff --git a/src/frontend/components/open-document-items.tsx b/src/frontend/components/open-document-items.tsx index 485f0b39a..2bc336f61 100644 --- a/src/frontend/components/open-document-items.tsx +++ b/src/frontend/components/open-document-items.tsx @@ -9,6 +9,7 @@ import { import { type PartialSelection } from "@backend/features/configurations/contract"; import { IconSize, StatusColor } from "../lib/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../lib/url"; +import { useOnshapeOrigin } from "../lib/onshape-params"; interface OpenDocumentItemsProps { /** Any Onshape path; a shell group's stops at the document. */ @@ -19,7 +20,8 @@ interface OpenDocumentItemsProps { /** The menu items for reaching an element's Onshape document. */ export function OpenDocumentItems(props: OpenDocumentItemsProps): ReactNode { - const url = makeUrl(props.path, props.selection); + const origin = useOnshapeOrigin(); + const url = makeUrl(origin, props.path, props.selection); return ( <> Build checks @@ -229,7 +236,7 @@ export function BuildChecksSection(props: BuildChecksSectionProps): ReactNode { ))} diff --git a/src/frontend/features/dashboard/parts-table.tsx b/src/frontend/features/dashboard/parts-table.tsx index 21d24f431..420e550f6 100644 --- a/src/frontend/features/dashboard/parts-table.tsx +++ b/src/frontend/features/dashboard/parts-table.tsx @@ -11,6 +11,7 @@ import { type PartUsageOut } from "@backend/features/analytics/contract"; import { MONTH_DAYS } from "@backend/features/analytics/measures"; import { LibraryId } from "@backend/features/library/library-id"; import { makeUrl } from "../../lib/url"; +import { useOnshapeOrigin } from "../../lib/onshape-params"; import { IconSize, StatusColor } from "../../lib/style-constants"; import { formatCount } from "./format"; import { @@ -188,6 +189,7 @@ interface PartRowProps { function PartRow({ libraryId, part }: PartRowProps): ReactNode { const navigate = useNavigate(); + const origin = useOnshapeOrigin(); return ( event.stopPropagation()}> { const element = { @@ -10,10 +10,10 @@ describe("makeUrl", () => { } as const; it("addresses a document, an instance and an element in turn", () => { - expect(makeUrl({ documentId: "doc" })).toBe( + expect(makeUrl(DEFAULT_ONSHAPE_ORIGIN, { documentId: "doc" })).toBe( "https://cad.onshape.com/documents/doc" ); - expect(makeUrl(element)).toBe( + expect(makeUrl(DEFAULT_ONSHAPE_ORIGIN, element)).toBe( "https://cad.onshape.com/documents/doc/w/ws/e/el" ); }); @@ -21,7 +21,7 @@ describe("makeUrl", () => { // Once, by the url: a quantity that reaches Onshape as `%2520m` is the // value `0.381%20m`, which is no quantity. it("escapes a configuration once", () => { - const url = makeUrl(element, { + const url = makeUrl(DEFAULT_ONSHAPE_ORIGIN, element, { Effective_Length: "0.381 m", List_7A7: "Hex" }); @@ -35,3 +35,26 @@ describe("makeUrl", () => { ); }); }); + +describe("toOnshapeOrigin", () => { + it("keeps a company's own Onshape", () => { + expect(toOnshapeOrigin("https://frcdesign.onshape.com")).toBe( + "https://frcdesign.onshape.com" + ); + expect(toOnshapeOrigin("https://frcdesign.onshape.com/")).toBe( + "https://frcdesign.onshape.com" + ); + }); + + // The launch is a url anyone can write, and these links open as Onshape's. + it.each([ + undefined, + "", + "not a url", + "http://frcdesign.onshape.com", + "https://onshape.com.example.com", + "https://evilonshape.com" + ])("falls back to cad for %s", (server) => { + expect(toOnshapeOrigin(server)).toBe(DEFAULT_ONSHAPE_ORIGIN); + }); +}); diff --git a/src/frontend/lib/url.tsx b/src/frontend/lib/url.tsx index e0c563713..46c070d72 100644 --- a/src/frontend/lib/url.tsx +++ b/src/frontend/lib/url.tsx @@ -11,9 +11,33 @@ import { notifications } from "@mantine/notifications"; import { LinkIcon } from "@phosphor-icons/react"; import { IconSize } from "./style-constants"; -/** The app's listing in the Onshape App Store, where it is subscribed to. */ -export const APP_STORE_URL = - "https://cad.onshape.com/appstore/apps/Manufacturers%20Models/6004ec5e83c40b107c183347"; +/** Onshape for anyone outside a company, and for a caller who never launched. */ +export const DEFAULT_ONSHAPE_ORIGIN = "https://cad.onshape.com"; + +/** + * The app's listing in the Onshape App Store, where it is subscribed to. A + * path, so it opens on the caller's own Onshape; see `useOnshapeOrigin`. + */ +export const APP_STORE_PATH = + "/appstore/apps/Manufacturers%20Models/6004ec5e83c40b107c183347"; + +/** Where a caller manages the apps they have granted access. */ +export const APPLICATIONS_PATH = "/user/applications"; + +/** + * The origin of the Onshape a launch came from — a company's own domain, such + * as frcdesign.onshape.com, for a company session — or cad's without one. Only + * an https onshape.com origin: the launch is a url anyone can write, and links + * built on this are opened as Onshape's. + */ +export function toOnshapeOrigin(server: string | undefined): string { + const url = server ? URL.parse(server) : null; + const isOnshape = + url?.protocol === "https:" && + (url.hostname === "onshape.com" || + url.hostname.endsWith(".onshape.com")); + return isOnshape ? url.origin : DEFAULT_ONSHAPE_ORIGIN; +} /** * The setup instructions. Opened in a window of their own: a navigation would @@ -26,10 +50,11 @@ export const SETUP_URL = "/setup"; * only what it names changes: Onshape fills in the rest from the defaults. */ export function makeUrl( + origin: string, path: DocumentPath | InstancePath | ElementPath, configuration?: PartialSelection ): string { - let url = `https://cad.onshape.com/documents/${path.documentId}`; + let url = `${origin}/documents/${path.documentId}`; if (isInstancePath(path)) { url += `/${path.instanceType}/${path.instanceId}`; } diff --git a/src/frontend/routes/_pages/beta-complete.tsx b/src/frontend/routes/_pages/beta-complete.tsx index 9917fac22..b98347240 100644 --- a/src/frontend/routes/_pages/beta-complete.tsx +++ b/src/frontend/routes/_pages/beta-complete.tsx @@ -2,7 +2,8 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { OpenUrlButton } from "../../components/open-url-button"; import { PageNotice } from "../../components/app-zero-state"; -import { APP_STORE_URL } from "../../lib/url"; +import { APP_STORE_PATH } from "../../lib/url"; +import { useOnshapeOrigin } from "../../lib/onshape-params"; /** * Where the beta-era app extension still points. Nothing links here anymore, @@ -14,8 +15,9 @@ export const Route = createFileRoute("/_pages/beta-complete")({ }); function BetaComplete(): JSX.Element { + const origin = useOnshapeOrigin(); const frcDesignAppButton = ( - + ); return ( diff --git a/src/frontend/routes/_pages/grant-denied.tsx b/src/frontend/routes/_pages/grant-denied.tsx index 1c29c45ea..84092437d 100644 --- a/src/frontend/routes/_pages/grant-denied.tsx +++ b/src/frontend/routes/_pages/grant-denied.tsx @@ -2,16 +2,20 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { OpenUrlButton } from "../../components/open-url-button"; import { PageNotice } from "../../components/app-zero-state"; +import { APPLICATIONS_PATH } from "../../lib/url"; +import { useOnshapeOrigin } from "../../lib/onshape-params"; export const Route = createFileRoute("/_pages/grant-denied")({ component: GrantDenied }); -const URL = "https://cad.onshape.com/user/applications"; - function GrantDenied(): JSX.Element { + const origin = useOnshapeOrigin(); const applicationAccessButton = ( - + ); return ( diff --git a/src/frontend/routes/_pages/setup.tsx b/src/frontend/routes/_pages/setup.tsx index 0ce5bdd3e..85c31d6f3 100644 --- a/src/frontend/routes/_pages/setup.tsx +++ b/src/frontend/routes/_pages/setup.tsx @@ -3,7 +3,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { type ReactNode } from "react"; import { NavbarRow } from "../../components/app-navbar"; import { OpenUrlButton } from "../../components/open-url-button"; -import { APP_STORE_URL } from "../../lib/url"; +import { APP_STORE_PATH } from "../../lib/url"; +import { useOnshapeOrigin } from "../../lib/onshape-params"; import frcDesignAppIcon from "/frc-design-app-prod.svg"; @@ -13,6 +14,7 @@ export const Route = createFileRoute("/_pages/setup")({ /** Where "Instructions" lands: what to do to end up with the app in Onshape. */ function Setup(): ReactNode { + const origin = useOnshapeOrigin(); return ( <> {/* The brand alone: there is nowhere else to go from here. */} @@ -40,7 +42,7 @@ function Setup(): ReactNode { diff --git a/src/frontend/routes/dashboard/library/$libraryId/part.tsx b/src/frontend/routes/dashboard/library/$libraryId/part.tsx index c2892ee98..d529e550f 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/part.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/part.tsx @@ -18,6 +18,7 @@ import type { InsertableReportOut } from "@backend/features/analytics/contract"; import { IconSize } from "../../../../lib/style-constants"; import { parseSearch } from "../../../../lib/search-params"; import { makeUrl } from "../../../../lib/url"; +import { useOnshapeOrigin } from "../../../../lib/onshape-params"; import { ConfigurationBreakdown } from "../../../../features/dashboard/configuration-breakdown"; import { METRICS } from "../../../../features/dashboard/metrics"; import { type DayRange } from "@backend/features/analytics/day"; @@ -156,11 +157,12 @@ interface PartTitleProps { /** The part's name, linked into Onshape like a part number is to its vendor. */ function PartTitle({ report }: PartTitleProps): ReactNode { + const origin = useOnshapeOrigin(); return ( <Anchor inherit - href={makeUrl(report.path)} + href={makeUrl(origin, report.path)} target="_blank" rel="noreferrer" // Centres the icon on the text rather than on its baseline. From ee92a17798af0da913a9cd921071a25410b18983 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 23 Sep 2026 21:41:25 +0000 Subject: [PATCH 16/88] Keep row icons their size beside a long title A verbose row's thumbnail and status badge gave up width to its title, so they drew smaller than the next row's, most visibly when zoomed in. Only the text shrinks now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/frontend/components/item-row.tsx | 16 +++++++++++++--- src/frontend/lib/styles.module.css | 12 ++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/frontend/components/item-row.tsx b/src/frontend/components/item-row.tsx index d4fe12889..b722277ae 100644 --- a/src/frontend/components/item-row.tsx +++ b/src/frontend/components/item-row.tsx @@ -51,11 +51,17 @@ export function CardTitle(props: CardTitleProps): ReactNode { } = props; return ( - <Group gap="sm" wrap="nowrap" flex={1} miw={0}> + <Group + gap="sm" + wrap="nowrap" + flex={1} + miw={0} + className={styles.onlyTextShrinks} + > {thumbnail} {/* Shrinks to truncate, but never grows: the badge belongs beside the name, not at the row's edge. */} - <Stack gap={0} miw={0}> + <Stack gap={0} miw={0} className={styles.shrinkingText}> <TruncatedText hoverText={title} size="sm" @@ -198,7 +204,11 @@ export function ItemRow(props: ItemRowProps): ReactNode { <Table.Td> <Group wrap="nowrap"> {left} - <Group gap="4px" justify="flex-end"> + <Group + gap="4px" + justify="flex-end" + className={styles.noShrink} + > {moreButton && <MenuButton>{menuItems}</MenuButton>} {rightSection} </Group> diff --git a/src/frontend/lib/styles.module.css b/src/frontend/lib/styles.module.css index c4b794aa7..7a62fcc19 100644 --- a/src/frontend/lib/styles.module.css +++ b/src/frontend/lib/styles.module.css @@ -42,6 +42,18 @@ flex-shrink: 0; } +/* + * A row whose text alone gives up room, so a long title truncates rather than + * squeezing the thumbnail and badge beside it smaller than the next row's. + */ +.onlyTextShrinks > * { + flex-shrink: 0; +} + +.onlyTextShrinks > .shrinkingText { + flex-shrink: 1; +} + /* * Text reads as centred on its cap height, a pixel above its line box, so an * icon centred on that box looks low. From 44f946f39f8dd9faa1d5c71a840295e97f4c7050 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 23 Sep 2026 21:41:26 +0000 Subject: [PATCH 17/88] Stick the home page's section headers to the top Each header sticks below the ones above it, so favorites and the library stay in reach however far the list is scrolled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- .../app/library/$libraryId/index.module.css | 15 +++++++++++++++ .../routes/app/library/$libraryId/index.tsx | 9 +++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/frontend/routes/app/library/$libraryId/index.module.css b/src/frontend/routes/app/library/$libraryId/index.module.css index 3523ac6a9..6deb01f23 100644 --- a/src/frontend/routes/app/library/$libraryId/index.module.css +++ b/src/frontend/routes/app/library/$libraryId/index.module.css @@ -1,9 +1,16 @@ /* * Mantine brightens a control to pure white or black; a section header is a * title like the group page's, so it reads in the same text color. + * + * Stuck below the headers already stuck, each `sectionHeader`'s height. The + * scroll container's padding already clears the navbar it runs underneath. */ .control { color: var(--mantine-color-text); + position: sticky; + top: calc(var(--section-index) * rem(48px)); + z-index: 1; + background: var(--mantine-color-body); } /* Its own padding would outgrow the header's height. */ @@ -14,3 +21,11 @@ .content { padding: 0; } + +/* + * Lets every header stick against the accordion as a whole rather than its own + * section, so a header stays in reach below the ones above it. + */ +.item { + display: contents; +} diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 8b389b261..d675e65c7 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -122,13 +122,18 @@ function SectionAccordion(props: SectionAccordionProps): ReactNode { // divides from the next one; content closes off an open one. classNames={{ control: `${classes.control} ${styles.sectionHeader} ${styles.dividerBottom}`, + item: classes.item, label: classes.label, content: `${classes.content} ${styles.dividerBottom}`, icon: styles.titleIcon }} > - {sections.map((section) => ( - <Accordion.Item key={section.value} value={section.value}> + {sections.map((section, index) => ( + <Accordion.Item + key={section.value} + value={section.value} + style={{ "--section-index": index }} + > <Accordion.Control icon={section.icon} className="interactive" From 740397dae1111c56231826b5a2a4274f8c340936 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 23 Sep 2026 21:41:27 +0000 Subject: [PATCH 18/88] Replace apiPath with plain template strings Its options (skipDocumentD, endRoute, endId, featureId) obscured what each call site asked for. addAssemblyFeature loses the update-by-id parameter nobody passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- .../lib/onshape/endpoints/assemblies.ts | 25 +++++------------ .../lib/onshape/endpoints/configurations.ts | 5 +--- .../lib/onshape/endpoints/documents.ts | 27 +++++-------------- src/backend/lib/onshape/endpoints/metadata.ts | 3 +-- .../lib/onshape/endpoints/part-studios.ts | 9 ++----- src/backend/lib/onshape/endpoints/parts.ts | 3 +-- .../lib/onshape/endpoints/thumbnails.ts | 13 +++------ src/backend/lib/onshape/endpoints/users.ts | 7 ++--- src/backend/lib/onshape/endpoints/versions.ts | 7 +---- .../lib/onshape/endpoints/workspaces.ts | 14 +++------- 10 files changed, 26 insertions(+), 87 deletions(-) diff --git a/src/backend/lib/onshape/endpoints/assemblies.ts b/src/backend/lib/onshape/endpoints/assemblies.ts index 567c6dce5..12779354f 100644 --- a/src/backend/lib/onshape/endpoints/assemblies.ts +++ b/src/backend/lib/onshape/endpoints/assemblies.ts @@ -1,7 +1,6 @@ import { OnshapeApi } from "../client"; import { assertWorkspace } from "../assertions"; import { ElementPath, toElementApiObject, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; import { PartType } from "./documents"; import { ElementType } from "../element-type"; import { IDENTITY_TRANSFORM } from "../objects/transform"; @@ -23,7 +22,7 @@ export function getAssembly( excludeSuppressed?: boolean; } = {} ): Promise<OnshapeAssemblyDefinition> { - return client.get(apiPath("assemblies", assemblyPath, toElementApiPath), { + return client.get(`/assemblies${toElementApiPath(assemblyPath)}`, { query: new URLSearchParams({ includeMateFeatures: String(options.includeMateFeatures ?? false), includeNonSolids: String(options.includeNonSolids ?? false), @@ -89,9 +88,7 @@ export function getAssemblyBoundingBox( assemblyPath: ElementPath ): Promise<OnshapeBoundingBox> { return client.get( - apiPath("assemblies", assemblyPath, toElementApiPath, { - endRoute: "boundingboxes" - }), + `/assemblies${toElementApiPath(assemblyPath)}/boundingboxes`, { query: { includeSketches: "false" } } ); } @@ -125,9 +122,7 @@ function insertInstance( transform?: number[] ): Promise<OnshapeInsertInstancesResponse> { return client.post( - apiPath("assemblies", assemblyPath, toElementApiPath, { - endRoute: "transformedinstances" - }), + `/assemblies${toElementApiPath(assemblyPath)}/transformedinstances`, { body: { transformGroups: [ @@ -141,23 +136,15 @@ function insertInstance( ); } -/** - * Adds or updates a feature in an assembly. - * - * @param featureId If specified, the existing feature with this ID is updated rather than creating a new one. - */ +/** Adds a feature to an assembly. */ export function addAssemblyFeature( client: OnshapeApi, assemblyPath: ElementPath, - feature: object, - featureId?: string + feature: object ): Promise<OnshapeCreatedFeature> { assertWorkspace(assemblyPath); return client.post( - apiPath("assemblies", assemblyPath, toElementApiPath, { - endRoute: "features", - featureId - }), + `/assemblies${toElementApiPath(assemblyPath)}/features`, { body: { feature } } ); } diff --git a/src/backend/lib/onshape/endpoints/configurations.ts b/src/backend/lib/onshape/endpoints/configurations.ts index af1507688..f20bae910 100644 --- a/src/backend/lib/onshape/endpoints/configurations.ts +++ b/src/backend/lib/onshape/endpoints/configurations.ts @@ -1,6 +1,5 @@ import { OnshapeApi } from "../client"; import { ElementPath, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; import { OnshapeConfigurationResponse } from "../types"; export function getConfiguration( @@ -8,8 +7,6 @@ export function getConfiguration( elementPath: ElementPath ): Promise<OnshapeConfigurationResponse> { return client.get( - apiPath("elements", elementPath, toElementApiPath, { - endRoute: "configuration" - }) + `/elements${toElementApiPath(elementPath)}/configuration` ); } diff --git a/src/backend/lib/onshape/endpoints/documents.ts b/src/backend/lib/onshape/endpoints/documents.ts index 67467085b..061a27424 100644 --- a/src/backend/lib/onshape/endpoints/documents.ts +++ b/src/backend/lib/onshape/endpoints/documents.ts @@ -1,11 +1,5 @@ import { OnshapeApi } from "../client"; -import { - DocumentPath, - InstancePath, - toDocumentApiPath, - toInstanceApiPath -} from "../path"; -import { apiPath } from "../api-path"; +import { DocumentPath, InstancePath, toInstanceApiPath } from "../path"; import { OnshapeDocumentContents, OnshapeDocumentInfo } from "../types"; /** Describes possible part types. */ @@ -19,11 +13,7 @@ export function getDocument( client: OnshapeApi, documentPath: DocumentPath ): Promise<OnshapeDocumentInfo> { - return client.get( - apiPath("documents", documentPath, toDocumentApiPath, { - skipDocumentD: true - }) - ); + return client.get(`/documents/${documentPath.documentId}`); } export function getContents( @@ -31,12 +21,9 @@ export function getContents( instancePath: InstancePath, includeThumbnails = false ): Promise<OnshapeDocumentContents> { - return client.get( - apiPath("documents", instancePath, toInstanceApiPath, { - endRoute: "contents" - }), - { query: { withThumbnails: includeThumbnails } } - ); + return client.get(`/documents${toInstanceApiPath(instancePath)}/contents`, { + query: { withThumbnails: includeThumbnails } + }); } /** The document's units, as much of the response as anything here reads. */ @@ -52,8 +39,6 @@ export function getUnitInfo( instancePath: InstancePath ): Promise<OnshapeUnitInfo> { return onshapeApi.get( - apiPath("documents", instancePath, toInstanceApiPath, { - endRoute: "unitinfo" - }) + `/documents${toInstanceApiPath(instancePath)}/unitinfo` ); } diff --git a/src/backend/lib/onshape/endpoints/metadata.ts b/src/backend/lib/onshape/endpoints/metadata.ts index 0f93736d7..67c6f4f2e 100644 --- a/src/backend/lib/onshape/endpoints/metadata.ts +++ b/src/backend/lib/onshape/endpoints/metadata.ts @@ -1,6 +1,5 @@ import { OnshapeApi } from "../client"; import { ElementPath, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; import { type Selection } from "../../../features/configurations/contract"; import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import type { OnshapeMetadataObject } from "../types"; @@ -21,7 +20,7 @@ export function getElementMetadata( if (encoded) { query.configuration = encoded; } - return client.get(apiPath("metadata", elementPath, toElementApiPath), { + return client.get(`/metadata${toElementApiPath(elementPath)}`, { query }); } diff --git a/src/backend/lib/onshape/endpoints/part-studios.ts b/src/backend/lib/onshape/endpoints/part-studios.ts index ff026acdb..663cb91d4 100644 --- a/src/backend/lib/onshape/endpoints/part-studios.ts +++ b/src/backend/lib/onshape/endpoints/part-studios.ts @@ -1,7 +1,6 @@ import { OnshapeApi } from "../client"; import { assertInstanceType } from "../assertions"; import { ElementPath, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; import { OnshapeCreatedFeature, OnshapeFeatureListResponse } from "../types"; export function addPartStudioFeature( @@ -11,9 +10,7 @@ export function addPartStudioFeature( ): Promise<OnshapeCreatedFeature> { assertInstanceType(partStudioPath, "w"); return client.post( - apiPath("partstudios", partStudioPath, toElementApiPath, { - endRoute: "features" - }), + `/partstudios${toElementApiPath(partStudioPath)}/features`, { body: { feature } } ); } @@ -23,9 +20,7 @@ export function getFeatures( partStudioPath: ElementPath ): Promise<OnshapeFeatureListResponse> { return client.get( - apiPath("partstudios", partStudioPath, toElementApiPath, { - endRoute: "features" - }), + `/partstudios${toElementApiPath(partStudioPath)}/features`, { query: { includeSketches: "false", diff --git a/src/backend/lib/onshape/endpoints/parts.ts b/src/backend/lib/onshape/endpoints/parts.ts index 6044356cd..78c648648 100644 --- a/src/backend/lib/onshape/endpoints/parts.ts +++ b/src/backend/lib/onshape/endpoints/parts.ts @@ -1,6 +1,5 @@ import { OnshapeApi } from "../client"; import { ElementPath, toElementApiPath } from "../path"; -import { apiPath } from "../api-path"; import { type Selection } from "../../../features/configurations/contract"; import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import type { OnshapePart } from "../types"; @@ -16,7 +15,7 @@ export function getParts( ): Promise<OnshapePart[]> { // The query form: this is escaped again on its way out. const encoded = encodeQueryConfiguration(configuration); - return client.get(apiPath("parts", elementPath, toElementApiPath), { + return client.get(`/parts${toElementApiPath(elementPath)}`, { query: encoded ? { configuration: encoded } : {} }); } diff --git a/src/backend/lib/onshape/endpoints/thumbnails.ts b/src/backend/lib/onshape/endpoints/thumbnails.ts index 14f1008c6..e1ff76c69 100644 --- a/src/backend/lib/onshape/endpoints/thumbnails.ts +++ b/src/backend/lib/onshape/endpoints/thumbnails.ts @@ -3,7 +3,6 @@ import { encodeQueryConfiguration } from "../../../features/configurations/utils import { OnshapeApi } from "../client"; import { assertInstanceType } from "../assertions"; import { ElementPath, toElementApiPath, toInstanceApiPath } from "../path"; -import { apiPath } from "../api-path"; import { ThumbnailSize } from "../../../features/thumbnails/contract"; /** Returns the thumbnail for a given element in a workspace or version. */ @@ -13,8 +12,7 @@ export function getElementThumbnail( size = ThumbnailSize.LARGE ): Promise<ArrayBuffer> { assertInstanceType(elementPath, "w", "v"); - const path = - apiPath("thumbnails", elementPath, toElementApiPath) + "/s/" + size; + const path = `/thumbnails${toElementApiPath(elementPath)}/s/${size}`; return client.getImage(path); } @@ -43,9 +41,7 @@ export async function getThumbnailId( } const insertables = await client.get( - apiPath("documents", elementPath, toInstanceApiPath, { - endRoute: "insertables" - }), + `/documents${toInstanceApiPath(elementPath)}/insertables`, { query } ); // A configuration matching nothing comes back with no items at all. @@ -64,9 +60,6 @@ export function getThumbnailFromId( thumbnailId: string, size = ThumbnailSize.LARGE ): Promise<ArrayBuffer> { - const path = - apiPath("thumbnails", undefined, undefined, { endId: thumbnailId }) + - "/s/" + - size; + const path = `/thumbnails/${encodeURIComponent(thumbnailId)}/s/${size}`; return client.getImage(path); } diff --git a/src/backend/lib/onshape/endpoints/users.ts b/src/backend/lib/onshape/endpoints/users.ts index 69ec9c2f8..26301a2c1 100644 --- a/src/backend/lib/onshape/endpoints/users.ts +++ b/src/backend/lib/onshape/endpoints/users.ts @@ -1,5 +1,4 @@ import { OAuthApi, OnshapeApi } from "../client"; -import { apiPath } from "../api-path"; import { AccessLevel } from "../../../features/auth/access-level"; interface SessionInfo { @@ -9,9 +8,7 @@ interface SessionInfo { } export function getSessionInfo(client: OAuthApi): Promise<SessionInfo> { - return client.get( - apiPath("users", undefined, undefined, { endRoute: "sessioninfo" }) - ); + return client.get("/users/sessioninfo"); } /** Returns the user ID associated with the current session. */ @@ -26,7 +23,7 @@ export async function getAccessLevel( ): Promise<AccessLevel> { try { const teamInfo = await client.get( - apiPath("teams", undefined, undefined, { endId: teamId }) + `/teams/${encodeURIComponent(teamId)}` ); if (teamInfo.admin) return AccessLevel.ADMIN; if (teamInfo.member) return AccessLevel.EDITOR; diff --git a/src/backend/lib/onshape/endpoints/versions.ts b/src/backend/lib/onshape/endpoints/versions.ts index 27f6eec67..529680d31 100644 --- a/src/backend/lib/onshape/endpoints/versions.ts +++ b/src/backend/lib/onshape/endpoints/versions.ts @@ -1,6 +1,5 @@ import { OnshapeApi } from "../client"; import { DocumentPath, toDocumentApiPath } from "../path"; -import { apiPath } from "../api-path"; import { OnshapeVersionInfo } from "../types"; /** @@ -12,11 +11,7 @@ function getVersions( client: OnshapeApi, documentPath: DocumentPath ): Promise<OnshapeVersionInfo[]> { - return client.get( - apiPath("documents", documentPath, toDocumentApiPath, { - endRoute: "versions" - }) - ); + return client.get(`/documents${toDocumentApiPath(documentPath)}/versions`); } /** The most recently created version of a document, with when it was cut. */ diff --git a/src/backend/lib/onshape/endpoints/workspaces.ts b/src/backend/lib/onshape/endpoints/workspaces.ts index 4cfb06771..699cb046b 100644 --- a/src/backend/lib/onshape/endpoints/workspaces.ts +++ b/src/backend/lib/onshape/endpoints/workspaces.ts @@ -1,6 +1,5 @@ import { OnshapeApi } from "../client"; import { DocumentPath, toDocumentApiPath } from "../path"; -import { apiPath } from "../api-path"; import { OnshapeWorkspaceInfo } from "../types"; export function getWorkspaces( @@ -8,9 +7,7 @@ export function getWorkspaces( documentPath: DocumentPath ): Promise<OnshapeWorkspaceInfo[]> { return client.get( - apiPath("documents", documentPath, toDocumentApiPath, { - endRoute: "workspaces" - }) + `/documents${toDocumentApiPath(documentPath)}/workspaces` ); } @@ -20,9 +17,7 @@ export function createWorkspace( branch: { name: string; description: string; versionId: string } ): Promise<OnshapeWorkspaceInfo> { return client.post( - apiPath("documents", documentPath, toDocumentApiPath, { - endRoute: "workspaces" - }), + `/documents${toDocumentApiPath(documentPath)}/workspaces`, { body: branch } ); } @@ -33,9 +28,6 @@ export function deleteWorkspace( workspaceId: string ): Promise<void> { return client.deleteNone( - apiPath("documents", documentPath, toDocumentApiPath, { - endRoute: "workspaces", - endId: workspaceId - }) + `/documents${toDocumentApiPath(documentPath)}/workspaces/${encodeURIComponent(workspaceId)}` ); } From 8db587049429e95ae89f32a80c644cc42773d40b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 23 Sep 2026 21:41:28 +0000 Subject: [PATCH 19/88] Stop exporting what nothing imports parse.ts exports only what routes use, and its tests go through those. readThumbnailUrls had no callers and is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/features/auth/request-auth.ts | 2 +- src/backend/features/build-checker/issues.ts | 2 +- .../features/configurations/instances.ts | 4 +- .../features/insert-location/parse.test.ts | 80 +++++++++++-------- src/backend/features/insert-location/parse.ts | 8 +- src/backend/features/load/context.ts | 2 +- src/backend/features/load/load-group.ts | 2 +- .../load/parse-configuration-records.ts | 2 +- src/backend/features/search/contract.ts | 2 +- src/backend/features/thumbnails/reconcile.ts | 2 +- src/backend/features/thumbnails/render.ts | 4 +- src/backend/features/thumbnails/store.ts | 25 +----- src/frontend/components/callout.tsx | 2 +- src/frontend/components/status-icon.tsx | 2 +- src/frontend/features/dashboard/metrics.ts | 2 +- .../features/dashboard/table-pagination.tsx | 2 +- src/frontend/features/insert/quantity-box.ts | 2 +- src/frontend/lib/onshape-launch.ts | 2 +- 18 files changed, 70 insertions(+), 77 deletions(-) diff --git a/src/backend/features/auth/request-auth.ts b/src/backend/features/auth/request-auth.ts index 78ed707de..f1c6c5765 100644 --- a/src/backend/features/auth/request-auth.ts +++ b/src/backend/features/auth/request-auth.ts @@ -78,7 +78,7 @@ export async function getOnshapeApi(c: AppContext): Promise<OAuthApi> { * session id rather than request because work started by one outlives it — a * render queued under the user who asked for it, which the renderer is keyed by. */ -export async function getUserIdFromSessionId( +async function getUserIdFromSessionId( kv: KVNamespace, sessionId: string ): Promise<string> { diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index 2bae006fd..6249ddd83 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -54,7 +54,7 @@ interface ConfigurationBuildIssueOf< } /** The issue types a configuration raises, rather than the element itself. */ -export type ConfigurationIssueType = +type ConfigurationIssueType = | BuildIssueType.CONFIGURATION_MULTIPLE_PARTS | BuildIssueType.UNSTABLE_COMPOSITE; diff --git a/src/backend/features/configurations/instances.ts b/src/backend/features/configurations/instances.ts index 9767a24dc..c0e765447 100644 --- a/src/backend/features/configurations/instances.ts +++ b/src/backend/features/configurations/instances.ts @@ -24,7 +24,7 @@ const MAX_COMBINATIONS = 256; const MAX_INSTANCES = 16; /** One controlling choice on the way to an instance. */ -export interface InstanceStep { +interface InstanceStep { parameterId: string; /** The choices leading here, e.g. "Generic"; several when they lead to the * same list, joined as "Generic or WCP". */ @@ -32,7 +32,7 @@ export interface InstanceStep { } /** One parameter as it is shown under one set of controlling choices. */ -export interface ParameterInstance { +interface ParameterInstance { parameter: ConfigurationParameter; /** The choices it is shown under, outermost first; empty when nothing * conditions it. */ diff --git a/src/backend/features/insert-location/parse.test.ts b/src/backend/features/insert-location/parse.test.ts index 35ea25bd0..288dff4bf 100644 --- a/src/backend/features/insert-location/parse.test.ts +++ b/src/backend/features/insert-location/parse.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { INSERT_LOCATION_SKETCH_ID, INSERT_LOCATION_SOURCE } from "./contract"; -import { findInsertLocationInstance, getInstanceTransform } from "./parse"; +import { findInsertLocation, getInsertLocationTransform } from "./parse"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { type ElementPath } from "../../lib/onshape/path"; import { type OnshapeAssemblyDefinition, type OnshapeAssemblyInstance @@ -27,50 +29,66 @@ function toAssembly( }; } -describe("findInsertLocationInstance", () => { - it("finds the sketch the app inserts, among other instances", () => { +const ASSEMBLY_PATH: ElementPath = { + documentId: "doc", + instanceType: "w", + instanceId: "ws", + elementId: "asm" +}; + +/** A client whose every request is answered with `assembly`. */ +function answering(assembly: OnshapeAssemblyDefinition): MockOnshapeApi { + const api = new MockOnshapeApi(); + vi.spyOn(api, "get").mockResolvedValue(assembly); + return api; +} + +const findIn = (assembly: OnshapeAssemblyDefinition) => + findInsertLocation(answering(assembly), ASSEMBLY_PATH); + +const transformIn = (assembly: OnshapeAssemblyDefinition, instanceId: string) => + getInsertLocationTransform(answering(assembly), ASSEMBLY_PATH, instanceId); + +describe("findInsertLocation", () => { + it("finds the sketch the app inserts, among other instances", async () => { const assembly = toAssembly([ { id: "part", type: "Part" }, MARKER, { id: "sub", type: "Assembly" } ]); - expect(findInsertLocationInstance(assembly)?.id).toBe("marker"); + expect(await findIn(assembly)).toBe("marker"); }); // An assembly can hold a marker inserted before the sketch was revised. - it("matches whatever version the marker was inserted from", () => { + it("matches whatever version the marker was inserted from", async () => { const assembly = toAssembly([ { ...MARKER, documentVersion: "older" } as OnshapeAssemblyInstance ]); - expect(findInsertLocationInstance(assembly)?.id).toBe("marker"); + expect(await findIn(assembly)).toBe("marker"); }); - it("ignores a suppressed marker, which nothing can insert at", () => { + it("ignores a suppressed marker, which nothing can insert at", async () => { expect( - findInsertLocationInstance( - toAssembly([{ ...MARKER, suppressed: true }]) - ) + await findIn(toAssembly([{ ...MARKER, suppressed: true }])) ).toBeUndefined(); }); - it("ignores a sketch inserted from some other tab", () => { + it("ignores a sketch inserted from some other tab", async () => { expect( - findInsertLocationInstance( - toAssembly([{ ...MARKER, elementId: "elsewhere" }]) - ) + await findIn(toAssembly([{ ...MARKER, elementId: "elsewhere" }])) ).toBeUndefined(); }); // The marker inserted from a version of the tab we no longer name, so the // sketch's own id is no longer what it was when the constant was written. - it("matches a sketch id the constant does not name", () => { + it("matches a sketch id the constant does not name", async () => { const assembly = toAssembly([{ ...MARKER, featureId: "redrawn" }]); - expect(findInsertLocationInstance(assembly)?.id).toBe("marker"); + expect(await findIn(assembly)).toBe("marker"); }); // Onshape naming the tab only on the partStudioFeatures entry, which is the // shape the instance list alone cannot be matched against. - it("finds a marker whose instance names only its feature", () => { + it("finds a marker whose instance names only its feature", async () => { const assembly = toAssembly( [{ id: "marker", type: "Feature", featureId: "sketch" }], undefined, @@ -82,10 +100,10 @@ describe("findInsertLocationInstance", () => { } ] ); - expect(findInsertLocationInstance(assembly)?.id).toBe("marker"); + expect(await findIn(assembly)).toBe("marker"); }); - it("ignores a feature inserted from some other tab's sketch", () => { + it("ignores a feature inserted from some other tab's sketch", async () => { const assembly = toAssembly( [{ id: "other", type: "Feature", featureId: "sketch" }], undefined, @@ -97,23 +115,23 @@ describe("findInsertLocationInstance", () => { } ] ); - expect(findInsertLocationInstance(assembly)).toBeUndefined(); + expect(await findIn(assembly)).toBeUndefined(); }); // An instance naming no feature at all, against a tab whose entry names no // feature either: nothing lines up, so nothing matches. - it("does not pair an instance and an entry by what both leave out", () => { + it("does not pair an instance and an entry by what both leave out", async () => { const assembly = toAssembly([{ id: "part", type: "Part" }], undefined, [ { documentId: INSERT_LOCATION_SOURCE.documentId, elementId: INSERT_LOCATION_SOURCE.elementId } ]); - expect(findInsertLocationInstance(assembly)).toBeUndefined(); + expect(await findIn(assembly)).toBeUndefined(); }); }); -describe("getInstanceTransform", () => { +describe("getInsertLocationTransform", () => { // prettier-ignore const transform = [ 1, 0, 0, -0.139, @@ -122,27 +140,25 @@ describe("getInstanceTransform", () => { 0, 0, 0, 1 ]; - it("reads where a top-level instance has been dragged to", () => { + it("reads where a top-level instance has been dragged to", async () => { const assembly = toAssembly( [MARKER], [{ path: ["marker"], transform }] ); - expect(getInstanceTransform(assembly, "marker")).toEqual(transform); + expect(await transformIn(assembly, "marker")).toEqual(transform); }); // A deeper path is the same instance inside a subassembly, which is a // different thing in a different place. - it("ignores an occurrence nested under another instance", () => { + it("ignores an occurrence nested under another instance", async () => { const assembly = toAssembly( [MARKER], [{ path: ["sub", "marker"], transform }] ); - expect(getInstanceTransform(assembly, "marker")).toBeUndefined(); + expect(await transformIn(assembly, "marker")).toBeUndefined(); }); - it("has no transform for an instance that is no longer there", () => { - expect( - getInstanceTransform(toAssembly([], []), "marker") - ).toBeUndefined(); + it("has no transform for an instance that is no longer there", async () => { + expect(await transformIn(toAssembly([], []), "marker")).toBeUndefined(); }); }); diff --git a/src/backend/features/insert-location/parse.ts b/src/backend/features/insert-location/parse.ts index b2151cef5..d97693e43 100644 --- a/src/backend/features/insert-location/parse.ts +++ b/src/backend/features/insert-location/parse.ts @@ -12,7 +12,7 @@ import { * The assembly as the insert location needs it. A sketch is not a solid, so * without `includeNonSolids` the marker is not in the response at all. */ -export function getAssemblyWithMarkers( +function getAssemblyWithMarkers( onshapeApi: OnshapeApi, assemblyPath: ElementPath ): Promise<OnshapeAssemblyDefinition> { @@ -43,7 +43,7 @@ function isFromSourceTab(reference: { * places the tab can be named are accepted: the instance itself, and the * `partStudioFeatures` entry its `featureId` points at. */ -export function findInsertLocationInstance( +function findInsertLocationInstance( assembly: OnshapeAssemblyDefinition ): OnshapeAssemblyInstance | undefined { const markerFeatureIds = new Set( @@ -67,7 +67,7 @@ export function findInsertLocationInstance( * Undefined when the instance is gone, which is what a marker deleted since the * app opened looks like — the insert then lands at the origin. */ -export function getInstanceTransform( +function getInstanceTransform( assembly: OnshapeAssemblyDefinition, instanceId: string ): number[] | undefined { @@ -77,7 +77,7 @@ export function getInstanceTransform( )?.transform; } -/** {@link findInsertLocationInstance} against the assembly as it is now. */ +/** The marker's instance id in the assembly as it is now. */ export async function findInsertLocation( onshapeApi: OnshapeApi, assemblyPath: ElementPath diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index 10550d2f0..2584c5f2c 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -27,7 +27,7 @@ export const LOAD_CONCURRENCY = 15; * probing's, because a thumbnail step holds its slot through minutes of * retries, and on the probing limiter that would stall probes behind it. */ -export const THUMBNAIL_CONCURRENCY = 10; +const THUMBNAIL_CONCURRENCY = 10; /** The runtime plumbing a load runs against. */ export interface LoadContext { diff --git a/src/backend/features/load/load-group.ts b/src/backend/features/load/load-group.ts index a88fa5769..9794d614b 100644 --- a/src/backend/features/load/load-group.ts +++ b/src/backend/features/load/load-group.ts @@ -431,7 +431,7 @@ export function findRemovedInsertables( } /** A stored insertable's new position in the document's tab order. */ -export interface InsertableOrder { +interface InsertableOrder { insertableId: string; sortOrder: number; } diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index 15439cbda..4eb71b5af 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -235,7 +235,7 @@ export interface ProbeTarget { * The client is fetched per read rather than held, since a step that retries * hours later needs a token that has not expired. */ -export type ProbeRunner = ( +type ProbeRunner = ( name: string, read: () => Promise<ConfigurationRecord[]> ) => Promise<ConfigurationRecord[]>; diff --git a/src/backend/features/search/contract.ts b/src/backend/features/search/contract.ts index c4dc5144d..f384755d3 100644 --- a/src/backend/features/search/contract.ts +++ b/src/backend/features/search/contract.ts @@ -40,7 +40,7 @@ export const INSERTABLE_FIELDS = [NAME_FIELD, GROUP_NAME_FIELD]; * surface can leave them out: they describe every configuration at once, which * a list showing one specific configuration has no way to represent. */ -export const CONFIGURATION_FIELDS = [PART_NUMBER_FIELD, PART_NAME_FIELD]; +const CONFIGURATION_FIELDS = [PART_NUMBER_FIELD, PART_NAME_FIELD]; export const SEARCH_OPTIONS: Options<SearchDocument> = { fields: [...INSERTABLE_FIELDS, ...CONFIGURATION_FIELDS], diff --git a/src/backend/features/thumbnails/reconcile.ts b/src/backend/features/thumbnails/reconcile.ts index 45b373708..9a3ba957f 100644 --- a/src/backend/features/thumbnails/reconcile.ts +++ b/src/backend/features/thumbnails/reconcile.ts @@ -35,7 +35,7 @@ const MAX_PAGES = 50; */ const MIN_AGE_MS = 24 * 60 * 60 * 1000; -export interface ReconcileResult { +interface ReconcileResult { /** Objects under the thumbnail prefix this run looked at. */ scanned: number; deleted: number; diff --git a/src/backend/features/thumbnails/render.ts b/src/backend/features/thumbnails/render.ts index 7027459f8..59c993228 100644 --- a/src/backend/features/thumbnails/render.ts +++ b/src/backend/features/thumbnails/render.ts @@ -19,7 +19,7 @@ import { PREFERRED_SIZE, RenderSource, ThumbnailSize } from "./contract"; import { thumbnailKey } from "./keys"; import type { RenderTarget } from "./render-workflow"; -export interface RenderRequest { +interface RenderRequest { /** What the element path is resolved from, since the caller has only this. */ insertableId: string; elementId: string; @@ -31,7 +31,7 @@ export interface RenderRequest { * What asking did: a render is coming, or it cannot — Onshape has no * insertable for the configuration, so the caller can say so at once. */ -export type RenderOutcome = "rendering" | "no-such-configuration"; +type RenderOutcome = "rendering" | "no-such-configuration"; /** Statuses of an instance still working towards its bytes. */ const ACTIVE = new Set<InstanceStatus["status"]>([ diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts index f0ef98370..a9c59f4b0 100644 --- a/src/backend/features/thumbnails/store.ts +++ b/src/backend/features/thumbnails/store.ts @@ -19,7 +19,7 @@ import { OnshapeApi } from "../../lib/onshape/client"; * What produced a stored thumbnail, tagged onto the R2 object. The key already * addresses it; this is for reading an object back and telling what it is. */ -export interface ThumbnailMetadata extends Record<string, string> { +interface ThumbnailMetadata extends Record<string, string> { microversionId: string; /** Empty for an element's own thumbnail, as everywhere else. */ configurationKey: ConfigurationKey; @@ -101,26 +101,3 @@ export function thumbnailUrls( }) }; } - -/** - * The urls for a subject, or null while either size is still rendering. Both or - * neither, so nothing records half a pair. - */ -export async function readThumbnailUrls( - bucket: R2Bucket, - elementId: string, - microversionId: string, - configurationKey: ConfigurationKey = DEFAULT_CONFIGURATION_KEY -): Promise<ThumbnailUrls | null> { - const stored = await Promise.all( - BOTH_SIZES.map((size) => - bucket.head( - thumbnailKey(elementId, microversionId, size, configurationKey) - ) - ) - ); - if (stored.some((object) => object === null)) { - return null; - } - return thumbnailUrls(elementId, microversionId, configurationKey); -} diff --git a/src/frontend/components/callout.tsx b/src/frontend/components/callout.tsx index b77fd2748..fe068ac7c 100644 --- a/src/frontend/components/callout.tsx +++ b/src/frontend/components/callout.tsx @@ -3,7 +3,7 @@ import { InfoIcon } from "@phosphor-icons/react"; import { ReactNode } from "react"; import { IconSize, StatusColor } from "../lib/style-constants"; -export interface CalloutAction { +interface CalloutAction { /** A verb or a destination, e.g. "Instructions". */ text: string; icon: ReactNode; diff --git a/src/frontend/components/status-icon.tsx b/src/frontend/components/status-icon.tsx index 98b533ddb..f2a3a3cec 100644 --- a/src/frontend/components/status-icon.tsx +++ b/src/frontend/components/status-icon.tsx @@ -9,7 +9,7 @@ import { } from "../lib/style-constants"; import styles from "../lib/styles.module.css"; -export interface StatusIconProps { +interface StatusIconProps { /** What the status is about: the target tab, a build, a connection. */ icon: Icon; /** The state itself, badged on the corner — a tick or a warning. */ diff --git a/src/frontend/features/dashboard/metrics.ts b/src/frontend/features/dashboard/metrics.ts index c55a39f7c..491977e55 100644 --- a/src/frontend/features/dashboard/metrics.ts +++ b/src/frontend/features/dashboard/metrics.ts @@ -73,7 +73,7 @@ export const METRICS: Record<MetricKey, MetricDefinition> = { }; /** The raw numerator and denominator behind a range value. */ -export interface MetricTerms { +interface MetricTerms { numerator: number; denominator: number; } diff --git a/src/frontend/features/dashboard/table-pagination.tsx b/src/frontend/features/dashboard/table-pagination.tsx index dbebc146d..24684500d 100644 --- a/src/frontend/features/dashboard/table-pagination.tsx +++ b/src/frontend/features/dashboard/table-pagination.tsx @@ -2,7 +2,7 @@ import { Group, Pagination } from "@mantine/core"; import { useState, type ReactNode } from "react"; /** Short enough that a page of a dashboard table reads at a glance. */ -export const ROWS_PER_PAGE = 10; +const ROWS_PER_PAGE = 10; interface Paged<T> { rows: T[]; diff --git a/src/frontend/features/insert/quantity-box.ts b/src/frontend/features/insert/quantity-box.ts index c41d254a0..84d6e4b83 100644 --- a/src/frontend/features/insert/quantity-box.ts +++ b/src/frontend/features/insert/quantity-box.ts @@ -7,7 +7,7 @@ import { } from "@backend/features/configurations/input-parser"; /** Everything the quantity box shows: the expression, its display, and any error. */ -export interface QuantityBox { +interface QuantityBox { /** Shown while the input has focus: what was typed, or what to edit. */ expression: string; /** The evaluated value, shown while it does not. */ diff --git a/src/frontend/lib/onshape-launch.ts b/src/frontend/lib/onshape-launch.ts index 8c166153c..5bab82b7c 100644 --- a/src/frontend/lib/onshape-launch.ts +++ b/src/frontend/lib/onshape-launch.ts @@ -11,7 +11,7 @@ import { ElementType } from "@backend/lib/onshape/element-type"; import { INSTANCE_TYPES, type ElementPath } from "@backend/lib/onshape/path"; /** A resolved color scheme, as Onshape provides it; Theme adds "system" on top. */ -export const ColorThemeType = z.enum(["light", "dark"]); +const ColorThemeType = z.enum(["light", "dark"]); export type ColorTheme = z.infer<typeof ColorThemeType>; From ad88f96eae553a0a222a5824331a85d7d6c6ea8f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 23 Sep 2026 21:43:59 +0000 Subject: [PATCH 20/88] Stick each section header only over its own list Headers now stick within their own section, so the next one pushes the last off rather than stacking beneath it, and a section showing a zero state does not stick at all: a header stuck over one only half-covered it on the way out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/frontend/components/app-zero-state.tsx | 2 ++ .../app/library/$libraryId/index.module.css | 22 +++++++++---------- .../routes/app/library/$libraryId/index.tsx | 8 ++----- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/frontend/components/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx index 5818fc7f2..1c969df97 100644 --- a/src/frontend/components/app-zero-state.tsx +++ b/src/frontend/components/app-zero-state.tsx @@ -33,6 +33,8 @@ export function ZeroState(props: ZeroStateProps): ReactNode { className={className} pt={24} pb={24} + // What a sticky section header checks for, to not stick over one. + data-zero-state > <EmptyState.Actions>{action}</EmptyState.Actions> </EmptyState> diff --git a/src/frontend/routes/app/library/$libraryId/index.module.css b/src/frontend/routes/app/library/$libraryId/index.module.css index 6deb01f23..02c69b83b 100644 --- a/src/frontend/routes/app/library/$libraryId/index.module.css +++ b/src/frontend/routes/app/library/$libraryId/index.module.css @@ -2,17 +2,25 @@ * Mantine brightens a control to pure white or black; a section header is a * title like the group page's, so it reads in the same text color. * - * Stuck below the headers already stuck, each `sectionHeader`'s height. The - * scroll container's padding already clears the navbar it runs underneath. + * Stuck while its own section is on screen, until the next header pushes it + * off. Top 0 is below the navbar: the scroll container's padding clears it. */ .control { color: var(--mantine-color-text); position: sticky; - top: calc(var(--section-index) * rem(48px)); + top: 0; z-index: 1; background: var(--mantine-color-body); } +/* + * A zero state is one short block with nothing to scroll through, so a header + * stuck over it only half-covers it on the way out. + */ +.item:has([data-zero-state]) .control { + position: static; +} + /* Its own padding would outgrow the header's height. */ .label { padding-block: 0; @@ -21,11 +29,3 @@ .content { padding: 0; } - -/* - * Lets every header stick against the accordion as a whole rather than its own - * section, so a header stays in reach below the ones above it. - */ -.item { - display: contents; -} diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index d675e65c7..d5c842fb1 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -128,12 +128,8 @@ function SectionAccordion(props: SectionAccordionProps): ReactNode { icon: styles.titleIcon }} > - {sections.map((section, index) => ( - <Accordion.Item - key={section.value} - value={section.value} - style={{ "--section-index": index }} - > + {sections.map((section) => ( + <Accordion.Item key={section.value} value={section.value}> <Accordion.Control icon={section.icon} className="interactive" From f7796a48fb8e1a7c749bea46793c78389cb950a9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 23 Sep 2026 22:01:02 +0000 Subject: [PATCH 21/88] Open hover cards on a tap too, without opening the row under them Mantine's HoverCard opens only on the mouse events a tap emulates, and lets the tap through, so on a phone the same tap opened the build-status card and the row's insert menu. AppHoverCard opens on hover and on a click or tap, which pins it; the click, the card's own clicks and the tap that dismisses it (which lands on an invisible overlay) are kept from the row. It replaces every HoverCard: build status, thumbnails and the insert location. Tooltips now show on touch as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- .../components/app-hover-card.test.tsx | 53 ++++++ src/frontend/components/app-hover-card.tsx | 157 ++++++++++++++++++ .../build-status/components/build-status.tsx | 85 ++++------ src/frontend/features/build-status/queries.ts | 4 +- .../components/insert-location-status.tsx | 71 ++++---- .../thumbnails/components/thumbnail.tsx | 51 +++--- src/frontend/theme.ts | 13 +- 7 files changed, 304 insertions(+), 130 deletions(-) create mode 100644 src/frontend/components/app-hover-card.test.tsx create mode 100644 src/frontend/components/app-hover-card.tsx diff --git a/src/frontend/components/app-hover-card.test.tsx b/src/frontend/components/app-hover-card.test.tsx new file mode 100644 index 000000000..891f0059e --- /dev/null +++ b/src/frontend/components/app-hover-card.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../__test_utils__/render"; +import { AppHoverCard } from "./app-hover-card"; + +function renderInRow() { + const openRow = vi.fn(); + renderWithProviders( + <div onClick={openRow}> + <AppHoverCard target={<span>badge</span>}>card</AppHoverCard> + <span>elsewhere in the row</span> + </div> + ); + return openRow; +} + +describe("AppHoverCard", () => { + // A tap on a phone used to open the card and the row it sits in at once. + it("opens on a click without the row seeing it", async () => { + const user = userEvent.setup(); + const openRow = renderInRow(); + + await user.click(screen.getByText("badge")); + + expect(screen.queryByText("card")).not.toBeNull(); + expect(openRow).not.toHaveBeenCalled(); + }); + + it("keeps a click on the card from the row too", async () => { + const user = userEvent.setup(); + const openRow = renderInRow(); + + await user.click(screen.getByText("badge")); + await user.click(screen.getByText("card")); + + expect(openRow).not.toHaveBeenCalled(); + }); + + // That the dismissing click lands on the overlay rather than a row is + // layout, which jsdom does not do; it was checked in a touch browser. + it("closes on a click outside", async () => { + const user = userEvent.setup(); + renderInRow(); + + await user.click(screen.getByText("badge")); + await user.click(document.body); + + await waitFor(() => { + expect(screen.queryByText("card")).toBeNull(); + }); + }); +}); diff --git a/src/frontend/components/app-hover-card.tsx b/src/frontend/components/app-hover-card.tsx new file mode 100644 index 000000000..e955c4cee --- /dev/null +++ b/src/frontend/components/app-hover-card.tsx @@ -0,0 +1,157 @@ +import { Box, Popover, type PopoverProps } from "@mantine/core"; +import { + createContext, + type MouseEvent, + type PointerEvent, + type ReactNode, + use, + useCallback, + useEffect, + useRef, + useState +} from "react"; + +const CloseHoverCardContext = createContext<() => void>(() => undefined); + +/** + * Closes the card a control is rendered inside. For a control that opens a + * modal: the pointer never leaves a card an overlay covers, so it would stay. + */ +export function useCloseHoverCard(): () => void { + return use(CloseHoverCardContext); +} + +interface AppHoverCardProps extends Pick< + PopoverProps, + "position" | "arrowSize" +> { + /** What is hovered or tapped. Wrapped, so it need not take a ref. */ + target: ReactNode; + /** The card's content. */ + children: ReactNode; + /** @default "md" */ + padding?: string; + /** @default 0 */ + openDelay?: number; + /** @default 150 */ + closeDelay?: number; +} + +/** + * A card that opens on hover and on a click or tap, which is the only way to + * reach one on a touchscreen. A click pins it open until clicked again or + * dismissed, so a mouse leaving it does not close what was asked for. + * + * The click is kept from the row underneath: Mantine's `HoverCard` opens on + * the mouse events a tap emulates and lets the tap through, so on a phone the + * same tap opened the card and the row. + */ +/** + * For what is portaled out of the row: React bubbles through the portal, so a + * click on the card or its overlay would otherwise reach the row too. + */ +const stopPropagation = (event: MouseEvent) => event.stopPropagation(); + +export function AppHoverCard(props: AppHoverCardProps): ReactNode { + const { + target, + children, + padding = "md", + openDelay = 0, + closeDelay = 150, + ...popoverProps + } = props; + const [opened, setOpened] = useState(false); + const [pinned, setPinned] = useState(false); + // Outlives the pin until the card has faded out: a tap's click arrives + // after the touchstart that closed the card, and has to land here too. + const [overlaid, setOverlaid] = useState(false); + const timer = useRef<number | undefined>(undefined); + + useEffect(() => () => window.clearTimeout(timer.current), []); + + const close = useCallback(() => { + window.clearTimeout(timer.current); + setOpened(false); + setPinned(false); + }, []); + + const schedule = (next: boolean, delay: number) => { + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setOpened(next), delay); + }; + + // Hover is a mouse's alone: a touch's pointer events arrive with its tap, + // and the click that follows decides. + const handleEnter = (event: PointerEvent) => { + if (event.pointerType === "mouse") { + schedule(true, openDelay); + } + }; + + const handleLeave = (event: PointerEvent) => { + if (event.pointerType === "mouse" && !pinned) { + schedule(false, closeDelay); + } + }; + + const handleClick = (event: MouseEvent) => { + event.stopPropagation(); + if (pinned) { + close(); + } else { + window.clearTimeout(timer.current); + setOpened(true); + setPinned(true); + setOverlaid(true); + } + }; + + return ( + <Popover + {...popoverProps} + opened={opened} + // A click outside or Escape, whichever pinned it. + onDismiss={close} + // Invisible, and only for a pinned card: the tap that dismisses it + // lands here rather than opening whatever row is underneath. A + // hovered card has none, since the pointer leaving it is what + // closes it. + withOverlay={overlaid} + overlayProps={{ + backgroundOpacity: 0, + onClick: stopPropagation + }} + onExitTransitionEnd={() => setOverlaid(false)} + // Across as well as along, so a card beside a row on a phone is + // pushed back on screen rather than cut off at its edge. + middlewares={{ flip: true, shift: { crossAxis: true, padding: 8 } }} + withinPortal + shadow="md" + withArrow + > + <Popover.Target> + <Box + component="span" + display="inline-flex" + onPointerEnter={handleEnter} + onPointerLeave={handleLeave} + onClick={handleClick} + > + {target} + </Box> + </Popover.Target> + <Popover.Dropdown + p={padding} + maw="calc(100vw - 16px)" + onPointerEnter={handleEnter} + onPointerLeave={handleLeave} + onClick={stopPropagation} + > + <CloseHoverCardContext value={close}> + {children} + </CloseHoverCardContext> + </Popover.Dropdown> + </Popover> + ); +} diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index d9c923cee..ee0b16af8 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -1,14 +1,6 @@ -import { - Divider, - Group, - HoverCard, - Loader, - Stack, - Text, - Tooltip -} from "@mantine/core"; +import { Divider, Group, Loader, Stack, Text, Tooltip } from "@mantine/core"; import { EyeSlashIcon, GitBranchIcon } from "@phosphor-icons/react"; -import { ReactNode, createContext, use, useCallback, useState } from "react"; +import { ReactNode } from "react"; import { formatDaysAgo } from "../../../lib/format-time"; import { BuildIssue, @@ -21,6 +13,7 @@ import { StatusColor } from "../../../lib/style-constants"; import { AppIcon } from "../../../components/app-icon"; +import { AppHoverCard } from "../../../components/app-hover-card"; import { RequireAccessLevel } from "../../auth/access-level"; import { TruncatedText } from "../../../components/truncated-text"; import { useBuildStatusQuery } from "../queries"; @@ -92,17 +85,6 @@ interface BuildStatusBadgeProps extends BuildStatusSubject { hoverMenu: ReactNode; } -/** - * For controls that open a modal: `HoverCard` closes on mouse-leave, which never - * fires when an overlay covers the dropdown, stranding it behind. - */ -const CloseCardContext = createContext<() => void>(() => undefined); - -/** Dismisses the build-status hover card a control is rendered inside. */ -export function useCloseBuildCard(): () => void { - return use(CloseCardContext); -} - /** * A severity icon whose hover card shows the build-status card wrapping the * given admin menu. Gated first, so the card and its admin controls only exist @@ -130,40 +112,35 @@ function BuildStatusHoverCard({ const maxSeverity = getMaxSeverity(issues); const jobRunning = useIsJobRunning(); - // Remounting is the only way to close an uncontrolled HoverCard on demand. - const [cardKey, setCardKey] = useState(0); - const close = useCallback(() => setCardKey((key) => key + 1), []); - return ( - <CloseCardContext value={close}> - <HoverCard key={cardKey} position="right" arrowSize={20}> - <HoverCard.Target> - {jobRunning ? ( - <Loader size={IconSize.SMALL} /> - ) : isHidden ? ( - // Nobody but an editor sees a hidden insertable, so what - // its checks say about it does not matter yet. - <AppIcon - icon={EyeSlashIcon} - color={StatusColor.WARNING} - label="Hidden" - /> - ) : ( - <IssueIcon severity={maxSeverity} /> - )} - </HoverCard.Target> - <HoverCard.Dropdown p="md" onClick={(e) => e.stopPropagation()}> - <BuildStatusCard - name={name} - issues={issues} - versionCreatedAt={versionCreatedAt} - configurationTarget={configurationTarget} - > - {hoverMenu} - </BuildStatusCard> - </HoverCard.Dropdown> - </HoverCard> - </CloseCardContext> + <AppHoverCard + position="right" + arrowSize={20} + target={ + jobRunning ? ( + <Loader size={IconSize.SMALL} /> + ) : isHidden ? ( + // Nobody but an editor sees a hidden insertable, so what + // its checks say about it does not matter yet. + <AppIcon + icon={EyeSlashIcon} + color={StatusColor.WARNING} + label="Hidden" + /> + ) : ( + <IssueIcon severity={maxSeverity} /> + ) + } + > + <BuildStatusCard + name={name} + issues={issues} + versionCreatedAt={versionCreatedAt} + configurationTarget={configurationTarget} + > + {hoverMenu} + </BuildStatusCard> + </AppHoverCard> ); } diff --git a/src/frontend/features/build-status/queries.ts b/src/frontend/features/build-status/queries.ts index f30e1f863..f437e988b 100644 --- a/src/frontend/features/build-status/queries.ts +++ b/src/frontend/features/build-status/queries.ts @@ -16,7 +16,7 @@ import { getAppErrorHandler } from "../../lib/errors"; import { patchQuery } from "../../lib/query-cache"; import { useRefreshLibrary } from "../../lib/refresh"; import { toInsertablePath, toLibraryPath } from "../../lib/api-paths"; -import { useCloseBuildCard } from "./components/build-status"; +import { useCloseHoverCard } from "../../components/app-hover-card"; import { type LibraryBuildStatus } from "@backend/features/build-checker/contract"; import { LibraryId } from "@backend/features/library/library-id"; import { useLibraryId } from "../../lib/library"; @@ -59,7 +59,7 @@ export function useSetVisibilityMutation( const refreshLibrary = useRefreshLibrary(); const key = useBuildStatusKey(); - const closeCard = useCloseBuildCard(); + const closeCard = useCloseHoverCard(); const mutation = useMutation({ mutationKey: ["set-insertable-visibility", ...insertableIds], diff --git a/src/frontend/features/insert-location/components/insert-location-status.tsx b/src/frontend/features/insert-location/components/insert-location-status.tsx index b349a0fec..ce7658546 100644 --- a/src/frontend/features/insert-location/components/insert-location-status.tsx +++ b/src/frontend/features/insert-location/components/insert-location-status.tsx @@ -1,5 +1,5 @@ import { ReactNode } from "react"; -import { Button, Center, EmptyState, HoverCard } from "@mantine/core"; +import { Button, Center, EmptyState } from "@mantine/core"; import { CheckIcon, PlusIcon, @@ -10,6 +10,7 @@ import { type TargetElement } from "../../../lib/onshape-launch"; import { IconSize, StatusColor } from "../../../lib/style-constants"; import { AppIcon } from "../../../components/app-icon"; import { StatusIcon } from "../../../components/status-icon"; +import { AppHoverCard } from "../../../components/app-hover-card"; import { useAddInsertLocationMutation, useInsertLocationQuery, @@ -58,10 +59,9 @@ function InsertLocationHoverCard( const stateColor = found ? StatusColor.SUCCESS : StatusColor.WARNING; return ( - <HoverCard position="bottom-end"> - <HoverCard.Target> - {/* Wrapped, because HoverCard.Target attaches a ref to its - child and StatusIcon does not take one. */} + <AppHoverCard + position="bottom-end" + target={ <Center my="auto"> <StatusIcon icon={TargetIcon} @@ -69,37 +69,36 @@ function InsertLocationHoverCard( color={stateColor} /> </Center> - </HoverCard.Target> - <HoverCard.Dropdown p="md"> - <EmptyState - align="left" - size="sm" - icon={ - <AppIcon - icon={stateIcon} - size={IconSize.CONTROL} - color={stateColor} - /> - } - title={ - found - ? "Insert location active" - : "No insert location found" - } - description={ - found - ? "New parts will be placed at the insert location." - : "New parts will be placed at the origin." - } - > - {!found && ( - <EmptyState.Actions> - <AddInsertLocationButton target={target} /> - </EmptyState.Actions> - )} - </EmptyState> - </HoverCard.Dropdown> - </HoverCard> + } + > + <EmptyState + align="left" + size="sm" + icon={ + <AppIcon + icon={stateIcon} + size={IconSize.CONTROL} + color={stateColor} + /> + } + title={ + found + ? "Insert location active" + : "No insert location found" + } + description={ + found + ? "New parts will be placed at the insert location." + : "New parts will be placed at the origin." + } + > + {!found && ( + <EmptyState.Actions> + <AddInsertLocationButton target={target} /> + </EmptyState.Actions> + )} + </EmptyState> + </AppHoverCard> ); } diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index 81799713b..07199aceb 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -12,10 +12,11 @@ import { ThumbnailSize } from "@backend/features/thumbnails/contract"; import { ElementPath } from "@backend/lib/onshape/path"; -import { Box, Card, Center, HoverCard, Loader } from "@mantine/core"; +import { Box, Card, Center, Loader } from "@mantine/core"; +import { AppHoverCard } from "../../../components/app-hover-card"; import { QuestionIcon } from "@phosphor-icons/react"; -import { ComponentPropsWithRef, PropsWithChildren, ReactNode } from "react"; +import { PropsWithChildren, ReactNode } from "react"; import { type ConfigurationKey, DEFAULT_CONFIGURATION_KEY @@ -99,13 +100,13 @@ export function CardThumbnail(props: CardThumbnailProps): ReactNode { const isRendering = configuredTarget?.renderSource !== undefined; return ( - <HoverCard + <AppHoverCard openDelay={150} closeDelay={50} position="right" arrowSize={20} - > - <HoverCard.Target> + padding="xs" + target={ <Thumbnail url={urlFor(ThumbnailSize.SMALL, smallThumbnailUrl)} fallbackUrl={fallbackFor(smallThumbnailUrl)} @@ -113,17 +114,16 @@ export function CardThumbnail(props: CardThumbnailProps): ReactNode { spinnerSize={25} isRendering={isRendering} /> - </HoverCard.Target> - <HoverCard.Dropdown p="xs"> - <Thumbnail - url={urlFor(ThumbnailSize.LARGE, largeThumbnailUrl)} - fallbackUrl={fallbackFor(largeThumbnailUrl)} - heightAndWidth={getHeightAndWidth(ThumbnailSize.LARGE, 0.6)} - spinnerSize={48} - isRendering={isRendering} - /> - </HoverCard.Dropdown> - </HoverCard> + } + > + <Thumbnail + url={urlFor(ThumbnailSize.LARGE, largeThumbnailUrl)} + fallbackUrl={fallbackFor(largeThumbnailUrl)} + heightAndWidth={getHeightAndWidth(ThumbnailSize.LARGE, 0.6)} + spinnerSize={48} + isRendering={isRendering} + /> + </AppHoverCard> ); } @@ -158,8 +158,7 @@ function isInvalidConfiguration(error: unknown): boolean { const retryRender = (failureCount: number, error: Error) => !isInvalidConfiguration(error) && failureCount <= POLL_RETRIES; -// Extend with div props to support being used as a HoverCard Target -interface ThumbnailProps extends ComponentPropsWithRef<"div"> { +interface ThumbnailProps { url?: string; /** * The element's own thumbnail, shown until `url` renders — a render takes @@ -175,14 +174,8 @@ interface ThumbnailProps extends ComponentPropsWithRef<"div"> { } function Thumbnail(props: ThumbnailProps): ReactNode { - const { - url, - fallbackUrl, - heightAndWidth, - spinnerSize, - isRendering, - ...centerProps - } = props; + const { url, fallbackUrl, heightAndWidth, spinnerSize, isRendering } = + props; const imageQuery = useQuery({ queryKey: storedThumbnailQueryKey(url), @@ -217,11 +210,7 @@ function Thumbnail(props: ThumbnailProps): ReactNode { } return ( - <Center - {...centerProps} - w={heightAndWidth.width} - h={heightAndWidth.height} - > + <Center w={heightAndWidth.width} h={heightAndWidth.height}> {content} </Center> ); diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index b8196211b..aa0a62f48 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -1,7 +1,6 @@ import { Card, createTheme, - HoverCard, type MantineColorsTuple, Tooltip } from "@mantine/core"; @@ -63,13 +62,13 @@ export function createAppTheme(libraryId: string) { // what makes it different. components: { Tooltip: Tooltip.extend({ - defaultProps: { withArrow: true, multiline: true, maw: 260 } - }), - HoverCard: HoverCard.extend({ defaultProps: { - withinPortal: true, - shadow: "md", - withArrow: true + withArrow: true, + multiline: true, + maw: 260, + // Off by Mantine's default, which leaves a touchscreen no + // way to read one. + events: { hover: true, focus: true, touch: true } } }), Card: Card.extend({ From a9600fcf2248da0758369a9bc137f323317490cb Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 23 Sep 2026 22:27:37 +0000 Subject: [PATCH 22/88] Reload on new Onshape versions, and re-ask access on admin team changes Adds an owner access level above admin, granted to the Onshape user OWNER_USER_ID names; the owner's latest session is kept for work the server starts on its own. The owner registers a company webhook from settings. A new version of a library document reloads just that document's groups under the owner's session, queued behind any reload already running; a member added to or removed from the admin team clears every cached access level. Deliveries are checked against a token minted at registration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/app.ts | 4 +- src/backend/features/auth/access-level.ts | 18 +- src/backend/features/auth/guards.ts | 19 +- src/backend/features/auth/owner.ts | 33 ++++ src/backend/features/auth/request-auth.ts | 21 +- .../features/auth/request-auth.worker.test.ts | 32 +++ src/backend/features/auth/session.ts | 17 +- src/backend/features/library/groups/routes.ts | 30 +-- src/backend/features/load/reload.ts | 101 ++++++++++ src/backend/features/load/workflows.ts | 24 ++- src/backend/features/webhooks/routes.ts | 136 +++++++++++++ .../features/webhooks/routes.worker.test.ts | 187 ++++++++++++++++++ src/backend/lib/context.ts | 2 + src/backend/lib/onshape/endpoints/webhooks.ts | 44 +++++ .../settings/components/settings-menu.tsx | 7 + .../components/register-webhooks-button.tsx | 24 +++ src/frontend/features/webhooks/queries.ts | 14 ++ wrangler.jsonc | 4 + 18 files changed, 675 insertions(+), 42 deletions(-) create mode 100644 src/backend/features/auth/owner.ts create mode 100644 src/backend/features/load/reload.ts create mode 100644 src/backend/features/webhooks/routes.ts create mode 100644 src/backend/features/webhooks/routes.worker.test.ts create mode 100644 src/backend/lib/onshape/endpoints/webhooks.ts create mode 100644 src/frontend/features/webhooks/components/register-webhooks-button.tsx create mode 100644 src/frontend/features/webhooks/queries.ts diff --git a/src/backend/app.ts b/src/backend/app.ts index 967edef21..02654cc5c 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -14,6 +14,7 @@ import { insertableRoutes } from "./features/library/insertables/routes"; import { libraryRoutes } from "./features/library/routes"; import { settingsRoutes } from "./features/settings/routes"; import { thumbnailRoutes } from "./features/thumbnails/routes"; +import { webhookRoutes } from "./features/webhooks/routes"; import { logger } from "hono/logger"; import { cacheMiddleware } from "./lib/cache"; import { bindAuth, getApp, type AuthResolver } from "./lib/context"; @@ -30,7 +31,8 @@ const apiRoutes = [ thumbnailRoutes, favoriteRoutes, buildStatusRoutes, - analyticsRoutes + analyticsRoutes, + webhookRoutes ]; export function createApp(resolveAuth: AuthResolver) { diff --git a/src/backend/features/auth/access-level.ts b/src/backend/features/auth/access-level.ts index cb7f5bc2f..ccbf7ad28 100644 --- a/src/backend/features/auth/access-level.ts +++ b/src/backend/features/auth/access-level.ts @@ -1,22 +1,26 @@ /** The permission tiers the app grants, and the predicates routes gate on. */ export enum AccessLevel { + /** + * The one Onshape user named by `OWNER_USER_ID`: an admin whose session the + * server borrows for work nobody asked for, like a webhook's reload. + */ + OWNER = "owner", ADMIN = "admin", EDITOR = "editor", USER = "user" } -export function hasEditorAccess(accessLevel: AccessLevel) { - return ( - accessLevel === AccessLevel.ADMIN || accessLevel === AccessLevel.EDITOR - ); -} - const ACCESS_LEVEL_RANK: Record<AccessLevel, number> = { [AccessLevel.USER]: 0, [AccessLevel.EDITOR]: 1, - [AccessLevel.ADMIN]: 2 + [AccessLevel.ADMIN]: 2, + [AccessLevel.OWNER]: 3 }; +export function hasEditorAccess(accessLevel: AccessLevel) { + return isWithinAccessLevel(AccessLevel.EDITOR, accessLevel); +} + /** Whether `accessLevel` grants no more than `maxAccessLevel` does. */ export function isWithinAccessLevel( accessLevel: AccessLevel, diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index 5a117104f..a2f6a3654 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -1,8 +1,11 @@ -/** The two gates routes mount: signed in to Onshape at all, and on the admin team. */ +/** + * The gates routes mount: signed in to Onshape at all, on the admin team, and + * the owner. + */ import type { MiddlewareHandler } from "hono"; import { forbiddenError, signInRequiredError } from "../../lib/api-error"; import type { AppContext, AppContextEnv } from "../../lib/context"; -import { hasEditorAccess } from "./access-level"; +import { AccessLevel, hasEditorAccess } from "./access-level"; import { isSignedIn } from "./request-auth"; async function requireSignIn(c: AppContext): Promise<void> { @@ -37,3 +40,15 @@ export const requireEditorMiddleware: MiddlewareHandler<AppContextEnv> = async ( } await next(); }; + +/** For what acts on the whole Onshape company, which only the owner may. */ +export const requireOwnerMiddleware: MiddlewareHandler<AppContextEnv> = async ( + c, + next +) => { + await requireSignIn(c); + if ((await c.var.getAccessLevel()) !== AccessLevel.OWNER) { + throw forbiddenError("Only the owner can use this functionality"); + } + await next(); +}; diff --git a/src/backend/features/auth/owner.ts b/src/backend/features/auth/owner.ts new file mode 100644 index 000000000..f67a60e14 --- /dev/null +++ b/src/backend/features/auth/owner.ts @@ -0,0 +1,33 @@ +/** + * The owner's session, kept for work the server starts on its own. A webhook + * has nobody signed in behind it, but loading a document calls Onshape as + * someone, so it borrows the session the owner last used the app with. + */ +import { type OAuthApi } from "../../lib/onshape/client"; +import { getOnshapeApiFromSessionId } from "./request-auth"; + +const OWNER_SESSION_KEY = "owner-session"; + +/** Called as the owner's access level is resolved, which a new sign-in does. */ +export async function rememberOwnerSession( + kv: KVNamespace, + sessionId: string +): Promise<void> { + await kv.put(OWNER_SESSION_KEY, sessionId); +} + +/** + * The owner's last session, or null when they have never used the app. It can + * have ended since — signed out, or unused past its lifetime — in which case + * calling Onshape with it fails until they next open the app. + */ +export function getOwnerSessionId(kv: KVNamespace): Promise<string | null> { + return kv.get(OWNER_SESSION_KEY); +} + +export async function getOwnerOnshapeApi( + kv: KVNamespace +): Promise<OAuthApi | undefined> { + const sessionId = await getOwnerSessionId(kv); + return sessionId ? getOnshapeApiFromSessionId(kv, sessionId) : undefined; +} diff --git a/src/backend/features/auth/request-auth.ts b/src/backend/features/auth/request-auth.ts index f1c6c5765..1a38647b1 100644 --- a/src/backend/features/auth/request-auth.ts +++ b/src/backend/features/auth/request-auth.ts @@ -11,6 +11,7 @@ import { } from "../../lib/onshape/endpoints/users"; import { type AppContext, type AuthResolver } from "../../lib/context"; import { AccessLevel } from "./access-level"; +import { rememberOwnerSession } from "./owner"; import { getOauthClient, makeAuthTokens, @@ -151,17 +152,27 @@ export async function isSignedIn(c: AppContext): Promise<boolean> { return signedIn; } +/** Whether the caller is the Onshape user `OWNER_USER_ID` names. */ +async function isOwner(c: AppContext): Promise<boolean> { + const ownerUserId = c.env.OWNER_USER_ID; + return !!ownerUserId && (await getCachedUserId(c)) === ownerUserId; +} + /** Returns the caller's access level, memoized in KV by session. */ async function getCachedAccessLevel(c: AppContext): Promise<AccessLevel> { - const key = accessLevelKey(getSessionId(c)); + const sessionId = getSessionId(c); + const key = accessLevelKey(sessionId); const cached = await c.env.KV.get(key); if (cached) return cached as AccessLevel; - const level = await getAccessLevel( - await getOnshapeApi(c), - c.env.ADMIN_TEAM - ); + let level: AccessLevel; + if (await isOwner(c)) { + level = AccessLevel.OWNER; + await rememberOwnerSession(c.env.KV, sessionId); + } else { + level = await getAccessLevel(await getOnshapeApi(c), c.env.ADMIN_TEAM); + } await c.env.KV.put(key, level, { expirationTtl: ACCESS_LEVEL_TTL_SECONDS }); diff --git a/src/backend/features/auth/request-auth.worker.test.ts b/src/backend/features/auth/request-auth.worker.test.ts index 4483d54c4..acaf9e339 100644 --- a/src/backend/features/auth/request-auth.worker.test.ts +++ b/src/backend/features/auth/request-auth.worker.test.ts @@ -5,6 +5,8 @@ import { AccessLevel } from "./access-level"; import { productionAuth } from "./request-auth"; import { createApp } from "../../app"; import { jsonRequest } from "../../../__test_utils__"; +import { saveSession } from "./session"; +import { getOwnerSessionId } from "./owner"; const app = createApp(productionAuth); @@ -43,3 +45,33 @@ describe("the dev access-level override", () => { expect(await getMaxAccessLevel()).toBe(AccessLevel.USER); }); }); + +describe("the owner", () => { + const OWNER = "owner-user-id"; + + /** A signed-in session whose user is already resolved, so Onshape is not asked. */ + async function accessLevelOf(userId: string): Promise<AccessLevel> { + const sessionId = crypto.randomUUID(); + await saveSession(env.KV, sessionId, { + accessToken: "token", + refreshToken: "refresh", + expiresAt: Date.now() + 60_000, + userId + }); + const res = await app.request( + "/api/access-data", + { + method: "GET", + headers: { Cookie: `frc-design-app-cookie=${sessionId}` } + }, + { ...env, OWNER_USER_ID: OWNER } + ); + const body: { maxAccessLevel: AccessLevel } = await res.json(); + return body.maxAccessLevel; + } + + it("is the user OWNER_USER_ID names, and their session is kept", async () => { + expect(await accessLevelOf(OWNER)).toBe(AccessLevel.OWNER); + expect(await getOwnerSessionId(env.KV)).not.toBeNull(); + }); +}); diff --git a/src/backend/features/auth/session.ts b/src/backend/features/auth/session.ts index 90cf94ce2..a11c712cd 100644 --- a/src/backend/features/auth/session.ts +++ b/src/backend/features/auth/session.ts @@ -57,9 +57,24 @@ function sessionKey(sessionId: string): string { return `tokens:${sessionId}`; } +const ACCESS_LEVEL_PREFIX = "access-level:"; + /** Keyed by session, so it is dropped along with one. */ export function accessLevelKey(sessionId: string): string { - return `access-level:${sessionId}`; + return ACCESS_LEVEL_PREFIX + sessionId; +} + +/** + * Forgets every session's cached access level, so each is asked of Onshape + * again on its next request: for when the admin team changes under them. + */ +export async function clearAccessLevels(kv: KVNamespace): Promise<void> { + let cursor: string | undefined; + do { + const page = await kv.list({ prefix: ACCESS_LEVEL_PREFIX, cursor }); + await Promise.all(page.keys.map((key) => kv.delete(key.name))); + cursor = page.list_complete ? undefined : page.cursor; + } while (cursor); } function loginKey(loginId: string): string { diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 235687118..5f06f7cee 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -10,14 +10,11 @@ import { getDocument } from "../../../lib/onshape/endpoints/documents"; import { requireEditorMiddleware } from "../../auth/guards"; import { type DocumentPath } from "../../../lib/onshape/path"; import { groups, insertables, favorites } from "../../../db/schema"; -import { bumpLibraryVersion, ensureLibrary, rebuildSearchDb } from "../db"; +import { bumpLibraryVersion, rebuildSearchDb } from "../db"; import { HttpStatus } from "http-status-ts"; import { handledError } from "../../../lib/api-error"; -import { - getJobStatus, - isReloadRunning, - trackJob -} from "../../load/job-tracker"; +import { getJobStatus, trackJob } from "../../load/job-tracker"; +import { startReload } from "../../load/reload"; import { z } from "zod"; import { validate } from "../../../lib/validate"; @@ -52,27 +49,14 @@ groupRoutes.post( requireEditorMiddleware, validate("query", reloadGroupsQuery), async (c) => { - const libraryId = getLibraryParam(c); const { forceReload } = c.req.valid("query"); - const sessionId = getSessionId(c); - - // Only one reload per library at a time. Racy under a sub-second - // double-trigger (KV has no compare-and-swap), which is fine here. - if (await isReloadRunning(c.env, libraryId)) { - return c.json({ status: "already-running" }); - } - - const db = getDb(c.env.DB); - await ensureLibrary(db, libraryId); - // The workflow owns the per-group version check — unchanged documents // are skipped inside it (unless forceReload). - const instance = await c.env.LOAD_LIBRARY_WORKFLOW.create({ - params: { libraryId, sessionId, forceReload } + const status = await startReload(c.env, getLibraryParam(c), { + sessionId: getSessionId(c), + forceReload }); - await trackJob(c.env, libraryId, "reload", instance.id); - - return c.json({ status: "triggered" }); + return c.json({ status }); } ); diff --git a/src/backend/features/load/reload.ts b/src/backend/features/load/reload.ts new file mode 100644 index 000000000..7f82e3279 --- /dev/null +++ b/src/backend/features/load/reload.ts @@ -0,0 +1,101 @@ +/** + * Starting a library reload, for the admin button and for Onshape's webhooks. + * A library runs one reload at a time; a document a webhook names while one is + * running is queued for when it ends, since the running one may have checked + * that document before its new version landed. + */ +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { ensureLibrary } from "../library/db"; +import type { LibraryId } from "../library/library-id"; +import { getOwnerSessionId } from "../auth/owner"; +import { isReloadRunning, trackJob } from "./job-tracker"; + +export interface ReloadRequest { + /** Whose Onshape session the reload calls Onshape with. */ + sessionId: string; + forceReload?: boolean; + /** Only the groups loaded from these; every group when absent. */ + documentIds?: string[]; +} + +export type ReloadOutcome = "triggered" | "already-running"; + +export async function startReload( + env: AppBindings, + libraryId: LibraryId, + request: ReloadRequest +): Promise<ReloadOutcome> { + // Racy under a sub-second double trigger (KV has no compare-and-swap), + // which is fine here. + if (await isReloadRunning(env, libraryId)) { + return "already-running"; + } + await ensureLibrary(getDb(env.DB), libraryId); + const instance = await env.LOAD_LIBRARY_WORKFLOW.create({ + params: { libraryId, ...request } + }); + await trackJob(env, libraryId, "reload", instance.id); + return "triggered"; +} + +function queueKey(libraryId: LibraryId): string { + return `queued-reload:${libraryId}`; +} + +/** Long enough to outlast any one reload, which is what drains the queue. */ +const QUEUE_TTL_SECONDS = 60 * 60 * 24; + +/** + * Reloads the groups loaded from `documentIds` under the owner's session, now + * or once the reload already running ends. + */ +export async function queueReload( + env: AppBindings, + libraryId: LibraryId, + documentIds: string[] +): Promise<void> { + const sessionId = await getOwnerSessionId(env.KV); + if (!sessionId) { + console.warn( + `No owner session to reload ${libraryId} with; the owner has not used the app yet.` + ); + return; + } + const outcome = await startReload(env, libraryId, { + sessionId, + documentIds + }); + if (outcome === "already-running") { + const queued = await readQueue(env, libraryId); + await env.KV.put( + queueKey(libraryId), + JSON.stringify([...new Set([...queued, ...documentIds])]), + { expirationTtl: QUEUE_TTL_SECONDS } + ); + } +} + +async function readQueue( + env: AppBindings, + libraryId: LibraryId +): Promise<string[]> { + const raw = await env.KV.get(queueKey(libraryId)); + return raw ? (JSON.parse(raw) as string[]) : []; +} + +/** + * Starts whatever was queued while a reload ran. Called by that reload once it + * no longer counts as running, so the reload this starts is not turned away. + */ +export async function startQueuedReload( + env: AppBindings, + libraryId: LibraryId +): Promise<void> { + const documentIds = await readQueue(env, libraryId); + if (documentIds.length === 0) { + return; + } + await env.KV.delete(queueKey(libraryId)); + await queueReload(env, libraryId, documentIds); +} diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 31cb09b9f..5005d618b 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -3,7 +3,7 @@ import { type WorkflowEvent, type WorkflowStep } from "cloudflare:workers"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import type { AppBindings } from "../../lib/context"; import { getDb } from "../../db/client"; import type { LibraryId } from "../library/library-id"; @@ -31,6 +31,7 @@ import { getOnshapeApiFromContext } from "./context"; import { untrackJob } from "./job-tracker"; +import { startQueuedReload } from "./reload"; import { loadGroup } from "./load-group"; import { ONSHAPE_STEP_RETRIES } from "./steps"; import { reconcileThumbnails } from "../thumbnails/reconcile"; @@ -39,6 +40,8 @@ export interface LoadLibraryParams { libraryId: LibraryId; sessionId: string; forceReload?: boolean; + /** Only the groups loaded from these; every group when absent. */ + documentIds?: string[]; } /** The outcome of loading a single group within a run. */ @@ -64,7 +67,12 @@ export class LoadLibraryWorkflow extends WorkflowEntrypoint< event: WorkflowEvent<LoadLibraryParams>, step: WorkflowStep ): Promise<GroupResult[]> { - const { libraryId, sessionId, forceReload = false } = event.payload; + const { + libraryId, + sessionId, + forceReload = false, + documentIds + } = event.payload; const ctx = createLoadContext(this.env, sessionId, step); const storedGroups = await step.do("list-groups", () => @@ -76,7 +84,14 @@ export class LoadLibraryWorkflow extends WorkflowEntrypoint< buildIssues: groups.buildIssues }) .from(groups) - .where(eq(groups.libraryId, libraryId)) + .where( + and( + eq(groups.libraryId, libraryId), + documentIds + ? inArray(groups.documentId, documentIds) + : undefined + ) + ) ); const results = await Promise.all( @@ -119,6 +134,9 @@ export class LoadLibraryWorkflow extends WorkflowEntrypoint< await step.do("untrack-job", () => untrackJob(ctx.env, libraryId, event.instanceId) ); + await step.do("start-queued-reload", () => + startQueuedReload(ctx.env, libraryId) + ); return results; } diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts new file mode 100644 index 000000000..124399136 --- /dev/null +++ b/src/backend/features/webhooks/routes.ts @@ -0,0 +1,136 @@ +/** + * Onshape's webhooks: registered by the owner for their company, and received + * here. A new version of a library document reloads its groups, and a change + * to the admin team's members re-asks everyone's access level. + * + * A notification carries nothing trusted: it only names a document or team, + * and what follows re-reads Onshape. The url's token is what keeps anyone + * else from making the server do that work. + */ +import { eq } from "drizzle-orm"; +import { HttpStatus } from "http-status-ts"; +import { type AppBindings, getApp } from "../../lib/context"; +import { forbiddenError, handledError } from "../../lib/api-error"; +import { getDb } from "../../db/client"; +import { groups } from "../../db/schema"; +import { getSessionInfo } from "../../lib/onshape/endpoints/users"; +import { + createWebhook, + deleteWebhook, + getCompanyWebhooks +} from "../../lib/onshape/endpoints/webhooks"; +import { requireOwnerMiddleware } from "../auth/guards"; +import { clearAccessLevels } from "../auth/session"; +import { queueReload } from "../load/reload"; + +export const webhookRoutes = getApp(); + +const RECEIVE_PATH = "/api/webhooks/onshape"; + +/** Kept in KV rather than configured, so registering is all it takes. */ +const TOKEN_KEY = "webhook-token"; + +export enum WebhookEvent { + CREATE_VERSION = "onshape.model.lifecycle.createversion", + TEAM_ADD_MEMBER = "onshape.team.addmember", + TEAM_REMOVE_MEMBER = "onshape.team.removemember" +} + +/** The fields read off a notification; Onshape sends more. */ +interface WebhookNotification { + event: string; + documentId?: string; + teamId?: string; +} + +/** POST /api/webhooks/register — replaces this deployment's webhook. */ +webhookRoutes.post("/webhooks/register", requireOwnerMiddleware, async (c) => { + const onshapeApi = await c.var.getOnshapeApi(); + const companyId = (await getSessionInfo(onshapeApi)).company?.id; + if (!companyId) { + throw handledError( + "Open the app from your company's Onshape to register its webhooks.", + HttpStatus.BAD_REQUEST + ); + } + + // Matched on this deployment's url alone, so dev, cert and production + // registering under one company leave each other's in place. + const receiveUrl = new URL(RECEIVE_PATH, c.req.url); + const existing = await getCompanyWebhooks(onshapeApi, companyId); + for (const webhook of existing) { + if (webhook.url.startsWith(receiveUrl.href)) { + await deleteWebhook(onshapeApi, webhook.id); + } + } + + // Stored first: Onshape posts webhook.register before create returns. + const token = crypto.randomUUID(); + await c.env.KV.put(TOKEN_KEY, token); + receiveUrl.searchParams.set("token", token); + + await createWebhook(onshapeApi, { + companyId, + events: Object.values(WebhookEvent), + url: receiveUrl.href, + name: "FRCDesignApp", + description: + "Reloads library documents on a new version, and access on admin team changes.", + options: { collapseEvents: false }, + isTransient: false + }); + return c.json({ status: "registered" }); +}); + +/** POST /api/webhooks/onshape?token= — where Onshape delivers. */ +webhookRoutes.post("/webhooks/onshape", async (c) => { + const token = c.req.query("token"); + const expected = await c.env.KV.get(TOKEN_KEY); + if (!token || !expected || !tokensMatch(token, expected)) { + throw forbiddenError("Unrecognized webhook"); + } + + const notification = await c.req.json<WebhookNotification>(); + switch (notification.event) { + case WebhookEvent.CREATE_VERSION: + if (notification.documentId) { + await reloadDocument(c.env, notification.documentId); + } + break; + case WebhookEvent.TEAM_ADD_MEMBER: + case WebhookEvent.TEAM_REMOVE_MEMBER: + if (notification.teamId === c.env.ADMIN_TEAM) { + await clearAccessLevels(c.env.KV); + } + break; + // webhook.register and webhook.ping only want a 200, which registration + // fails without. + } + return c.json({}); +}); + +/** Reloads the document's groups in every library holding it; most hold none. */ +async function reloadDocument( + env: AppBindings, + documentId: string +): Promise<void> { + const libraries = await getDb(env.DB) + .selectDistinct({ libraryId: groups.libraryId }) + .from(groups) + .where(eq(groups.documentId, documentId)); + for (const { libraryId } of libraries) { + await queueReload(env, libraryId, [documentId]); + } +} + +/** Compared in constant time, so response timing gives nothing of it away. */ +function tokensMatch(given: string, expected: string): boolean { + if (given.length !== expected.length) { + return false; + } + let difference = 0; + for (let i = 0; i < given.length; i++) { + difference |= given.charCodeAt(i) ^ expected.charCodeAt(i); + } + return difference === 0; +} diff --git a/src/backend/features/webhooks/routes.worker.test.ts b/src/backend/features/webhooks/routes.worker.test.ts new file mode 100644 index 000000000..e835218aa --- /dev/null +++ b/src/backend/features/webhooks/routes.worker.test.ts @@ -0,0 +1,187 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TEST_GROUP_ID, + TEST_LIBRARY_ID, + createTestApp, + jsonRequest, + resetDb, + seedGroup +} from "../../../__test_utils__"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { getDb } from "../../db/client"; +import { AccessLevel } from "../auth/access-level"; +import { accessLevelKey } from "../auth/session"; +import { rememberOwnerSession } from "../auth/owner"; +import * as JobTracker from "../load/job-tracker"; +import { WebhookEvent } from "./routes"; + +const db = getDb(env.DB); +const TOKEN = "the-token"; + +/** Delivers a notification the way Onshape would, with `token` on the url. */ +function deliver(body: object, token = TOKEN) { + return createTestApp().request( + `/api/webhooks/onshape?token=${token}`, + jsonRequest("POST", body), + env + ); +} + +describe("receiving a webhook", () => { + beforeEach(async () => { + await resetDb(db); + await env.KV.put("webhook-token", TOKEN); + }); + afterEach(() => vi.restoreAllMocks()); + + it("turns away a delivery without the registered token", async () => { + const res = await deliver({ event: "webhook.ping" }, "guess"); + expect(res.status).toBe(403); + }); + + // Registration fails unless Onshape's own check is answered. + it("answers Onshape's registration check", async () => { + const res = await deliver({ event: "webhook.register" }); + expect(res.status).toBe(200); + }); + + describe("a new version", () => { + beforeEach(async () => { + await seedGroup(db, TEST_GROUP_ID); + await rememberOwnerSession(env.KV, "owner-session"); + vi.spyOn(JobTracker, "trackJob").mockResolvedValue(); + }); + + it("reloads the groups loaded from that document, as the owner", async () => { + vi.spyOn(JobTracker, "isReloadRunning").mockResolvedValue(false); + const create = vi + .spyOn(env.LOAD_LIBRARY_WORKFLOW, "create") + .mockResolvedValue({ id: "wf" } as never); + + await deliver({ + event: WebhookEvent.CREATE_VERSION, + documentId: `doc-${TEST_GROUP_ID}` + }); + + expect(create.mock.calls[0][0]?.params).toEqual({ + libraryId: TEST_LIBRARY_ID, + sessionId: "owner-session", + documentIds: [`doc-${TEST_GROUP_ID}`] + }); + }); + + it("ignores a document no library holds", async () => { + const create = vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "create"); + await deliver({ + event: WebhookEvent.CREATE_VERSION, + documentId: "somebody-elses" + }); + expect(create).not.toHaveBeenCalled(); + }); + + // The running reload may have checked it before the version landed. + it("queues the document behind a reload already running", async () => { + vi.spyOn(JobTracker, "isReloadRunning").mockResolvedValue(true); + const create = vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "create"); + + await deliver({ + event: WebhookEvent.CREATE_VERSION, + documentId: `doc-${TEST_GROUP_ID}` + }); + + expect(create).not.toHaveBeenCalled(); + expect( + await env.KV.get(`queued-reload:${TEST_LIBRARY_ID}`, "json") + ).toEqual([`doc-${TEST_GROUP_ID}`]); + }); + }); + + describe("an admin team change", () => { + it("re-asks everyone's access level", async () => { + await env.KV.put(accessLevelKey("someone"), AccessLevel.EDITOR); + await deliver({ + event: WebhookEvent.TEAM_REMOVE_MEMBER, + teamId: env.ADMIN_TEAM + }); + expect(await env.KV.get(accessLevelKey("someone"))).toBeNull(); + }); + + it("leaves access alone for any other team", async () => { + await env.KV.put(accessLevelKey("someone"), AccessLevel.EDITOR); + await deliver({ + event: WebhookEvent.TEAM_ADD_MEMBER, + teamId: "another-team" + }); + expect(await env.KV.get(accessLevelKey("someone"))).toBe( + AccessLevel.EDITOR + ); + }); + }); +}); + +describe("registering webhooks", () => { + afterEach(() => vi.restoreAllMocks()); + + /** Onshape, holding one webhook of this deployment's and one of another's. */ + function mockOnshape() { + const onshapeApi = new MockOnshapeApi(); + vi.spyOn(onshapeApi, "get").mockImplementation((path: string) => + Promise.resolve( + path === "/users/sessioninfo" + ? { id: "owner", company: { id: "company" } } + : { + items: [ + { + id: "ours", + url: "http://localhost/api/webhooks/onshape?token=old" + }, + { + id: "theirs", + url: "https://cert.example.com/api/webhooks/onshape?token=x" + } + ] + } + ) + ); + const post = vi.spyOn(onshapeApi, "post").mockResolvedValue({}); + const remove = vi + .spyOn(onshapeApi, "deleteNone") + .mockResolvedValue(undefined); + return { onshapeApi, post, remove }; + } + + it("is the owner's alone", async () => { + const res = await createTestApp({ + accessLevel: AccessLevel.ADMIN + }).request("/api/webhooks/register", jsonRequest("POST"), env); + expect(res.status).toBe(403); + }); + + it("replaces this deployment's webhook and no one else's", async () => { + const { onshapeApi, post, remove } = mockOnshape(); + + const res = await createTestApp({ + accessLevel: AccessLevel.OWNER, + onshapeApi + }).request( + "http://localhost/api/webhooks/register", + jsonRequest("POST"), + env + ); + + expect(res.status).toBe(200); + expect(remove).toHaveBeenCalledExactlyOnceWith("/webhooks/ours"); + const token = await env.KV.get("webhook-token"); + expect(post).toHaveBeenCalledWith( + "/webhooks", + expect.objectContaining({ + body: expect.objectContaining({ + companyId: "company", + url: `http://localhost/api/webhooks/onshape?token=${token}`, + isTransient: false + }) + }) + ); + }); +}); diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 77428cecb..fc5b4aef4 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -18,6 +18,8 @@ export interface AppBindings { /** One instance per configuration being rendered; see `requestRender`. */ RENDER_THUMBNAIL_WORKFLOW: Workflow<RenderThumbnailParams>; ADMIN_TEAM: string; + /** The Onshape user id granted `AccessLevel.OWNER`; unset grants nobody. */ + OWNER_USER_ID?: string; /** Dev-only: the access level granted, bypassing Onshape. */ VITE_ACCESS_LEVEL_OVERRIDE?: string; /** Testing-only: treat requests as signed in with a fake user. Not for production. */ diff --git a/src/backend/lib/onshape/endpoints/webhooks.ts b/src/backend/lib/onshape/endpoints/webhooks.ts new file mode 100644 index 000000000..4b7aedd88 --- /dev/null +++ b/src/backend/lib/onshape/endpoints/webhooks.ts @@ -0,0 +1,44 @@ +import { OnshapeApi } from "../client"; + +export interface OnshapeWebhookInfo { + id: string; + url: string; + events: string[]; +} + +export interface CreateWebhookParams { + /** The company whose events it hears; the caller has to administer it. */ + companyId: string; + events: string[]; + url: string; + name: string; + description: string; + options: { collapseEvents: boolean }; + /** False, or Onshape deletes it after a while without events. */ + isTransient: boolean; +} + +export async function getCompanyWebhooks( + client: OnshapeApi, + companyId: string +): Promise<OnshapeWebhookInfo[]> { + const response: { items: OnshapeWebhookInfo[] } = await client.get( + "/webhooks", + { query: { company: companyId } } + ); + return response.items; +} + +export function createWebhook( + client: OnshapeApi, + params: CreateWebhookParams +): Promise<OnshapeWebhookInfo> { + return client.post("/webhooks", { body: params }); +} + +export function deleteWebhook( + client: OnshapeApi, + webhookId: string +): Promise<void> { + return client.deleteNone(`/webhooks/${encodeURIComponent(webhookId)}`); +} diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 982f10b5c..0e0098263 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -24,6 +24,7 @@ import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { SETUP_URL } from "../../../lib/url"; import { useLibraryId } from "../../../lib/library"; import { ReloadGroupsButton } from "../../library/components/reload-groups-button"; +import { RegisterWebhooksButton } from "../../webhooks/components/register-webhooks-button"; /** The FRCDesign Discord, where feedback and support now live. */ const DISCORD_INVITE_URL = "https://discord.gg/PMgzEUTgB7"; @@ -212,6 +213,11 @@ function AdminSettings(): ReactNode { <ReloadGroupsButton reloadAll /> </InputRow> </RequireAccessLevel> + <RequireAccessLevel accessLevel={AccessLevel.OWNER}> + <InputRow label="Reload on new Onshape versions"> + <RegisterWebhooksButton /> + </InputRow> + </RequireAccessLevel> </Stack> ); } @@ -224,6 +230,7 @@ function AccessLevelSelect(): ReactNode { label="Access level" value={currentAccessLevel} options={[ + AccessLevel.OWNER, AccessLevel.ADMIN, AccessLevel.EDITOR, AccessLevel.USER diff --git a/src/frontend/features/webhooks/components/register-webhooks-button.tsx b/src/frontend/features/webhooks/components/register-webhooks-button.tsx new file mode 100644 index 000000000..42164000e --- /dev/null +++ b/src/frontend/features/webhooks/components/register-webhooks-button.tsx @@ -0,0 +1,24 @@ +import { Button } from "@mantine/core"; +import { BroadcastIcon } from "@phosphor-icons/react"; +import { type ReactNode } from "react"; +import { IconSize, StatusColor } from "../../../lib/style-constants"; +import { useRegisterWebhooksMutation } from "../queries"; + +/** + * Registers again from scratch, replacing whatever this deployment had: safe + * to press whenever reloads stop following new versions. + */ +export function RegisterWebhooksButton(): ReactNode { + const mutation = useRegisterWebhooksMutation(); + return ( + <Button + variant="light" + color={StatusColor.INFO} + leftSection={<BroadcastIcon size={IconSize.SMALL} />} + onClick={() => mutation.mutate()} + loading={mutation.isPending} + > + Register webhooks + </Button> + ); +} diff --git a/src/frontend/features/webhooks/queries.ts b/src/frontend/features/webhooks/queries.ts new file mode 100644 index 000000000..09d11ff33 --- /dev/null +++ b/src/frontend/features/webhooks/queries.ts @@ -0,0 +1,14 @@ +import { useMutation } from "@tanstack/react-query"; +import { apiPost } from "../../lib/api-client"; +import { getAppErrorHandler } from "../../lib/errors"; +import { showInfoToast } from "../../lib/notifications"; + +/** Points the owner's company's Onshape webhooks at this deployment. */ +export function useRegisterWebhooksMutation() { + return useMutation({ + mutationKey: ["register-webhooks"], + mutationFn: () => apiPost("/webhooks/register"), + onError: getAppErrorHandler("Failed to register Onshape webhooks!"), + onSuccess: () => showInfoToast("Onshape webhooks registered.") + }); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 5dd0b3980..c894477c8 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -90,6 +90,10 @@ "vars": { // Onshape team ID used to determine editor/admin access level in production "ADMIN_TEAM": "5b620150b2190f0fca90ec10", + // The Onshape user id granted the owner access level, whose session + // webhook-triggered reloads run under. Unset grants nobody; each + // environment below needs it too. + // "OWNER_USER_ID": "", "NODE_ENV": "development" }, /** From 07642cb7a5b31cd2004857043b373e0de5e6fcef Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 23 Sep 2026 22:50:24 +0000 Subject: [PATCH 23/88] Push job, library, thumbnail and access changes to clients A LiveUpdates Durable Object holds every open client's WebSocket, hibernating, tagged by the library it shows. Jobs starting and finishing, a background load bumping a library's version, a configuration's render landing and an admin team change are pushed through it. Clients apply them to the query cache: job status is no longer polled, and a render waits for its push instead of asking every two seconds. While the socket is down, both fall back to polling as before, and a reconnect refreshes the library for what it missed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/app.ts | 4 +- src/backend/features/live/contract.ts | 34 +++++ src/backend/features/live/live-updates.ts | 36 +++++ .../features/live/live-updates.worker.test.ts | 67 +++++++++ src/backend/features/live/notify.ts | 60 ++++++++ src/backend/features/live/routes.ts | 20 +++ src/backend/features/load/job-tracker.ts | 8 +- src/backend/features/load/workflows.ts | 3 + .../features/thumbnails/render-workflow.ts | 8 ++ .../thumbnails/render-workflow.worker.test.ts | 1 + src/backend/features/thumbnails/render.ts | 1 + src/backend/features/webhooks/routes.ts | 2 + src/backend/index.ts | 1 + src/backend/lib/context.ts | 3 + src/frontend/features/library/queries.ts | 33 +++-- .../thumbnails/components/thumbnail.tsx | 58 +++----- .../features/thumbnails/render-wait.test.tsx | 98 +++++++++++++ .../features/thumbnails/render-wait.ts | 131 ++++++++++++++++++ src/frontend/lib/live-sync.ts | 92 ++++++++++++ src/frontend/lib/live-updates.ts | 97 +++++++++++++ src/frontend/routes/app/route.tsx | 2 + worker-configuration.d.ts | 6 +- wrangler.jsonc | 17 +++ 23 files changed, 727 insertions(+), 55 deletions(-) create mode 100644 src/backend/features/live/contract.ts create mode 100644 src/backend/features/live/live-updates.ts create mode 100644 src/backend/features/live/live-updates.worker.test.ts create mode 100644 src/backend/features/live/notify.ts create mode 100644 src/backend/features/live/routes.ts create mode 100644 src/frontend/features/thumbnails/render-wait.test.tsx create mode 100644 src/frontend/features/thumbnails/render-wait.ts create mode 100644 src/frontend/lib/live-sync.ts create mode 100644 src/frontend/lib/live-updates.ts diff --git a/src/backend/app.ts b/src/backend/app.ts index 02654cc5c..c186bdde2 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -15,6 +15,7 @@ import { libraryRoutes } from "./features/library/routes"; import { settingsRoutes } from "./features/settings/routes"; import { thumbnailRoutes } from "./features/thumbnails/routes"; import { webhookRoutes } from "./features/webhooks/routes"; +import { liveRoutes } from "./features/live/routes"; import { logger } from "hono/logger"; import { cacheMiddleware } from "./lib/cache"; import { bindAuth, getApp, type AuthResolver } from "./lib/context"; @@ -32,7 +33,8 @@ const apiRoutes = [ favoriteRoutes, buildStatusRoutes, analyticsRoutes, - webhookRoutes + webhookRoutes, + liveRoutes ]; export function createApp(resolveAuth: AuthResolver) { diff --git a/src/backend/features/live/contract.ts b/src/backend/features/live/contract.ts new file mode 100644 index 000000000..4943e2dd7 --- /dev/null +++ b/src/backend/features/live/contract.ts @@ -0,0 +1,34 @@ +/** + * What the server pushes to open clients, so they need not poll for it. None + * of it is private: it says that something changed, and a client that cares + * asks the usual routes for what, under its own access. + */ +import type { LibraryId } from "../library/library-id"; +import type { JobStatus } from "../load/contract"; +import type { ConfigurationKey } from "../configurations/contract"; + +export enum LiveMessageType { + /** A library's load jobs started or finished. */ + JOBS = "jobs", + /** A library's contents changed under a new cache version. */ + LIBRARY = "library", + /** A configuration's thumbnail finished rendering. */ + THUMBNAIL = "thumbnail", + /** Who is on the admin team changed, so access may have. */ + ACCESS = "access" +} + +export type LiveMessage = + | { type: LiveMessageType.JOBS; libraryId: LibraryId; status: JobStatus } + | { type: LiveMessageType.LIBRARY; libraryId: LibraryId } + | { + type: LiveMessageType.THUMBNAIL; + elementId: string; + microversionId: string; + configurationKey: ConfigurationKey; + } + | { type: LiveMessageType.ACCESS }; + +/** Where a client connects, naming the library it is showing. */ +export const LIVE_PATH = "/api/live"; +export const LIVE_LIBRARY_PARAM = "library"; diff --git a/src/backend/features/live/live-updates.ts b/src/backend/features/live/live-updates.ts new file mode 100644 index 000000000..3da59ad78 --- /dev/null +++ b/src/backend/features/live/live-updates.ts @@ -0,0 +1,36 @@ +/** + * Holds every open client's WebSocket and relays what the server pushes. One + * instance for the whole app: the pushes are few and small, and one place to + * send them keeps a sender from having to know who is listening where. + * + * Sockets are accepted for hibernation, so an idle instance costs nothing + * while its clients stay connected; each is tagged with the library its client + * shows, which is what a library's messages are sent to. + */ +import { DurableObject } from "cloudflare:workers"; +import type { AppBindings } from "../../lib/context"; +import type { LibraryId } from "../library/library-id"; +import { LIVE_LIBRARY_PARAM, type LiveMessage } from "./contract"; + +export class LiveUpdates extends DurableObject<AppBindings> { + fetch(request: Request): Response { + const libraryId = new URL(request.url).searchParams.get( + LIVE_LIBRARY_PARAM + ); + const { 0: client, 1: server } = new WebSocketPair(); + this.ctx.acceptWebSocket(server, libraryId ? [libraryId] : []); + return new Response(null, { status: 101, webSocket: client }); + } + + /** To the clients showing `libraryId`, or to every client without one. */ + broadcast(message: LiveMessage, libraryId?: LibraryId): void { + const data = JSON.stringify(message); + for (const socket of this.ctx.getWebSockets(libraryId)) { + try { + socket.send(data); + } catch { + // Closing already; its client reconnects and resyncs. + } + } + } +} diff --git a/src/backend/features/live/live-updates.worker.test.ts b/src/backend/features/live/live-updates.worker.test.ts new file mode 100644 index 000000000..7773c2da3 --- /dev/null +++ b/src/backend/features/live/live-updates.worker.test.ts @@ -0,0 +1,67 @@ +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { createTestApp } from "../../../__test_utils__"; +import { LibraryId } from "../library/library-id"; +import { LIVE_PATH, type LiveMessage, LiveMessageType } from "./contract"; + +/** A client connected for `libraryId`, collecting what it is sent. */ +async function connect(libraryId: LibraryId) { + const res = await createTestApp().request( + `${LIVE_PATH}?library=${libraryId}`, + { headers: { Upgrade: "websocket" } }, + env + ); + expect(res.status).toBe(101); + const socket = res.webSocket; + if (!socket) { + throw new Error("No WebSocket in the upgrade response"); + } + socket.accept(); + const received: LiveMessage[] = []; + socket.addEventListener("message", (event) => { + received.push(JSON.parse(event.data as string) as LiveMessage); + }); + return { socket, received }; +} + +/** Lets a message cross from the Durable Object to its sockets. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +const stub = () => env.LIVE_UPDATES.getByName("all"); + +describe("live updates", () => { + it("wants a WebSocket upgrade", async () => { + const res = await createTestApp().request(LIVE_PATH, {}, env); + expect(res.status).toBe(426); + }); + + it("sends a library's messages to its viewers alone", async () => { + const frc = await connect(LibraryId.FRC_DESIGN_LIB); + const ftc = await connect(LibraryId.FTC_DESIGN_LIB); + const message: LiveMessage = { + type: LiveMessageType.LIBRARY, + libraryId: LibraryId.FRC_DESIGN_LIB + }; + + await stub().broadcast(message, LibraryId.FRC_DESIGN_LIB); + await settle(); + + expect(frc.received).toEqual([message]); + expect(ftc.received).toEqual([]); + frc.socket.close(); + ftc.socket.close(); + }); + + it("sends a message for no library to everyone", async () => { + const frc = await connect(LibraryId.FRC_DESIGN_LIB); + const ftc = await connect(LibraryId.FTC_DESIGN_LIB); + + await stub().broadcast({ type: LiveMessageType.ACCESS }); + await settle(); + + expect(frc.received).toEqual([{ type: LiveMessageType.ACCESS }]); + expect(ftc.received).toEqual([{ type: LiveMessageType.ACCESS }]); + frc.socket.close(); + ftc.socket.close(); + }); +}); diff --git a/src/backend/features/live/notify.ts b/src/backend/features/live/notify.ts new file mode 100644 index 000000000..6c64290d4 --- /dev/null +++ b/src/backend/features/live/notify.ts @@ -0,0 +1,60 @@ +/** + * Pushes to open clients. A push is a courtesy on top of work already done, so + * one that fails is logged and dropped rather than failing that work: a client + * that missed it resyncs when it reconnects. + */ +import type { AppBindings } from "../../lib/context"; +import type { LibraryId } from "../library/library-id"; +import type { JobStatus } from "../load/contract"; +import { type LiveMessage, LiveMessageType } from "./contract"; + +async function broadcast( + env: AppBindings, + message: LiveMessage, + libraryId?: LibraryId +): Promise<void> { + try { + await env.LIVE_UPDATES.getByName("all").broadcast(message, libraryId); + } catch (error) { + console.error(`Failed to push ${message.type}`, error); + } +} + +/** A library's jobs as they now stand, after one started or finished. */ +export function pushJobStatus( + env: AppBindings, + libraryId: LibraryId, + status: JobStatus +): Promise<void> { + return broadcast( + env, + { type: LiveMessageType.JOBS, libraryId, status }, + libraryId + ); +} + +/** Tells a library's viewers to move to its new cache version. */ +export function pushLibraryChanged( + env: AppBindings, + libraryId: LibraryId +): Promise<void> { + return broadcast( + env, + { type: LiveMessageType.LIBRARY, libraryId }, + libraryId + ); +} + +export function pushThumbnailRendered( + env: AppBindings, + subject: Omit< + Extract<LiveMessage, { type: LiveMessageType.THUMBNAIL }>, + "type" + > +): Promise<void> { + return broadcast(env, { type: LiveMessageType.THUMBNAIL, ...subject }); +} + +export function pushAccessChanged(env: AppBindings): Promise<void> { + return broadcast(env, { type: LiveMessageType.ACCESS }); +} diff --git a/src/backend/features/live/routes.ts b/src/backend/features/live/routes.ts new file mode 100644 index 000000000..b77516dae --- /dev/null +++ b/src/backend/features/live/routes.ts @@ -0,0 +1,20 @@ +import { HttpStatus } from "http-status-ts"; +import { getApp } from "../../lib/context"; +import { handledError } from "../../lib/api-error"; +import { LIVE_PATH } from "./contract"; + +export const liveRoutes = getApp(); + +/** + * GET /api/live?library= — a WebSocket of what the server pushes. Open to + * anyone, since nothing sent over it is private (see `contract.ts`). + */ +liveRoutes.get(LIVE_PATH.replace(/^\/api/, ""), (c) => { + if (c.req.header("Upgrade") !== "websocket") { + throw handledError( + "Expected a WebSocket upgrade", + HttpStatus.UPGRADE_REQUIRED + ); + } + return c.env.LIVE_UPDATES.getByName("all").fetch(c.req.raw); +}); diff --git a/src/backend/features/load/job-tracker.ts b/src/backend/features/load/job-tracker.ts index 13d94837d..86ab67997 100644 --- a/src/backend/features/load/job-tracker.ts +++ b/src/backend/features/load/job-tracker.ts @@ -1,6 +1,7 @@ import type { AppBindings } from "../../lib/context"; import type { LibraryId } from "../library/library-id"; import type { JobStatus } from "./contract"; +import { pushJobStatus } from "../live/notify"; /** * Backstop for a job that crashes before untracking itself; must outlast the @@ -82,7 +83,10 @@ export async function getJobStatus( env: AppBindings, libraryId: LibraryId ): Promise<JobStatus> { - const jobs = await activeJobs(env, libraryId); + return statusOf(await activeJobs(env, libraryId)); +} + +function statusOf(jobs: TrackedJob[]): JobStatus { if (jobs.length === 0) { return { running: false }; } @@ -102,6 +106,7 @@ export async function trackJob( await env.KV.put(jobsKey(libraryId), JSON.stringify(jobs), { expirationTtl: JOB_TTL_SECONDS }); + await pushJobStatus(env, libraryId, statusOf(jobs)); } /** @@ -123,4 +128,5 @@ export async function untrackJob( expirationTtl: JOB_TTL_SECONDS }); } + await pushJobStatus(env, libraryId, await getJobStatus(env, libraryId)); } diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 5005d618b..73af0c351 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -32,6 +32,7 @@ import { } from "./context"; import { untrackJob } from "./job-tracker"; import { startQueuedReload } from "./reload"; +import { pushLibraryChanged } from "../live/notify"; import { loadGroup } from "./load-group"; import { ONSHAPE_STEP_RETRIES } from "./steps"; import { reconcileThumbnails } from "../thumbnails/reconcile"; @@ -282,6 +283,7 @@ export async function createShellGroup( // to go with it, since buildSearchDb indexes insertables and the shell has // none — the index the old version served is still right for the new one. await bumpLibraryVersion(db, params.libraryId); + await pushLibraryChanged(env, params.libraryId); } /** @@ -319,4 +321,5 @@ async function finalizeLibrary( const db = getDb(env.DB); await rebuildSearchDb(env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); + await pushLibraryChanged(env, libraryId); } diff --git a/src/backend/features/thumbnails/render-workflow.ts b/src/backend/features/thumbnails/render-workflow.ts index e28a07af1..308ed2524 100644 --- a/src/backend/features/thumbnails/render-workflow.ts +++ b/src/backend/features/thumbnails/render-workflow.ts @@ -18,6 +18,7 @@ import { rateLimitDelay } from "../load/steps"; import { type ConfigurationKey } from "../configurations/contract"; import { ThumbnailSize } from "./contract"; import { putThumbnail } from "./store"; +import { pushThumbnailRendered } from "../live/notify"; /** One stored size: where it goes, and what to ask Onshape for. */ export interface RenderTarget { @@ -31,6 +32,8 @@ export interface RenderThumbnailParams { thumbnailId: string; /** Both sizes, the one the asking surface shows first leading. */ targets: RenderTarget[]; + /** What is told to clients waiting on the render once each size lands. */ + elementId: string; /** Tagged onto each stored object, for telling later what it depicts. */ microversionId: string; configurationKey: ConfigurationKey; @@ -94,4 +97,9 @@ async function storeRender( microversionId: params.microversionId, configurationKey: params.configurationKey }); + await pushThumbnailRendered(env, { + elementId: params.elementId, + microversionId: params.microversionId, + configurationKey: params.configurationKey + }); } diff --git a/src/backend/features/thumbnails/render-workflow.worker.test.ts b/src/backend/features/thumbnails/render-workflow.worker.test.ts index 437be308b..47d852712 100644 --- a/src/backend/features/thumbnails/render-workflow.worker.test.ts +++ b/src/backend/features/thumbnails/render-workflow.worker.test.ts @@ -37,6 +37,7 @@ it("stores both sizes once Onshape has rendered them", async () => { { size: ThumbnailSize.LARGE, key: key(ThumbnailSize.LARGE) }, { size: ThumbnailSize.SMALL, key: key(ThumbnailSize.SMALL) } ], + elementId: "e1", microversionId: "mv1", configurationKey: "a=1", sessionId: "session" diff --git a/src/backend/features/thumbnails/render.ts b/src/backend/features/thumbnails/render.ts index 59c993228..391de3258 100644 --- a/src/backend/features/thumbnails/render.ts +++ b/src/backend/features/thumbnails/render.ts @@ -85,6 +85,7 @@ export async function requestRender( params: { thumbnailId, targets: renderTargets(request, source), + elementId: request.elementId, microversionId: request.microversionId, configurationKey: request.configurationKey, sessionId diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index 124399136..44d0598f5 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -22,6 +22,7 @@ import { import { requireOwnerMiddleware } from "../auth/guards"; import { clearAccessLevels } from "../auth/session"; import { queueReload } from "../load/reload"; +import { pushAccessChanged } from "../live/notify"; export const webhookRoutes = getApp(); @@ -101,6 +102,7 @@ webhookRoutes.post("/webhooks/onshape", async (c) => { case WebhookEvent.TEAM_REMOVE_MEMBER: if (notification.teamId === c.env.ADMIN_TEAM) { await clearAccessLevels(c.env.KV); + await pushAccessChanged(c.env); } break; // webhook.register and webhook.ping only want a 200, which registration diff --git a/src/backend/index.ts b/src/backend/index.ts index b9d32e406..7f2949db6 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -8,6 +8,7 @@ export { LoadLibraryWorkflow } from "./features/load/workflows"; export { RenderThumbnailWorkflow } from "./features/thumbnails/render-workflow"; +export { LiveUpdates } from "./features/live/live-updates"; import { createApp } from "./app"; import { productionAuth } from "./features/auth/request-auth"; diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index fc5b4aef4..25efdb436 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -4,6 +4,7 @@ import type { LoadLibraryParams } from "../features/load/workflows"; import type { RenderThumbnailParams } from "../features/thumbnails/render-workflow"; +import type { LiveUpdates } from "../features/live/live-updates"; import { type AccessLevel } from "../features/auth/access-level"; import { type OAuthApi } from "./onshape/client"; @@ -17,6 +18,8 @@ export interface AppBindings { ADD_GROUP_WORKFLOW: Workflow<AddGroupParams>; /** One instance per configuration being rendered; see `requestRender`. */ RENDER_THUMBNAIL_WORKFLOW: Workflow<RenderThumbnailParams>; + /** Relays pushes to open clients; see `features/live`. */ + LIVE_UPDATES: DurableObjectNamespace<LiveUpdates>; ADMIN_TEAM: string; /** The Onshape user id granted `AccessLevel.OWNER`; unset grants nobody. */ OWNER_USER_ID?: string; diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index db318e583..fb472b805 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -27,6 +27,7 @@ import { showSuccessToast } from "../../lib/notifications"; import { getAppErrorHandler, appError } from "../../lib/errors"; +import { useIsLiveConnected } from "../../lib/live-updates"; import { modals } from "@mantine/modals"; import { parseOnshapeDocumentId } from "../../lib/url"; import { useRefreshLibrary } from "../../lib/refresh"; @@ -74,7 +75,10 @@ export function useCacheVersion(): number { return versionQuery.data ?? 0; } -/** Poll a fresh job often, then back off: a full reload runs for hours. */ +/** + * Without pushes, poll a fresh job often, then back off: a full reload runs + * for hours. + */ const FASTEST_POLL_MS = 3_000; const POLL_STEPS = [ { untilMs: 15_000, intervalMs: FASTEST_POLL_MS }, @@ -88,20 +92,25 @@ function jobPollInterval(runningForMs: number): number { } /** - * Checked once on load, then polled while something runs and left alone when a - * check comes back idle. `canPoll` is the caller's gate: the route is editor-only. + * Checked once on load, then kept current by the server's pushes. `canAsk` is + * the caller's gate: the route is editor-only. `live` is whether pushes are + * arriving; while they are not, a running job is polled for as it used to be. */ -function getJobStatusQuery(libraryId: LibraryId, canPoll: boolean) { +function getJobStatusQuery( + libraryId: LibraryId, + canAsk: boolean, + live: boolean +) { return queryOptions<JobStatus>({ queryKey: jobStatusQueryKey(libraryId), queryFn: () => apiGet("/job-status/library/" + libraryId), - enabled: canPoll, + enabled: canAsk, // Every status badge observes this, so rows mounting as the user scrolls - // would each trigger a fetch. Only the poll should set the pace. - staleTime: FASTEST_POLL_MS, + // would each trigger a fetch. Only a push or the poll should. + staleTime: live ? Infinity : FASTEST_POLL_MS, refetchInterval: (query) => { const status = query.state.data; - if (!status?.running) { + if (live || !status?.running) { return false; } return jobPollInterval(status.runningForMs); @@ -122,10 +131,12 @@ export function useIsJobRunning(): boolean { function useJobStatusQuery() { const libraryId = useLibraryId(); const { signedIn, currentAccessLevel } = useAccessData(); + const live = useIsLiveConnected(); return useQuery( getJobStatusQuery( libraryId, - signedIn && hasEditorAccess(currentAccessLevel) + signedIn && hasEditorAccess(currentAccessLevel), + live ) ); } @@ -178,8 +189,8 @@ export function useSetGroupOrderMutation() { } /** - * Shows the spinner without waiting for a round trip, and starts the job poll, - * which stays idle until something is known to be running. + * Shows the spinner without waiting for the push, and starts the job poll when + * pushes are not arriving, which stays idle until something is known to run. */ function markJobStarted(libraryId: LibraryId): void { const justStarted: JobStatus = { running: true, runningForMs: 0 }; diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index 07199aceb..1bdafdac2 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -1,7 +1,6 @@ import { skipToken, useQuery } from "@tanstack/react-query"; -import { HttpStatus } from "http-status-ts"; import { loadImage } from "../../../lib/api-client"; -import { ImageLoadError } from "../../../lib/errors"; +import { isInvalidConfiguration, loadRenderedImage } from "../render-wait"; import { renderQueryKey, storedThumbnailQueryKey @@ -96,7 +95,7 @@ export function CardThumbnail(props: CardThumbnailProps): ReactNode { configuredTarget ? stored : undefined; // Only a row that started the render has one coming; anything else takes the - // miss for the answer rather than polling for a render nobody started. + // miss for the answer rather than waiting on a render nobody started. const isRendering = configuredTarget?.renderSource !== undefined; return ( @@ -127,37 +126,9 @@ export function CardThumbnail(props: CardThumbnailProps): ReactNode { ); } -/** - * How long any surface waits out a render before calling it failed: as long as - * `RenderThumbnailWorkflow` does, after which nothing more is coming. - */ -const RENDER_TIMEOUT_MS = 60_000; - -/** A poll is a worker reading R2, not an Onshape call, so it can be this tight. */ -const POLL_INTERVAL_MS = 2_000; - -/** Retries inside the window; the first ask is not one of them. */ -const POLL_RETRIES = RENDER_TIMEOUT_MS / POLL_INTERVAL_MS; - /** Nothing is rendering it, so a miss is worth one more try and no more. */ const STORED_RETRIES = 1; -/** - * Onshape has no insertable for the configuration, which the route answers with - * its own status: the part did not regenerate, so no render is coming and - * polling for one only delays saying so. - */ -function isInvalidConfiguration(error: unknown): boolean { - return ( - error instanceof ImageLoadError && - error.status === HttpStatus.UNPROCESSABLE_ENTITY - ); -} - -/** Polls out a render, and gives up at once on one that cannot happen. */ -const retryRender = (failureCount: number, error: Error) => - !isInvalidConfiguration(error) && failureCount <= POLL_RETRIES; - interface ThumbnailProps { url?: string; /** @@ -169,7 +140,7 @@ interface ThumbnailProps { fallbackUrl?: string; spinnerSize: number; heightAndWidth: HeightAndWidth; - /** Whether a miss is a render still running, and so worth polling out. */ + /** Whether a miss is a render still running, and so worth waiting out. */ isRendering?: boolean; } @@ -181,9 +152,15 @@ function Thumbnail(props: ThumbnailProps): ReactNode { queryKey: storedThumbnailQueryKey(url), // Narrowed here rather than guarded inside: `enabled` is what keeps it // from running, and the query function should not restate that. - queryFn: url ? ({ signal }) => loadImage(url, signal) : skipToken, - retry: isRendering ? retryRender : STORED_RETRIES, - retryDelay: isRendering ? POLL_INTERVAL_MS : undefined + queryFn: url + ? ({ signal }) => + isRendering + ? loadRenderedImage(url, signal) + : loadImage(url, signal) + : skipToken, + // A render waits itself out; asking again after it gives up would + // only start the wait over. + retry: isRendering ? false : STORED_RETRIES }); const fallbackQuery = useQuery({ queryKey: storedThumbnailQueryKey(fallbackUrl), @@ -247,9 +224,9 @@ const PREVIEW_SIZE = ThumbnailSize.LARGE; const PREVIEW_SPINNER_SIZE = 36; /** - * Polls for a configuration's render. Until it lands the route answers 404, so - * a miss is a rejected query and the retry is the poll; starting a render is - * idempotent, so every poll can ask without starting another. + * Waits out a configuration's render. The first ask starts it, and asking + * again while it runs starts nothing more, so a wait can ask as often as it + * needs to. */ function usePreviewThumbnail(props: PreviewImageProps, enabled: boolean) { const { path, insertableId, microversionId, configurationKey } = props; @@ -264,12 +241,11 @@ function usePreviewThumbnail(props: PreviewImageProps, enabled: boolean) { return useQuery({ queryKey: renderQueryKey(url), - queryFn: ({ signal }) => loadImage(url, signal), + queryFn: ({ signal }) => loadRenderedImage(url, signal), // The previous configuration's render, so the box does not blank out // while this one is still being waited on. placeholderData: (previousData) => previousData, - retry: retryRender, - retryDelay: POLL_INTERVAL_MS, + retry: false, enabled }); } diff --git a/src/frontend/features/thumbnails/render-wait.test.tsx b/src/frontend/features/thumbnails/render-wait.test.tsx new file mode 100644 index 000000000..7a9f3389a --- /dev/null +++ b/src/frontend/features/thumbnails/render-wait.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type LiveMessage, + LiveMessageType +} from "@backend/features/live/contract"; +import { thumbnailUrl } from "@backend/features/thumbnails/keys"; +import { ThumbnailSize } from "@backend/features/thumbnails/contract"; + +const live = vi.hoisted(() => ({ + connected: true, + listeners: new Set<(message: LiveMessage) => void>() +})); + +vi.mock("../../lib/live-updates", () => ({ + isLiveConnected: () => live.connected, + subscribeLiveMessages: (listener: (message: LiveMessage) => void) => { + live.listeners.add(listener); + return () => live.listeners.delete(listener); + }, + subscribeLiveConnection: () => () => undefined +})); + +const { loadRenderedImage } = await import("./render-wait"); + +const URL_WAITED_ON = thumbnailUrl({ + elementId: "e1", + microversionId: "mv1", + size: ThumbnailSize.LARGE, + configurationKey: "size=large" +}); + +const push = (configurationKey: string) => + live.listeners.forEach((listener) => + listener({ + type: LiveMessageType.THUMBNAIL, + elementId: "e1", + microversionId: "mv1", + configurationKey + }) + ); + +/** The route: not rendered, until `landed` says it is. */ +function mockRoute() { + const route = { landed: false }; + const fetch = vi + .spyOn(globalThis, "fetch") + .mockImplementation(() => + Promise.resolve( + new Response(null, { status: route.landed ? 200 : 404 }) + ) + ); + return { route, fetch }; +} + +describe("waiting out a render", () => { + beforeEach(() => { + vi.useFakeTimers(); + live.connected = true; + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("asks again as soon as the render is pushed, not on a timer", async () => { + const { route, fetch } = mockRoute(); + const loaded = loadRenderedImage(URL_WAITED_ON); + await vi.advanceTimersByTimeAsync(10_000); + expect(fetch).toHaveBeenCalledTimes(1); + + route.landed = true; + push("size=large"); + + await expect(loaded).resolves.toBe(URL_WAITED_ON); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("ignores a push for another configuration", async () => { + const { fetch } = mockRoute(); + void loadRenderedImage(URL_WAITED_ON).catch(() => undefined); + await vi.advanceTimersByTimeAsync(0); + + push("size=small"); + await vi.advanceTimersByTimeAsync(10_000); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("polls while the push connection is down", async () => { + live.connected = false; + const { fetch } = mockRoute(); + void loadRenderedImage(URL_WAITED_ON).catch(() => undefined); + + await vi.advanceTimersByTimeAsync(6_500); + + expect(fetch).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/frontend/features/thumbnails/render-wait.ts b/src/frontend/features/thumbnails/render-wait.ts new file mode 100644 index 000000000..46985875a --- /dev/null +++ b/src/frontend/features/thumbnails/render-wait.ts @@ -0,0 +1,131 @@ +/** + * Waiting out a configuration's render. Until it lands the route answers 404; + * the server pushes when it has, so a miss waits for that push rather than + * asking again on a timer — unless the push connection is down, when it asks + * every couple of seconds as it always used to. + */ +import { HttpStatus } from "http-status-ts"; +import { DEFAULT_CONFIGURATION_KEY } from "@backend/features/configurations/contract"; +import { + type LiveMessage, + LiveMessageType +} from "@backend/features/live/contract"; +import { parseThumbnailUrl } from "@backend/features/thumbnails/keys"; +import { loadImage } from "../../lib/api-client"; +import { ImageLoadError } from "../../lib/errors"; +import { + isLiveConnected, + subscribeLiveConnection, + subscribeLiveMessages +} from "../../lib/live-updates"; + +/** + * How long any surface waits out a render before calling it failed: as long as + * `RenderThumbnailWorkflow` does, after which nothing more is coming. + */ +const RENDER_TIMEOUT_MS = 60_000; + +/** A poll is a worker reading R2, not an Onshape call, so it can be this tight. */ +const POLL_INTERVAL_MS = 2_000; + +/** + * Onshape has no insertable for the configuration, which the route answers with + * its own status: the part did not regenerate, so no render is coming and + * waiting for one only delays saying so. + */ +export function isInvalidConfiguration(error: unknown): boolean { + return ( + error instanceof ImageLoadError && + error.status === HttpStatus.UNPROCESSABLE_ENTITY + ); +} + +/** Whether a push says the render `url` serves has landed. */ +export function isRenderOf(url: string, message: LiveMessage): boolean { + if (message.type !== LiveMessageType.THUMBNAIL) { + return false; + } + const subject = parseThumbnailUrl(url); + const configurationKey = + URL.parse(url, window.location.origin)?.searchParams.get( + "configurationKey" + ) ?? DEFAULT_CONFIGURATION_KEY; + return ( + subject?.elementId === message.elementId && + subject.microversionId === message.microversionId && + configurationKey === message.configurationKey + ); +} + +/** Resolves after `ms`, or sooner on `wake`; rejects when `signal` aborts. */ +function sleep( + ms: number, + signal: AbortSignal | undefined, + onWake: (wake: () => void) => void +): Promise<void> { + return new Promise((resolve, reject) => { + const done = () => { + window.clearTimeout(timer); + signal?.removeEventListener("abort", abort); + resolve(); + }; + const abort = () => { + window.clearTimeout(timer); + reject(signal?.reason as Error); + }; + const timer = window.setTimeout(done, ms); + signal?.addEventListener("abort", abort, { once: true }); + onWake(done); + }); +} + +/** + * The render `url` serves, once there is one. Throws once no render is coming: + * the configuration is invalid, or the window has passed. + */ +export async function loadRenderedImage( + url: string, + signal?: AbortSignal +): Promise<string> { + const deadline = Date.now() + RENDER_TIMEOUT_MS; + // Watched from before the first ask, so a push landing while one is in + // flight is not missed and waited out to the deadline. + const waiting = { + pushed: false, + wake: undefined as (() => void) | undefined + }; + const stopMessages = subscribeLiveMessages((message) => { + if (isRenderOf(url, message)) { + waiting.pushed = true; + waiting.wake?.(); + } + }); + // A dropped connection has to fall back to polling, not wait on a push + // that will not arrive. + const stopConnection = subscribeLiveConnection(() => waiting.wake?.()); + try { + for (;;) { + waiting.pushed = false; + try { + return await loadImage(url, signal); + } catch (error) { + if (isInvalidConfiguration(error) || Date.now() >= deadline) { + throw error; + } + } + if (!waiting.pushed) { + const remaining = deadline - Date.now(); + await sleep( + isLiveConnected() + ? remaining + : Math.min(POLL_INTERVAL_MS, remaining), + signal, + (next) => (waiting.wake = next) + ); + } + } + } finally { + stopMessages(); + stopConnection(); + } +} diff --git a/src/frontend/lib/live-sync.ts b/src/frontend/lib/live-sync.ts new file mode 100644 index 000000000..82994680f --- /dev/null +++ b/src/frontend/lib/live-sync.ts @@ -0,0 +1,92 @@ +/** + * Applies the server's pushes to what the app has cached, for the library on + * screen. Mounted once, by the app shell. + */ +import { useEffect, useRef } from "react"; +import { + type LiveMessage, + LiveMessageType +} from "@backend/features/live/contract"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; +import { useAccessData } from "../features/auth/access-level"; +import { isRenderOf } from "../features/thumbnails/render-wait"; +import { useLibraryId } from "./library"; +import { + connectLiveUpdates, + isLiveConnected, + subscribeLiveConnection, + subscribeLiveMessages +} from "./live-updates"; +import { queryClient } from "./query-client"; +import { accessDataQueryKey, jobStatusQueryKey } from "./query-keys"; +import { useRefreshLibrary } from "./refresh"; + +export function useLiveSync(): void { + const libraryId = useLibraryId(); + const refreshLibrary = useRefreshLibrary(); + const { signedIn, currentAccessLevel } = useAccessData(); + // Only an editor's job status is asked for at all; anyone else seeing + // one pushed would show a spinner for work they cannot see. + const showsJobs = signedIn && hasEditorAccess(currentAccessLevel); + const hasConnected = useRef(false); + + useEffect(() => connectLiveUpdates(libraryId), [libraryId]); + + useEffect(() => { + const apply = (message: LiveMessage) => { + switch (message.type) { + case LiveMessageType.JOBS: + if (showsJobs && message.libraryId === libraryId) { + queryClient.setQueryData( + jobStatusQueryKey(libraryId), + message.status + ); + } + break; + case LiveMessageType.LIBRARY: + if (message.libraryId === libraryId) { + void refreshLibrary(); + } + break; + case LiveMessageType.THUMBNAIL: + // A row that took a miss for its answer, now that there + // is something to show. Anything waiting on the render + // hears the push itself; see `loadRenderedImage`. + void queryClient.refetchQueries({ + predicate: (query) => { + const [kind, url] = query.queryKey; + return ( + kind === "storage-thumbnail" && + typeof url === "string" && + query.state.status === "error" && + isRenderOf(url, message) + ); + } + }); + break; + case LiveMessageType.ACCESS: + void queryClient.invalidateQueries({ + queryKey: accessDataQueryKey() + }); + break; + } + }; + return subscribeLiveMessages(apply); + }, [libraryId, refreshLibrary, showsJobs]); + + // Pushes sent while the connection was down are gone, so a reconnect asks + // for what they would have said. The first connection has nothing missed. + useEffect( + () => + subscribeLiveConnection(() => { + if (!isLiveConnected()) { + return; + } + if (hasConnected.current) { + void refreshLibrary(); + } + hasConnected.current = true; + }), + [refreshLibrary] + ); +} diff --git a/src/frontend/lib/live-updates.ts b/src/frontend/lib/live-updates.ts new file mode 100644 index 000000000..bb4d88f11 --- /dev/null +++ b/src/frontend/lib/live-updates.ts @@ -0,0 +1,97 @@ +/** + * The app's one WebSocket to the server's pushes (`features/live` on the + * backend). Reconnects on its own, backing off; while it is down, what would + * have been pushed is polled for instead, so callers ask `isLiveConnected` + * before deciding how to wait. + */ +import { useSyncExternalStore } from "react"; +import { + LIVE_LIBRARY_PARAM, + LIVE_PATH, + type LiveMessage +} from "@backend/features/live/contract"; +import type { LibraryId } from "@backend/features/library/library-id"; + +type MessageListener = (message: LiveMessage) => void; +type ConnectionListener = () => void; + +const FIRST_RETRY_MS = 1_000; +const LAST_RETRY_MS = 30_000; + +const messageListeners = new Set<MessageListener>(); +const connectionListeners = new Set<ConnectionListener>(); + +let socket: WebSocket | undefined; +let connected = false; +let retryMs = FIRST_RETRY_MS; +let retryTimer: number | undefined; + +function setConnected(next: boolean): void { + if (connected === next) { + return; + } + connected = next; + connectionListeners.forEach((listener) => listener()); +} + +function liveUrl(libraryId: LibraryId): string { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const query = new URLSearchParams({ [LIVE_LIBRARY_PARAM]: libraryId }); + return `${protocol}//${window.location.host}${LIVE_PATH}?${query.toString()}`; +} + +function open(libraryId: LibraryId): void { + const current = new WebSocket(liveUrl(libraryId)); + socket = current; + current.onopen = () => { + retryMs = FIRST_RETRY_MS; + setConnected(true); + }; + current.onmessage = (event: MessageEvent<string>) => { + const message = JSON.parse(event.data) as LiveMessage; + messageListeners.forEach((listener) => listener(message)); + }; + current.onclose = () => { + // A socket this module already let go of, which it closed itself. + if (socket !== current) { + return; + } + socket = undefined; + setConnected(false); + retryTimer = window.setTimeout(() => open(libraryId), retryMs); + retryMs = Math.min(retryMs * 2, LAST_RETRY_MS); + }; +} + +/** Connects for `libraryId`, dropping any connection for another; returns a disconnect. */ +export function connectLiveUpdates(libraryId: LibraryId): () => void { + open(libraryId); + return () => { + window.clearTimeout(retryTimer); + const current = socket; + socket = undefined; + current?.close(); + setConnected(false); + }; +} + +export function subscribeLiveMessages(listener: MessageListener): () => void { + messageListeners.add(listener); + return () => messageListeners.delete(listener); +} + +export function subscribeLiveConnection( + listener: ConnectionListener +): () => void { + connectionListeners.add(listener); + return () => connectionListeners.delete(listener); +} + +/** For deciding how to wait at the moment of waiting, outside of rendering. */ +export function isLiveConnected(): boolean { + return connected; +} + +export function useIsLiveConnected(): boolean { + return useSyncExternalStore(subscribeLiveConnection, isLiveConnected); +} diff --git a/src/frontend/routes/app/route.tsx b/src/frontend/routes/app/route.tsx index d10ba81ce..6615737ee 100644 --- a/src/frontend/routes/app/route.tsx +++ b/src/frontend/routes/app/route.tsx @@ -29,6 +29,7 @@ import { AppNavbar } from "../../components/app-navbar"; import { ProgramSelect } from "../../features/library/components/program-select"; import { SectionLoading } from "../../components/app-zero-state"; import { useMessageListener } from "../../lib/messages"; +import { useLiveSync } from "../../lib/live-sync"; import { updateUiState } from "../../lib/ui-state"; import { RootAppError } from "../../components/root-error"; @@ -108,6 +109,7 @@ function App() { const { ref: headerRef, height: headerHeight } = useElementSize(); useMessageListener(); + useLiveSync(); return ( <AppShell header={{ height: headerHeight || 56 }}> diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 0072907a5..c0e7f6542 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 9190a9566875ea509c4ac0cb6d97261b) +// Generated by Wrangler by running `wrangler types` (hash: 4a0648d046ff4aacf05d7c84330b3e71) // Runtime types generated with workerd@1.20260811.1 2026-05-14 nodejs_compat interface __BaseEnv_Env { KV: KVNamespace; @@ -14,6 +14,7 @@ interface __BaseEnv_Env { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; + LIVE_UPDATES: DurableObjectNamespace<import("./src/backend/index").LiveUpdates>; LOAD_LIBRARY_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadLibraryWorkflow['run']>[0]['payload']>; ADD_GROUP_WORKFLOW: Workflow<Parameters<import("./src/backend/index").AddGroupWorkflow['run']>[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow<Parameters<import("./src/backend/index").RenderThumbnailWorkflow['run']>[0]['payload']>; @@ -21,6 +22,7 @@ interface __BaseEnv_Env { declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/backend/index"); + durableNamespaces: "LiveUpdates"; } interface CertEnv { KV: KVNamespace; @@ -35,6 +37,7 @@ declare namespace Cloudflare { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; + LIVE_UPDATES: DurableObjectNamespace<import("./src/backend/index").LiveUpdates>; LOAD_LIBRARY_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadLibraryWorkflow['run']>[0]['payload']>; ADD_GROUP_WORKFLOW: Workflow<Parameters<import("./src/backend/index").AddGroupWorkflow['run']>[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow<Parameters<import("./src/backend/index").RenderThumbnailWorkflow['run']>[0]['payload']>; @@ -52,6 +55,7 @@ declare namespace Cloudflare { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; + LIVE_UPDATES: DurableObjectNamespace<import("./src/backend/index").LiveUpdates>; LOAD_LIBRARY_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadLibraryWorkflow['run']>[0]['payload']>; ADD_GROUP_WORKFLOW: Workflow<Parameters<import("./src/backend/index").AddGroupWorkflow['run']>[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow<Parameters<import("./src/backend/index").RenderThumbnailWorkflow['run']>[0]['payload']>; diff --git a/wrangler.jsonc b/wrangler.jsonc index c894477c8..27b381457 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -71,6 +71,9 @@ ], // Inherited by every environment. v2 deletes the thumbnail render queue, // replaced by RenderThumbnailWorkflow; the object's stored queue goes with it. + "durable_objects": { + "bindings": [{ "name": "LIVE_UPDATES", "class_name": "LiveUpdates" }] + }, "migrations": [ { "tag": "v1", @@ -79,6 +82,10 @@ { "tag": "v2", "deleted_classes": ["ThumbnailRenderer"] + }, + { + "tag": "v3", + "new_sqlite_classes": ["LiveUpdates"] } ], /** @@ -146,6 +153,11 @@ "class_name": "RenderThumbnailWorkflow" } ], + "durable_objects": { + "bindings": [ + { "name": "LIVE_UPDATES", "class_name": "LiveUpdates" } + ] + }, "vars": { "ADMIN_TEAM": "6a62e6efcc21741bea57362c", // Production, not because cert is production, but because @@ -198,6 +210,11 @@ "class_name": "RenderThumbnailWorkflow" } ], + "durable_objects": { + "bindings": [ + { "name": "LIVE_UPDATES", "class_name": "LiveUpdates" } + ] + }, "vars": { "ADMIN_TEAM": "5b620150b2190f0fca90ec10", "NODE_ENV": "production" From 23855ed15c7e2f5af21bcca5a0204e5f82e50b9c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 00:48:00 +0000 Subject: [PATCH 24/88] Register Onshape webhooks on the owner's behalf, without a button Whenever the owner's access level is resolved, the registration on record is checked in the background and replaced if Onshape no longer has it; an unregister notification forgets it, so the owner's next visit registers anew. The register button, its route and the owner-only guard it needed are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/features/auth/guards.ts | 19 +-- src/backend/features/auth/request-auth.ts | 21 +++ .../features/auth/request-auth.worker.test.ts | 8 +- src/backend/features/webhooks/registration.ts | 121 ++++++++++++++++++ .../webhooks/registration.worker.test.ts | 93 ++++++++++++++ src/backend/features/webhooks/routes.ts | 79 +++--------- .../features/webhooks/routes.worker.test.ts | 80 ++---------- src/backend/lib/onshape/endpoints/webhooks.ts | 7 + .../settings/components/settings-menu.tsx | 6 - .../components/register-webhooks-button.tsx | 24 ---- src/frontend/features/webhooks/queries.ts | 14 -- 11 files changed, 279 insertions(+), 193 deletions(-) create mode 100644 src/backend/features/webhooks/registration.ts create mode 100644 src/backend/features/webhooks/registration.worker.test.ts delete mode 100644 src/frontend/features/webhooks/components/register-webhooks-button.tsx delete mode 100644 src/frontend/features/webhooks/queries.ts diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index a2f6a3654..5a117104f 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -1,11 +1,8 @@ -/** - * The gates routes mount: signed in to Onshape at all, on the admin team, and - * the owner. - */ +/** The two gates routes mount: signed in to Onshape at all, and on the admin team. */ import type { MiddlewareHandler } from "hono"; import { forbiddenError, signInRequiredError } from "../../lib/api-error"; import type { AppContext, AppContextEnv } from "../../lib/context"; -import { AccessLevel, hasEditorAccess } from "./access-level"; +import { hasEditorAccess } from "./access-level"; import { isSignedIn } from "./request-auth"; async function requireSignIn(c: AppContext): Promise<void> { @@ -40,15 +37,3 @@ export const requireEditorMiddleware: MiddlewareHandler<AppContextEnv> = async ( } await next(); }; - -/** For what acts on the whole Onshape company, which only the owner may. */ -export const requireOwnerMiddleware: MiddlewareHandler<AppContextEnv> = async ( - c, - next -) => { - await requireSignIn(c); - if ((await c.var.getAccessLevel()) !== AccessLevel.OWNER) { - throw forbiddenError("Only the owner can use this functionality"); - } - await next(); -}; diff --git a/src/backend/features/auth/request-auth.ts b/src/backend/features/auth/request-auth.ts index 1a38647b1..a65d919a2 100644 --- a/src/backend/features/auth/request-auth.ts +++ b/src/backend/features/auth/request-auth.ts @@ -12,6 +12,7 @@ import { import { type AppContext, type AuthResolver } from "../../lib/context"; import { AccessLevel } from "./access-level"; import { rememberOwnerSession } from "./owner"; +import { ensureWebhook } from "../webhooks/registration"; import { getOauthClient, makeAuthTokens, @@ -158,6 +159,25 @@ async function isOwner(c: AppContext): Promise<boolean> { return !!ownerUserId && (await getCachedUserId(c)) === ownerUserId; } +/** + * In the background where the runtime allows it: nothing the owner asked for + * waits on Onshape for this, and a failure only means the next check retries. + */ +function keepWebhookRegistered(c: AppContext): void { + const work = getOnshapeApi(c) + .then((onshapeApi) => + ensureWebhook(c.env, onshapeApi, new URL(c.req.url).origin) + ) + .catch((error: unknown) => { + console.error("Failed to register Onshape webhooks", error); + }); + try { + c.executionCtx.waitUntil(work); + } catch { + // No execution context, as under test; the promise runs regardless. + } +} + /** Returns the caller's access level, memoized in KV by session. */ async function getCachedAccessLevel(c: AppContext): Promise<AccessLevel> { const sessionId = getSessionId(c); @@ -170,6 +190,7 @@ async function getCachedAccessLevel(c: AppContext): Promise<AccessLevel> { if (await isOwner(c)) { level = AccessLevel.OWNER; await rememberOwnerSession(c.env.KV, sessionId); + keepWebhookRegistered(c); } else { level = await getAccessLevel(await getOnshapeApi(c), c.env.ADMIN_TEAM); } diff --git a/src/backend/features/auth/request-auth.worker.test.ts b/src/backend/features/auth/request-auth.worker.test.ts index acaf9e339..7d3725507 100644 --- a/src/backend/features/auth/request-auth.worker.test.ts +++ b/src/backend/features/auth/request-auth.worker.test.ts @@ -1,12 +1,13 @@ import { env } from "cloudflare:workers"; import { env as processEnv } from "process"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { AccessLevel } from "./access-level"; import { productionAuth } from "./request-auth"; import { createApp } from "../../app"; import { jsonRequest } from "../../../__test_utils__"; import { saveSession } from "./session"; import { getOwnerSessionId } from "./owner"; +import * as Registration from "../webhooks/registration"; const app = createApp(productionAuth); @@ -71,7 +72,12 @@ describe("the owner", () => { } it("is the user OWNER_USER_ID names, and their session is kept", async () => { + const ensure = vi + .spyOn(Registration, "ensureWebhook") + .mockResolvedValue(); expect(await accessLevelOf(OWNER)).toBe(AccessLevel.OWNER); expect(await getOwnerSessionId(env.KV)).not.toBeNull(); + // And the webhooks are kept registered on their behalf. + expect(ensure).toHaveBeenCalledOnce(); }); }); diff --git a/src/backend/features/webhooks/registration.ts b/src/backend/features/webhooks/registration.ts new file mode 100644 index 000000000..fb9f2ea6f --- /dev/null +++ b/src/backend/features/webhooks/registration.ts @@ -0,0 +1,121 @@ +/** + * Keeps this deployment's Onshape webhook registered, on the owner's behalf: + * Onshape creates webhooks with a user's token, and events for the whole + * company want one of its admins, which the owner is taken to be. Checked + * whenever the owner's access is resolved, so it needs no one to set it up and + * comes back by itself if Onshape drops it. + */ +import type { AppBindings } from "../../lib/context"; +import { type OAuthApi, OnshapeApiError } from "../../lib/onshape/client"; +import { getSessionInfo } from "../../lib/onshape/endpoints/users"; +import { + createWebhook, + deleteWebhook, + getCompanyWebhooks, + getWebhook +} from "../../lib/onshape/endpoints/webhooks"; + +export const RECEIVE_PATH = "/api/webhooks/onshape"; + +export enum WebhookEvent { + CREATE_VERSION = "onshape.model.lifecycle.createversion", + TEAM_ADD_MEMBER = "onshape.team.addmember", + TEAM_REMOVE_MEMBER = "onshape.team.removemember" +} + +/** What was registered, and the token its deliveries carry. */ +export interface WebhookRegistration { + token: string; + /** Absent between storing the token and Onshape answering the create. */ + webhookId?: string; +} + +const REGISTRATION_KEY = "webhook-registration"; + +export async function getRegistration( + env: AppBindings +): Promise<WebhookRegistration | null> { + return env.KV.get<WebhookRegistration>(REGISTRATION_KEY, "json"); +} + +/** For when Onshape says it dropped the webhook: the next check registers anew. */ +export async function forgetRegistration(env: AppBindings): Promise<void> { + await env.KV.delete(REGISTRATION_KEY); +} + +/** Whether the recorded webhook is still Onshape's, at this deployment's url. */ +async function isStillRegistered( + onshapeApi: OAuthApi, + registration: WebhookRegistration | null, + receiveUrl: URL +): Promise<boolean> { + if (!registration?.webhookId) { + return false; + } + try { + const webhook = await getWebhook(onshapeApi, registration.webhookId); + return webhook.url.startsWith(receiveUrl.href); + } catch (error) { + if (error instanceof OnshapeApiError && error.status === 404) { + return false; + } + throw error; + } +} + +/** + * Registers the webhook unless the one on record still stands. `origin` is + * this deployment's, which the webhook is delivered to. + */ +export async function ensureWebhook( + env: AppBindings, + onshapeApi: OAuthApi, + origin: string +): Promise<void> { + const receiveUrl = new URL(RECEIVE_PATH, origin); + if ( + await isStillRegistered( + onshapeApi, + await getRegistration(env), + receiveUrl + ) + ) { + return; + } + + const companyId = (await getSessionInfo(onshapeApi)).company?.id; + if (!companyId) { + console.warn( + "Not registering Onshape webhooks: the owner opened the app outside their company." + ); + return; + } + + // Matched on this deployment's url alone, so dev, cert and production + // registering under one company leave each other's in place. + for (const webhook of await getCompanyWebhooks(onshapeApi, companyId)) { + if (webhook.url.startsWith(receiveUrl.href)) { + await deleteWebhook(onshapeApi, webhook.id); + } + } + + // Stored first: Onshape posts webhook.register before create returns. + const token = crypto.randomUUID(); + await env.KV.put(REGISTRATION_KEY, JSON.stringify({ token })); + receiveUrl.searchParams.set("token", token); + + const webhook = await createWebhook(onshapeApi, { + companyId, + events: Object.values(WebhookEvent), + url: receiveUrl.href, + name: "FRCDesignApp", + description: + "Reloads library documents on a new version, and access on admin team changes.", + options: { collapseEvents: false }, + isTransient: false + }); + await env.KV.put( + REGISTRATION_KEY, + JSON.stringify({ token, webhookId: webhook.id }) + ); +} diff --git a/src/backend/features/webhooks/registration.worker.test.ts b/src/backend/features/webhooks/registration.worker.test.ts new file mode 100644 index 000000000..8448da7a0 --- /dev/null +++ b/src/backend/features/webhooks/registration.worker.test.ts @@ -0,0 +1,93 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { OnshapeApiError } from "../../lib/onshape/client"; +import { ensureWebhook, getRegistration } from "./registration"; + +const ORIGIN = "https://app.example.com"; +const OURS = `${ORIGIN}/api/webhooks/onshape?token=old`; + +/** + * Onshape, holding a webhook of this deployment's and one of another's. `ours` + * is what asking after the recorded webhook answers, or undefined for gone. + */ +function mockOnshape(ours?: { url: string }) { + const onshapeApi = new MockOnshapeApi(); + vi.spyOn(onshapeApi, "get").mockImplementation((path: string) => { + if (path === "/users/sessioninfo") { + return Promise.resolve({ id: "owner", company: { id: "company" } }); + } + if (path.startsWith("/webhooks/")) { + return ours + ? Promise.resolve(ours) + : Promise.reject(new OnshapeApiError("gone", 404)); + } + return Promise.resolve({ + items: [ + { id: "stale", url: OURS }, + { + id: "theirs", + url: "https://cert.example.com/api/webhooks/onshape?token=x" + } + ] + }); + }); + const post = vi + .spyOn(onshapeApi, "post") + .mockResolvedValue({ id: "new-webhook" }); + const remove = vi + .spyOn(onshapeApi, "deleteNone") + .mockResolvedValue(undefined); + return { onshapeApi, post, remove }; +} + +describe("keeping the webhook registered", () => { + beforeEach(async () => { + await env.KV.delete("webhook-registration"); + }); + afterEach(() => vi.restoreAllMocks()); + + it("registers one when none is on record, replacing this deployment's", async () => { + const { onshapeApi, post, remove } = mockOnshape(); + + await ensureWebhook(env, onshapeApi, ORIGIN); + + expect(remove).toHaveBeenCalledExactlyOnceWith("/webhooks/stale"); + const registration = await getRegistration(env); + expect(registration?.webhookId).toBe("new-webhook"); + expect(post).toHaveBeenCalledWith( + "/webhooks", + expect.objectContaining({ + body: expect.objectContaining({ + companyId: "company", + url: `${ORIGIN}/api/webhooks/onshape?token=${registration?.token}`, + isTransient: false + }) + }) + ); + }); + + it("leaves a registration that still stands alone", async () => { + await env.KV.put( + "webhook-registration", + JSON.stringify({ token: "old", webhookId: "stale" }) + ); + const { onshapeApi, post } = mockOnshape({ url: OURS }); + + await ensureWebhook(env, onshapeApi, ORIGIN); + + expect(post).not.toHaveBeenCalled(); + }); + + it("registers again once Onshape no longer has the one on record", async () => { + await env.KV.put( + "webhook-registration", + JSON.stringify({ token: "old", webhookId: "stale" }) + ); + const { onshapeApi, post } = mockOnshape(); + + await ensureWebhook(env, onshapeApi, ORIGIN); + + expect(post).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index 44d0598f5..6bcb2368c 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -1,6 +1,5 @@ /** - * Onshape's webhooks: registered by the owner for their company, and received - * here. A new version of a library document reloads its groups, and a change + * Onshape's webhooks, as delivered; `registration.ts` keeps them registered. A new version of a library document reloads its groups, and a change * to the admin team's members re-asks everyone's access level. * * A notification carries nothing trusted: it only names a document or team, @@ -8,85 +7,33 @@ * else from making the server do that work. */ import { eq } from "drizzle-orm"; -import { HttpStatus } from "http-status-ts"; import { type AppBindings, getApp } from "../../lib/context"; -import { forbiddenError, handledError } from "../../lib/api-error"; +import { forbiddenError } from "../../lib/api-error"; import { getDb } from "../../db/client"; import { groups } from "../../db/schema"; -import { getSessionInfo } from "../../lib/onshape/endpoints/users"; -import { - createWebhook, - deleteWebhook, - getCompanyWebhooks -} from "../../lib/onshape/endpoints/webhooks"; -import { requireOwnerMiddleware } from "../auth/guards"; import { clearAccessLevels } from "../auth/session"; import { queueReload } from "../load/reload"; import { pushAccessChanged } from "../live/notify"; +import { + forgetRegistration, + getRegistration, + WebhookEvent +} from "./registration"; export const webhookRoutes = getApp(); -const RECEIVE_PATH = "/api/webhooks/onshape"; - -/** Kept in KV rather than configured, so registering is all it takes. */ -const TOKEN_KEY = "webhook-token"; - -export enum WebhookEvent { - CREATE_VERSION = "onshape.model.lifecycle.createversion", - TEAM_ADD_MEMBER = "onshape.team.addmember", - TEAM_REMOVE_MEMBER = "onshape.team.removemember" -} - /** The fields read off a notification; Onshape sends more. */ interface WebhookNotification { event: string; + webhookId?: string; documentId?: string; teamId?: string; } -/** POST /api/webhooks/register — replaces this deployment's webhook. */ -webhookRoutes.post("/webhooks/register", requireOwnerMiddleware, async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const companyId = (await getSessionInfo(onshapeApi)).company?.id; - if (!companyId) { - throw handledError( - "Open the app from your company's Onshape to register its webhooks.", - HttpStatus.BAD_REQUEST - ); - } - - // Matched on this deployment's url alone, so dev, cert and production - // registering under one company leave each other's in place. - const receiveUrl = new URL(RECEIVE_PATH, c.req.url); - const existing = await getCompanyWebhooks(onshapeApi, companyId); - for (const webhook of existing) { - if (webhook.url.startsWith(receiveUrl.href)) { - await deleteWebhook(onshapeApi, webhook.id); - } - } - - // Stored first: Onshape posts webhook.register before create returns. - const token = crypto.randomUUID(); - await c.env.KV.put(TOKEN_KEY, token); - receiveUrl.searchParams.set("token", token); - - await createWebhook(onshapeApi, { - companyId, - events: Object.values(WebhookEvent), - url: receiveUrl.href, - name: "FRCDesignApp", - description: - "Reloads library documents on a new version, and access on admin team changes.", - options: { collapseEvents: false }, - isTransient: false - }); - return c.json({ status: "registered" }); -}); - /** POST /api/webhooks/onshape?token= — where Onshape delivers. */ webhookRoutes.post("/webhooks/onshape", async (c) => { const token = c.req.query("token"); - const expected = await c.env.KV.get(TOKEN_KEY); + const expected = (await getRegistration(c.env))?.token; if (!token || !expected || !tokensMatch(token, expected)) { throw forbiddenError("Unrecognized webhook"); } @@ -105,6 +52,14 @@ webhookRoutes.post("/webhooks/onshape", async (c) => { await pushAccessChanged(c.env); } break; + case "webhook.unregister": + if ( + notification.webhookId === + (await getRegistration(c.env))?.webhookId + ) { + await forgetRegistration(c.env); + } + break; // webhook.register and webhook.ping only want a 200, which registration // fails without. } diff --git a/src/backend/features/webhooks/routes.worker.test.ts b/src/backend/features/webhooks/routes.worker.test.ts index e835218aa..f7a3718d8 100644 --- a/src/backend/features/webhooks/routes.worker.test.ts +++ b/src/backend/features/webhooks/routes.worker.test.ts @@ -8,13 +8,12 @@ import { resetDb, seedGroup } from "../../../__test_utils__"; -import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; import { getDb } from "../../db/client"; import { AccessLevel } from "../auth/access-level"; import { accessLevelKey } from "../auth/session"; import { rememberOwnerSession } from "../auth/owner"; import * as JobTracker from "../load/job-tracker"; -import { WebhookEvent } from "./routes"; +import { WebhookEvent } from "./registration"; const db = getDb(env.DB); const TOKEN = "the-token"; @@ -31,7 +30,10 @@ function deliver(body: object, token = TOKEN) { describe("receiving a webhook", () => { beforeEach(async () => { await resetDb(db); - await env.KV.put("webhook-token", TOKEN); + await env.KV.put( + "webhook-registration", + JSON.stringify({ token: TOKEN, webhookId: "ours" }) + ); }); afterEach(() => vi.restoreAllMocks()); @@ -46,6 +48,12 @@ describe("receiving a webhook", () => { expect(res.status).toBe(200); }); + // So the owner's next visit registers a new one. + it("forgets a registration Onshape dropped", async () => { + await deliver({ event: "webhook.unregister", webhookId: "ours" }); + expect(await env.KV.get("webhook-registration")).toBeNull(); + }); + describe("a new version", () => { beforeEach(async () => { await seedGroup(db, TEST_GROUP_ID); @@ -119,69 +127,3 @@ describe("receiving a webhook", () => { }); }); }); - -describe("registering webhooks", () => { - afterEach(() => vi.restoreAllMocks()); - - /** Onshape, holding one webhook of this deployment's and one of another's. */ - function mockOnshape() { - const onshapeApi = new MockOnshapeApi(); - vi.spyOn(onshapeApi, "get").mockImplementation((path: string) => - Promise.resolve( - path === "/users/sessioninfo" - ? { id: "owner", company: { id: "company" } } - : { - items: [ - { - id: "ours", - url: "http://localhost/api/webhooks/onshape?token=old" - }, - { - id: "theirs", - url: "https://cert.example.com/api/webhooks/onshape?token=x" - } - ] - } - ) - ); - const post = vi.spyOn(onshapeApi, "post").mockResolvedValue({}); - const remove = vi - .spyOn(onshapeApi, "deleteNone") - .mockResolvedValue(undefined); - return { onshapeApi, post, remove }; - } - - it("is the owner's alone", async () => { - const res = await createTestApp({ - accessLevel: AccessLevel.ADMIN - }).request("/api/webhooks/register", jsonRequest("POST"), env); - expect(res.status).toBe(403); - }); - - it("replaces this deployment's webhook and no one else's", async () => { - const { onshapeApi, post, remove } = mockOnshape(); - - const res = await createTestApp({ - accessLevel: AccessLevel.OWNER, - onshapeApi - }).request( - "http://localhost/api/webhooks/register", - jsonRequest("POST"), - env - ); - - expect(res.status).toBe(200); - expect(remove).toHaveBeenCalledExactlyOnceWith("/webhooks/ours"); - const token = await env.KV.get("webhook-token"); - expect(post).toHaveBeenCalledWith( - "/webhooks", - expect.objectContaining({ - body: expect.objectContaining({ - companyId: "company", - url: `http://localhost/api/webhooks/onshape?token=${token}`, - isTransient: false - }) - }) - ); - }); -}); diff --git a/src/backend/lib/onshape/endpoints/webhooks.ts b/src/backend/lib/onshape/endpoints/webhooks.ts index 4b7aedd88..7cc6e99ad 100644 --- a/src/backend/lib/onshape/endpoints/webhooks.ts +++ b/src/backend/lib/onshape/endpoints/webhooks.ts @@ -29,6 +29,13 @@ export async function getCompanyWebhooks( return response.items; } +export function getWebhook( + client: OnshapeApi, + webhookId: string +): Promise<OnshapeWebhookInfo> { + return client.get(`/webhooks/${encodeURIComponent(webhookId)}`); +} + export function createWebhook( client: OnshapeApi, params: CreateWebhookParams diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 0e0098263..963471b1d 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -24,7 +24,6 @@ import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { SETUP_URL } from "../../../lib/url"; import { useLibraryId } from "../../../lib/library"; import { ReloadGroupsButton } from "../../library/components/reload-groups-button"; -import { RegisterWebhooksButton } from "../../webhooks/components/register-webhooks-button"; /** The FRCDesign Discord, where feedback and support now live. */ const DISCORD_INVITE_URL = "https://discord.gg/PMgzEUTgB7"; @@ -213,11 +212,6 @@ function AdminSettings(): ReactNode { <ReloadGroupsButton reloadAll /> </InputRow> </RequireAccessLevel> - <RequireAccessLevel accessLevel={AccessLevel.OWNER}> - <InputRow label="Reload on new Onshape versions"> - <RegisterWebhooksButton /> - </InputRow> - </RequireAccessLevel> </Stack> ); } diff --git a/src/frontend/features/webhooks/components/register-webhooks-button.tsx b/src/frontend/features/webhooks/components/register-webhooks-button.tsx deleted file mode 100644 index 42164000e..000000000 --- a/src/frontend/features/webhooks/components/register-webhooks-button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Button } from "@mantine/core"; -import { BroadcastIcon } from "@phosphor-icons/react"; -import { type ReactNode } from "react"; -import { IconSize, StatusColor } from "../../../lib/style-constants"; -import { useRegisterWebhooksMutation } from "../queries"; - -/** - * Registers again from scratch, replacing whatever this deployment had: safe - * to press whenever reloads stop following new versions. - */ -export function RegisterWebhooksButton(): ReactNode { - const mutation = useRegisterWebhooksMutation(); - return ( - <Button - variant="light" - color={StatusColor.INFO} - leftSection={<BroadcastIcon size={IconSize.SMALL} />} - onClick={() => mutation.mutate()} - loading={mutation.isPending} - > - Register webhooks - </Button> - ); -} diff --git a/src/frontend/features/webhooks/queries.ts b/src/frontend/features/webhooks/queries.ts deleted file mode 100644 index 09d11ff33..000000000 --- a/src/frontend/features/webhooks/queries.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { apiPost } from "../../lib/api-client"; -import { getAppErrorHandler } from "../../lib/errors"; -import { showInfoToast } from "../../lib/notifications"; - -/** Points the owner's company's Onshape webhooks at this deployment. */ -export function useRegisterWebhooksMutation() { - return useMutation({ - mutationKey: ["register-webhooks"], - mutationFn: () => apiPost("/webhooks/register"), - onError: getAppErrorHandler("Failed to register Onshape webhooks!"), - onSuccess: () => showInfoToast("Onshape webhooks registered.") - }); -} From c581c6d0db2e2016ff8efd70a3d0d6c5998c421c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 02:27:55 +0000 Subject: [PATCH 25/88] Load one document at a time, and give each library an admin team Loads: - LoadDocumentWorkflow replaces LoadLibraryWorkflow and AddGroupWorkflow. Adding a document writes its shell group and asks for its load; the owner's "reload everything" asks for one per group. - load_jobs in D1 keeps one load per group running; one asked for meanwhile is marked on the row and started as the running one ends. The last load in a library rebuilds its search index. - Each load registers its document's version webhook (isTransient false), removed with the document's last group. A new version reloads that document's groups under the owner's session. - Thumbnail reconciliation moves to a daily cron. Access: - The owner sets each library's admin team. Its members are stored in admin_team_members and pulled again on the team's webhook, so access is a per-library lookup; ADMIN_TEAM and the KV access cache are gone. - A membership sync bumps the library version, which is how clients learn their access changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 34 +- drizzle/0006_admin_teams_webhooks_jobs.sql | 29 + drizzle/meta/0006_snapshot.json | 1370 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/__test_utils__/seed.ts | 8 +- src/__test_utils__/test-app.ts | 16 +- src/backend/app.ts | 6 +- src/backend/db/schema.ts | 77 +- src/backend/features/admin-team/contract.ts | 6 + src/backend/features/admin-team/routes.ts | 113 ++ .../features/admin-team/routes.worker.test.ts | 124 ++ src/backend/features/admin-team/sync.ts | 70 + src/backend/features/auth/guards.ts | 64 +- .../features/auth/guards.worker.test.ts | 34 +- src/backend/features/auth/owner.ts | 9 +- src/backend/features/auth/request-auth.ts | 90 +- .../features/auth/request-auth.worker.test.ts | 72 +- src/backend/features/auth/routes.ts | 19 +- .../features/auth/routes.worker.test.ts | 4 +- src/backend/features/library/db.ts | 26 + src/backend/features/library/groups/routes.ts | 88 +- .../library/groups/routes.worker.test.ts | 119 +- .../features/library/insertables/routes.ts | 22 +- src/backend/features/live/contract.ts | 12 +- .../features/live/live-updates.worker.test.ts | 12 +- src/backend/features/live/notify.ts | 4 - src/backend/features/load/context.ts | 6 +- src/backend/features/load/job-tracker.ts | 132 -- .../features/load/job-tracker.worker.test.ts | 137 -- src/backend/features/load/jobs.ts | 249 +++ src/backend/features/load/jobs.worker.test.ts | 137 ++ src/backend/features/load/reload.ts | 101 -- src/backend/features/load/routes.ts | 32 + src/backend/features/load/workflows.ts | 275 ++-- .../features/load/workflows.worker.test.ts | 7 +- src/backend/features/thumbnails/reconcile.ts | 5 +- src/backend/features/thumbnails/routes.ts | 15 +- src/backend/features/webhooks/registration.ts | 199 ++- .../webhooks/registration.worker.test.ts | 137 +- src/backend/features/webhooks/routes.ts | 104 +- .../features/webhooks/routes.worker.test.ts | 151 +- src/backend/index.ts | 25 +- src/backend/lib/context.ts | 16 +- src/backend/lib/errors.worker.test.ts | 4 +- src/backend/lib/onshape/endpoints/teams.ts | 28 + src/backend/lib/onshape/endpoints/webhooks.ts | 4 +- src/frontend/components/root-error.tsx | 10 +- .../components/admin-team-setting.tsx | 54 + src/frontend/features/admin-team/queries.ts | 42 + src/frontend/features/auth/access-level.tsx | 13 +- src/frontend/features/favorites/queries.ts | 5 +- .../library/components/reload-all-button.tsx | 57 + .../components/reload-groups-button.tsx | 66 - src/frontend/features/library/queries.ts | 18 +- .../settings/components/settings-menu.tsx | 13 +- src/frontend/features/settings/settings.ts | 7 +- src/frontend/lib/live-sync.ts | 7 +- src/frontend/lib/query-keys.ts | 9 +- worker-configuration.d.ts | 16 +- wrangler.jsonc | 42 +- 60 files changed, 3326 insertions(+), 1232 deletions(-) create mode 100644 drizzle/0006_admin_teams_webhooks_jobs.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 src/backend/features/admin-team/contract.ts create mode 100644 src/backend/features/admin-team/routes.ts create mode 100644 src/backend/features/admin-team/routes.worker.test.ts create mode 100644 src/backend/features/admin-team/sync.ts delete mode 100644 src/backend/features/load/job-tracker.ts delete mode 100644 src/backend/features/load/job-tracker.worker.test.ts create mode 100644 src/backend/features/load/jobs.ts create mode 100644 src/backend/features/load/jobs.worker.test.ts delete mode 100644 src/backend/features/load/reload.ts create mode 100644 src/backend/features/load/routes.ts create mode 100644 src/backend/lib/onshape/endpoints/teams.ts create mode 100644 src/frontend/features/admin-team/components/admin-team-setting.tsx create mode 100644 src/frontend/features/admin-team/queries.ts create mode 100644 src/frontend/features/library/components/reload-all-button.tsx delete mode 100644 src/frontend/features/library/components/reload-groups-button.tsx diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 66a914464..b3b920c52 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -61,14 +61,14 @@ thumbnails/config/{elementId}/{microversionId}/{configKey}/{size} `{configKey}` is the url-encoded `ConfigurationKey` — the canonical configuration with hidden and default-valued parameters dropped and quantities in meters and radians — so two equivalent selections resolve to one cached image. Encoding it keeps its `;` and `=` inside a single path segment. Including `{microversionId}` makes every object immutable, so an updated document lands on new keys rather than overwriting in place. -Nothing expires on a timer: there is no R2 lifecycle rule, and renders are meant to last. What that costs is orphans — a tab edited into a new microversion leaves its old pair behind, and a deleted group or tab leaves everything it had. The **`reconcile-thumbnails` step** at the end of `LoadLibraryWorkflow` collects them, in `features/thumbnails/reconcile.ts`: +Nothing expires on a timer: there is no R2 lifecycle rule, and renders are meant to last. What that costs is orphans — a tab edited into a new microversion leaves its old pair behind, and a deleted group or tab leaves everything it had. A **daily cron** (the `scheduled` handler in `src/backend/index.ts`) collects them, in `features/thumbnails/reconcile.ts`: - The live set is every `(elementId, microversionId)` still named by an insertable row, plus the ones a group's two stored thumbnail urls point at — a group's document thumbnail is often not one of its own insertables, and those urls are the only record of which element it is. -- It spans **every library**, because a thumbnail key names no library. A set built from the library being reloaded would read every other library's thumbnails as orphaned. +- It spans **every library**, because a thumbnail key names no library. A set built from one library would read every other library's thumbnails as orphaned. - Both prefixes are reconciled the same way: a configuration render is addressed by the same element and microversion, so it lives and dies with the element's default. - An object younger than 24 hours is kept whatever the live set says. A group load stores thumbnails as it goes and commits its rows at the end, and a configuration render is started by a user opening the insert menu rather than by any job — so something in flight is indistinguishable from something orphaned, and only age tells them apart. - An empty live set deletes nothing: a library really can have no elements, but so can a read that failed. -- A run scans at most 50 pages of 1,000. A bucket larger than that is finished by the next reload. +- A run scans at most 50 pages of 1,000. A bucket larger than that is finished by the next run. Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&configurationKey=&renderSource=&insertableId=`: @@ -82,17 +82,27 @@ Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&co ### Workflows — Background Jobs -Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. The two load workflows live in `src/backend/features/load/workflows.ts`; the thumbnail one lives with the feature it serves, in `src/backend/features/thumbnails/render-workflow.ts`: +Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. The load workflow lives in `src/backend/features/load/workflows.ts`; the thumbnail one lives with the feature it serves, in `src/backend/features/thumbnails/render-workflow.ts`: -| Binding | Class | What it does | -| --------------------------- | ------------------------- | ------------------------------------------------------------------------------------ | -| `LOAD_LIBRARY_WORKFLOW` | `LoadLibraryWorkflow` | Reloads every group whose document has a new version, then rebuilds the search index | -| `ADD_GROUP_WORKFLOW` | `AddGroupWorkflow` | Adds an Onshape document to a library and loads it | -| `RENDER_THUMBNAIL_WORKFLOW` | `RenderThumbnailWorkflow` | Waits out one configuration's render and stores both sizes in R2 | +| Binding | Class | What it does | +| --------------------------- | ------------------------- | ----------------------------------------------------------------------------- | +| `LOAD_DOCUMENT_WORKFLOW` | `LoadDocumentWorkflow` | Loads one group's document when its version moved on (or always, when forced) | +| `RENDER_THUMBNAIL_WORKFLOW` | `RenderThumbnailWorkflow` | Waits out one configuration's render and stores both sizes in R2 | Loading a group means walking the document structure, downloading metadata for every part and assembly, probing each indexed configuration, generating thumbnails, and writing it all to D1 — far too long for a single HTTP request. The request kicks the workflow off and returns immediately. -Each workflow carries the requesting user's `sessionId`, since it calls Onshape under their tokens after the request has ended. +Every load is one document: adding a document, a new version of one (see Webhooks below), and the owner's "reload everything", which starts one per group. `features/load/jobs.ts` keeps at most one load per group running, in the `load_jobs` table: a load asked for while one runs is marked on that row, and the running load starts it as it finishes. The last load to finish in a library rebuilds its search index, once rather than per document. + +Each workflow carries a `sessionId` whose tokens it calls Onshape under after the request has ended: the requesting user's, or the owner's for a webhook. + +### Webhooks and live updates + +Onshape pushes two things, registered with `isTransient: false` and recorded in the `onshape_webhooks` table, each with its own token in the delivery url (`features/webhooks`): + +- **A new version of a library document.** Registered by the document's load; removed with the last group loaded from it. Reloads that document's groups. +- **A change to an admin team's members.** Registered when the owner sets a library's admin team. Pulls the team's members again. + +The server pushes to open clients over a WebSocket held by the `LiveUpdates` Durable Object (`features/live`): jobs starting and finishing, a library's new version, and a configuration's render landing. Clients poll only while that connection is down. ### Assets — Static File Serving (`c.env.ASSETS`) @@ -187,8 +197,8 @@ Other top-level files: ## Access Levels -The app has three access levels, checked on every protected API call: **ADMIN**, **EDITOR**, and **USER**. Admin and editor access currently grant the same permissions (adding, removing, and renaming groups, toggling insertable visibility), but they are kept separate so permissions can be tightened in the future if needed. USER access allows anyone who logs in via OAuth to browse the library, insert parts, and manage their own favorites. +The app has four access levels, checked on every protected API call: **OWNER**, **ADMIN**, **EDITOR**, and **USER**. Access is per library. Admin and editor access currently grant the same permissions in their library (adding, removing, and renaming groups, toggling insertable visibility), but they are kept separate so permissions can be tightened in the future if needed. USER access allows anyone who logs in via OAuth to browse the library, insert parts, and manage their own favorites. -The Worker determines a user's access level in `src/backend/features/auth/caller.ts` by calling the Onshape API to check team membership against the `ADMIN_TEAM` binding. Backend routes that require elevated access are wrapped with `requireEditorMiddleware` or `requireAdminMiddleware` from `src/backend/features/auth/guards.ts`. +The **owner** is the one Onshape user named by `OWNER_USER_ID`, with every library. The owner sets each library's admin team; its members are stored in `admin_team_members` (team admins as ADMIN, members as EDITOR) and kept current by the team's webhook, so a user's access is a database lookup in `src/backend/features/auth/request-auth.ts`. Routes that require elevated access are wrapped with `requireEditor` or `requireOwnerMiddleware` from `src/backend/features/auth/guards.ts`; one naming an insertable rather than a library looks the library up from it. The owner's latest session is also what webhook-triggered loads run under. During local development, you can bypass the team membership check by setting `ACCESS_LEVEL_OVERRIDE=admin` (or `editor`/`user`) in your `.env` file. diff --git a/drizzle/0006_admin_teams_webhooks_jobs.sql b/drizzle/0006_admin_teams_webhooks_jobs.sql new file mode 100644 index 000000000..a966da856 --- /dev/null +++ b/drizzle/0006_admin_teams_webhooks_jobs.sql @@ -0,0 +1,29 @@ +CREATE TABLE `admin_team_members` ( + `library_id` text NOT NULL, + `user_id` text NOT NULL, + `is_team_admin` integer NOT NULL, + PRIMARY KEY(`library_id`, `user_id`), + FOREIGN KEY (`library_id`) REFERENCES `libraries`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `load_jobs` ( + `group_id` text PRIMARY KEY NOT NULL, + `library_id` text NOT NULL, + `instance_id` text, + `started_at` integer NOT NULL, + `rerun` integer DEFAULT false NOT NULL, + `rerun_force` integer DEFAULT false NOT NULL, + FOREIGN KEY (`group_id`) REFERENCES `groups`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`library_id`) REFERENCES `libraries`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `onshape_webhooks` ( + `subject` text NOT NULL, + `subject_id` text NOT NULL, + `webhook_id` text, + `token` text NOT NULL, + PRIMARY KEY(`subject`, `subject_id`) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `onshape_webhooks_token_unique` ON `onshape_webhooks` (`token`);--> statement-breakpoint +ALTER TABLE `libraries` ADD `admin_team_id` text; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..15f86d88f --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1370 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cac6acf8-d0f8-448b-a93f-5ae1ec06ac95", + "prevId": "04f9ddbb-bdbb-437a-bbc7-4933163255ee", + "tables": { + "admin_team_members": { + "name": "admin_team_members", + "columns": { + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_team_admin": { + "name": "is_team_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "admin_team_members_library_id_libraries_id_fk": { + "name": "admin_team_members_library_id_libraries_id_fk", + "tableFrom": "admin_team_members", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "admin_team_members_library_id_user_id_pk": { + "columns": ["library_id", "user_id"], + "name": "admin_team_members_library_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "configurations": { + "name": "configurations", + "columns": { + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_insertable_id_insertables_id_fk": { + "name": "configurations_insertable_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_selection": { + "name": "default_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "favorites_user_id_library_id_insertable_id_unique": { + "name": "favorites_user_id_library_id_insertable_id_unique", + "columns": ["user_id", "library_id", "insertable_id"], + "isUnique": true + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_library_id_libraries_id_fk": { + "name": "favorites_library_id_libraries_id_fk", + "tableFrom": "favorites", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_insertable_id_insertables_id_fk": { + "name": "favorites_insertable_id_insertables_id_fk", + "tableFrom": "favorites", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_workspace_id": { + "name": "thumbnail_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_alphabetically": { + "name": "sort_alphabetically", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groups_document_id_library_id_unique": { + "name": "groups_document_id_library_id_unique", + "columns": ["document_id", "library_id"], + "isUnique": true + } + }, + "foreignKeys": { + "groups_library_id_libraries_id_fk": { + "name": "groups_library_id_libraries_id_fk", + "tableFrom": "groups", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertables": { + "name": "insertables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_type": { + "name": "element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "microversion_id": { + "name": "microversion_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_open_composite": { + "name": "is_open_composite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "supports_fasten": { + "name": "supports_fasten", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "excluded_parameter_ids": { + "name": "excluded_parameter_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vendors": { + "name": "vendors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_metadata": { + "name": "part_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "insertables_group_id_groups_id_fk": { + "name": "insertables_group_id_groups_id_fk", + "tableFrom": "insertables", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "insertables_library_id_libraries_id_fk": { + "name": "insertables_library_id_libraries_id_fk", + "tableFrom": "insertables", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "libraries": { + "name": "libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "admin_team_id": { + "name": "admin_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "load_jobs": { + "name": "load_jobs", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rerun": { + "name": "rerun", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rerun_force": { + "name": "rerun_force", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "load_jobs_group_id_groups_id_fk": { + "name": "load_jobs_group_id_groups_id_fk", + "tableFrom": "load_jobs", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "load_jobs_library_id_libraries_id_fk": { + "name": "load_jobs_library_id_libraries_id_fk", + "tableFrom": "load_jobs", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onshape_webhooks": { + "name": "onshape_webhooks", + "columns": { + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "onshape_webhooks_token_unique": { + "name": "onshape_webhooks_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onshape_webhooks_subject_subject_id_pk": { + "columns": ["subject", "subject_id"], + "name": "onshape_webhooks_subject_subject_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "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'" + }, + "tab_id": { + "name": "tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_library_id_libraries_id_fk": { + "name": "users_library_id_libraries_id_fk", + "tableFrom": "users", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_configuration_metrics": { + "name": "daily_configuration_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parameter_id": { + "name": "parameter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_configuration_metrics_day_idx": { + "name": "daily_configuration_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { + "columns": [ + "library_id", + "element_id", + "parameter_id", + "value", + "day" + ], + "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_metrics": { + "name": "daily_insertable_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_insertable_metrics_day_idx": { + "name": "daily_insertable_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { + "columns": [ + "library_id", + "element_id", + "day", + "target_element_type" + ], + "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_users": { + "name": "daily_insertable_users", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_users_library_id_element_id_day_user_id_pk": { + "columns": ["library_id", "element_id", "day", "user_id"], + "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_metrics": { + "name": "daily_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fasten_count": { + "name": "fasten_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_metrics_day_library_id_type_pk": { + "columns": ["day", "library_id", "type"], + "name": "daily_metrics_day_library_id_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_source_metrics": { + "name": "daily_source_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_source_metrics_day_library_id_source_pk": { + "columns": ["day", "library_id", "source"], + "name": "daily_source_metrics_day_library_id_source_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_target_metrics": { + "name": "daily_target_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_target_metrics_day_library_id_target_element_type_pk": { + "columns": ["day", "library_id", "target_element_type"], + "name": "daily_target_metrics_day_library_id_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_user_activity": { + "name": "daily_user_activity", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "daily_user_activity_day_idx": { + "name": "daily_user_activity_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_user_activity_day_library_id_user_id_pk": { + "columns": ["day", "library_id", "user_id"], + "name": "daily_user_activity_day_library_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_quick_insert": { + "name": "is_quick_insert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten": { + "name": "fasten", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "events_day_idx": { + "name": "events_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertable_stats": { + "name": "insertable_stats", + "columns": { + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_inserted_at": { + "name": "first_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_inserted_at": { + "name": "last_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "insertable_stats_count_idx": { + "name": "insertable_stats_count_idx", + "columns": ["library_id", "insert_count"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "insertable_stats_library_id_element_id_pk": { + "columns": ["library_id", "element_id"], + "name": "insertable_stats_library_id_element_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_stats_user_id_library_id_pk": { + "columns": ["user_id", "library_id"], + "name": "user_stats_user_id_library_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 49e5f5718..001984b3e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1790196152689, "tag": "0005_excluded_parameters", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1790215505598, + "tag": "0006_admin_teams_webhooks_jobs", + "breakpoints": true } ] } diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 16d2a651c..5f4d7850d 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -6,7 +6,10 @@ import { groups, insertables, libraries, - users + users, + adminTeamMembers, + loadJobs, + onshapeWebhooks } from "@backend/db/schema"; import { dailyConfigurationMetrics, @@ -73,10 +76,13 @@ export async function resetDb(db: Db): Promise<void> { await db.batch([ db.delete(favorites), db.delete(configurations), + db.delete(loadJobs), db.delete(insertables), db.delete(groups), db.delete(users), + db.delete(adminTeamMembers), db.delete(libraries), + db.delete(onshapeWebhooks), // Analytics has no foreign keys, so nothing cascades these away. db.delete(events), db.delete(dailyMetrics), diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index 4b886d034..caeb5c771 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -1,12 +1,16 @@ import { createApp } from "@backend/app"; import { AccessLevel } from "@backend/features/auth/access-level"; +import type { LibraryId } from "@backend/features/library/library-id"; import { MOCK_ONSHAPE_API, MockOnshapeApi } from "./mock-onshape-api"; export interface TestAppOptions { /** Current user id, returned by `c.var.getUserId()` (default `"test-user"`). */ userId?: string; - /** Access level returned by `c.var.getAccessLevel()` (default `ADMIN`). */ - accessLevel?: AccessLevel; + /** + * Access level returned by `c.var.getAccessLevel()` (default `ADMIN`): one + * for every library, or one per library. + */ + accessLevel?: AccessLevel | ((libraryId: LibraryId) => AccessLevel); /** Onshape mock returned by `c.var.getOnshapeApi()` (default a fresh mock). */ onshapeApi?: MockOnshapeApi; /** @@ -30,8 +34,12 @@ export function createTestApp(options: TestAppOptions = {}) { ? Promise.resolve(options.onshapeApi ?? MOCK_ONSHAPE_API) : Promise.reject(new Error("Not signed in")), getUserId: () => Promise.resolve(options.userId ?? "test-user"), - getAccessLevel: () => - Promise.resolve(options.accessLevel ?? AccessLevel.ADMIN), + getAccessLevel: (libraryId) => { + const level = options.accessLevel ?? AccessLevel.ADMIN; + return Promise.resolve( + typeof level === "function" ? level(libraryId) : level + ); + }, isAuthenticated: () => Promise.resolve(options.isAuthenticated ?? true) })); } diff --git a/src/backend/app.ts b/src/backend/app.ts index c186bdde2..6bea1ebd0 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -16,6 +16,8 @@ import { settingsRoutes } from "./features/settings/routes"; import { thumbnailRoutes } from "./features/thumbnails/routes"; import { webhookRoutes } from "./features/webhooks/routes"; import { liveRoutes } from "./features/live/routes"; +import { adminTeamRoutes } from "./features/admin-team/routes"; +import { loadRoutes } from "./features/load/routes"; import { logger } from "hono/logger"; import { cacheMiddleware } from "./lib/cache"; import { bindAuth, getApp, type AuthResolver } from "./lib/context"; @@ -34,7 +36,9 @@ const apiRoutes = [ buildStatusRoutes, analyticsRoutes, webhookRoutes, - liveRoutes + liveRoutes, + adminTeamRoutes, + loadRoutes ]; export function createApp(resolveAuth: AuthResolver) { diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index ef114d6da..cb119b142 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -3,7 +3,8 @@ import { text, integer, unique, - customType + customType, + primaryKey } from "drizzle-orm/sqlite-core"; import { ElementType } from "../lib/onshape/element-type"; import { FastenInfo } from "../features/library/insertables/fasten"; @@ -68,12 +69,33 @@ const versionCreatedAt = () => integer("version_created_at", { mode: "timestamp_ms" }); export const libraries = sqliteTable("libraries", { - id: text("id").primaryKey(), - cacheVersion: integer("cache_version").notNull().default(0) - // The serialized MiniSearch index now lives in R2 (see rebuildSearchDb), + id: text("id").$type<LibraryId>().primaryKey(), + // The serialized MiniSearch index lives in R2 (see rebuildSearchDb), // keyed by library id, rather than in a D1 column. + cacheVersion: integer("cache_version").notNull().default(0), + // The Onshape team whose members may edit the library, set by the owner. + // Null until set, when nobody but the owner can. + adminTeamId: text("admin_team_id") }); +/** + * The library's admin team as Onshape last reported it, so access is a lookup + * rather than a question for Onshape. Replaced whole on every sync; see + * `features/access/admin-team.ts`. + */ +export const adminTeamMembers = sqliteTable( + "admin_team_members", + { + libraryId: libraryId().references(() => libraries.id, { + onDelete: "cascade" + }), + userId: text("user_id").notNull(), + // An admin of the team rather than only a member of it. + isTeamAdmin: integer("is_team_admin", { mode: "boolean" }).notNull() + }, + (t) => [primaryKey({ columns: [t.libraryId, t.userId] })] +); + /** * The `versionId` a group carries before a load pins a real one, so a group * whose load failed still has a row that can be seen, deleted, and retried. @@ -228,3 +250,50 @@ export const favorites = sqliteTable( }, (t) => [unique().on(t.userId, t.libraryId, t.insertableId)] ); + +/** + * What an Onshape webhook is registered for: new versions of one document, or + * an admin team's membership. + */ +export enum WebhookSubject { + DOCUMENT = "document", + TEAM = "team" +} + +/** + * The webhooks this deployment registered, one per subject, and the token + * each one's deliveries carry. The token is stored before Onshape answers the + * create, since it checks the url before then; `webhookId` follows. + */ +export const onshapeWebhooks = sqliteTable( + "onshape_webhooks", + { + subject: text("subject").$type<WebhookSubject>().notNull(), + // A document id or a team id, by subject. + subjectId: text("subject_id").notNull(), + webhookId: text("webhook_id"), + token: text("token").notNull().unique() + }, + (t) => [primaryKey({ columns: [t.subject, t.subjectId] })] +); + +/** + * The load running for each group, one at most: another asked for meanwhile + * sets `rerun`, and the running one starts it as it finishes. Rows rather than + * a KV list, since loads start and finish concurrently and a list in KV loses + * writes that race. + */ +export const loadJobs = sqliteTable("load_jobs", { + groupId: text("group_id") + .primaryKey() + .references(() => groups.id, { onDelete: "cascade" }), + libraryId: libraryId().references(() => libraries.id), + // Null for the moment between claiming the row and the instance existing. + instanceId: text("instance_id"), + startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), + rerun: integer("rerun", { mode: "boolean" }).notNull().default(false), + // Whether the rerun reloads unchanged insertables too. + rerunForce: integer("rerun_force", { mode: "boolean" }) + .notNull() + .default(false) +}); diff --git a/src/backend/features/admin-team/contract.ts b/src/backend/features/admin-team/contract.ts new file mode 100644 index 000000000..c357aa653 --- /dev/null +++ b/src/backend/features/admin-team/contract.ts @@ -0,0 +1,6 @@ +/** What the owner sees of a library's admin team. */ +export interface AdminTeamOut { + /** Null until the owner sets one. */ + teamId: string | null; + memberCount: number; +} diff --git a/src/backend/features/admin-team/routes.ts b/src/backend/features/admin-team/routes.ts new file mode 100644 index 000000000..81e6c5c59 --- /dev/null +++ b/src/backend/features/admin-team/routes.ts @@ -0,0 +1,113 @@ +import { count, eq } from "drizzle-orm"; +import { HttpStatus } from "http-status-ts"; +import { z } from "zod"; +import { getApp } from "../../lib/context"; +import { handledError } from "../../lib/api-error"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { validate } from "../../lib/validate"; +import { type Db, getDb } from "../../db/client"; +import { adminTeamMembers, libraries, WebhookSubject } from "../../db/schema"; +import { requireOwnerMiddleware } from "../auth/guards"; +import { ensureLibrary } from "../library/db"; +import type { LibraryId } from "../library/library-id"; +import { ensureWebhook, removeWebhook } from "../webhooks/registration"; +import type { AdminTeamOut } from "./contract"; +import { librariesOfTeam, syncAdminTeam } from "./sync"; + +export const adminTeamRoutes = getApp(); + +const setAdminTeamBody = z.object({ + /** Null takes the team away, leaving only the owner able to edit. */ + teamId: z.string().trim().min(1).nullable() +}); + +async function getAdminTeam( + db: Db, + libraryId: LibraryId +): Promise<AdminTeamOut> { + const [library, members] = await Promise.all([ + db + .select({ teamId: libraries.adminTeamId }) + .from(libraries) + .where(eq(libraries.id, libraryId)) + .get(), + db + .select({ count: count() }) + .from(adminTeamMembers) + .where(eq(adminTeamMembers.libraryId, libraryId)) + .get() + ]); + return { + teamId: library?.teamId ?? null, + memberCount: members?.count ?? 0 + }; +} + +/** GET /api/admin-team/library/:libraryId */ +adminTeamRoutes.get( + "/admin-team" + libraryRoute(), + requireOwnerMiddleware, + async (c) => c.json(await getAdminTeam(getDb(c.env.DB), getLibraryParam(c))) +); + +/** + * POST /api/admin-team/library/:libraryId — sets the team, pulls its members, + * and registers the webhook that keeps them current. + */ +adminTeamRoutes.post( + "/admin-team" + libraryRoute(), + requireOwnerMiddleware, + validate("json", setAdminTeamBody), + async (c) => { + const libraryId = getLibraryParam(c); + const { teamId } = c.req.valid("json"); + const db = getDb(c.env.DB); + const onshapeApi = await c.var.getOnshapeApi(); + + await ensureLibrary(db, libraryId); + const previous = (await getAdminTeam(db, libraryId)).teamId; + const setTeam = (adminTeamId: string | null) => + db + .update(libraries) + .set({ adminTeamId }) + .where(eq(libraries.id, libraryId)); + + await setTeam(teamId); + try { + await syncAdminTeam(c.env, onshapeApi, libraryId); + } catch (error) { + // A team the owner cannot read is a typo more often than not; + // keep the one that was working. + await setTeam(previous); + console.error(`Failed to read team ${teamId}`, error); + throw handledError( + "Couldn't read that team's members from Onshape. Check the team id.", + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + + const origin = new URL(c.req.url).origin; + if (teamId) { + await ensureWebhook( + c.env, + onshapeApi, + WebhookSubject.TEAM, + teamId, + origin + ); + } + if ( + previous && + previous !== teamId && + (await librariesOfTeam(c.env, previous)).length === 0 + ) { + await removeWebhook( + c.env, + onshapeApi, + WebhookSubject.TEAM, + previous + ); + } + return c.json(await getAdminTeam(db, libraryId)); + } +); diff --git a/src/backend/features/admin-team/routes.worker.test.ts b/src/backend/features/admin-team/routes.worker.test.ts new file mode 100644 index 000000000..23d0a82c3 --- /dev/null +++ b/src/backend/features/admin-team/routes.worker.test.ts @@ -0,0 +1,124 @@ +import { env } from "cloudflare:workers"; +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TEST_LIBRARY_ID, + createTestApp, + jsonRequest, + resetDb, + seedLibrary +} from "../../../__test_utils__"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { getDb } from "../../db/client"; +import { + adminTeamMembers, + libraries, + onshapeWebhooks, + WebhookSubject +} from "../../db/schema"; +import { OnshapeApiError } from "../../lib/onshape/client"; +import { AccessLevel } from "../auth/access-level"; + +const db = getDb(env.DB); +const PATH = `/api/admin-team/library/${TEST_LIBRARY_ID}`; + +/** Onshape, with a team of one member and one admin. */ +function mockOnshape() { + const onshapeApi = new MockOnshapeApi(); + vi.spyOn(onshapeApi, "get").mockImplementation((path: string) => { + if (path === "/users/sessioninfo") { + return Promise.resolve({ id: "owner", company: { id: "company" } }); + } + if (path === "/teams/team/members") { + return Promise.resolve({ + items: [ + { admin: false, member: { id: "member" } }, + { admin: true, member: { id: "team-admin" } } + ] + }); + } + return Promise.reject(new OnshapeApiError("no such team", 404)); + }); + const post = vi + .spyOn(onshapeApi, "post") + .mockResolvedValue({ id: "team-webhook" }); + return { onshapeApi, post }; +} + +function setTeam(teamId: string | null, onshapeApi: MockOnshapeApi) { + return createTestApp({ + accessLevel: AccessLevel.OWNER, + onshapeApi + }).request(PATH, jsonRequest("POST", { teamId }), env); +} + +describe("setting a library's admin team", () => { + beforeEach(async () => { + await resetDb(db); + await seedLibrary(db); + }); + afterEach(() => vi.restoreAllMocks()); + + it("is the owner's alone", async () => { + const res = await createTestApp({ + accessLevel: AccessLevel.ADMIN + }).request(PATH, jsonRequest("POST", { teamId: "team" }), env); + expect(res.status).toBe(403); + }); + + it("stores the team's members and registers its webhook", async () => { + const { onshapeApi, post } = mockOnshape(); + + const res = await setTeam("team", onshapeApi); + + expect(await res.json()).toEqual({ teamId: "team", memberCount: 2 }); + expect( + await db + .select({ + userId: adminTeamMembers.userId, + isTeamAdmin: adminTeamMembers.isTeamAdmin + }) + .from(adminTeamMembers) + .all() + ).toEqual([ + { userId: "member", isTeamAdmin: false }, + { userId: "team-admin", isTeamAdmin: true } + ]); + expect(post).toHaveBeenCalledOnce(); + expect( + await db + .select({ subject: onshapeWebhooks.subject }) + .from(onshapeWebhooks) + .all() + ).toEqual([{ subject: WebhookSubject.TEAM }]); + }); + + // A mistyped id should not lock everyone but the owner out. + it("keeps the team that was working when the new one cannot be read", async () => { + const { onshapeApi } = mockOnshape(); + await setTeam("team", onshapeApi); + + const res = await setTeam("typo", onshapeApi); + + expect(res.status).toBe(422); + const library = await db + .select({ adminTeamId: libraries.adminTeamId }) + .from(libraries) + .where(eq(libraries.id, TEST_LIBRARY_ID)) + .get(); + expect(library?.adminTeamId).toBe("team"); + }); + + it("takes the team away, and its webhook with it", async () => { + const { onshapeApi } = mockOnshape(); + const remove = vi + .spyOn(onshapeApi, "deleteNone") + .mockResolvedValue(undefined); + await setTeam("team", onshapeApi); + + const res = await setTeam(null, onshapeApi); + + expect(await res.json()).toEqual({ teamId: null, memberCount: 0 }); + expect(remove).toHaveBeenCalledWith("/webhooks/team-webhook"); + }); +}); diff --git a/src/backend/features/admin-team/sync.ts b/src/backend/features/admin-team/sync.ts new file mode 100644 index 000000000..c48d914e0 --- /dev/null +++ b/src/backend/features/admin-team/sync.ts @@ -0,0 +1,70 @@ +/** + * Keeps a library's stored admin team in step with Onshape's. Access is read + * from what is stored, so a sync is what changes anyone's access; it is + * announced as a new library version, which already has every open client + * refresh what it shows, access included. + */ +import { eq } from "drizzle-orm"; +import type { BatchItem } from "drizzle-orm/batch"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { adminTeamMembers, libraries } from "../../db/schema"; +import type { OnshapeApi } from "../../lib/onshape/client"; +import { getTeamMembers } from "../../lib/onshape/endpoints/teams"; +import { bumpLibraryVersion } from "../library/db"; +import type { LibraryId } from "../library/library-id"; +import { pushLibraryChanged } from "../live/notify"; + +/** Three columns a row, under D1's 100 bound parameters a statement. */ +const ROWS_PER_INSERT = 30; + +export async function syncAdminTeam( + env: AppBindings, + onshapeApi: OnshapeApi, + libraryId: LibraryId +): Promise<void> { + const db = getDb(env.DB); + const library = await db + .select({ adminTeamId: libraries.adminTeamId }) + .from(libraries) + .where(eq(libraries.id, libraryId)) + .get(); + const teamId = library?.adminTeamId; + const members = teamId ? await getTeamMembers(onshapeApi, teamId) : []; + + const rows = members.map((member) => ({ + libraryId, + userId: member.member.id, + isTeamAdmin: member.admin + })); + // Replaced whole, in one batch, so nobody reads a team half written. + const writes: BatchItem<"sqlite">[] = [ + db + .delete(adminTeamMembers) + .where(eq(adminTeamMembers.libraryId, libraryId)) + ]; + for (let i = 0; i < rows.length; i += ROWS_PER_INSERT) { + writes.push( + db + .insert(adminTeamMembers) + .values(rows.slice(i, i + ROWS_PER_INSERT)) + .onConflictDoNothing() + ); + } + await db.batch(writes as [BatchItem<"sqlite">, ...BatchItem<"sqlite">[]]); + + await bumpLibraryVersion(db, libraryId); + await pushLibraryChanged(env, libraryId); +} + +/** Every library a team administers, for when that team changes. */ +export async function librariesOfTeam( + env: AppBindings, + teamId: string +): Promise<LibraryId[]> { + const rows = await getDb(env.DB) + .select({ id: libraries.id }) + .from(libraries) + .where(eq(libraries.adminTeamId, teamId)); + return rows.map((row) => row.id); +} diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index 5a117104f..96522f87f 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -1,9 +1,19 @@ -/** The two gates routes mount: signed in to Onshape at all, and on the admin team. */ +/** + * The gates routes mount: signed in to Onshape at all, on a library's admin + * team, and the owner. + */ import type { MiddlewareHandler } from "hono"; -import { forbiddenError, signInRequiredError } from "../../lib/api-error"; +import { + forbiddenError, + handledError, + signInRequiredError +} from "../../lib/api-error"; import type { AppContext, AppContextEnv } from "../../lib/context"; -import { hasEditorAccess } from "./access-level"; +import { getLibraryParam } from "../../lib/route-params"; +import { DEFAULT_LIBRARY, type LibraryId } from "../library/library-id"; +import { AccessLevel, hasEditorAccess } from "./access-level"; import { isSignedIn } from "./request-auth"; +import { HttpStatus } from "http-status-ts"; async function requireSignIn(c: AppContext): Promise<void> { if (!(await isSignedIn(c))) { @@ -21,19 +31,53 @@ export const requireSignInMiddleware: MiddlewareHandler<AppContextEnv> = async ( await next(); }; +/** Which library a request acts on; undefined when what it names is gone. */ +type LibraryOf = (c: AppContext) => Promise<LibraryId | undefined>; + +const libraryParam: LibraryOf = (c) => Promise.resolve(getLibraryParam(c)); + +/** + * Editing a library takes a place on its admin team. `libraryOf` is for a route + * naming something inside a library rather than the library: the library is + * looked up from it, not taken from the caller, whose word it would otherwise + * be. Editing implies a session: access level alone would admit a signed-out + * caller under a dev access-level override, and answer 403 rather than 401. + */ +export function requireEditor( + libraryOf: LibraryOf = libraryParam +): MiddlewareHandler<AppContextEnv> { + return async (c, next) => { + await requireSignIn(c); + const libraryId = await libraryOf(c); + if (!libraryId) { + throw handledError("Not found", HttpStatus.NOT_FOUND); + } + if (!hasEditorAccess(await c.var.getAccessLevel(libraryId))) { + throw forbiddenError( + "You must be on the library's admin team to use this functionality" + ); + } + await next(); + }; +} + +/** For a route under `libraryRoute()`. */ +export const requireEditorMiddleware = requireEditor(); + /** - * Editing implies a session: access level alone would admit a signed-out caller - * under a dev access-level override, and answer 403 rather than 401 otherwise. + * For what reaches past any one library. The owner's access is the same in + * every library, so any library answers. */ -export const requireEditorMiddleware: MiddlewareHandler<AppContextEnv> = async ( +export const requireOwnerMiddleware: MiddlewareHandler<AppContextEnv> = async ( c, next ) => { await requireSignIn(c); - if (!hasEditorAccess(await c.var.getAccessLevel())) { - throw forbiddenError( - "You must be on the admin team to use this functionality" - ); + const level = await c.var.getAccessLevel( + c.req.param("libraryId") ? getLibraryParam(c) : DEFAULT_LIBRARY + ); + if (level !== AccessLevel.OWNER) { + throw forbiddenError("Only the owner can use this functionality"); } await next(); }; diff --git a/src/backend/features/auth/guards.worker.test.ts b/src/backend/features/auth/guards.worker.test.ts index 3c2e9c6d0..de460469c 100644 --- a/src/backend/features/auth/guards.worker.test.ts +++ b/src/backend/features/auth/guards.worker.test.ts @@ -7,6 +7,8 @@ import { createTestApp, jsonRequest, resetDb, + seedGroup, + seedInsertable, seedLibrary } from "../../../__test_utils__"; import { getDb } from "../../db/client"; @@ -63,7 +65,7 @@ describe("requireEditorMiddleware", () => { }); const res = await app.request( - `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, jsonRequest("POST"), env ); @@ -77,10 +79,38 @@ describe("requireEditorMiddleware", () => { }); const res = await app.request( - `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, jsonRequest("POST"), env ); expect(res.status).toBe(403); }); }); + +describe("editing something inside a library", () => { + beforeEach(() => resetDb(db)); + + // The library comes from the insertable, not from anything the caller + // sends, so an editor of one library cannot reach into another. + it("takes the access of the library the insertable is in", async () => { + await seedGroup(db, "ftc-group", LibraryId.FTC_DESIGN_LIB); + await seedInsertable(db, { + id: "ftc-part", + groupId: "ftc-group", + libraryId: LibraryId.FTC_DESIGN_LIB + }); + const app = createTestApp({ + accessLevel: (libraryId) => + libraryId === LibraryId.FRC_DESIGN_LIB + ? AccessLevel.ADMIN + : AccessLevel.USER + }); + + const res = await app.request( + "/api/index-configurations/insertable/ftc-part", + jsonRequest("POST", { indexConfigurations: true }), + env + ); + expect(res.status).toBe(403); + }); +}); diff --git a/src/backend/features/auth/owner.ts b/src/backend/features/auth/owner.ts index f67a60e14..60307f4d1 100644 --- a/src/backend/features/auth/owner.ts +++ b/src/backend/features/auth/owner.ts @@ -8,12 +8,17 @@ import { getOnshapeApiFromSessionId } from "./request-auth"; const OWNER_SESSION_KEY = "owner-session"; -/** Called as the owner's access level is resolved, which a new sign-in does. */ +/** + * Called whenever the owner's access is resolved; written only when their + * session has changed, which a sign-in does. + */ export async function rememberOwnerSession( kv: KVNamespace, sessionId: string ): Promise<void> { - await kv.put(OWNER_SESSION_KEY, sessionId); + if ((await kv.get(OWNER_SESSION_KEY)) !== sessionId) { + await kv.put(OWNER_SESSION_KEY, sessionId); + } } /** diff --git a/src/backend/features/auth/request-auth.ts b/src/backend/features/auth/request-auth.ts index a65d919a2..b4495ab6f 100644 --- a/src/backend/features/auth/request-auth.ts +++ b/src/backend/features/auth/request-auth.ts @@ -1,25 +1,23 @@ /** - * Answers a request's auth questions from its session, memoized in KV. `createApp` + * Answers a request's auth questions from its session. `createApp` * binds `productionAuth` onto every request; guards and routes ask through `c.var`. */ import { env as processEnv } from "process"; import { OAuthApi } from "../../lib/onshape/client"; -import { - getAccessLevel, - getSessionInfo, - getUserId -} from "../../lib/onshape/endpoints/users"; +import { getSessionInfo, getUserId } from "../../lib/onshape/endpoints/users"; import { type AppContext, type AuthResolver } from "../../lib/context"; +import { and, eq } from "drizzle-orm"; +import { getDb } from "../../db/client"; +import { adminTeamMembers } from "../../db/schema"; +import type { LibraryId } from "../library/library-id"; import { AccessLevel } from "./access-level"; import { rememberOwnerSession } from "./owner"; -import { ensureWebhook } from "../webhooks/registration"; import { getOauthClient, makeAuthTokens, TOKEN_ENDPOINT } from "./onshape-oauth"; import { - accessLevelKey, getSession, getSessionCompanyId, getSessionId, @@ -27,9 +25,6 @@ import { saveSession } from "./session"; -/** How long a resolved access level is cached in KV. */ -const ACCESS_LEVEL_TTL_SECONDS = 60 * 60; - /** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; @@ -153,51 +148,34 @@ export async function isSignedIn(c: AppContext): Promise<boolean> { return signedIn; } -/** Whether the caller is the Onshape user `OWNER_USER_ID` names. */ -async function isOwner(c: AppContext): Promise<boolean> { - const ownerUserId = c.env.OWNER_USER_ID; - return !!ownerUserId && (await getCachedUserId(c)) === ownerUserId; -} - /** - * In the background where the runtime allows it: nothing the owner asked for - * waits on Onshape for this, and a failure only means the next check retries. + * The caller's access to `libraryId`: the owner's anywhere, and otherwise what + * the library's admin team says, as last synced from Onshape. A lookup rather + * than a question for Onshape, so it needs no caching. */ -function keepWebhookRegistered(c: AppContext): void { - const work = getOnshapeApi(c) - .then((onshapeApi) => - ensureWebhook(c.env, onshapeApi, new URL(c.req.url).origin) - ) - .catch((error: unknown) => { - console.error("Failed to register Onshape webhooks", error); - }); - try { - c.executionCtx.waitUntil(work); - } catch { - // No execution context, as under test; the promise runs regardless. +async function getLibraryAccessLevel( + c: AppContext, + libraryId: LibraryId +): Promise<AccessLevel> { + const userId = await getCachedUserId(c); + if (c.env.OWNER_USER_ID && userId === c.env.OWNER_USER_ID) { + await rememberOwnerSession(c.env.KV, getSessionId(c)); + return AccessLevel.OWNER; } -} - -/** Returns the caller's access level, memoized in KV by session. */ -async function getCachedAccessLevel(c: AppContext): Promise<AccessLevel> { - const sessionId = getSessionId(c); - const key = accessLevelKey(sessionId); - - const cached = await c.env.KV.get(key); - if (cached) return cached as AccessLevel; - - let level: AccessLevel; - if (await isOwner(c)) { - level = AccessLevel.OWNER; - await rememberOwnerSession(c.env.KV, sessionId); - keepWebhookRegistered(c); - } else { - level = await getAccessLevel(await getOnshapeApi(c), c.env.ADMIN_TEAM); + const member = await getDb(c.env.DB) + .select({ isTeamAdmin: adminTeamMembers.isTeamAdmin }) + .from(adminTeamMembers) + .where( + and( + eq(adminTeamMembers.libraryId, libraryId), + eq(adminTeamMembers.userId, userId) + ) + ) + .get(); + if (!member) { + return AccessLevel.USER; } - await c.env.KV.put(key, level, { - expirationTtl: ACCESS_LEVEL_TTL_SECONDS - }); - return level; + return member.isTeamAdmin ? AccessLevel.ADMIN : AccessLevel.EDITOR; } /** @@ -213,13 +191,13 @@ export const productionAuth: AuthResolver = (c) => ({ } return getCachedUserId(c); }, - getAccessLevel: async () => { + getAccessLevel: async (libraryId) => { const override = getAccessLevelOverride(c); if (override) return override; - // getCachedAccessLevel needs a real Onshape session, so only call it - // for a genuinely signed-in caller (not FORCE_SIGNED_IN). + // Needs a real Onshape session to know who is asking, so only for a + // genuinely signed-in caller (not FORCE_SIGNED_IN). if (!isForceSignedIn(c) && (await isSignedIn(c))) { - return getCachedAccessLevel(c); + return getLibraryAccessLevel(c, libraryId); } return AccessLevel.USER; }, diff --git a/src/backend/features/auth/request-auth.worker.test.ts b/src/backend/features/auth/request-auth.worker.test.ts index 7d3725507..59fb7a798 100644 --- a/src/backend/features/auth/request-auth.worker.test.ts +++ b/src/backend/features/auth/request-auth.worker.test.ts @@ -1,22 +1,28 @@ import { env } from "cloudflare:workers"; import { env as processEnv } from "process"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AccessLevel } from "./access-level"; import { productionAuth } from "./request-auth"; import { createApp } from "../../app"; -import { jsonRequest } from "../../../__test_utils__"; +import { jsonRequest, resetDb, seedLibrary } from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { adminTeamMembers } from "../../db/schema"; +import { LibraryId } from "../library/library-id"; import { saveSession } from "./session"; import { getOwnerSessionId } from "./owner"; -import * as Registration from "../webhooks/registration"; const app = createApp(productionAuth); /** What the real caller resolves for a request carrying no Onshape session. */ async function getMaxAccessLevel(override?: AccessLevel): Promise<AccessLevel> { - const res = await app.request("/api/access-data", jsonRequest("GET"), { - ...env, - VITE_ACCESS_LEVEL_OVERRIDE: override - }); + const res = await app.request( + "/api/access-data/library/frc-design-lib", + jsonRequest("GET"), + { + ...env, + VITE_ACCESS_LEVEL_OVERRIDE: override + } + ); const body: { maxAccessLevel: AccessLevel } = await res.json(); return body.maxAccessLevel; } @@ -47,11 +53,33 @@ describe("the dev access-level override", () => { }); }); -describe("the owner", () => { +describe("access from a library's admin team", () => { const OWNER = "owner-user-id"; + beforeEach(async () => { + const db = getDb(env.DB); + await resetDb(db); + await seedLibrary(db, LibraryId.FRC_DESIGN_LIB); + await seedLibrary(db, LibraryId.FTC_DESIGN_LIB); + await db.insert(adminTeamMembers).values([ + { + libraryId: LibraryId.FRC_DESIGN_LIB, + userId: "member", + isTeamAdmin: false + }, + { + libraryId: LibraryId.FRC_DESIGN_LIB, + userId: "team-admin", + isTeamAdmin: true + } + ]); + }); + /** A signed-in session whose user is already resolved, so Onshape is not asked. */ - async function accessLevelOf(userId: string): Promise<AccessLevel> { + async function accessLevelOf( + userId: string, + libraryId: LibraryId = LibraryId.FRC_DESIGN_LIB + ): Promise<AccessLevel> { const sessionId = crypto.randomUUID(); await saveSession(env.KV, sessionId, { accessToken: "token", @@ -60,7 +88,7 @@ describe("the owner", () => { userId }); const res = await app.request( - "/api/access-data", + `/api/access-data/library/${libraryId}`, { method: "GET", headers: { Cookie: `frc-design-app-cookie=${sessionId}` } @@ -72,12 +100,26 @@ describe("the owner", () => { } it("is the user OWNER_USER_ID names, and their session is kept", async () => { - const ensure = vi - .spyOn(Registration, "ensureWebhook") - .mockResolvedValue(); expect(await accessLevelOf(OWNER)).toBe(AccessLevel.OWNER); expect(await getOwnerSessionId(env.KV)).not.toBeNull(); - // And the webhooks are kept registered on their behalf. - expect(ensure).toHaveBeenCalledOnce(); + }); + + it("makes a member an editor, and a team admin an admin", async () => { + expect(await accessLevelOf("member")).toBe(AccessLevel.EDITOR); + expect(await accessLevelOf("team-admin")).toBe(AccessLevel.ADMIN); + expect(await accessLevelOf("stranger")).toBe(AccessLevel.USER); + }); + + // Access is per library now: one library's team edits that library alone. + it("grants nothing in a library whose team the user is not on", async () => { + expect( + await accessLevelOf("team-admin", LibraryId.FTC_DESIGN_LIB) + ).toBe(AccessLevel.USER); + }); + + it("gives the owner every library", async () => { + expect(await accessLevelOf(OWNER, LibraryId.FTC_DESIGN_LIB)).toBe( + AccessLevel.OWNER + ); }); }); diff --git a/src/backend/features/auth/routes.ts b/src/backend/features/auth/routes.ts index 5aad8dc97..ad8b9f413 100644 --- a/src/backend/features/auth/routes.ts +++ b/src/backend/features/auth/routes.ts @@ -2,6 +2,7 @@ import { HttpStatus } from "http-status-ts"; import { internalError } from "../../lib/api-error"; import { getApp } from "../../lib/context"; import { cacheMiddleware } from "../../lib/cache"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { type AccessData } from "./access-level"; import { isSignedIn } from "./request-auth"; import { doCallback, doSignIn } from "./onshape-oauth"; @@ -13,13 +14,17 @@ export const authRoutes = getApp(); /** What the app needs to know about the caller, mounted at /api. */ export const accessRoutes = getApp(); -/** GET /api/access-data */ -accessRoutes.get("/access-data", cacheMiddleware(), async (c) => { - return c.json({ - maxAccessLevel: await c.var.getAccessLevel(), - signedIn: await isSignedIn(c) - } satisfies AccessData); -}); +/** GET /api/access-data/library/:libraryId */ +accessRoutes.get( + "/access-data" + libraryRoute(), + cacheMiddleware(), + async (c) => { + return c.json({ + maxAccessLevel: await c.var.getAccessLevel(getLibraryParam(c)), + signedIn: await isSignedIn(c) + } satisfies AccessData); + } +); /** The app's own entry, which re-runs the gate and opens wherever it lands. */ const ENTRY_PATH = "/init"; diff --git a/src/backend/features/auth/routes.worker.test.ts b/src/backend/features/auth/routes.worker.test.ts index 6f5b90dfd..89ceaeed7 100644 --- a/src/backend/features/auth/routes.worker.test.ts +++ b/src/backend/features/auth/routes.worker.test.ts @@ -15,7 +15,7 @@ describe("GET /access-data", () => { const app = createTestApp({ accessLevel: AccessLevel.EDITOR }); const res = await app.request( - "/api/access-data", + "/api/access-data/library/frc-design-lib", jsonRequest("GET"), env ); @@ -34,7 +34,7 @@ describe("GET /access-data", () => { }); const res = await app.request( - "/api/access-data", + "/api/access-data/library/frc-design-lib", jsonRequest("GET"), env ); diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index 2c5723d7c..a31db63f0 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -242,3 +242,29 @@ async function getIndexedConfigurations( } return indexed; } + +/** The library an insertable is in, for a route that names only the insertable. */ +export async function libraryOfInsertable( + db: Db, + insertableId: string +): Promise<LibraryId | undefined> { + const row = await db + .select({ libraryId: insertables.libraryId }) + .from(insertables) + .where(eq(insertables.id, insertableId)) + .get(); + return row?.libraryId; +} + +/** The library a group is in, for a route that names only the group. */ +export async function libraryOfGroup( + db: Db, + groupId: string +): Promise<LibraryId | undefined> { + const row = await db + .select({ libraryId: groups.libraryId }) + .from(groups) + .where(eq(groups.id, groupId)) + .get(); + return row?.libraryId; +} diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 5f06f7cee..96de21684 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -9,21 +9,23 @@ import { getSessionId } from "../../auth/session"; import { getDocument } from "../../../lib/onshape/endpoints/documents"; import { requireEditorMiddleware } from "../../auth/guards"; import { type DocumentPath } from "../../../lib/onshape/path"; -import { groups, insertables, favorites } from "../../../db/schema"; +import { + groups, + insertables, + favorites, + WebhookSubject +} from "../../../db/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../db"; import { HttpStatus } from "http-status-ts"; import { handledError } from "../../../lib/api-error"; -import { getJobStatus, trackJob } from "../../load/job-tracker"; -import { startReload } from "../../load/reload"; +import { getJobStatus, requestLoads } from "../../load/jobs"; +import { createShellGroup } from "../../load/workflows"; +import { removeWebhook } from "../../webhooks/registration"; import { z } from "zod"; import { validate } from "../../../lib/validate"; export const groupRoutes = getApp(); -const reloadGroupsQuery = z.object({ - forceReload: z.stringbool().default(false) -}); - const setVisibilityBody = z.object({ insertableIds: z.array(z.string()), isVisible: z.boolean() @@ -43,24 +45,7 @@ const addGroupBody = z.object({ const deleteGroupQuery = z.object({ groupId: z.string().min(1) }); -/** POST /api/reload-groups/library/:libraryId?forceReload=true */ -groupRoutes.post( - "/reload-groups" + libraryRoute(), - requireEditorMiddleware, - validate("query", reloadGroupsQuery), - async (c) => { - const { forceReload } = c.req.valid("query"); - // The workflow owns the per-group version check — unchanged documents - // are skipped inside it (unless forceReload). - const status = await startReload(c.env, getLibraryParam(c), { - sessionId: getSessionId(c), - forceReload - }); - return c.json({ status }); - } -); - -/** GET /api/job-status/library/:libraryId — checked on load, then polled. */ +/** GET /api/job-status/library/:libraryId — checked on load, then pushed. */ groupRoutes.get( "/job-status" + libraryRoute(), requireEditorMiddleware, @@ -218,18 +203,22 @@ groupRoutes.post( } const groupId = crypto.randomUUID(); - - const instance = await c.env.ADD_GROUP_WORKFLOW.create({ - params: { - groupId, - documentId: body.newDocumentId, - documentName, + await createShellGroup(c.env, { + groupId, + documentId: body.newDocumentId, + documentName, + libraryId, + selectedGroupId: body.selectedGroupId + }); + await requestLoads(c.env, [ + { libraryId, + groupId, sessionId, - selectedGroupId: body.selectedGroupId + forceReload: false, + origin: new URL(c.req.url).origin } - }); - await trackJob(c.env, libraryId, "add-group", instance.id); + ]); return c.json({ name: documentName }); } @@ -247,11 +236,34 @@ groupRoutes.delete( const db = getDb(c.env.DB); // Cascade deletes insertables → favorites, and configurations automatically - await db + const [deleted] = await db .delete(groups) - .where( - and(eq(groups.id, groupId), eq(groups.libraryId, libraryId)) - ); + .where(and(eq(groups.id, groupId), eq(groups.libraryId, libraryId))) + .returning({ documentId: groups.documentId }); + + // The document's webhook goes with the last group loaded from it. + if (deleted) { + const stillUsed = await db + .select({ id: groups.id }) + .from(groups) + .where(eq(groups.documentId, deleted.documentId)) + .get(); + if (!stillUsed) { + // Logged rather than failing a delete that has happened: a + // webhook left behind reloads nothing, since no group matches. + await removeWebhook( + c.env, + await c.var.getOnshapeApi(), + WebhookSubject.DOCUMENT, + deleted.documentId + ).catch((error: unknown) => { + console.error( + `Failed to remove the webhook for ${deleted.documentId}`, + error + ); + }); + } + } await rebuildSearchDb(c.env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); diff --git a/src/backend/features/library/groups/routes.worker.test.ts b/src/backend/features/library/groups/routes.worker.test.ts index 2ee8b779b..9c85684bd 100644 --- a/src/backend/features/library/groups/routes.worker.test.ts +++ b/src/backend/features/library/groups/routes.worker.test.ts @@ -19,7 +19,7 @@ import type { JobStatus } from "../../load/contract"; import { searchIndexKey } from "../db"; import { SEARCH_OPTIONS, type SearchDocument } from "../../search/contract"; import * as DocumentsEndpoint from "../../../lib/onshape/endpoints/documents"; -import * as JobTracker from "../../load/job-tracker"; +import * as Jobs from "../../load/jobs"; const db = getDb(env.DB); @@ -182,67 +182,6 @@ describe("group admin routes", () => { }); }); -describe("POST /reload-groups", () => { - beforeEach(() => resetDb(db)); - afterEach(() => vi.restoreAllMocks()); - - // The "false" case is a regression test: z.coerce.boolean() reads the string - // "false" as true, so passing forceReload=false used to force a reload. - it.each([ - ["omitted", "", false], - ["false", "?forceReload=false", false], - ["true", "?forceReload=true", true] - ])( - "triggers one library workflow with forceReload %s", - async (_label, query, forceReload) => { - await seedGroup(db, TEST_GROUP_ID); - vi.spyOn(JobTracker, "isReloadRunning").mockResolvedValue(false); - const trackSpy = vi - .spyOn(JobTracker, "trackJob") - .mockResolvedValue(); - const createSpy = vi - .spyOn(env.LOAD_LIBRARY_WORKFLOW, "create") - .mockResolvedValue({ id: "wf" } as never); - - const res = await createTestApp().request( - `/api/reload-groups/library/${TEST_LIBRARY_ID}${query}`, - sessionRequest("POST"), - env - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ status: "triggered" }); - expect(createSpy).toHaveBeenCalledOnce(); - expect(createSpy.mock.calls[0][0]?.params).toEqual({ - libraryId: TEST_LIBRARY_ID, - sessionId: "test-session", - forceReload - }); - // The new run's instance id is tracked for the running-job checks. - expect(trackSpy).toHaveBeenCalledWith( - expect.anything(), - TEST_LIBRARY_ID, - "reload", - "wf" - ); - } - ); - - it("skips creating a workflow when a reload is already running", async () => { - await seedGroup(db, TEST_GROUP_ID); - vi.spyOn(JobTracker, "isReloadRunning").mockResolvedValue(true); - const createSpy = vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "create"); - - const res = await createTestApp().request( - `/api/reload-groups/library/${TEST_LIBRARY_ID}`, - sessionRequest("POST"), - env - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ status: "already-running" }); - expect(createSpy).not.toHaveBeenCalled(); - }); -}); - describe("GET /job-status", () => { beforeEach(() => resetDb(db)); afterEach(() => vi.restoreAllMocks()); @@ -251,7 +190,7 @@ describe("GET /job-status", () => { { running: true, runningForMs: 4_000 }, { running: false } ])("reports $running", async (status) => { - vi.spyOn(JobTracker, "getJobStatus").mockResolvedValue(status); + vi.spyOn(Jobs, "getJobStatus").mockResolvedValue(status); const res = await createTestApp().request( `/api/job-status/library/${TEST_LIBRARY_ID}`, @@ -260,7 +199,7 @@ describe("GET /job-status", () => { ); expect(res.status).toBe(200); expect(await res.json()).toEqual(status); - // Polled for live state, so it must never be served from a cache. + // Asked for live state, so it must never be served from a cache. expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); }); @@ -269,16 +208,13 @@ describe("POST /group", () => { beforeEach(() => resetDb(db)); afterEach(() => vi.restoreAllMocks()); - it("forwards selectedGroupId and triggers the workflow, without writing the group row itself", async () => { + it("writes the group after the selected one, and asks for its load", async () => { await seedGroup(db, TEST_GROUP_ID); // sortOrder 0 vi.spyOn(DocumentsEndpoint, "getDocument").mockResolvedValue({ id: "doc-new", name: "New Doc" }); - const createSpy = vi - .spyOn(env.ADD_GROUP_WORKFLOW, "create") - .mockResolvedValue({ id: "wf" } as never); - const trackSpy = vi.spyOn(JobTracker, "trackJob").mockResolvedValue(); + const loadSpy = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); const res = await createTestApp().request( `/api/group/library/${TEST_LIBRARY_ID}`, @@ -291,27 +227,24 @@ describe("POST /group", () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ name: "New Doc" }); - expect(createSpy).toHaveBeenCalledOnce(); - const params: unknown = createSpy.mock.calls[0][0]?.params; - expect(params).toMatchObject({ - groupId: expect.any(String), - documentId: "doc-new", - libraryId: TEST_LIBRARY_ID, - sessionId: "test-session", - selectedGroupId: TEST_GROUP_ID - }); - expect(trackSpy).toHaveBeenCalledWith( - expect.anything(), - TEST_LIBRARY_ID, - "add-group", - "wf" - ); - - // The route (the workflow is mocked here) doesn't compute or write sort - // order itself anymore — only the existing group exists, untouched. - const rows = await db.select().from(groups).all(); - expect(rows.map((r) => r.documentId)).toEqual([`doc-${TEST_GROUP_ID}`]); - expect(rows[0].sortOrder).toBe(0); + const rows = await db + .select() + .from(groups) + .orderBy(asc(groups.sortOrder)) + .all(); + expect(rows.map((row) => row.documentId)).toEqual([ + `doc-${TEST_GROUP_ID}`, + "doc-new" + ]); + expect(loadSpy).toHaveBeenCalledWith(expect.anything(), [ + { + libraryId: TEST_LIBRARY_ID, + groupId: rows[1].id, + sessionId: "test-session", + forceReload: false, + origin: "http://localhost" + } + ]); }); it("422s when the document was already added", async () => { @@ -320,9 +253,7 @@ describe("POST /group", () => { id: "doc-test-group", name: "Dup" }); - const createSpy = vi - .spyOn(env.ADD_GROUP_WORKFLOW, "create") - .mockResolvedValue({ id: "wf" } as never); + const loadSpy = vi.spyOn(Jobs, "requestLoads"); const res = await createTestApp().request( `/api/group/library/${TEST_LIBRARY_ID}`, @@ -330,6 +261,6 @@ describe("POST /group", () => { env ); expect(res.status).toBe(422); - expect(createSpy).not.toHaveBeenCalled(); + expect(loadSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 325f10bb1..37ac44bf6 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -7,12 +7,13 @@ import { type AppContext, getApp } from "../../../lib/context"; import type { BatchItem } from "drizzle-orm/batch"; import { getInsertableParam, insertableRoute } from "../../../lib/route-params"; import { getDb, type Db } from "../../../db/client"; -import { - requireEditorMiddleware, - requireSignInMiddleware -} from "../../auth/guards"; +import { requireEditor, requireSignInMiddleware } from "../../auth/guards"; import { insertables, configurations } from "../../../db/schema"; -import { bumpLibraryVersion, rebuildSearchDb } from "../db"; +import { + bumpLibraryVersion, + libraryOfInsertable, + rebuildSearchDb +} from "../db"; import { type InsertOut } from "../contract"; import { toElementPath, INSTANCE_TYPES } from "../../../lib/onshape/path"; import { @@ -51,6 +52,11 @@ import { addBuildIssue, clearBuildIssue } from "../../build-checker/issues"; export const insertableRoutes = getApp(); +/** An editor of the library the insertable in the path is in. */ +const requireInsertableEditor = requireEditor((c) => + libraryOfInsertable(getDb(c.env.DB), getInsertableParam(c)) +); + /** POST /api/toggle-insert-and-fasten/insertable/:insertableId */ const setFastenBody = z.object({ supportsFasten: z.boolean() }); @@ -62,7 +68,7 @@ const excludedParametersBody = z.object({ insertableRoutes.post( "/toggle-insert-and-fasten" + insertableRoute(), - requireEditorMiddleware, + requireInsertableEditor, validate("json", setFastenBody), async (c) => { const db = getDb(c.env.DB); @@ -106,7 +112,7 @@ insertableRoutes.post( /** POST /api/index-configurations/insertable/:insertableId */ insertableRoutes.post( "/index-configurations" + insertableRoute(), - requireEditorMiddleware, + requireInsertableEditor, validate("json", indexConfigurationsBody), async (c) => { const { indexConfigurations } = c.req.valid("json"); @@ -118,7 +124,7 @@ insertableRoutes.post( /** POST /api/excluded-parameters/insertable/:insertableId */ insertableRoutes.post( "/excluded-parameters" + insertableRoute(), - requireEditorMiddleware, + requireInsertableEditor, validate("json", excludedParametersBody), async (c) => { const { excludedParameterIds } = c.req.valid("json"); diff --git a/src/backend/features/live/contract.ts b/src/backend/features/live/contract.ts index 4943e2dd7..f9cf205b3 100644 --- a/src/backend/features/live/contract.ts +++ b/src/backend/features/live/contract.ts @@ -10,12 +10,13 @@ import type { ConfigurationKey } from "../configurations/contract"; export enum LiveMessageType { /** A library's load jobs started or finished. */ JOBS = "jobs", - /** A library's contents changed under a new cache version. */ + /** + * A library changed under a new cache version: its contents, or who is on + * its admin team. + */ LIBRARY = "library", /** A configuration's thumbnail finished rendering. */ - THUMBNAIL = "thumbnail", - /** Who is on the admin team changed, so access may have. */ - ACCESS = "access" + THUMBNAIL = "thumbnail" } export type LiveMessage = @@ -26,8 +27,7 @@ export type LiveMessage = elementId: string; microversionId: string; configurationKey: ConfigurationKey; - } - | { type: LiveMessageType.ACCESS }; + }; /** Where a client connects, naming the library it is showing. */ export const LIVE_PATH = "/api/live"; diff --git a/src/backend/features/live/live-updates.worker.test.ts b/src/backend/features/live/live-updates.worker.test.ts index 7773c2da3..044f45639 100644 --- a/src/backend/features/live/live-updates.worker.test.ts +++ b/src/backend/features/live/live-updates.worker.test.ts @@ -56,11 +56,17 @@ describe("live updates", () => { const frc = await connect(LibraryId.FRC_DESIGN_LIB); const ftc = await connect(LibraryId.FTC_DESIGN_LIB); - await stub().broadcast({ type: LiveMessageType.ACCESS }); + const message: LiveMessage = { + type: LiveMessageType.THUMBNAIL, + elementId: "e1", + microversionId: "mv1", + configurationKey: "" + }; + await stub().broadcast(message); await settle(); - expect(frc.received).toEqual([{ type: LiveMessageType.ACCESS }]); - expect(ftc.received).toEqual([{ type: LiveMessageType.ACCESS }]); + expect(frc.received).toEqual([message]); + expect(ftc.received).toEqual([message]); frc.socket.close(); ftc.socket.close(); }); diff --git a/src/backend/features/live/notify.ts b/src/backend/features/live/notify.ts index 6c64290d4..f38bdc860 100644 --- a/src/backend/features/live/notify.ts +++ b/src/backend/features/live/notify.ts @@ -54,7 +54,3 @@ export function pushThumbnailRendered( ): Promise<void> { return broadcast(env, { type: LiveMessageType.THUMBNAIL, ...subject }); } - -export function pushAccessChanged(env: AppBindings): Promise<void> { - return broadcast(env, { type: LiveMessageType.ACCESS }); -} diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index 2584c5f2c..b25dd381f 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -2,7 +2,7 @@ import type { WorkflowStep } from "cloudflare:workers"; import { createLimiter, type Limiter } from "../../lib/limiter"; import type { AppBindings } from "../../lib/context"; import { getOnshapeApiFromSessionId } from "../auth/request-auth"; -import type { OnshapeApi } from "../../lib/onshape/client"; +import type { OAuthApi } from "../../lib/onshape/client"; import type { ElementType } from "../../lib/onshape/element-type"; import type { LibraryId } from "../library/library-id"; import type { ElementPath, InstancePath } from "../../lib/onshape/path"; @@ -53,9 +53,7 @@ export function createLoadContext( }; } -export function getOnshapeApiFromContext( - ctx: LoadContext -): Promise<OnshapeApi> { +export function getOnshapeApiFromContext(ctx: LoadContext): Promise<OAuthApi> { return getOnshapeApiFromSessionId(ctx.env.KV, ctx.sessionId); } diff --git a/src/backend/features/load/job-tracker.ts b/src/backend/features/load/job-tracker.ts deleted file mode 100644 index 86ab67997..000000000 --- a/src/backend/features/load/job-tracker.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { AppBindings } from "../../lib/context"; -import type { LibraryId } from "../library/library-id"; -import type { JobStatus } from "./contract"; -import { pushJobStatus } from "../live/notify"; - -/** - * Backstop for a job that crashes before untracking itself; must outlast the - * longest load (reloads and large adds can run for hours). - */ -const JOB_TTL_SECONDS = 60 * 60 * 24; - -/** Instance statuses that mean a job is still live. */ -const ACTIVE_STATUSES = new Set([ - "queued", - "running", - "paused", - "waiting", - "waitingForPause" -]); - -type JobKind = "reload" | "add-group"; - -interface TrackedJob { - id: string; - kind: JobKind; - /** Epoch ms the job was created. */ - startedAt: number; -} - -function jobsKey(libraryId: LibraryId): string { - return `library-jobs:${libraryId}`; -} - -function workflowForKind(env: AppBindings, kind: JobKind) { - return kind === "reload" - ? env.LOAD_LIBRARY_WORKFLOW - : env.ADD_GROUP_WORKFLOW; -} - -/** Whether a tracked job's workflow instance is still live. */ -async function isJobActive( - env: AppBindings, - job: TrackedJob -): Promise<boolean> { - try { - const instance = await workflowForKind(env, job.kind).get(job.id); - const status = (await instance.status()).status; - return ACTIVE_STATUSES.has(status); - } catch { - return false; // Instance aged out of retention or never existed. - } -} - -async function readJobs( - env: AppBindings, - libraryId: LibraryId -): Promise<TrackedJob[]> { - const raw = await env.KV.get(jobsKey(libraryId)); - return raw ? (JSON.parse(raw) as TrackedJob[]) : []; -} - -/** The tracked jobs whose workflow is still running. */ -async function activeJobs( - env: AppBindings, - libraryId: LibraryId -): Promise<TrackedJob[]> { - const jobs = await readJobs(env, libraryId); - const live = await Promise.all(jobs.map((job) => isJobActive(env, job))); - return jobs.filter((_, i) => live[i]); -} - -/** Whether a reload is running — used to keep reloads a singleton per library. */ -export async function isReloadRunning( - env: AppBindings, - libraryId: LibraryId -): Promise<boolean> { - const jobs = await activeJobs(env, libraryId); - return jobs.some((job) => job.kind === "reload"); -} - -/** Reports the oldest running job's age, which paces the client's polling. */ -export async function getJobStatus( - env: AppBindings, - libraryId: LibraryId -): Promise<JobStatus> { - return statusOf(await activeJobs(env, libraryId)); -} - -function statusOf(jobs: TrackedJob[]): JobStatus { - if (jobs.length === 0) { - return { running: false }; - } - const startedAt = Math.min(...jobs.map((job) => job.startedAt)); - return { running: true, runningForMs: Date.now() - startedAt }; -} - -/** Records a newly-created job, pruning any that have since finished. */ -export async function trackJob( - env: AppBindings, - libraryId: LibraryId, - kind: JobKind, - instanceId: string -): Promise<void> { - const jobs = await activeJobs(env, libraryId); - jobs.push({ id: instanceId, kind, startedAt: Date.now() }); - await env.KV.put(jobsKey(libraryId), JSON.stringify(jobs), { - expirationTtl: JOB_TTL_SECONDS - }); - await pushJobStatus(env, libraryId, statusOf(jobs)); -} - -/** - * Called in the workflow's final step so running state clears promptly rather - * than waiting out the TTL. Touches only its own entry. - */ -export async function untrackJob( - env: AppBindings, - libraryId: LibraryId, - instanceId: string -): Promise<void> { - const remaining = (await readJobs(env, libraryId)).filter( - (job) => job.id !== instanceId - ); - if (remaining.length === 0) { - await env.KV.delete(jobsKey(libraryId)); - } else { - await env.KV.put(jobsKey(libraryId), JSON.stringify(remaining), { - expirationTtl: JOB_TTL_SECONDS - }); - } - await pushJobStatus(env, libraryId, await getJobStatus(env, libraryId)); -} diff --git a/src/backend/features/load/job-tracker.worker.test.ts b/src/backend/features/load/job-tracker.worker.test.ts deleted file mode 100644 index 4faa39ebd..000000000 --- a/src/backend/features/load/job-tracker.worker.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { env } from "cloudflare:workers"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getJobStatus, - isReloadRunning, - trackJob, - untrackJob -} from "./job-tracker"; -import { TEST_LIBRARY_ID } from "../../../__test_utils__"; - -interface Job { - id: string; - kind: "reload" | "add-group"; - startedAt?: number; -} - -/** Stubs the stored jobs array and every instance's live status. */ -function mockJobs(jobs: Job[], status?: string) { - vi.spyOn(env.KV, "get").mockResolvedValue(JSON.stringify(jobs) as never); - const instance = { status: () => Promise.resolve({ status }) } as never; - vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "get").mockResolvedValue(instance); - vi.spyOn(env.ADD_GROUP_WORKFLOW, "get").mockResolvedValue(instance); -} - -describe("job-tracker", () => { - afterEach(() => vi.restoreAllMocks()); - - it("reports nothing running when no jobs are stored", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue(null as never); - expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(false); - expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ - running: false - }); - }); - - it.each(["queued", "running", "waiting", "paused", "waitingForPause"])( - "counts a %s reload as running", - async (status) => { - mockJobs([{ id: "r1", kind: "reload" }], status); - expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(true); - expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe( - true - ); - } - ); - - it.each(["complete", "errored", "terminated", "unknown"])( - "counts a %s reload as finished", - async (status) => { - mockJobs([{ id: "r1", kind: "reload" }], status); - expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(false); - expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe( - false - ); - } - ); - - it("a running add-group counts as any-job but not as a reload", async () => { - mockJobs([{ id: "a1", kind: "add-group" }], "running"); - expect(await isReloadRunning(env, TEST_LIBRARY_ID)).toBe(false); - expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe(true); - }); - - it("treats an aged-out instance as finished", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue( - JSON.stringify([{ id: "r1", kind: "reload" }]) as never - ); - vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "get").mockRejectedValue( - new Error("not found") - ); - expect((await getJobStatus(env, TEST_LIBRARY_ID)).running).toBe(false); - }); - - it("reports how long the oldest running job has been going", async () => { - const now = Date.now(); - mockJobs( - [ - { id: "r1", kind: "reload", startedAt: now - 30_000 }, - { id: "a1", kind: "add-group", startedAt: now - 5_000 } - ], - "running" - ); - - const status = await getJobStatus(env, TEST_LIBRARY_ID); - expect(status.running).toBe(true); - if (!status.running) return; - expect(status.runningForMs).toBeGreaterThanOrEqual(30_000); - expect(status.runningForMs).toBeLessThan(40_000); - }); - - it("appends a tracked job under the library key with a TTL", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue(null as never); - const putSpy = vi.spyOn(env.KV, "put").mockResolvedValue(); - - await trackJob(env, TEST_LIBRARY_ID, "reload", "r1"); - - const [key, value, options] = putSpy.mock.calls[0]; - expect(key).toBe(`library-jobs:${TEST_LIBRARY_ID}`); - expect(JSON.parse(value as string)).toEqual([ - { id: "r1", kind: "reload", startedAt: expect.any(Number) } - ]); - expect(options).toEqual( - expect.objectContaining({ expirationTtl: expect.any(Number) }) - ); - }); - - it("untrackJob removes only its own entry, keeping others", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue( - JSON.stringify([ - { id: "r1", kind: "reload" }, - { id: "a1", kind: "add-group" } - ]) as never - ); - const putSpy = vi.spyOn(env.KV, "put").mockResolvedValue(); - - await untrackJob(env, TEST_LIBRARY_ID, "r1"); - - expect(putSpy).toHaveBeenCalledWith( - `library-jobs:${TEST_LIBRARY_ID}`, - JSON.stringify([{ id: "a1", kind: "add-group" }]), - expect.objectContaining({ expirationTtl: expect.any(Number) }) - ); - }); - - it("untrackJob deletes the key once the last job finishes", async () => { - vi.spyOn(env.KV, "get").mockResolvedValue( - JSON.stringify([{ id: "r1", kind: "reload" }]) as never - ); - const deleteSpy = vi.spyOn(env.KV, "delete").mockResolvedValue(); - - await untrackJob(env, TEST_LIBRARY_ID, "r1"); - - expect(deleteSpy).toHaveBeenCalledWith( - `library-jobs:${TEST_LIBRARY_ID}` - ); - }); -}); diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts new file mode 100644 index 000000000..719998b19 --- /dev/null +++ b/src/backend/features/load/jobs.ts @@ -0,0 +1,249 @@ +/** + * Document loads, one per group at a time. A load asked for while one runs is + * marked on the running one's row instead, and that load starts it as it + * finishes: two loads writing one group's rows at once would interleave, and + * the second may well be the one with the newer version to load. + * + * Rows in D1 rather than a list in KV, since loads start and finish + * concurrently — a full reload starts one per document — and KV loses writes + * that race. + */ +import { and, count, eq, inArray, min } from "drizzle-orm"; +import type { BatchItem } from "drizzle-orm/batch"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { chunkForInArray } from "../../db/chunk"; +import { loadJobs } from "../../db/schema"; +import { bumpLibraryVersion, rebuildSearchDb } from "../library/db"; +import type { LibraryId } from "../library/library-id"; +import { pushJobStatus, pushLibraryChanged } from "../live/notify"; +import type { JobStatus } from "./contract"; + +export interface LoadDocumentParams { + libraryId: LibraryId; + groupId: string; + /** Whose Onshape session the load calls Onshape with. */ + sessionId: string; + /** Reloads insertables whose version has not changed, too. */ + forceReload: boolean; + /** This deployment's, which the document's webhook is delivered to. */ + origin: string; +} + +/** Instance statuses that mean a load is still live. */ +const ACTIVE_STATUSES = new Set<InstanceStatus["status"]>([ + "queued", + "running", + "paused", + "waiting", + "waitingForPause" +]); + +/** + * How long a claimed row may go without an instance before it is taken for + * one whose start failed partway. + */ +const CLAIM_GRACE_MS = 60_000; + +/** Workflows create at most this many instances a call. */ +const CREATE_BATCH = 100; + +/** Five columns a row, under D1's 100 bound parameters a statement. */ +const ROWS_PER_INSERT = 15; + +type LoadJob = typeof loadJobs.$inferSelect; + +/** Whether a row's load is still running; one that crashed left its row behind. */ +async function isAlive(env: AppBindings, job: LoadJob): Promise<boolean> { + if (!job.instanceId) { + return Date.now() - job.startedAt.getTime() < CLAIM_GRACE_MS; + } + try { + const instance = await env.LOAD_DOCUMENT_WORKFLOW.get(job.instanceId); + return ACTIVE_STATUSES.has((await instance.status()).status); + } catch { + return false; // Aged out of retention, or never created. + } +} + +/** Clears the rows of loads that are no longer running. */ +async function clearDead( + env: AppBindings, + jobs: LoadJob[] +): Promise<LoadJob[]> { + const alive = await Promise.all(jobs.map((job) => isAlive(env, job))); + const dead = jobs.filter((_, i) => !alive[i]).map((job) => job.groupId); + const db = getDb(env.DB); + for (const groupIds of chunkForInArray(dead)) { + await db.delete(loadJobs).where(inArray(loadJobs.groupId, groupIds)); + } + return jobs.filter((_, i) => alive[i]); +} + +/** Starts a load of each group, or marks it to run again after its current one. */ +export async function requestLoads( + env: AppBindings, + requests: LoadDocumentParams[] +): Promise<void> { + const db = getDb(env.DB); + const byGroup = new Map( + requests.map((request) => [request.groupId, request]) + ); + const groupIds = [...byGroup.keys()]; + + const existing: LoadJob[] = []; + for (const chunk of chunkForInArray(groupIds)) { + existing.push( + ...(await db + .select() + .from(loadJobs) + .where(inArray(loadJobs.groupId, chunk))) + ); + } + const running = new Set( + (await clearDead(env, existing)).map((job) => job.groupId) + ); + + // Running already: its load starts this one as it finishes. + const queued = requests.filter((request) => running.has(request.groupId)); + const writes: BatchItem<"sqlite">[] = queued.map((request) => + db + .update(loadJobs) + .set({ + rerun: true, + // Once asked for, a forced reload is not downgraded. + ...(request.forceReload ? { rerunForce: true } : {}) + }) + .where(eq(loadJobs.groupId, request.groupId)) + ); + + const toStart = requests + .filter((request) => !running.has(request.groupId)) + .map((params) => ({ id: crypto.randomUUID(), params })); + const startedAt = new Date(); + for (let i = 0; i < toStart.length; i += ROWS_PER_INSERT) { + writes.push( + db + .insert(loadJobs) + .values( + toStart.slice(i, i + ROWS_PER_INSERT).map((start) => ({ + groupId: start.params.groupId, + libraryId: start.params.libraryId, + instanceId: start.id, + startedAt + })) + ) + .onConflictDoNothing() + ); + } + if (writes.length > 0) { + await db.batch( + writes as [BatchItem<"sqlite">, ...BatchItem<"sqlite">[]] + ); + } + + for (let i = 0; i < toStart.length; i += CREATE_BATCH) { + await env.LOAD_DOCUMENT_WORKFLOW.createBatch( + toStart.slice(i, i + CREATE_BATCH) + ); + } + + const libraryIds = new Set(requests.map((request) => request.libraryId)); + for (const libraryId of libraryIds) { + await pushJobStatus( + env, + libraryId, + await runningStatus(env, libraryId) + ); + } +} + +/** What a finished load does next: nothing, or its group's queued load. */ +type FinishOutcome = "done" | "rerun"; + +/** + * Called by a load as it finishes, whether it failed or not. Starts the load + * queued behind it; otherwise lets the group go, and the last load in the + * library rebuilds its search index, once rather than per document. + */ +export async function finishLoad( + env: AppBindings, + params: LoadDocumentParams +): Promise<FinishOutcome> { + const db = getDb(env.DB); + const job = await db + .select() + .from(loadJobs) + .where(eq(loadJobs.groupId, params.groupId)) + .get(); + + let outcome: FinishOutcome = "done"; + if (job?.rerun) { + const instanceId = crypto.randomUUID(); + await db + .update(loadJobs) + .set({ + instanceId, + startedAt: new Date(), + rerun: false, + rerunForce: false + }) + .where(eq(loadJobs.groupId, params.groupId)); + await env.LOAD_DOCUMENT_WORKFLOW.create({ + id: instanceId, + params: { ...params, forceReload: job.rerunForce } + }); + outcome = "rerun"; + } else { + await db.delete(loadJobs).where(eq(loadJobs.groupId, params.groupId)); + } + + const status = await runningStatus(env, params.libraryId); + // Rebuilt before the bump: the new version makes /search-db immutable, so + // a client fetching in between would pin the stale index for a year. A + // load that is not the last only bumps, so the library shows its document + // meanwhile, and whatever search that version pins is corrected by the + // last load's. + if (!status.running) { + await rebuildSearchDb(env.BLOB, db, params.libraryId); + } + await bumpLibraryVersion(db, params.libraryId); + await pushLibraryChanged(env, params.libraryId); + await pushJobStatus(env, params.libraryId, status); + return outcome; +} + +/** Whether loads are running, from the rows alone, trusting each to be live. */ +async function runningStatus( + env: AppBindings, + libraryId: LibraryId +): Promise<JobStatus> { + const row = await getDb(env.DB) + .select({ running: count(), startedAt: min(loadJobs.startedAt) }) + .from(loadJobs) + .where(eq(loadJobs.libraryId, libraryId)) + .get(); + if (!row?.running || !row.startedAt) { + return { running: false }; + } + return { + running: true, + runningForMs: Date.now() - row.startedAt.getTime() + }; +} + +/** + * Whether loads are running, clearing any left behind by a load that crashed + * first. For the client's first look; pushes keep it current after that. + */ +export async function getJobStatus( + env: AppBindings, + libraryId: LibraryId +): Promise<JobStatus> { + const jobs = await getDb(env.DB) + .select() + .from(loadJobs) + .where(and(eq(loadJobs.libraryId, libraryId))); + await clearDead(env, jobs); + return runningStatus(env, libraryId); +} diff --git a/src/backend/features/load/jobs.worker.test.ts b/src/backend/features/load/jobs.worker.test.ts new file mode 100644 index 000000000..0ec1b5489 --- /dev/null +++ b/src/backend/features/load/jobs.worker.test.ts @@ -0,0 +1,137 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TEST_LIBRARY_ID, resetDb, seedGroup } from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { loadJobs } from "../../db/schema"; +import * as LibraryDb from "../library/db"; +import { + finishLoad, + getJobStatus, + requestLoads, + type LoadDocumentParams +} from "./jobs"; + +const db = getDb(env.DB); + +function params(groupId: string, forceReload = false): LoadDocumentParams { + return { + libraryId: TEST_LIBRARY_ID, + groupId, + sessionId: "session", + forceReload, + origin: "https://app.example.com" + }; +} + +/** Every instance reports `status`, as far as the jobs can tell. */ +function instancesAre(status: InstanceStatus["status"]) { + vi.spyOn(env.LOAD_DOCUMENT_WORKFLOW, "get").mockResolvedValue({ + status: () => Promise.resolve({ status }) + } as never); +} + +const job = (groupId: string) => + db + .select() + .from(loadJobs) + .all() + .then((rows) => rows.find((row) => row.groupId === groupId)); + +describe("document loads", () => { + beforeEach(async () => { + await resetDb(db); + await seedGroup(db, "a"); + await seedGroup(db, "b"); + }); + afterEach(() => vi.restoreAllMocks()); + + it("starts a load per group, all in one batch", async () => { + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") + .mockResolvedValue([]); + + await requestLoads(env, [params("a"), params("b")]); + + expect(create).toHaveBeenCalledOnce(); + const started = create.mock.calls[0][0]; + expect(started.map((start) => start.params?.groupId)).toEqual([ + "a", + "b" + ]); + expect((await job("a"))?.instanceId).toBe(started[0].id); + instancesAre("running"); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toMatchObject({ + running: true + }); + }); + + // Two loads writing one group's rows at once would interleave. + it("queues a load behind the group's running one, keeping it forced", async () => { + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") + .mockResolvedValue([]); + await requestLoads(env, [params("a")]); + instancesAre("running"); + + await requestLoads(env, [params("a", true)]); + await requestLoads(env, [params("a", false)]); + + expect(create).toHaveBeenCalledOnce(); + expect(await job("a")).toMatchObject({ rerun: true, rerunForce: true }); + }); + + it("replaces the row of a load that crashed", async () => { + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") + .mockResolvedValue([]); + await requestLoads(env, [params("a")]); + instancesAre("errored"); + + await requestLoads(env, [params("a")]); + + expect(create).toHaveBeenCalledTimes(2); + }); + + describe("finishing", () => { + beforeEach(() => { + vi.spyOn( + env.LOAD_DOCUMENT_WORKFLOW, + "createBatch" + ).mockResolvedValue([]); + }); + + it("starts the load queued behind it", async () => { + await requestLoads(env, [params("a")]); + instancesAre("running"); + await requestLoads(env, [params("a", true)]); + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "create") + .mockResolvedValue({ id: "next" } as never); + + expect(await finishLoad(env, params("a"))).toBe("rerun"); + + expect(create.mock.calls[0][0]?.params).toMatchObject({ + groupId: "a", + forceReload: true + }); + expect(await job("a")).toMatchObject({ rerun: false }); + }); + + // Once per library rather than once per document. + it("rebuilds search only as the library's last load finishes", async () => { + const rebuild = vi + .spyOn(LibraryDb, "rebuildSearchDb") + .mockResolvedValue(""); + await requestLoads(env, [params("a"), params("b")]); + + await finishLoad(env, params("a")); + expect(rebuild).not.toHaveBeenCalled(); + + await finishLoad(env, params("b")); + expect(rebuild).toHaveBeenCalledOnce(); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + running: false + }); + }); + }); +}); diff --git a/src/backend/features/load/reload.ts b/src/backend/features/load/reload.ts deleted file mode 100644 index 7f82e3279..000000000 --- a/src/backend/features/load/reload.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Starting a library reload, for the admin button and for Onshape's webhooks. - * A library runs one reload at a time; a document a webhook names while one is - * running is queued for when it ends, since the running one may have checked - * that document before its new version landed. - */ -import type { AppBindings } from "../../lib/context"; -import { getDb } from "../../db/client"; -import { ensureLibrary } from "../library/db"; -import type { LibraryId } from "../library/library-id"; -import { getOwnerSessionId } from "../auth/owner"; -import { isReloadRunning, trackJob } from "./job-tracker"; - -export interface ReloadRequest { - /** Whose Onshape session the reload calls Onshape with. */ - sessionId: string; - forceReload?: boolean; - /** Only the groups loaded from these; every group when absent. */ - documentIds?: string[]; -} - -export type ReloadOutcome = "triggered" | "already-running"; - -export async function startReload( - env: AppBindings, - libraryId: LibraryId, - request: ReloadRequest -): Promise<ReloadOutcome> { - // Racy under a sub-second double trigger (KV has no compare-and-swap), - // which is fine here. - if (await isReloadRunning(env, libraryId)) { - return "already-running"; - } - await ensureLibrary(getDb(env.DB), libraryId); - const instance = await env.LOAD_LIBRARY_WORKFLOW.create({ - params: { libraryId, ...request } - }); - await trackJob(env, libraryId, "reload", instance.id); - return "triggered"; -} - -function queueKey(libraryId: LibraryId): string { - return `queued-reload:${libraryId}`; -} - -/** Long enough to outlast any one reload, which is what drains the queue. */ -const QUEUE_TTL_SECONDS = 60 * 60 * 24; - -/** - * Reloads the groups loaded from `documentIds` under the owner's session, now - * or once the reload already running ends. - */ -export async function queueReload( - env: AppBindings, - libraryId: LibraryId, - documentIds: string[] -): Promise<void> { - const sessionId = await getOwnerSessionId(env.KV); - if (!sessionId) { - console.warn( - `No owner session to reload ${libraryId} with; the owner has not used the app yet.` - ); - return; - } - const outcome = await startReload(env, libraryId, { - sessionId, - documentIds - }); - if (outcome === "already-running") { - const queued = await readQueue(env, libraryId); - await env.KV.put( - queueKey(libraryId), - JSON.stringify([...new Set([...queued, ...documentIds])]), - { expirationTtl: QUEUE_TTL_SECONDS } - ); - } -} - -async function readQueue( - env: AppBindings, - libraryId: LibraryId -): Promise<string[]> { - const raw = await env.KV.get(queueKey(libraryId)); - return raw ? (JSON.parse(raw) as string[]) : []; -} - -/** - * Starts whatever was queued while a reload ran. Called by that reload once it - * no longer counts as running, so the reload this starts is not turned away. - */ -export async function startQueuedReload( - env: AppBindings, - libraryId: LibraryId -): Promise<void> { - const documentIds = await readQueue(env, libraryId); - if (documentIds.length === 0) { - return; - } - await env.KV.delete(queueKey(libraryId)); - await queueReload(env, libraryId, documentIds); -} diff --git a/src/backend/features/load/routes.ts b/src/backend/features/load/routes.ts new file mode 100644 index 000000000..89fe7d8e8 --- /dev/null +++ b/src/backend/features/load/routes.ts @@ -0,0 +1,32 @@ +import { getApp } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { groups } from "../../db/schema"; +import { requireOwnerMiddleware } from "../auth/guards"; +import { getSessionId } from "../auth/session"; +import { requestLoads } from "./jobs"; + +export const loadRoutes = getApp(); + +/** + * POST /api/reload-all — force reloads every document in every library, one + * load per group. New versions reload themselves through their webhooks, so + * this is for what they cannot catch: a change in how the app reads documents, + * or a document that has never been loaded with a webhook to register. + */ +loadRoutes.post("/reload-all", requireOwnerMiddleware, async (c) => { + const sessionId = getSessionId(c); + const origin = new URL(c.req.url).origin; + const allGroups = await getDb(c.env.DB) + .select({ groupId: groups.id, libraryId: groups.libraryId }) + .from(groups); + await requestLoads( + c.env, + allGroups.map((group) => ({ + ...group, + sessionId, + forceReload: true, + origin + })) + ); + return c.json({ documents: allGroups.length }); +}); diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 73af0c351..0fc472e3a 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -3,20 +3,23 @@ import { type WorkflowEvent, type WorkflowStep } from "cloudflare:workers"; -import { and, eq, inArray } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { AppBindings } from "../../lib/context"; import { getDb } from "../../db/client"; import type { LibraryId } from "../library/library-id"; import { bumpLibraryVersion, ensureLibrary, - placeNewGroup, - rebuildSearchDb + placeNewGroup } from "../library/db"; import { getDocument } from "../../lib/onshape/endpoints/documents"; import { getLatestVersion } from "../../lib/onshape/endpoints/versions"; import type { InstancePath } from "../../lib/onshape/path"; -import { groups, PLACEHOLDER_VERSION_ID } from "../../db/schema"; +import { + groups, + PLACEHOLDER_VERSION_ID, + WebhookSubject +} from "../../db/schema"; import { addBuildIssue, type BuildIssue, @@ -30,171 +33,120 @@ import { createLoadContext, getOnshapeApiFromContext } from "./context"; -import { untrackJob } from "./job-tracker"; -import { startQueuedReload } from "./reload"; +import { finishLoad, type LoadDocumentParams } from "./jobs"; import { pushLibraryChanged } from "../live/notify"; import { loadGroup } from "./load-group"; import { ONSHAPE_STEP_RETRIES } from "./steps"; -import { reconcileThumbnails } from "../thumbnails/reconcile"; - -export interface LoadLibraryParams { - libraryId: LibraryId; - sessionId: string; - forceReload?: boolean; - /** Only the groups loaded from these; every group when absent. */ - documentIds?: string[]; -} +import { ensureWebhook } from "../webhooks/registration"; -/** The outcome of loading a single group within a run. */ -type GroupResult = - | { groupId: string; status: "skipped" | "failed" } +/** What a load did with its group. */ +export type LoadResult = + | { status: "skipped" | "failed" | "gone" } | { - groupId: string; - status: "created" | "reloaded"; + status: "loaded"; loadedElements: number; deletedElements: number; failedElements: number; }; /** - * Reloads every group in a library whose document has a new version (or all of - * them, on forceReload), then rebuilds the search index once at the end. - */ -export class LoadLibraryWorkflow extends WorkflowEntrypoint< - AppBindings, - LoadLibraryParams -> { - async run( - event: WorkflowEvent<LoadLibraryParams>, - step: WorkflowStep - ): Promise<GroupResult[]> { - const { - libraryId, - sessionId, - forceReload = false, - documentIds - } = event.payload; - const ctx = createLoadContext(this.env, sessionId, step); - - const storedGroups = await step.do("list-groups", () => - getDb(ctx.env.DB) - .select({ - groupId: groups.id, - documentId: groups.documentId, - versionId: groups.versionId, - buildIssues: groups.buildIssues - }) - .from(groups) - .where( - and( - eq(groups.libraryId, libraryId), - documentIds - ? inArray(groups.documentId, documentIds) - : undefined - ) - ) - ); - - const results = await Promise.all( - storedGroups.map(async (storedGroup): Promise<GroupResult> => { - const { groupId, documentId } = storedGroup; - try { - const target = await resolveGroupTarget( - ctx, - { libraryId, groupId, documentId }, - `-${groupId}` - ); - if ( - storedGroup.versionId === - target.versionPath.instanceId && - !forceReload && - !hasFailedLoad(storedGroup.buildIssues) - ) { - return { groupId, status: "skipped" }; - } - const loaded = await loadGroup(ctx, target, forceReload); - return { groupId, status: "reloaded", ...loaded }; - } catch (error) { - // The only record of why: the group row stores that it - // failed, never what failed. - console.error(`Failed to load group ${groupId}`, error); - await ctx.step.do(`flag-failed-${groupId}`, () => - flagFailedGroup(ctx.env, groupId) - ); - return { groupId, status: "failed" }; - } - }) - ); - - await step.do("finalize", () => finalizeLibrary(ctx.env, libraryId)); - // Last, so every group that was going to write rows has. A group that - // failed kept its old rows, so its thumbnails still read as live. - await step.do("reconcile-thumbnails", () => - reconcileThumbnails(ctx.env.BLOB, getDb(ctx.env.DB)) - ); - await step.do("untrack-job", () => - untrackJob(ctx.env, libraryId, event.instanceId) - ); - await step.do("start-queued-reload", () => - startQueuedReload(ctx.env, libraryId) - ); - - return results; - } -} - -export interface AddGroupParams { - /** The new group's id, minted by the route. */ - groupId: string; - documentId: string; - /** The document's name, already fetched by the route. */ - documentName: string; - libraryId: LibraryId; - sessionId: string; - /** An existing group to place the new group after. */ - selectedGroupId?: string; -} - -/** - * Adds an Onshape document to a library by inserting and then loading it. + * Loads one group's document: when its version has moved on, or always on a + * forced reload. New versions, added documents and forced reloads all come + * through here, one instance per group at a time; see `jobs.ts`. */ -export class AddGroupWorkflow extends WorkflowEntrypoint< +export class LoadDocumentWorkflow extends WorkflowEntrypoint< AppBindings, - AddGroupParams + LoadDocumentParams > { async run( - event: WorkflowEvent<AddGroupParams>, + event: WorkflowEvent<LoadDocumentParams>, step: WorkflowStep - ): Promise<GroupResult> { + ): Promise<LoadResult> { const params = event.payload; const ctx = createLoadContext(this.env, params.sessionId, step); + try { + return await loadDocument(ctx, params); + } finally { + // Whatever happened, so the group is let go and whatever queued + // behind this load starts. + await step.do("finish", () => finishLoad(this.env, params)); + } + } +} - // Written before anything can fail, so an add that dies partway leaves a - // group the library still shows and an editor can retry or delete. - await step.do("create-shell-group", () => - createShellGroup(ctx.env, params) - ); +async function loadDocument( + ctx: LoadContext, + params: LoadDocumentParams +): Promise<LoadResult> { + const { groupId, libraryId, forceReload } = params; + const stored = await ctx.step.do("read-group", () => + getDb(ctx.env.DB) + .select({ + documentId: groups.documentId, + versionId: groups.versionId, + buildIssues: groups.buildIssues + }) + .from(groups) + .where(eq(groups.id, groupId)) + .get() + ); + // Deleted since the load was asked for. + if (!stored) { + return { status: "gone" }; + } - let result: GroupResult; - try { - const target = await resolveGroupTarget(ctx, params, ""); - const loaded = await loadGroup(ctx, target, false); - result = { groupId: params.groupId, status: "created", ...loaded }; - } catch { - await step.do("flag-failed-group", () => - flagFailedGroup(ctx.env, params.groupId) - ); - result = { groupId: params.groupId, status: "failed" }; + let result: LoadResult; + try { + const target = await resolveGroupTarget(ctx, { + libraryId, + groupId, + documentId: stored.documentId + }); + if ( + stored.versionId === target.versionPath.instanceId && + !forceReload && + !hasFailedLoad(stored.buildIssues) + ) { + result = { status: "skipped" }; + } else { + result = { + status: "loaded", + ...(await loadGroup(ctx, target, forceReload)) + }; } + } catch (error) { + // The only record of why: the group row stores that it failed, never + // what failed. + console.error(`Failed to load group ${groupId}`, error); + await ctx.step.do("flag-failed", () => + flagFailedGroup(ctx.env, groupId) + ); + result = { status: "failed" }; + } - await step.do("finalize", () => - finalizeLibrary(ctx.env, params.libraryId) + // After the load rather than before, so a document that cannot be read + // does not get a webhook. Its failure is logged rather than failing the + // load: the document is loaded either way, and the next load tries again. + try { + await ctx.step.do( + "register-webhook", + { retries: ONSHAPE_STEP_RETRIES }, + async () => + ensureWebhook( + ctx.env, + await getOnshapeApiFromContext(ctx), + WebhookSubject.DOCUMENT, + stored.documentId, + params.origin + ) ); - await step.do("untrack-job", () => - untrackJob(ctx.env, params.libraryId, event.instanceId) + } catch (error) { + console.error( + `Failed to register a webhook for ${stored.documentId}`, + error ); - return result; } + return result; } /** @@ -215,12 +167,11 @@ function hasFailedLoad(buildIssues: BuildIssue[]): boolean { /** Reads the document and its latest version, pinning the group to that version. */ async function resolveGroupTarget( ctx: LoadContext, - ids: { libraryId: LibraryId; groupId: string; documentId: string }, - stepSuffix: string + ids: { libraryId: LibraryId; groupId: string; documentId: string } ): Promise<GroupTarget> { const { documentId } = ids; const document = await ctx.step.do( - `document${stepSuffix}`, + "document", { retries: ONSHAPE_STEP_RETRIES }, async () => getDocument(await getOnshapeApiFromContext(ctx), { documentId }) @@ -228,7 +179,7 @@ async function resolveGroupTarget( // The step hands back what Onshape sent, `createdAt` still an ISO string: // a step's result is persisted for replay, which a Date does not survive. const version = await ctx.step.do( - `version${stepSuffix}`, + "version", { retries: ONSHAPE_STEP_RETRIES }, async () => getLatestVersion(await getOnshapeApiFromContext(ctx), { @@ -251,13 +202,24 @@ async function resolveGroupTarget( }; } +export interface ShellGroup { + groupId: string; + documentId: string; + /** The document's name, already fetched by the route. */ + documentName: string; + libraryId: LibraryId; + /** An existing group to place the new group after. */ + selectedGroupId?: string; +} + /** - * Writes the group row the load then fills in, creating the library if this is - * its first groups. Exported for its tests. + * Writes the group row a load then fills in, creating the library if this is + * its first group. Written before the load is asked for, so an add whose load + * fails still leaves a group an editor can see, retry or delete. */ export async function createShellGroup( env: AppBindings, - params: AddGroupParams + params: ShellGroup ): Promise<void> { const db = getDb(env.DB); await ensureLibrary(db, params.libraryId); @@ -312,14 +274,3 @@ async function flagFailedGroup( }) .where(eq(groups.id, groupId)); } - -/** Rebuild the library's search index and bump its cache version. */ -async function finalizeLibrary( - env: AppBindings, - libraryId: LibraryId -): Promise<void> { - const db = getDb(env.DB); - await rebuildSearchDb(env.BLOB, db, libraryId); - await bumpLibraryVersion(db, libraryId); - await pushLibraryChanged(env, libraryId); -} diff --git a/src/backend/features/load/workflows.worker.test.ts b/src/backend/features/load/workflows.worker.test.ts index 708bafe6d..cd59a6086 100644 --- a/src/backend/features/load/workflows.worker.test.ts +++ b/src/backend/features/load/workflows.worker.test.ts @@ -9,16 +9,15 @@ import { resetDb, seedGroup } from "../../../__test_utils__"; -import { createShellGroup, type AddGroupParams } from "./workflows"; +import { createShellGroup, type ShellGroup } from "./workflows"; const db = getDb(env.DB); -const PARAMS: AddGroupParams = { +const PARAMS: ShellGroup = { groupId: "new-group", documentId: "doc-new", documentName: "New Doc", - libraryId: TEST_LIBRARY_ID, - sessionId: "test-session" + libraryId: TEST_LIBRARY_ID }; function readVersion(): Promise<number | undefined> { diff --git a/src/backend/features/thumbnails/reconcile.ts b/src/backend/features/thumbnails/reconcile.ts index 9a3ba957f..041fe3830 100644 --- a/src/backend/features/thumbnails/reconcile.ts +++ b/src/backend/features/thumbnails/reconcile.ts @@ -29,9 +29,8 @@ const MAX_PAGES = 50; * stored before the row naming it is written — a group load uploads as it goes * and commits its rows at the end, and a configuration render is started by a * user opening the insert menu, outside any job this could wait on. Either one - * would look orphaned while it is in flight. Matched to the job TTL, since that - * is the longest a load is expected to take; anything genuinely orphaned is - * simply collected by a later reload instead. + * would look orphaned while it is in flight. A day, the longest a load is + * expected to take; anything genuinely orphaned is collected by a later run. */ const MIN_AGE_MS = 24 * 60 * 60 * 1000; diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index e9e23d9f0..0e6836bf4 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -9,7 +9,8 @@ import { RenderSource, ThumbnailSize } from "./contract"; import { thumbnailKey } from "./keys"; import { DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; import { requestRender } from "./render"; -import { requireEditorMiddleware } from "../auth/guards"; +import { requireEditor } from "../auth/guards"; +import { libraryOfGroup, libraryOfInsertable } from "../library/db"; import { getDb } from "../../db/client"; import { reloadGroupThumbnail, reloadInsertableThumbnail } from "./reload"; @@ -127,6 +128,16 @@ const reloadThumbnailBody = z.object({ insertableId: z.string().min(1).optional() }); +/** An editor of the library whose group or insertable the body names. */ +const requireThumbnailEditor = requireEditor(async (c) => { + const body = await c.req.json<z.infer<typeof reloadThumbnailBody>>(); + const db = getDb(c.env.DB); + if (body.insertableId) { + return libraryOfInsertable(db, body.insertableId); + } + return body.groupId ? libraryOfGroup(db, body.groupId) : undefined; +}); + /** * POST /api/reload-thumbnail * @@ -137,7 +148,7 @@ const reloadThumbnailBody = z.object({ */ thumbnailRoutes.post( "/reload-thumbnail", - requireEditorMiddleware, + requireThumbnailEditor, validate("json", reloadThumbnailBody), async (c) => { const { groupId, insertableId } = c.req.valid("json"); diff --git a/src/backend/features/webhooks/registration.ts b/src/backend/features/webhooks/registration.ts index fb9f2ea6f..20b2264cb 100644 --- a/src/backend/features/webhooks/registration.ts +++ b/src/backend/features/webhooks/registration.ts @@ -1,18 +1,18 @@ /** - * Keeps this deployment's Onshape webhook registered, on the owner's behalf: - * Onshape creates webhooks with a user's token, and events for the whole - * company want one of its admins, which the owner is taken to be. Checked - * whenever the owner's access is resolved, so it needs no one to set it up and - * comes back by itself if Onshape drops it. + * The Onshape webhooks this deployment registers: one per library document, + * for its new versions, and one per admin team, for its members. Each is + * registered with `isTransient: false`, which Onshape documents as exempting + * it from cleanup, so once one is on record it is taken to stand. */ +import { and, eq } from "drizzle-orm"; import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { onshapeWebhooks, WebhookSubject } from "../../db/schema"; import { type OAuthApi, OnshapeApiError } from "../../lib/onshape/client"; import { getSessionInfo } from "../../lib/onshape/endpoints/users"; import { createWebhook, - deleteWebhook, - getCompanyWebhooks, - getWebhook + deleteWebhook } from "../../lib/onshape/endpoints/webhooks"; export const RECEIVE_PATH = "/api/webhooks/onshape"; @@ -20,102 +20,139 @@ export const RECEIVE_PATH = "/api/webhooks/onshape"; export enum WebhookEvent { CREATE_VERSION = "onshape.model.lifecycle.createversion", TEAM_ADD_MEMBER = "onshape.team.addmember", - TEAM_REMOVE_MEMBER = "onshape.team.removemember" + TEAM_REMOVE_MEMBER = "onshape.team.removemember", + UNREGISTER = "webhook.unregister" } -/** What was registered, and the token its deliveries carry. */ -export interface WebhookRegistration { - token: string; - /** Absent between storing the token and Onshape answering the create. */ - webhookId?: string; -} - -const REGISTRATION_KEY = "webhook-registration"; - -export async function getRegistration( - env: AppBindings -): Promise<WebhookRegistration | null> { - return env.KV.get<WebhookRegistration>(REGISTRATION_KEY, "json"); -} +export type RegisteredWebhook = typeof onshapeWebhooks.$inferSelect; -/** For when Onshape says it dropped the webhook: the next check registers anew. */ -export async function forgetRegistration(env: AppBindings): Promise<void> { - await env.KV.delete(REGISTRATION_KEY); +function whereSubject(subject: WebhookSubject, subjectId: string) { + return and( + eq(onshapeWebhooks.subject, subject), + eq(onshapeWebhooks.subjectId, subjectId) + ); } -/** Whether the recorded webhook is still Onshape's, at this deployment's url. */ -async function isStillRegistered( +/** + * What to ask Onshape for. A document's names the document, from which Onshape + * infers the company. A team's events are company-wide and name no team, so + * the company is the registering user's, and the receiver picks out the team. + */ +async function subjectParams( onshapeApi: OAuthApi, - registration: WebhookRegistration | null, - receiveUrl: URL -): Promise<boolean> { - if (!registration?.webhookId) { - return false; + subject: WebhookSubject, + subjectId: string +) { + if (subject === WebhookSubject.DOCUMENT) { + return { + documentId: subjectId, + events: [WebhookEvent.CREATE_VERSION] + }; } - try { - const webhook = await getWebhook(onshapeApi, registration.webhookId); - return webhook.url.startsWith(receiveUrl.href); - } catch (error) { - if (error instanceof OnshapeApiError && error.status === 404) { - return false; - } - throw error; + const companyId = (await getSessionInfo(onshapeApi)).company?.id; + if (!companyId) { + throw new Error( + "A team's webhook needs a company; open the app from your company's Onshape." + ); } + return { + companyId, + events: [WebhookEvent.TEAM_ADD_MEMBER, WebhookEvent.TEAM_REMOVE_MEMBER] + }; } -/** - * Registers the webhook unless the one on record still stands. `origin` is - * this deployment's, which the webhook is delivered to. - */ +/** Registers a webhook for the subject unless one is already on record. */ export async function ensureWebhook( env: AppBindings, onshapeApi: OAuthApi, + subject: WebhookSubject, + subjectId: string, origin: string ): Promise<void> { - const receiveUrl = new URL(RECEIVE_PATH, origin); - if ( - await isStillRegistered( - onshapeApi, - await getRegistration(env), - receiveUrl - ) - ) { - return; - } - - const companyId = (await getSessionInfo(onshapeApi)).company?.id; - if (!companyId) { - console.warn( - "Not registering Onshape webhooks: the owner opened the app outside their company." - ); + const db = getDb(env.DB); + const existing = await db + .select({ webhookId: onshapeWebhooks.webhookId }) + .from(onshapeWebhooks) + .where(whereSubject(subject, subjectId)) + .get(); + if (existing?.webhookId) { return; } - // Matched on this deployment's url alone, so dev, cert and production - // registering under one company leave each other's in place. - for (const webhook of await getCompanyWebhooks(onshapeApi, companyId)) { - if (webhook.url.startsWith(receiveUrl.href)) { - await deleteWebhook(onshapeApi, webhook.id); - } - } - - // Stored first: Onshape posts webhook.register before create returns. + // Stored first: Onshape posts webhook.register to the url before create + // returns, and the token is how the receiver recognizes it. const token = crypto.randomUUID(); - await env.KV.put(REGISTRATION_KEY, JSON.stringify({ token })); - receiveUrl.searchParams.set("token", token); + await db + .insert(onshapeWebhooks) + .values({ subject, subjectId, token }) + .onConflictDoUpdate({ + target: [onshapeWebhooks.subject, onshapeWebhooks.subjectId], + set: { token, webhookId: null } + }); + const url = new URL(RECEIVE_PATH, origin); + url.searchParams.set("token", token); const webhook = await createWebhook(onshapeApi, { - companyId, - events: Object.values(WebhookEvent), - url: receiveUrl.href, + ...(await subjectParams(onshapeApi, subject, subjectId)), + url: url.href, name: "FRCDesignApp", - description: - "Reloads library documents on a new version, and access on admin team changes.", + description: `Keeps the FRCDesignApp in step with this ${subject}.`, options: { collapseEvents: false }, isTransient: false }); - await env.KV.put( - REGISTRATION_KEY, - JSON.stringify({ token, webhookId: webhook.id }) - ); + await db + .update(onshapeWebhooks) + .set({ webhookId: webhook.id }) + .where(whereSubject(subject, subjectId)); +} + +/** Unregisters the subject's webhook, when nothing needs it any more. */ +export async function removeWebhook( + env: AppBindings, + onshapeApi: OAuthApi, + subject: WebhookSubject, + subjectId: string +): Promise<void> { + const db = getDb(env.DB); + const existing = await db + .select({ webhookId: onshapeWebhooks.webhookId }) + .from(onshapeWebhooks) + .where(whereSubject(subject, subjectId)) + .get(); + if (existing?.webhookId) { + try { + await deleteWebhook(onshapeApi, existing.webhookId); + } catch (error) { + // Already gone is what was wanted. + if (!(error instanceof OnshapeApiError && error.status === 404)) { + throw error; + } + } + } + await db.delete(onshapeWebhooks).where(whereSubject(subject, subjectId)); +} + +/** The webhook a delivery's token belongs to, or undefined for a stranger. */ +export function findWebhookByToken( + env: AppBindings, + token: string +): Promise<RegisteredWebhook | undefined> { + return getDb(env.DB) + .select() + .from(onshapeWebhooks) + .where(eq(onshapeWebhooks.token, token)) + .get(); +} + +/** + * Drops the record of a webhook Onshape unregistered, so the next load of its + * document, or setting of its team, registers another. + */ +export async function forgetWebhook( + env: AppBindings, + webhook: RegisteredWebhook +): Promise<void> { + await getDb(env.DB) + .delete(onshapeWebhooks) + .where(whereSubject(webhook.subject, webhook.subjectId)); } diff --git a/src/backend/features/webhooks/registration.worker.test.ts b/src/backend/features/webhooks/registration.worker.test.ts index 8448da7a0..d824c7717 100644 --- a/src/backend/features/webhooks/registration.worker.test.ts +++ b/src/backend/features/webhooks/registration.worker.test.ts @@ -1,36 +1,20 @@ import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { resetDb } from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { onshapeWebhooks, WebhookSubject } from "../../db/schema"; import { OnshapeApiError } from "../../lib/onshape/client"; -import { ensureWebhook, getRegistration } from "./registration"; +import { ensureWebhook, removeWebhook } from "./registration"; +const db = getDb(env.DB); const ORIGIN = "https://app.example.com"; -const OURS = `${ORIGIN}/api/webhooks/onshape?token=old`; -/** - * Onshape, holding a webhook of this deployment's and one of another's. `ours` - * is what asking after the recorded webhook answers, or undefined for gone. - */ -function mockOnshape(ours?: { url: string }) { +function mockOnshape() { const onshapeApi = new MockOnshapeApi(); - vi.spyOn(onshapeApi, "get").mockImplementation((path: string) => { - if (path === "/users/sessioninfo") { - return Promise.resolve({ id: "owner", company: { id: "company" } }); - } - if (path.startsWith("/webhooks/")) { - return ours - ? Promise.resolve(ours) - : Promise.reject(new OnshapeApiError("gone", 404)); - } - return Promise.resolve({ - items: [ - { id: "stale", url: OURS }, - { - id: "theirs", - url: "https://cert.example.com/api/webhooks/onshape?token=x" - } - ] - }); + vi.spyOn(onshapeApi, "get").mockResolvedValue({ + id: "owner", + company: { id: "company" } }); const post = vi .spyOn(onshapeApi, "post") @@ -41,53 +25,88 @@ function mockOnshape(ours?: { url: string }) { return { onshapeApi, post, remove }; } -describe("keeping the webhook registered", () => { - beforeEach(async () => { - await env.KV.delete("webhook-registration"); - }); - afterEach(() => vi.restoreAllMocks()); +const stored = () => db.select().from(onshapeWebhooks).all(); - it("registers one when none is on record, replacing this deployment's", async () => { - const { onshapeApi, post, remove } = mockOnshape(); +describe("registering webhooks", () => { + beforeEach(() => resetDb(db)); + afterEach(() => vi.restoreAllMocks()); - await ensureWebhook(env, onshapeApi, ORIGIN); + it("registers a document's for its new versions, delivered with its token", async () => { + const { onshapeApi, post } = mockOnshape(); - expect(remove).toHaveBeenCalledExactlyOnceWith("/webhooks/stale"); - const registration = await getRegistration(env); - expect(registration?.webhookId).toBe("new-webhook"); - expect(post).toHaveBeenCalledWith( - "/webhooks", - expect.objectContaining({ - body: expect.objectContaining({ - companyId: "company", - url: `${ORIGIN}/api/webhooks/onshape?token=${registration?.token}`, - isTransient: false - }) - }) + await ensureWebhook( + env, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc", + ORIGIN ); + + const [row] = await stored(); + expect(row.webhookId).toBe("new-webhook"); + expect(post).toHaveBeenCalledWith("/webhooks", { + body: expect.objectContaining({ + documentId: "doc", + events: ["onshape.model.lifecycle.createversion"], + url: `${ORIGIN}/api/webhooks/onshape?token=${row.token}`, + isTransient: false + }) as unknown + }); }); - it("leaves a registration that still stands alone", async () => { - await env.KV.put( - "webhook-registration", - JSON.stringify({ token: "old", webhookId: "stale" }) + it("registers a team's for the company's membership changes", async () => { + const { onshapeApi, post } = mockOnshape(); + + await ensureWebhook( + env, + onshapeApi, + WebhookSubject.TEAM, + "team", + ORIGIN ); - const { onshapeApi, post } = mockOnshape({ url: OURS }); - await ensureWebhook(env, onshapeApi, ORIGIN); + expect(post).toHaveBeenCalledWith("/webhooks", { + body: expect.objectContaining({ + companyId: "company", + events: ["onshape.team.addmember", "onshape.team.removemember"] + }) as unknown + }); + }); - expect(post).not.toHaveBeenCalled(); + // Registered as never transient, so one on record is taken to stand. + it("registers nothing for a subject already registered", async () => { + const { onshapeApi, post } = mockOnshape(); + await ensureWebhook( + env, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc", + ORIGIN + ); + await ensureWebhook( + env, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc", + ORIGIN + ); + expect(post).toHaveBeenCalledOnce(); }); - it("registers again once Onshape no longer has the one on record", async () => { - await env.KV.put( - "webhook-registration", - JSON.stringify({ token: "old", webhookId: "stale" }) + it("removes one Onshape already dropped without complaint", async () => { + const { onshapeApi, remove } = mockOnshape(); + await ensureWebhook( + env, + onshapeApi, + WebhookSubject.DOCUMENT, + "doc", + ORIGIN ); - const { onshapeApi, post } = mockOnshape(); + remove.mockRejectedValue(new OnshapeApiError("gone", 404)); - await ensureWebhook(env, onshapeApi, ORIGIN); + await removeWebhook(env, onshapeApi, WebhookSubject.DOCUMENT, "doc"); - expect(post).toHaveBeenCalledOnce(); + expect(remove).toHaveBeenCalledWith("/webhooks/new-webhook"); + expect(await stored()).toEqual([]); }); }); diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index 6bcb2368c..9f89a5cc5 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -1,22 +1,24 @@ /** - * Onshape's webhooks, as delivered; `registration.ts` keeps them registered. A new version of a library document reloads its groups, and a change - * to the admin team's members re-asks everyone's access level. + * Where Onshape delivers; `registration.ts` registers what it delivers for. + * A new version of a library document reloads its groups, and a change to an + * admin team's members pulls that team again. * - * A notification carries nothing trusted: it only names a document or team, - * and what follows re-reads Onshape. The url's token is what keeps anyone - * else from making the server do that work. + * A delivery is recognized by the token in its url, and acted on for the + * subject that token was registered for, never for what the payload names: the + * url is all that keeps anyone else from making the server do this work. */ import { eq } from "drizzle-orm"; import { type AppBindings, getApp } from "../../lib/context"; import { forbiddenError } from "../../lib/api-error"; import { getDb } from "../../db/client"; -import { groups } from "../../db/schema"; -import { clearAccessLevels } from "../auth/session"; -import { queueReload } from "../load/reload"; -import { pushAccessChanged } from "../live/notify"; +import { groups, WebhookSubject } from "../../db/schema"; +import { getOwnerOnshapeApi, getOwnerSessionId } from "../auth/owner"; +import { librariesOfTeam, syncAdminTeam } from "../admin-team/sync"; +import { requestLoads } from "../load/jobs"; import { - forgetRegistration, - getRegistration, + findWebhookByToken, + forgetWebhook, + RECEIVE_PATH, WebhookEvent } from "./registration"; @@ -25,69 +27,85 @@ export const webhookRoutes = getApp(); /** The fields read off a notification; Onshape sends more. */ interface WebhookNotification { event: string; - webhookId?: string; - documentId?: string; teamId?: string; } -/** POST /api/webhooks/onshape?token= — where Onshape delivers. */ -webhookRoutes.post("/webhooks/onshape", async (c) => { +/** POST /api/webhooks/onshape?token= */ +webhookRoutes.post(RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { const token = c.req.query("token"); - const expected = (await getRegistration(c.env))?.token; - if (!token || !expected || !tokensMatch(token, expected)) { + const webhook = token ? await findWebhookByToken(c.env, token) : undefined; + if (!webhook) { throw forbiddenError("Unrecognized webhook"); } const notification = await c.req.json<WebhookNotification>(); + const origin = new URL(c.req.url).origin; switch (notification.event) { case WebhookEvent.CREATE_VERSION: - if (notification.documentId) { - await reloadDocument(c.env, notification.documentId); + if (webhook.subject === WebhookSubject.DOCUMENT) { + await reloadDocument(c.env, webhook.subjectId, origin); } break; case WebhookEvent.TEAM_ADD_MEMBER: case WebhookEvent.TEAM_REMOVE_MEMBER: - if (notification.teamId === c.env.ADMIN_TEAM) { - await clearAccessLevels(c.env.KV); - await pushAccessChanged(c.env); - } - break; - case "webhook.unregister": + // A team's webhook hears every team in the company. if ( - notification.webhookId === - (await getRegistration(c.env))?.webhookId + webhook.subject === WebhookSubject.TEAM && + notification.teamId === webhook.subjectId ) { - await forgetRegistration(c.env); + await resyncTeam(c.env, webhook.subjectId); } break; + case WebhookEvent.UNREGISTER: + await forgetWebhook(c.env, webhook); + break; // webhook.register and webhook.ping only want a 200, which registration // fails without. } return c.json({}); }); -/** Reloads the document's groups in every library holding it; most hold none. */ +/** + * Loads the document's groups, in every library holding it, under the owner's + * session: nobody is signed in behind a webhook. + */ async function reloadDocument( env: AppBindings, - documentId: string + documentId: string, + origin: string ): Promise<void> { - const libraries = await getDb(env.DB) - .selectDistinct({ libraryId: groups.libraryId }) + const sessionId = await getOwnerSessionId(env.KV); + if (!sessionId) { + console.warn( + `No owner session to reload ${documentId} with; the owner has not used the app yet.` + ); + return; + } + const documentGroups = await getDb(env.DB) + .select({ groupId: groups.id, libraryId: groups.libraryId }) .from(groups) .where(eq(groups.documentId, documentId)); - for (const { libraryId } of libraries) { - await queueReload(env, libraryId, [documentId]); - } + await requestLoads( + env, + documentGroups.map((group) => ({ + ...group, + sessionId, + forceReload: false, + origin + })) + ); } -/** Compared in constant time, so response timing gives nothing of it away. */ -function tokensMatch(given: string, expected: string): boolean { - if (given.length !== expected.length) { - return false; +/** Pulls the team's members again for every library it administers. */ +async function resyncTeam(env: AppBindings, teamId: string): Promise<void> { + const onshapeApi = await getOwnerOnshapeApi(env.KV); + if (!onshapeApi) { + console.warn( + `No owner session to pull team ${teamId} with; the owner has not used the app yet.` + ); + return; } - let difference = 0; - for (let i = 0; i < given.length; i++) { - difference |= given.charCodeAt(i) ^ expected.charCodeAt(i); + for (const libraryId of await librariesOfTeam(env, teamId)) { + await syncAdminTeam(env, onshapeApi, libraryId); } - return difference === 0; } diff --git a/src/backend/features/webhooks/routes.worker.test.ts b/src/backend/features/webhooks/routes.worker.test.ts index f7a3718d8..aa14d5ce0 100644 --- a/src/backend/features/webhooks/routes.worker.test.ts +++ b/src/backend/features/webhooks/routes.worker.test.ts @@ -1,4 +1,5 @@ import { env } from "cloudflare:workers"; +import { eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TEST_GROUP_ID, @@ -9,121 +10,121 @@ import { seedGroup } from "../../../__test_utils__"; import { getDb } from "../../db/client"; -import { AccessLevel } from "../auth/access-level"; -import { accessLevelKey } from "../auth/session"; +import { libraries, onshapeWebhooks, WebhookSubject } from "../../db/schema"; import { rememberOwnerSession } from "../auth/owner"; -import * as JobTracker from "../load/job-tracker"; +import * as Owner from "../auth/owner"; +import * as Sync from "../admin-team/sync"; +import * as Jobs from "../load/jobs"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; import { WebhookEvent } from "./registration"; const db = getDb(env.DB); -const TOKEN = "the-token"; +const DOCUMENT = `doc-${TEST_GROUP_ID}`; /** Delivers a notification the way Onshape would, with `token` on the url. */ -function deliver(body: object, token = TOKEN) { +function deliver(body: object, token: string) { return createTestApp().request( - `/api/webhooks/onshape?token=${token}`, + `http://localhost/api/webhooks/onshape?token=${token}`, jsonRequest("POST", body), env ); } +async function registered(subject: WebhookSubject, subjectId: string) { + const token = `${subject}-token`; + await db + .insert(onshapeWebhooks) + .values({ subject, subjectId, token, webhookId: `${subject}-webhook` }); + return token; +} + describe("receiving a webhook", () => { beforeEach(async () => { await resetDb(db); - await env.KV.put( - "webhook-registration", - JSON.stringify({ token: TOKEN, webhookId: "ours" }) - ); + await seedGroup(db, TEST_GROUP_ID); + await rememberOwnerSession(env.KV, "owner-session"); }); afterEach(() => vi.restoreAllMocks()); - it("turns away a delivery without the registered token", async () => { + it("turns away a delivery without a registered token", async () => { + await registered(WebhookSubject.DOCUMENT, DOCUMENT); const res = await deliver({ event: "webhook.ping" }, "guess"); expect(res.status).toBe(403); }); // Registration fails unless Onshape's own check is answered. it("answers Onshape's registration check", async () => { - const res = await deliver({ event: "webhook.register" }); + const token = await registered(WebhookSubject.DOCUMENT, DOCUMENT); + const res = await deliver({ event: "webhook.register" }, token); expect(res.status).toBe(200); }); - // So the owner's next visit registers a new one. - it("forgets a registration Onshape dropped", async () => { - await deliver({ event: "webhook.unregister", webhookId: "ours" }); - expect(await env.KV.get("webhook-registration")).toBeNull(); - }); + it("loads the document's groups on a new version, as the owner", async () => { + const token = await registered(WebhookSubject.DOCUMENT, DOCUMENT); + const load = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); - describe("a new version", () => { - beforeEach(async () => { - await seedGroup(db, TEST_GROUP_ID); - await rememberOwnerSession(env.KV, "owner-session"); - vi.spyOn(JobTracker, "trackJob").mockResolvedValue(); - }); - - it("reloads the groups loaded from that document, as the owner", async () => { - vi.spyOn(JobTracker, "isReloadRunning").mockResolvedValue(false); - const create = vi - .spyOn(env.LOAD_LIBRARY_WORKFLOW, "create") - .mockResolvedValue({ id: "wf" } as never); - - await deliver({ - event: WebhookEvent.CREATE_VERSION, - documentId: `doc-${TEST_GROUP_ID}` - }); + await deliver( + // What the payload names is not trusted; the token's subject is. + { event: WebhookEvent.CREATE_VERSION, documentId: "elsewhere" }, + token + ); - expect(create.mock.calls[0][0]?.params).toEqual({ + expect(load).toHaveBeenCalledWith(expect.anything(), [ + { libraryId: TEST_LIBRARY_ID, + groupId: TEST_GROUP_ID, sessionId: "owner-session", - documentIds: [`doc-${TEST_GROUP_ID}`] - }); - }); + forceReload: false, + origin: "http://localhost" + } + ]); + }); - it("ignores a document no library holds", async () => { - const create = vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "create"); - await deliver({ - event: WebhookEvent.CREATE_VERSION, - documentId: "somebody-elses" - }); - expect(create).not.toHaveBeenCalled(); + describe("an admin team change", () => { + beforeEach(async () => { + await db + .update(libraries) + .set({ adminTeamId: "team" }) + .where(eq(libraries.id, TEST_LIBRARY_ID)); + vi.spyOn(Owner, "getOwnerOnshapeApi").mockResolvedValue( + new MockOnshapeApi() + ); }); - // The running reload may have checked it before the version landed. - it("queues the document behind a reload already running", async () => { - vi.spyOn(JobTracker, "isReloadRunning").mockResolvedValue(true); - const create = vi.spyOn(env.LOAD_LIBRARY_WORKFLOW, "create"); + it("pulls the team again for each library it administers", async () => { + const token = await registered(WebhookSubject.TEAM, "team"); + const sync = vi.spyOn(Sync, "syncAdminTeam").mockResolvedValue(); - await deliver({ - event: WebhookEvent.CREATE_VERSION, - documentId: `doc-${TEST_GROUP_ID}` - }); + await deliver( + { event: WebhookEvent.TEAM_ADD_MEMBER, teamId: "team" }, + token + ); - expect(create).not.toHaveBeenCalled(); - expect( - await env.KV.get(`queued-reload:${TEST_LIBRARY_ID}`, "json") - ).toEqual([`doc-${TEST_GROUP_ID}`]); + expect(sync).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + TEST_LIBRARY_ID + ); }); - }); - describe("an admin team change", () => { - it("re-asks everyone's access level", async () => { - await env.KV.put(accessLevelKey("someone"), AccessLevel.EDITOR); - await deliver({ - event: WebhookEvent.TEAM_REMOVE_MEMBER, - teamId: env.ADMIN_TEAM - }); - expect(await env.KV.get(accessLevelKey("someone"))).toBeNull(); - }); + // A team's webhook hears every team in the company. + it("ignores another team's change", async () => { + const token = await registered(WebhookSubject.TEAM, "team"); + const sync = vi.spyOn(Sync, "syncAdminTeam").mockResolvedValue(); - it("leaves access alone for any other team", async () => { - await env.KV.put(accessLevelKey("someone"), AccessLevel.EDITOR); - await deliver({ - event: WebhookEvent.TEAM_ADD_MEMBER, - teamId: "another-team" - }); - expect(await env.KV.get(accessLevelKey("someone"))).toBe( - AccessLevel.EDITOR + await deliver( + { event: WebhookEvent.TEAM_REMOVE_MEMBER, teamId: "other" }, + token ); + + expect(sync).not.toHaveBeenCalled(); }); }); + + // So the next load of the document registers a new one. + it("forgets a webhook Onshape dropped", async () => { + const token = await registered(WebhookSubject.DOCUMENT, DOCUMENT); + await deliver({ event: WebhookEvent.UNREGISTER }, token); + expect(await db.select().from(onshapeWebhooks).all()).toEqual([]); + }); }); diff --git a/src/backend/index.ts b/src/backend/index.ts index 7f2949db6..89d0306a4 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -3,13 +3,28 @@ * Workflow class has to be an export of the Worker's entrypoint for the * `class_name`s in wrangler.jsonc to resolve. */ -export { - AddGroupWorkflow, - LoadLibraryWorkflow -} from "./features/load/workflows"; +export { LoadDocumentWorkflow } from "./features/load/workflows"; export { RenderThumbnailWorkflow } from "./features/thumbnails/render-workflow"; export { LiveUpdates } from "./features/live/live-updates"; import { createApp } from "./app"; import { productionAuth } from "./features/auth/request-auth"; +import type { AppBindings } from "./lib/context"; +import { getDb } from "./db/client"; +import { reconcileThumbnails } from "./features/thumbnails/reconcile"; -export default createApp(productionAuth); +const app = createApp(productionAuth); + +export default { + fetch: app.fetch, + /** + * The daily cron in wrangler.jsonc. Thumbnails outlive what shows them, and + * no load sees the whole library any more to clear them as it finishes. + */ + scheduled(_controller, env, ctx) { + ctx.waitUntil( + reconcileThumbnails(env.BLOB, getDb(env.DB)).then((result) => { + console.log("Reconciled thumbnails", result); + }) + ); + } +} satisfies ExportedHandler<AppBindings>; diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 25efdb436..978bf19bd 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -1,11 +1,9 @@ import { type Context, type MiddlewareHandler, Hono } from "hono"; -import type { - AddGroupParams, - LoadLibraryParams -} from "../features/load/workflows"; +import type { LoadDocumentParams } from "../features/load/jobs"; import type { RenderThumbnailParams } from "../features/thumbnails/render-workflow"; import type { LiveUpdates } from "../features/live/live-updates"; import { type AccessLevel } from "../features/auth/access-level"; +import type { LibraryId } from "../features/library/library-id"; import { type OAuthApi } from "./onshape/client"; export interface AppBindings { @@ -14,13 +12,12 @@ export interface AppBindings { ASSETS: Fetcher; /** Thumbnails and search indexes; prefixes keep them apart. */ BLOB: R2Bucket; - LOAD_LIBRARY_WORKFLOW: Workflow<LoadLibraryParams>; - ADD_GROUP_WORKFLOW: Workflow<AddGroupParams>; + /** One instance per group being loaded at a time; see `load/jobs.ts`. */ + LOAD_DOCUMENT_WORKFLOW: Workflow<LoadDocumentParams>; /** One instance per configuration being rendered; see `requestRender`. */ RENDER_THUMBNAIL_WORKFLOW: Workflow<RenderThumbnailParams>; /** Relays pushes to open clients; see `features/live`. */ LIVE_UPDATES: DurableObjectNamespace<LiveUpdates>; - ADMIN_TEAM: string; /** The Onshape user id granted `AccessLevel.OWNER`; unset grants nobody. */ OWNER_USER_ID?: string; /** Dev-only: the access level granted, bypassing Onshape. */ @@ -37,7 +34,7 @@ interface AppVariables { /** Injected by {@link bindAuth}; see {@link RequestAuth}. */ getOnshapeApi: () => Promise<OAuthApi>; getUserId: () => Promise<string>; - getAccessLevel: () => Promise<AccessLevel>; + getAccessLevel: (libraryId: LibraryId) => Promise<AccessLevel>; isAuthenticated: () => Promise<boolean>; } @@ -55,7 +52,8 @@ export type AppContext = Context<AppContextEnv>; interface RequestAuth { getOnshapeApi: () => Promise<OAuthApi>; getUserId: () => Promise<string>; - getAccessLevel: () => Promise<AccessLevel>; + /** The caller's access to one library; the owner's is the same in all. */ + getAccessLevel: (libraryId: LibraryId) => Promise<AccessLevel>; isAuthenticated: () => Promise<boolean>; } diff --git a/src/backend/lib/errors.worker.test.ts b/src/backend/lib/errors.worker.test.ts index 2ce16e8eb..67deec27d 100644 --- a/src/backend/lib/errors.worker.test.ts +++ b/src/backend/lib/errors.worker.test.ts @@ -18,7 +18,7 @@ describe("api error responses", () => { const app = createTestApp({ signedIn: false }); const res = await app.request( - `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, jsonRequest("POST"), env ); @@ -34,7 +34,7 @@ describe("api error responses", () => { const app = createTestApp({ accessLevel: AccessLevel.USER }); const res = await app.request( - `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, jsonRequest("POST"), env ); diff --git a/src/backend/lib/onshape/endpoints/teams.ts b/src/backend/lib/onshape/endpoints/teams.ts new file mode 100644 index 000000000..20e362be7 --- /dev/null +++ b/src/backend/lib/onshape/endpoints/teams.ts @@ -0,0 +1,28 @@ +import { OnshapeApi } from "../client"; + +export interface OnshapeTeamMember { + /** Whether the member administers the team, not only belongs to it. */ + admin: boolean; + member: { id: string }; +} + +/** Onshape pages the list; this is as many as it hands back at once. */ +const PAGE_SIZE = 20; + +/** Every member of a team, across however many pages. */ +export async function getTeamMembers( + client: OnshapeApi, + teamId: string +): Promise<OnshapeTeamMember[]> { + const members: OnshapeTeamMember[] = []; + for (let offset = 0; ; offset += PAGE_SIZE) { + const page: { items: OnshapeTeamMember[] } = await client.get( + `/teams/${encodeURIComponent(teamId)}/members`, + { query: { offset: String(offset), limit: String(PAGE_SIZE) } } + ); + members.push(...page.items); + if (page.items.length < PAGE_SIZE) { + return members; + } + } +} diff --git a/src/backend/lib/onshape/endpoints/webhooks.ts b/src/backend/lib/onshape/endpoints/webhooks.ts index 7cc6e99ad..9991a32a1 100644 --- a/src/backend/lib/onshape/endpoints/webhooks.ts +++ b/src/backend/lib/onshape/endpoints/webhooks.ts @@ -8,7 +8,9 @@ export interface OnshapeWebhookInfo { export interface CreateWebhookParams { /** The company whose events it hears; the caller has to administer it. */ - companyId: string; + companyId?: string; + /** The document whose events it hears, for a document's events. */ + documentId?: string; events: string[]; url: string; name: string; diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index 64c1e71f9..6eddc5c32 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -12,7 +12,8 @@ import { } from "@mantine/core"; import { CheckIcon, CopyIcon, HouseIcon } from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; -import { ReloadGroupsButton } from "../features/library/components/reload-groups-button"; +import { ReloadAllButton } from "../features/library/components/reload-all-button"; +import { AccessLevel } from "@backend/features/auth/access-level"; import { DEFAULT_LIBRARY } from "@backend/features/library/library-id"; /** @@ -23,8 +24,11 @@ export function RootAppError(): ReactNode { <PageNotice title="The app has crashed due to an unexpected error." action={ - <RequireAccessLevel useMaxAccessLevel> - <ReloadGroupsButton reloadAll /> + <RequireAccessLevel + accessLevel={AccessLevel.OWNER} + useMaxAccessLevel + > + <ReloadAllButton /> </RequireAccessLevel> } /> diff --git a/src/frontend/features/admin-team/components/admin-team-setting.tsx b/src/frontend/features/admin-team/components/admin-team-setting.tsx new file mode 100644 index 000000000..bace4b5e7 --- /dev/null +++ b/src/frontend/features/admin-team/components/admin-team-setting.tsx @@ -0,0 +1,54 @@ +import { Button, Group, Stack, Text, TextInput } from "@mantine/core"; +import { type ReactNode, useId, useState } from "react"; +import { useAdminTeamQuery, useSetAdminTeamMutation } from "../queries"; + +/** + * The owner's choice of the Onshape team that may edit this library. Its + * members, and changes to them, are what give anyone else editor access. + */ +export function AdminTeamSetting(): ReactNode { + const query = useAdminTeamQuery(); + const mutation = useSetAdminTeamMutation(); + const inputId = useId(); + // Null until edited, so the stored team shows once it has loaded. + const [draft, setDraft] = useState<string | null>(null); + + const stored = query.data?.teamId ?? ""; + const value = draft ?? stored; + const trimmed = value.trim(); + + const save = () => { + mutation.mutate(trimmed || null, { + onSuccess: () => setDraft(null) + }); + }; + + return ( + <Stack gap={4}> + <Group gap="sm" wrap="nowrap" align="flex-end"> + <TextInput + id={inputId} + label="Admin team id" + placeholder="None: only you can edit" + value={value} + onChange={(event) => setDraft(event.currentTarget.value)} + disabled={query.isPending} + flex={1} + /> + <Button + variant="light" + onClick={save} + loading={mutation.isPending} + disabled={trimmed === stored} + > + Save + </Button> + </Group> + {query.data?.teamId && ( + <Text size="xs" c="dimmed"> + {query.data.memberCount} members can edit this library. + </Text> + )} + </Stack> + ); +} diff --git a/src/frontend/features/admin-team/queries.ts b/src/frontend/features/admin-team/queries.ts new file mode 100644 index 000000000..d362f0ce3 --- /dev/null +++ b/src/frontend/features/admin-team/queries.ts @@ -0,0 +1,42 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AdminTeamOut } from "@backend/features/admin-team/contract"; +import { apiGet, apiPost } from "../../lib/api-client"; +import { toLibraryPath } from "../../lib/api-paths"; +import { getAppErrorHandler } from "../../lib/errors"; +import { useLibraryId } from "../../lib/library"; +import { showSuccessToast } from "../../lib/notifications"; +import { queryClient } from "../../lib/query-client"; +import { adminTeamQueryKey } from "../../lib/query-keys"; + +export function useAdminTeamQuery() { + const libraryId = useLibraryId(); + return useQuery({ + queryKey: adminTeamQueryKey(libraryId), + queryFn: () => + apiGet<AdminTeamOut>("/admin-team" + toLibraryPath(libraryId)) + }); +} + +/** + * Sets the library's admin team, which the server pulls the members of. The + * change reaches everyone's access through the library version it bumps. + */ +export function useSetAdminTeamMutation() { + const libraryId = useLibraryId(); + return useMutation({ + mutationKey: ["admin-team", libraryId], + mutationFn: (teamId: string | null) => + apiPost<AdminTeamOut>("/admin-team" + toLibraryPath(libraryId), { + body: { teamId } + }), + onError: getAppErrorHandler("Failed to set the admin team!"), + onSuccess: (team) => { + queryClient.setQueryData(adminTeamQueryKey(libraryId), team); + showSuccessToast( + team.teamId + ? `Admin team set: ${team.memberCount} members.` + : "Admin team removed." + ); + } + }); +} diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index 277bef4bb..3512f4d7f 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -7,6 +7,9 @@ import { hasEditorAccess } from "@backend/features/auth/access-level"; import { accessDataQueryKey } from "../../lib/query-keys"; +import { toLibraryPath } from "../../lib/api-paths"; +import { useLibraryId } from "../../lib/library"; +import type { LibraryId } from "@backend/features/library/library-id"; import { apiGet } from "../../lib/api-client"; import { useGetUiState } from "../../lib/ui-state"; @@ -24,10 +27,11 @@ const DEFAULT_ACCESS_DATA: AccessData = { signedIn: false }; -export function getAccessDataQuery() { +/** Access to one library: its admin team is what grants more than a user's. */ +export function getAccessDataQuery(libraryId: LibraryId) { return queryOptions<AccessData>({ - queryKey: accessDataQueryKey(), - queryFn: () => apiGet("/access-data") + queryKey: accessDataQueryKey(libraryId), + queryFn: () => apiGet("/access-data" + toLibraryPath(libraryId)) }); } @@ -46,7 +50,8 @@ interface ResolvedAccessData extends AccessData { * drop below the granted max), so it survives the query refetching on navigation. */ export function useAccessData(): ResolvedAccessData { - const { data, isPending } = useQuery(getAccessDataQuery()); + const libraryId = useLibraryId(); + const { data, isPending } = useQuery(getAccessDataQuery(libraryId)); const serverData = data ?? DEFAULT_ACCESS_DATA; const uiState = useGetUiState(); const chosenLevel = uiState.accessLevel; diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts index 425ffc492..45d5a6129 100644 --- a/src/frontend/features/favorites/queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -30,8 +30,9 @@ function getFavoritesQuery(libraryId: LibraryId) { /** Awaits access rather than blocking the loader on it: signed out has none. */ export async function prefetchFavorites(libraryId: LibraryId): Promise<void> { - const { signedIn } = - await queryClient.ensureQueryData(getAccessDataQuery()); + const { signedIn } = await queryClient.ensureQueryData( + getAccessDataQuery(libraryId) + ); if (signedIn) { await queryClient.prefetchQuery(getFavoritesQuery(libraryId)); } diff --git a/src/frontend/features/library/components/reload-all-button.tsx b/src/frontend/features/library/components/reload-all-button.tsx new file mode 100644 index 000000000..276e8d471 --- /dev/null +++ b/src/frontend/features/library/components/reload-all-button.tsx @@ -0,0 +1,57 @@ +import { useReloadAllMutation } from "../queries"; +import { Button, Text } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { ArrowsClockwiseIcon, WarningIcon } from "@phosphor-icons/react"; +import { AppIcon } from "../../../components/app-icon"; +import { AppTitle } from "../../../components/app-title"; +import { IconSize, StatusColor } from "../../../lib/style-constants"; +import { ReactNode } from "react"; + +/** + * Force reloads every document in every library. New versions reload + * themselves, so this is for a change in how documents are read. Spoken in + * red: it spends a great deal of the account's Onshape allocation. + */ +export function ReloadAllButton(): ReactNode { + const mutation = useReloadAllMutation(); + + const handleClick = () => { + modals.openConfirmModal({ + title: ( + <AppTitle + icon={ + <AppIcon + icon={WarningIcon} + size={IconSize.MEDIUM} + color={StatusColor.ERROR} + /> + } + title="Reload every library" + /> + ), + children: ( + <Text size="sm"> + Are you sure you want to reload every document in every + library? This is an expensive operation. New versions of + documents are already reloaded on their own. + </Text> + ), + labels: { confirm: "Reload everything", cancel: "Cancel" }, + centered: true, + confirmProps: { variant: "light", color: StatusColor.ERROR }, + onConfirm: () => mutation.mutate() + }); + }; + + return ( + <Button + variant="light" + color={StatusColor.ERROR} + leftSection={<ArrowsClockwiseIcon size={IconSize.SMALL} />} + onClick={handleClick} + loading={mutation.isPending} + > + Reload everything + </Button> + ); +} diff --git a/src/frontend/features/library/components/reload-groups-button.tsx b/src/frontend/features/library/components/reload-groups-button.tsx deleted file mode 100644 index 98feed639..000000000 --- a/src/frontend/features/library/components/reload-groups-button.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useReloadGroupsMutation } from "../queries"; -import { Button, Text } from "@mantine/core"; -import { modals } from "@mantine/modals"; -import { ArrowsClockwiseIcon, WarningIcon } from "@phosphor-icons/react"; -import { AppIcon } from "../../../components/app-icon"; -import { AppTitle } from "../../../components/app-title"; -import { IconSize, StatusColor } from "../../../lib/style-constants"; -import { ReactNode } from "react"; - -interface ReloadGroupsButtonProps { - reloadAll?: boolean; -} - -export function ReloadGroupsButton(props: ReloadGroupsButtonProps): ReactNode { - const { reloadAll = false } = props; - - // Reloading everything spends the account's Onshape allocation, so it is - // spoken in the same red as anything else that cannot be taken back. - const color = reloadAll ? StatusColor.ERROR : StatusColor.INFO; - - const mutation = useReloadGroupsMutation(reloadAll); - - const handleClick = () => { - modals.openConfirmModal({ - title: ( - <AppTitle - icon={ - <AppIcon - icon={reloadAll ? WarningIcon : ArrowsClockwiseIcon} - size={IconSize.MEDIUM} - color={color} - /> - } - title={ - reloadAll - ? "Reload all documents" - : "Reload outdated documents" - } - /> - ), - children: ( - <Text size="sm"> - {reloadAll - ? "Are you sure you want to reload all documents? This is an expensive operation and should only be done after checking with Alex." - : "Are you sure you want to reload outdated documents?"} - </Text> - ), - labels: { confirm: "Reload documents", cancel: "Cancel" }, - centered: true, - confirmProps: { variant: "light", color }, - onConfirm: () => mutation.mutate() - }); - }; - - return ( - <Button - variant="light" - color={color} - leftSection={<ArrowsClockwiseIcon size={IconSize.SMALL} />} - onClick={handleClick} - loading={mutation.isPending} - > - Reload documents - </Button> - ); -} diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index fb472b805..6bdc2d455 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -200,23 +200,17 @@ function markJobStarted(libraryId: LibraryId): void { ); } -/** Reloads documents whose version moved on, or all of them. */ -export function useReloadGroupsMutation(reloadAll: boolean) { +/** Force reloads every document in every library; the owner's alone. */ +export function useReloadAllMutation() { const libraryId = useLibraryId(); return useMutation({ - mutationKey: ["reload-groups", libraryId], - mutationFn: (): Promise<{ status: string }> => - apiPost("/reload-groups" + toLibraryPath(libraryId), { - query: { forceReload: reloadAll } - }), + mutationKey: ["reload-all"], + mutationFn: (): Promise<{ documents: number }> => + apiPost("/reload-all"), onError: getAppErrorHandler("Failed to reload documents!"), onSuccess: (data) => { markJobStarted(libraryId); - showInfoToast( - data.status === "already-running" - ? "A reload is already running." - : "Reloading documents..." - ); + showInfoToast(`Reloading ${data.documents} documents...`); } }); } diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 963471b1d..0c26645ee 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -23,7 +23,8 @@ import { useGetUiState, updateUiState } from "../../../lib/ui-state"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { SETUP_URL } from "../../../lib/url"; import { useLibraryId } from "../../../lib/library"; -import { ReloadGroupsButton } from "../../library/components/reload-groups-button"; +import { ReloadAllButton } from "../../library/components/reload-all-button"; +import { AdminTeamSetting } from "../../admin-team/components/admin-team-setting"; /** The FRCDesign Discord, where feedback and support now live. */ const DISCORD_INVITE_URL = "https://discord.gg/PMgzEUTgB7"; @@ -204,12 +205,10 @@ function AdminSettings(): ReactNode { <Stack gap="sm"> {/* Always show the access level select so admins can change access level if needed */} <AccessLevelSelect /> - <RequireAccessLevel> - <InputRow label="Reload outdated documents"> - <ReloadGroupsButton /> - </InputRow> - <InputRow label="Reload all documents"> - <ReloadGroupsButton reloadAll /> + <RequireAccessLevel accessLevel={AccessLevel.OWNER}> + <AdminTeamSetting /> + <InputRow label="Reload every library"> + <ReloadAllButton /> </InputRow> </RequireAccessLevel> </Stack> diff --git a/src/frontend/features/settings/settings.ts b/src/frontend/features/settings/settings.ts index 226dda9af..d0caa0be9 100644 --- a/src/frontend/features/settings/settings.ts +++ b/src/frontend/features/settings/settings.ts @@ -2,6 +2,7 @@ import type { SettingsUpdate } from "@backend/features/settings/settings"; import { showErrorToast } from "../../lib/notifications"; import { apiPost } from "../../lib/api-client"; import { getAccessDataQuery } from "../auth/access-level"; +import { DEFAULT_LIBRARY } from "@backend/features/library/library-id"; import { queryClient } from "../../lib/query-client"; import { setSettingsSync } from "../../lib/ui-state"; @@ -9,8 +10,10 @@ import { setSettingsSync } from "../../lib/ui-state"; async function postSettings(newSettings: SettingsUpdate): Promise<void> { // Resolved here rather than read off a render: a placeholder that says // signed out would skip the save for a user who has a server-side row. - const { signedIn } = - await queryClient.ensureQueryData(getAccessDataQuery()); + // Any library answers, being signed in or not the same in all of them. + const { signedIn } = await queryClient.ensureQueryData( + getAccessDataQuery(DEFAULT_LIBRARY) + ); if (!signedIn) { return; } diff --git a/src/frontend/lib/live-sync.ts b/src/frontend/lib/live-sync.ts index 82994680f..d1ce0ae30 100644 --- a/src/frontend/lib/live-sync.ts +++ b/src/frontend/lib/live-sync.ts @@ -18,7 +18,7 @@ import { subscribeLiveMessages } from "./live-updates"; import { queryClient } from "./query-client"; -import { accessDataQueryKey, jobStatusQueryKey } from "./query-keys"; +import { jobStatusQueryKey } from "./query-keys"; import { useRefreshLibrary } from "./refresh"; export function useLiveSync(): void { @@ -64,11 +64,6 @@ export function useLiveSync(): void { } }); break; - case LiveMessageType.ACCESS: - void queryClient.invalidateQueries({ - queryKey: accessDataQueryKey() - }); - break; } }; return subscribeLiveMessages(apply); diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts index d57de5a65..4a67c067b 100644 --- a/src/frontend/lib/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -5,8 +5,13 @@ import { LibraryId } from "@backend/features/library/library-id"; import { ElementPath, InstancePath } from "@backend/lib/onshape/path"; -export function accessDataQueryKey() { - return ["access-data"]; +/** Access is per library; without one, the prefix every library's shares. */ +export function accessDataQueryKey(libraryId?: LibraryId) { + return libraryId ? ["access-data", libraryId] : ["access-data"]; +} + +export function adminTeamQueryKey(libraryId: LibraryId) { + return ["admin-team", libraryId]; } export function configurationQueryKey( diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index c0e7f6542..2a4bca2ef 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,12 +1,11 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 4a0648d046ff4aacf05d7c84330b3e71) +// Generated by Wrangler by running `wrangler types` (hash: 50b17c2e2ef4dfa6004b69eb5b2ebf03) // Runtime types generated with workerd@1.20260811.1 2026-05-14 nodejs_compat interface __BaseEnv_Env { KV: KVNamespace; BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; - ADMIN_TEAM: "6a62e6efcc21741bea57362c" | "5b620150b2190f0fca90ec10"; NODE_ENV: "production" | "development"; API_ACCESS_KEY: string; API_SECRET_KEY: string; @@ -15,8 +14,7 @@ interface __BaseEnv_Env { SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; LIVE_UPDATES: DurableObjectNamespace<import("./src/backend/index").LiveUpdates>; - LOAD_LIBRARY_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadLibraryWorkflow['run']>[0]['payload']>; - ADD_GROUP_WORKFLOW: Workflow<Parameters<import("./src/backend/index").AddGroupWorkflow['run']>[0]['payload']>; + LOAD_DOCUMENT_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadDocumentWorkflow['run']>[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow<Parameters<import("./src/backend/index").RenderThumbnailWorkflow['run']>[0]['payload']>; } declare namespace Cloudflare { @@ -29,7 +27,6 @@ declare namespace Cloudflare { BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; - ADMIN_TEAM: "6a62e6efcc21741bea57362c"; NODE_ENV: "production"; API_ACCESS_KEY: string; API_SECRET_KEY: string; @@ -38,8 +35,7 @@ declare namespace Cloudflare { SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; LIVE_UPDATES: DurableObjectNamespace<import("./src/backend/index").LiveUpdates>; - LOAD_LIBRARY_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadLibraryWorkflow['run']>[0]['payload']>; - ADD_GROUP_WORKFLOW: Workflow<Parameters<import("./src/backend/index").AddGroupWorkflow['run']>[0]['payload']>; + LOAD_DOCUMENT_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadDocumentWorkflow['run']>[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow<Parameters<import("./src/backend/index").RenderThumbnailWorkflow['run']>[0]['payload']>; } interface ProductionEnv { @@ -47,7 +43,6 @@ declare namespace Cloudflare { BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; - ADMIN_TEAM: "5b620150b2190f0fca90ec10"; NODE_ENV: "production"; API_ACCESS_KEY: string; API_SECRET_KEY: string; @@ -56,8 +51,7 @@ declare namespace Cloudflare { SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; LIVE_UPDATES: DurableObjectNamespace<import("./src/backend/index").LiveUpdates>; - LOAD_LIBRARY_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadLibraryWorkflow['run']>[0]['payload']>; - ADD_GROUP_WORKFLOW: Workflow<Parameters<import("./src/backend/index").AddGroupWorkflow['run']>[0]['payload']>; + LOAD_DOCUMENT_WORKFLOW: Workflow<Parameters<import("./src/backend/index").LoadDocumentWorkflow['run']>[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow<Parameters<import("./src/backend/index").RenderThumbnailWorkflow['run']>[0]['payload']>; } interface Env extends __BaseEnv_Env {} @@ -67,7 +61,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "ADMIN_TEAM" | "NODE_ENV" | "API_ACCESS_KEY" | "API_SECRET_KEY" | "OAUTH_CLIENT_ID" | "OAUTH_CLIENT_SECRET" | "SESSION_SECRET" | "VITE_ACCESS_LEVEL_OVERRIDE">> {} + interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "NODE_ENV" | "API_ACCESS_KEY" | "API_SECRET_KEY" | "OAUTH_CLIENT_ID" | "OAUTH_CLIENT_SECRET" | "SESSION_SECRET" | "VITE_ACCESS_LEVEL_OVERRIDE">> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index 27b381457..2e77df385 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -54,14 +54,9 @@ ], "workflows": [ { - "name": "load-library-workflow", - "binding": "LOAD_LIBRARY_WORKFLOW", - "class_name": "LoadLibraryWorkflow" - }, - { - "name": "add-group-workflow", - "binding": "ADD_GROUP_WORKFLOW", - "class_name": "AddGroupWorkflow" + "name": "load-document-workflow", + "binding": "LOAD_DOCUMENT_WORKFLOW", + "class_name": "LoadDocumentWorkflow" }, { "name": "render-thumbnail-workflow", @@ -74,6 +69,11 @@ "durable_objects": { "bindings": [{ "name": "LIVE_UPDATES", "class_name": "LiveUpdates" }] }, + // Daily, clearing thumbnails nothing shows any more; see the scheduled + // handler in src/backend/index.ts. Inherited by every environment. + "triggers": { + "crons": ["0 9 * * *"] + }, "migrations": [ { "tag": "v1", @@ -95,8 +95,6 @@ * https://developers.cloudflare.com/workers/configuration/secrets/ */ "vars": { - // Onshape team ID used to determine editor/admin access level in production - "ADMIN_TEAM": "5b620150b2190f0fca90ec10", // The Onshape user id granted the owner access level, whose session // webhook-triggered reloads run under. Unset grants nobody; each // environment below needs it too. @@ -138,14 +136,9 @@ ], "workflows": [ { - "name": "load-library-workflow-cert", - "binding": "LOAD_LIBRARY_WORKFLOW", - "class_name": "LoadLibraryWorkflow" - }, - { - "name": "add-group-workflow-cert", - "binding": "ADD_GROUP_WORKFLOW", - "class_name": "AddGroupWorkflow" + "name": "load-document-workflow-cert", + "binding": "LOAD_DOCUMENT_WORKFLOW", + "class_name": "LoadDocumentWorkflow" }, { "name": "render-thumbnail-workflow-cert", @@ -159,7 +152,6 @@ ] }, "vars": { - "ADMIN_TEAM": "6a62e6efcc21741bea57362c", // Production, not because cert is production, but because // FORCE_SIGNED_IN and VITE_ACCESS_LEVEL_OVERRIDE are gated on // this alone: anything but "production" leaves them armed. @@ -195,14 +187,9 @@ ], "workflows": [ { - "name": "load-library-workflow-prod", - "binding": "LOAD_LIBRARY_WORKFLOW", - "class_name": "LoadLibraryWorkflow" - }, - { - "name": "add-group-workflow-prod", - "binding": "ADD_GROUP_WORKFLOW", - "class_name": "AddGroupWorkflow" + "name": "load-document-workflow-prod", + "binding": "LOAD_DOCUMENT_WORKFLOW", + "class_name": "LoadDocumentWorkflow" }, { "name": "render-thumbnail-workflow-prod", @@ -216,7 +203,6 @@ ] }, "vars": { - "ADMIN_TEAM": "5b620150b2190f0fca90ec10", "NODE_ENV": "production" }, "limits": { From 35b970cdf86871c220c4b5e8e1789b1e57e5af9b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 02:38:41 +0000 Subject: [PATCH 26/88] Rebuild search with every load, and set the owner Each load that wrote to its group now rebuilds its library's search index before bumping the version, rather than leaving that to the library's last load, so every load stands alone. Reload everything starts every load at once and returns. OWNER_USER_ID is set in every environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 2 +- src/backend/features/load/jobs.ts | 44 +++++++++++-------- src/backend/features/load/jobs.worker.test.ts | 12 ++--- src/backend/features/load/routes.ts | 4 +- src/backend/features/load/workflows.ts | 11 ++++- worker-configuration.d.ts | 7 ++- wrangler.jsonc | 9 ++-- 7 files changed, 53 insertions(+), 36 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index b3b920c52..17657fd70 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -91,7 +91,7 @@ Cloudflare Workflows let you run a long-running background job that survives bey Loading a group means walking the document structure, downloading metadata for every part and assembly, probing each indexed configuration, generating thumbnails, and writing it all to D1 — far too long for a single HTTP request. The request kicks the workflow off and returns immediately. -Every load is one document: adding a document, a new version of one (see Webhooks below), and the owner's "reload everything", which starts one per group. `features/load/jobs.ts` keeps at most one load per group running, in the `load_jobs` table: a load asked for while one runs is marked on that row, and the running load starts it as it finishes. The last load to finish in a library rebuilds its search index, once rather than per document. +Every load is one document: adding a document, a new version of one (see Webhooks below), and the owner's "reload everything", which starts one per group. `features/load/jobs.ts` keeps at most one load per group running, in the `load_jobs` table: a load asked for while one runs is marked on that row, and the running load starts it as it finishes. Each load that wrote to its group rebuilds its library's search index and bumps its version, so every load stands alone and "reload everything" simply starts them all at once. Each workflow carries a `sessionId` whose tokens it calls Onshape under after the request has ended: the requesting user's, or the owner's for a webhook. diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index 719998b19..55362bce6 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -142,11 +142,15 @@ export async function requestLoads( ); } + // Started together: each load stands alone, down to its library's search + // index, so nothing waits on any other. + const batches: (typeof toStart)[] = []; for (let i = 0; i < toStart.length; i += CREATE_BATCH) { - await env.LOAD_DOCUMENT_WORKFLOW.createBatch( - toStart.slice(i, i + CREATE_BATCH) - ); + batches.push(toStart.slice(i, i + CREATE_BATCH)); } + await Promise.all( + batches.map((batch) => env.LOAD_DOCUMENT_WORKFLOW.createBatch(batch)) + ); const libraryIds = new Set(requests.map((request) => request.libraryId)); for (const libraryId of libraryIds) { @@ -162,15 +166,24 @@ export async function requestLoads( type FinishOutcome = "done" | "rerun"; /** - * Called by a load as it finishes, whether it failed or not. Starts the load - * queued behind it; otherwise lets the group go, and the last load in the - * library rebuilds its search index, once rather than per document. + * Called by a load as it finishes, whether it failed or not. Publishes what it + * wrote, when it wrote anything, and starts the load queued behind it or lets + * the group go. */ export async function finishLoad( env: AppBindings, - params: LoadDocumentParams + params: LoadDocumentParams, + changed: boolean ): Promise<FinishOutcome> { const db = getDb(env.DB); + // Rebuilt before the bump: the new version makes /search-db immutable, so + // a client fetching in between would pin the stale index for a year. + if (changed) { + await rebuildSearchDb(env.BLOB, db, params.libraryId); + await bumpLibraryVersion(db, params.libraryId); + await pushLibraryChanged(env, params.libraryId); + } + const job = await db .select() .from(loadJobs) @@ -198,18 +211,11 @@ export async function finishLoad( await db.delete(loadJobs).where(eq(loadJobs.groupId, params.groupId)); } - const status = await runningStatus(env, params.libraryId); - // Rebuilt before the bump: the new version makes /search-db immutable, so - // a client fetching in between would pin the stale index for a year. A - // load that is not the last only bumps, so the library shows its document - // meanwhile, and whatever search that version pins is corrected by the - // last load's. - if (!status.running) { - await rebuildSearchDb(env.BLOB, db, params.libraryId); - } - await bumpLibraryVersion(db, params.libraryId); - await pushLibraryChanged(env, params.libraryId); - await pushJobStatus(env, params.libraryId, status); + await pushJobStatus( + env, + params.libraryId, + await runningStatus(env, params.libraryId) + ); return outcome; } diff --git a/src/backend/features/load/jobs.worker.test.ts b/src/backend/features/load/jobs.worker.test.ts index 0ec1b5489..d3d9123cb 100644 --- a/src/backend/features/load/jobs.worker.test.ts +++ b/src/backend/features/load/jobs.worker.test.ts @@ -108,7 +108,7 @@ describe("document loads", () => { .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "create") .mockResolvedValue({ id: "next" } as never); - expect(await finishLoad(env, params("a"))).toBe("rerun"); + expect(await finishLoad(env, params("a"), true)).toBe("rerun"); expect(create.mock.calls[0][0]?.params).toMatchObject({ groupId: "a", @@ -117,17 +117,17 @@ describe("document loads", () => { expect(await job("a")).toMatchObject({ rerun: false }); }); - // Once per library rather than once per document. - it("rebuilds search only as the library's last load finishes", async () => { + // Each load publishes what it wrote, standing alone. + it("rebuilds search for a load that changed its group", async () => { const rebuild = vi .spyOn(LibraryDb, "rebuildSearchDb") .mockResolvedValue(""); await requestLoads(env, [params("a"), params("b")]); - await finishLoad(env, params("a")); - expect(rebuild).not.toHaveBeenCalled(); + await finishLoad(env, params("a"), true); + expect(rebuild).toHaveBeenCalledOnce(); - await finishLoad(env, params("b")); + await finishLoad(env, params("b"), false); expect(rebuild).toHaveBeenCalledOnce(); expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ running: false diff --git a/src/backend/features/load/routes.ts b/src/backend/features/load/routes.ts index 89fe7d8e8..8ff7fbe57 100644 --- a/src/backend/features/load/routes.ts +++ b/src/backend/features/load/routes.ts @@ -8,8 +8,8 @@ import { requestLoads } from "./jobs"; export const loadRoutes = getApp(); /** - * POST /api/reload-all — force reloads every document in every library, one - * load per group. New versions reload themselves through their webhooks, so + * POST /api/reload-all — force reloads every document in every library: starts + * one load per group, all at once, and returns without waiting on any. New versions reload themselves through their webhooks, so * this is for what they cannot catch: a change in how the app reads documents, * or a document that has never been loaded with a webhook to register. */ diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 0fc472e3a..ffce34a15 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -64,12 +64,19 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< ): Promise<LoadResult> { const params = event.payload; const ctx = createLoadContext(this.env, params.sessionId, step); + // Anything but a skip wrote to the group: a failure flags it. + let result: LoadResult = { status: "failed" }; try { - return await loadDocument(ctx, params); + result = await loadDocument(ctx, params); + return result; } finally { // Whatever happened, so the group is let go and whatever queued // behind this load starts. - await step.do("finish", () => finishLoad(this.env, params)); + const changed = + result.status === "loaded" || result.status === "failed"; + await step.do("finish", () => + finishLoad(this.env, params, changed) + ); } } } diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 2a4bca2ef..416de91dd 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,11 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 50b17c2e2ef4dfa6004b69eb5b2ebf03) +// Generated by Wrangler by running `wrangler types` (hash: 11adf3a92ec427099f88e645e61af8af) // Runtime types generated with workerd@1.20260811.1 2026-05-14 nodejs_compat interface __BaseEnv_Env { KV: KVNamespace; BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; + OWNER_USER_ID: "5eace32713a966103efd2aa0"; NODE_ENV: "production" | "development"; API_ACCESS_KEY: string; API_SECRET_KEY: string; @@ -27,6 +28,7 @@ declare namespace Cloudflare { BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; + OWNER_USER_ID: "5eace32713a966103efd2aa0"; NODE_ENV: "production"; API_ACCESS_KEY: string; API_SECRET_KEY: string; @@ -43,6 +45,7 @@ declare namespace Cloudflare { BLOB: R2Bucket; DB: D1Database; ASSETS: Fetcher; + OWNER_USER_ID: "5eace32713a966103efd2aa0"; NODE_ENV: "production"; API_ACCESS_KEY: string; API_SECRET_KEY: string; @@ -61,7 +64,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "NODE_ENV" | "API_ACCESS_KEY" | "API_SECRET_KEY" | "OAUTH_CLIENT_ID" | "OAUTH_CLIENT_SECRET" | "SESSION_SECRET" | "VITE_ACCESS_LEVEL_OVERRIDE">> {} + interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "OWNER_USER_ID" | "NODE_ENV" | "API_ACCESS_KEY" | "API_SECRET_KEY" | "OAUTH_CLIENT_ID" | "OAUTH_CLIENT_SECRET" | "SESSION_SECRET" | "VITE_ACCESS_LEVEL_OVERRIDE">> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index 2e77df385..4cc37c197 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -95,10 +95,9 @@ * https://developers.cloudflare.com/workers/configuration/secrets/ */ "vars": { - // The Onshape user id granted the owner access level, whose session - // webhook-triggered reloads run under. Unset grants nobody; each - // environment below needs it too. - // "OWNER_USER_ID": "", + // The Onshape user granted the owner access level, whose session + // webhook-triggered loads run under. + "OWNER_USER_ID": "5eace32713a966103efd2aa0", "NODE_ENV": "development" }, /** @@ -152,6 +151,7 @@ ] }, "vars": { + "OWNER_USER_ID": "5eace32713a966103efd2aa0", // Production, not because cert is production, but because // FORCE_SIGNED_IN and VITE_ACCESS_LEVEL_OVERRIDE are gated on // this alone: anything but "production" leaves them armed. @@ -203,6 +203,7 @@ ] }, "vars": { + "OWNER_USER_ID": "5eace32713a966103efd2aa0", "NODE_ENV": "production" }, "limits": { From b05124cb6a914f35b97a74357b6604e948137890 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 03:22:16 +0000 Subject: [PATCH 27/88] Store parameter roles, drop legacy upgrades, and tidy records and units - Roles are recognized once as a document is loaded and stored on the parameter; indexing, keys and the UI read parameter.role instead of matching names again. The name folding lives with the one detector. - legacy.ts is gone: a force reload rewrites records and parameters in the current shape. A favorite saved in base units stays valid, shown in meters until saved again. - searchRecordsOf(row) replaces toRecords then toSearchRecords at every call site, and the search index is built from those records. - UnitInfo's fields are required. useUnitInfo resolves the document's units, or a placeholder (inches, degrees) outside a document or when they fail to load, so nothing downstream merges defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- AGENTS.md | 3 - src/__test_utils__/configuration-fixtures.ts | 12 ++- src/backend/db/schema.ts | 12 +-- .../configurations/combinations.test.ts | 24 +++--- .../features/configurations/combinations.ts | 8 +- .../features/configurations/contract.ts | 33 ++++++-- src/backend/features/configurations/legacy.ts | 79 ------------------- .../features/configurations/roles.test.ts | 44 ++++++----- src/backend/features/configurations/roles.ts | 48 +++++------ src/backend/features/configurations/routes.ts | 13 +-- .../features/configurations/selection.test.ts | 4 +- src/backend/features/configurations/utils.ts | 38 ++++----- src/backend/features/favorites/routes.ts | 15 +--- .../features/favorites/routes.worker.test.ts | 31 +------- src/backend/features/library/db.ts | 28 +++---- .../library/insertables/routes.worker.test.ts | 6 +- .../features/load/parse-configuration.ts | 3 +- src/backend/features/search/build.test.ts | 20 ++--- src/backend/features/search/build.ts | 25 ++---- src/backend/features/search/records.ts | 27 +++++++ src/frontend/components/parameter-role.tsx | 2 +- .../components/parsed-section.tsx | 3 +- .../insert/components/configurations.test.tsx | 7 +- .../insert/components/configurations.tsx | 14 +--- .../features/insert/quantity-box.test.ts | 10 ++- src/frontend/features/insert/queries.ts | 27 ++++--- src/frontend/features/search/search.test.ts | 3 +- 27 files changed, 221 insertions(+), 318 deletions(-) delete mode 100644 src/backend/features/configurations/legacy.ts diff --git a/AGENTS.md b/AGENTS.md index 1aee9ad86..6783cdf4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,9 +90,6 @@ default" — goes through `canonicalValue`/`canonicalValues`, never through a ke Don't add a third form: if something needs a different view of a selection, it wants a function in `selection.ts`, not a new shape. -Rows written before selections kept expressions are upgraded on read by -`configurations/legacy.ts`; a reload rewrites them in the current shape. - # Tests `npm test` runs two Vitest projects. A backend test that needs bindings (D1, diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index b465e98df..898927010 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -9,7 +9,8 @@ import { type EnumParameter, type QuantityParameter, type StringParameter, - type UnitInfo + type UnitInfo, + ParameterRole } from "@backend/features/configurations/contract"; import { QuantityType, Unit } from "@backend/features/configurations/enums"; import { quantityDefault } from "@backend/features/configurations/selection"; @@ -57,6 +58,15 @@ export function stringParam(id: string): StringParameter { return { id, name: id, default: "", type: ParameterType.STRING }; } +/** A text parameter recognized, as a load would, as a derivation variable. */ +export function derivationParam(id: string): StringParameter { + return { + ...stringParam(id), + name: "Derivation Variable", + role: ParameterRole.DERIVATION_VARIABLE + }; +} + /** * A length quantity parameter defaulting to 1 inch, its default spelled the way * `parseOnshapeConfiguration` stores one: from `defaultValue` and `unit`. diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index cb119b142..eea0e0242 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -19,10 +19,6 @@ import { PartMetadata } from "../features/configurations/contract"; import { BuildIssue, knownBuildIssues } from "../features/build-checker/issues"; -import { - upgradeParameters, - upgradeRecords -} from "../features/configurations/legacy"; /** A JSON column whose stored rows may predate its current shape. */ function upgradedJson<T>(upgrade: (stored: T) => T) { @@ -191,14 +187,14 @@ export const configurations = sqliteTable("configurations", { insertableId: text("insertable_id") .primaryKey() .references(() => insertables.id, { onDelete: "cascade" }), - parameters: upgradedJson<ConfigurationParameter[]>(upgradeParameters)( - "parameters" - ) + parameters: text("parameters", { mode: "json" }) + .$type<ConfigurationParameter[]>() .notNull() .default([]), // One record per indexed configuration. Empty unless the insertable is // indexed; the element's own metadata lives on `insertables.partMetadata`. - records: upgradedJson<ConfigurationRecord[]>(upgradeRecords)("records") + records: text("records", { mode: "json" }) + .$type<ConfigurationRecord[]>() .notNull() .default([]) }); diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index 28d51d491..1bdd28ec1 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -12,6 +12,7 @@ import { import { OptionVisibilityType, ConfigurationParameter, + ParameterRole, VisibilityCondition, VisibilityType } from "./contract"; @@ -229,25 +230,28 @@ describe("isIndexingEnabled", () => { describe("isIndexedParameter", () => { it("varies enum and boolean parameters", () => { - expect(isIndexedParameter(enumParam("a", ["x", "y"]), [])).toBe(true); - expect(isIndexedParameter(boolParam("b"), [])).toBe(true); + 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); - expect(isIndexedParameter(stringParam("s"), [])).toBe(false); + expect(isIndexedParameter(quantityParam("q"))).toBe(false); + expect(isIndexedParameter(stringParam("s"))).toBe(false); }); it("does not vary a parameter an admin excluded", () => { - expect(isIndexedParameter(enumParam("a", ["x", "y"]), [], ["a"])).toBe( + expect(isIndexedParameter(enumParam("a", ["x", "y"]), ["a"])).toBe( false ); }); // A role says how a part is drawn or derived, never which part it is. it("never varies a parameter with a role", () => { - const color = { ...enumParam("p", ["x", "y"]), name: "Color" }; - expect(isIndexedParameter(color, [color])).toBe(false); + const color = { + ...enumParam("p", ["x", "y"]), + role: ParameterRole.COLOR + }; + expect(isIndexedParameter(color)).toBe(false); }); // The card reports indexing off this helper, so it has to describe exactly @@ -257,7 +261,7 @@ describe("isIndexedParameter", () => { enumParam("varied", ["x", "y"]), boolParam("flag"), enumParam("excluded", ["x", "y"]), - { ...boolParam("color"), name: "Color" }, + { ...boolParam("color"), role: ParameterRole.COLOR }, quantityParam("length") ]; const excluded = ["excluded"]; @@ -272,9 +276,7 @@ describe("isIndexedParameter", () => { ); expect([...enumeratedKeys].sort()).toEqual( parameters - .filter((parameter) => - isIndexedParameter(parameter, parameters, excluded) - ) + .filter((parameter) => isIndexedParameter(parameter, excluded)) .map((parameter) => parameter.id) .sort() ); diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index 527cec689..88afdd913 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -11,7 +11,6 @@ import { } from "./contract"; import { evaluateCondition, getVisibleOptions } from "./utils"; import { ElementType } from "../../lib/onshape/element-type"; -import { parameterRole } from "./roles"; /** * The most combinations we enumerate for one insertable; beyond it nothing is @@ -112,13 +111,12 @@ export function effectiveExclusions( */ export function isIndexedParameter( parameter: ConfigurationParameter, - parameters: ConfigurationParameter[], excludedParameterIds: readonly string[] = [] ): parameter is EnumParameter | BooleanParameter { return ( (parameter.type === ParameterType.ENUM || parameter.type === ParameterType.BOOLEAN) && - parameterRole(parameter, parameters) === undefined && + parameter.role === undefined && !excludedParameterIds.includes(parameter.id) ); } @@ -154,7 +152,7 @@ export function countCombinations( ): number | null { // Depth-first: only the count is wanted, so one path is held rather than all. const indexed = parameters.filter((parameter) => - isIndexedParameter(parameter, parameters, excludedParameterIds) + isIndexedParameter(parameter, excludedParameterIds) ); let count = 0; let capped = false; @@ -206,7 +204,7 @@ export function enumerateConfigurations( let configurations: PartialSelection[] = [{}]; for (const parameter of parameters) { - if (!isIndexedParameter(parameter, parameters, excludedParameterIds)) { + if (!isIndexedParameter(parameter, excludedParameterIds)) { continue; } diff --git a/src/backend/features/configurations/contract.ts b/src/backend/features/configurations/contract.ts index b568a2774..ad5e71959 100644 --- a/src/backend/features/configurations/contract.ts +++ b/src/backend/features/configurations/contract.ts @@ -97,11 +97,30 @@ export type ConfigurationParameter = | BooleanParameter | StringParameter; +/** + * Parameters that are about how a part is derived or drawn rather than which + * part it is, identified as a document is loaded; see `roles.ts`. + */ +export enum ParameterRole { + /** + * A text parameter a document adds so one part can be derived into a part + * studio more than once: Onshape refuses a second derive of the same + * configuration, and a unique value here makes each one different. + */ + DERIVATION_VARIABLE = "derivation-variable", + COLOR = "color", + /** One of a color's R, G and B, when a part spells a color out as three. */ + COLOR_CHANNEL = "color-channel", + TESSELLATION = "tessellation" +} + interface ConfigurationParameterBase { id: string; name: string; default: string; condition?: VisibilityCondition; + /** Absent for an ordinary parameter, which is most of them. */ + role?: ParameterRole; } export interface BooleanParameter extends ConfigurationParameterBase { type: ParameterType.BOOLEAN; @@ -198,13 +217,11 @@ export interface Configuration { * The current document's units. Every field is optional: an absent one leaves * the quantity on its own default unit. */ +/** A document's units, which quantities are shown in. */ export interface UnitInfo { - angleUnit?: Unit; - lengthUnit?: Unit; - lengthPrecision?: number; - anglePrecision?: number; - realPrecision?: number; + angleUnit: Unit; + lengthUnit: Unit; + lengthPrecision: number; + anglePrecision: number; + realPrecision: number; } - -/** No document units available; each quantity falls back to its own unit. */ -export const EMPTY_UNIT_INFO: UnitInfo = {}; diff --git a/src/backend/features/configurations/legacy.ts b/src/backend/features/configurations/legacy.ts deleted file mode 100644 index 97cdc0492..000000000 --- a/src/backend/features/configurations/legacy.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Reads configuration data stored before selections kept what was entered. - * Rows then held keys where they now hold values, and spelled quantities in - * base units; a reload of the row's group rewrites it in the current shape, so - * each of these can go once every library has been reloaded since. - */ -import { - type ConfigurationParameter, - type ConfigurationRecord, - ParameterType, - type PartialSelection -} from "./contract"; -import { canonicalValue, quantityDefault } from "./selection"; -import { decodeConfiguration } from "./utils"; -import { evaluateBaseValue, formatValueInUnit } from "./input-parser"; - -/** A record stored under the key of what it probed, rather than the values. */ -interface LegacyRecord extends Omit<ConfigurationRecord, "values"> { - configurationKey: string; -} - -/** - * A record's values. A legacy key named only enums and booleans, which it - * spelled as entered, so decoding it gives the values back exactly. - */ -export function upgradeRecords( - records: (ConfigurationRecord | LegacyRecord)[] -): ConfigurationRecord[] { - return records.map((record) => { - if ("values" in record) { - return record; - } - const { configurationKey, ...rest } = record; - return { ...rest, values: decodeConfiguration(configurationKey) }; - }); -} - -/** A quantity's default in its own unit, where it used to be in base units. */ -export function upgradeParameters( - parameters: ConfigurationParameter[] -): ConfigurationParameter[] { - return parameters.map((parameter) => - parameter.type === ParameterType.QUANTITY - ? { ...parameter, default: quantityDefault(parameter) } - : parameter - ); -} - -/** - * A stored selection's quantities in their parameter's unit, where they were - * saved in base units: "0.0508 m" reads back as "2 in". Only a value spelled - * exactly as a base-unit value was is touched, so an expression somebody typed - * is left as they typed it. - */ -export function upgradeSelection( - selection: PartialSelection, - parameters: ConfigurationParameter[] -): PartialSelection { - const upgraded = { ...selection }; - for (const parameter of parameters) { - const value = upgraded[parameter.id]; - if ( - parameter.type !== ParameterType.QUANTITY || - value === undefined || - canonicalValue(parameter, value) !== value - ) { - continue; - } - const base = evaluateBaseValue( - value, - parameter.quantityType, - parameter.unit - ); - if (base !== undefined) { - upgraded[parameter.id] = formatValueInUnit(base, parameter.unit); - } - } - return upgraded; -} diff --git a/src/backend/features/configurations/roles.test.ts b/src/backend/features/configurations/roles.test.ts index 20c802940..74be2cbaa 100644 --- a/src/backend/features/configurations/roles.test.ts +++ b/src/backend/features/configurations/roles.test.ts @@ -1,59 +1,65 @@ import { describe, expect, it } from "vitest"; -import { isDerivationVariable, parameterRole, ParameterRole } from "./roles"; +import { isDerivationVariable, withRoles } from "./roles"; +import { type ConfigurationParameter, ParameterRole } from "./contract"; import { enumParam, stringParam } from "../../../__test_utils__/configuration-fixtures"; -const named = (name: string) => ({ ...enumParam("p", ["x", "y"]), name }); +const named = (name: string): ConfigurationParameter => ({ + ...enumParam(name, ["x", "y"]), + name +}); + +/** The role each of `parameters` is recognized in, alongside the rest. */ +const rolesOf = (...parameters: ConfigurationParameter[]) => + withRoles(parameters).map((parameter) => parameter.role); -describe("parameterRole", () => { +describe("recognizing roles", () => { it.each([ ["Color", ParameterRole.COLOR], ["Part colour", ParameterRole.COLOR], ["Tessellation Quality", ParameterRole.TESSELLATION], ["Tesselation quality", ParameterRole.TESSELLATION] ])("recognizes %s", (name, role) => { - expect(parameterRole(named(name))).toBe(role); + expect(rolesOf(named(name))).toEqual([role]); }); it.each(["Length", "Bearing", "Gear Ratio", "Colorway"])( "gives an ordinary parameter named %s no role", (name) => { - expect(parameterRole(named(name))).toBeUndefined(); + expect(rolesOf(named(name))).toEqual([undefined]); } ); it("recognizes a color channel beside its two siblings", () => { - const channels = ["R", "G", "B"].map(named); - for (const channel of channels) { - expect(parameterRole(channel, channels)).toBe( - ParameterRole.COLOR_CHANNEL - ); - } + expect(rolesOf(named("R"), named("G"), named("B"))).toEqual([ + ParameterRole.COLOR_CHANNEL, + ParameterRole.COLOR_CHANNEL, + ParameterRole.COLOR_CHANNEL + ]); }); // A lone "B" is as likely a size as a blue. it("gives a single letter with no channel siblings no role", () => { - const lone = named("B"); - expect(parameterRole(lone, [named("A"), lone])).toBeUndefined(); + expect(rolesOf(named("A"), named("B"))).toEqual([undefined, undefined]); }); }); describe("derivation variables", () => { it("recognizes a text parameter named for derivation", () => { - const parameter = { ...stringParam("d"), name: "Derivation Variable" }; - expect(parameterRole(parameter)).toBe( - ParameterRole.DERIVATION_VARIABLE - ); + const [parameter] = withRoles([ + { ...stringParam("d"), name: "Derivation Variable" } + ]); + expect(parameter.role).toBe(ParameterRole.DERIVATION_VARIABLE); expect(isDerivationVariable(parameter)).toBe(true); }); // Only a text one can take the unique value the app fills in, so one of // another type is an ordinary parameter: indexed and editable as usual. it("gives one of another type no role", () => { - const parameter = named("Derivation Variable"); - expect(parameterRole(parameter)).toBeUndefined(); + const [parameter] = withRoles([named("Derivation Variable")]); + expect(parameter.role).toBeUndefined(); expect(isDerivationVariable(parameter)).toBe(false); }); }); diff --git a/src/backend/features/configurations/roles.ts b/src/backend/features/configurations/roles.ts index 2f03cd475..378b3f458 100644 --- a/src/backend/features/configurations/roles.ts +++ b/src/backend/features/configurations/roles.ts @@ -1,23 +1,13 @@ /** - * Parameters that are about how a part is derived or drawn rather than which - * part it is. Onshape records nothing that says so, so they are recognized by - * name; see `parameterRole`. + * Recognizing the parameters that play a role. Onshape records nothing that + * says so, so they are recognized by name, once, as a document is loaded; what + * is found is stored on the parameter as `role`. */ -import { type ConfigurationParameter, ParameterType } from "./contract"; - -export enum ParameterRole { - /** - * A text parameter a document adds so one part can be derived into a part - * studio more than once: Onshape refuses a second derive of the same - * configuration, and a unique value here makes each one different. Only a - * text one: the app fills it with a unique value, which is text. - */ - DERIVATION_VARIABLE = "derivation-variable", - COLOR = "color", - /** One of a color's R, G and B, when a part spells a color out as three. */ - COLOR_CHANNEL = "color-channel", - TESSELLATION = "tessellation" -} +import { + type ConfigurationParameter, + ParameterRole, + ParameterType +} from "./contract"; /** A color's channels, when a part spells one out as three parameters. */ const COLOR_CHANNELS = [ @@ -31,12 +21,12 @@ function normalizedName(parameter: ConfigurationParameter): string { /** * The role a parameter plays, if any. A lone "R" or "B" could mean anything, - * so a channel counts only beside its two siblings, which is what `parameters` - * is for. + * so a channel counts only beside its two siblings. A derivation variable is + * only a text one: the app fills it with a unique value, which is text. */ -export function parameterRole( +function identifyRole( parameter: ConfigurationParameter, - parameters: ConfigurationParameter[] = [] + names: Set<string> ): ParameterRole | undefined { const name = normalizedName(parameter); if ( @@ -51,15 +41,25 @@ export function parameterRole( if (/tess?ell?ation/.test(name)) { return ParameterRole.TESSELLATION; } - const names = new Set(parameters.map(normalizedName)); const isChannel = COLOR_CHANNELS.some( (set) => set.includes(name) && set.every((entry) => names.has(entry)) ); return isChannel ? ParameterRole.COLOR_CHANNEL : undefined; } +/** An insertable's parameters, each carrying the role it was recognized in. */ +export function withRoles( + parameters: ConfigurationParameter[] +): ConfigurationParameter[] { + const names = new Set(parameters.map(normalizedName)); + return parameters.map((parameter) => { + const role = identifyRole(parameter, names); + return role ? { ...parameter, role } : parameter; + }); +} + export function isDerivationVariable( parameter: ConfigurationParameter ): boolean { - return parameterRole(parameter) === ParameterRole.DERIVATION_VARIABLE; + return parameter.role === ParameterRole.DERIVATION_VARIABLE; } diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 141cd18b2..64efecbed 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -8,8 +8,8 @@ import { getDb } from "../../db/client"; import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; import { configurations, insertables } from "../../db/schema"; import { type ConfigurationResult, type UnitInfo } from "./contract"; -import { toSearchRecords } from "../search/records"; -import { DEFAULT_QUANTITY_PRECISION, toRecords } from "./utils"; +import { searchRecordsOf } from "../search/records"; +import { DEFAULT_QUANTITY_PRECISION } from "./utils"; import { QuantityType, type Unit } from "./enums"; import { INSTANCE_TYPES } from "../../lib/onshape/path"; import { internalError } from "../../lib/api-error"; @@ -54,14 +54,9 @@ configurationRoutes.get( ); } - const parameters = config.parameters ?? []; const result: ConfigurationResult = { - parameters, - records: toSearchRecords( - toRecords(config.partMetadata, config.records ?? []), - parameters, - config.vendors - ) + parameters: config.parameters ?? [], + records: searchRecordsOf(config) }; return c.json(result); } diff --git a/src/backend/features/configurations/selection.test.ts b/src/backend/features/configurations/selection.test.ts index 65dd1201e..70d556a15 100644 --- a/src/backend/features/configurations/selection.test.ts +++ b/src/backend/features/configurations/selection.test.ts @@ -22,7 +22,7 @@ import { boolParam, enumParam, quantityParam, - stringParam + derivationParam } from "../../../__test_utils__/configuration-fixtures"; const size = enumParam("size", ["s", "l"]); @@ -227,7 +227,7 @@ describe("formatValue", () => { }); describe("derivation variables", () => { - const derivation = { ...stringParam("dv"), name: "Derivation Variable" }; + const derivation = derivationParam("dv"); const params: ConfigurationParameter[] = [size, derivation]; // Unique to each insert by design, so it must not split one render in two. diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index c43ee11e4..6d5800f79 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -1,5 +1,4 @@ import { - type ConfigurationRecord, type PartMetadata, type PartialSelection, Selection, @@ -238,6 +237,18 @@ export function getVisibleOptions( /** Display precision used when the document's units aren't available. */ export const DEFAULT_QUANTITY_PRECISION = 3; +/** + * The units shown outside a document, which has none to take: Onshape's own + * for a new document, in the units the library's parts are drawn in. + */ +export const PLACEHOLDER_UNIT_INFO: UnitInfo = { + lengthUnit: Unit.INCH, + angleUnit: Unit.DEGREE, + lengthPrecision: DEFAULT_QUANTITY_PRECISION, + anglePrecision: DEFAULT_QUANTITY_PRECISION, + realPrecision: DEFAULT_QUANTITY_PRECISION +}; + /** * 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. @@ -254,24 +265,21 @@ export function getEvaluateOptions( if (quantityType === QuantityType.LENGTH) { return { quantityType, - displayPrecision: - unitInfo.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: unitInfo.lengthUnit ?? parameter.unit, + displayPrecision: unitInfo.lengthPrecision, + displayUnit: unitInfo.lengthUnit, ...minAndMax }; } else if (quantityType === QuantityType.ANGLE) { return { quantityType, - displayPrecision: - unitInfo.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: unitInfo.angleUnit ?? parameter.unit, + displayPrecision: unitInfo.anglePrecision, + displayUnit: unitInfo.angleUnit, ...minAndMax }; } else if (quantityType === QuantityType.REAL) { return { quantityType, - displayPrecision: - unitInfo.realPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayPrecision: unitInfo.realPrecision, displayUnit: Unit.UNITLESS, ...minAndMax }; @@ -283,15 +291,3 @@ export function getEvaluateOptions( ...minAndMax }; } - -/** - * An insertable's full record list: its own part data first — the record an - * unset configuration falls back to — then one per indexed configuration. - */ -export function toRecords( - partMetadata: PartMetadata | null, - records: ConfigurationRecord[] -): ConfigurationRecord[] { - if (!partMetadata) return records; - return [{ ...partMetadata, values: {} }, ...records]; -} diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index 334c740b0..3eabd7b9d 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -18,15 +18,13 @@ import { toSelection, toStoredSelection } from "../configurations/selection"; -import { upgradeSelection } from "../configurations/legacy"; import { MAX_FAVORITES, type Favorite, type FavoritesData } from "./contract"; import { type ConfigurationParameter, type PartialSelection, type SearchRecord } from "../configurations/contract"; -import { toRecords } from "../configurations/utils"; -import { toSearchRecords } from "../search/records"; +import { searchRecordsOf } from "../search/records"; import type { LibraryId } from "../library/library-id"; import { z } from "zod"; import { validate } from "../../lib/validate"; @@ -85,10 +83,7 @@ async function getFavorites( // parameter existed still has to answer as a selection. const defaultSelection = row.defaultSelection ? toSelection( - toStoredSelection( - upgradeSelection(row.defaultSelection, parameters), - parameters - ), + toStoredSelection(row.defaultSelection, parameters), parameters ) : undefined; @@ -178,13 +173,11 @@ async function getConfigurations( ); return new Map( reads.flat().map((row) => { - const parameters = row.parameters ?? []; - const records = toRecords(row.partMetadata, row.records ?? []); return [ row.insertableId, { - parameters, - records: toSearchRecords(records, parameters, row.vendors) + parameters: row.parameters ?? [], + records: searchRecordsOf(row) } ]; }) diff --git a/src/backend/features/favorites/routes.worker.test.ts b/src/backend/features/favorites/routes.worker.test.ts index 1179008fa..f159833f1 100644 --- a/src/backend/features/favorites/routes.worker.test.ts +++ b/src/backend/features/favorites/routes.worker.test.ts @@ -11,7 +11,7 @@ import { MAX_FAVORITES } from "./contract"; import { configurationRecord, quantityParam, - stringParam + derivationParam } from "../../../__test_utils__/configuration-fixtures"; const partMetadata = (partNumber: string) => @@ -283,30 +283,6 @@ describe("favorites routes", () => { ); }); - // Saved before selections kept what was entered, in base units. - it("reads a legacy base-unit quantity back in its parameter's unit", async () => { - await seedPartStudio(db); - await seedConfiguration(db); - await db - .update(configurations) - .set({ parameters: [quantityParam("length")] }) - .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); - const favoriteId = await seedFavorite(db, TEST_PART_STUDIO_ID); - await db - .update(favorites) - .set({ defaultSelection: { length: "0.0508 m" } }) - .where(eq(favorites.id, favoriteId)); - - const res = await createTestApp().request( - favoritesUrl, - jsonRequest("GET"), - env - ); - expect(soleFavorite(await res.json()).defaultSelection).toEqual({ - length: "2 in" - }); - }); - it("only returns the current user's favorites", async () => { await seedTestData(db); await seedFavorite(db, TEST_PART_STUDIO_ID, "other-user"); @@ -608,10 +584,7 @@ describe("favorites routes", () => { await db .update(configurations) .set({ - parameters: [ - quantityParam("length"), - { ...stringParam("dv"), name: "Derivation Variable" } - ] + parameters: [quantityParam("length"), derivationParam("dv")] }) .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index a31db63f0..87c4fa280 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -10,8 +10,9 @@ import { } from "../../db/schema"; import { LibraryId } from "./library-id"; import { InsertableOut, LibraryOut, Insertables, Groups } from "./contract"; -import { toRecords } from "../configurations/utils"; -import { buildSearchDb, type IndexedConfiguration } from "../search/build"; +import { type SearchRecord } from "../configurations/contract"; +import { buildSearchDb } from "../search/build"; +import { searchRecordsOf } from "../search/records"; /** * Assembles the full `LibraryOut` (groups + insertables, in sort order) for a @@ -198,7 +199,7 @@ export async function rebuildSearchDb( ): Promise<string> { const [libraryData, indexed] = await Promise.all([ getLibraryOut(db, libraryId), - getIndexedConfigurations(db, libraryId) + getSearchRecords(db, libraryId) ]); const searchDb = JSON.stringify(buildSearchDb(libraryData, indexed)); // Uncompressed: encoding here would leave the runtime compressing an @@ -210,18 +211,18 @@ export async function rebuildSearchDb( } /** - * What `buildSearchDb` indexes: an element's own part data plus one record per - * indexed configuration, and the parameters those are read against. Left - * joined — an unconfigurable element has no row. + * What `buildSearchDb` indexes: each insertable's records. Left joined — an + * unconfigurable element has no configuration row. */ -async function getIndexedConfigurations( +async function getSearchRecords( db: Db, libraryId: LibraryId -): Promise<Record<string, IndexedConfiguration>> { +): Promise<Record<string, SearchRecord[]>> { const rows = await db .select({ id: insertables.id, partMetadata: insertables.partMetadata, + vendors: insertables.vendors, parameters: configurations.parameters, records: configurations.records }) @@ -233,14 +234,9 @@ async function getIndexedConfigurations( .where(eq(insertables.libraryId, libraryId)) .all(); - const indexed: Record<string, IndexedConfiguration> = {}; - for (const row of rows) { - const records = toRecords(row.partMetadata, row.records ?? []); - if (records.length > 0) { - indexed[row.id] = { parameters: row.parameters ?? [], records }; - } - } - return indexed; + return Object.fromEntries( + rows.map((row) => [row.id, searchRecordsOf(row)]) + ); } /** The library an insertable is in, for a route that names only the insertable. */ diff --git a/src/backend/features/library/insertables/routes.worker.test.ts b/src/backend/features/library/insertables/routes.worker.test.ts index 55ebe0fa5..66d404c28 100644 --- a/src/backend/features/library/insertables/routes.worker.test.ts +++ b/src/backend/features/library/insertables/routes.worker.test.ts @@ -31,7 +31,7 @@ import { AUTO_INDEX_THRESHOLD } from "../../configurations/combinations"; import { enumParam, quantityParam, - stringParam + derivationParam } from "../../../../__test_utils__/configuration-fixtures"; const db = getDb(env.DB); @@ -204,9 +204,7 @@ describe("insertable routes", () => { await db .update(configurations) .set({ - parameters: [ - { ...stringParam("dv"), name: "Derivation Variable" } - ] + parameters: [derivationParam("dv")] }) .where(eq(configurations.insertableId, TEST_PART_STUDIO_ID)); const spy = vi diff --git a/src/backend/features/load/parse-configuration.ts b/src/backend/features/load/parse-configuration.ts index 99212ab95..1decf7174 100644 --- a/src/backend/features/load/parse-configuration.ts +++ b/src/backend/features/load/parse-configuration.ts @@ -1,3 +1,4 @@ +import { withRoles } from "../configurations/roles"; import { type EnumOption, type OptionVisibilityCondition, @@ -169,5 +170,5 @@ export function parseOnshapeConfiguration( } } - return parameters; + return withRoles(parameters); } diff --git a/src/backend/features/search/build.test.ts b/src/backend/features/search/build.test.ts index d386e5d93..a587d2a7e 100644 --- a/src/backend/features/search/build.test.ts +++ b/src/backend/features/search/build.test.ts @@ -151,15 +151,7 @@ describe("buildSearchDb", () => { it("keeps a placeholder part number out of the index and the records", () => { const db = buildSearchDb(library("Spacer"), { - i1: { - parameters: [], - records: [ - record({ - partNumber: "N/A", - name: "Spacer" - }) - ] - } + i1: unconfigured([record({ partNumber: "N/A", name: "Spacer" })]) }); expect(db.search("n/a")).toEqual([]); expect(stored(db).records).toEqual([ @@ -170,16 +162,16 @@ describe("buildSearchDb", () => { // The vendor is a resolution fallback, not something to match against. it("never searches the vendor", () => { const db = buildSearchDb(library("Spacer", [Vendor.WCP]), { - i1: { - parameters: [], - records: [ + i1: unconfigured( + [ record({ partNumber: "WCP-1025", name: "Spacer", vendor: "WestCoast Products" }) - ] - } + ], + [Vendor.WCP] + ) }); expect(db.search("westcoast")).toEqual([]); expect(stored(db).records).toEqual([ diff --git a/src/backend/features/search/build.ts b/src/backend/features/search/build.ts index f05a49b97..460811c9b 100644 --- a/src/backend/features/search/build.ts +++ b/src/backend/features/search/build.ts @@ -4,18 +4,9 @@ */ import MiniSearch from "minisearch"; import { LibraryOut } from "../library/contract"; -import { - type ConfigurationParameter, - type ConfigurationRecord -} from "../configurations/contract"; +import { type SearchRecord } from "../configurations/contract"; import { SEARCH_OPTIONS, type SearchDocument } from "./contract"; -import { distinctRecords, toSearchRecords } from "./records"; - -/** What indexing one insertable's configurations needs. */ -export interface IndexedConfiguration { - parameters: ConfigurationParameter[]; - records: ConfigurationRecord[]; -} +import { distinctRecords } from "./records"; /** Joins the distinct non-null values with spaces (a searchable field's form). */ function uniqueJoin(values: (string | undefined)[]): string { @@ -26,7 +17,8 @@ function uniqueJoin(values: (string | undefined)[]): string { export function buildSearchDb( libraryData: LibraryOut, - configurations: Record<string, IndexedConfiguration> = {} + /** Each insertable's records, by id; see `searchRecordsOf`. */ + searchRecords: Record<string, SearchRecord[]> = {} ): MiniSearch<SearchDocument> { const searchDb = new MiniSearch<SearchDocument>(SEARCH_OPTIONS); @@ -36,14 +28,7 @@ export function buildSearchDb( .filter((element) => !!element) .map((element) => { const parentGroup = libraryData.groups[element.groupId]; - const configuration = configurations[element.id]; - const records = distinctRecords( - toSearchRecords( - configuration?.records ?? [], - configuration?.parameters ?? [], - element.vendors - ) - ); + const records = distinctRecords(searchRecords[element.id] ?? []); return { id: element.id, groupId: element.groupId, diff --git a/src/backend/features/search/records.ts b/src/backend/features/search/records.ts index 1b460452e..e875c0286 100644 --- a/src/backend/features/search/records.ts +++ b/src/backend/features/search/records.ts @@ -6,6 +6,7 @@ import { type ConfigurationParameter, type ConfigurationRecord, + type PartMetadata, type SearchRecord } from "../configurations/contract"; import { toKey, toSelection } from "../configurations/selection"; @@ -225,3 +226,29 @@ export function distinctRecords(records: SearchRecord[]): SearchRecord[] { return true; }); } + +/** An insertable's stored configuration data, as the joined rows read it. */ +export interface StoredConfiguration { + /** The element's own part data; null when it has none to show. */ + partMetadata: PartMetadata | null; + /** Null for an element with nothing to configure, which has no row. */ + parameters: ConfigurationParameter[] | null; + records: ConfigurationRecord[] | null; + vendors: Vendor[]; +} + +/** + * Every record an insertable can be shown or found as: its own part data + * first — the record an unset configuration falls back to — then one per + * indexed configuration. + */ +export function searchRecordsOf(stored: StoredConfiguration): SearchRecord[] { + const own: ConfigurationRecord[] = stored.partMetadata + ? [{ ...stored.partMetadata, values: {} }] + : []; + return toSearchRecords( + [...own, ...(stored.records ?? [])], + stored.parameters ?? [], + stored.vendors + ); +} diff --git a/src/frontend/components/parameter-role.tsx b/src/frontend/components/parameter-role.tsx index f6002710a..39642c9ca 100644 --- a/src/frontend/components/parameter-role.tsx +++ b/src/frontend/components/parameter-role.tsx @@ -7,7 +7,7 @@ import { SwatchesIcon } from "@phosphor-icons/react"; import { ReactNode } from "react"; -import { ParameterRole } from "@backend/features/configurations/roles"; +import { ParameterRole } from "@backend/features/configurations/contract"; import { IconSize } from "../lib/style-constants"; const ROLE_LABELS: Record<ParameterRole, string> = { diff --git a/src/frontend/features/build-status/components/parsed-section.tsx b/src/frontend/features/build-status/components/parsed-section.tsx index a997208cd..9589eb942 100644 --- a/src/frontend/features/build-status/components/parsed-section.tsx +++ b/src/frontend/features/build-status/components/parsed-section.tsx @@ -9,7 +9,6 @@ import { Tooltip } from "@mantine/core"; import { CheckIcon, XIcon } from "@phosphor-icons/react"; -import { parameterRole } from "@backend/features/configurations/roles"; import { ParameterRoleLabel, ROLE_ICONS @@ -191,7 +190,7 @@ function IndexedControl(props: ParameterRowProps): ReactNode { const { insertableId, status, parameter } = props; const mutation = useExcludedParametersMutation(insertableId); - const role = parameterRole(parameter, status.configuration?.parameters); + const role = parameter.role; if (role) { return ( <Tooltip diff --git a/src/frontend/features/insert/components/configurations.test.tsx b/src/frontend/features/insert/components/configurations.test.tsx index 6f0ac286c..91dea541e 100644 --- a/src/frontend/features/insert/components/configurations.test.tsx +++ b/src/frontend/features/insert/components/configurations.test.tsx @@ -12,7 +12,7 @@ import { boolParam, enumParam, quantityParam, - stringParam + derivationParam } from "../../../../__test_utils__/configuration-fixtures"; import { createTestQueryClient, @@ -160,10 +160,7 @@ describe("ConfigurationWrapper", () => { // Filled in for the person rather than by them, and kept out of the url. it("fills a derivation variable itself and keeps it read-only", async () => { const { lastReport } = renderPanel({ - parameters: [ - { ...stringParam("dv"), name: "Derivation Variable" }, - size - ], + parameters: [derivationParam("dv"), size], records: [] }); diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 5e93cf513..e673514b8 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -31,7 +31,6 @@ import { StringParameter, QuantityParameter, UnitInfo, - EMPTY_UNIT_INFO, SearchRecord } from "@backend/features/configurations/contract"; import { @@ -49,10 +48,9 @@ import { } from "@backend/features/configurations/selection"; import { isDerivationVariable } from "@backend/features/configurations/roles"; import { evaluateExpression } from "@backend/features/configurations/input-parser"; -import { useConfigurationQuery, useUnitInfoQuery } from "../queries"; +import { useConfigurationQuery, useUnitInfo } from "../queries"; import { SectionNotice } from "../../../components/app-zero-state"; import classes from "./configurations.module.css"; -import { useTargetElement } from "../../../lib/onshape-params"; import { normalizeSelection, resolveSelectedOption, @@ -128,11 +126,7 @@ export function ConfigurationWrapper( const query = useConfigurationQuery(insertableId, microversionId); - // Units come from the current document; empty when not connected to one, in - // which case each quantity renders in its own unit (see getEvaluateOptions). - const target = useTargetElement(); - const unitInfoQuery = useUnitInfoQuery(target); - const unitInfo = unitInfoQuery.data ?? EMPTY_UNIT_INFO; + const unitInfo = useUnitInfo(); const parameters = query.data?.parameters; // Whole the moment the parameters are known, since a search hit names only @@ -180,9 +174,7 @@ export function ConfigurationWrapper( if (query.isError) { return <SectionNotice title="Failed to load selection." />; } - // isLoading, not isPending: the units query sits disabled (and so forever - // pending) when there is no document to ask. - if (query.isPending || unitInfoQuery.isLoading || !whole) { + if (query.isPending || !unitInfo || !whole) { return ( <Center my="md"> <Loader /> diff --git a/src/frontend/features/insert/quantity-box.test.ts b/src/frontend/features/insert/quantity-box.test.ts index 3b346b6a0..11522eeab 100644 --- a/src/frontend/features/insert/quantity-box.test.ts +++ b/src/frontend/features/insert/quantity-box.test.ts @@ -4,7 +4,10 @@ import { type QuantityParameter } from "@backend/features/configurations/contract"; import { QuantityType, Unit } from "@backend/features/configurations/enums"; -import { getEvaluateOptions } from "@backend/features/configurations/utils"; +import { + getEvaluateOptions, + PLACEHOLDER_UNIT_INFO +} from "@backend/features/configurations/utils"; import { seedFrom } from "./quantity-box"; const SHAFT_LENGTH: QuantityParameter = { @@ -19,8 +22,8 @@ const SHAFT_LENGTH: QuantityParameter = { unit: Unit.INCH }; -/** Standalone has no document to ask, so each quantity falls back to its own unit. */ -const STANDALONE = getEvaluateOptions(SHAFT_LENGTH, {}); +/** Standalone has no document to ask, so quantities show in the placeholder's units. */ +const STANDALONE = getEvaluateOptions(SHAFT_LENGTH, PLACEHOLDER_UNIT_INFO); describe("seedFrom", () => { // The box edits what was typed and shows what it evaluates to. @@ -33,6 +36,7 @@ describe("seedFrom", () => { it("shows the value in the document's unit when there is one", () => { const metric = getEvaluateOptions(SHAFT_LENGTH, { + ...PLACEHOLDER_UNIT_INFO, lengthUnit: Unit.MILLIMETER, lengthPrecision: 1 }); diff --git a/src/frontend/features/insert/queries.ts b/src/frontend/features/insert/queries.ts index 0200ee35b..52f2a0627 100644 --- a/src/frontend/features/insert/queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -10,7 +10,7 @@ import { type PartialSelection, type UnitInfo } from "@backend/features/configurations/contract"; -import { type ElementPath, InstancePath } from "@backend/lib/onshape/path"; +import { type ElementPath } from "@backend/lib/onshape/path"; import { InsertableOut, type InsertOut @@ -29,6 +29,7 @@ import { } from "../../lib/query-keys"; import { toInsertablePath } from "../../lib/api-paths"; import { useInsertLocationId } from "../insert-location/queries"; +import { PLACEHOLDER_UNIT_INFO } from "@backend/features/configurations/utils"; interface InsertArgs { /** Whether the part is favorited — see `source` for where the insert began. */ @@ -38,25 +39,31 @@ interface InsertArgs { } /** - * The current document's units. There are none to ask for when the app is not - * in a document, and each quantity then falls back to its own unit. + * The units the configuration panel shows quantities in: the document's, or + * a placeholder outside one, or should the document's fail to load. Undefined + * only while they are loading. */ -export function useUnitInfoQuery(instancePath: InstancePath | undefined) { - return useQuery<UnitInfo>({ - queryKey: unitInfoQueryKey(instancePath), +export function useUnitInfo(): UnitInfo | undefined { + const target = useTargetElement(); + const query = useQuery<UnitInfo>({ + queryKey: unitInfoQueryKey(target), // Narrowed here rather than guarded inside, as the thumbnail queries // are: the query function should not restate what stops it running. - queryFn: instancePath + queryFn: target ? () => apiGet("/unit-info", { query: { - documentId: instancePath.documentId, - instanceId: instancePath.instanceId, - instanceType: instancePath.instanceType + documentId: target.documentId, + instanceId: target.instanceId, + instanceType: target.instanceType } }) : skipToken }); + if (!target || query.isError) { + return PLACEHOLDER_UNIT_INFO; + } + return query.data; } /** diff --git a/src/frontend/features/search/search.test.ts b/src/frontend/features/search/search.test.ts index c196c2535..127c73634 100644 --- a/src/frontend/features/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import MiniSearch from "minisearch"; import { buildSearchDb as buildIndex } from "@backend/features/search/build"; +import { toSearchRecords } from "@backend/features/search/records"; import { type SearchDocument } from "@backend/features/search/contract"; import { doSearch } from "./search"; import { type Position } from "../../lib/highlight"; @@ -28,7 +29,7 @@ const buildSearchDb = ( Object.fromEntries( Object.entries(records).map(([id, list]) => [ id, - { parameters: [], records: list } + toSearchRecords(list, []) ]) ) ); From 2ee1143f982d0d06d260604f32bfd31d3f26362d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 03:45:16 +0000 Subject: [PATCH 28/88] Spin only the groups loading, drop polling, and show quantities in their own units - Job status is the set of groups loading, pushed as it changes; a group's row, its parts and its card spin only while it loads. The navbar still shows anything loading in the library. - No polling fallback: job status comes from pushes alone, and a render waits for its push, asking once more at its deadline. A reconnect still refreshes the library for what it missed. - Each quantity box reads the document's units itself, from the one cached query, and shows its own unit until they arrive or outside a document, so the panel no longer waits on them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/features/configurations/utils.ts | 28 +++--- .../library/groups/routes.worker.test.ts | 6 +- src/backend/features/load/contract.ts | 11 +-- src/backend/features/load/jobs.ts | 25 ++---- src/backend/features/load/jobs.worker.test.ts | 6 +- src/frontend/components/app-navbar.tsx | 9 +- .../build-status/components/build-status.tsx | 47 ++++++---- .../insert/components/configurations.tsx | 21 ++--- .../features/insert/quantity-box.test.ts | 15 ++-- src/frontend/features/insert/queries.ts | 13 ++- .../library/components/insertable-card.tsx | 1 + src/frontend/features/library/queries.ts | 85 +++++-------------- .../features/thumbnails/render-wait.test.tsx | 20 ++--- .../features/thumbnails/render-wait.ts | 22 +---- src/frontend/lib/live-updates.ts | 11 +-- src/frontend/lib/refresh.ts | 19 +---- 16 files changed, 126 insertions(+), 213 deletions(-) diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index 6d5800f79..2e4cd93c1 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -237,25 +237,14 @@ export function getVisibleOptions( /** Display precision used when the document's units aren't available. */ export const DEFAULT_QUANTITY_PRECISION = 3; -/** - * The units shown outside a document, which has none to take: Onshape's own - * for a new document, in the units the library's parts are drawn in. - */ -export const PLACEHOLDER_UNIT_INFO: UnitInfo = { - lengthUnit: Unit.INCH, - angleUnit: Unit.DEGREE, - lengthPrecision: DEFAULT_QUANTITY_PRECISION, - anglePrecision: DEFAULT_QUANTITY_PRECISION, - realPrecision: DEFAULT_QUANTITY_PRECISION -}; - /** * 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 + /** The document's; without one, each quantity shows in its own unit. */ + unitInfo: UnitInfo | undefined ): EvaluateOptions { const quantityType = parameter.quantityType; const minAndMax = { @@ -265,21 +254,24 @@ export function getEvaluateOptions( if (quantityType === QuantityType.LENGTH) { return { quantityType, - displayPrecision: unitInfo.lengthPrecision, - displayUnit: unitInfo.lengthUnit, + displayPrecision: + unitInfo?.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo?.lengthUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType === QuantityType.ANGLE) { return { quantityType, - displayPrecision: unitInfo.anglePrecision, - displayUnit: unitInfo.angleUnit, + displayPrecision: + unitInfo?.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo?.angleUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType === QuantityType.REAL) { return { quantityType, - displayPrecision: unitInfo.realPrecision, + displayPrecision: + unitInfo?.realPrecision ?? DEFAULT_QUANTITY_PRECISION, displayUnit: Unit.UNITLESS, ...minAndMax }; diff --git a/src/backend/features/library/groups/routes.worker.test.ts b/src/backend/features/library/groups/routes.worker.test.ts index 9c85684bd..f142555af 100644 --- a/src/backend/features/library/groups/routes.worker.test.ts +++ b/src/backend/features/library/groups/routes.worker.test.ts @@ -187,9 +187,9 @@ describe("GET /job-status", () => { afterEach(() => vi.restoreAllMocks()); it.each<JobStatus>([ - { running: true, runningForMs: 4_000 }, - { running: false } - ])("reports $running", async (status) => { + { loadingGroupIds: [TEST_GROUP_ID] }, + { loadingGroupIds: [] } + ])("reports $loadingGroupIds loading", async (status) => { vi.spyOn(Jobs, "getJobStatus").mockResolvedValue(status); const res = await createTestApp().request( diff --git a/src/backend/features/load/contract.ts b/src/backend/features/load/contract.ts index b752b231d..296281d08 100644 --- a/src/backend/features/load/contract.ts +++ b/src/backend/features/load/contract.ts @@ -1,7 +1,4 @@ -/** - * Whether a library-load job is running, and how long it has been going. - * Milliseconds since the oldest running job started paces the client's polling. - */ -export type JobStatus = - | { running: false } - | { running: true; runningForMs: number }; +/** Which of a library's groups are loading, so each can show that it is. */ +export interface JobStatus { + loadingGroupIds: string[]; +} diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index 55362bce6..0b0ad2b86 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -8,7 +8,7 @@ * concurrently — a full reload starts one per document — and KV loses writes * that race. */ -import { and, count, eq, inArray, min } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; import type { AppBindings } from "../../lib/context"; import { getDb } from "../../db/client"; @@ -219,28 +219,21 @@ export async function finishLoad( return outcome; } -/** Whether loads are running, from the rows alone, trusting each to be live. */ +/** The groups loading, from the rows alone, trusting each to be live. */ async function runningStatus( env: AppBindings, libraryId: LibraryId ): Promise<JobStatus> { - const row = await getDb(env.DB) - .select({ running: count(), startedAt: min(loadJobs.startedAt) }) + const rows = await getDb(env.DB) + .select({ groupId: loadJobs.groupId }) .from(loadJobs) - .where(eq(loadJobs.libraryId, libraryId)) - .get(); - if (!row?.running || !row.startedAt) { - return { running: false }; - } - return { - running: true, - runningForMs: Date.now() - row.startedAt.getTime() - }; + .where(eq(loadJobs.libraryId, libraryId)); + return { loadingGroupIds: rows.map((row) => row.groupId) }; } /** - * Whether loads are running, clearing any left behind by a load that crashed - * first. For the client's first look; pushes keep it current after that. + * The groups loading, clearing any left behind by a load that crashed first. + * For the client's first look; pushes keep it current after that. */ export async function getJobStatus( env: AppBindings, @@ -249,7 +242,7 @@ export async function getJobStatus( const jobs = await getDb(env.DB) .select() .from(loadJobs) - .where(and(eq(loadJobs.libraryId, libraryId))); + .where(eq(loadJobs.libraryId, libraryId)); await clearDead(env, jobs); return runningStatus(env, libraryId); } diff --git a/src/backend/features/load/jobs.worker.test.ts b/src/backend/features/load/jobs.worker.test.ts index d3d9123cb..8261e6cd9 100644 --- a/src/backend/features/load/jobs.worker.test.ts +++ b/src/backend/features/load/jobs.worker.test.ts @@ -60,8 +60,8 @@ describe("document loads", () => { ]); expect((await job("a"))?.instanceId).toBe(started[0].id); instancesAre("running"); - expect(await getJobStatus(env, TEST_LIBRARY_ID)).toMatchObject({ - running: true + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + loadingGroupIds: ["a", "b"] }); }); @@ -130,7 +130,7 @@ describe("document loads", () => { await finishLoad(env, params("b"), false); expect(rebuild).toHaveBeenCalledOnce(); expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ - running: false + loadingGroupIds: [] }); }); }); diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index ba34fa478..1bb70c7f1 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -38,11 +38,13 @@ import { useAccessData } from "../features/auth/access-level"; import { startSignIn } from "../features/auth/sign-in"; -import { useJobStatus } from "../lib/refresh"; import { LibraryId } from "@backend/features/library/library-id"; import { type AppTab } from "@backend/features/settings/app-tab"; import { queryClient } from "../lib/query-client"; -import { getLibraryVersionQuery } from "../features/library/queries"; +import { + getLibraryVersionQuery, + useIsJobRunning +} from "../features/library/queries"; import { InsertLocationStatus } from "../features/insert-location/components/insert-location-status"; import styles from "../lib/styles.module.css"; @@ -128,8 +130,7 @@ function JobIndicator(): ReactNode { } function RunningJobLoader(): ReactNode { - // Single editor-gated job-status consumer, so it owns refresh-on-finish. - const jobRunning = useJobStatus(); + const jobRunning = useIsJobRunning(); if (!jobRunning) return null; return ( <Tooltip label="The library is being loaded from Onshape in the background"> diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index ee0b16af8..2ef0da9f1 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -17,7 +17,7 @@ import { AppHoverCard } from "../../../components/app-hover-card"; import { RequireAccessLevel } from "../../auth/access-level"; import { TruncatedText } from "../../../components/truncated-text"; import { useBuildStatusQuery } from "../queries"; -import { useIsJobRunning } from "../../library/queries"; +import { useIsGroupLoading } from "../../library/queries"; import { BuildChecksSection, type ConfigurationTarget, @@ -37,6 +37,8 @@ import styles from "../../../lib/styles.module.css"; interface BuildStatusSubject { /** The group/insertable name shown in the header. */ name: string; + /** The group it is, or is in: a load of that group is what spins. */ + groupId: string; issues: BuildIssue[]; /** When Onshape cut the version it is pinned to (epoch ms); null if none. */ versionCreatedAt: number | null; @@ -56,12 +58,19 @@ interface BuildStatusCardProps extends BuildStatusSubject { * the build checks (when any), and the wrapped group/insertable admin menu. */ function BuildStatusCard(props: BuildStatusCardProps): ReactNode { - const { name, issues, versionCreatedAt, configurationTarget, children } = - props; + const { + name, + groupId, + issues, + versionCreatedAt, + configurationTarget, + children + } = props; return ( <Stack gap="sm" w={300}> <CardHeader name={name} + groupId={groupId} issues={issues} versionCreatedAt={versionCreatedAt} /> @@ -103,6 +112,7 @@ type BuildStatusHoverCardProps = BuildStatusBadgeProps; function BuildStatusHoverCard({ name, + groupId, issues, versionCreatedAt, configurationTarget, @@ -110,14 +120,14 @@ function BuildStatusHoverCard({ hoverMenu }: BuildStatusHoverCardProps): ReactNode { const maxSeverity = getMaxSeverity(issues); - const jobRunning = useIsJobRunning(); + const loading = useIsGroupLoading(groupId); return ( <AppHoverCard position="right" arrowSize={20} target={ - jobRunning ? ( + loading ? ( <Loader size={IconSize.SMALL} /> ) : isHidden ? ( // Nobody but an editor sees a hidden insertable, so what @@ -134,6 +144,7 @@ function BuildStatusHoverCard({ > <BuildStatusCard name={name} + groupId={groupId} issues={issues} versionCreatedAt={versionCreatedAt} configurationTarget={configurationTarget} @@ -146,13 +157,14 @@ function BuildStatusHoverCard({ interface CardHeaderProps { name: string; + groupId: string; issues: BuildIssue[]; versionCreatedAt: number | null; } /** The card header: name + severity summary on the left, the version's age on the right. */ function CardHeader(props: CardHeaderProps): ReactNode { - const { name, issues, versionCreatedAt } = props; + const { name, groupId, issues, versionCreatedAt } = props; return ( <Stack gap={6}> <Group @@ -170,7 +182,10 @@ function CardHeader(props: CardHeaderProps): ReactNode { > {name} </TruncatedText> - <VersionAge versionCreatedAt={versionCreatedAt} /> + <VersionAge + groupId={groupId} + versionCreatedAt={versionCreatedAt} + /> </Group> <SeverityBadges issues={issues} /> </Stack> @@ -178,22 +193,21 @@ function CardHeader(props: CardHeaderProps): ReactNode { } interface VersionAgeProps { + groupId: string; versionCreatedAt: number | null; } /** * How old the pinned Onshape version is — when the version was cut, not when we - * last synced it. A spinner (with a tooltip) stands in while a job is running; + * last synced it. A spinner (with a tooltip) stands in while its group loads; * otherwise the version icon and a day count say it without a label. */ function VersionAge(props: VersionAgeProps): ReactNode { - const { versionCreatedAt } = props; - // Asked for again rather than threaded through three components; React - // Query serves both readers from one cache entry. - const jobRunning = useIsJobRunning(); - if (jobRunning) { + const { groupId, versionCreatedAt } = props; + const loading = useIsGroupLoading(groupId); + if (loading) { return ( - <Tooltip label="The library is being loaded from Onshape in the background"> + <Tooltip label="Being loaded from Onshape in the background"> <Loader size="xs" className={styles.noShrink} /> </Tooltip> ); @@ -216,6 +230,7 @@ function VersionAge(props: VersionAgeProps): ReactNode { interface InsertableStatusBadgeProps { insertableId: string; + groupId: string; name: string; } @@ -223,13 +238,14 @@ interface InsertableStatusBadgeProps { export function InsertableStatusBadge( props: InsertableStatusBadgeProps ): ReactNode { - const { insertableId, name } = props; + const { insertableId, groupId, name } = props; const { data } = useBuildStatusQuery(); const insertable = data?.insertables[insertableId]; if (!insertable) return null; return ( <BuildStatusBadge name={name} + groupId={groupId} issues={insertable.buildIssues} versionCreatedAt={insertable.versionCreatedAt} isHidden={!insertable.isVisible} @@ -286,6 +302,7 @@ export function GroupStatusBadge(props: GroupStatusBadgeProps): ReactNode { return ( <BuildStatusBadge name={name} + groupId={groupId} issues={issues} versionCreatedAt={groupStatus.versionCreatedAt} hoverMenu={ diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index e673514b8..92ad86943 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -30,7 +30,6 @@ import { BooleanParameter, StringParameter, QuantityParameter, - UnitInfo, SearchRecord } from "@backend/features/configurations/contract"; import { @@ -126,8 +125,6 @@ export function ConfigurationWrapper( const query = useConfigurationQuery(insertableId, microversionId); - const unitInfo = useUnitInfo(); - const parameters = query.data?.parameters; // Whole the moment the parameters are known, since a search hit names only // its overrides, and settled against the conditions so a row never has to @@ -174,7 +171,7 @@ export function ConfigurationWrapper( if (query.isError) { return <SectionNotice title="Failed to load selection." />; } - if (query.isPending || !unitInfo || !whole) { + if (query.isPending || !whole) { return ( <Center my="md"> <Loader /> @@ -187,7 +184,6 @@ export function ConfigurationWrapper( configurationResult={query.data} selection={whole} setSelection={editSelection} - unitInfo={unitInfo} /> ); } @@ -196,13 +192,12 @@ interface ConfigurationParametersProps { configurationResult: ConfigurationResult; selection: Selection; setSelection: Dispatch<Selection>; - unitInfo: UnitInfo; } function ConfigurationParameters( props: ConfigurationParametersProps ): ReactNode { - const { configurationResult, selection, setSelection, unitInfo } = props; + const { configurationResult, selection, setSelection } = props; return ( <div className={classes.grid}> @@ -213,7 +208,6 @@ function ConfigurationParameters( selection={selection} setSelection={setSelection} parameters={configurationResult.parameters} - unitInfo={unitInfo} /> ))} </div> @@ -265,7 +259,6 @@ interface ParameterRowProps { selection: Selection; setSelection: Dispatch<Selection>; parameters: ConfigurationParameter[]; - unitInfo: UnitInfo; } /** @@ -273,7 +266,7 @@ interface ParameterRowProps { * the `.map` it replaces, it changed identity every render — and effects name it. */ function ParameterRow(props: ParameterRowProps): ReactNode { - const { parameter, selection, setSelection, parameters, unitInfo } = props; + const { parameter, selection, setSelection, parameters } = props; const handleValueChange = useCallback( (newValue: string | undefined) => { @@ -291,7 +284,6 @@ function ParameterRow(props: ParameterRowProps): ReactNode { selection={selection} parameters={parameters} onValueChange={handleValueChange} - unitInfo={unitInfo} /> ); } @@ -303,7 +295,6 @@ interface ParameterProps<T extends ConfigurationParameter> { onValueChange: (newValue: string | undefined) => void; selection: Selection; parameters: ConfigurationParameter[]; - unitInfo: UnitInfo; } function ParameterInput( @@ -426,7 +417,11 @@ function StringInput(props: ParameterProps<StringParameter>): ReactNode { function QuantityInput(props: ParameterProps<QuantityParameter>): ReactNode { // Alone among the inputs in holding its own state: the box keeps what was // typed, and `value` re-seeds it only when it changes somewhere else. - const { parameter, value, onValueChange, unitInfo } = props; + const { parameter, value, onValueChange } = props; + // Its own query per box: they all share the one cached answer, and a box + // shows its own unit until the document's arrive rather than holding the + // panel up for them. + const unitInfo = useUnitInfo(); const evaluateOptions = useMemo( () => getEvaluateOptions(parameter, unitInfo), diff --git a/src/frontend/features/insert/quantity-box.test.ts b/src/frontend/features/insert/quantity-box.test.ts index 11522eeab..3a638f2dd 100644 --- a/src/frontend/features/insert/quantity-box.test.ts +++ b/src/frontend/features/insert/quantity-box.test.ts @@ -4,10 +4,7 @@ import { type QuantityParameter } from "@backend/features/configurations/contract"; import { QuantityType, Unit } from "@backend/features/configurations/enums"; -import { - getEvaluateOptions, - PLACEHOLDER_UNIT_INFO -} from "@backend/features/configurations/utils"; +import { getEvaluateOptions } from "@backend/features/configurations/utils"; import { seedFrom } from "./quantity-box"; const SHAFT_LENGTH: QuantityParameter = { @@ -22,8 +19,8 @@ const SHAFT_LENGTH: QuantityParameter = { unit: Unit.INCH }; -/** Standalone has no document to ask, so quantities show in the placeholder's units. */ -const STANDALONE = getEvaluateOptions(SHAFT_LENGTH, PLACEHOLDER_UNIT_INFO); +/** Standalone has no document to ask, so each quantity shows its own unit. */ +const STANDALONE = getEvaluateOptions(SHAFT_LENGTH, undefined); describe("seedFrom", () => { // The box edits what was typed and shows what it evaluates to. @@ -36,9 +33,11 @@ describe("seedFrom", () => { it("shows the value in the document's unit when there is one", () => { const metric = getEvaluateOptions(SHAFT_LENGTH, { - ...PLACEHOLDER_UNIT_INFO, lengthUnit: Unit.MILLIMETER, - lengthPrecision: 1 + angleUnit: Unit.DEGREE, + lengthPrecision: 1, + anglePrecision: 3, + realPrecision: 3 }); expect(seedFrom("47 in", SHAFT_LENGTH, metric)).toEqual({ expression: "47 in", diff --git a/src/frontend/features/insert/queries.ts b/src/frontend/features/insert/queries.ts index 52f2a0627..b7a7eaa78 100644 --- a/src/frontend/features/insert/queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -29,7 +29,6 @@ import { } from "../../lib/query-keys"; import { toInsertablePath } from "../../lib/api-paths"; import { useInsertLocationId } from "../insert-location/queries"; -import { PLACEHOLDER_UNIT_INFO } from "@backend/features/configurations/utils"; interface InsertArgs { /** Whether the part is favorited — see `source` for where the insert began. */ @@ -39,9 +38,8 @@ interface InsertArgs { } /** - * The units the configuration panel shows quantities in: the document's, or - * a placeholder outside one, or should the document's fail to load. Undefined - * only while they are loading. + * The current document's units, or undefined outside a document — and while + * they load, or should they fail to — when each quantity shows its own. */ export function useUnitInfo(): UnitInfo | undefined { const target = useTargetElement(); @@ -58,11 +56,10 @@ export function useUnitInfo(): UnitInfo | undefined { instanceType: target.instanceType } }) - : skipToken + : skipToken, + // A document's units do not change while it is open. + staleTime: Infinity }); - if (!target || query.isError) { - return PLACEHOLDER_UNIT_INFO; - } return query.data; } diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index 8be5bba51..8d120f469 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -109,6 +109,7 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { buildStatusBadge={ <InsertableStatusBadge insertableId={insertable.id} + groupId={insertable.groupId} name={insertable.name} /> } diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 6bdc2d455..68d5cf94d 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -27,7 +27,6 @@ import { showSuccessToast } from "../../lib/notifications"; import { getAppErrorHandler, appError } from "../../lib/errors"; -import { useIsLiveConnected } from "../../lib/live-updates"; import { modals } from "@mantine/modals"; import { parseOnshapeDocumentId } from "../../lib/url"; import { useRefreshLibrary } from "../../lib/refresh"; @@ -75,70 +74,47 @@ export function useCacheVersion(): number { return versionQuery.data ?? 0; } -/** - * Without pushes, poll a fresh job often, then back off: a full reload runs - * for hours. - */ -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; -} - /** * Checked once on load, then kept current by the server's pushes. `canAsk` is - * the caller's gate: the route is editor-only. `live` is whether pushes are - * arriving; while they are not, a running job is polled for as it used to be. + * the caller's gate: the route is editor-only. */ -function getJobStatusQuery( - libraryId: LibraryId, - canAsk: boolean, - live: boolean -) { +function getJobStatusQuery(libraryId: LibraryId, canAsk: boolean) { return queryOptions<JobStatus>({ queryKey: jobStatusQueryKey(libraryId), queryFn: () => apiGet("/job-status/library/" + libraryId), enabled: canAsk, - // Every status badge observes this, so rows mounting as the user scrolls - // would each trigger a fetch. Only a push or the poll should. - staleTime: live ? Infinity : FASTEST_POLL_MS, - refetchInterval: (query) => { - const status = query.state.data; - if (live || !status?.running) { - return false; - } - return jobPollInterval(status.runningForMs); - } + // Every status badge observes this, so rows mounting as the user + // scrolls would each trigger a fetch. Only a push should change it. + staleTime: Infinity }); } +const NOTHING_LOADING: string[] = []; + /** - * Whether a library load is running, which several places show a spinner for. - * The endpoint is editor-only and needs an Onshape session, so callers who have - * neither don't poll it at all. + * The groups loading in the library on screen. The endpoint is editor-only and + * needs an Onshape session, so callers who have neither see none. */ -export function useIsJobRunning(): boolean { - const jobStatusQuery = useJobStatusQuery(); - return jobStatusQuery.data?.running ?? false; -} - -function useJobStatusQuery() { +function useLoadingGroupIds(): string[] { const libraryId = useLibraryId(); const { signedIn, currentAccessLevel } = useAccessData(); - const live = useIsLiveConnected(); - return useQuery( + const query = useQuery( getJobStatusQuery( libraryId, - signedIn && hasEditorAccess(currentAccessLevel), - live + signedIn && hasEditorAccess(currentAccessLevel) ) ); + return query.data?.loadingGroupIds ?? NOTHING_LOADING; +} + +/** Whether anything in the library is loading. */ +export function useIsJobRunning(): boolean { + return useLoadingGroupIds().length > 0; +} + +/** Whether this group is loading, which its row and its parts show. */ +export function useIsGroupLoading(groupId: string): boolean { + return useLoadingGroupIds().includes(groupId); } /** Deleting cascades to insertables and their favorites. */ @@ -188,28 +164,14 @@ export function useSetGroupOrderMutation() { }); } -/** - * Shows the spinner without waiting for the push, and starts the job poll when - * pushes are not arriving, which stays idle until something is known to run. - */ -function markJobStarted(libraryId: LibraryId): void { - const justStarted: JobStatus = { running: true, runningForMs: 0 }; - queryClient.setQueryData<JobStatus>( - jobStatusQueryKey(libraryId), - justStarted - ); -} - /** Force reloads every document in every library; the owner's alone. */ export function useReloadAllMutation() { - const libraryId = useLibraryId(); return useMutation({ mutationKey: ["reload-all"], mutationFn: (): Promise<{ documents: number }> => apiPost("/reload-all"), onError: getAppErrorHandler("Failed to reload documents!"), onSuccess: (data) => { - markJobStarted(libraryId); showInfoToast(`Reloading ${data.documents} documents...`); } }); @@ -237,7 +199,6 @@ export function useAddGroupMutation(selectedGroupId?: string) { ), onSuccess: () => { showInfoToast("Adding document...", { id: "add-group" }); - markJobStarted(libraryId); } }); } diff --git a/src/frontend/features/thumbnails/render-wait.test.tsx b/src/frontend/features/thumbnails/render-wait.test.tsx index 7a9f3389a..444714cd6 100644 --- a/src/frontend/features/thumbnails/render-wait.test.tsx +++ b/src/frontend/features/thumbnails/render-wait.test.tsx @@ -7,17 +7,14 @@ import { thumbnailUrl } from "@backend/features/thumbnails/keys"; import { ThumbnailSize } from "@backend/features/thumbnails/contract"; const live = vi.hoisted(() => ({ - connected: true, listeners: new Set<(message: LiveMessage) => void>() })); vi.mock("../../lib/live-updates", () => ({ - isLiveConnected: () => live.connected, subscribeLiveMessages: (listener: (message: LiveMessage) => void) => { live.listeners.add(listener); return () => live.listeners.delete(listener); - }, - subscribeLiveConnection: () => () => undefined + } })); const { loadRenderedImage } = await import("./render-wait"); @@ -55,7 +52,6 @@ function mockRoute() { describe("waiting out a render", () => { beforeEach(() => { vi.useFakeTimers(); - live.connected = true; }); afterEach(() => { vi.useRealTimers(); @@ -86,13 +82,15 @@ describe("waiting out a render", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("polls while the push connection is down", async () => { - live.connected = false; - const { fetch } = mockRoute(); - void loadRenderedImage(URL_WAITED_ON).catch(() => undefined); + // A push can be lost, so the deadline asks once more before giving up. + it("asks once more at the deadline without a push", async () => { + const { route, fetch } = mockRoute(); + const loaded = loadRenderedImage(URL_WAITED_ON); + route.landed = true; - await vi.advanceTimersByTimeAsync(6_500); + await vi.advanceTimersByTimeAsync(60_000); - expect(fetch).toHaveBeenCalledTimes(4); + await expect(loaded).resolves.toBe(URL_WAITED_ON); + expect(fetch).toHaveBeenCalledTimes(2); }); }); diff --git a/src/frontend/features/thumbnails/render-wait.ts b/src/frontend/features/thumbnails/render-wait.ts index 46985875a..3cfb0828b 100644 --- a/src/frontend/features/thumbnails/render-wait.ts +++ b/src/frontend/features/thumbnails/render-wait.ts @@ -1,8 +1,7 @@ /** * Waiting out a configuration's render. Until it lands the route answers 404; * the server pushes when it has, so a miss waits for that push rather than - * asking again on a timer — unless the push connection is down, when it asks - * every couple of seconds as it always used to. + * asking again on a timer. */ import { HttpStatus } from "http-status-ts"; import { DEFAULT_CONFIGURATION_KEY } from "@backend/features/configurations/contract"; @@ -13,11 +12,7 @@ import { import { parseThumbnailUrl } from "@backend/features/thumbnails/keys"; import { loadImage } from "../../lib/api-client"; import { ImageLoadError } from "../../lib/errors"; -import { - isLiveConnected, - subscribeLiveConnection, - subscribeLiveMessages -} from "../../lib/live-updates"; +import { subscribeLiveMessages } from "../../lib/live-updates"; /** * How long any surface waits out a render before calling it failed: as long as @@ -25,9 +20,6 @@ import { */ const RENDER_TIMEOUT_MS = 60_000; -/** A poll is a worker reading R2, not an Onshape call, so it can be this tight. */ -const POLL_INTERVAL_MS = 2_000; - /** * Onshape has no insertable for the configuration, which the route answers with * its own status: the part did not regenerate, so no render is coming and @@ -100,9 +92,6 @@ export async function loadRenderedImage( waiting.wake?.(); } }); - // A dropped connection has to fall back to polling, not wait on a push - // that will not arrive. - const stopConnection = subscribeLiveConnection(() => waiting.wake?.()); try { for (;;) { waiting.pushed = false; @@ -113,12 +102,10 @@ export async function loadRenderedImage( throw error; } } + // Once more at the deadline, for a push that never reached us. if (!waiting.pushed) { - const remaining = deadline - Date.now(); await sleep( - isLiveConnected() - ? remaining - : Math.min(POLL_INTERVAL_MS, remaining), + deadline - Date.now(), signal, (next) => (waiting.wake = next) ); @@ -126,6 +113,5 @@ export async function loadRenderedImage( } } finally { stopMessages(); - stopConnection(); } } diff --git a/src/frontend/lib/live-updates.ts b/src/frontend/lib/live-updates.ts index bb4d88f11..81aa28616 100644 --- a/src/frontend/lib/live-updates.ts +++ b/src/frontend/lib/live-updates.ts @@ -1,10 +1,8 @@ /** * The app's one WebSocket to the server's pushes (`features/live` on the - * backend). Reconnects on its own, backing off; while it is down, what would - * have been pushed is polled for instead, so callers ask `isLiveConnected` - * before deciding how to wait. + * backend). Reconnects on its own, backing off; what was pushed while it was + * down is asked for again on reconnecting (see `live-sync.ts`). */ -import { useSyncExternalStore } from "react"; import { LIVE_LIBRARY_PARAM, LIVE_PATH, @@ -87,11 +85,6 @@ export function subscribeLiveConnection( return () => connectionListeners.delete(listener); } -/** For deciding how to wait at the moment of waiting, outside of rendering. */ export function isLiveConnected(): boolean { return connected; } - -export function useIsLiveConnected(): boolean { - return useSyncExternalStore(subscribeLiveConnection, isLiveConnected); -} diff --git a/src/frontend/lib/refresh.ts b/src/frontend/lib/refresh.ts index 1fd7de4f5..d35429d4a 100644 --- a/src/frontend/lib/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback } from "react"; import { useRouter } from "@tanstack/react-router"; import { queryClient } from "./query-client"; -import { useIsJobRunning } from "../features/library/queries"; import { accessDataQueryKey, favoritesQueryKey, @@ -64,19 +63,3 @@ export function useRefreshFavorites(): () => Promise<void> { await router.invalidate(); }, [router, libraryId]); } - -/** Polls whether a load job is running and refreshes the library once it finishes. */ -export function useJobStatus(): boolean { - const refreshLibrary = useRefreshLibrary(); - const running = useIsJobRunning(); - // A ref, not state: tracking the previous value to detect the finished - // transition shouldn't trigger a render (and set-state-in-effect is banned). - const wasRunning = useRef(running); - useEffect(() => { - if (wasRunning.current && !running) { - void refreshLibrary(); - } - wasRunning.current = running; - }, [running, refreshLibrary]); - return running; -} From 1fad2a0a5d6d9940195fbfd31e8f07520eb836c8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 04:25:26 +0000 Subject: [PATCH 29/88] Simplify thumbnail renders; prefer undefined to null Renders store both sizes in parallel, so RenderSource no longer orders anything: insertableId alone asks for a render. The render workflow's instance is named after Onshape's thumbnail id rather than a hash, and a configuration Onshape has no part for throws a handled 422 from requestRender instead of threading an outcome string through the route. AGENTS.md now prefers undefined to null, with null kept at boundaries and where it means "clear"; a pass converts the internal nulls. The settings defaults shrink to DEFAULT_THEME, the only one read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- AGENTS.md | 16 +++ docs/REFERENCE.md | 12 +- src/backend/db/schema.ts | 7 +- src/backend/features/admin-team/contract.ts | 4 +- src/backend/features/admin-team/routes.ts | 9 +- .../features/admin-team/routes.worker.test.ts | 2 +- src/backend/features/analytics/health.ts | 2 +- .../features/analytics/logged-event.ts | 6 +- src/backend/features/auth/owner.ts | 8 +- src/backend/features/auth/session.ts | 6 +- .../features/build-checker/contract.ts | 4 +- .../features/build-checker/issues.test.ts | 2 +- src/backend/features/build-checker/issues.ts | 8 +- src/backend/features/build-checker/routes.ts | 4 +- .../build-checker/routes.worker.test.ts | 2 +- .../configurations/combinations.test.ts | 16 ++- .../features/configurations/combinations.ts | 13 +- src/backend/features/entry/routes.ts | 4 +- src/backend/features/load/steps.ts | 8 +- src/backend/features/settings/settings.ts | 7 +- src/backend/features/thumbnails/contract.ts | 16 --- src/backend/features/thumbnails/keys.ts | 13 +- .../features/thumbnails/render-workflow.ts | 21 ++- src/backend/features/thumbnails/render.ts | 125 +++++++----------- src/backend/features/thumbnails/routes.ts | 52 ++------ .../features/thumbnails/routes.worker.test.ts | 41 ++---- src/backend/lib/onshape/client.ts | 6 +- .../lib/onshape/endpoints/thumbnails.ts | 18 +-- .../components/admin-team-setting.tsx | 6 +- .../build-status/components/admin-section.tsx | 7 +- .../build-status/components/build-status.tsx | 6 +- .../build-status/components/issues.tsx | 10 +- .../components/parsed-section.tsx | 6 +- .../features/dashboard/health-report.tsx | 13 +- .../favorites/components/favorite-card.tsx | 2 - .../settings/components/settings-menu.tsx | 4 +- .../thumbnails/components/thumbnail.tsx | 14 +- src/frontend/lib/ui-state.ts | 10 +- 38 files changed, 203 insertions(+), 307 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6783cdf4c..7eecfe9c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,22 @@ Update the comment in the same change as the code it describes, and delete it when it stops being true. A stale comment outranks the code in a reader's head, which is what makes it worse than none. +## Absent values + +Prefer `undefined` to `null` for a value that is not there: an optional field +(`name?: string`), a function that finds nothing, an unset state. Where a +boundary hands us `null` — a D1 column, KV's `get`, a DOM API, an Onshape +response — convert it where it enters (`?? undefined`) rather than carrying it +inward. + +`null` stays only where it has to: + +- the boundary's own shape: a Drizzle column type, a row straight from a query; +- where `undefined` cannot go: a TanStack Query result, a Workflow step's + result, a JSON field that must be sent to say "clear this"; +- where it means something `undefined` cannot, next to it: `null` for "none at + all" beside `undefined` for "the default". + ## Components A component's props are a named `interface <Component>Props` declared just above diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 17657fd70..1cfd3488e 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -70,14 +70,14 @@ Nothing expires on a timer: there is no R2 lifecycle rule, and renders are meant - An empty live set deletes nothing: a library really can have no elements, but so can a read that failed. - A run scans at most 50 pages of 1,000. A bucket larger than that is finished by the next run. -Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&configurationKey=&renderSource=&insertableId=`: +Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&configurationKey=&insertableId=`: - **Hit** — streamed from R2 as immutable, cacheable for a year. - **Miss** — 404, uncached, so a render landing later is not shadowed. A configuration miss is never answered with the element's default: that would show a part the caller did not ask for. The client shows the default itself while it waits. -- **Miss with `renderSource` and `insertableId`** — the route also starts a `RenderThumbnailWorkflow` for the configuration (`features/thumbnails/render.ts`). The insert menu and favorite rows ask for this; search rows do not, so one cold search cannot start a render per row. - - The instance id is a hash of the element, microversion and key, so every poll for one render finds the same instance and starts nothing new. - - Only when there is no instance does the route spend an Onshape call, resolving the thumbnail id once. Onshape having no insertable for the configuration answers 422 at once, which the client shows as a configuration that failed to regenerate. - - The workflow is handed that id and both R2 keys, the size the asking surface shows first leading, and asks Onshape for the bytes until they land (404 means still rendering), for about a minute. +- **Miss with `insertableId`**, from a signed-in caller — the route also starts a `RenderThumbnailWorkflow` for the configuration (`features/thumbnails/render.ts`). The insert menu and favorite rows ask for this; search rows do not, so one cold search cannot start a render per row. + - The route resolves Onshape's thumbnail id for the configuration, one Onshape call per miss. Onshape having no part for the configuration answers 422 at once, which the client shows as a configuration that failed to regenerate. + - The instance id is built from that thumbnail id, so every miss for one render finds the same instance and starts nothing new. + - The workflow is handed that id and both R2 keys, and asks Onshape for both sizes at once until they land (404 means still rendering), for about a minute. - A finished instance found on a miss left no bytes behind, so it is restarted. ### Workflows — Background Jobs @@ -102,7 +102,7 @@ Onshape pushes two things, registered with `isTransient: false` and recorded in - **A new version of a library document.** Registered by the document's load; removed with the last group loaded from it. Reloads that document's groups. - **A change to an admin team's members.** Registered when the owner sets a library's admin team. Pulls the team's members again. -The server pushes to open clients over a WebSocket held by the `LiveUpdates` Durable Object (`features/live`): jobs starting and finishing, a library's new version, and a configuration's render landing. Clients poll only while that connection is down. +The server pushes to open clients over a WebSocket held by the `LiveUpdates` Durable Object (`features/live`): jobs starting and finishing, a library's new version, and a configuration's render landing. Nothing polls: a client that reconnects asks again for what it may have missed. ### Assets — Static File Serving (`c.env.ASSETS`) diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index eea0e0242..711fb8d1e 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -10,7 +10,7 @@ import { ElementType } from "../lib/onshape/element-type"; import { FastenInfo } from "../features/library/insertables/fasten"; import { DEFAULT_LIBRARY, LibraryId } from "../features/library/library-id"; import { AppTab } from "../features/settings/app-tab"; -import { DEFAULT_SETTINGS, Theme } from "../features/settings/settings"; +import { DEFAULT_THEME, Theme } from "../features/settings/settings"; import { Vendor } from "../features/library/vendors"; import { ConfigurationParameter, @@ -201,10 +201,7 @@ export const configurations = sqliteTable("configurations", { export const users = sqliteTable("users", { id: text("id").primaryKey(), - theme: text("theme") - .$type<Theme>() - .notNull() - .default(DEFAULT_SETTINGS.theme), + theme: text("theme").$type<Theme>().notNull().default(DEFAULT_THEME), // Dead, and not droppable: SQLite cannot drop a column named in a foreign // key, and rebuilding the table means dropping it, which D1 refuses while // favorites point at these rows. Its default is why a user row still needs diff --git a/src/backend/features/admin-team/contract.ts b/src/backend/features/admin-team/contract.ts index c357aa653..b74cdd217 100644 --- a/src/backend/features/admin-team/contract.ts +++ b/src/backend/features/admin-team/contract.ts @@ -1,6 +1,6 @@ /** What the owner sees of a library's admin team. */ export interface AdminTeamOut { - /** Null until the owner sets one. */ - teamId: string | null; + /** Absent until the owner sets one. */ + teamId?: string; memberCount: number; } diff --git a/src/backend/features/admin-team/routes.ts b/src/backend/features/admin-team/routes.ts index 81e6c5c59..997b80ae1 100644 --- a/src/backend/features/admin-team/routes.ts +++ b/src/backend/features/admin-team/routes.ts @@ -38,7 +38,7 @@ async function getAdminTeam( .get() ]); return { - teamId: library?.teamId ?? null, + teamId: library?.teamId ?? undefined, memberCount: members?.count ?? 0 }; } @@ -66,13 +66,14 @@ adminTeamRoutes.post( await ensureLibrary(db, libraryId); const previous = (await getAdminTeam(db, libraryId)).teamId; - const setTeam = (adminTeamId: string | null) => + const setTeam = (adminTeamId: string | undefined) => db .update(libraries) - .set({ adminTeamId }) + // Drizzle skips an undefined field, so clearing takes null. + .set({ adminTeamId: adminTeamId ?? null }) .where(eq(libraries.id, libraryId)); - await setTeam(teamId); + await setTeam(teamId ?? undefined); try { await syncAdminTeam(c.env, onshapeApi, libraryId); } catch (error) { diff --git a/src/backend/features/admin-team/routes.worker.test.ts b/src/backend/features/admin-team/routes.worker.test.ts index 23d0a82c3..6088fee2b 100644 --- a/src/backend/features/admin-team/routes.worker.test.ts +++ b/src/backend/features/admin-team/routes.worker.test.ts @@ -118,7 +118,7 @@ describe("setting a library's admin team", () => { const res = await setTeam(null, onshapeApi); - expect(await res.json()).toEqual({ teamId: null, memberCount: 0 }); + expect(await res.json()).toEqual({ memberCount: 0 }); expect(remove).toHaveBeenCalledWith("/webhooks/team-webhook"); }); }); diff --git a/src/backend/features/analytics/health.ts b/src/backend/features/analytics/health.ts index b2ddd0189..479739c34 100644 --- a/src/backend/features/analytics/health.ts +++ b/src/backend/features/analytics/health.ts @@ -66,7 +66,7 @@ export function summarizeHealth( }; const record = (issues: BuildIssue[]) => { - if (getMaxSeverity(issues) === null) { + if (getMaxSeverity(issues) === undefined) { counts.healthyItems++; return; } diff --git a/src/backend/features/analytics/logged-event.ts b/src/backend/features/analytics/logged-event.ts index 1611f1284..0486806b0 100644 --- a/src/backend/features/analytics/logged-event.ts +++ b/src/backend/features/analytics/logged-event.ts @@ -47,14 +47,14 @@ export type LoggedInsert = LoggedEvent & { }; /** - * Null for another kind, and for an insert whose columns disagree with its type — + * Undefined for another kind, and for an insert whose columns disagree with its type — * a row from a version that did not set them, worth reading past not crashing on. */ -export function asInsert(event: LoggedEvent): LoggedInsert | null { +export function asInsert(event: LoggedEvent): LoggedInsert | undefined { const isInsert = event.type === EventType.INSERT && event.elementId !== null && event.targetElementType !== null && event.source !== null; - return isInsert ? (event as LoggedInsert) : null; + return isInsert ? (event as LoggedInsert) : undefined; } diff --git a/src/backend/features/auth/owner.ts b/src/backend/features/auth/owner.ts index 60307f4d1..796c8d3e2 100644 --- a/src/backend/features/auth/owner.ts +++ b/src/backend/features/auth/owner.ts @@ -22,12 +22,14 @@ export async function rememberOwnerSession( } /** - * The owner's last session, or null when they have never used the app. It can + * The owner's last session, or undefined when they have never used the app. It can * have ended since — signed out, or unused past its lifetime — in which case * calling Onshape with it fails until they next open the app. */ -export function getOwnerSessionId(kv: KVNamespace): Promise<string | null> { - return kv.get(OWNER_SESSION_KEY); +export async function getOwnerSessionId( + kv: KVNamespace +): Promise<string | undefined> { + return (await kv.get(OWNER_SESSION_KEY)) ?? undefined; } export async function getOwnerOnshapeApi( diff --git a/src/backend/features/auth/session.ts b/src/backend/features/auth/session.ts index a11c712cd..fc43c73b2 100644 --- a/src/backend/features/auth/session.ts +++ b/src/backend/features/auth/session.ts @@ -156,11 +156,11 @@ interface LoginSession { /** Single-use: reading it also clears it, so a state cannot be replayed. */ export async function takeLoginSession( c: AppContext -): Promise<LoginSession | null> { +): Promise<LoginSession | undefined> { const loginId = getCookie(c, LOGIN_COOKIE); - if (!loginId) return null; + if (!loginId) return undefined; const raw = await c.env.KV.get(loginKey(loginId)); - if (!raw) return null; + if (!raw) return undefined; const session = JSON.parse(raw) as LoginSession; diff --git a/src/backend/features/build-checker/contract.ts b/src/backend/features/build-checker/contract.ts index 7deb1ad33..e3fe44592 100644 --- a/src/backend/features/build-checker/contract.ts +++ b/src/backend/features/build-checker/contract.ts @@ -13,7 +13,7 @@ export interface GroupBuildStatus { sortAlphabetically: boolean; insertableOrder: string[]; /** When Onshape cut the version this group is pinned to (epoch ms). */ - versionCreatedAt: number | null; + versionCreatedAt?: number; } export interface InsertableBuildStatus { @@ -29,7 +29,7 @@ export interface InsertableBuildStatus { vendors: Vendor[]; configuration?: ConfigurationBuildStatus; /** When Onshape cut the version this insertable is pinned to (epoch ms). */ - versionCreatedAt: number | null; + versionCreatedAt?: number; } export interface LibraryBuildStatus { diff --git a/src/backend/features/build-checker/issues.test.ts b/src/backend/features/build-checker/issues.test.ts index 3778d8bf9..1ef6190ec 100644 --- a/src/backend/features/build-checker/issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -90,7 +90,7 @@ describe("knownBuildIssues", () => { describe("getMaxSeverity", () => { it("returns null when there are no issues", () => { - expect(getMaxSeverity([])).toBeNull(); + expect(getMaxSeverity([])).toBeUndefined(); }); const { INFO, WARNING, ERROR } = BuildIssueSeverity; diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index 6249ddd83..de1b58048 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -207,16 +207,16 @@ const SEVERITY_ORDER: BuildIssueSeverity[] = [ ]; /** - * Returns the worst severity present in `issues`, or `null` when there are no issues. + * Returns the worst severity present in `issues`, or undefined when there are none. */ export function getMaxSeverity( issues: BuildIssue[] -): BuildIssueSeverity | null { - let max: BuildIssueSeverity | null = null; +): BuildIssueSeverity | undefined { + let max: BuildIssueSeverity | undefined; for (const issue of issues) { const severity = getIssueSeverity(issue); if ( - max === null || + max === undefined || SEVERITY_ORDER.indexOf(severity) > SEVERITY_ORDER.indexOf(max) ) { max = severity; diff --git a/src/backend/features/build-checker/routes.ts b/src/backend/features/build-checker/routes.ts index dcbc7ca2a..29b15d52b 100644 --- a/src/backend/features/build-checker/routes.ts +++ b/src/backend/features/build-checker/routes.ts @@ -93,7 +93,7 @@ buildStatusRoutes.get( buildIssues: group.buildIssues, sortAlphabetically: group.sortAlphabetically, insertableOrder: groupInsertables.map((ins) => ins.id), - versionCreatedAt: group.versionCreatedAt?.getTime() ?? null + versionCreatedAt: group.versionCreatedAt?.getTime() }; } @@ -109,7 +109,7 @@ buildStatusRoutes.get( excludedParameterIds: ins.excludedParameterIds, vendors: ins.vendors, configuration: configMap.get(ins.id), - versionCreatedAt: ins.versionCreatedAt?.getTime() ?? null + versionCreatedAt: ins.versionCreatedAt?.getTime() }; } diff --git a/src/backend/features/build-checker/routes.worker.test.ts b/src/backend/features/build-checker/routes.worker.test.ts index 07b5f1738..bdf2f715b 100644 --- a/src/backend/features/build-checker/routes.worker.test.ts +++ b/src/backend/features/build-checker/routes.worker.test.ts @@ -88,7 +88,7 @@ describe("GET /build-status", () => { expect(res.status).toBe(200); const body: LibraryBuildStatus = await res.json(); - expect(body.groups[TEST_GROUP_ID].versionCreatedAt).toBeNull(); + expect(body.groups[TEST_GROUP_ID].versionCreatedAt).toBeUndefined(); }); // D1 takes at most 100 bound parameters in a statement and an `inArray` diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index 1bdd28ec1..6b489e304 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -162,11 +162,11 @@ describe("countConfigurations", () => { ); it("reports no count past the cap, where enumeration stops", () => { - expect( - countConfigurations( - paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS + 1) - ) - ).toMatchObject({ count: null, band: IndexingBand.EXCEEDED }); + const counted = countConfigurations( + paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS + 1) + ); + expect(counted.count).toBeUndefined(); + expect(counted.band).toBe(IndexingBand.EXCEEDED); }); }); @@ -187,7 +187,7 @@ describe("countCombinations", () => { it("counts on past the index cap, which countConfigurations stops at", () => { const params = paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS * 4); - expect(countConfigurations(params).count).toBeNull(); + expect(countConfigurations(params).count).toBeUndefined(); expect(countCombinations(params)).toBe( MAX_PART_NUMBER_CONFIGURATIONS * 4 ); @@ -207,7 +207,9 @@ describe("countCombinations", () => { }); it("gives up past its own cap rather than counting forever", () => { - expect(countCombinations(paramsWithConfigs(64), [], 32)).toBeNull(); + expect( + countCombinations(paramsWithConfigs(64), [], 32) + ).toBeUndefined(); }); }); diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index 88afdd913..3b1bd8f96 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -54,10 +54,11 @@ export function isIndexingEnabled( export interface ConfigurationCount { /** - * The number of combinations, `0` when there is nothing to vary, or `null` - * past the cap — enumeration stops there, so the true total is unknown. + * The number of combinations, `0` when there is nothing to vary, or + * undefined past the cap — enumeration stops there, so the true total is + * unknown. */ - count: number | null; + count?: number; band: IndexingBand; /** The combinations counted, so the load path need not enumerate again. */ configurations: PartialSelection[]; @@ -73,7 +74,7 @@ export function countConfigurations( excludedParameterIds ); if (capped) { - return { count: null, band: IndexingBand.EXCEEDED, configurations: [] }; + return { band: IndexingBand.EXCEEDED, configurations: [] }; } // The lone default that nothing-to-vary enumerates to is not a configuration // of its own: a non-configurable insertable has none. @@ -149,7 +150,7 @@ export function countCombinations( parameters: ConfigurationParameter[], excludedParameterIds: readonly string[] = [], cap: number = MAX_COUNTED_CONFIGURATIONS -): number | null { +): number | undefined { // Depth-first: only the count is wanted, so one path is held rather than all. const indexed = parameters.filter((parameter) => isIndexedParameter(parameter, excludedParameterIds) @@ -181,7 +182,7 @@ export function countCombinations( }; walk(0, {}); - return capped ? null : count; + return capped ? undefined : count; } interface EnumerateResult { diff --git a/src/backend/features/entry/routes.ts b/src/backend/features/entry/routes.ts index 328e6fe8a..f70caa15c 100644 --- a/src/backend/features/entry/routes.ts +++ b/src/backend/features/entry/routes.ts @@ -9,7 +9,7 @@ import { cacheMiddleware } from "../../lib/cache"; import { getApp, type AppContext } from "../../lib/context"; import { isSignedIn } from "../auth/request-auth"; import { getSessionCompanyId, PERSONAL_COMPANY_ID } from "../auth/session"; -import { DEFAULT_SETTINGS } from "../settings/settings"; +import { DEFAULT_THEME } from "../settings/settings"; import { DEFAULT_LIBRARY } from "../library/library-id"; import { type AppTab, @@ -102,7 +102,7 @@ async function getAppEntry(c: AppContext): Promise<AppEntry> { if (systemTheme !== null) { search.set("systemTheme", systemTheme); } - search.set("theme", user?.theme ?? DEFAULT_SETTINGS.theme); + search.set("theme", user?.theme ?? DEFAULT_THEME); // Checked rather than trusted, as the stored group above is: the frontend // 404s an id it does not know, and this url is the only thing between a diff --git a/src/backend/features/load/steps.ts b/src/backend/features/load/steps.ts index 1ed59aa76..145fa2934 100644 --- a/src/backend/features/load/steps.ts +++ b/src/backend/features/load/steps.ts @@ -27,17 +27,17 @@ interface RetryDelayInput { const RATE_LIMIT_JITTER_SECONDS = 20; /** - * How long Onshape asked us to wait plus jitter, or `null` when the error + * How long Onshape asked us to wait plus jitter, or undefined when the error * wasn't a rate limit. * * Read off the message: this runs on an error Workflows rebuilt, which is no * longer an `OnshapeRateLimitError`, so an `instanceof` here answered false for * every real 429 and quietly handed back the curve below instead. */ -export function rateLimitDelay(error: Error): `${number} seconds` | null { +export function rateLimitDelay(error: Error): `${number} seconds` | undefined { const retryAfterSeconds = readRetryAfterSeconds(error); - if (retryAfterSeconds === null) { - return null; + if (retryAfterSeconds === undefined) { + return undefined; } // Rounded: Workflows documents whole units, not fractional ones. const jitter = Math.round(Math.random() * RATE_LIMIT_JITTER_SECONDS); diff --git a/src/backend/features/settings/settings.ts b/src/backend/features/settings/settings.ts index 038d056c5..a19acdb58 100644 --- a/src/backend/features/settings/settings.ts +++ b/src/backend/features/settings/settings.ts @@ -16,10 +16,7 @@ export interface Settings { groupId: string | null; } +/** Absent leaves a setting as it is; null clears the tab or group. */ export type SettingsUpdate = Partial<Settings>; -export const DEFAULT_SETTINGS: Settings = { - theme: Theme.SYSTEM, - tabId: null, - groupId: null -}; +export const DEFAULT_THEME = Theme.SYSTEM; diff --git a/src/backend/features/thumbnails/contract.ts b/src/backend/features/thumbnails/contract.ts index 0b329aaac..4b7182c24 100644 --- a/src/backend/features/thumbnails/contract.ts +++ b/src/backend/features/thumbnails/contract.ts @@ -12,19 +12,3 @@ export interface ThumbnailUrls { small: string; large: string; } - -/** Which surface asked for a render, which decides the size stored first. */ -export enum RenderSource { - INSERT_MENU = "insert", - /** A row in a list — favorites, search results. */ - ROW = "row" -} - -/** - * The size each surface shows first. Both are always stored, so a row and the - * hover card it opens cannot disagree, but the one on screen goes first. - */ -export const PREFERRED_SIZE: Record<RenderSource, ThumbnailSize> = { - [RenderSource.INSERT_MENU]: ThumbnailSize.LARGE, - [RenderSource.ROW]: ThumbnailSize.SMALL -}; diff --git a/src/backend/features/thumbnails/keys.ts b/src/backend/features/thumbnails/keys.ts index d4aaf665c..7f7f962e2 100644 --- a/src/backend/features/thumbnails/keys.ts +++ b/src/backend/features/thumbnails/keys.ts @@ -3,7 +3,7 @@ import { type ConfigurationKey, DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; -import { RenderSource, ThumbnailSize } from "./contract"; +import { ThumbnailSize } from "./contract"; /** Everything a thumbnail is stored under, and what reconciliation scans. */ export const THUMBNAIL_PREFIX = "thumbnails/"; @@ -66,11 +66,10 @@ interface ThumbnailUrlOptions { /** Empty (the default) serves the element's own thumbnail. */ configurationKey: ConfigurationKey; /** - * Set to have a miss queue this configuration for rendering; which surface - * is asking is what orders it against everything else queued. + * The insertable to render a miss from, for a surface the configuration + * was picked on; absent serves what is stored and starts nothing, or a + * search would start a render per row. */ - renderSource?: RenderSource; - /** Only needed to render: what the render resolves the element from. */ insertableId?: string; } @@ -80,7 +79,6 @@ export function thumbnailUrl({ microversionId, size, configurationKey, - renderSource, insertableId }: ThumbnailUrlOptions): string { // `v` is the one abbreviation: it is the cache version every immutable url @@ -88,8 +86,7 @@ export function thumbnailUrl({ const query = new URLSearchParams({ v: microversionId }); if (configurationKey !== DEFAULT_CONFIGURATION_KEY) { query.set("configurationKey", configurationKey); - if (renderSource && insertableId) { - query.set("renderSource", renderSource); + if (insertableId) { query.set("insertableId", insertableId); } } diff --git a/src/backend/features/thumbnails/render-workflow.ts b/src/backend/features/thumbnails/render-workflow.ts index 308ed2524..1290422f7 100644 --- a/src/backend/features/thumbnails/render-workflow.ts +++ b/src/backend/features/thumbnails/render-workflow.ts @@ -30,7 +30,7 @@ export interface RenderTarget { export interface RenderThumbnailParams { /** Resolved once by the route; fixed for an element and configuration. */ thumbnailId: string; - /** Both sizes, the one the asking surface shows first leading. */ + /** Both sizes, stored as each lands. */ targets: RenderTarget[]; /** What is told to clients waiting on the render once each size lands. */ elementId: string; @@ -62,16 +62,15 @@ export class RenderThumbnailWorkflow extends WorkflowEntrypoint< event: WorkflowEvent<RenderThumbnailParams>, step: WorkflowStep ): Promise<void> { - const { targets } = event.payload; - // One at a time: the second size is the same render, so it lands as - // soon as the first has. - for (const target of targets) { - await step.do( - `store-${target.size}`, - { retries: RENDER_RETRIES }, - () => storeRender(this.env, event.payload, target) - ); - } + await Promise.all( + event.payload.targets.map((target) => + step.do( + `store-${target.size}`, + { retries: RENDER_RETRIES }, + () => storeRender(this.env, event.payload, target) + ) + ) + ); } } diff --git a/src/backend/features/thumbnails/render.ts b/src/backend/features/thumbnails/render.ts index 391de3258..8c0649794 100644 --- a/src/backend/features/thumbnails/render.ts +++ b/src/backend/features/thumbnails/render.ts @@ -1,23 +1,21 @@ /** - * Starts a configuration's render, once. The route calls this on every miss — - * a client polls it until the bytes land — so asking again while a render is - * under way has to cost nothing. + * Starts a configuration's render, once. A client waiting on one may ask again + * — at its deadline, or on a push it missed — so asking while a render is under + * way has to start nothing more. */ import { eq } from "drizzle-orm"; +import { HttpStatus } from "http-status-ts"; import type { AppContext } from "../../lib/context"; +import { handledError } from "../../lib/api-error"; import { getDb } from "../../db/client"; import { groups, insertables } from "../../db/schema"; import { type ElementPath, toElementPath } from "../../lib/onshape/path"; -import { - getThumbnailId, - NoSuchConfigurationError -} from "../../lib/onshape/endpoints/thumbnails"; +import { getThumbnailId } from "../../lib/onshape/endpoints/thumbnails"; import { getSessionId } from "../auth/session"; import { type ConfigurationKey } from "../configurations/contract"; import { decodeConfiguration } from "../configurations/utils"; -import { PREFERRED_SIZE, RenderSource, ThumbnailSize } from "./contract"; +import { ThumbnailSize } from "./contract"; import { thumbnailKey } from "./keys"; -import type { RenderTarget } from "./render-workflow"; interface RenderRequest { /** What the element path is resolved from, since the caller has only this. */ @@ -27,12 +25,6 @@ interface RenderRequest { configurationKey: ConfigurationKey; } -/** - * What asking did: a render is coming, or it cannot — Onshape has no - * insertable for the configuration, so the caller can say so at once. - */ -type RenderOutcome = "rendering" | "no-such-configuration"; - /** Statuses of an instance still working towards its bytes. */ const ACTIVE = new Set<InstanceStatus["status"]>([ "queued", @@ -42,16 +34,30 @@ const ACTIVE = new Set<InstanceStatus["status"]>([ "waitingForPause" ]); +/** + * Starts the render unless one is under way. Throws a handled 422 when Onshape + * has no insertable for the configuration, which the client shows as a part + * that failed to regenerate. + */ export async function requestRender( c: AppContext, - request: RenderRequest, - source: RenderSource -): Promise<RenderOutcome> { - // First, so a caller with no session to render under spends nothing. + request: RenderRequest +): Promise<void> { const sessionId = getSessionId(c); - const workflow = c.env.RENDER_THUMBNAIL_WORKFLOW; - const id = await instanceId(request); + const thumbnailId = await getThumbnailId( + await c.var.getOnshapeApi(), + await elementPathOf(c, request.insertableId), + decodeConfiguration(request.configurationKey) + ); + if (!thumbnailId) { + throw handledError( + "Onshape has no part for this configuration.", + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + const workflow = c.env.RENDER_THUMBNAIL_WORKFLOW; + const id = renderInstanceId(thumbnailId); const existing = await findInstance(workflow, id); if (existing) { const { status } = await existing.status(); @@ -60,23 +66,7 @@ export async function requestRender( if (!ACTIVE.has(status)) { await existing.restart(); } - return "rendering"; - } - - // Resolved here rather than in the workflow: it is one quick call, and a - // configuration Onshape cannot resolve is worth saying so about now. - let thumbnailId: string; - try { - thumbnailId = await getThumbnailId( - await c.var.getOnshapeApi(), - await elementPathOf(c, request.insertableId), - decodeConfiguration(request.configurationKey) - ); - } catch (error) { - if (error instanceof NoSuchConfigurationError) { - return "no-such-configuration"; - } - throw error; + return; } try { @@ -84,7 +74,15 @@ export async function requestRender( id, params: { thumbnailId, - targets: renderTargets(request, source), + targets: Object.values(ThumbnailSize).map((size) => ({ + size, + key: thumbnailKey( + request.elementId, + request.microversionId, + size, + request.configurationKey + ) + })), elementId: request.elementId, microversionId: request.microversionId, configurationKey: request.configurationKey, @@ -92,54 +90,21 @@ export async function requestRender( } }); } catch (error) { - // Two polls racing to start the same render; the other one won. + // Two requests racing to start the same render; the other one won. if (!(await findInstance(workflow, id))) { throw error; } } - return "rendering"; -} - -/** Both sizes, the one the asking surface shows first leading. */ -function renderTargets( - request: RenderRequest, - source: RenderSource -): RenderTarget[] { - const preferred = PREFERRED_SIZE[source]; - return Object.values(ThumbnailSize) - .sort((a, b) => Number(b === preferred) - Number(a === preferred)) - .map((size) => ({ - size, - key: thumbnailKey( - request.elementId, - request.microversionId, - size, - request.configurationKey - ) - })); } /** - * Named by what it renders, so every poll for one render finds the same - * instance. Hashed: a configuration can run past the 100 characters an id - * allows, and spells characters an id may not contain. + * Named by Onshape's own id for the render, so every request for it finds the + * same instance. Onshape serves the bytes by that id alone, so as far as we + * can tell it already pins the element, version and configuration. Its format + * is undocumented, so anything an instance id may not hold is replaced. */ -async function instanceId(request: RenderRequest): Promise<string> { - const subject = [ - request.elementId, - request.microversionId, - request.configurationKey - ].join("\n"); - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(subject) - ); - return ( - "render-" + - [...new Uint8Array(digest)] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join("") - ); +function renderInstanceId(thumbnailId: string): string { + return `render-${thumbnailId.replace(/[^\w-]/g, "_")}`.slice(0, 100); } /** Undefined for an id no instance holds, which `get` answers by throwing. */ @@ -175,7 +140,7 @@ async function elementPathOf( .where(eq(insertables.id, insertableId)) .get(); if (!row) { - throw new NoSuchConfigurationError(`No insertable ${insertableId}`); + throw handledError("No such part.", HttpStatus.NOT_FOUND); } if (!row.thumbnailWorkspaceId) { return toElementPath(row); diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index 0e6836bf4..ea68f2335 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -5,7 +5,8 @@ import { validate } from "../../lib/validate"; import { CachePolicy, setCache } from "../../lib/cache"; import { getApp } from "../../lib/context"; -import { RenderSource, ThumbnailSize } from "./contract"; +import { ThumbnailSize } from "./contract"; +import { isSignedIn } from "../auth/request-auth"; import { thumbnailKey } from "./keys"; import { DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; import { requestRender } from "./render"; @@ -24,20 +25,16 @@ const storedThumbnailParams = z.object({ /** Absent means the element default, which is what `""` encodes. */ const configurationKeyQuery = z.string().default(DEFAULT_CONFIGURATION_KEY); -const renderSourceQuery = z.enum(RenderSource); - const storedThumbnailQuery = z.object({ /** The microversion, part of the key — which is what makes a hit immutable. */ v: z.string().min(1), configurationKey: configurationKeyQuery, - /** Absent means serve what is stored and queue nothing. */ - renderSource: renderSourceQuery.optional(), - /** The insertable to render from; only sent with `renderSource`. */ + /** The insertable to render a miss from; absent serves what is stored. */ insertableId: z.string().optional() }); /** - * GET /api/thumbnail/:size/:elementId?v=&configurationKey=&renderSource= + * GET /api/thumbnail/:size/:elementId?v=&configurationKey=&insertableId= * Each answer caches itself: stored bytes are pinned by the url, a miss is not. */ thumbnailRoutes.get( @@ -49,7 +46,6 @@ thumbnailRoutes.get( const { v: microversionId, configurationKey, - renderSource, insertableId } = c.req.valid("query"); const object = await c.env.BLOB.get( @@ -68,28 +64,19 @@ thumbnailRoutes.get( // own thumbnail: standing that in shows a part the caller did not ask // for, and a favorite pinned to a configuration would show the wrong // one. A caller that wants the element default asks for it by key. + // Signed out, there is no session to render under, so the caller just + // keeps missing. if ( configurationKey !== DEFAULT_CONFIGURATION_KEY && - renderSource && - insertableId + insertableId && + (await isSignedIn(c)) ) { - const outcome = await requestRender( - c, - { - insertableId, - elementId, - configurationKey, - microversionId - }, - renderSource - ).catch(() => { - // Never fatal — no session to render under, most often; the - // caller just keeps missing. - return undefined; + await requestRender(c, { + insertableId, + elementId, + configurationKey, + microversionId }); - if (outcome === "no-such-configuration") { - return noSuchConfiguration(); - } } return notRenderedYet(); } @@ -103,19 +90,6 @@ function notRenderedYet(): Response { ); } -/** - * Onshape has no insertable for this configuration, so no render is coming. - * Told apart from a miss by its status, which is what lets a client stop - * polling and say the configuration is what is wrong; the answer is not cached, - * since a reload of the document can make it wrong. - */ -function noSuchConfiguration(): Response { - return setCache( - new Response(null, { status: HttpStatus.UNPROCESSABLE_ENTITY }), - CachePolicy.NO_CACHE - ); -} - function thumbnailResponse(object: R2ObjectBody): Response { const headers = new Headers(); object.writeHttpMetadata(headers); diff --git a/src/backend/features/thumbnails/routes.worker.test.ts b/src/backend/features/thumbnails/routes.worker.test.ts index 3b045bac3..55f9667d5 100644 --- a/src/backend/features/thumbnails/routes.worker.test.ts +++ b/src/backend/features/thumbnails/routes.worker.test.ts @@ -13,7 +13,7 @@ import * as ThumbnailEndpoints from "../../lib/onshape/endpoints/thumbnails"; import { getDb } from "../../db/client"; import { groups } from "../../db/schema"; import { eq } from "drizzle-orm"; -import { RenderSource, ThumbnailSize } from "./contract"; +import { ThumbnailSize } from "./contract"; import { parseThumbnailKey, parseThumbnailUrl, @@ -40,7 +40,8 @@ function get(url: string, sessionId?: string) { Cookie: `frc-design-app-cookie=${sessionId}` }; } - return createTestApp().request(url, init, env); + // Signed in only with a session, as a real caller is. + return createTestApp({ signedIn: !!sessionId }).request(url, init, env); } describe("thumbnailKey", () => { @@ -121,7 +122,6 @@ describe("reading a thumbnail address back", () => { ...SUBJECT, size: SIZE, configurationKey: "a=1;b=2", - renderSource: RenderSource.ROW, insertableId: INSERTABLE_ID }); expect(parseThumbnailUrl(url)).toEqual(SUBJECT); @@ -272,13 +272,12 @@ describe("rendering a configuration's thumbnail", () => { ); } - function renderUrl(elementId: string, renderSource = RenderSource.ROW) { + function renderUrl(elementId: string) { return thumbnailUrl({ elementId, microversionId: MICROVERSION, size: SIZE, configurationKey: CANONICAL_CONFIGURATION, - renderSource, insertableId: TEST_PART_STUDIO_ID }); } @@ -309,21 +308,8 @@ describe("rendering a configuration's thumbnail", () => { await seedPartStudio(db); }); - it("omits the source when no insertable is named", () => { - const url = thumbnailUrl({ - elementId: "any", - microversionId: MICROVERSION, - size: SIZE, - configurationKey: CANONICAL_CONFIGURATION, - renderSource: RenderSource.ROW - }); - expect( - new URL(url, "http://x").searchParams.get("renderSource") - ).toBeNull(); - }); - - // Polling is how the client waits, so asking twice has to be asking once - // — and cost Onshape one call, not one per poll. + // A waiting client can ask again — at its deadline, or on a push it + // missed — and that has to start nothing more. it("starts one render on a miss, however often it is asked", async () => { await seedDefaultOnly("warm-element"); const thumbnailId = mockThumbnailId(); @@ -336,7 +322,7 @@ describe("rendering a configuration's thumbnail", () => { }); expect(started).toBe(1); - expect(thumbnailId).toHaveBeenCalledTimes(1); + expect(thumbnailId).toHaveBeenCalledTimes(2); }); it("renders from the group's thumbnail workspace", async () => { @@ -359,8 +345,8 @@ describe("rendering a configuration's thumbnail", () => { // A miss is a render still coming; this is one that never will be, and the // client shows different wording for each. it("answers a configuration Onshape cannot resolve with its own status", async () => { - vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockRejectedValue( - new ThumbnailEndpoints.NoSuchConfigurationError("none") + vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockResolvedValue( + undefined ); const started = await startedDuring(async () => { @@ -386,7 +372,7 @@ describe("rendering a configuration's thumbnail", () => { // Search results show many configurations at once; one cold search must not // start a render per row. - it("starts nothing when no source is named", async () => { + it("starts nothing when no insertable is named", async () => { const started = await startedDuring(async () => { const res = await get( thumbnailUrl({ @@ -401,11 +387,4 @@ describe("rendering a configuration's thumbnail", () => { }); expect(started).toBe(0); }); - - it("rejects a source that is not one a client may claim", async () => { - const res = await get( - `/api/thumbnail/${SIZE}/any?v=${MICROVERSION}&configurationKey=x&renderSource=load` - ); - expect(res.status).toBe(400); - }); }); diff --git a/src/backend/lib/onshape/client.ts b/src/backend/lib/onshape/client.ts index 390b86456..ce8004bac 100644 --- a/src/backend/lib/onshape/client.ts +++ b/src/backend/lib/onshape/client.ts @@ -61,13 +61,13 @@ export class OnshapeRateLimitError extends OnshapeApiError { const RETRY_AFTER_PATTERN = /Onshape API error 429 \(retry after (\d+)s\)/; /** - * The seconds a 429 asked us to wait, or null when the error is not one. Reads + * The seconds a 429 asked us to wait, or undefined when the error is not one. Reads * the message rather than the instance, so it answers the same for an error * Workflows rebuilt as for the one that was thrown. */ -export function readRetryAfterSeconds(error: Error): number | null { +export function readRetryAfterSeconds(error: Error): number | undefined { const match = RETRY_AFTER_PATTERN.exec(error.message); - return match ? Number.parseInt(match[1], 10) : null; + return match ? Number.parseInt(match[1], 10) : undefined; } export abstract class OnshapeApi { diff --git a/src/backend/lib/onshape/endpoints/thumbnails.ts b/src/backend/lib/onshape/endpoints/thumbnails.ts index e1ff76c69..c1f273462 100644 --- a/src/backend/lib/onshape/endpoints/thumbnails.ts +++ b/src/backend/lib/onshape/endpoints/thumbnails.ts @@ -16,18 +16,16 @@ export function getElementThumbnail( return client.getImage(path); } -/** The configuration matches no insertable, so retrying can only fail again. */ -export class NoSuchConfigurationError extends Error {} - /** * The id Onshape renders a configured element's thumbnail under: fixed for an * element and configuration, and asking for its bytes is what starts a render. + * Undefined when the configuration matches no insertable. */ export async function getThumbnailId( client: OnshapeApi, elementPath: ElementPath, configuration: Selection -): Promise<string> { +): Promise<string | undefined> { const query = new URLSearchParams({ includeParts: "true", includeAssemblies: "true", @@ -40,18 +38,14 @@ export async function getThumbnailId( query.set("configuration", encoded); } - const insertables = await client.get( + const insertables: { + items?: { predictableThumbnailId?: string }[]; + } = await client.get( `/documents${toInstanceApiPath(elementPath)}/insertables`, { query } ); // A configuration matching nothing comes back with no items at all. - const thumbnailId = insertables.items?.[0]?.predictableThumbnailId; - if (!thumbnailId) { - throw new NoSuchConfigurationError( - "Onshape returned no insertable for the configuration" - ); - } - return thumbnailId; + return insertables.items?.[0]?.predictableThumbnailId; } /** Fails repeatedly while Onshape renders the thumbnail in the background. */ diff --git a/src/frontend/features/admin-team/components/admin-team-setting.tsx b/src/frontend/features/admin-team/components/admin-team-setting.tsx index bace4b5e7..211780a8b 100644 --- a/src/frontend/features/admin-team/components/admin-team-setting.tsx +++ b/src/frontend/features/admin-team/components/admin-team-setting.tsx @@ -10,8 +10,8 @@ export function AdminTeamSetting(): ReactNode { const query = useAdminTeamQuery(); const mutation = useSetAdminTeamMutation(); const inputId = useId(); - // Null until edited, so the stored team shows once it has loaded. - const [draft, setDraft] = useState<string | null>(null); + // Unset until edited, so the stored team shows once it has loaded. + const [draft, setDraft] = useState<string>(); const stored = query.data?.teamId ?? ""; const value = draft ?? stored; @@ -19,7 +19,7 @@ export function AdminTeamSetting(): ReactNode { const save = () => { mutation.mutate(trimmed || null, { - onSuccess: () => setDraft(null) + onSuccess: () => setDraft(undefined) }); }; diff --git a/src/frontend/features/build-status/components/admin-section.tsx b/src/frontend/features/build-status/components/admin-section.tsx index dbb1b4570..c554cae93 100644 --- a/src/frontend/features/build-status/components/admin-section.tsx +++ b/src/frontend/features/build-status/components/admin-section.tsx @@ -141,10 +141,7 @@ function IndexingRow(props: IndexingRowProps): ReactNode { ); } else if (band === IndexingBand.AUTOMATIC) { control = ( - <IndexingIcon - severity={null} - tooltip="Metadata is indexed from every configuration." - /> + <IndexingIcon tooltip="Metadata is indexed from every configuration." /> ); } else { control = ( @@ -167,7 +164,7 @@ function IndexingRow(props: IndexingRowProps): ReactNode { } interface IndexingIconProps { - severity: BuildIssueSeverity | null; + severity?: BuildIssueSeverity; tooltip: string; } diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index 2ef0da9f1..d8b599948 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -41,7 +41,7 @@ interface BuildStatusSubject { groupId: string; issues: BuildIssue[]; /** When Onshape cut the version it is pinned to (epoch ms); null if none. */ - versionCreatedAt: number | null; + versionCreatedAt?: number; /** Set for an insertable, so an issue can open the configuration it blames. */ configurationTarget?: ConfigurationTarget; /** Draws the badge as hidden-from-users instead of as its worst severity. */ @@ -159,7 +159,7 @@ interface CardHeaderProps { name: string; groupId: string; issues: BuildIssue[]; - versionCreatedAt: number | null; + versionCreatedAt?: number; } /** The card header: name + severity summary on the left, the version's age on the right. */ @@ -194,7 +194,7 @@ function CardHeader(props: CardHeaderProps): ReactNode { interface VersionAgeProps { groupId: string; - versionCreatedAt: number | null; + versionCreatedAt?: number; } /** diff --git a/src/frontend/features/build-status/components/issues.tsx b/src/frontend/features/build-status/components/issues.tsx index ab9620f18..72181d045 100644 --- a/src/frontend/features/build-status/components/issues.tsx +++ b/src/frontend/features/build-status/components/issues.tsx @@ -59,8 +59,8 @@ export function useGroupBuildIssues( } interface IssueIconProps extends Omit<AppIconProps, "icon" | "color"> { - /** The severity to render, or null if all checks pass. */ - severity: BuildIssueSeverity | null; + /** The severity to render; absent when every check passes. */ + severity?: BuildIssueSeverity; } /** The icon each severity is drawn as; `ok` is a build with nothing to say. */ @@ -71,8 +71,8 @@ const SEVERITY_ICONS = { ok: CheckIcon }; -/** The color a severity is spoken in; null is a build with nothing to say. */ -function severityColor(severity: BuildIssueSeverity | null): StatusColor { +/** The color a severity is spoken in; none is a build with nothing to say. */ +function severityColor(severity?: BuildIssueSeverity): StatusColor { switch (severity) { case BuildIssueSeverity.ERROR: return StatusColor.ERROR; @@ -80,7 +80,7 @@ function severityColor(severity: BuildIssueSeverity | null): StatusColor { return StatusColor.WARNING; case BuildIssueSeverity.INFO: return StatusColor.INFO; - case null: + case undefined: return StatusColor.SUCCESS; } } diff --git a/src/frontend/features/build-status/components/parsed-section.tsx b/src/frontend/features/build-status/components/parsed-section.tsx index 9589eb942..d447f5500 100644 --- a/src/frontend/features/build-status/components/parsed-section.tsx +++ b/src/frontend/features/build-status/components/parsed-section.tsx @@ -66,7 +66,7 @@ export function useConfigurationCount( /** The true total, which runs past the index cap the band is decided by. */ function useDisplayedConfigurationCount( status: InsertableBuildStatus -): number | null { +): number | undefined { const { elementType, excludedParameterIds } = status; const parameters = status.configuration?.parameters; return useMemo( @@ -80,8 +80,8 @@ function useDisplayedConfigurationCount( } /** Open-ended only past the counting cap, which nothing real reaches. */ -function configurationCountValue(count: number | null): StateRowValue { - if (count === null) { +function configurationCountValue(count: number | undefined): StateRowValue { + if (count === undefined) { return { kind: "text", text: `Over ${MAX_COUNTED_CONFIGURATIONS.toLocaleString()}` diff --git a/src/frontend/features/dashboard/health-report.tsx b/src/frontend/features/dashboard/health-report.tsx index e1de264b8..4ab30e557 100644 --- a/src/frontend/features/dashboard/health-report.tsx +++ b/src/frontend/features/dashboard/health-report.tsx @@ -19,22 +19,23 @@ export function HealthTiles({ counts }: HealthTilesProps): ReactNode { { label: "Parts", value: formatCount(counts.insertableCount), - severity: undefined + icon: undefined }, { label: "Healthy", value: formatFraction(counts.healthyItems, total), - severity: null + // Every check passing is drawn as the absence of a severity. + icon: <IssueIcon /> }, { label: "Errors", value: formatCount(counts.errorCount), - severity: BuildIssueSeverity.ERROR + icon: <IssueIcon severity={BuildIssueSeverity.ERROR} /> }, { label: "Warnings", value: formatCount(counts.warningCount), - severity: BuildIssueSeverity.WARNING + icon: <IssueIcon severity={BuildIssueSeverity.WARNING} /> } ]; @@ -43,9 +44,7 @@ export function HealthTiles({ counts }: HealthTilesProps): ReactNode { {tiles.map((tile) => ( <Card key={tile.label} withBorder padding="lg" radius="md"> <Group gap="xs"> - {tile.severity !== undefined && ( - <IssueIcon severity={tile.severity} /> - )} + {tile.icon} <Text size="sm" c="dimmed" tt="uppercase" fw={700}> {tile.label} </Text> diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index 751127145..51b1f6f74 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -1,5 +1,4 @@ import { DEFAULT_CONFIGURATION_KEY } from "@backend/features/configurations/contract"; -import { RenderSource } from "@backend/features/thumbnails/contract"; import { ReactNode } from "react"; import { Favorite } from "@backend/features/favorites/contract"; import { InsertableOut } from "@backend/features/library/contract"; @@ -95,7 +94,6 @@ export function FavoriteCard(props: FavoriteCardProps): ReactNode { configurationKey: favorite.configurationKey ?? DEFAULT_CONFIGURATION_KEY, - renderSource: RenderSource.ROW, insertableId: insertable.id }} /> diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 0c26645ee..6b3482404 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_SETTINGS, Theme } from "@backend/features/settings/settings"; +import { DEFAULT_THEME, Theme } from "@backend/features/settings/settings"; import { Box, Button, Select, Stack } from "@mantine/core"; import { ArrowLeftIcon, SignOutIcon } from "@phosphor-icons/react"; import { useMatch } from "@tanstack/react-router"; @@ -193,7 +193,7 @@ function ThemeSelect(): ReactNode { return ( <SettingSelect label="Theme" - value={theme ?? DEFAULT_SETTINGS.theme} + value={theme ?? DEFAULT_THEME} options={[Theme.SYSTEM, Theme.DARK, Theme.LIGHT]} onSelect={(theme) => updateUiState({ theme })} /> diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index 1bdafdac2..b5ebdb98b 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -6,10 +6,7 @@ import { storedThumbnailQueryKey } from "../../../lib/query-keys"; import { ElementType } from "@backend/lib/onshape/element-type"; -import { - RenderSource, - ThumbnailSize -} from "@backend/features/thumbnails/contract"; +import { ThumbnailSize } from "@backend/features/thumbnails/contract"; import { ElementPath } from "@backend/lib/onshape/path"; import { Box, Card, Center, Loader } from "@mantine/core"; import { AppHoverCard } from "../../../components/app-hover-card"; @@ -58,11 +55,9 @@ interface ThumbnailTarget { /** Empty means the element default. */ configurationKey: ConfigurationKey; /** - * Set where a miss should start a render: surfaces the user picked the - * configuration on. A search would otherwise start one per row. + * The insertable to render a miss from, set where the user picked the + * configuration. A search would otherwise start a render per row. */ - renderSource?: RenderSource; - /** Only needed to render: what the render resolves the element from. */ insertableId?: string; } @@ -96,7 +91,7 @@ export function CardThumbnail(props: CardThumbnailProps): ReactNode { // Only a row that started the render has one coming; anything else takes the // miss for the answer rather than waiting on a render nobody started. - const isRendering = configuredTarget?.renderSource !== undefined; + const isRendering = configuredTarget?.insertableId !== undefined; return ( <AppHoverCard @@ -235,7 +230,6 @@ function usePreviewThumbnail(props: PreviewImageProps, enabled: boolean) { microversionId, size: PREVIEW_SIZE, configurationKey, - renderSource: RenderSource.INSERT_MENU, insertableId }); diff --git a/src/frontend/lib/ui-state.ts b/src/frontend/lib/ui-state.ts index 7bd26111e..fb8f2913e 100644 --- a/src/frontend/lib/ui-state.ts +++ b/src/frontend/lib/ui-state.ts @@ -4,7 +4,7 @@ import { AccessLevel } from "@backend/features/auth/access-level"; import { LibraryId } from "@backend/features/library/library-id"; import { UtilityTab } from "@backend/features/settings/app-tab"; import { Vendor } from "@backend/features/library/vendors"; -import { DEFAULT_SETTINGS, Theme } from "@backend/features/settings/settings"; +import { DEFAULT_THEME, Theme } from "@backend/features/settings/settings"; import { OnshapeLaunchType } from "./onshape-launch"; /** Bumped when a change to the schema makes stored state unusable. */ @@ -22,12 +22,12 @@ const AppTabType = z.union([LibraryIdType, z.enum(Object.values(UtilityTab))]); * reads: the row is the copy, and the entry redirect is what seeds it back. */ const SyncedStateSchema = z.object({ - theme: ThemeType.default(DEFAULT_SETTINGS.theme), + theme: ThemeType.default(DEFAULT_THEME), /** The tab last opened; null until one is picked, which the welcome asks * for. */ - tabId: AppTabType.nullable().default(DEFAULT_SETTINGS.tabId), + tabId: AppTabType.nullable().default(null), /** The group last opened in that tab; null for the tab itself. */ - groupId: z.string().nullable().default(DEFAULT_SETTINGS.groupId) + groupId: z.string().nullable().default(null) }); /** Kept until the browser's storage is cleared: preferences, and where to resume. */ @@ -118,7 +118,7 @@ type Subscriber = () => void; const subscribers = new Set<Subscriber>(); /** The state this session is working from; the stores are written behind it. */ -let currentState: UiState | null = null; +let currentState: UiState | undefined; /** Blocked or partitioned storage must not break the app, only its memory. */ function readStorage(area: StateArea): string | null { From d69f63abd45fda4ccf235b4096ddd154484cfcb2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 12:54:41 +0000 Subject: [PATCH 30/88] Tighten comment guidance and apply it; simplify AppHoverCard AGENTS.md now defaults to no comment, one or two lines when there is one, no history or alternatives not taken, and no hedging where the answer can be checked. Comments across the codebase are cut to match, and stale ones corrected. AppHoverCard is Mantine's HoverCard where the device can hover and a Popover the target opens on a tap where it cannot, replacing the hand-rolled timers and pin state. useCloseHoverCard goes with it. Also: - The access-level KV cache nothing wrote is removed. - InsertOut.featureId is optional, and a part-studio derive no longer reports a Fasten mate it didn't make. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- AGENTS.md | 40 +++-- src/__test_utils__/apply-migrations.ts | 3 +- src/__test_utils__/configuration-fixtures.ts | 10 +- src/__test_utils__/fake-step.ts | 5 +- src/__test_utils__/insertable-fixtures.ts | 5 +- src/__test_utils__/mock-onshape-api.ts | 3 - src/__test_utils__/render.tsx | 6 +- src/__test_utils__/seed.ts | 25 +-- src/__test_utils__/test-app.ts | 19 +- src/backend/app.ts | 7 +- src/backend/db/chunk.ts | 12 +- src/backend/db/schema.ts | 90 +++------- src/backend/db/updates.ts | 11 +- src/backend/features/admin-team/routes.ts | 8 +- src/backend/features/admin-team/sync.ts | 7 +- src/backend/features/analytics/contract.ts | 65 ++----- src/backend/features/analytics/day.test.ts | 4 - src/backend/features/analytics/day.ts | 25 +-- src/backend/features/analytics/growth.ts | 34 +--- .../features/analytics/growth.worker.test.ts | 16 +- src/backend/features/analytics/health.test.ts | 5 +- src/backend/features/analytics/health.ts | 17 +- .../features/analytics/logged-event.ts | 15 +- src/backend/features/analytics/measures.ts | 16 +- .../features/analytics/metric-queries.ts | 30 +--- .../analytics/parameter-usage.test.ts | 2 - .../features/analytics/parameter-usage.ts | 24 +-- .../features/analytics/part-queries.ts | 37 +--- src/backend/features/analytics/range.test.ts | 2 - src/backend/features/analytics/range.ts | 28 +-- src/backend/features/analytics/rollups.ts | 14 +- .../features/analytics/rollups.worker.test.ts | 4 +- src/backend/features/analytics/routes.ts | 23 +-- .../features/analytics/routes.worker.test.ts | 52 ++---- src/backend/features/analytics/schema.ts | 52 ++---- .../features/analytics/seasons.test.ts | 5 +- src/backend/features/analytics/seasons.ts | 28 +-- src/backend/features/analytics/tracking.ts | 24 +-- .../analytics/tracking.worker.test.ts | 25 +-- src/backend/features/analytics/usage.ts | 20 +-- src/backend/features/auth/access-level.ts | 10 +- src/backend/features/auth/guards.ts | 17 +- .../features/auth/guards.worker.test.ts | 6 +- src/backend/features/auth/onshape-oauth.ts | 19 +- src/backend/features/auth/owner.ts | 16 +- src/backend/features/auth/request-auth.ts | 44 +---- .../features/auth/request-auth.worker.test.ts | 2 - src/backend/features/auth/routes.ts | 30 +--- .../features/auth/routes.worker.test.ts | 26 +-- src/backend/features/auth/session.ts | 49 +---- src/backend/features/build-checker/checks.ts | 10 +- .../features/build-checker/issues.test.ts | 6 +- src/backend/features/build-checker/issues.ts | 42 +---- src/backend/features/build-checker/routes.ts | 7 +- .../build-checker/routes.worker.test.ts | 10 +- .../configurations/combinations.test.ts | 5 +- .../features/configurations/combinations.ts | 57 ++---- .../features/configurations/contract.ts | 62 ++----- src/backend/features/configurations/enums.ts | 4 +- .../configurations/input-parser.test.ts | 3 +- .../features/configurations/input-parser.ts | 64 ++----- .../features/configurations/instances.test.ts | 10 +- .../features/configurations/instances.ts | 72 +++----- .../features/configurations/part-number.ts | 5 +- .../features/configurations/roles.test.ts | 2 - src/backend/features/configurations/roles.ts | 12 +- src/backend/features/configurations/routes.ts | 10 +- .../configurations/routes.worker.test.ts | 2 - .../features/configurations/selection.test.ts | 2 - .../features/configurations/selection.ts | 79 +++----- .../features/configurations/utils.test.ts | 13 +- src/backend/features/configurations/utils.ts | 50 ++---- src/backend/features/entry/routes.ts | 44 ++--- .../features/entry/routes.worker.test.ts | 15 +- src/backend/features/favorites/contract.ts | 14 +- src/backend/features/favorites/routes.ts | 42 ++--- .../features/favorites/routes.worker.test.ts | 33 +--- .../features/insert-location/contract.ts | 32 +--- .../features/insert-location/parse.test.ts | 10 +- src/backend/features/insert-location/parse.ts | 31 +--- .../features/insert-location/placement.ts | 17 +- .../features/insert-location/routes.ts | 10 +- src/backend/features/library/contract.ts | 7 +- src/backend/features/library/db.ts | 35 +--- .../features/library/db.worker.test.ts | 9 +- src/backend/features/library/groups/routes.ts | 9 +- .../library/groups/routes.worker.test.ts | 11 +- .../features/library/insertables/routes.ts | 45 ++--- .../library/insertables/routes.worker.test.ts | 44 ++--- src/backend/features/library/library-id.ts | 6 +- src/backend/features/library/routes.ts | 8 +- .../features/library/routes.worker.test.ts | 9 +- src/backend/features/library/vendors.test.ts | 5 +- src/backend/features/library/vendors.ts | 26 +-- src/backend/features/live/contract.ts | 11 +- src/backend/features/live/live-updates.ts | 10 +- src/backend/features/live/notify.ts | 6 +- src/backend/features/live/routes.ts | 5 +- src/backend/features/load/context.ts | 19 +- src/backend/features/load/jobs.ts | 34 +--- src/backend/features/load/load-group.ts | 78 ++------ .../features/load/load-group.worker.test.ts | 23 +-- src/backend/features/load/load-insertable.ts | 38 +--- .../load/load-insertable.worker.test.ts | 13 +- .../load/parse-configuration-records.test.ts | 22 +-- .../load/parse-configuration-records.ts | 54 ++---- .../features/load/parse-configuration.test.ts | 5 +- .../features/load/parse-configuration.ts | 3 +- .../features/load/parse-document-contents.ts | 8 +- .../features/load/parse-vendors.test.ts | 5 +- src/backend/features/load/parse-vendors.ts | 8 +- src/backend/features/load/routes.ts | 6 +- src/backend/features/load/steps.test.ts | 7 +- src/backend/features/load/steps.ts | 45 ++--- src/backend/features/load/workflows.ts | 44 ++--- .../features/load/workflows.worker.test.ts | 5 +- src/backend/features/search/build.test.ts | 2 - src/backend/features/search/build.ts | 5 +- src/backend/features/search/contract.ts | 24 +-- src/backend/features/search/fields.ts | 6 +- src/backend/features/search/records.ts | 59 ++---- src/backend/features/search/tokenize.test.ts | 23 +-- src/backend/features/search/tokenize.ts | 63 ++----- src/backend/features/settings/app-tab.test.ts | 2 - src/backend/features/settings/app-tab.ts | 14 +- src/backend/features/settings/routes.ts | 3 +- src/backend/features/settings/settings.ts | 3 +- src/backend/features/thumbnails/contract.ts | 5 +- src/backend/features/thumbnails/keys.ts | 31 +--- src/backend/features/thumbnails/reconcile.ts | 34 +--- .../thumbnails/reconcile.worker.test.ts | 13 +- src/backend/features/thumbnails/reload.ts | 18 +- .../features/thumbnails/reload.worker.test.ts | 2 - .../features/thumbnails/render-workflow.ts | 16 +- .../thumbnails/render-workflow.worker.test.ts | 2 - src/backend/features/thumbnails/render.ts | 27 +-- src/backend/features/thumbnails/routes.ts | 25 +-- .../features/thumbnails/routes.worker.test.ts | 17 +- src/backend/features/thumbnails/store.ts | 20 +-- src/backend/features/thumbnails/workspace.ts | 25 +-- src/backend/features/webhooks/registration.ts | 20 +-- src/backend/features/webhooks/routes.ts | 17 +- src/backend/index.ts | 11 +- src/backend/lib/api-error.ts | 8 +- src/backend/lib/cache.ts | 8 +- src/backend/lib/context.ts | 5 +- src/backend/lib/errors.ts | 8 +- src/backend/lib/errors.worker.test.ts | 2 - src/backend/lib/limiter.ts | 5 +- src/backend/lib/onshape/client.test.ts | 2 - src/backend/lib/onshape/client.ts | 46 +---- src/backend/lib/onshape/element-type.ts | 3 - .../lib/onshape/endpoints/assemblies.ts | 20 +-- src/backend/lib/onshape/endpoints/metadata.ts | 3 +- src/backend/lib/onshape/endpoints/parts.ts | 4 - .../lib/onshape/endpoints/thumbnails.ts | 6 +- src/backend/lib/onshape/endpoints/versions.ts | 6 +- .../lib/onshape/objects/assembly-features.ts | 5 +- .../lib/onshape/objects/derive-feature.ts | 3 +- src/backend/lib/onshape/objects/transform.ts | 5 +- src/backend/lib/onshape/path.ts | 11 +- src/backend/lib/onshape/types.ts | 15 +- src/backend/lib/validate.ts | 5 +- src/frontend/components/alerts.tsx | 5 +- src/frontend/components/app-brand.tsx | 7 +- .../components/app-hover-card.test.tsx | 44 ++++- src/frontend/components/app-hover-card.tsx | 169 +++++------------- src/frontend/components/app-icon.tsx | 5 +- src/frontend/components/app-menu.tsx | 25 +-- src/frontend/components/app-modal.tsx | 19 +- src/frontend/components/app-navbar.tsx | 57 ++---- src/frontend/components/app-title.tsx | 16 +- src/frontend/components/app-zero-state.tsx | 13 +- src/frontend/components/breadcrumbs.tsx | 3 +- src/frontend/components/callout.tsx | 7 +- src/frontend/components/change-order.tsx | 3 - src/frontend/components/get-app.tsx | 6 +- src/frontend/components/input-row.tsx | 6 +- src/frontend/components/item-row.tsx | 24 +-- src/frontend/components/open-app-modal.tsx | 5 +- .../components/open-document-items.test.tsx | 1 - src/frontend/components/part-number.tsx | 11 +- .../components/reload-thumbnail-item.tsx | 6 +- src/frontend/components/root-error.tsx | 17 +- src/frontend/components/status-icon.tsx | 16 +- src/frontend/components/truncated-text.tsx | 5 +- .../components/admin-team-setting.tsx | 5 +- src/frontend/features/admin-team/queries.ts | 5 +- src/frontend/features/auth/access-level.tsx | 25 +-- src/frontend/features/auth/sign-in.ts | 11 +- src/frontend/features/auth/sign-out.ts | 5 +- .../build-status/components/admin-section.tsx | 10 +- .../build-status/components/build-status.tsx | 19 +- .../build-status/components/issues.tsx | 21 +-- .../components/parsed-section.tsx | 16 +- .../build-status/components/sections.tsx | 5 +- src/frontend/features/build-status/queries.ts | 19 +- .../features/dashboard/change-indicator.tsx | 5 +- .../dashboard/configuration-breakdown.tsx | 16 +- .../features/dashboard/dashboard-navbar.tsx | 9 +- .../features/dashboard/dashboard-state.tsx | 5 +- src/frontend/features/dashboard/derived.ts | 7 +- src/frontend/features/dashboard/format.ts | 5 +- .../features/dashboard/growth-section.tsx | 5 +- .../features/dashboard/health-report.tsx | 3 +- .../features/dashboard/implicit-default.tsx | 3 +- .../features/dashboard/lifetime-tiles.tsx | 4 - src/frontend/features/dashboard/metrics.ts | 15 +- .../features/dashboard/parameter-path.tsx | 8 +- .../features/dashboard/parts-sort.test.ts | 3 +- src/frontend/features/dashboard/parts-sort.ts | 5 +- .../features/dashboard/parts-table.tsx | 5 +- .../features/dashboard/range-control.tsx | 3 +- src/frontend/features/dashboard/range.ts | 9 +- src/frontend/features/dashboard/series.ts | 24 +-- src/frontend/features/dashboard/sparkline.tsx | 10 +- .../features/dashboard/stat-tiles.tsx | 6 +- .../features/dashboard/table-pagination.tsx | 6 +- .../features/dashboard/treemap-chart.tsx | 8 +- .../features/dashboard/treemap-data.test.ts | 2 - .../features/dashboard/treemap-data.ts | 24 +-- .../features/dashboard/trend-chart.tsx | 10 +- .../features/dashboard/trend-tile.tsx | 4 - .../features/dashboard/usage-treemap.tsx | 5 +- .../favorites/components/favorite-button.tsx | 31 +--- .../favorites/components/favorite-card.tsx | 11 +- .../favorites/components/favorite-menu.tsx | 3 +- .../favorites/components/favorites-list.tsx | 13 +- src/frontend/features/favorites/queries.ts | 13 +- .../components/insert-location-status.tsx | 12 +- .../features/insert-location/queries.ts | 14 +- .../insert/components/configurations.test.tsx | 4 +- .../insert/components/configurations.tsx | 69 ++----- .../insert/components/insert-menu.tsx | 22 +-- src/frontend/features/insert/insert-tips.ts | 20 +-- .../features/insert/open-insert-menu.tsx | 13 +- .../features/insert/parameter-value.test.ts | 10 +- .../features/insert/parameter-value.ts | 22 +-- src/frontend/features/insert/quantity-box.ts | 8 +- src/frontend/features/insert/queries.ts | 40 ++--- .../features/insert/restore-insert-menu.ts | 29 +-- .../library/components/insertable-card.tsx | 13 +- .../library/components/program-select.tsx | 9 +- .../library/components/reload-all-button.tsx | 6 +- src/frontend/features/library/queries.ts | 26 +-- .../search/components/search-errors.tsx | 3 - .../search/components/search-results.tsx | 8 +- src/frontend/features/search/filter.ts | 8 +- src/frontend/features/search/queries.ts | 3 +- src/frontend/features/search/search.test.ts | 43 +---- src/frontend/features/search/search.ts | 35 +--- .../settings/components/settings-menu.tsx | 8 +- .../settings/components/vendor-filters.tsx | 13 +- .../features/settings/open-settings-menu.tsx | 5 +- src/frontend/features/settings/settings.ts | 10 +- .../thumbnails/components/thumbnail.tsx | 58 ++---- src/frontend/features/thumbnails/queries.ts | 6 +- .../features/thumbnails/render-wait.ts | 25 +-- src/frontend/lib/api-client.ts | 26 +-- src/frontend/lib/api-paths.ts | 5 +- src/frontend/lib/app-params.ts | 24 +-- src/frontend/lib/errors.ts | 16 +- src/frontend/lib/format-time.ts | 5 +- src/frontend/lib/highlight.ts | 6 +- src/frontend/lib/library.ts | 15 +- src/frontend/lib/live-sync.ts | 15 +- src/frontend/lib/live-updates.ts | 6 +- src/frontend/lib/messages.ts | 28 +-- src/frontend/lib/notifications.tsx | 9 +- src/frontend/lib/onshape-launch.ts | 18 +- src/frontend/lib/onshape-params.ts | 18 +- src/frontend/lib/query-cache.ts | 5 +- src/frontend/lib/query-keys.test.ts | 3 +- src/frontend/lib/query-keys.ts | 17 +- src/frontend/lib/refresh.ts | 16 +- src/frontend/lib/search-params.ts | 5 +- src/frontend/lib/style-constants.ts | 54 +----- src/frontend/lib/tabs.ts | 11 +- src/frontend/lib/ui-state.test.ts | 4 - src/frontend/lib/ui-state.ts | 56 ++---- src/frontend/lib/url.test.ts | 3 +- src/frontend/lib/url.tsx | 33 +--- src/frontend/router.ts | 3 +- src/frontend/routes/__root.tsx | 9 +- src/frontend/routes/_pages/beta-complete.tsx | 6 +- src/frontend/routes/_pages/version-error.tsx | 6 +- .../routes/app/library/$libraryId/index.tsx | 6 +- .../routes/app/library/$libraryId/route.tsx | 14 +- src/frontend/routes/app/route.tsx | 16 +- src/frontend/routes/dashboard/index.tsx | 3 +- .../dashboard/library/$libraryId/index.tsx | 5 +- .../dashboard/library/$libraryId/route.tsx | 3 +- src/frontend/routes/dashboard/route.tsx | 8 +- src/frontend/routes/index.tsx | 6 +- src/frontend/theme.ts | 21 +-- 295 files changed, 1150 insertions(+), 3919 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7eecfe9c2..f53ee5163 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,25 +2,27 @@ ## Comments -Explain _why_, not _what_: the what is in the code. Don't restate a signature -(write "returns the access level, respecting the cache", not a paragraph -re-deriving the caching), and delete comments that narrate obvious steps. - -One or two lines is the usual size. Go longer only for something genuinely -hard — a protocol Onshape does not document, a fix whose reason is not visible -from the code — and then say the hard thing plainly rather than compressing it -into dense prose. **No comment is better than a long one, and a long one is -better than a short one that is wrong.** Brevity is not worth an inaccuracy. - -A comment is a claim, and a reader will believe it without checking. So: - -- **Hedge where you are actually unsure.** "as far as I can tell" and "Onshape - does not document this" are useful; they tell the next person where to look. - Confident phrasing on a guess is worse than no comment. - -Update the comment in the same change as the code it describes, and delete it -when it stops being true. A stale comment outranks the code in a reader's head, -which is what makes it worse than none. +Default to none. A good name and type say what a thing is; a comment is for the +_why_ a reader cannot get from the code: a workaround, a constraint from +Onshape or Cloudflare, a choice that looks wrong but isn't. + +Keep it to one line, two at most. If it needs more, the code probably wants +restructuring or the detail belongs in the commit message. The exception is a +genuinely obscure protocol, and even then aim for a short paragraph. + +Write it plainly and with confidence: + +- **Say what is true now.** No history ("used to", "no longer", "was moved + here"), and no alternatives you didn't take ("rather than X"). Those go in + the commit message. +- **Don't hedge.** If you are unsure, check — read the docs, run it, write a + test — then state the result. Leave uncertainty in only where it cannot be + checked, such as undocumented Onshape behavior, and say so in a few words. +- **Don't restate the code.** No narrating steps, re-deriving a signature, or + listing every caller. + +Update or delete a comment in the same change as the code it describes: a +stale comment is worse than none. ## Absent values diff --git a/src/__test_utils__/apply-migrations.ts b/src/__test_utils__/apply-migrations.ts index 81728d641..de3e2eb0e 100644 --- a/src/__test_utils__/apply-migrations.ts +++ b/src/__test_utils__/apply-migrations.ts @@ -1,6 +1,5 @@ import { applyD1Migrations } from "cloudflare:test"; import { env } from "cloudflare:workers"; -// Setup files may run more than once; applyD1Migrations only applies what is -// missing, so calling it here is safe. +// Only applies what's missing, so repeated setup is safe. await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index 898927010..2381dd8cf 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -1,7 +1,4 @@ -/** - * Import directly, not through `__test_utils__/index.ts`: the barrel reaches - * `cloudflare:workers`, which the node project's tests cannot resolve. - */ +// Import directly: the barrel reaches `cloudflare:workers`, which node tests can't resolve. import { ParameterType, type BooleanParameter, @@ -67,10 +64,7 @@ export function derivationParam(id: string): StringParameter { }; } -/** - * A length quantity parameter defaulting to 1 inch, its default spelled the way - * `parseOnshapeConfiguration` stores one: from `defaultValue` and `unit`. - */ +/** Defaults to 1 inch, spelled as `parseOnshapeConfiguration` stores it. */ export function quantityParam( id: string, extra: Omit<Partial<QuantityParameter>, "default"> = {} diff --git a/src/__test_utils__/fake-step.ts b/src/__test_utils__/fake-step.ts index 50e494ead..c8d3521c1 100644 --- a/src/__test_utils__/fake-step.ts +++ b/src/__test_utils__/fake-step.ts @@ -1,9 +1,6 @@ import type { WorkflowStep } from "cloudflare:workers"; -/** - * Runs each step inline, so the load functions can be exercised without a real - * workflow. Durability and retries are Cloudflare's concern, not these tests'. - */ +/** Runs each step inline; durability and retries are Cloudflare's concern. */ export const FAKE_STEP = { do: (_name: string, optionsOrFn: unknown, maybeFn?: unknown) => { const run = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn; diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index c2e9f1cb6..7942088d9 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -1,7 +1,4 @@ -/** - * Factories for the load pipeline's insertable shapes. Import directly: the - * barrel re-exports Workers-only helpers these tests cannot resolve. - */ +// Import directly: the barrel re-exports Workers-only helpers. import type { InsertableTarget } from "@backend/features/load/context"; import type { ParsedInsertable } from "@backend/features/load/load-insertable"; import { ElementType } from "@backend/lib/onshape/element-type"; diff --git a/src/__test_utils__/mock-onshape-api.ts b/src/__test_utils__/mock-onshape-api.ts index 92008ced9..069694065 100644 --- a/src/__test_utils__/mock-onshape-api.ts +++ b/src/__test_utils__/mock-onshape-api.ts @@ -1,8 +1,5 @@ import { OAuthApi } from "@backend/lib/onshape/client"; -/** - * A thin shell client extending OAuthApi. - */ export class MockOnshapeApi extends OAuthApi { constructor() { // The token/refresh callback are unused — requests never reach the network. diff --git a/src/__test_utils__/render.tsx b/src/__test_utils__/render.tsx index c86570d01..f24d5d8e8 100644 --- a/src/__test_utils__/render.tsx +++ b/src/__test_utils__/render.tsx @@ -1,8 +1,4 @@ -/** - * Renders a component the way the app does — themed, with a query cache — - * for the dom project's tests. The cache never refetches on its own, so a test - * seeds what a component reads rather than serving it over a network. - */ +/** Themed, with a query cache that never fetches: seed what a component reads. */ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "@testing-library/react"; diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 5f4d7850d..7ed1ad3a2 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -67,10 +67,7 @@ export const TEST_PARAMETERS: ConfigurationParameter[] = [ } ]; -/** - * Truncates every table these helpers touch, in FK-safe order. D1 storage is - * isolated per test *file*, so call this in `beforeEach` to isolate tests. - */ +/** D1 storage is only isolated per file, so call in `beforeEach`. */ export async function resetDb(db: Db): Promise<void> { // One batch, one round trip: this runs before nearly every test. await db.batch([ @@ -105,10 +102,7 @@ export async function seedLibrary( return id; } -/** - * Seeds the libraries a user row needs: the tab's, so a group seeded in it has - * one to belong to, and the default its dead `library_id` falls back to. - */ +/** Also seeds the default library its dead `library_id` column falls back to. */ export async function seedUser( db: Db, id: string = TEST_USER_ID, @@ -146,10 +140,7 @@ export async function seedGroup( return id; } -/** - * Inserts an insertable row, defaulting to the standard part studio. Pass - * overrides to seed a row with the specific columns a test wants to manipulate. - */ +/** Defaults to the standard part studio. */ export async function seedInsertable( db: Db, overrides: Partial<typeof insertables.$inferInsert> = {} @@ -211,10 +202,7 @@ export async function seedFavorite( return id; } -/** - * Seeds a configuration for a given insertable. - * Note configurations are always 1:1 with insertables so the configuration id is also the insertable id. - */ +/** Configurations are 1:1 with insertables and share their id. */ export async function seedConfiguration( db: Db, insertableId: string = TEST_PART_STUDIO_ID @@ -228,10 +216,7 @@ export async function seedConfiguration( .onConflictDoNothing(); } -/** - * Seeds the canonical dataset: a library, a user, a groups, a part studio, an - * assembly, and two favorites (the user's, on the part studio and the assembly). - */ +/** A library, a user, a group, a part studio, an assembly, and a favorite on each. */ export async function seedTestData(db: Db): Promise<void> { await seedLibrary(db); await seedGroup(db); diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index caeb5c771..bf5559cd4 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -6,26 +6,17 @@ import { MOCK_ONSHAPE_API, MockOnshapeApi } from "./mock-onshape-api"; export interface TestAppOptions { /** Current user id, returned by `c.var.getUserId()` (default `"test-user"`). */ userId?: string; - /** - * Access level returned by `c.var.getAccessLevel()` (default `ADMIN`): one - * for every library, or one per library. - */ + /** Default `ADMIN`; a function sets it per library. */ accessLevel?: AccessLevel | ((libraryId: LibraryId) => AccessLevel); /** Onshape mock returned by `c.var.getOnshapeApi()` (default a fresh mock). */ onshapeApi?: MockOnshapeApi; - /** - * When false, `getOnshapeApi` rejects so `isSignedIn()` is false (simulating - * a not-signed-in caller). Default true. - */ + /** When false, `getOnshapeApi` rejects, so `isSignedIn()` is false. Default true. */ signedIn?: boolean; /** Whether the caller passes the auth gate (default true). */ isAuthenticated?: boolean; } -/** - * The real app from `createApp`, answering its auth questions from `options` - * instead of `productionAuth`. Drive it with `app.request(path, init, env)`. - */ +/** The real app, with auth from `options` instead of `productionAuth`. */ export function createTestApp(options: TestAppOptions = {}) { const signedIn = options.signedIn ?? true; return createApp(() => ({ @@ -44,10 +35,6 @@ export function createTestApp(options: TestAppOptions = {}) { })); } -/** - * Builds a `RequestInit` for a JSON request, serializing `body` and setting the - * content-type header. Use with `app.request(path, jsonRequest(...), env)`. - */ export function jsonRequest(method: string, body?: unknown): RequestInit { if (body === undefined) return { method }; return { diff --git a/src/backend/app.ts b/src/backend/app.ts index 6bea1ebd0..6e97f9d37 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,7 +1,3 @@ -/** - * Composition root: binds the request's auth onto every request and mounts each - * feature's routes. Everything it wires lives in a feature or in lib. - */ import { analyticsRoutes } from "./features/analytics/routes"; import { accessRoutes, authRoutes } from "./features/auth/routes"; import { buildStatusRoutes } from "./features/build-checker/routes"; @@ -44,8 +40,7 @@ const apiRoutes = [ export function createApp(resolveAuth: AuthResolver) { const app = getApp(); - // Reaches Workers Logs, which wrangler.jsonc enables. Only /init, /api/* - // and /auth/* run the Worker, so static assets are not logged. + // To Workers Logs. Static assets don't run the Worker, so they aren't logged. app.use("*", logger()); app.use("*", bindAuth(resolveAuth)); diff --git a/src/backend/db/chunk.ts b/src/backend/db/chunk.ts index 18dbeb8b7..4f83b0e80 100644 --- a/src/backend/db/chunk.ts +++ b/src/backend/db/chunk.ts @@ -1,15 +1,7 @@ -/** - * D1 rejects a statement carrying more than 100 bound parameters, and drizzle's - * `inArray` binds one per value. The ten left spare cover the rest of a - * statement's conditions; the widest here pairs a library id with the list. - */ +/** D1 allows 100 bound parameters per statement; the spare ten cover the other conditions. */ const MAX_IN_ARRAY_VALUES = 90; -/** - * Splits values into runs an `inArray` can carry in a single statement. An - * empty list yields no chunks, which leaves a caller's loop doing nothing — - * the same as the `false` drizzle builds for an empty `inArray`. - */ +/** Empty input yields no chunks. */ export function chunkForInArray<T>(values: T[]): T[][] { const chunks: T[][] = []; for (let i = 0; i < values.length; i += MAX_IN_ARRAY_VALUES) { diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 711fb8d1e..1bb628f6d 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -30,12 +30,8 @@ function upgradedJson<T>(upgrade: (stored: T) => T) { } /** - * Build-time issues flagged by the build checker, recomputed on reload. Declared - * once because both tables carry exactly this column. - * - * Filtered on read rather than trusted: a stored array was written by whichever - * deploy last loaded the row, so it can still name a check that has since been - * removed. The next write of the row drops it for good. + * Filtered on read: a row keeps whatever checks the deploy that wrote it knew, + * so it can name one since removed. */ const buildIssues = () => upgradedJson<BuildIssue[]>(knownBuildIssues)("build_issues") @@ -48,37 +44,24 @@ const thumbnailUrls = () => ({ largeThumbnailUrl: text("large_thumbnail_url") }); -/** - * Ordered `$type` then constraints, so the column reads as what it holds before - * what is true of it; the callers add their own `.references`. - */ const libraryId = () => text("library_id").$type<LibraryId>().notNull(); /** Null before the first successful load. Failures are conveyed by build issues. */ const lastLoadedAt = () => integer("last_loaded_at", { mode: "timestamp_ms" }); -/** - * When Onshape cut the version this row is pinned to — what the row depicts, - * rather than when we last asked. Null until the row has a real version. - */ +/** When Onshape cut the pinned version. Null until there is one. */ const versionCreatedAt = () => integer("version_created_at", { mode: "timestamp_ms" }); export const libraries = sqliteTable("libraries", { id: text("id").$type<LibraryId>().primaryKey(), - // The serialized MiniSearch index lives in R2 (see rebuildSearchDb), - // keyed by library id, rather than in a D1 column. + // The search index is in R2, keyed by library id; see rebuildSearchDb. cacheVersion: integer("cache_version").notNull().default(0), - // The Onshape team whose members may edit the library, set by the owner. - // Null until set, when nobody but the owner can. + // Null until the owner sets one; until then only the owner can edit. adminTeamId: text("admin_team_id") }); -/** - * The library's admin team as Onshape last reported it, so access is a lookup - * rather than a question for Onshape. Replaced whole on every sync; see - * `features/access/admin-team.ts`. - */ +/** The admin team's members as of the last sync, replaced whole each time. */ export const adminTeamMembers = sqliteTable( "admin_team_members", { @@ -86,16 +69,12 @@ export const adminTeamMembers = sqliteTable( onDelete: "cascade" }), userId: text("user_id").notNull(), - // An admin of the team rather than only a member of it. isTeamAdmin: integer("is_team_admin", { mode: "boolean" }).notNull() }, (t) => [primaryKey({ columns: [t.libraryId, t.userId] })] ); -/** - * The `versionId` a group carries before a load pins a real one, so a group - * whose load failed still has a row that can be seen, deleted, and retried. - */ +/** Before a load pins a real version, so a failed group can still be retried. */ export const PLACEHOLDER_VERSION_ID = "placeholder"; export const groups = sqliteTable( @@ -110,8 +89,7 @@ export const groups = sqliteTable( documentId: text("document_id").notNull(), versionId: text("version_id").notNull(), versionCreatedAt: versionCreatedAt(), - // Branched off `versionId`; see `thumbnails/workspace.ts`. Null until - // a load has made one. + // See `thumbnails/workspace.ts`. Null until a load has made one. thumbnailWorkspaceId: text("thumbnail_workspace_id"), sortAlphabetically: integer("sort_alphabetically", { mode: "boolean" }) .notNull() @@ -148,13 +126,11 @@ export const insertables = sqliteTable("insertables", { supportsFasten: integer("supports_fasten", { mode: "boolean" }) .notNull() .default(false), - // Indexes this insertable's configurations even above the auto threshold. - // User-owned; preserved across reloads. + // Set by an admin; kept across reloads. indexConfigurations: integer("index_configurations", { mode: "boolean" }) .notNull() .default(false), - // Parameters an admin left out of indexing. User-owned; preserved across - // reloads. Part studios only: an assembly indexes every one it can. + // Set by an admin; kept across reloads. excludedParameterIds: text("excluded_parameter_ids", { mode: "json" }) .$type<string[]>() .notNull() @@ -170,8 +146,7 @@ export const insertables = sqliteTable("insertables", { fastenInfo: text("fasten_info", { mode: "json" }).$type<FastenInfo | null>(), - // The element's own part identity, probed from its defaults. Null until a - // probe succeeds; a configurable insertable left unindexed never gets one. + // Null until probed; a configurable insertable left unindexed never is. partMetadata: text("part_metadata", { mode: "json" }).$type<PartMetadata | null>(), @@ -179,10 +154,8 @@ export const insertables = sqliteTable("insertables", { lastLoadedAt: lastLoadedAt() }); -/** - * Split off rather than folded into `insertables` because `parameters` and - * `records` are large: inline, they would slow every scan of the library. - */ +// Its own table because `parameters` and `records` are large and would slow +// every scan of `insertables`. export const configurations = sqliteTable("configurations", { insertableId: text("insertable_id") .primaryKey() @@ -191,8 +164,7 @@ export const configurations = sqliteTable("configurations", { .$type<ConfigurationParameter[]>() .notNull() .default([]), - // One record per indexed configuration. Empty unless the insertable is - // indexed; the element's own metadata lives on `insertables.partMetadata`. + // Empty unless indexed; the default part is `insertables.partMetadata`. records: text("records", { mode: "json" }) .$type<ConfigurationRecord[]>() .notNull() @@ -202,18 +174,14 @@ export const configurations = sqliteTable("configurations", { export const users = sqliteTable("users", { id: text("id").primaryKey(), theme: text("theme").$type<Theme>().notNull().default(DEFAULT_THEME), - // Dead, and not droppable: SQLite cannot drop a column named in a foreign - // key, and rebuilding the table means dropping it, which D1 refuses while - // favorites point at these rows. Its default is why a user row still needs - // the default library to exist. + // Unused, but SQLite can't drop a column in a foreign key, and D1 won't + // rebuild the table while favorites reference it. libraryId: libraryId() .default(DEFAULT_LIBRARY) .references(() => libraries.id), - // The tab last opened, which entry resumes in. Null until one is picked. - // No foreign key: only some tabs name a library row. + // Null until one is picked. No foreign key: not every tab is a library. tabId: text("tab_id").$type<AppTab>(), - // The group last opened in that tab, which entry resumes in. Null for the - // tab itself; a stale one resolves to that, so it is never cleaned. + // Null for the tab itself. A stale id resolves to that, so it isn't cleaned. groupId: text("group_id") }); @@ -230,33 +198,25 @@ export const favorites = sqliteTable( insertableId: text("insertable_id") .notNull() .references(() => insertables.id, { onDelete: "cascade" }), - // The selection the favorite opens with, as it was entered, less the - // derivation variables each insert fills afresh. Null for an - // insertable with nothing to configure. + // Null for an insertable with nothing to configure. defaultSelection: text("default_selection", { mode: "json" }).$type<PartialSelection | null>(), sortOrder: integer("sort_order").notNull().default(0), - // Null on rows predating the column: backfilling would draw a cliff - // of favorites on a day nobody favorited anything. + // Null on rows older than the column. createdAt: integer("created_at", { mode: "timestamp_ms" }) }, (t) => [unique().on(t.userId, t.libraryId, t.insertableId)] ); -/** - * What an Onshape webhook is registered for: new versions of one document, or - * an admin team's membership. - */ export enum WebhookSubject { DOCUMENT = "document", TEAM = "team" } /** - * The webhooks this deployment registered, one per subject, and the token - * each one's deliveries carry. The token is stored before Onshape answers the - * create, since it checks the url before then; `webhookId` follows. + * One per subject. The token is stored before Onshape answers the create, + * since Onshape calls the url before then. */ export const onshapeWebhooks = sqliteTable( "onshape_webhooks", @@ -271,10 +231,8 @@ export const onshapeWebhooks = sqliteTable( ); /** - * The load running for each group, one at most: another asked for meanwhile - * sets `rerun`, and the running one starts it as it finishes. Rows rather than - * a KV list, since loads start and finish concurrently and a list in KV loses - * writes that race. + * At most one running load per group; a request meanwhile sets `rerun`. In D1 + * because concurrent writes to a KV list lose updates. */ export const loadJobs = sqliteTable("load_jobs", { groupId: text("group_id") diff --git a/src/backend/db/updates.ts b/src/backend/db/updates.ts index a18f66cb3..9fa955e41 100644 --- a/src/backend/db/updates.ts +++ b/src/backend/db/updates.ts @@ -1,20 +1,13 @@ import { sql, type SQL } from "drizzle-orm"; import { type SQLiteColumn } from "drizzle-orm/sqlite-core"; -/** - * Expressions for the `set` of an update or upsert, where the new value is - * built from the stored one. Drizzle ships no equivalent, so they live here. - */ +// Updates computed from the stored value, which Drizzle has no helper for. -/** `column + by`, for a row that counts rather than replaces. */ export function increment(column: SQLiteColumn, by: number | SQL = 1): SQL { return sql`${column} + ${by}`; } -/** - * Bounds rather than assignment, so a write arriving out of order still leaves - * the true first and last. Spelled in ms: a raw `sql` fragment has no codec. - */ +/** Bounds, so out-of-order writes keep the true first and last. In ms, since raw `sql` has no codec. */ export function earliest(column: SQLiteColumn, value: Date): SQL { return sql`min(${column}, ${value.getTime()})`; } diff --git a/src/backend/features/admin-team/routes.ts b/src/backend/features/admin-team/routes.ts index 997b80ae1..169ee3f8c 100644 --- a/src/backend/features/admin-team/routes.ts +++ b/src/backend/features/admin-team/routes.ts @@ -50,10 +50,7 @@ adminTeamRoutes.get( async (c) => c.json(await getAdminTeam(getDb(c.env.DB), getLibraryParam(c))) ); -/** - * POST /api/admin-team/library/:libraryId — sets the team, pulls its members, - * and registers the webhook that keeps them current. - */ +/** POST /api/admin-team/library/:libraryId: sets the team, pulls its members, registers its webhook. */ adminTeamRoutes.post( "/admin-team" + libraryRoute(), requireOwnerMiddleware, @@ -77,8 +74,7 @@ adminTeamRoutes.post( try { await syncAdminTeam(c.env, onshapeApi, libraryId); } catch (error) { - // A team the owner cannot read is a typo more often than not; - // keep the one that was working. + // Usually a typo, so keep the team that worked. await setTeam(previous); console.error(`Failed to read team ${teamId}`, error); throw handledError( diff --git a/src/backend/features/admin-team/sync.ts b/src/backend/features/admin-team/sync.ts index c48d914e0..64dff3df8 100644 --- a/src/backend/features/admin-team/sync.ts +++ b/src/backend/features/admin-team/sync.ts @@ -1,9 +1,4 @@ -/** - * Keeps a library's stored admin team in step with Onshape's. Access is read - * from what is stored, so a sync is what changes anyone's access; it is - * announced as a new library version, which already has every open client - * refresh what it shows, access included. - */ +/** Access is read from the stored team, and a sync bumps the library version so clients refresh. */ import { eq } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; import type { AppBindings } from "../../lib/context"; diff --git a/src/backend/features/analytics/contract.ts b/src/backend/features/analytics/contract.ts index 4880d9b0f..4b77417b9 100644 --- a/src/backend/features/analytics/contract.ts +++ b/src/backend/features/analytics/contract.ts @@ -3,10 +3,7 @@ import { ElementType } from "../../lib/onshape/element-type"; import type { ElementPath } from "../../lib/onshape/path"; import { InsertSource } from "./usage"; -/** - * Inserts by the kind of tab they landed in. Every type is listed, so a tab - * nobody inserts into reads as a zero rather than a missing key. - */ +/** Every type is listed, so an unused one reads as zero. */ export type InsertTargets = Record<ElementType, number>; export function emptyTargets(): InsertTargets { @@ -23,16 +20,10 @@ export interface AnalyticsTotals { /** Subsets of `inserts`; divide by it for the percentages. */ favoriteInserts: number; quickInserts: number; - /** - * Insert-and-fasten, which Onshape only offers on an assembly target — so - * its denominator is the assembly entry of `targets`, not `inserts`. - */ + /** Onshape only offers fasten on assemblies, so compare to that entry of `targets`. */ fastenInserts: number; targets: InsertTargets; - /** - * Favorites standing right now, not over the range: a favorite is state a - * user keeps, not an event, so it has no day to be windowed by. - */ + /** Current, not over the range: a favorite is state, not an event. */ favorites: number; } @@ -42,10 +33,7 @@ export interface DailyInsertPoint { counts: Partial<Record<LibraryId, number>>; } -/** - * One day of every tracked metric, as raw counts. One series backs every trend, - * so a tile and the chart behind it cannot disagree. - */ +/** One series backs every trend, so a tile and its chart can't disagree. */ export interface DailyMetricPoint { day: string; inserts: number; @@ -67,10 +55,7 @@ export interface InsertSourceUsage { quickInsertCount: number; } -/** - * Severity counts are of issues, not items — one part with three warnings is - * three — while `healthyItems` counts items, as the two counts above it do. - */ +/** Severity counts are of issues; `healthyItems` counts items. */ export interface LibraryHealthCounts { groupCount: number; insertableCount: number; @@ -85,10 +70,7 @@ export interface LibrarySummary { health: LibraryHealthCounts; } -/** - * Why a change cannot be stated: an unmeasured baseline, two empty windows, or - * an empty baseline, which reads as new rather than infinite. - */ +/** An empty baseline reads as new rather than infinite. */ export enum ChangeUnavailable { NO_PRIOR_DATA = "no-prior-data", PARTIAL_PRIOR_DATA = "partial-prior-data", @@ -125,10 +107,6 @@ export interface GrowthOut { season: Record<GrowthMeasure, PeriodComparison>; } -/** - * What one library's page reads. Narrower than the app overview it used to - * share a type with, which had every library page paying for unopened fields. - */ export interface LibrarySummaryOut { /** Lifetime, for the headline cards. */ totals: AnalyticsTotals; @@ -164,8 +142,7 @@ export interface PartUsageOut { insertCount: number; /** The window's inserts scaled to a month; see {@link usesPerMonth}. */ usesPerMonth: number; - /** Daily inserts over a trailing {@link MONTH_DAYS}, oldest first: a - * shape rather than the reported window, which can be years of smear. */ + /** Daily inserts over the last {@link MONTH_DAYS}, oldest first. */ recent: number[]; } @@ -176,49 +153,33 @@ export interface ConfigurationValueUsage { label: string; count: number; isDefault: boolean; - /** - * The option the app lands on here, the declared default not being offered - * in this instance; see `resolveSelectedOption`. - */ + /** The declared default isn't offered here; see `resolveSelectedOption`. */ isImplicitDefault?: boolean; } -/** - * One parameter as it is shown under one set of controlling choices. A list - * another choice filters has an entry per branch, so the options in each are - * only the ones that branch offers. - */ +/** One parameter under one set of controlling choices; see `instances.ts`. */ export interface ConfigurationParameterUsage { parameterId: string; name: string; type: string; defaultValue?: string; - /** - * The controlling choices this instance is shown under, outermost first — - * ["Generic"] for the list a Generic vendor offers. Empty when nothing - * conditions the parameter. - */ + /** Outermost first, e.g. ["Generic"]; empty when unconditioned. */ path: string[]; /** Recorded values counted here, the base for percentages. */ total: number; values: ConfigurationValueUsage[]; } -/** - * One declared enum option and how often it was chosen. Only an enum declares - * its options, so only an enum can have one nobody picked. - */ +/** Only an enum declares its options, so only an enum can have an unused one. */ export interface UnusedOptionOut { path: ElementPath; partName: string; parameterId: string; parameterName: string; - /** The choices the parameter is shown under; see - * {@link ConfigurationParameterUsage.path}. */ + /** See {@link ConfigurationParameterUsage.path}. */ parameterPath: string[]; value: ConfigurationValueUsage; - /** Every recorded value for this parameter, which the count is a - * fraction of. */ + /** Every recorded value for this parameter. */ parameterTotal: number; } diff --git a/src/backend/features/analytics/day.test.ts b/src/backend/features/analytics/day.test.ts index e364a5cd7..4b554695a 100644 --- a/src/backend/features/analytics/day.test.ts +++ b/src/backend/features/analytics/day.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; import { addDays, toDayKey, toReportingDay } from "./day"; describe("toDayKey", () => { - // The whole point of reporting in a fixed US zone: an evening build session - // is one day's work, and UTC would split it across two. it("keeps a US evening on the day it was worked", () => { // 10pm Eastern on 10 Sep, which UTC already calls the 11th. expect(toDayKey(Date.parse("2026-09-11T02:00:00Z"))).toBe("2026-09-10"); @@ -32,8 +30,6 @@ describe("addDays", () => { expect(addDays("2026-12-31", 1)).toBe("2027-01-01"); }); - // A day key is a calendar date, so the spring-forward day is still one day - // wide even though the zone's day is 23 hours long. it("steps one day across a DST transition", () => { expect(addDays("2026-03-07", 1)).toBe("2026-03-08"); expect(addDays("2026-03-08", 1)).toBe("2026-03-09"); diff --git a/src/backend/features/analytics/day.ts b/src/backend/features/analytics/day.ts index 3bcbaa55a..39e9bfda8 100644 --- a/src/backend/features/analytics/day.ts +++ b/src/backend/features/analytics/day.ts @@ -1,17 +1,9 @@ -/** - * The day key every rollup is keyed on, and the window a read covers. - * - * Imported by both sides, so it stays free of anything Worker-only. - */ +/** Imported by both sides, so nothing Worker-only. */ -/** - * US teams work evenings, and a UTC midnight cuts that session in half: 8pm - * Eastern is already tomorrow. Fixed, so an insert lands on one day for everyone. - */ +/** US teams work evenings, which UTC midnight would split. */ const REPORTING_TIME_ZONE = "America/New_York"; -// en-CA formats as YYYY-MM-DD, which is the shape day keys are compared as. -// Built once: constructing a formatter per call is the expensive part. +// en-CA gives YYYY-MM-DD. Built once, since construction is the expensive part. const dayFormat = new Intl.DateTimeFormat("en-CA", { timeZone: REPORTING_TIME_ZONE, year: "numeric", @@ -24,20 +16,13 @@ export function toDayKey(timestamp: number): string { return dayFormat.format(timestamp); } -/** - * Steps a day key by whole days. A key is a calendar date, not an instant, so - * parsing at UTC midnight keeps every step 24 hours — a DST zone would not. - */ +/** Parsed at UTC midnight so every step is 24 hours. */ export function addDays(day: string, count: number): string { const at = Date.parse(`${day}T00:00:00Z`) + count * 24 * 3600 * 1000; return new Date(at).toISOString().slice(0, 10); } -/** - * The last day a report covers: yesterday, since today is still filling. Ending - * a series on a part-finished day puts a dip at the right of every chart that - * recovers by the next morning. - */ +/** Yesterday: today is still filling, and would dip every chart. */ export function toReportingDay(timestamp: number): string { return addDays(toDayKey(timestamp), -1); } diff --git a/src/backend/features/analytics/growth.ts b/src/backend/features/analytics/growth.ts index bbaf15366..900dd250d 100644 --- a/src/backend/features/analytics/growth.ts +++ b/src/backend/features/analytics/growth.ts @@ -24,22 +24,14 @@ interface Window { to: string; } -/** - * The two trailing windows to compare, ending on the last reported day: a - * part-finished today would manufacture a decline every morning that recovers - * by evening. - */ +/** Ends on the last reported day, since a partial today reads as a decline. */ export function recentWindows(through: string): { current: Window; previous: Window; } { - // A month back from it, inclusive: the -1 is what makes the span the count - // of days rather than one more than it. const to = through; const from = addDays(to, -(MONTH_DAYS - 1)); - // The equal window immediately before, ending the day before `from`, so the - // two are the same length and share no day. return { current: { from, to }, previous: { @@ -49,10 +41,7 @@ export function recentWindows(through: string): { }; } -/** - * Withholds the percentage when the baseline reaches back past the day tracking - * started: that window is empty for want of recording, not of activity. - */ +/** Withheld when the baseline predates tracking, since it's empty for want of data. */ export function toComparison( current: number, previous: number, @@ -80,8 +69,7 @@ export function toComparison( return { ...base, unavailable: ChangeUnavailable.PARTIAL_PRIOR_DATA }; } if (previous === 0) { - // Both empty is a quiet stretch, not a gap in what was recorded — the - // difference decides whether the UI blames tracking or the period. + // Lets the UI tell a quiet stretch from missing data. return { ...base, unavailable: @@ -126,10 +114,7 @@ async function countEvents( }; } -/** - * Two queries rather than one: a COUNT(DISTINCT) cannot be split by a CASE, and - * someone active in both windows counts once in each. - */ +/** Two queries: COUNT(DISTINCT) can't be split by a CASE. */ async function countPeople( db: Db, windows: { current: Window; previous: Window }, @@ -157,10 +142,6 @@ async function countPeople( return { current: current?.value ?? 0, previous: previous?.value ?? 0 }; } -/** - * Measures one window pair three ways, so a set of comparisons is built from a - * single description of what to compare. - */ async function measure( db: Db, windows: { current: Window; previous: Window }, @@ -191,11 +172,8 @@ async function measure( } /** - * Growth for a library, or for the app when no library is given. The app spans - * Sept–Apr, which covers FRC's Jan–Apr, so its season is named without a program. - * - * `through` is the last complete day, not today: every window here ends on it, - * so a season to date is not half a day short of the stretch it is compared to. + * For the app when no library is given. The app's season spans Sept–Apr, which + * covers FRC's Jan–Apr, so it's named without a program. */ export async function getGrowth( db: Db, diff --git a/src/backend/features/analytics/growth.worker.test.ts b/src/backend/features/analytics/growth.worker.test.ts index 53bf10d6b..4ebd38d5a 100644 --- a/src/backend/features/analytics/growth.worker.test.ts +++ b/src/backend/features/analytics/growth.worker.test.ts @@ -10,10 +10,7 @@ import { getGrowth, recentWindows, toComparison } from "./growth"; const db = getDb(env.DB); -/** - * The last complete day, which is what the routes report through. Late August: - * outside both seasons, so season comparisons use whole ones. - */ +/** Late August, outside both seasons. */ const THROUGH = "2026-08-26"; async function seedInserts( @@ -64,8 +61,7 @@ describe("toComparison", () => { }); it("withholds a change when the baseline predates tracking", () => { - // The whole prior window is before anything was recorded, so its zero - // means "not measured", not "nothing happened". + // The prior window predates tracking, so its zero means "not measured". const out = toComparison(120, 0, WINDOWS, LABELS, "2026-08-01"); expect(out.changeRatio).toBeUndefined(); expect(out.unavailable).toBe(ChangeUnavailable.NO_PRIOR_DATA); @@ -124,8 +120,6 @@ describe("getGrowth", () => { }); it("compares whole seasons when the window falls between them", async () => { - // August is off-season, and app-wide seasons run Sept-Apr so both - // competitions are covered by one window. await seedInserts("2026-03-01", 100); await seedInserts("2025-03-01", 50); @@ -141,8 +135,7 @@ describe("getGrowth", () => { }); it("counts an FTC-only autumn the app-wide season would miss on FRC", async () => { - // October is inside Sept-Apr but outside FRC's Jan-Apr, so measuring - // the app on FRC's span would drop this entirely. + // October is outside FRC's Jan–Apr, so measuring on FRC's span would drop it. await seedInserts("2025-10-15", 40); const growth = await getGrowth(db, THROUGH, "2024-09-01"); @@ -168,8 +161,7 @@ describe("getGrowth", () => { }); it("clips an in-season baseline to the same elapsed stretch", async () => { - // 1 Feb is 154 days into a Sept-Apr season. Last season ran on past - // that point, and only the part inside it may be compared against. + // Only the part of last season up to the same point may be compared. await seedInserts("2027-01-15", 25); await seedInserts("2026-01-15", 40); await seedInserts("2026-03-15", 20); diff --git a/src/backend/features/analytics/health.test.ts b/src/backend/features/analytics/health.test.ts index 4201a42cc..b6f043379 100644 --- a/src/backend/features/analytics/health.test.ts +++ b/src/backend/features/analytics/health.test.ts @@ -18,8 +18,7 @@ describe("summarizeHealth", () => { ] }, { - // Info-only, so it leaves the item unhealthy without - // landing on either tile. + // Info-only: unhealthy, but on neither tile. buildIssues: [{ type: BuildIssueType.NO_THUMBNAIL_TAB }] } ] @@ -52,8 +51,6 @@ describe("summarizeHealth", () => { }); it("reports a group's stored issues without recomputing any", () => { - // Visibility checks now run in the workflow and on the visibility - // toggle, so this only reads what they wrote. const counts = summarizeHealth( [ { diff --git a/src/backend/features/analytics/health.ts b/src/backend/features/analytics/health.ts index 479739c34..3b45e9de9 100644 --- a/src/backend/features/analytics/health.ts +++ b/src/backend/features/analytics/health.ts @@ -1,7 +1,3 @@ -/** - * The library's build health, counted rather than listed: which items carry an - * issue, and how severe the worst one is. - */ import { and, eq } from "drizzle-orm"; import { type Db } from "../../db/client"; import { groups, insertables } from "../../db/schema"; @@ -14,10 +10,7 @@ import { type BuildIssue } from "../build-checker/issues"; -/** - * Hidden insertables are exempt from the build checks, so the health report - * leaves them out entirely rather than counting them as healthy. - */ +/** Hidden insertables skip the build checks, so they aren't counted at all. */ function visibleIn(libraryId: LibraryId) { return and( eq(insertables.libraryId, libraryId), @@ -49,10 +42,7 @@ export async function getHealthCounts( return summarizeHealth(allGroups, allInsertables); } -/** - * Hidden insertables are filtered upstream: exempt from the checks, so never - * healthy. - */ +/** Expects hidden insertables already filtered out. */ export function summarizeHealth( groups: { buildIssues: BuildIssue[] }[], insertables: { buildIssues: BuildIssue[] }[] @@ -78,8 +68,7 @@ export function summarizeHealth( case BuildIssueSeverity.WARNING: counts.warningCount++; break; - // Info issues are counted by neither tile, so they only have - // to leave the item healthy-or-not, which `record` already did. + // Counted by neither tile. case BuildIssueSeverity.INFO: break; } diff --git a/src/backend/features/analytics/logged-event.ts b/src/backend/features/analytics/logged-event.ts index 0486806b0..931c0271a 100644 --- a/src/backend/features/analytics/logged-event.ts +++ b/src/backend/features/analytics/logged-event.ts @@ -1,7 +1,4 @@ -/** - * The log's rows, read as the events they are. `events` is one wide table, so - * this is the one place that knows which columns a given kind sets. - */ +/** The one place that knows which columns each kind of event sets. */ import { type ElementType } from "../../lib/onshape/element-type"; import { EventType, type InsertSource } from "./usage"; import { type LoggedEvent } from "./schema"; @@ -21,10 +18,7 @@ export type EventCore = Pick< /** The rest, which only an insert fills in. */ type InsertColumns = Omit<LoggedEvent, keyof EventCore>; -/** - * Spelled out rather than defaulted: a column added to the log stops compiling - * here until someone says what a non-insert records for it. - */ +/** Spelled out, so a new column fails to compile until someone decides its value here. */ export const NOT_AN_INSERT: InsertColumns = { elementId: null, documentId: null, @@ -46,10 +40,7 @@ export type LoggedInsert = LoggedEvent & { source: InsertSource; }; -/** - * Undefined for another kind, and for an insert whose columns disagree with its type — - * a row from a version that did not set them, worth reading past not crashing on. - */ +/** Undefined for another kind, or an insert from a version that didn't set these columns. */ export function asInsert(event: LoggedEvent): LoggedInsert | undefined { const isInsert = event.type === EventType.INSERT && diff --git a/src/backend/features/analytics/measures.ts b/src/backend/features/analytics/measures.ts index f8b491c4f..33b9fed67 100644 --- a/src/backend/features/analytics/measures.ts +++ b/src/backend/features/analytics/measures.ts @@ -1,19 +1,9 @@ -/** - * The windows the dashboard reports over, and the one rate it derives. - * - * Imported by both sides, so it stays free of anything Worker-only. - */ +/** Imported by both sides, so nothing Worker-only. */ -/** - * What "a month" means throughout: the recent comparison window, a sparkline's - * days, and the span a rate scales to. The dashboard titles itself from it. - */ +/** The comparison window, a sparkline's length, and the span rates scale to. */ export const MONTH_DAYS = 30; -/** - * Inserts per month, so a new part is not buried under an old one. The span is - * floored at a month, or a first week of 2 extrapolates to 60. - */ +/** Floored at a month, or a first week of 2 extrapolates to 60. */ export function usesPerMonth( insertCount: number, firstInsertedAt: number | undefined, diff --git a/src/backend/features/analytics/metric-queries.ts b/src/backend/features/analytics/metric-queries.ts index 641cfa645..ded08a4b5 100644 --- a/src/backend/features/analytics/metric-queries.ts +++ b/src/backend/features/analytics/metric-queries.ts @@ -1,7 +1,3 @@ -/** - * Reads of the library-wide rollups: totals, the day series behind the charts, - * and where inserts started from. - */ import { and, asc, @@ -67,10 +63,6 @@ export async function getTotals( }; } -/** - * The scope every rollup read takes: one library or all of them, one window or - * all of time. The tables differ only in which columns carry the two. - */ function scopeFilters( columns: { day: SQLiteColumn; libraryId: SQLiteColumn }, libraryId?: LibraryId, @@ -129,10 +121,7 @@ export function toTargets( return targets; } -/** - * `user_stats` holds one row per user for all time and so cannot be windowed; - * a range counts the per-day activity rollup instead. - */ +/** `user_stats` is all-time, so a range counts the daily rollup. */ function countUsers(db: Db, libraryId?: LibraryId, range?: DayRange) { if (range) { return db @@ -214,11 +203,7 @@ function countTargetsByDay(db: Db, range: DayRange, libraryId?: LibraryId) { .all(); } -/** - * DISTINCT, not COUNT: the table holds one row per user *per library* per day, - * so an unscoped read counts someone active in two libraries twice — and would - * then disagree with getTotals, which counts distinct. - */ +/** DISTINCT: rows are per library, so COUNT would count a user in two libraries twice. */ function countUsersByDay(db: Db, range: DayRange, libraryId?: LibraryId) { return db .select({ @@ -231,10 +216,7 @@ function countUsersByDay(db: Db, range: DayRange, libraryId?: LibraryId) { .all(); } -/** - * Every metric's daily values as one series, read from rollups: the event log - * grows with every insert rather than with the range. - */ +/** From rollups, since the event log grows with every insert. */ export async function getMetricSeries( db: Db, range: DayRange, @@ -284,8 +266,7 @@ export async function getMetricSeries( pointFor(row.day).activeUsers = row.activeUsers; } - // A quiet day is a zero, not a missing point: anything dividing by the - // number of points would average over active days instead of calendar ones. + // A quiet day is a zero, so averages are over calendar days. for (const day of eachDay(range)) pointFor(day); return [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)); @@ -365,8 +346,7 @@ export async function getSeries( point.counts[row.libraryId] = row.count; byDay.set(row.day, point); } - // Same reason as the metric series: a missing day is a zero, and a line - // that jumps across it reads as activity that never happened. + // A missing day is a zero, or the line jumps across it. for (const day of eachDay(range)) { if (!byDay.has(day)) byDay.set(day, { day, counts: {} }); } diff --git a/src/backend/features/analytics/parameter-usage.test.ts b/src/backend/features/analytics/parameter-usage.test.ts index abdf7aa1b..820db0042 100644 --- a/src/backend/features/analytics/parameter-usage.test.ts +++ b/src/backend/features/analytics/parameter-usage.test.ts @@ -38,8 +38,6 @@ describe("buildParameterUsage", () => { const small = usage.values.find((value) => value.value === "small"); expect(small).toMatchObject({ count: 0, label: "Small" }); - // The default is flagged even though it isn't the popular choice — - // which is the whole point of the report. const medium = usage.values.find((value) => value.value === "medium"); expect(medium?.isDefault).toBe(true); expect(usage.values[0].value).toBe("large"); diff --git a/src/backend/features/analytics/parameter-usage.ts b/src/backend/features/analytics/parameter-usage.ts index d5246995f..b7a6bd5b3 100644 --- a/src/backend/features/analytics/parameter-usage.ts +++ b/src/backend/features/analytics/parameter-usage.ts @@ -1,7 +1,3 @@ -/** - * What a part's recorded configuration values say, merged with the parameters - * it declares today — once per way each parameter is shown. - */ import { ParameterType, type ConfigurationParameter @@ -17,14 +13,8 @@ import type { const MAX_FREE_FORM_VALUES = 20; /** - * Merged with the parameters declared today, so an unused option still surfaces - * and a retired one is dropped: nobody can pick it any more. - * - * One entry per instance rather than per parameter — a list another choice - * filters is a different list under each. The rollup counts a value without - * recording what else was chosen alongside it, so an option two branches both - * offer is counted in both; an option only one branch offers, which is what - * conditioning options is usually for, is counted exactly once. + * Against today's parameters, so unused options show and retired ones drop. + * One entry per instance; an option two branches offer is counted in both. */ export function buildParameterUsage( parameters: ConfigurationParameter[], @@ -34,8 +24,6 @@ export function buildParameterUsage( return toParameterInstances(parameters).map((instance) => { const parameter = instance.parameter; - // `new Map(undefined)` is empty, which is what a parameter nobody has - // configured should read as. const counts = new Map( rowsByParameter .get(parameter.id) @@ -68,18 +56,14 @@ export function buildParameterUsage( type: parameter.type, defaultValue: parameter.default, path: instance.path.map((step) => step.label), - // An instance's own options, so its percentages add to a hundred; - // a free-form list is truncated, so its total stays the true one. + // An enum's percentages add to 100; a free-form list is truncated, so it keeps the true total. total: isEnum ? sumValues(values) : sumCounts(counts), values: values.sort((a, b) => b.count - a.count) }; }); } -/** - * Unbounded distinct values, so only the most-used are returned, plus the - * default. Labelled in the parameter's unit; nobody reads a tube length in metres. - */ +/** The most-used values plus the default, in the parameter's unit. */ function toFreeFormValues( counts: Map<string, number>, parameter: ConfigurationParameter, diff --git a/src/backend/features/analytics/part-queries.ts b/src/backend/features/analytics/part-queries.ts index 032d32cd1..434dbbff8 100644 --- a/src/backend/features/analytics/part-queries.ts +++ b/src/backend/features/analytics/part-queries.ts @@ -1,7 +1,3 @@ -/** - * Reads of the per-part rollups: what a part was used for inside a window, and - * how that lands day by day. - */ import { and, count, countDistinct, eq, gte, lte, sum } from "drizzle-orm"; import { type Db } from "../../db/client"; import { @@ -33,11 +29,7 @@ export interface PartRow { firstInsertedAt: Date | null; } -/** - * Every part the library still lists, with the date it was first inserted. - * Driven off `insertables` rather than the stats table, so a part nobody has - * used lists at zero and one that has left the library does not list at all. - */ +/** From `insertables`, so unused parts list at zero and removed ones don't list. */ export function getPartRows( db: Db, libraryId: LibraryId, @@ -73,7 +65,6 @@ export function getPartRows( ); } -/** One part counted over the window rather than over its whole history. */ export function toWindowedPart( row: PartRow, windowed: Map<string, number>, @@ -83,8 +74,7 @@ export function toWindowedPart( const from = Date.parse(`${range.from}T00:00:00Z`); const to = Math.min(Date.now(), Date.parse(`${range.to}T23:59:59Z`)); const insertCount = windowed.get(row.elementId) ?? 0; - // Rated over the days the part has existed, so arriving late in the window - // does not read as unpopular. + // Over the days it existed, so arriving late doesn't read as unpopular. const firstUsed = Math.max(row.firstInsertedAt?.getTime() ?? from, from); return { @@ -102,10 +92,7 @@ export function toWindowedPart( }; } -/** - * Inserts per element inside the window, folded out of the daily rollup — - * a range scan, thanks to `daily_insertable_metrics_day_idx`. - */ +/** A range scan on `daily_insertable_metrics_day_idx`. */ export async function getWindowedInsertCounts( db: Db, libraryId: LibraryId, @@ -179,11 +166,7 @@ function emptySparkline(): number[] { return Array.from({ length: MONTH_DAYS }, () => 0); } -/** - * Daily insert counts per part over the trailing window, as dense arrays the - * table can plot directly. Ends on the last complete day, as every other - * series does, so no row trails off into a half-recorded today. - */ +/** Ends on the last complete day, like every other series. */ export async function getPartSparklines( db: Db, libraryId: LibraryId @@ -194,8 +177,7 @@ export async function getPartSparklines( ); const dayIndex = new Map(days.map((day, i) => [day, i])); - // Summed over targets: a part inserted into both kinds of tab on one day - // has a row apiece, and the sparkline plots the day. + // Summed over targets, which each have a row. const rows = await db .select({ elementId: dailyInsertableMetrics.elementId, @@ -304,10 +286,6 @@ export function sumPartTargets( .all(); } -/** - * Favorites are keyed by insertable id, so a part that has left the library has - * none to count. - */ export function countPartFavorites( db: Db, libraryId: LibraryId, @@ -326,10 +304,7 @@ export function countPartFavorites( .get(); } -/** - * The parameters the part declares today. The 1:1 configurations table is keyed - * by insertable id, so they are only reachable through a live row. - */ +/** Only reachable through a live insertable row. */ export async function getPartParameters( db: Db, insertableId: string | undefined diff --git a/src/backend/features/analytics/range.test.ts b/src/backend/features/analytics/range.test.ts index eabf21e0f..6e43add56 100644 --- a/src/backend/features/analytics/range.test.ts +++ b/src/backend/features/analytics/range.test.ts @@ -55,8 +55,6 @@ describe("eachDay", () => { }); it("caps a far-future end rather than allocating a point per day to it", () => { - // Unclamped this is 2.9 million days, which exhausts the Worker before - // the caller ever gets to build a point for each one. const days = eachDay({ from: "2026-01-01", to: "9999-12-31" }); expect(days.length).toBeLessThanOrEqual(10 * 366); expect(days[0]).toBe("2026-01-01"); diff --git a/src/backend/features/analytics/range.ts b/src/backend/features/analytics/range.ts index bfd7aaadd..5e13e8308 100644 --- a/src/backend/features/analytics/range.ts +++ b/src/backend/features/analytics/range.ts @@ -1,6 +1,3 @@ -/** - * The window a dashboard read covers, and the days it spans. - */ import { min } from "drizzle-orm"; import z from "zod"; import { type Db } from "../../db/client"; @@ -10,18 +7,11 @@ import { addDays, toReportingDay, type DayRange } from "./day"; /** The uses a part must be at or below for the low-usage reports to list it. */ const DEFAULT_UNUSED_THRESHOLD = 5; -/** - * The most days one densified series may cover. Only a hand-edited url reaches - * it: the app asks for at most the days since tracking began. - */ +/** Only a hand-edited url reaches this. */ const MAX_SERIES_DAYS = 10 * 366; const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); -/** - * Both bounds are required: every page states the window it reports, so a - * missing one is the caller's bug. - */ export const rangeQuery = z.object({ from: day, to: day }); /** A range, plus the cutoff the low-usage reports list at or below. */ @@ -33,10 +23,7 @@ export const thresholdQuery = rangeQuery.extend({ .default(DEFAULT_UNUSED_THRESHOLD) }); -/** - * Every day in the range. Clamp `from` first, or "all time" fills two decades of - * zeroes; capped as well, since the allocation happens here. - */ +/** Clamp `from` first, or "all time" fills two decades. */ export function eachDay(range: DayRange): string[] { const days: string[] = []; let day = range.from; @@ -47,10 +34,7 @@ export function eachDay(range: DayRange): string[] { return days; } -/** - * The first day anything was recorded, which is what tells "nothing happened" - * from "we were not tracking yet". - */ +/** Tells "nothing happened" from "not tracking yet". */ export async function getTrackingSince(db: Db): Promise<string | undefined> { const row = await db .select({ day: min(dailyMetrics.day) }) @@ -59,11 +43,7 @@ export async function getTrackingSince(db: Db): Promise<string | undefined> { return row?.day ?? undefined; } -/** - * Narrows a range to the days tracking covers. `to` is held to the last - * reported day as well: today is still filling, nothing was recorded tomorrow, - * and an unclamped end runs to any year asked for. - */ +/** `to` is held to the last reported day, since today is still filling. */ export function clampRange( range: DayRange, since: string | undefined, diff --git a/src/backend/features/analytics/rollups.ts b/src/backend/features/analytics/rollups.ts index 8bb06944e..d34297470 100644 --- a/src/backend/features/analytics/rollups.ts +++ b/src/backend/features/analytics/rollups.ts @@ -16,10 +16,7 @@ import { type LoggedEvent } from "./schema"; -/** - * Every counter one event feeds, derived from the row alone — which is what lets - * a replay rebuild the rollups exactly, or move them to a batch job. - */ +/** Derived from the row alone, so a replay rebuilds the rollups exactly. */ export function rollupWrites( db: Db, event: LoggedEvent @@ -78,10 +75,7 @@ function countDay(db: Db, event: LoggedEvent) { }); } -/** - * Records that this user was active that day. Idempotent, so the row is written - * once per user per library per day no matter how much they do. - */ +/** Idempotent: one row per user per library per day. */ function markUserActive(db: Db, event: LoggedEvent) { return db .insert(dailyUserActivity) @@ -168,7 +162,7 @@ function countTarget(db: Db, event: LoggedInsert) { }); } -/** As {@link countTarget}, but for one part rather than the library. */ +/** {@link countTarget} for one part. */ function countPartDay(db: Db, event: LoggedInsert) { return db .insert(dailyInsertableMetrics) @@ -190,7 +184,7 @@ function countPartDay(db: Db, event: LoggedInsert) { }); } -/** As {@link markUserActive}, but for one part rather than the library. */ +/** {@link markUserActive} for one part. */ function markPartUser(db: Db, event: LoggedInsert) { return db .insert(dailyInsertableUsers) diff --git a/src/backend/features/analytics/rollups.worker.test.ts b/src/backend/features/analytics/rollups.worker.test.ts index 91760b521..c1ab2a6af 100644 --- a/src/backend/features/analytics/rollups.worker.test.ts +++ b/src/backend/features/analytics/rollups.worker.test.ts @@ -88,7 +88,6 @@ describe("rollupWrites", () => { }); it("rebuilds every rollup from the log alone", async () => { - // Two days, so the day keys have to come from the rows rather than now. const day = 24 * 3600 * 1000; const start = Date.parse("2026-03-01T09:00:00Z"); const clock = vi.spyOn(Date, "now"); @@ -125,8 +124,7 @@ describe("rollupWrites", () => { const live = await readRollups(); expect(live.every((rows) => rows.length > 0)).toBe(true); - // What a batch job would do: drop the rollups and derive them from the log. - // Newest first, since nothing promises a job reads rows in the order written. + // Newest first, since a job needn't read rows in order. for (const table of ROLLUPS) { await db.delete(table); } diff --git a/src/backend/features/analytics/routes.ts b/src/backend/features/analytics/routes.ts index c6972cb4a..c868cf730 100644 --- a/src/backend/features/analytics/routes.ts +++ b/src/backend/features/analytics/routes.ts @@ -51,10 +51,7 @@ import { export const analyticsRoutes = getApp(); -/** - * Every handler here is public, which in this app means never touching - * `getUserId()` or `getOnshapeApi()`. Only aggregates leave — never a user id. - */ +/** Public: never call `getUserId()` or `getOnshapeApi()`, and return only aggregates. */ /** GET /api/analytics/overview */ analyticsRoutes.get( @@ -64,8 +61,7 @@ analyticsRoutes.get( const db = getDb(c.env.DB); const requested = c.req.valid("query"); const trackingSince = await getTrackingSince(db); - // Series are densified, so they read the clamped range; the totals still - // read what was asked for, where extra empty days cost nothing. + // Series are densified, so they need the clamped range. const range = clampRange(requested, trackingSince); const [totals, perLibrary, series, metricSeries, sources, growth] = @@ -121,8 +117,7 @@ analyticsRoutes.get( /** GET /api/analytics/health/library/:libraryId?v=:cacheVersion */ analyticsRoutes.get( "/analytics/health" + libraryRoute(), - // Off the same build issues `/build-status` reads, so keyed the same way. - // Public rather than private: this answer is the same for whoever asks. + // Public cache: the answer is the same for everyone. cacheMiddleware(CachePolicy.PUBLIC_CACHE), async (c) => { const libraryId = getLibraryParam(c); @@ -228,8 +223,7 @@ analyticsRoutes.get( byElement.get(part.elementId) ?? [] ); for (const parameter of usage) { - // Only an enum declares the options it could have been given, so - // only an enum can have one that was never picked. + // Only an enum declares its options. if (parameter.type !== ParameterType.ENUM) continue; for (const value of parameter.values) { if (value.count > threshold) continue; @@ -246,8 +240,7 @@ analyticsRoutes.get( } } - // Never-picked first, then by how much of the parameter went elsewhere: - // an option skipped on a heavily configured part is the stronger signal. + // Never-picked first, then by how much of the parameter went elsewhere. out.sort( (a, b) => a.value.count - b.value.count || @@ -289,8 +282,7 @@ analyticsRoutes.get( (total, count) => total + count, 0 ); - // Rated over the days the part has existed inside the window, as the - // parts table rates it. + // Over the days the part existed in the window, as the parts table rates it. const windowStart = Date.parse(`${range.from}T00:00:00Z`); const windowEnd = Math.min( Date.now(), @@ -301,8 +293,7 @@ analyticsRoutes.get( windowStart ); - // Only a part still in the library has a report: its name, its path - // and its parameters all come from the row that is no longer there. + // Its name, path and parameters all come from the row. if (!insertable) { throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } diff --git a/src/backend/features/analytics/routes.worker.test.ts b/src/backend/features/analytics/routes.worker.test.ts index 9312c705a..421eb7eec 100644 --- a/src/backend/features/analytics/routes.worker.test.ts +++ b/src/backend/features/analytics/routes.worker.test.ts @@ -50,10 +50,7 @@ import { BuildIssueType } from "../build-checker/issues"; const db = getDb(env.DB); const elementId = TEST_PART_STUDIO_PATH.elementId; -/** - * The real app with services that throw, as they do without a session: an - * endpoint that touched one would fail here, which keeps the dashboard public. - */ +/** Services throw, as without a session, which keeps the dashboard public. */ function anonymousGet(path: string) { const app = createApp(() => ({ getOnshapeApi: () => Promise.reject(new Error("no session")), @@ -87,10 +84,7 @@ interface SeedInsertOptions { target?: ElementType; } -/** - * The rollups `count` inserts of one part on one day would leave behind, which - * is all any endpoint reads. - */ +/** The rollups these inserts would leave, which is all any endpoint reads. */ async function seedInserts( count: number, options: SeedInsertOptions = {} @@ -271,7 +265,6 @@ describe("analytics routes", () => { favoriteInserts: 4, fastenInserts: 3, quickInserts: 6, - // The fasten denominator, so the UI reads 3/5 rather than 3/10. targets: { [ElementType.ASSEMBLY]: 5, [ElementType.PART_STUDIO]: 5 @@ -295,8 +288,7 @@ describe("analytics routes", () => { it("scopes rangeTotals to the range while totals stay lifetime", async () => { await seedMetric("2026-01-01", 5); await seedMetric("2026-06-15", 3); - // user_stats holds one all-time row per user, so a range reads - // the per-day activity rollup instead. + // user_stats is all-time, so a range reads the daily rollup. await db.insert(dailyUserActivity).values([ { day: "2026-01-01", @@ -324,8 +316,7 @@ describe("analytics routes", () => { expect(body.totals.inserts).toBe(8); }); - // The rollup holds one row per user *per library* per day, so counting - // rows would report one person active in two libraries as two. + // The rollup is per library, so counting rows would count this user twice. it("counts a user active in two libraries once in the day series", async () => { await seedMetric("2026-06-15", 4); await db.insert(dailyUserActivity).values([ @@ -349,14 +340,11 @@ describe("analytics routes", () => { const day = body.metricSeries.find( (point) => point.day === "2026-06-15" ); - // One, not two — which is also how getTotals counts a windowed - // read of this table, so the tile and the series agree. expect(day?.activeUsers).toBe(1); }); it("fills quiet days in, so an average is per calendar day", async () => { - // Two active days in a wider window: left sparse, a mean over the - // points would report the two-day average as the month's. + // Sparse points would make a mean report the two-day average as the month's. await seedMetric("2026-06-15", 2); await seedMetric("2026-06-18", 4); @@ -365,8 +353,7 @@ describe("analytics routes", () => { ); const body: AnalyticsOverviewOut = await res.json(); - // Clamped to the first recorded day, not back to the requested - // one: "all time" reaches to 2000 and would fill two decades. + // "All time" reaches to 2000, so it clamps to the first recorded day. expect(body.metricSeries.map((point) => point.day)).toEqual([ "2026-06-15", "2026-06-16", @@ -421,8 +408,7 @@ describe("analytics routes", () => { count: 5, quickInsertCount: 5 }); - // Present as a zero rather than missing, so the UI shows every - // source — a new one included, from the day it is added. + // So the UI shows every source, including a new one. expect(bySource[InsertSource.BROWSE]).toMatchObject({ count: 0 }); expect(bySource[InsertSource.GROUP_SEARCH]).toMatchObject({ count: 0 @@ -468,8 +454,6 @@ describe("analytics routes", () => { ); const body: AnalyticsOverviewOut = await res.json(); - // Every day in the window is present, but only the one inside it - // carries a count — days outside are excluded, not zeroed. expect(body.series).toHaveLength(62); expect(body.series[0].day).toBe("2026-05-01"); expect(body.series.at(-1)?.day).toBe("2026-07-01"); @@ -528,8 +512,7 @@ describe("analytics routes", () => { }); it("still lists a part that went unused in the window", async () => { - // Zero here means "not used lately", which is the interesting - // reading — dropping the row would hide it. + // Zero means "not used lately", which is worth showing. await seedPartStudio(db); await seedInserts(4, { day: "2025-03-01" }); @@ -544,8 +527,6 @@ describe("analytics routes", () => { }); it("keeps history for a part that is no longer visible", async () => { - // A hidden part is still in the library, so it stays listed with - // whatever usage it accumulated while it was insertable. await seedPartStudio(db); await seedInserts(9); @@ -572,8 +553,7 @@ describe("analytics routes", () => { it("leaves out a part that is no longer in the library", async () => { await seedPartStudio(db); await seedInserts(9); - // Usage with no insertable behind it: the tab was deleted. It would - // otherwise top the table with a part nobody can open. + // The tab was deleted, so nobody could open it. await seedInserts(30, { element: "e-gone" }); const res = await anonymousGet(partsUrl()); @@ -593,8 +573,7 @@ describe("analytics routes", () => { await seedPartStudio(db); await seedAssembly(db); await db.update(insertables).set({ isVisible: true }); - // The part studio earned 60 over two years; the assembly earned 20 - // in the last two months and is the one in active use. + // The assembly is the one in active use. await db.insert(insertableStats).values([ { libraryId: TEST_LIBRARY_ID, @@ -627,8 +606,7 @@ describe("analytics routes", () => { }); it("rates a part over its own days, not the whole window", async () => { - // Both parts were used 10 times, but one only existed for the last - // stretch of the window and must not be marked down for it. + // One part only existed for the last stretch and mustn't be marked down for it. const day = 24 * 3600 * 1000; const ago = (days: number) => toDayKey(Date.now() - days * day); await seedPartStudio(db); @@ -686,8 +664,7 @@ describe("analytics routes", () => { }); it("adds up a day's targets in the sparkline and the count", async () => { - // One row per target, so a part used both ways in a day would - // otherwise plot whichever row came back last. + // One row per target, so both must be summed. const last = toReportingDay(Date.now()); await seedPartStudio(db); await seedInserts(2, { @@ -709,8 +686,6 @@ describe("analytics routes", () => { }); it("keeps the sparkline at 30 days whatever the range", async () => { - // It is a shape, not a window: two years of points in a sparkline - // is a smear. await seedPartStudio(db); await seedInserts(2); @@ -836,8 +811,7 @@ describe("analytics routes", () => { }); it("ignores a parameter with no declared options", async () => { - // A boolean or quantity has no option list, so "never used" is not - // a question that can be asked of it. + // Only an enum has an option list to go unused. await seedPartStudio(db); await db.update(insertables).set({ isVisible: true }); await seedConfiguration(db); diff --git a/src/backend/features/analytics/schema.ts b/src/backend/features/analytics/schema.ts index da0cfeaf8..7e0310b89 100644 --- a/src/backend/features/analytics/schema.ts +++ b/src/backend/features/analytics/schema.ts @@ -1,7 +1,4 @@ -/** - * Tracking's own tables, kept out of `db/schema.ts` because nothing here points - * at the app's data: no foreign key crosses between the two sides. - */ +/** Kept out of `db/schema.ts`: no foreign key crosses between the two. */ import { sqliteTable, @@ -17,8 +14,8 @@ import { Selection } from "../configurations/contract"; import { EventType, InsertSource } from "./usage"; /** - * Append-only usage log, keyed on the Onshape `elementId` and free of foreign - * keys: a re-added tab gets a fresh app id, and a reload must not drop history. + * Append-only. Keyed on Onshape's `elementId` with no foreign keys, so a reload + * or re-added tab keeps history. */ export const events = sqliteTable( "events", @@ -32,13 +29,9 @@ export const events = sqliteTable( day: text("day").notNull(), libraryId: text("library_id").$type<LibraryId>().notNull(), userId: text("user_id").notNull(), - /** - * What the columns meant when the row was written. 1 is the backfill - * for rows predating the column, which is what they were. - */ + /** Rows predating the column were version 1. */ schemaVersion: integer("schema_version").notNull().default(1), - // The whole path inserted from, version included: what the part was - // when it was used, which the library row no longer says after a reload. + // The versioned path, since the library row moves on after a reload. elementId: text("element_id"), documentId: text("document_id"), instanceId: text("instance_id"), @@ -51,26 +44,21 @@ export const events = sqliteTable( selection: text("selection", { mode: "json" }).$type<Selection | null>(), - // Whether the part was favorited at insert time — not where the insert - // came from; `source` carries that. + // Favorited at insert time; `source` says where the insert came from. isFavorite: integer("is_favorite", { mode: "boolean" }), isQuickInsert: integer("is_quick_insert", { mode: "boolean" }), source: text("source").$type<InsertSource>(), // Insert-and-fasten, which Onshape only offers for assembly targets. fasten: integer("fasten", { mode: "boolean" }) }, - // Indexed by day alone: nothing reads the log to report a metric, so this - // exists to rebuild the rollups below, which is a walk through time. + // Only used to rebuild the rollups, which walk by day. (t) => [index("events_day_idx").on(t.day)] ); /** One row of the log: everything the rollups are derived from. */ export type LoggedEvent = typeof events.$inferSelect; -/** - * Per-day counts, each flag counter a subset of `count`. Fasten's denominator is - * the assembly row of {@link dailyTargetMetrics}, Onshape offering it only there. - */ +/** Each flag counter is a subset of `count`. */ export const dailyMetrics = sqliteTable( "daily_metrics", { @@ -85,10 +73,7 @@ export const dailyMetrics = sqliteTable( (t) => [primaryKey({ columns: [t.day, t.libraryId, t.type] })] ); -/** - * Per-day inserts split by the kind of tab they landed in. A dimension rather - * than a counter per type, so a new kind of target needs no column. - */ +/** A row per type, so a new target type needs no column. */ export const dailyTargetMetrics = sqliteTable( "daily_target_metrics", { @@ -135,10 +120,7 @@ export const insertableStats = sqliteTable( ] ); -/** - * Per-day counts for one part, split by target as {@link dailyTargetMetrics} is. - * Keyed part-first for one part's history, indexed by day for a whole library's. - */ +/** Keyed part-first for one part's history; indexed by day for a library's. */ export const dailyInsertableMetrics = sqliteTable( "daily_insertable_metrics", { @@ -158,10 +140,7 @@ export const dailyInsertableMetrics = sqliteTable( ] ); -/** - * {@link dailyUserActivity} narrowed to one part: a distinct-user count is the - * one measure a counter cannot accumulate, so the identities are kept per day. - */ +/** Distinct users can't be summed, so identities are kept per day. */ export const dailyInsertableUsers = sqliteTable( "daily_insertable_users", { @@ -177,10 +156,6 @@ export const dailyInsertableUsers = sqliteTable( ] ); -/** - * How often each configuration value was chosen, per day, so a default that - * nobody wants (or an option nobody picks) is visible over any window. - */ export const dailyConfigurationMetrics = sqliteTable( "daily_configuration_metrics", { @@ -199,10 +174,7 @@ export const dailyConfigurationMetrics = sqliteTable( ] ); -/** - * One row per user per library per day, so a distinct-user count is a DISTINCT - * over users x days rather than over every insert ever made. - */ +/** So distinct users is a count over days, not over every insert. */ export const dailyUserActivity = sqliteTable( "daily_user_activity", { diff --git a/src/backend/features/analytics/seasons.test.ts b/src/backend/features/analytics/seasons.test.ts index 86a187860..6a698f368 100644 --- a/src/backend/features/analytics/seasons.test.ts +++ b/src/backend/features/analytics/seasons.test.ts @@ -42,8 +42,7 @@ describe("currentSeason", () => { it.each([ ["2026-08-31", undefined], ["2026-09-01", "FTC 2026–27"], - // The one that a naive year lookup gets wrong: January belongs to a - // season that opened the previous September. + // January belongs to the season that opened the previous September. ["2027-01-15", "FTC 2026–27"], ["2027-04-30", "FTC 2026–27"], ["2027-05-01", undefined] @@ -77,8 +76,6 @@ describe("seasonWindow", () => { describe("baselineWindow", () => { it("clips the baseline to the same elapsed stretch", () => { - // 32 days into 2027 compares against the first 32 days of 2026, not - // against the whole of it. const baseline = baselineWindow( seasonWindow(Program.FRC, "2027-02-01") ); diff --git a/src/backend/features/analytics/seasons.ts b/src/backend/features/analytics/seasons.ts index 0787981c9..35d84a0b1 100644 --- a/src/backend/features/analytics/seasons.ts +++ b/src/backend/features/analytics/seasons.ts @@ -1,10 +1,7 @@ import { LibraryId } from "../library/library-id"; import { addDays } from "./day"; -/** - * Competition seasons, which is what makes usage comparable: a library built - * for a Jan–Apr competition has a year nothing like a calendar one. - */ +/** Usage is compared by season: a Jan–Apr competition's year is nothing like a calendar one. */ export enum Program { FRC = "FRC", FTC = "FTC" @@ -17,10 +14,7 @@ export const LIBRARY_PROGRAM: Record<LibraryId, Program> = { [LibraryId.FTC_DESIGN_LIB]: Program.FTC }; -/** - * Month a season opens and the month it closes, both inclusive. FTC opens - * before New Year and closes after it, so its span covers two calendar years. - */ +/** Inclusive. FTC's season spans New Year. */ const SPANS: Record<Program, { startMonth: number; endMonth: number }> = { [Program.FRC]: { startMonth: 1, endMonth: 4 }, [Program.FTC]: { startMonth: 9, endMonth: 4 } @@ -34,10 +28,7 @@ export interface Season { /** Those months as `YYYY-MM`, which is all a chart marker needs. */ startMonth: string; endMonth: string; - /** - * The year the season ends in, which is how both programs name themselves: - * FTC's Sept 2026 – Apr 2027 is the 2027 season, as is FRC's Jan–Apr 2027. - */ + /** The year it ends in: FTC's Sept 2026 – Apr 2027 is the 2027 season. */ year: number; /** "2027" or "2026–27" — the season named without naming a program. */ years: string; @@ -82,8 +73,7 @@ export function currentSeason( day: string ): Season | undefined { const year = Number(day.slice(0, 4)); - // A day in January belongs to a season that opened the previous year, so - // both candidates have to be tried. + // A January day belongs to a season opened the year before. for (const candidate of [ seasonOf(program, year), seasonOf(program, year + 1) @@ -126,10 +116,7 @@ function daysBetween(from: string, to: string): number { return Math.round(ms / (24 * 3600 * 1000)) + 1; } -/** - * In season, the season so far; between seasons, the last complete one — an - * off-season week against a full season would show a collapse every May. - */ +/** Between seasons, the last complete one, or every May would show a collapse. */ export function seasonWindow(program: Program, day: string): SeasonWindow { const current = currentSeason(program, day); if (current === undefined) { @@ -151,10 +138,7 @@ export function seasonWindow(program: Program, day: string): SeasonWindow { }; } -/** - * The same stretch of the previous season, so a half-finished season is - * compared against half of the one before rather than all of it. - */ +/** The same stretch of the previous season, so a half season compares to a half. */ export function baselineWindow(window: SeasonWindow): { from: string; to: string; diff --git a/src/backend/features/analytics/tracking.ts b/src/backend/features/analytics/tracking.ts index 39c9c0aa0..e4cc2ef3b 100644 --- a/src/backend/features/analytics/tracking.ts +++ b/src/backend/features/analytics/tracking.ts @@ -18,18 +18,14 @@ import { toDayKey } from "./day"; export interface InsertEvent { libraryId: LibraryId; userId: string; - /** The version-pinned tab inserted from, logged whole: the rollups key on - * its element id, and the rest says which version was used. */ + /** The rollups key on the element id; the rest records the version. */ path: ElementPath; insertableId: string; /** The type of tab the user inserted into. */ targetElementType: ElementType; /** The whole selection the insert applied; undefined when it has none. */ selection: Selection | undefined; - /** - * The parameters that selection was made whole against, carried rather than - * read back: applying the insert already had to load them. - */ + /** Passed in, since the insert already loaded them. */ parameters: ConfigurationParameter[]; /** Whether the part was favorited, not where the insert came from. */ isFavorite: boolean; @@ -43,10 +39,7 @@ interface AppOpenEvent { userId: string; } -/** - * Usage data is never worth failing a user's insert over, so errors are logged - * and dropped. Awaits when no execution context is available. - */ +/** Tracking never fails an insert, so errors are logged. Awaits without an execution context. */ export async function trackInBackground( c: AppContext, work: () => Promise<void> @@ -95,11 +88,7 @@ export async function trackAppOpen( }); } -/** - * What Onshape applied for a selection: the values no condition hid, spelled - * canonically so "5 in" and "(2 + 3) in" count as one value. Null when the - * insertable has nothing to configure, which is what the log records. - */ +/** Canonical, so "5 in" and "(2 + 3) in" count as one. Null when there's nothing to configure. */ function appliedSelection( selection: Selection | undefined, parameters: ConfigurationParameter[] @@ -125,10 +114,7 @@ function core( }; } -/** - * The two halves of a write, batched so neither lands without the other. Kept - * apart so the counting can move to a batch job without touching the recording. - */ +/** Batched so neither half lands without the other. */ async function record(db: Db, event: LoggedEvent): Promise<void> { const writes: BatchItem<"sqlite">[] = [ db.insert(events).values(event), diff --git a/src/backend/features/analytics/tracking.worker.test.ts b/src/backend/features/analytics/tracking.worker.test.ts index 0bf849079..8256a5a87 100644 --- a/src/backend/features/analytics/tracking.worker.test.ts +++ b/src/backend/features/analytics/tracking.worker.test.ts @@ -90,10 +90,7 @@ const SIZE_PARAMETERS: ConfigurationParameter[] = [ } ]; -/** - * An insert of a configured part, whole against its parameters — which is what - * the insert routes hand tracking. - */ +/** Whole, as the insert routes hand it over. */ function configuredEvent( values: Record<string, string>, overrides: Partial<InsertEvent> = {} @@ -130,8 +127,6 @@ describe("tracking", () => { libraryId: TEST_LIBRARY_ID, userId: TEST_USER_ID, schemaVersion: EVENT_SCHEMA_VERSION, - // The whole path, so the version used is still known after a - // reload moves the library on. ...TEST_PART_STUDIO_PATH, selection: { size: "large" } }); @@ -170,8 +165,6 @@ describe("tracking", () => { expect(daily).toHaveLength(1); expect(daily[0].count).toBe(3); - // The user is unique, so still one row — this is what makes the - // unique-user count a cheap COUNT. const users = await db.select().from(userStats).all(); expect(users).toHaveLength(1); expect(users[0].insertCount).toBe(3); @@ -270,8 +263,7 @@ describe("tracking", () => { it("leaves out a parameter the selection hides", async () => { await seedSizeConfiguration(); - // "reinforced" is only shown for the large size, and Onshape - // applies nothing it does not show. + // "reinforced" is hidden for small, so it isn't applied. await trackInsert( fakeContext(), configuredEvent({ size: "small", reinforced: "true" }) @@ -310,8 +302,7 @@ describe("tracking", () => { ]); }); - // A quick insert chooses nothing, and the route hands over the whole - // selection anyway — every parameter at its default. + // A quick insert chooses nothing, but every parameter's default applies. it("counts the defaults of a selection nobody touched", async () => { await seedSizeConfiguration(); @@ -325,8 +316,6 @@ describe("tracking", () => { Object.fromEntries( values.map((row) => [row.parameterId, row.value]) ) - // No "reinforced": the small default hides it, so it applied - // nothing. ).toEqual({ size: "small", length: "0.0254 m" }); }); @@ -367,8 +356,7 @@ describe("tracking", () => { targetElementType: ElementType.ASSEMBLY }) ); - // A part-studio insert is never fasten-eligible, so it must not - // dilute the denominator. + // Part-studio inserts can't fasten, so they mustn't dilute the denominator. await trackInsert( fakeContext(), insertEvent({ @@ -420,8 +408,6 @@ describe("tracking", () => { }); }); - // The point of the two: a search filtered to one group is a different - // search from one across the library, and reads as one. it("counts a search inside a group apart from one across the library", async () => { await trackInsert( fakeContext(), @@ -446,8 +432,7 @@ describe("tracking", () => { }); it("keeps a favorited part inserted from search attributed to search", async () => { - // isFavorite is a property of the part; source is where the insert - // began. Conflating them would misreport the favorites list. + // isFavorite is about the part; source is where the insert began. await trackInsert( fakeContext(), insertEvent({ diff --git a/src/backend/features/analytics/usage.ts b/src/backend/features/analytics/usage.ts index 62099b1e8..a2e965ff6 100644 --- a/src/backend/features/analytics/usage.ts +++ b/src/backend/features/analytics/usage.ts @@ -1,29 +1,17 @@ /** - * The vocabulary of what gets recorded. Named for what it describes rather than - * for the events themselves: as `events.ts` it built to an `events-<hash>.js` - * chunk, and the frontend imports these enums, so an ad blocker that matches - * that name takes the main entry and the library route down with it. That is - * what blocked it in dev, where the url carries the path as written. + * Not named `events.ts`: ad blockers match the `events-<hash>.js` chunk, and + * the frontend imports these enums. */ -/** - * What a logged event's columns mean. Bump it when that changes, so a reader can - * tell rows written under the old reading from rows written under the new. - */ +/** Bump when the columns' meaning changes. */ export const EVENT_SCHEMA_VERSION = 1; -/** - * The kinds of usage events recorded for the analytics dashboard. - */ export enum EventType { INSERT = "insert", APP_OPEN = "app_open" } -/** - * Where an insert started, not whether the part is favorited. One value per place - * rather than a source crossed with a location: only search has two forms. - */ +/** Not whether the part is favorited. */ export enum InsertSource { /** Searching the whole library, from its home list. */ SEARCH = "search", diff --git a/src/backend/features/auth/access-level.ts b/src/backend/features/auth/access-level.ts index ccbf7ad28..c137304e6 100644 --- a/src/backend/features/auth/access-level.ts +++ b/src/backend/features/auth/access-level.ts @@ -1,9 +1,6 @@ /** The permission tiers the app grants, and the predicates routes gate on. */ export enum AccessLevel { - /** - * The one Onshape user named by `OWNER_USER_ID`: an admin whose session the - * server borrows for work nobody asked for, like a webhook's reload. - */ + /** `OWNER_USER_ID`: an admin whose session the server borrows for its own work. */ OWNER = "owner", ADMIN = "admin", EDITOR = "editor", @@ -29,10 +26,7 @@ export function isWithinAccessLevel( return ACCESS_LEVEL_RANK[accessLevel] <= ACCESS_LEVEL_RANK[maxAccessLevel]; } -/** - * Server-provided access: the highest level granted plus sign-in state. The - * level the app is currently viewed as is client-side (see useAccessData). - */ +/** The level currently viewed is client-side; see useAccessData. */ export interface AccessData { maxAccessLevel: AccessLevel; signedIn: boolean; diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index 96522f87f..ac1737d56 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -1,7 +1,3 @@ -/** - * The gates routes mount: signed in to Onshape at all, on a library's admin - * team, and the owner. - */ import type { MiddlewareHandler } from "hono"; import { forbiddenError, @@ -37,11 +33,9 @@ type LibraryOf = (c: AppContext) => Promise<LibraryId | undefined>; const libraryParam: LibraryOf = (c) => Promise.resolve(getLibraryParam(c)); /** - * Editing a library takes a place on its admin team. `libraryOf` is for a route - * naming something inside a library rather than the library: the library is - * looked up from it, not taken from the caller, whose word it would otherwise - * be. Editing implies a session: access level alone would admit a signed-out - * caller under a dev access-level override, and answer 403 rather than 401. + * `libraryOf` looks the library up from what the route names, rather than + * trusting the caller. Requires a session, or a dev override would let a + * signed-out caller through. */ export function requireEditor( libraryOf: LibraryOf = libraryParam @@ -64,10 +58,7 @@ export function requireEditor( /** For a route under `libraryRoute()`. */ export const requireEditorMiddleware = requireEditor(); -/** - * For what reaches past any one library. The owner's access is the same in - * every library, so any library answers. - */ +/** The owner's access is the same everywhere, so any library answers. */ export const requireOwnerMiddleware: MiddlewareHandler<AppContextEnv> = async ( c, next diff --git a/src/backend/features/auth/guards.worker.test.ts b/src/backend/features/auth/guards.worker.test.ts index de460469c..ffb22eab9 100644 --- a/src/backend/features/auth/guards.worker.test.ts +++ b/src/backend/features/auth/guards.worker.test.ts @@ -56,8 +56,7 @@ describe("requireEditorMiddleware", () => { await resetDb(db); }); - // Access level alone would admit a signed-out caller wherever it is - // granted without a session, e.g. behind a dev access-level override. + // A dev access-level override grants the level without a session. it("401s an editor-level caller who is not signed in", async () => { const app = createTestApp({ signedIn: false, @@ -90,8 +89,7 @@ describe("requireEditorMiddleware", () => { describe("editing something inside a library", () => { beforeEach(() => resetDb(db)); - // The library comes from the insertable, not from anything the caller - // sends, so an editor of one library cannot reach into another. + // So an editor of one library can't reach into another. it("takes the access of the library the insertable is in", async () => { await seedGroup(db, "ftc-group", LibraryId.FTC_DESIGN_LIB); await seedInsertable(db, { diff --git a/src/backend/features/auth/onshape-oauth.ts b/src/backend/features/auth/onshape-oauth.ts index 4895b1881..39247943c 100644 --- a/src/backend/features/auth/onshape-oauth.ts +++ b/src/backend/features/auth/onshape-oauth.ts @@ -15,11 +15,7 @@ import { const AUTH_ENDPOINT = "https://oauth.onshape.com/oauth/authorize"; export const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; -/** - * No redirect uri, so neither the authorization request nor the code exchange - * names one and Onshape returns the caller to whichever its OAuth app - * registers — so a host wants an app registering its own callback. - */ +/** No redirect uri, so Onshape uses the OAuth app's registered callback. */ export function getOauthClient(): OAuth2Client { return new OAuth2Client(env.OAUTH_CLIENT_ID, env.OAUTH_CLIENT_SECRET, null); } @@ -32,11 +28,7 @@ export function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { }; } -/** - * Stores the redirectUrl and state. - * - * Returns the URL the user should be redirected to. - */ +/** Stores the redirect url and state; returns the url to send the user to. */ export async function doSignIn( c: AppContext, redirectUrl: string, @@ -54,10 +46,7 @@ export async function doSignIn( state, [] ); - // company_id scopes the sign-in to an enterprise, and Onshape only accepts - // a real one: sign-in worked in an enterprise and failed for plain - // cad.onshape.com users, whose id is PERSONAL_COMPANY_ID. So that id is - // left off, as a standalone sign-in's missing company already is. + // Onshape only accepts a real enterprise's company_id. if (companyId && companyId !== PERSONAL_COMPANY_ID) { authorizationUrl.searchParams.set("company_id", companyId); } @@ -74,7 +63,7 @@ export async function doCallback(c: AppContext): Promise<Response> { const session = await takeLoginSession(c); - // There was a problem with the cookie used to store redirect information + // The redirect cookie was missing. if (!session) { if (isSafari(c.req.raw)) { return c.redirect("/safari-error"); diff --git a/src/backend/features/auth/owner.ts b/src/backend/features/auth/owner.ts index 796c8d3e2..b2ecb7215 100644 --- a/src/backend/features/auth/owner.ts +++ b/src/backend/features/auth/owner.ts @@ -1,17 +1,13 @@ /** - * The owner's session, kept for work the server starts on its own. A webhook - * has nobody signed in behind it, but loading a document calls Onshape as - * someone, so it borrows the session the owner last used the app with. + * Work the server starts on its own, like a webhook load, still calls Onshape + * as someone, so it borrows the owner's last session. */ import { type OAuthApi } from "../../lib/onshape/client"; import { getOnshapeApiFromSessionId } from "./request-auth"; const OWNER_SESSION_KEY = "owner-session"; -/** - * Called whenever the owner's access is resolved; written only when their - * session has changed, which a sign-in does. - */ +/** Only writes when the session changed. */ export async function rememberOwnerSession( kv: KVNamespace, sessionId: string @@ -21,11 +17,7 @@ export async function rememberOwnerSession( } } -/** - * The owner's last session, or undefined when they have never used the app. It can - * have ended since — signed out, or unused past its lifetime — in which case - * calling Onshape with it fails until they next open the app. - */ +/** Can have expired, in which case Onshape calls fail until the owner next opens the app. */ export async function getOwnerSessionId( kv: KVNamespace ): Promise<string | undefined> { diff --git a/src/backend/features/auth/request-auth.ts b/src/backend/features/auth/request-auth.ts index b4495ab6f..973b7d859 100644 --- a/src/backend/features/auth/request-auth.ts +++ b/src/backend/features/auth/request-auth.ts @@ -1,7 +1,4 @@ -/** - * Answers a request's auth questions from its session. `createApp` - * binds `productionAuth` onto every request; guards and routes ask through `c.var`. - */ +/** `createApp` binds `productionAuth` onto every request; routes ask through `c.var`. */ import { env as processEnv } from "process"; import { OAuthApi } from "../../lib/onshape/client"; import { getSessionInfo, getUserId } from "../../lib/onshape/endpoints/users"; @@ -40,9 +37,7 @@ export async function getOnshapeApiFromSessionId( .refreshAccessToken(TOKEN_ENDPOINT, session.refreshToken, []) .then((refreshed) => makeAuthTokens(refreshed)); - // Awaited, not floated: a cancelled write leaves the old token in KV - // and every later request refreshes again. Spread, so the refresh keeps - // the userId the session already resolved. + // Awaited: a cancelled write leaves the old token, so every request refreshes. await saveSession(kv, sessionId, { ...session, ...newTokens }); return newTokens.accessToken; @@ -57,11 +52,7 @@ export async function getOnshapeApiFromSessionId( return new OAuthApi(accessToken, refreshCallback); } -/** - * Creates/caches an Onshape API instance from the AppContext. - * - * Note this function should not be called directly, as it is bound to the context directly. - */ +/** Cached on the context; call it through `c.var`. */ export async function getOnshapeApi(c: AppContext): Promise<OAuthApi> { const cached = c.get("onshapeApi"); if (cached) return cached; @@ -70,11 +61,7 @@ export async function getOnshapeApi(c: AppContext): Promise<OAuthApi> { return api; } -/** - * The Onshape user a session belongs to, resolved once and kept on it. Taken by - * session id rather than request because work started by one outlives it — a - * render queued under the user who asked for it, which the renderer is keyed by. - */ +/** Takes a session id, since work a request starts can outlive it. */ async function getUserIdFromSessionId( kv: KVNamespace, sessionId: string @@ -98,9 +85,7 @@ export async function isAuthenticated(c: AppContext): Promise<boolean> { try { const onshapeApi = await c.var.getOnshapeApi(); const sessionInfo = await getSessionInfo(onshapeApi); - // Onshape reports no company for a session outside an enterprise; - // PERSONAL_COMPANY_ID is the id it uses for those, and what we store - // for them. + // Onshape reports no company outside an enterprise. const tokenCompanyId = sessionInfo.company?.id ?? PERSONAL_COMPANY_ID; return getSessionCompanyId(c) === tokenCompanyId; } catch { @@ -135,10 +120,7 @@ async function hasOnshapeSession(c: AppContext): Promise<boolean> { } } -/** - * Whether the caller has a valid Onshape session, memoized on the request. - * `FORCE_SIGNED_IN` stands in for the session it cannot have in development. - */ +/** Memoized on the request. */ export async function isSignedIn(c: AppContext): Promise<boolean> { const cached = c.get("signedIn"); if (cached !== undefined) return cached; @@ -148,11 +130,7 @@ export async function isSignedIn(c: AppContext): Promise<boolean> { return signedIn; } -/** - * The caller's access to `libraryId`: the owner's anywhere, and otherwise what - * the library's admin team says, as last synced from Onshape. A lookup rather - * than a question for Onshape, so it needs no caching. - */ +/** The owner's anywhere; otherwise the library's admin team as last synced. */ async function getLibraryAccessLevel( c: AppContext, libraryId: LibraryId @@ -178,10 +156,7 @@ async function getLibraryAccessLevel( return member.isTeamAdmin ? AccessLevel.ADMIN : AccessLevel.EDITOR; } -/** - * The real answers. getUserId only runs behind requireSignInMiddleware; - * getAccessLevel falls back to USER for anyone without a real Onshape session. - */ +/** getAccessLevel falls back to USER without a real session. */ export const productionAuth: AuthResolver = (c) => ({ getOnshapeApi: () => getOnshapeApi(c), getUserId: () => { @@ -194,8 +169,7 @@ export const productionAuth: AuthResolver = (c) => ({ getAccessLevel: async (libraryId) => { const override = getAccessLevelOverride(c); if (override) return override; - // Needs a real Onshape session to know who is asking, so only for a - // genuinely signed-in caller (not FORCE_SIGNED_IN). + // FORCE_SIGNED_IN has no real session to identify the caller. if (!isForceSignedIn(c) && (await isSignedIn(c))) { return getLibraryAccessLevel(c, libraryId); } diff --git a/src/backend/features/auth/request-auth.worker.test.ts b/src/backend/features/auth/request-auth.worker.test.ts index 59fb7a798..c54566572 100644 --- a/src/backend/features/auth/request-auth.worker.test.ts +++ b/src/backend/features/auth/request-auth.worker.test.ts @@ -39,8 +39,6 @@ describe("the dev access-level override", () => { ); }); - // It is the one thing standing between a stray env var and admin, so it - // must not survive a production build. it("is ignored in production", async () => { processEnv.NODE_ENV = "production"; expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( diff --git a/src/backend/features/auth/routes.ts b/src/backend/features/auth/routes.ts index ad8b9f413..a007d2033 100644 --- a/src/backend/features/auth/routes.ts +++ b/src/backend/features/auth/routes.ts @@ -39,24 +39,10 @@ function isOnshapeUrl(url: URL): boolean { } /** - * Where a finished sign-in may land the caller. - * - * `redirectUrl` is ours: `/init` builds it from the launch it was called with, - * so it is the only target that returns the caller to the element they opened - * the panel on. It wins wherever there is one. - * - * `redirectOnshapeUri` is Onshape's, set when Onshape sends a caller here - * itself, and the callback hands whatever was stored to `c.redirect` unread. So - * it is only taken as an absolute url on Onshape. Onshape does not document - * whether it can be a bare path, and one would be the more damaging case: it - * resolves against this origin instead, landing the caller on a url the app has - * no route for, with none of the launch parameters the panel needs. - * - * Anything else falls back to the entry rather than refusing the sign-in. What - * Onshape actually sends here is not something we can see from the outside, so - * a value this does not recognize is as likely to be our own reading being too - * narrow as it is to be hostile, and opening the app without the caller's - * element beats leaving them unable to sign in at all. + * `redirectUrl` is ours, built by `/init`, and wins. `redirectOnshapeUri` is + * Onshape's and is only taken as an absolute Onshape url, since the callback + * redirects to it unread. Anything else falls back to the entry: Onshape + * doesn't document what it sends, and opening the app beats failing sign-in. */ function getSignInRedirect(query: Record<string, string>): string | undefined { const { redirectUrl, redirectOnshapeUri } = query; @@ -87,17 +73,13 @@ authRoutes.get("/sign-in", async (c) => { ); } - // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the - // user can pick their account on Onshape. + // Absent standalone, so the user can pick their account on Onshape. const companyId = query.sessionCompanyId; const authorizationUrl = await doSignIn(c, redirectUrl, companyId); return c.redirect(authorizationUrl); }); -/** - * Standalone only: inside Onshape the panel's session is Onshape's to end. - * Where the caller lands is theirs to say, as long as it is this app. - */ +/** Standalone only: inside Onshape, the session is Onshape's to end. */ authRoutes.get("/sign-out", async (c) => { await endSession(c); return c.redirect(getLocalRedirect(c.req.query("redirectUrl"))); diff --git a/src/backend/features/auth/routes.worker.test.ts b/src/backend/features/auth/routes.worker.test.ts index 89ceaeed7..b216614b8 100644 --- a/src/backend/features/auth/routes.worker.test.ts +++ b/src/backend/features/auth/routes.worker.test.ts @@ -59,7 +59,6 @@ async function seedSession(sessionId: string) { expiresAt: Date.now() + 10000 }) ); - await env.KV.put(`access-level:${sessionId}`, AccessLevel.ADMIN); } describe("GET /auth/sign-out", () => { @@ -77,7 +76,7 @@ describe("GET /auth/sign-out", () => { ); } - it("drops the session and everything keyed to it", async () => { + it("drops the session", async () => { await seedSession("session-1"); const res = await signOut("/app/library/frc-design-lib", "session-1"); @@ -85,7 +84,6 @@ describe("GET /auth/sign-out", () => { expect(res.status).toBe(302); expect(res.headers.get("Location")).toBe("/app/library/frc-design-lib"); expect(await env.KV.get("tokens:session-1")).toBeNull(); - expect(await env.KV.get("access-level:session-1")).toBeNull(); expect(res.headers.get("Set-Cookie")).toContain(`${SESSION_COOKIE}=;`); }); @@ -125,9 +123,7 @@ describe("GET /auth/sign-in", () => { return new URL(res.headers.get("Location")!); } - // Named by neither half of the flow, so Onshape returns the caller to - // whichever redirect url its OAuth app registers — which is why a host - // wants an app of its own. + // Onshape uses the OAuth app's registered redirect url. it("names no callback, leaving it to the OAuth app", async () => { const url = await authorizationUrl(""); expect(url.searchParams.get("redirect_uri")).toBeNull(); @@ -138,8 +134,7 @@ describe("GET /auth/sign-in", () => { expect(url.searchParams.get("company_id")).toBe("company-1"); }); - // Onshape rejects "cad" as a company, which is what a non-enterprise user - // arrives with. + // Onshape rejects "cad", which a non-enterprise user arrives with. it("names no company for a personal account", async () => { const url = await authorizationUrl("sessionCompanyId=cad"); expect(url.searchParams.has("company_id")).toBe(false); @@ -150,9 +145,7 @@ describe("GET /auth/sign-in", () => { expect(url.searchParams.has("company_id")).toBe(false); }); - // The gate sends a caller here whenever Onshape will not take their - // session, so a sign-in they never finish must not sign them out of the one - // they arrived with. + // An unfinished sign-in mustn't end the session the caller had. it("leaves the session the caller arrived with alone", async () => { await seedSession("session-1"); @@ -166,10 +159,6 @@ describe("GET /auth/sign-in", () => { const LOGIN_COOKIE = "frc-design-app-login"; -/** - * Where the callback would send the caller: what the sign-in stored, which - * `doCallback` redirects to unread. - */ describe("GET /auth/sign-in redirect target", () => { async function storedRedirect(query: string): Promise<string | undefined> { const res = await createTestApp().request( @@ -211,9 +200,7 @@ describe("GET /auth/sign-in redirect target", () => { ).toBe("/init?documentId=doc-1"); }); - // The reported bug: resolved against this origin it is a url with no route, - // and the caller lands on the app's not-found page with no way back into - // the document they came from. + // Resolved against this app it has no route, stranding the caller. it("refuses a bare path, which would resolve against this app", async () => { expect( await storedRedirect( @@ -236,8 +223,7 @@ describe("GET /auth/sign-in redirect target", () => { ).toBe("/init"); }); - // Refusing outright would leave a caller unable to sign in at all if this - // reading of what Onshape sends turns out to be too narrow. + // Opening the app beats blocking sign-in if this reading is too narrow. it("opens the app rather than refusing a value it cannot place", async () => { expect(await storedRedirect("redirectOnshapeUri=not-a-url")).toBe( "/init" diff --git a/src/backend/features/auth/session.ts b/src/backend/features/auth/session.ts index fc43c73b2..0035adc2b 100644 --- a/src/backend/features/auth/session.ts +++ b/src/backend/features/auth/session.ts @@ -29,11 +29,7 @@ export function getSessionId(c: AppContext): string { return sessionId; } -/** - * Onshape's company id for a session outside an enterprise. It is what - * `/init` carries for a plain cad.onshape.com user, and it is not a company - * OAuth will accept. - */ +/** What `/init` carries outside an enterprise. OAuth won't accept it as a company. */ export const PERSONAL_COMPANY_ID = "cad"; export function getSessionCompanyId(c: AppContext) { @@ -57,42 +53,15 @@ function sessionKey(sessionId: string): string { return `tokens:${sessionId}`; } -const ACCESS_LEVEL_PREFIX = "access-level:"; - -/** Keyed by session, so it is dropped along with one. */ -export function accessLevelKey(sessionId: string): string { - return ACCESS_LEVEL_PREFIX + sessionId; -} - -/** - * Forgets every session's cached access level, so each is asked of Onshape - * again on its next request: for when the admin team changes under them. - */ -export async function clearAccessLevels(kv: KVNamespace): Promise<void> { - let cursor: string | undefined; - do { - const page = await kv.list({ prefix: ACCESS_LEVEL_PREFIX, cursor }); - await Promise.all(page.keys.map((key) => kv.delete(key.name))); - cursor = page.list_complete ? undefined : page.cursor; - } while (cursor); -} - function loginKey(loginId: string): string { return `login-session:${loginId}`; } -/** Drops what a session id keys; the cookie is the caller's to clear. */ +/** The cookie is the caller's to clear. */ async function dropSession(kv: KVNamespace, sessionId: string): Promise<void> { - await Promise.all([ - kv.delete(sessionKey(sessionId)), - kv.delete(accessLevelKey(sessionId)) - ]); + await kv.delete(sessionKey(sessionId)); } -/** - * Signs the caller out: the tokens and what was resolved from them go, and the - * cookie with them, so the next request is simply a stranger's. - */ export async function endSession(c: AppContext): Promise<void> { const sessionId = getCookie(c, SESSION_COOKIE); if (sessionId) { @@ -102,11 +71,7 @@ export async function endSession(c: AppContext): Promise<void> { deleteCookie(c, SESSION_COOKIE, COOKIE_OPTIONS); } -/** - * Puts the caller in a newly signed-in session, and drops the one they came - * with. The id is minted here rather than at sign-in, so it is only ever - * replaced by a sign-in that finished, and never carries over one that did not. - */ +/** Minted here, so a session is only replaced by a sign-in that finished. */ export async function beginSession( c: AppContext, tokens: AuthTokens @@ -170,10 +135,8 @@ export async function takeLoginSession( } /** - * Starts an OAuth round trip. It rides its own cookie: `/init` sends a caller - * here whenever Onshape will not take their session, and one who never comes - * back through the callback — the sign-in failed, or they closed the panel — - * keeps the session they arrived with. + * On its own cookie, so a caller who never returns through the callback keeps + * the session they had. */ export async function startLoginSession( c: AppContext, diff --git a/src/backend/features/build-checker/checks.ts b/src/backend/features/build-checker/checks.ts index 8d615a920..7a189e13e 100644 --- a/src/backend/features/build-checker/checks.ts +++ b/src/backend/features/build-checker/checks.ts @@ -11,10 +11,7 @@ interface GroupCheckInput { hasFailedInsertables: boolean; } -/** - * Computes build-time issues for a group. Pure: takes already-resolved signals - * from the load-document workflow rather than fetching anything itself. - */ +/** Pure: the load resolves the signals. */ export function checkGroup(input: GroupCheckInput): BuildIssue[] { let issues: BuildIssue[] = []; @@ -43,10 +40,7 @@ interface InsertableCheckInput { thumbnailUrls: ThumbnailUrls | null; } -/** - * Computes build-time issues for an insertable. Pure: takes already-resolved - * signals from the load-document workflow rather than fetching anything itself. - */ +/** Pure: the load resolves the signals. */ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { let issues: BuildIssue[] = []; diff --git a/src/backend/features/build-checker/issues.test.ts b/src/backend/features/build-checker/issues.test.ts index 1ef6190ec..f3887c4af 100644 --- a/src/backend/features/build-checker/issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -58,7 +58,6 @@ describe("getIssueDescription", () => { }); describe("knownBuildIssues", () => { - /** A type an older deploy stored, cast because this build no longer has it. */ const retired = { type: "thumbnail-pending" } as unknown as BuildIssue; it("drops a type this build has no check for", () => { @@ -79,8 +78,6 @@ describe("knownBuildIssues", () => { }); it("keeps every type it knows", () => { - // Only the type is read, so the ones carrying a configuration stand up - // bare here rather than being built twice. const issues = Object.values(BuildIssueType).map( (type) => ({ type }) as BuildIssue ); @@ -119,8 +116,7 @@ describe("addBuildIssue", () => { expect(result).toEqual(existing); }); - // Callers hold onto the array they passed in, so it must never be the one - // that comes back, even when there was nothing to add. + // Callers keep the array they passed in. it("returns a new array even when nothing is added", () => { const existing: BuildIssue[] = [{ type: BuildIssueType.NO_VENDORS }]; expect( diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index de1b58048..6715e5bf0 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -1,7 +1,4 @@ -/** - * Data-quality issues for groups and insertables. Most are stored at load time; - * a few are computed live where they depend on per-user state. - */ +/** Mostly stored at load time; a few are computed live from per-user state. */ import { AUTO_INDEX_THRESHOLD, MAX_PART_NUMBER_CONFIGURATIONS @@ -33,17 +30,11 @@ export enum BuildIssueType { LOAD_FAILED = "load-failed" } -/** - * Base shape for a build issue, discriminated on type. - */ interface BuildIssueOf<T extends BuildIssueType> { type: T; } -/** - * An issue particular configurations raise, which the element's own defaults do - * not — a problem with those configurations rather than with the part itself. - */ +/** Raised by some configurations but not the defaults. */ interface ConfigurationBuildIssueOf< T extends ConfigurationIssueType > extends BuildIssueOf<T> { @@ -53,7 +44,6 @@ interface ConfigurationBuildIssueOf< configurationCount: number; } -/** The issue types a configuration raises, rather than the element itself. */ type ConfigurationIssueType = | BuildIssueType.CONFIGURATION_MULTIPLE_PARTS | BuildIssueType.UNSTABLE_COMPOSITE; @@ -72,10 +62,7 @@ export type BuildIssue = | BuildIssueOf<BuildIssueType.INSERTABLES_FAILED> | BuildIssueOf<BuildIssueType.LOAD_FAILED>; -/** - * Builds the issue a set of offending configurations raises. The first is the - * one the card links out to; the rest are only counted. - */ +/** The card links to the first; the rest are counted. */ export function toConfigurationIssue( type: ConfigurationIssueType, offenders: { values: PartialSelection }[] @@ -87,11 +74,7 @@ export function toConfigurationIssue( }; } -/** - * The configuration an issue blames, or undefined where the element itself is - * at fault. Also undefined for an issue stored before issues carried values, - * until the next load rewrites it. - */ +/** Undefined when the element itself is at fault. */ export function getIssueConfiguration( issue: BuildIssue ): PartialSelection | undefined { @@ -100,11 +83,7 @@ export function getIssueConfiguration( const BUILD_ISSUE_TYPES = new Set<string>(Object.values(BuildIssueType)); -/** - * Drops issues this deploy has no check for. A stored array was written by - * whichever deploy last loaded the row, so it can name a type since removed from - * `BuildIssueType`, which has no severity or description to render. - */ +/** Drops types a later deploy removed. */ export function knownBuildIssues(issues: BuildIssue[]): BuildIssue[] { return issues.filter((issue) => BUILD_ISSUE_TYPES.has(issue.type)); } @@ -164,10 +143,7 @@ export function getIssueSeverity(issue: BuildIssue): BuildIssueSeverity { } } -/** - * Adds each of `newIssues` to `issues`, skipping any whose type is already - * present, and returning a new array only when something was added. - */ +/** Skips types already present; returns the same array if nothing was added. */ export function addBuildIssue( issues: BuildIssue[], ...newIssues: BuildIssue[] @@ -189,9 +165,6 @@ export function hasBuildIssue( return issues.some((issue) => types.includes(issue.type)); } -/** - * Removes any issue whose type is one of `types`. - */ export function clearBuildIssue( issues: BuildIssue[], ...types: BuildIssueType[] @@ -206,9 +179,6 @@ const SEVERITY_ORDER: BuildIssueSeverity[] = [ BuildIssueSeverity.ERROR ]; -/** - * Returns the worst severity present in `issues`, or undefined when there are none. - */ export function getMaxSeverity( issues: BuildIssue[] ): BuildIssueSeverity | undefined { diff --git a/src/backend/features/build-checker/routes.ts b/src/backend/features/build-checker/routes.ts index 29b15d52b..28a1b1362 100644 --- a/src/backend/features/build-checker/routes.ts +++ b/src/backend/features/build-checker/routes.ts @@ -18,8 +18,7 @@ export const buildStatusRoutes = getApp(); buildStatusRoutes.get( "/build-status" + libraryRoute(), requireEditorMiddleware, - // The same for every editor, but only for an editor: a shared cache would - // hand it to whoever asked for the url next. + // Private: only editors may see it. cacheMiddleware(CachePolicy.PRIVATE_CACHE), async (c) => { const libraryId = getLibraryParam(c); @@ -61,9 +60,7 @@ buildStatusRoutes.get( .all() ]); - // Joined to the library rather than filtered by the ids just read: D1 - // takes at most 100 bound parameters in a statement, and an `inArray` - // binds one per id, so listing them fails on any real library. + // Joined rather than filtered by id: D1 binds at most 100 parameters. const allConfigurations = await db .select({ insertableId: configurations.insertableId, diff --git a/src/backend/features/build-checker/routes.worker.test.ts b/src/backend/features/build-checker/routes.worker.test.ts index bdf2f715b..986b66bd3 100644 --- a/src/backend/features/build-checker/routes.worker.test.ts +++ b/src/backend/features/build-checker/routes.worker.test.ts @@ -25,8 +25,7 @@ describe("GET /build-status", () => { beforeEach(() => resetDb(db)); afterEach(() => vi.restoreAllMocks()); - // The date the card shows is the version's, not the sync's: a library - // reloaded today off a year-old version is a year-old version. + // The version's date, not the sync's. it("returns each group's and insertable's version date", async () => { await seedPartStudio(db); await db @@ -91,9 +90,7 @@ describe("GET /build-status", () => { expect(body.groups[TEST_GROUP_ID].versionCreatedAt).toBeUndefined(); }); - // D1 takes at most 100 bound parameters in a statement and an `inArray` - // binds one per value, so listing every insertable id failed 500 here on - // any library past that — every real one. + // D1 binds at most 100 parameters, and `inArray` binds one per value. it("serves a library with more insertables than a statement can bind", async () => { const count = 120; await seedGroup(db); @@ -122,8 +119,7 @@ describe("GET /build-status", () => { ); }); - // A removed check has no severity or description, so left in it renders as a - // blank callout and steals the row's status color. + // A removed check would render as a blank callout. it("drops a stored issue whose type this build no longer has", async () => { await seedPartStudio(db); // Written as raw JSON, the way the deploy that still had the check did. diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index 6b489e304..0c30e0dde 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -215,8 +215,6 @@ describe("countCombinations", () => { describe("isIndexingEnabled", () => { it.each([ - // Under the threshold everything indexes, custom included: a part with - // no part number is a normal record, not a reason to skip it. { band: IndexingBand.AUTOMATIC, force: false, on: true }, { band: IndexingBand.AUTOMATIC, force: true, on: true }, // Past the threshold it waits to be enabled. @@ -256,8 +254,7 @@ describe("isIndexedParameter", () => { expect(isIndexedParameter(color)).toBe(false); }); - // The card reports indexing off this helper, so it has to describe exactly - // what enumeration varies. + // The admin card reports indexing from this. it("matches the keys enumeration actually varies", () => { const parameters = [ enumParam("varied", ["x", "y"]), diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index 3b1bd8f96..3cc3c139c 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -1,7 +1,4 @@ -/** - * Enumerates an insertable's configuration combinations. Only indexed enum and - * boolean parameters vary; the rest ride their Onshape defaults. - */ +/** Only indexed enum and boolean parameters vary; the rest keep their defaults. */ import { type PartialSelection, BooleanParameter, @@ -12,16 +9,10 @@ import { import { evaluateCondition, getVisibleOptions } from "./utils"; import { ElementType } from "../../lib/onshape/element-type"; -/** - * The most combinations we enumerate for one insertable; beyond it nothing is - * indexed, which is what bounds load time and Onshape usage. - */ +/** Past this nothing is indexed, which bounds load time and Onshape usage. */ export const MAX_PART_NUMBER_CONFIGURATIONS = 512; -/** - * At or above this, indexing waits for an admin, who can trim the count back by - * excluding parameters; see the `MANUAL_INDEXING_REQUIRED` build issue. - */ +/** At or above this, an admin decides whether to index; see `MANUAL_INDEXING_REQUIRED`. */ export const AUTO_INDEX_THRESHOLD = 128; /** Where a configuration count sits relative to the two indexing limits. */ @@ -34,10 +25,7 @@ export enum IndexingBand { EXCEEDED = "exceeded" } -/** - * Shared with the admin card so the two cannot disagree. The count is the only - * gate: past the cap nothing enumerates, past the threshold an admin decides. - */ +/** Shared with the admin card so the two can't disagree. */ export function isIndexingEnabled( band: IndexingBand, indexConfigurations: boolean @@ -53,11 +41,7 @@ export function isIndexingEnabled( } export interface ConfigurationCount { - /** - * The number of combinations, `0` when there is nothing to vary, or - * undefined past the cap — enumeration stops there, so the true total is - * unknown. - */ + /** Undefined past the cap, where enumeration stops. */ count?: number; band: IndexingBand; /** The combinations counted, so the load path need not enumerate again. */ @@ -76,8 +60,7 @@ export function countConfigurations( if (capped) { return { band: IndexingBand.EXCEEDED, configurations: [] }; } - // The lone default that nothing-to-vary enumerates to is not a configuration - // of its own: a non-configurable insertable has none. + // The lone default isn't a configuration of its own. const count = configurations.some( (selection) => Object.keys(selection).length > 0 ) @@ -93,10 +76,7 @@ export function countConfigurations( }; } -/** - * The exclusions that apply. An assembly takes none: Onshape does not let one - * exclude parameters from its properties either, so there is no call to make. - */ +/** An assembly takes none: Onshape can't exclude parameters from one either. */ export function effectiveExclusions( elementType: ElementType, excludedParameterIds: readonly string[] @@ -105,10 +85,8 @@ export function effectiveExclusions( } /** - * Whether indexing varies this parameter, and so multiplies the count. Never - * one with a role: those change how a part is drawn or derived, not which part - * it is. Shared with the admin card so it cannot drift from - * {@link enumerateConfigurations}. + * Never one with a role, which changes how a part is drawn, not which part it + * is. Shared with the admin card. */ export function isIndexedParameter( parameter: ConfigurationParameter, @@ -122,10 +100,7 @@ export function isIndexedParameter( ); } -/** - * What enumeration varies this parameter over, given what is fixed so far. None - * when visibility leaves it no option, which leaves it for Onshape to default. - */ +/** Empty when visibility leaves no option, so Onshape defaults it. */ export function parameterValues( parameter: EnumParameter | BooleanParameter, selection: PartialSelection, @@ -151,7 +126,7 @@ export function countCombinations( excludedParameterIds: readonly string[] = [], cap: number = MAX_COUNTED_CONFIGURATIONS ): number | undefined { - // Depth-first: only the count is wanted, so one path is held rather than all. + // Depth-first, holding one path, since only the count is wanted. const indexed = parameters.filter((parameter) => isIndexedParameter(parameter, excludedParameterIds) ); @@ -186,16 +161,15 @@ export function countCombinations( } interface EnumerateResult { - /** What each combination varies — enums and booleans — which is why these - * are partial; the only caller runs `toSelection` over them. */ + /** Only the enums and booleans each combination varies. */ configurations: PartialSelection[]; /** True when enumeration was stopped for exceeding the cap. */ capped: boolean; } /** - * The cartesian product of enum and boolean values, minus what visibility hides. - * Declaration order is load-bearing: search dedupes first-wins. + * The product of enum and boolean values, minus what visibility hides. Order + * matters: search dedupes first-wins. */ export function enumerateConfigurations( parameters: ConfigurationParameter[], @@ -212,8 +186,7 @@ export function enumerateConfigurations( const next: PartialSelection[] = []; for (const selection of configurations) { const values = parameterValues(parameter, selection, parameters); - // Nothing to vary here, so the parameter is left unset; - // `toSelection` fills it from the default Onshape would apply. + // Left unset, so `toSelection` fills in the default. if (values.length === 0) { next.push(selection); continue; diff --git a/src/backend/features/configurations/contract.ts b/src/backend/features/configurations/contract.ts index ad5e71959..b6550c5f5 100644 --- a/src/backend/features/configurations/contract.ts +++ b/src/backend/features/configurations/contract.ts @@ -70,16 +70,11 @@ interface AlwaysShownVisibilityCondition { export interface ConfigurationResult { parameters: ConfigurationParameter[]; - /** Every record probed, so the insert menu can show the part number and - * name of the selected configuration. Empty when not indexed. */ + /** Empty when not indexed. */ records: SearchRecord[]; } -/** - * A {@link ConfigurationRecord} as a client reads it: what the part is called, - * where to buy it, and the configuration that produces it. MiniSearch-free, so - * the index and the `/configuration` route can share it. - */ +/** MiniSearch-free, so the index and `/configuration` share it. */ export interface SearchRecord { partNumber?: string; name?: string; @@ -97,16 +92,9 @@ export type ConfigurationParameter = | BooleanParameter | StringParameter; -/** - * Parameters that are about how a part is derived or drawn rather than which - * part it is, identified as a document is loaded; see `roles.ts`. - */ +/** Parameters about how a part is derived or drawn, not which part it is; see `roles.ts`. */ export enum ParameterRole { - /** - * A text parameter a document adds so one part can be derived into a part - * studio more than once: Onshape refuses a second derive of the same - * configuration, and a unique value here makes each one different. - */ + /** Onshape refuses a second derive of the same configuration, so this gets a unique value. */ DERIVATION_VARIABLE = "derivation-variable", COLOR = "color", /** One of a color's R, G and B, when a part spells a color out as three. */ @@ -150,37 +138,19 @@ export interface QuantityParameter extends ConfigurationParameterBase { unit: Unit; // Always UNITLESS for QuantityType.INTEGER and QuantityType.REAL } -/** - * What someone picked, keyed by parameter id: every declared parameter, each - * value as it was entered — a quantity is the expression typed, "(2 + 3) in", - * never the number it evaluates to. `toSelection` is what makes one. - */ +/** Every declared parameter, as entered; see AGENTS.md. */ export type Selection = Record<string, string>; -/** - * A selection still being built: enumeration names only what it varies, and a - * search hit only what it records. `toSelection` is what makes one whole. - */ +/** `toSelection` makes one whole. */ export type PartialSelection = Partial<Selection>; -/** - * A selection's identity, for addressing its thumbnail and nothing else: what - * it overrides, canonically spelled, so two selections rendering the same part - * share one render. Never stored in place of the selection it came from. - * {@link DEFAULT_CONFIGURATION_KEY} — empty — overrides nothing. - */ +/** Names a selection's thumbnail and nothing else; see AGENTS.md. */ export type ConfigurationKey = string; -/** - * The key of a selection that overrides nothing: the element's own defaults. Here - * rather than in `selection.ts`, which `utils.ts` would have to import back from. - */ +// Here rather than in `selection.ts` to avoid an import cycle with `utils.ts`. export const DEFAULT_CONFIGURATION_KEY: ConfigurationKey = ""; -/** - * The part one probe resolved to: the element itself from its own defaults, a - * {@link ConfigurationRecord} from any other selection. - */ +/** The part one probe resolved to. */ export interface PartMetadata { partNumber?: string; name?: string; @@ -197,26 +167,16 @@ export interface PartMetadata { /** What one probe came back with, and the enumerated values it probed. */ export interface ConfigurationRecord extends PartMetadata { - /** - * The enum and boolean values enumeration chose; every other parameter was - * at its default. Empty for the element's own defaults. - */ + /** The enum and boolean values enumeration chose; empty for the defaults. */ values: PartialSelection; } -/** - * An insertable's configuration: the parameters it exposes and a record for each - * configuration we probed. Mirrors the `configurations` row. - */ +/** Mirrors the `configurations` row. */ export interface Configuration { parameters: ConfigurationParameter[]; records: ConfigurationRecord[]; } -/** - * The current document's units. Every field is optional: an absent one leaves - * the quantity on its own default unit. - */ /** A document's units, which quantities are shown in. */ export interface UnitInfo { angleUnit: Unit; diff --git a/src/backend/features/configurations/enums.ts b/src/backend/features/configurations/enums.ts index 988a0f320..04077295a 100644 --- a/src/backend/features/configurations/enums.ts +++ b/src/backend/features/configurations/enums.ts @@ -1,6 +1,4 @@ -/** - * Value enums Onshape emits unchanged. - */ +/** As Onshape sends them. */ export enum QuantityType { LENGTH = "LENGTH", diff --git a/src/backend/features/configurations/input-parser.test.ts b/src/backend/features/configurations/input-parser.test.ts index 0a93c89e8..2092b1b4e 100644 --- a/src/backend/features/configurations/input-parser.test.ts +++ b/src/backend/features/configurations/input-parser.test.ts @@ -73,8 +73,7 @@ describe("evaluateExpression", () => { expect(evaluateExpression("2 mm", DEGREES).hasError).toBe(true); }); - // `expression` is what the input redisplays and the menu stores, so it has - // to be something this same parser still reads. + // The stored expression has to re-parse. it.each([ ["(1 + 2) * 3 mm"], ["(2 + 3) mm"], diff --git a/src/backend/features/configurations/input-parser.ts b/src/backend/features/configurations/input-parser.ts index 953ac6678..71cdd6971 100644 --- a/src/backend/features/configurations/input-parser.ts +++ b/src/backend/features/configurations/input-parser.ts @@ -47,10 +47,7 @@ function canonicalPrecision(type: UnitType): number { return Math.round(-Math.log10(TOLERANCE[type])); } -/** - * The one spelling of a value: its base unit, to the decimals its tolerance - * distinguishes. Two values read as equal spell the same, which is why it keys. - */ +/** Base unit, to the precision its tolerance distinguishes: equal values spell the same. */ export function formatBaseValue(value: ValueWithUnits): string { return formatValueWithUnits( value, @@ -59,10 +56,7 @@ export function formatBaseValue(value: ValueWithUnits): string { ); } -/** - * The same value in another unit, still to full precision — every unit here is - * larger than its base, so the decimals a base spelling keeps are never fewer. - */ +/** Full precision, since every unit here is larger than its base. */ export function formatValueInUnit(value: ValueWithUnits, unit: Unit): string { return formatValueWithUnits(value, unit, canonicalPrecision(value.type)); } @@ -129,10 +123,7 @@ interface ValueWithUnits { interface ValueLiteral { value: number; - /** - * The raw string value. - * Used to maintain decimal accuracy. - */ + /** Kept as a string to preserve decimal accuracy. */ rawValue: string; type: UnitType; unit: Unit; @@ -277,8 +268,7 @@ function classifyUnit(identifier: string): Unit { } /* - * The grammar below, which the recursive-descent parser implements but does not - * state. Precedence tightest first: unary sign, `*` and `/`, unit, `+` and `-`. + * Precedence, tightest first: unary sign, `*` and `/`, unit, `+` and `-`. * * EXP ::= POSTFIX { ("+" | "-") POSTFIX } * POSTFIX ::= TERM [ Identifier ] // the unit applies to the TERM @@ -306,8 +296,7 @@ PRIMARY.setPattern( }; return { kind: "value", value: valueLiteral }; }), - // Kept as a node rather than unwrapped to the inner expression, so - // `stringify` writes the parens back and its output re-parses. + // Kept as a node so `stringify` writes the parens back. apply( kmid(tok(TokenKind.LParen), EXP, tok(TokenKind.RParen)), (expr): Expr => ({ kind: "paren", expr }) @@ -396,8 +385,8 @@ function getOpName(op: Operator): string { } /** - * Matching Onshape, a type is only assumed for the final result — so unitless + - * unit is always invalid, as are units on a unitless quantityType. + * Like Onshape, a type is only assumed for the final result, so unitless + unit + * is invalid, as are units on a unitless quantity type. */ function evaluateExpressionValue( expr: Expr, @@ -491,8 +480,6 @@ function evaluateExpressionValue( ); case "/": - // unit / number -> unit - // number / number -> number if (right.type === "number") { if (tolerantEqualsZero(right)) { throw new ParseError(`Cannot divide by 0`); @@ -598,8 +585,7 @@ function formatExpression( expression = expression + " " + getUnitDisplayStr(displayUnit); } - // "2 deg" in a length parses, but it is no length; comparing it against - // the length bounds below would throw rather than report. + // "2 deg" parses in a length box; the bounds check below would throw on it. const expected = expectedType(quantityType); if (value.type !== expected) { return { @@ -644,53 +630,29 @@ function formatExpression( export interface Result { hasError: false; - /** - * The formatted result. Includes the value rounded to the correct display precision and the display unit. - * @example `12.00 in` - */ + /** Rounded to display precision, in the display unit: `12.00 in`. */ displayExpression: string; - /** - * The formatted expression. Essentially the raw input with clean spacing and possibly the display unit applied. - * @example "(3.5 + 8.5) in" - */ + /** The input with clean spacing, and the display unit if it had none: "(3.5 + 8.5) in". */ expression: string; } interface ErrorResult { hasError: true; - /** - * The original, unformatted expression. - */ expression: string; - /** - * An error message to display to the user. - */ errorMessage: string; } export interface EvaluateOptions { - /** - * The type of the expression. - */ quantityType: QuantityType; - /** - * Number of decimals to round to. - * Should be 0 for real and integer expressions. - */ + /** 0 for real and integer expressions. */ displayPrecision: number; - /** - * Unit to use in the displayExpression. - * Should be unitless for real and integer expressions. - */ + /** Unitless for real and integer expressions. */ displayUnit: Unit; max: ValueWithUnits; min: ValueWithUnits; } -/** - * The value in base units: meters, radians, or unitless. A bare number takes - * `defaultUnit`, as in the input; undefined when it does not parse. - */ +/** In meters, radians or unitless; a bare number takes `defaultUnit`. */ export function evaluateBaseValue( input: string, quantityType: QuantityType, diff --git a/src/backend/features/configurations/instances.test.ts b/src/backend/features/configurations/instances.test.ts index 4feae20e0..48cf511ff 100644 --- a/src/backend/features/configurations/instances.test.ts +++ b/src/backend/features/configurations/instances.test.ts @@ -102,8 +102,7 @@ describe("toParameterInstances", () => { optionConditions: [shownWhen(["s2"], "vendor", "wcp")] }); - // Both series leave a generic part the same list, so the series is not - // what decides it and is left off that path. + // Both series leave generic the same list, so the series is left off. expect(labels([series, gated, size])).toEqual([ "series", "old › vendor", @@ -114,8 +113,7 @@ describe("toParameterInstances", () => { }); it("names a checkbox by its own name and the state it is in", () => { - // A checkbox has no option names to borrow, so "true" on its own would - // say nothing about which checkbox it is. + // "true" alone wouldn't say which checkbox. const hub = boolParam("hub"); const style = enumParam("style", ["plain", "splined"], { optionConditions: [shownWhen(["splined"], "hub", "true")] @@ -132,8 +130,6 @@ describe("toParameterInstances", () => { it("names both choices when a checkbox and a list each narrow the options", () => { const vendor = enumParam("vendor", ["generic", "wcp"]); const hub = boolParam("hub"); - // Two independent conditions, so the four combinations leave four - // different lists and each path has to name both choices. const style = enumParam("style", ["plain", "wcpOnly", "hubbed"], { optionConditions: [ shownWhen(["wcpOnly"], "vendor", "wcp"), @@ -183,8 +179,6 @@ describe("toParameterInstances", () => { }); it("reports a parameter whole when its condition names a stale parameter", () => { - // A RANGE condition on something that is no longer an enum throws; the - // report has to survive it. const stale: ConfigurationParameter = { ...quantityParam("length"), condition: { diff --git a/src/backend/features/configurations/instances.ts b/src/backend/features/configurations/instances.ts index c0e765447..df8eb4a5a 100644 --- a/src/backend/features/configurations/instances.ts +++ b/src/backend/features/configurations/instances.ts @@ -1,7 +1,6 @@ /** - * The ways one parameter can be shown. A list whose options another choice - * filters is not one list but several, and anything reporting on it as one - * merges choices that were never offered together. + * A list whose options another choice filters is really several lists, one per + * way it is shown; reporting on it as one merges choices never offered together. */ import { type ConfigurationParameter, @@ -15,43 +14,30 @@ import { parameterValues } from "./combinations"; import { formatValue } from "./selection"; import { evaluateCondition, getOption, getVisibleOptions } from "./utils"; -/** - * The combinations of controlling choices one parameter is walked over, and the - * instances that may come out of it. Past either the parameter is reported - * whole: an instanced report nobody can read is worse than an aggregated one. - */ +// Past either cap a parameter is reported whole: too many instances is +// unreadable. const MAX_COMBINATIONS = 256; const MAX_INSTANCES = 16; /** One controlling choice on the way to an instance. */ interface InstanceStep { parameterId: string; - /** The choices leading here, e.g. "Generic"; several when they lead to the - * same list, joined as "Generic or WCP". */ + /** e.g. "Generic", or "Generic or WCP" when both lead to the same list. */ label: string; } /** One parameter as it is shown under one set of controlling choices. */ interface ParameterInstance { parameter: ConfigurationParameter; - /** The choices it is shown under, outermost first; empty when nothing - * conditions it. */ + /** Outermost first; empty when nothing conditions it. */ path: InstanceStep[]; - /** What it offers here, in declaration order; empty for anything that is - * not an enum, which declares no options to filter. */ + /** In declaration order; empty for anything but an enum. */ options: EnumOption[]; - /** - * The option the app lands on here because the declared default is not - * offered; see `resolveSelectedOption`, which falls through to the first. - */ + /** Set when the declared default isn't offered; see `resolveSelectedOption`. */ implicitDefaultId?: string; } -/** - * Every parameter, once per way it is shown. Parameter order is kept, and a - * parameter nothing conditions yields exactly one instance with an empty path — - * which is what an un-instanced report already was. - */ +/** One per way each parameter is shown; an unconditioned one gets a single instance with an empty path. */ export function toParameterInstances( parameters: ConfigurationParameter[] ): ParameterInstance[] { @@ -59,9 +45,7 @@ export function toParameterInstances( try { return instancesOf(parameter, parameters); } catch { - // `evaluateCondition` throws on a condition naming a parameter that - // is not an enum any more. One part's conditions going stale must - // not take down a whole library's report, so it reports whole. + // A stale condition shouldn't take down the library's whole report. return [wholeInstance(parameter)]; } }); @@ -81,8 +65,7 @@ function instancesOf( return [wholeInstance(parameter)]; } - // Keyed by what the parameter offers, so two vendors that filter the list - // the same way are one instance rather than two identical ones. + // Keyed by the options, so vendors that filter alike share an instance. const groups = new Map< string, { combinations: PartialSelection[]; options: EnumOption[] } @@ -105,9 +88,8 @@ function instancesOf( } } - // No combination shows it, which means the conditions cannot be satisfied - // the way they were read. The values recorded against it say otherwise, so - // it is reported whole rather than dropped. + // Nothing shows it, yet values were recorded against it, so report it whole + // rather than drop it. if (groups.size === 0 || groups.size > MAX_INSTANCES) { return [wholeInstance(parameter)]; } @@ -160,10 +142,8 @@ function conditionsOf( } /** - * The parameters whose choices decide how `parameter` is shown, in declaration - * order. Transitive: a list filtered by a vendor whose own options a series - * filters is shown once per pair, and enumerating the vendor needs the series - * fixed first. + * Transitive: a list filtered by a vendor whose options a series filters is + * shown once per pair. */ function controllingParameters( parameter: ConfigurationParameter, @@ -185,8 +165,7 @@ function controllingParameters( } } - // Only an enum or a boolean can be walked over: a quantity takes any number - // the user types, which is no set of instances. + // A quantity takes any number, so it can't be enumerated. return parameters.filter( (entry) => found.has(entry.id) && @@ -195,11 +174,7 @@ function controllingParameters( ); } -/** - * Every combination of controlling choices, in declaration order so each is - * enumerated against what is already fixed. Undefined past the cap, where the - * paths would outnumber the options they lead to. - */ +/** In declaration order, so each is enumerated against what is fixed. Undefined past the cap. */ function enumerateControls( controllers: ConfigurationParameter[], parameters: ConfigurationParameter[] @@ -216,8 +191,7 @@ function enumerateControls( const next: PartialSelection[] = []; for (const combination of combinations) { const values = parameterValues(controller, combination, parameters); - // Nothing to vary here — it is hidden under what is already fixed — - // so it stays unset, and the conditions reading it do not hold. + // Hidden under what is fixed, so it stays unset. if (values.length === 0) { next.push(combination); continue; @@ -248,10 +222,7 @@ function valuesOf( return values; } -/** - * The steps naming this instance. A controller whose every value leads here - * says nothing about it, so it is left out. - */ +/** Leaves out a controller whose every value leads here. */ function toPath( group: PartialSelection[], combinations: PartialSelection[], @@ -274,9 +245,8 @@ function toPath( } /** - * What to call one step. An option names itself, so the enum it belongs to is - * left out — "Generic", not "Vendor: Generic". A checkbox has no such name, so - * it is the parameter that is named and the state that qualifies it. + * An option names itself ("Generic", not "Vendor: Generic"); a checkbox names + * its parameter and state. */ function toStepLabel( parameter: ConfigurationParameter, diff --git a/src/backend/features/configurations/part-number.ts b/src/backend/features/configurations/part-number.ts index b86341658..4a113b77d 100644 --- a/src/backend/features/configurations/part-number.ts +++ b/src/backend/features/configurations/part-number.ts @@ -8,10 +8,7 @@ export function isPlaceholderPartNumber(text: string): boolean { return PLACEHOLDER_PART_NUMBER.test(text.trim()); } -/** - * The part number when it identifies the part, and nothing when it repeats the - * name or holds a placeholder. One rule, so an unsearchable number is never shown. - */ +/** Undefined when it repeats the name or is a placeholder. */ export function meaningfulPartNumber( partNumber: string | undefined | null, name?: string | null diff --git a/src/backend/features/configurations/roles.test.ts b/src/backend/features/configurations/roles.test.ts index 74be2cbaa..165443c92 100644 --- a/src/backend/features/configurations/roles.test.ts +++ b/src/backend/features/configurations/roles.test.ts @@ -55,8 +55,6 @@ describe("derivation variables", () => { expect(isDerivationVariable(parameter)).toBe(true); }); - // Only a text one can take the unique value the app fills in, so one of - // another type is an ordinary parameter: indexed and editable as usual. it("gives one of another type no role", () => { const [parameter] = withRoles([named("Derivation Variable")]); expect(parameter.role).toBeUndefined(); diff --git a/src/backend/features/configurations/roles.ts b/src/backend/features/configurations/roles.ts index 378b3f458..2c06d9439 100644 --- a/src/backend/features/configurations/roles.ts +++ b/src/backend/features/configurations/roles.ts @@ -1,8 +1,4 @@ -/** - * Recognizing the parameters that play a role. Onshape records nothing that - * says so, so they are recognized by name, once, as a document is loaded; what - * is found is stored on the parameter as `role`. - */ +/** Onshape doesn't mark roles, so they're recognized by name at load and stored as `role`. */ import { type ConfigurationParameter, ParameterRole, @@ -19,11 +15,7 @@ function normalizedName(parameter: ConfigurationParameter): string { return parameter.name.trim().toLowerCase(); } -/** - * The role a parameter plays, if any. A lone "R" or "B" could mean anything, - * so a channel counts only beside its two siblings. A derivation variable is - * only a text one: the app fills it with a unique value, which is text. - */ +/** A channel counts only beside its two siblings. A derivation variable must be text, since the app fills it with a UUID. */ function identifyRole( parameter: ConfigurationParameter, names: Set<string> diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 64efecbed..8e8c42f63 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -30,8 +30,7 @@ configurationRoutes.get( async (c) => { const insertableId = getInsertableParam(c); const db = getDb(c.env.DB); - // Left join: the element's own part data is the fallback record, and it - // lives on the insertable whether or not it is configurable. + // The element's own part data is the fallback record, on the insertable. const config = await db .select({ partMetadata: insertables.partMetadata, @@ -68,10 +67,7 @@ interface OnshapeUnit { value: Unit; } -/** - * The document's unit for a quantity type. Onshape names one for every type, so a - * missing entry is a response we do not understand, not an absent preference. - */ +/** Onshape names a unit for every type, so a missing one is a response we don't understand. */ function getDefaultUnit( units: OnshapeUnit[], quantityType: QuantityType @@ -96,8 +92,6 @@ configurationRoutes.get( const instancePath = c.req.valid("query"); const rawUnitInfo = await getUnitInfo(onshapeApi, instancePath); - // Onshape answers with strings; this is where the app decides they are - // the quantity types and units it knows. const units = rawUnitInfo.defaultUnits.units as OnshapeUnit[]; const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); diff --git a/src/backend/features/configurations/routes.worker.test.ts b/src/backend/features/configurations/routes.worker.test.ts index 965c0b49d..13d2ec323 100644 --- a/src/backend/features/configurations/routes.worker.test.ts +++ b/src/backend/features/configurations/routes.worker.test.ts @@ -39,8 +39,6 @@ describe("configuration routes", () => { expect(body).toEqual({ parameters: TEST_PARAMETERS, records: [] }); }); - // The element's own part data is the record an unset configuration falls - // back to, and it lives on the insertable, not in a configurations row. it("GET /configuration/insertable/:insertableId serves the element's own part data as a record", async () => { await seedPartStudio(db, { partMetadata: { diff --git a/src/backend/features/configurations/selection.test.ts b/src/backend/features/configurations/selection.test.ts index 70d556a15..ddb847d2f 100644 --- a/src/backend/features/configurations/selection.test.ts +++ b/src/backend/features/configurations/selection.test.ts @@ -197,8 +197,6 @@ describe("findRecord", () => { expect(findRecord(select({ size: "l" }), records)).toBe(large); }); - // A record omits what its enumeration hid, so the selection's value for - // that parameter says nothing either way. it("matches a record that omits a parameter it hid", () => { const b = { values: { size: "l" } }; expect(findRecord({ size: "l", hidden: "x" }, [own, b])).toBe(b); diff --git a/src/backend/features/configurations/selection.ts b/src/backend/features/configurations/selection.ts index b2194bc62..d1b10015f 100644 --- a/src/backend/features/configurations/selection.ts +++ b/src/backend/features/configurations/selection.ts @@ -1,9 +1,6 @@ /** - * The two forms a configuration takes, and the only place either is built. - * - * A selection is what someone picked, spelled as they picked it, and it is what - * Onshape is sent and what gets stored. A key is derived from one only to name - * its thumbnail: it canonicalizes, which loses the expression that was typed. + * Builds the two forms of a configuration: a selection, as entered, and the + * key derived from it to name a thumbnail. See AGENTS.md. */ import { type ConfigurationKey, @@ -27,10 +24,7 @@ import { formatValueWithUnits } from "./input-parser"; -/** - * A quantity's default as Onshape declares it, in the parameter's own unit: - * "1 in". What a quantity's `default` is spelled as. - */ +/** A quantity's default in the parameter's own unit, e.g. "1 in". */ export function quantityDefault( parameter: Pick<QuantityParameter, "defaultValue" | "unit"> ): string { @@ -40,9 +34,8 @@ export function quantityDefault( } /** - * Every declared parameter, and nothing else. What arrived is kept as it was - * entered, except that a checkbox is spelled the one way Onshape spells it and - * a quantity loses surrounding whitespace; what is missing takes its default. + * Every declared parameter, as entered, with missing ones defaulted. Checkboxes + * are normalized to Onshape's spelling and quantities trimmed. */ export function toSelection( values: PartialSelection, @@ -62,10 +55,7 @@ export function toSelection( return selection; } -/** - * What a selection actually applies: Onshape never applies a parameter its - * condition hides, so a hidden one is left off. - */ +/** Drops parameters hidden by a condition, which Onshape never applies. */ export function appliedValues( selection: Selection, parameters: ConfigurationParameter[] @@ -83,10 +73,7 @@ export function appliedValues( return values; } -/** - * One spelling per value: a quantity in base units, so "1in", "1 in" and - * "25.4 mm" agree. An unparseable quantity keeps its own spelling. - */ +/** Quantities in base units, so "1in", "1 in" and "25.4 mm" agree. */ export function canonicalValue( parameter: ConfigurationParameter, value: string @@ -103,9 +90,8 @@ export function canonicalValue( } /** - * The applied values, canonically spelled: what two selections are compared by, - * and what analytics counts, where "5 in" and "(2 + 3) in" are one value. A - * derivation variable is left out, being unique to one insert by design. + * Applied values, canonically spelled, for comparing and counting. Derivation + * variables are left out since each insert's is unique. */ export function canonicalValues( selection: Selection, @@ -130,11 +116,7 @@ function isDefault(parameter: ConfigurationParameter, value: string): boolean { ); } -/** - * What Onshape is told: only what the selection changes from the element's - * defaults, each value as it was entered, so a typed "(2 + 3) in" reaches - * Onshape as that. Empty for the element's defaults. - */ +/** The values that differ from the element's defaults, as entered. */ export function onshapeOverrides( selection: Selection, parameters: ConfigurationParameter[] @@ -151,9 +133,8 @@ export function onshapeOverrides( } /** - * A selection's thumbnail identity: what it overrides, canonically spelled. - * Two selections that render the same part key the same, which a derivation - * variable, unique to each insert, would stop. + * Selections that render the same part get the same key, so derivation + * variables are left out. */ export function toKey( selection: Selection, @@ -171,12 +152,9 @@ export function toKey( } /** - * The selection with each derivation variable given a fresh unique value, so - * deriving it cannot collide with an earlier derive of the same part. Onshape - * refuses a second derive of the same part in the same configuration. - * - * `keepFilled` leaves a value that is already set, which is what lets the panel - * show one value rather than a new one every render. + * Gives each derivation variable a fresh value: Onshape refuses a second derive + * of the same part in the same configuration. `keepFilled` keeps existing ones + * so the panel doesn't show a new value every render. */ export function withDerivationValues( selection: Selection, @@ -196,11 +174,7 @@ export function withDerivationValues( return next; } -/** - * The selection without its derivation variables, for anything kept or shared — - * a favorite, the url. Each insert fills its own, so a kept one would only be - * a stale value to collide with. - */ +/** Strips derivation variables before a selection is stored or shared. */ export function toStoredSelection( selection: PartialSelection, parameters: ConfigurationParameter[] @@ -215,12 +189,9 @@ export function toStoredSelection( } /** - * The shortest configuration that is not empty: the first parameter the - * selection applies, at the value it applies. Onshape fills the rest in from the - * element's own defaults, so it names the same part no overrides do — for a - * caller that must hand Onshape a configuration but cannot hand it an empty one. - * - * Itself empty only when a condition hides every parameter the element has. + * The first applied parameter alone. Onshape fills in the rest from defaults, + * so this names the default part for callers that can't send an empty + * configuration. Empty only when every parameter is hidden. */ export function toShortestConfiguration( selection: Selection, @@ -234,9 +205,8 @@ export function toShortestConfiguration( } /** - * The record a selection produces. Records name only what enumeration varied, - * so several can match — the element's own, naming nothing, always does — and - * the one naming the most wins. + * Records name only what enumeration varied, so several can match; the most + * specific wins. */ export function findRecord<T extends Pick<ConfigurationRecord, "values">>( selection: Selection, @@ -256,17 +226,14 @@ export function findRecord<T extends Pick<ConfigurationRecord, "values">>( } /** - * A value as a person reads it: a quantity evaluated, in the unit its parameter - * declares, and a checkbox as its state. An enum's value is its option id, - * which only its own options can name, so the caller holding them spells that. + * Enums are left as their option id, which the caller spells from the options + * it holds. */ export function formatValue( parameter: ConfigurationParameter, value: string ): string { if (parameter.type === ParameterType.BOOLEAN) { - // Anything else was not written by `toSelection`, so it rides as - // stored rather than being read as a "No". if (value === "true") return "Yes"; if (value === "false") return "No"; return value; diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts index ba2abbb3a..0fcbac859 100644 --- a/src/backend/features/configurations/utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -67,8 +67,7 @@ describe("configuration text", () => { }); describe("encodeQueryConfiguration", () => { - // Escaped once more by whatever puts it in a query, so a space has to - // still be a space here; Onshape's own examples read `dia1=1+m`. + // The query adds a layer; Onshape's examples read `dia1=1+m`. it("leaves a quantity's space for the query layer to escape", () => { expect(encodeQueryConfiguration({ length: "0.0508 m" })).toBe( "length=0.0508 m" @@ -146,8 +145,6 @@ describe("getPartUrl", () => { ).toBeUndefined(); }); - // A part configurable across vendors carries a generic vendor, but each - // configuration's number still says who sells that one. it("reads the vendor out of the part number over a generic tagging", () => { const url = getPartUrl(metadata({ partNumber: "TTB-0016" }), [ Vendor.WCP, @@ -168,8 +165,6 @@ describe("getPartUrl", () => { }); describe("getVisibleOptions", () => { - // Only the last three sizes are restricted, to the heavy style; nothing is - // said about s1 and s2. const style = enumParam("style", ["light", "heavy"]); const size = enumParam("size", ["s1", "s2", "s3", "s4", "s5"], { optionConditions: [ @@ -192,8 +187,7 @@ describe("getVisibleOptions", () => { expect(getVisibleOptions(plain, {}, [plain])).toHaveLength(2); }); - // The panel drops an enum with no options left, so reading the conditions - // as a list of what may be shown took the whole parameter off the screen. + // The panel drops an enum with no options left. it("keeps the options no condition names", () => { const visible = getVisibleOptions(size, { style: "light" }, params); expect(visible.map((option) => option.id)).toEqual(["s1", "s2"]); @@ -245,8 +239,7 @@ describe("getVisibleOptions", () => { }); describe("evaluateCondition", () => { - // The parser drops children it cannot represent, so a logical can arrive - // holding none — and an OR of nothing reads as never. + // The parser drops children it can't represent. it.each([LogicalOp.AND, LogicalOp.OR])( "shows a parameter whose %s condition holds no children", (operation) => { diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index 2e4cd93c1..4b343824c 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -22,10 +22,7 @@ import { import { LogicalOp, QuantityType, Unit } from "./enums"; import { type EvaluateOptions, valueWithUnits } from "./input-parser"; -/** - * Whether a parameter is shown. Takes a partial selection: visibility is what - * enumeration consults while a combination is still being built up. - */ +/** Takes a partial selection, since enumeration checks it mid-combination. */ export function evaluateCondition( condition: VisibilityCondition | undefined, selection: PartialSelection, @@ -36,8 +33,7 @@ export function evaluateCondition( } if (condition.type === VisibilityType.LOGICAL) { - // An OR of no children reads as false, which would hide a parameter - // over a condition the parser merely failed to represent. + // An empty OR is a condition the parser failed to represent; don't hide over it. if (condition.children.length === 0) { return true; } @@ -74,13 +70,9 @@ export function evaluateCondition( return true; } -/** A description holding a link is the link, rather than a description. */ const ABSOLUTE_URL = new RegExp("^https?://", "i"); -/** - * The page for a part, in descending precision: a description that is already a - * url, then the vendor the part number names, then the taggings standing in. - */ +/** A description that is a url, else the vendor's page for the part number, else the tagged vendor's. */ export function getPartUrl( record: PartMetadata, vendors: Vendor[] = [] @@ -88,8 +80,6 @@ export function getPartUrl( if (record.description && ABSOLUTE_URL.test(record.description)) { return record.description; } - // WCP-123 -> WCP, then what the part says it is, then the insertable's - // tagging when it names one vendor and one only. let vendor = parseVendorFromPartNumber(record.partNumber); vendor ??= parseVendor(record.vendor); if (!vendor && vendors.length === 1) { @@ -99,14 +89,9 @@ export function getPartUrl( } /** - * The text form of a configuration: `id=value;id=value`, values percent-encoded - * so a `;` or `=` typed into a string parameter cannot read as the end of the - * assignment. {@link decodeConfiguration} is the other half, and `utils.test.ts` - * pins the round trip. - * - * This is the form a key takes, and the form a request body carries, where - * nothing escapes it a second time. A query parameter is escaped again in - * transport, so it takes {@link encodeQueryConfiguration} instead. + * `id=value;id=value`, percent-encoded so a typed `;` or `=` can't end an + * assignment. For keys and request bodies; a query parameter uses + * {@link encodeQueryConfiguration}. */ export function encodeConfiguration(configuration?: PartialSelection): string { return assignments(configuration) @@ -132,14 +117,9 @@ function escapeForQuery(value: string): string { } /** - * The form Onshape's `configuration` query parameter takes: the same - * assignments, with only the three structural characters escaped. - * - * Putting it in a query escapes it once more, and Onshape's examples show a - * quantity arriving with exactly that one layer — `dia1=1+m`, `theta=2+degree` - * — so a value percent-encoded here reaches them as the literal `0.381%20m`, - * which is no quantity. The structural three still keep a typed `;` from ending - * an assignment, and `decodeConfiguration` reads this form back too. + * Onshape's `configuration` query parameter. Only `;`, `=` and `%` are escaped: + * the query adds its own layer, and a value encoded twice reaches Onshape as + * `0.381%20m`, which isn't a quantity. */ export function encodeQueryConfiguration( configuration?: PartialSelection @@ -192,11 +172,8 @@ function getControlledOptionIds( } /** - * The options an enum currently offers. A condition restricts the options it - * names and says nothing about the rest, so an option no condition names is - * always offered, and one several name is offered while any of them holds. - * Offering only what a passing condition names instead empties a - * partly-conditioned enum, and the panel drops a parameter with no options. + * An option no condition names is always offered; one several name is offered + * while any holds. */ export function getVisibleOptions( enumParameter: EnumParameter, @@ -237,10 +214,7 @@ export function getVisibleOptions( /** Display precision used when the document's units aren't available. */ export const DEFAULT_QUANTITY_PRECISION = 3; -/** - * The evaluation settings for a quantity parameter: its own bounds, plus the - * document's display unit and precision, falling back to the parameter's own. - */ +/** Bounds from the parameter; unit and precision from the document, else the parameter. */ export function getEvaluateOptions( parameter: QuantityParameter, /** The document's; without one, each quantity shows in its own unit. */ diff --git a/src/backend/features/entry/routes.ts b/src/backend/features/entry/routes.ts index f70caa15c..ca3db1825 100644 --- a/src/backend/features/entry/routes.ts +++ b/src/backend/features/entry/routes.ts @@ -1,7 +1,4 @@ -/** - * `/init` is where Onshape lands. It gates on auth, then resumes the caller in - * the tab and theme they last used. - */ +/** Where Onshape lands: gates on auth, then resumes the last tab and theme. */ import { and, eq } from "drizzle-orm"; import { getDb, type Db } from "../../db/client"; import { groups, users } from "../../db/schema"; @@ -23,20 +20,10 @@ import { trackAppOpen, trackInBackground } from "../analytics/tracking"; const SIGN_IN_ATTEMPTED = "signInAttempted"; /** - * Whether to send the caller through Onshape's sign-in before opening the app. - * - * The gate is `isAuthenticated`: a session Onshape takes, scoped to the company - * whose document the panel was opened in. What a sign-in cannot always do is - * produce one. Onshape's authorize endpoint takes a `company_id` to scope a - * token to an enterprise and documents no value standing for a personal - * account, so for a caller holding an enterprise session who opens a plain - * cad.onshape.com document there is nothing to ask for on their behalf — and - * the reported redirect loop is what came of asking anyway. - * - * So a caller with no session at all is always worth signing in, and one whose - * session is merely scoped elsewhere only when there is a company to name. - * `SIGN_IN_ATTEMPTED` backstops both: whatever came back, the app opens on the - * second pass rather than bouncing a third time. + * Onshape's authorize endpoint has no `company_id` for a personal account, so + * a caller with an enterprise session opening a personal document can't get a + * session for it; asking anyway loops. So only sign in when there's no session + * or a company to name, and `SIGN_IN_ATTEMPTED` stops a second bounce. */ async function needsSignIn(c: AppContext): Promise<boolean> { if (await c.var.isAuthenticated()) return false; @@ -68,8 +55,7 @@ interface AppEntry { /** Where the caller left off, as their row records it. */ function getUserEntry(db: Db, userId: string) { - // The join is the check on the stored group: one deleted, or left behind by - // a tab switch, comes back null and lands the caller in the tab itself. + // A deleted or stale group joins to null, landing in the tab itself. return db .select({ tabId: users.tabId, @@ -85,10 +71,7 @@ function getUserEntry(db: Db, userId: string) { .get(); } -/** - * The url the caller resumes at, seeded with what they last used. Returns who - * they are too, so `/init` records the open without a second lookup. - */ +/** Also returns the user id, so `/init` records the open without another lookup. */ async function getAppEntry(c: AppContext): Promise<AppEntry> { const userId = (await isSignedIn(c)) ? await c.var.getUserId() : undefined; const user = userId @@ -104,21 +87,16 @@ async function getAppEntry(c: AppContext): Promise<AppEntry> { } search.set("theme", user?.theme ?? DEFAULT_THEME); - // Checked rather than trusted, as the stored group above is: the frontend - // 404s an id it does not know, and this url is the only thing between a - // stale row and the caller's panel. + // Validated: the frontend 404s an unknown id. const chosenTab = user?.tabId ? toAppTab(user.tabId, DEFAULT_LIBRARY) : undefined; - // A caller with no tab lands in the default library, where the welcome - // asks for one; the seed is how the app tells the two apart. + // Without a tab the welcome asks for one. if (chosenTab) { search.set("tabId", chosenTab); } const tabId = chosenTab ?? DEFAULT_LIBRARY; const path = getTabPath(tabId); - // Only a library has groups to resume in; the join above already returns - // none for a tab that is not one. const groupPath = user?.groupId ? `${path}/groups/${user.groupId}` : path; return { url: `${groupPath}?${search.toString()}`, userId, tabId }; } @@ -131,9 +109,7 @@ entryRoutes.get("/init", cacheMiddleware(), async (c) => { return c.redirect(getSignInUrl(c)); } const { url, userId, tabId } = await getAppEntry(c); - // Reaching here is exactly "the panel was opened", and it is the only entry - // Onshape uses. Best-effort, so the redirect never waits on it. Only a - // library open is logged, the log being library-scoped. + // Only library opens are logged, since the log is per library. if (userId && isLibraryTab(tabId)) { await trackInBackground(c, () => trackAppOpen(c, { libraryId: tabId, userId }) diff --git a/src/backend/features/entry/routes.worker.test.ts b/src/backend/features/entry/routes.worker.test.ts index 1e2c1c29a..739997387 100644 --- a/src/backend/features/entry/routes.worker.test.ts +++ b/src/backend/features/entry/routes.worker.test.ts @@ -60,8 +60,7 @@ describe("GET /init", () => { expect(await db.select().from(events).get()).toBeUndefined(); }); - // The frontend 404s a tab id it does not know, so a row naming one the app - // has dropped would strand the caller on every panel open. + // The frontend 404s an unknown tab id. it("sends a user whose stored tab is unknown to the default", async () => { await seedLibrary(db); await db @@ -123,8 +122,6 @@ describe("GET /init", () => { expect(location.searchParams.get("theme")).toBe(Theme.SYSTEM); }); - // The seed is what tells the app a tab was chosen; without one the caller - // lands in the default library for the welcome to ask over. it("seeds the tab a row names, and none for a user who has not chosen", async () => { const seededTab = async () => { const res = await createTestApp().request( @@ -188,8 +185,6 @@ describe("GET /init", () => { ); }); - // The group is gone, so the caller lands in the library rather than on a - // "group not found" page. it("falls back to the library when the group has been deleted", async () => { await seedResume(TEST_LIBRARY_ID, "deleted-group"); @@ -242,9 +237,7 @@ describe("GET /init", () => { ); }); - // Onshape decides which company a token is scoped to, and there is no - // personal company to ask it for, so a caller carrying an enterprise - // session into a plain document fails the gate every time it is tried. + // There's no personal company to ask Onshape for, so this would loop. it("opens the app rather than signing a caller in twice", async () => { const location = await signInRedirect("/init"); const res = await createTestApp({ @@ -263,8 +256,6 @@ describe("GET /init", () => { expect(entry.searchParams.has("signInAttempted")).toBe(false); }); - // The enterprise the caller needs is a company Onshape's authorize endpoint - // takes, so a session scoped elsewhere is worth trying to replace. it("signs in a caller whose session is scoped to another company", async () => { const res = await createTestApp({ isAuthenticated: false }).request( "/init?sessionCompanyId=company-1", @@ -276,8 +267,6 @@ describe("GET /init", () => { expect(location.pathname).toBe("/auth/sign-in"); }); - // There is no personal company to ask Onshape for, so the round trip comes - // back with the same session it started with. it("opens the app for a session it cannot ask Onshape to rescope", async () => { const res = await createTestApp({ isAuthenticated: false }).request( "/init", diff --git a/src/backend/features/favorites/contract.ts b/src/backend/features/favorites/contract.ts index 4343f3a95..53472c084 100644 --- a/src/backend/features/favorites/contract.ts +++ b/src/backend/features/favorites/contract.ts @@ -5,10 +5,7 @@ import { } from "../configurations/contract"; import { LibraryId } from "../library/library-id"; -/** - * The most favorites one user may keep in one library. Far past what anyone - * curates by hand, and low enough that reordering stays a single batch. - */ +/** Low enough that reordering stays one batch. */ export const MAX_FAVORITES = 250; export interface Favorite { @@ -17,14 +14,9 @@ export interface Favorite { libraryId: LibraryId; /** The selection it opens with; absent for the element's own defaults. */ defaultSelection?: Selection; - /** That selection's key, which names its thumbnail. Derived per response: - * a reload moves the defaults, and a card has no parameters of its own. */ + /** Derived per response, since a reload can move the defaults. */ configurationKey?: ConfigurationKey; - /** - * What that selection is called and numbered. Derived here for the same - * reason as the key, and carried so a favorite row never has to read a part - * number off anything but the configuration it was saved with. - */ + /** Derived like the key, so the part number always matches the saved configuration. */ record?: SearchRecord; } diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index 3eabd7b9d..3ff55ac79 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -67,8 +67,7 @@ async function getFavorites( .orderBy(asc(favorites.sortOrder)) .all(); - // Keyed here rather than stored: a reload can move the defaults a key is - // measured against, and only the selection is the favorite's own. + // Computed, not stored: a reload can move the defaults a key is measured against. const configurationsById = await getConfigurations( db, rows.map((row) => row.insertableId) @@ -79,8 +78,7 @@ async function getFavorites( for (const row of rows) { const { parameters = [], records = [] } = configurationsById.get(row.insertableId) ?? {}; - // Made whole on the way out as well as in: a row written before a - // parameter existed still has to answer as a selection. + // A row written before a parameter existed still has to be whole. const defaultSelection = row.defaultSelection ? toSelection( toStoredSelection(row.defaultSelection, parameters), @@ -95,9 +93,7 @@ async function getFavorites( configurationKey: defaultSelection ? toKey(defaultSelection, parameters) : undefined, - // The record this favorite's own selection produces, so a row can - // never show a part number belonging to another configuration. No - // selection is the element's defaults, which match its own record. + // So a row never shows another configuration's part number. record: findRecord( defaultSelection ?? toSelection({}, parameters), records @@ -109,10 +105,6 @@ async function getFavorites( return { favorites: favoritesOut, favoriteOrder }; } -/** - * What a favorite keeps: the selection whole, but without a derivation - * variable, which each insert fills afresh. - */ function toFavoriteSelection( selection: PartialSelection, parameters: ConfigurationParameter[] @@ -135,23 +127,19 @@ async function getParametersFor( interface InsertableConfiguration { /** What a stored selection is made whole against. */ parameters: ConfigurationParameter[]; - /** What each configuration is called — the element's own among them — - * for the one this favorite names. */ + /** Includes the element's own. */ records: SearchRecord[]; } /** - * Joined from the insertable rather than the configurations row alone: a - * record's vendor url is derived from the insertable's own vendors. An - * insertable with nothing to configure has no configurations row, which the - * left join answers as empty. + * Joined with the insertable, whose vendors give a record its url. A left join, + * since an unconfigurable insertable has no configurations row. */ async function getConfigurations( db: Db, insertableIds: string[] ): Promise<Map<string, InsertableConfiguration>> { - // Chunked: a caller can have more favorites than one statement can bind ids - // for. + // Chunked: there can be more favorites than one statement binds. const reads = await Promise.all( chunkForInArray(insertableIds).map((ids) => db @@ -211,19 +199,14 @@ favoriteRoutes.post( const db = getDb(c.env.DB); - // The favorite's own key requires a user row, which a caller who has - // never changed a setting does not have yet. Named rather than left to - // the dead column's default, which points at a library this caller may - // have no row for. + // The favorite references a user row, which a new caller doesn't have yet. + // The library is named since the dead column's default may not exist. await db .insert(users) .values({ id: userId, libraryId }) .onConflictDoNothing(); - // Counted to see whether there is room for one more, and the highest - // order taken so the new one lands after it. Not the count: deleting - // from the middle leaves a gap, and counting would then reuse an order - // a live favorite still holds. + // The highest order, not the count: deletes leave gaps. const existing = await db .select({ value: count(), @@ -240,8 +223,6 @@ favoriteRoutes.post( const sortOrder = (existing?.highestOrder ?? -1) + 1; if ((existing?.value ?? 0) >= MAX_FAVORITES) { - // Handled rather than internal: the caller can act on this, and - // removing one is the whole of what it takes. throw handledError( `You can keep up to ${MAX_FAVORITES} favorites in a library. Remove one to add another.`, HttpStatus.CONFLICT @@ -294,8 +275,7 @@ favoriteRoutes.post( const userId = await c.var.getUserId(); const db = getDb(c.env.DB); - // Scoped to the owner rather than checked first: a favorite that is not - // theirs matches nothing, which costs no extra read. + // Scoped to the owner, so someone else's favorite matches nothing. const writes = favoriteOrder.map((id, i) => db .update(favorites) diff --git a/src/backend/features/favorites/routes.worker.test.ts b/src/backend/features/favorites/routes.worker.test.ts index f159833f1..dc29dc60b 100644 --- a/src/backend/features/favorites/routes.worker.test.ts +++ b/src/backend/features/favorites/routes.worker.test.ts @@ -51,10 +51,7 @@ interface FavoritesBody { favoriteOrder: string[]; } -/** - * Rows straight in, since the route under test is the one being filled up. One - * insertable apiece: a favorite is unique per user, library and insertable. - */ +/** Inserted directly, since the route under test is what's being filled. */ async function fillFavorites(howMany: number) { const db = getDb(env.DB); await seedGroup(db); @@ -65,8 +62,7 @@ async function fillFavorites(howMany: number) { insertableId: `filler-insertable-${i}`, sortOrder: i })); - // D1 binds at most 100 parameters per query, and an insertable row spends - // seventeen of them, so these go in small chunks rather than one statement. + // D1 binds at most 100 parameters, and an insertable row takes seventeen. for (const chunk of inChunks(rows, 5)) { await db.insert(insertables).values( chunk.map((row) => ({ @@ -138,8 +134,7 @@ describe("favorites routes", () => { expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); - // Derived per response rather than stored, so it cannot go stale when a - // reload changes what the parameters default to. + // Derived per response, so a reload that changes defaults can't stale it. it("derives each favorite's key from the selection it stores", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -159,8 +154,6 @@ describe("favorites routes", () => { ); }); - // The selection is what the favorite opens with, so it keeps a value - // the key drops for matching the parameter's default. it("answers with a whole selection, not only its overrides", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -182,16 +175,14 @@ describe("favorites routes", () => { expect(favorite.configurationKey).toBe(""); }); - // The row's thumbnail is this configuration's, so its part number has - // to be too — resolving it from anything else shows two parts at once. + // The thumbnail is this configuration's, so the part number must be too. it("resolves the record its own selection produces", async () => { await seedPartStudio(db); await seedConfiguration(db); await db .update(configurations) .set({ - // The favorite's own record listed second, so picking the - // first would answer with the default instead. + // Listed second, so taking the first would give the default. records: [ { values: { boolean: "true" }, @@ -227,9 +218,7 @@ describe("favorites routes", () => { expect(favorite.record?.name).toBe("Plain"); }); - // A favorite saved with no selection of its own opens on the element's - // defaults, whose part data lives on the insertable rather than among - // the configurations' records. + // The defaults' part data is on the insertable, not in the records. it("resolves the element's own record for a favorite with no selection", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -262,8 +251,6 @@ describe("favorites routes", () => { expect(favorite.record?.partNumber).toBe("WCP-2222"); }); - // An insertable with nothing to configure has no configurations row at - // all, but still has part data of its own to show. it("resolves the record of an insertable with no configuration", async () => { await seedPartStudio(db); await db @@ -322,8 +309,6 @@ describe("favorites routes", () => { expect(row?.sortOrder).toBe(1); }); - // Counting instead would reuse an order a live favorite still holds, - // and the two would then sort against each other arbitrarily. it("does not reuse an order after one is deleted from the middle", async () => { await fillFavorites(3); await seedPartStudio(db); @@ -357,7 +342,6 @@ describe("favorites routes", () => { ); expect(res.status).toBe(409); - // Handled, so the client shows this rather than its own wording. expect(await res.json<unknown>()).toMatchObject({ kind: ApiErrorKind.HANDLED, message: expect.stringContaining(String(MAX_FAVORITES)) @@ -384,8 +368,7 @@ describe("favorites routes", () => { it("stamps createdAt, leaving rows that predate the column null", async () => { await seedPartStudio(db); await seedAssembly(db); - // Seeded without a timestamp, as every row predating the column - // looks. + // As rows predating the column look. const old = await seedFavorite(db, TEST_PART_STUDIO_ID); const app = createTestApp(); @@ -566,8 +549,6 @@ describe("favorites routes", () => { }); }); - // Stored as a selection, so what is written is what the insertable - // declares — not whatever the request happened to name. it("drops a value for a parameter the insertable does not have", async () => { await seedPartStudio(db); await seedConfiguration(db); diff --git a/src/backend/features/insert-location/contract.ts b/src/backend/features/insert-location/contract.ts index af998592f..1e21fbd63 100644 --- a/src/backend/features/insert-location/contract.ts +++ b/src/backend/features/insert-location/contract.ts @@ -1,15 +1,9 @@ -/** - * The insert location: a marker in the assembly being inserted into, which new - * parts land on instead of the origin. A leaf, so the frontend can name it - * without pulling the Onshape client into the bundle. - */ +/** A marker in an assembly that new parts land on. A leaf, so the frontend can import it. */ import { type ElementPath } from "../../lib/onshape/path"; /** - * The tab the marker is inserted from. A standalone mate connector cannot be - * created in an assembly through the API, so the app inserts a sketch that - * carries one instead — pinned to a version, which is what makes the ids below - * stable. + * The API can't create a standalone mate connector in an assembly, so a sketch + * carrying one is inserted instead, from a pinned version so the ids are stable. */ export const INSERT_LOCATION_SOURCE: ElementPath = { documentId: "6c26fe7a89b71b80707ee3cf", @@ -18,27 +12,13 @@ export const INSERT_LOCATION_SOURCE: ElementPath = { elementId: "8252e798e07255ac1235e7e5" }; -/** - * The sketch inside that tab: what an assembly gets an instance of. Only the - * insert names it — recognizing a marker goes by the tab, so an assembly - * holding one from before the sketch was redrawn still counts. - */ +/** Only inserts use it; recognizing a marker goes by the tab. */ export const INSERT_LOCATION_SKETCH_ID = "FoHmJsKNNEuStrH_0"; -/** - * The mate connector that sketch carries. An id in the source tab rather than - * in any one assembly: two SELECTION messages from different assemblies - * reported it unchanged, with only the occurrence around it differing. - * - * Unused — kept for whatever eventually points the caller at the marker in the - * viewport. The frontend's `messages.ts` says how far that got. - */ +/** Unused, kept for pointing the caller at the marker; see `messages.ts`. */ export const INSERT_LOCATION_MATE_CONNECTOR_ID = "F2t8fekeOt5UXBq_0"; export interface InsertLocationOut { - /** - * The marker's instance id in this assembly — not the sketch's own id, - * which every assembly shares. Absent when the assembly has none. - */ + /** Not the sketch's id, which every assembly shares. */ instanceId?: string; } diff --git a/src/backend/features/insert-location/parse.test.ts b/src/backend/features/insert-location/parse.test.ts index 288dff4bf..9ea021566 100644 --- a/src/backend/features/insert-location/parse.test.ts +++ b/src/backend/features/insert-location/parse.test.ts @@ -79,15 +79,12 @@ describe("findInsertLocation", () => { ).toBeUndefined(); }); - // The marker inserted from a version of the tab we no longer name, so the - // sketch's own id is no longer what it was when the constant was written. + // A marker from an older version of the tab has a different sketch id. it("matches a sketch id the constant does not name", async () => { const assembly = toAssembly([{ ...MARKER, featureId: "redrawn" }]); expect(await findIn(assembly)).toBe("marker"); }); - // Onshape naming the tab only on the partStudioFeatures entry, which is the - // shape the instance list alone cannot be matched against. it("finds a marker whose instance names only its feature", async () => { const assembly = toAssembly( [{ id: "marker", type: "Feature", featureId: "sketch" }], @@ -118,8 +115,6 @@ describe("findInsertLocation", () => { expect(await findIn(assembly)).toBeUndefined(); }); - // An instance naming no feature at all, against a tab whose entry names no - // feature either: nothing lines up, so nothing matches. it("does not pair an instance and an entry by what both leave out", async () => { const assembly = toAssembly([{ id: "part", type: "Part" }], undefined, [ { @@ -148,8 +143,7 @@ describe("getInsertLocationTransform", () => { expect(await transformIn(assembly, "marker")).toEqual(transform); }); - // A deeper path is the same instance inside a subassembly, which is a - // different thing in a different place. + // A nested one is inside a subassembly. it("ignores an occurrence nested under another instance", async () => { const assembly = toAssembly( [MARKER], diff --git a/src/backend/features/insert-location/parse.ts b/src/backend/features/insert-location/parse.ts index d97693e43..40e42eb0a 100644 --- a/src/backend/features/insert-location/parse.ts +++ b/src/backend/features/insert-location/parse.ts @@ -8,10 +8,7 @@ import { type OnshapeAssemblyInstance } from "../../lib/onshape/types"; -/** - * The assembly as the insert location needs it. A sketch is not a solid, so - * without `includeNonSolids` the marker is not in the response at all. - */ +/** Sketches aren't solids, so the marker needs `includeNonSolids`. */ function getAssemblyWithMarkers( onshapeApi: OnshapeApi, assemblyPath: ElementPath @@ -31,17 +28,10 @@ function isFromSourceTab(reference: { } /** - * The marker's instance, matched on the tab it came from rather than on its - * name, which anybody can rename. The version is left out of the match: an - * assembly can hold a marker inserted from an older one. So is the sketch's own - * feature id — the tab holds nothing but that sketch, so anything an assembly - * holds from it is a marker, and an id that has to be right is an id that can - * go stale. - * - * Which fields Onshape fills in on a sketch instance is not something it - * documents, and a marker that inserted fine was not found again, so both - * places the tab can be named are accepted: the instance itself, and the - * `partStudioFeatures` entry its `featureId` points at. + * Matched on the source tab, not the name (renameable), version (an older + * marker still counts) or feature id (could go stale). Onshape doesn't document + * which fields a sketch instance fills in, so both the instance and its + * `partStudioFeatures` entry are checked. */ function findInsertLocationInstance( assembly: OnshapeAssemblyDefinition @@ -62,11 +52,7 @@ function findInsertLocationInstance( ); } -/** - * Where a top-level instance sits, as a transform an insert can be placed by. - * Undefined when the instance is gone, which is what a marker deleted since the - * app opened looks like — the insert then lands at the origin. - */ +/** Undefined when the marker was deleted, so the insert lands at the origin. */ function getInstanceTransform( assembly: OnshapeAssemblyDefinition, instanceId: string @@ -86,10 +72,7 @@ export async function findInsertLocation( return findInsertLocationInstance(assembly)?.id; } -/** - * Where the insert location is now, which only Onshape knows: the caller has - * the marker's instance id, and it moves whenever somebody drags it. - */ +/** Asked of Onshape, since the marker moves whenever someone drags it. */ export async function getInsertLocationTransform( onshapeApi: OnshapeApi, assemblyPath: ElementPath, diff --git a/src/backend/features/insert-location/placement.ts b/src/backend/features/insert-location/placement.ts index 7437b33aa..8cc78e974 100644 --- a/src/backend/features/insert-location/placement.ts +++ b/src/backend/features/insert-location/placement.ts @@ -1,10 +1,7 @@ /** Where a new insert location marker goes. */ import { type OnshapeBoundingBox } from "../../lib/onshape/types"; -/** - * How far past the face the marker sits, in metres. Enough to be grabbable - * rather than flush against the geometry it is clearing. - */ +/** In metres; enough to grab the marker. */ const CLEARANCE = 0.01; export type Point = [number, number, number]; @@ -12,12 +9,9 @@ export type Point = [number, number, number]; const ORIGIN: Point = [0, 0, 0]; /** - * The closest point to the origin that is clear of the assembly's geometry: the - * origin itself when nothing is over it, and otherwise just past whichever of - * the six faces the origin is nearest. A marker buried inside the robot cannot - * be grabbed, and one flung to a corner cannot be found. - * - * No box — an empty assembly, or a call that failed — leaves it at the origin. + * The origin if nothing covers it, else just past the nearest face: buried + * markers can't be grabbed and far-flung ones can't be found. No box leaves it + * at the origin. */ export function toMarkerPoint(box: OnshapeBoundingBox | undefined): Point { if (!box) { @@ -35,8 +29,7 @@ export function toMarkerPoint(box: OnshapeBoundingBox | undefined): Point { return ORIGIN; } - // Inside: leave by the nearest face. `low` is at or below zero here and - // `high` at or above it, so each distance is the face's own magnitude. + // `low` <= 0 <= `high` here, so each distance is the face's magnitude. const exits = axes.flatMap(([low, high], axis) => [ { axis, to: low - CLEARANCE, distance: -low }, { axis, to: high + CLEARANCE, distance: high } diff --git a/src/backend/features/insert-location/routes.ts b/src/backend/features/insert-location/routes.ts index c0a8a587f..3764ab991 100644 --- a/src/backend/features/insert-location/routes.ts +++ b/src/backend/features/insert-location/routes.ts @@ -49,8 +49,7 @@ insertLocationRoutes.post( const onshapeApi = await c.var.getOnshapeApi(); const { targetPath } = c.req.valid("json"); - // A second marker would leave the lookup picking between them, so an - // assembly that already has one keeps it. + // A second marker would make the lookup ambiguous. const existing = await findInsertLocation(onshapeApi, targetPath); if (existing) { return c.json({ @@ -58,8 +57,7 @@ insertLocationRoutes.post( } satisfies InsertLocationOut); } - // Clear of the geometry, so the marker can be grabbed and dragged. - // A box we cannot read is not a reason to refuse to add one. + // Clear of the geometry, so it can be grabbed. An unreadable box isn't a reason to refuse. const box = await getAssemblyBoundingBox(onshapeApi, targetPath).catch( () => undefined ); @@ -72,9 +70,7 @@ insertLocationRoutes.post( toTranslation(toMarkerPoint(box)) ); - // The insert answers with the occurrence it made, whose path is the new - // instance's own id. Looked up again if it did not, rather than leaving - // the caller thinking the marker is still missing. + // Looked up again if the insert didn't report the occurrence. const instanceId = inserted.insertInstanceResponses?.[0]?.occurrences?.[0]?.path[0] ?? (await findInsertLocation(onshapeApi, targetPath)); diff --git a/src/backend/features/library/contract.ts b/src/backend/features/library/contract.ts index dda0c6692..d6761f5c4 100644 --- a/src/backend/features/library/contract.ts +++ b/src/backend/features/library/contract.ts @@ -41,12 +41,9 @@ export interface GroupOut { export type Insertables = Record<string, InsertableOut>; export type Groups = Record<string, GroupOut>; -/** - * What an insert answers with: the fasten mate's feature when one was built, and - * null whenever none was — the part studio path never builds one. - */ +/** The feature to open: the derive in a part studio, the fasten mate in an assembly. */ export interface InsertOut { - featureId: string | null; + featureId?: string; } export interface LibraryOut { diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index 87c4fa280..1ee00661f 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -14,10 +14,6 @@ import { type SearchRecord } from "../configurations/contract"; import { buildSearchDb } from "../search/build"; import { searchRecordsOf } from "../search/records"; -/** - * Assembles the full `LibraryOut` (groups + insertables, in sort order) for a - * library from D1. - */ export async function getLibraryOut( db: Db, libraryId: LibraryId @@ -42,8 +38,7 @@ export async function getLibraryOut( .where(eq(insertables.libraryId, libraryId)) .orderBy(asc(insertables.sortOrder)) .all(), - // Ids only: a configurations row exists exactly when there are - // parameters, and its payload is fetched when one is opened. + // Ids only; the payload is fetched when one is opened. db .select({ insertableId: configurations.insertableId }) .from(configurations) @@ -68,8 +63,7 @@ export async function getLibraryOut( groupInsertables.sort((a, b) => a.name.localeCompare(b.name)); } const insertableOrder = groupInsertables.map((ins) => ins.id); - // A shell group has no version to link to, so its path stops at the - // document rather than pointing at a `/v/placeholder` that 404s. + // A shell group has no version, so link to the document instead. const hasVersion = group.versionId !== PLACEHOLDER_VERSION_ID; groupsOut[group.id] = { id: group.id, @@ -122,10 +116,7 @@ export async function getLibraryOut( }; } -/** - * Renumbers a library's groups to open a slot and returns its sort order. The - * caller writes the row, since it also decides create vs. update. - */ +/** Returns the new slot; the caller writes the row. */ export async function placeNewGroup( db: Db, libraryId: LibraryId, @@ -144,8 +135,6 @@ export async function placeNewGroup( // An unknown or unspecified selection puts the new group last. const newIndex = selectedIndex === -1 ? siblings.length : selectedIndex + 1; - // Renumber every sibling to close any gaps: those at or past the new slot - // shift up by one to make room for it. await Promise.all( siblings.map((sibling, index) => db @@ -158,10 +147,7 @@ export async function placeNewGroup( return newIndex; } -/** - * The row everything pointing at a library needs first. Called wherever a library - * id is written: a library gets its row on the first group added to it. - */ +/** Called wherever a library id is written. */ export async function ensureLibrary( db: Db, libraryId: LibraryId @@ -182,11 +168,7 @@ export async function bumpLibraryVersion( }); } -/** - * The R2 object key holding a library's serialized MiniSearch index. Versioned - * by the shape of what it stores: an index written in an older shape is left - * behind rather than read, and the route rebuilds a missing one. - */ +/** Versioned by shape: an older index is ignored and the route rebuilds it. */ export function searchIndexKey(libraryId: LibraryId): string { return `search-index/v2/${libraryId}.json`; } @@ -202,18 +184,13 @@ export async function rebuildSearchDb( getSearchRecords(db, libraryId) ]); const searchDb = JSON.stringify(buildSearchDb(libraryData, indexed)); - // Uncompressed: encoding here would leave the runtime compressing an - // already-compressed body. + // Uncompressed, since the runtime compresses responses itself. await bucket.put(searchIndexKey(libraryId), searchDb, { httpMetadata: { contentType: "application/json" } }); return searchDb; } -/** - * What `buildSearchDb` indexes: each insertable's records. Left joined — an - * unconfigurable element has no configuration row. - */ async function getSearchRecords( db: Db, libraryId: LibraryId diff --git a/src/backend/features/library/db.worker.test.ts b/src/backend/features/library/db.worker.test.ts index 8a1722d06..5312eabc9 100644 --- a/src/backend/features/library/db.worker.test.ts +++ b/src/backend/features/library/db.worker.test.ts @@ -15,10 +15,7 @@ import { getLibraryOut, placeNewGroup, rebuildSearchDb } from "./db"; const db = getDb(env.DB); -/** - * Inserts a minimal group row at the given sort order — mirrors what the load-group - * workflow writes once `placeNewGroup` has told it where the new group belongs. - */ +/** Mirrors what the load writes once `placeNewGroup` has made room. */ async function insertGroupAt(id: string, sortOrder: number): Promise<void> { await db.insert(groups).values({ id, @@ -58,8 +55,6 @@ describe("placeNewGroup", () => { const sortOrder = await placeNewGroup(db, TEST_LIBRARY_ID, "g1"); expect(sortOrder).toBe(1); - // g1.5 itself is never inserted by placeNewGroup — only the existing - // siblings get renumbered to make room for it. const rows = await db .select() .from(groups) @@ -75,8 +70,6 @@ describe("rebuildSearchDb", () => { await resetDb(db); }); - // An unconfigurable insertable has no configurations row, so its part - // number reaches search only through the insertable's own part data. it("indexes an unconfigurable insertable's part number", async () => { await seedGroup(db); await seedInsertable(db, { diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 96de21684..e0a9c1cd9 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -66,8 +66,7 @@ groupRoutes.post( const db = getDb(c.env.DB); - // "Hide all elements" names every insertable in a group, which is more - // ids than one statement can bind. + // "Hide all" can name more ids than one statement binds. const writes: BatchItem<"sqlite">[] = []; for (const insertableIds of chunkForInArray(body.insertableIds)) { if (!body.isVisible) { @@ -100,8 +99,7 @@ groupRoutes.post( ); } - // Rebuild before bumping: the new version makes /search-db immutable, - // so a client fetching in between would pin the stale index for a year. + // Before the bump, which makes /search-db immutable for a year. await rebuildSearchDb(c.env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); return c.json({ success: true }); @@ -249,8 +247,7 @@ groupRoutes.delete( .where(eq(groups.documentId, deleted.documentId)) .get(); if (!stillUsed) { - // Logged rather than failing a delete that has happened: a - // webhook left behind reloads nothing, since no group matches. + // Logged: a leftover webhook matches no group, so it reloads nothing. await removeWebhook( c.env, await c.var.getOnshapeApi(), diff --git a/src/backend/features/library/groups/routes.worker.test.ts b/src/backend/features/library/groups/routes.worker.test.ts index f142555af..1671b8cd5 100644 --- a/src/backend/features/library/groups/routes.worker.test.ts +++ b/src/backend/features/library/groups/routes.worker.test.ts @@ -23,10 +23,7 @@ import * as Jobs from "../../load/jobs"; const db = getDb(env.DB); -/** - * `jsonRequest` plus a session cookie, for routes calling `getSessionId` — which - * reads the request directly rather than going through the mocked services. - */ +/** `getSessionId` reads the cookie directly, bypassing the mocks. */ function sessionRequest(method: string, body?: unknown): RequestInit { const init = jsonRequest(method, body); return { @@ -71,8 +68,7 @@ describe("group admin routes", () => { expect(remaining).toHaveLength(0); }); - // Search reads isVisible out of the index, not the row, so leaving it stale - // drops the insertable from every result until the next full load. + // Search reads isVisible from the index. it.each([false, true])( "POST /set-insertable-visibility rebuilds the search index (isVisible=%s)", async (isVisible) => { @@ -97,8 +93,7 @@ describe("group admin routes", () => { } ); - // "Hide all elements" sends a whole group's ids, and D1 takes at most 100 - // bound parameters in a statement. + // "Hide all" sends a whole group; D1 binds at most 100 parameters. it("POST /set-insertable-visibility handles more ids than a statement can bind", async () => { const count = 120; await seedGroup(db); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 37ac44bf6..73b4d2dbf 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -133,10 +133,7 @@ insertableRoutes.post( } ); -/** - * Re-probes an insertable's configurations under changed indexing settings. - * Probes before committing anything: if that throws, nothing is written. - */ +/** Probes before writing anything, so a failure writes nothing. */ async function reindex( c: AppContext, insertableId: string, @@ -223,16 +220,12 @@ async function reindex( } await db.batch([writes[0], ...writes.slice(1)]); - // Records feed the search index; rebuild before the bump makes the - // /search-db url immutable, or a stale index gets pinned for a year. + // Before the bump, which makes /search-db immutable for a year. await rebuildSearchDb(c.env.BLOB, db, row.libraryId); await bumpLibraryVersion(db, row.libraryId); } -/** - * The tab being inserted into, in the body so the whole path arrives as one - * object. A half-built one is rejected here, not as a nonsense Onshape URL. - */ +/** Rejected here if half-built, rather than reaching Onshape as a bad url. */ const targetPathSchema = z.object({ documentId: z.string().min(1), instanceId: z.string().min(1), @@ -242,10 +235,7 @@ const targetPathSchema = z.object({ const selectionSchema = z.record(z.string(), z.string()).optional(); -/** - * What an insert applies, made whole against the insertable's parameters. Every - * request crosses here, so nothing past it holds a partial or as-typed map. - */ +/** Every request goes through here, so nothing past it holds a partial selection. */ async function readSelection( db: Db, insertableId: string, @@ -275,8 +265,7 @@ const insertBody = z.object({ selection: selectionSchema, isFavorite: z.boolean().default(false), isQuickInsert: z.boolean().default(false), - // Where the insert began, which `isFavorite` does not answer. Defaulted so - // an older client cannot drop the whole tracking batch on a NOT NULL. + // Defaulted so an older client can't fail the tracking batch on NOT NULL. source: z.enum(InsertSource).default(InsertSource.BROWSE) }); @@ -326,8 +315,7 @@ insertableRoutes.post( insertableId, body.selection ); - // Fresh on every derive, whatever the client sent: a restored menu or - // a quick insert would otherwise repeat an earlier derive's value. + // Always fresh: a restored menu or quick insert would repeat an earlier value. const selection = requested && withDerivationValues(requested, parameters); @@ -364,7 +352,7 @@ insertableRoutes.post( ); return c.json({ - featureId: result.feature?.featureId ?? null + featureId: result.feature?.featureId } satisfies InsertOut); } ); @@ -414,18 +402,13 @@ insertableRoutes.post( body.selection ); - // Only what the selection overrides. Onshape applies the element's own - // default to every parameter left out, so this inserts the same thing — - // and a whole selection can outrun the configuration Onshape accepts. + // Only the overrides: a whole selection can exceed what Onshape accepts. let configuration = selection ? encodeConfiguration(onshapeOverrides(selection, parameters)) : undefined; - // Except a part studio at its defaults, which Onshape refuses to insert - // from an empty configuration and from no configuration alike, though an - // assembly inserts from either. Naming one parameter, at the default it - // already holds, is enough: what Onshape wants turns out to be a - // configuration that is there, not one that is complete. + // Onshape won't insert a part studio from an empty configuration (an assembly + // is fine), so name one parameter at its default. if ( selection && configuration === "" && @@ -436,8 +419,7 @@ insertableRoutes.post( ); } - // Resolved here rather than sent by the client: the marker moves - // whenever somebody drags it, so only Onshape knows where it is now. + // Resolved here, since the marker moves whenever someone drags it. const transform = body.insertLocationId ? await getInsertLocationTransform( onshapeApi, @@ -458,8 +440,7 @@ insertableRoutes.post( } ); - // The insert has landed, and every path below records it exactly once — so a - // fasten that never happened leaves none unrecorded, and `fasten` says what was. + // Every path below records the insert exactly once. const track = (fasten: boolean) => trackInBackground(c, async () => trackInsert(c, { @@ -479,7 +460,7 @@ insertableRoutes.post( if (!body.fasten) { await track(false); - return c.json({ featureId: null } satisfies InsertOut); + return c.json({} satisfies InsertOut); } const fastenInfo = row.fastenInfo; diff --git a/src/backend/features/library/insertables/routes.worker.test.ts b/src/backend/features/library/insertables/routes.worker.test.ts index 66d404c28..9aa106aa6 100644 --- a/src/backend/features/library/insertables/routes.worker.test.ts +++ b/src/backend/features/library/insertables/routes.worker.test.ts @@ -6,6 +6,7 @@ import { dailyConfigurationMetrics, events } from "../../analytics/schema"; import { InsertSource } from "../../analytics/usage"; import { ElementType } from "../../../lib/onshape/element-type"; import { Vendor } from "../vendors"; +import type { InsertOut } from "../contract"; import { MateLocation } from "./fasten"; import { BuildIssueType } from "../../build-checker/issues"; import { @@ -112,8 +113,6 @@ describe("insertable routes", () => { ); }); - // The client names which of the two searches it was; the route takes it - // whole rather than deriving anything from the request. it("POST /add-to-part-studio records the source it was sent", async () => { await seedPartStudio(db); vi.spyOn(PartStudioEndpoints, "addPartStudioFeature").mockResolvedValue( @@ -138,8 +137,6 @@ describe("insertable routes", () => { expect(event?.source).toBe(InsertSource.GROUP_SEARCH); }); - // The one place a request's configuration is made whole, so an insert that - // names nothing still applies — and records — every parameter. it("POST /add-to-part-studio fills the selection it was not given", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -168,8 +165,7 @@ describe("insertable routes", () => { }); }); - // The feature dialog shows this expression, so it is the one that was typed - // rather than the number it evaluates to, in whatever unit. + // The feature dialog shows the typed expression, not its value. it("POST /add-to-part-studio derives with the expression that was typed", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -196,8 +192,7 @@ describe("insertable routes", () => { ); }); - // Onshape refuses a second derive of the same configuration, so each derive - // gets its own value, whatever the client sent. + // Onshape refuses a second derive of the same configuration. it("POST /add-to-part-studio fills a derivation variable afresh each time", async () => { await seedPartStudio(db); await seedConfiguration(db); @@ -231,8 +226,6 @@ describe("insertable routes", () => { expect(values[1]).not.toBe(values[0]); }); - // 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" }], [ @@ -281,8 +274,8 @@ describe("insertable routes", () => { ); expect(res.status).toBe(200); - const body: { featureId: string | null } = await res.json(); - expect(body.featureId).toBeNull(); + const body: InsertOut = await res.json(); + expect(body.featureId).toBeUndefined(); expect(spy).toHaveBeenCalledWith( MOCK_ONSHAPE_API, @@ -347,8 +340,7 @@ describe("insertable routes", () => { ); }); - // A marker can be deleted between the app opening and an insert, and an - // insert at the origin beats refusing to insert at all. + // The marker can be deleted after the app opens. it("POST /add-to-assembly inserts at the origin when the location is gone", async () => { await seedAssembly(db); vi.spyOn(AssemblyEndpoints, "getAssembly").mockResolvedValue({ @@ -380,9 +372,7 @@ describe("insertable routes", () => { ); }); - // Onshape applies the element's own default to whatever the configuration - // leaves out, so naming every parameter says the same thing at far greater - // length — and long enough is a configuration Onshape refuses to insert. + // Onshape defaults what's left out, and a long enough configuration is refused. it.each([ ["nothing when the selection is all defaults", { boolean: "true" }, ""], ["the override alone", { boolean: "false" }, "boolean=false"] @@ -493,8 +483,7 @@ describe("insertable routes", () => { expect(await loggedInsert()).toMatchObject({ fasten: true }); }); - // The part is in the assembly either way, so the insert is still recorded — - // as the unfastened insert it turned out to be. + // The part is in the assembly either way. it("keeps the insert but drops the fasten when the mate fails", async () => { await seedFastenable(); vi.spyOn(AssemblyEndpoints, "addAssemblyFeature").mockRejectedValue( @@ -531,8 +520,6 @@ describe("insertable routes", () => { const row = await readInsertable(TEST_PART_STUDIO_ID); expect(row?.indexConfigurations).toBe(true); - // Nothing to configure, so the part data lands on the insertable and - // no configurations row is manufactured to hold it. expect(row?.partMetadata).toEqual({ partNumber: "PN-123", hasMultipleParts: false, @@ -541,8 +528,6 @@ describe("insertable routes", () => { expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); - // Excluding a parameter re-probes without it, so its options stop - // multiplying the records. it("POST /excluded-parameters stores the exclusion and reindexes without it", async () => { await seedPartStudio(db); await db.insert(configurations).values({ @@ -575,8 +560,7 @@ describe("insertable routes", () => { ).toEqual([{ size: "l" }]); }); - // Onshape lets no assembly exclude parameters from its properties, so - // neither does the app. + // Onshape can't exclude parameters from an assembly either. it("POST /excluded-parameters refuses an assembly", async () => { await seedAssembly(db); @@ -602,8 +586,6 @@ describe("insertable routes", () => { jsonRequest("POST", { indexConfigurations: true }), env ); - // Surfaced to the client rather than silently enabling. How long to - // wait is the loader's business; the caller is told to try again. expect(res.status).toBe(429); const body: { message: string } = await res.json(); expect(body.message).toContain("rate limit"); @@ -614,8 +596,6 @@ describe("insertable routes", () => { expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); - // Over the auto-index threshold nothing indexes unless an admin asks, so - // turning force off there drops the records and the configuration row. it("POST /index-configurations clears the data when forcing off", async () => { await seedGroup(db); await seedInsertable(db); @@ -656,8 +636,6 @@ describe("insertable routes", () => { expect((await readConfig(TEST_PART_STUDIO_ID))?.records).toEqual([]); }); - // Nothing gates on vendors any more, so a part below the threshold indexes - // whether or not anyone sells it. it("POST /index-configurations keeps indexing a custom part", async () => { await seedGroup(db); await seedInsertable(db, { @@ -681,8 +659,7 @@ describe("insertable routes", () => { expect(row?.buildIssues).toEqual([]); }); - // The route merges into the row's stored issues, so it has to clear the ones - // indexing owns first, or a resolved issue would stick around forever. + // Indexing's own issues are cleared before merging, or a resolved one sticks. it("POST /index-configurations replaces stale part-number issues", async () => { await seedPartStudio(db); await db @@ -706,7 +683,6 @@ describe("insertable routes", () => { expect(res.status).toBe(200); const row = await readInsertable(TEST_PART_STUDIO_ID); - // The cap no longer applies, and the unrelated issue survives. expect(row?.buildIssues).toEqual([{ type: BuildIssueType.NO_VENDORS }]); }); }); diff --git a/src/backend/features/library/library-id.ts b/src/backend/features/library/library-id.ts index c04c28130..30621b9ad 100644 --- a/src/backend/features/library/library-id.ts +++ b/src/backend/features/library/library-id.ts @@ -4,9 +4,5 @@ export enum LibraryId { MKCAD = "mkcad" } -/** - * The library the app falls back to: what a caller with no choice of their own - * opens in, and what a library-scoped reader uses when the tab showing is not - * a library. - */ +/** For callers with no choice of their own, and readers on a non-library tab. */ export const DEFAULT_LIBRARY = LibraryId.FRC_DESIGN_LIB; diff --git a/src/backend/features/library/routes.ts b/src/backend/features/library/routes.ts index 81500690b..769588a1b 100644 --- a/src/backend/features/library/routes.ts +++ b/src/backend/features/library/routes.ts @@ -35,10 +35,7 @@ libraryRoutes.get( } ); -/** - * GET /api/search-db/library/:libraryId?v=:cacheVersion. Never set - * `Content-Encoding` here: the runtime would compress it a second time. - */ +/** GET /api/search-db/library/:libraryId?v=:cacheVersion. No `Content-Encoding`: the runtime compresses. */ libraryRoutes.get( "/search-db" + libraryRoute(), cacheMiddleware(CachePolicy.PUBLIC_CACHE), @@ -47,8 +44,7 @@ libraryRoutes.get( const object = await c.env.BLOB.get(searchIndexKey(libraryId)); if (!object) { - // Never built in the shape this deploy reads; build it now rather - // than waiting on the next load. + // Not built in this deploy's shape yet. const searchDb = await rebuildSearchDb( c.env.BLOB, getDb(c.env.DB), diff --git a/src/backend/features/library/routes.worker.test.ts b/src/backend/features/library/routes.worker.test.ts index a182174f4..1cd9a68b8 100644 --- a/src/backend/features/library/routes.worker.test.ts +++ b/src/backend/features/library/routes.worker.test.ts @@ -73,8 +73,7 @@ describe("library routes", () => { ); const body = await res.text(); - // The seeded parameter's name is distinctive; the payload stays in D1 - // and is fetched from /api/configuration/:insertableId when needed. + // Parameters are fetched per insertable, not sent with the library. expect(body).not.toContain(TEST_PARAMETERS[0].name); expect(body).not.toContain("parameters"); expect(body).not.toContain("records"); @@ -92,8 +91,7 @@ describe("library routes", () => { env ); expect(res.status).toBe(200); - // A hand-set Content-Encoding gets compressed again by the runtime, - // leaving the client a gzip stream after one inflate. + // The runtime would compress it again. expect(res.headers.get("Content-Encoding")).toBeNull(); expect(res.headers.get("Content-Type")).toBe("application/json"); @@ -102,8 +100,7 @@ describe("library routes", () => { expect(parsed.documentCount).toBeGreaterThan(0); }); - // An index written in an older shape sits under an older key, so a miss - // is what every library looks like the first time a deploy reads it. + // Every library misses the first time a deploy with a new index shape reads it. it("GET /search-db builds the index when there is none", async () => { await seedTestData(db); const app = createTestApp(); diff --git a/src/backend/features/library/vendors.test.ts b/src/backend/features/library/vendors.test.ts index 4faae1eb2..edf44b6e6 100644 --- a/src/backend/features/library/vendors.test.ts +++ b/src/backend/features/library/vendors.test.ts @@ -87,8 +87,7 @@ describe("getLibraryVendors", () => { expect(frc).toContain(Vendor.MCM); }); - // The roster itself, so a vendor cannot quietly go missing: FRC teams see - // this list and nothing else, in this order. + // Pinned so a vendor can't quietly go missing. it("stocks FRC with exactly the vendors it buys from", () => { expect( getLibraryVendors(LibraryId.FRC_DESIGN_LIB).map((vendor) => [ @@ -114,8 +113,6 @@ describe("getLibraryVendors", () => { ]); }); - // The same roster check for FTC, whose list is its own and shares only - // part of FRC's. it("stocks FTC with exactly the vendors it buys from", () => { expect( getLibraryVendors(LibraryId.FTC_DESIGN_LIB).map((vendor) => [ diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts index f86c86003..38315fa78 100644 --- a/src/backend/features/library/vendors.ts +++ b/src/backend/features/library/vendors.ts @@ -31,8 +31,7 @@ export enum Vendor { TTB = "TTB", VEX = "VEX", WCP = "WCP", - /** Last, being the absence of a vendor: the team made it, so nobody sells - * it and it has no part number. */ + /** Made by the team, so nobody sells it. */ CUSTOM = "Custom" } @@ -78,18 +77,12 @@ const FTC_VENDORS: Vendor[] = [ Vendor.CUSTOM ]; -/** - * The vendors a library stocks, which is what its filters offer. Tagging stays - * library-generic. MKCad is FRC, so it shares that list. - */ +/** What a library's filters offer. MKCad shares FRC's list. */ export function getLibraryVendors(libraryId: LibraryId): Vendor[] { return libraryId === LibraryId.FTC_DESIGN_LIB ? FTC_VENDORS : FRC_VENDORS; } -/** - * Resolves the free text Onshape carries as a vendor to one we know, written - * either as its code or as its full name. - */ +/** Accepts a code or a full name. */ export function parseVendor(vendor: string | undefined): Vendor | undefined { const text = clean(vendor)?.toUpperCase(); if (!text) { @@ -102,20 +95,14 @@ export function parseVendor(vendor: string | undefined): Vendor | undefined { ); } -/** - * The vendor a part number names itself, e.g. `WCP-1025` — more precise than an - * insertable's tagging, which is generic wherever one part spans vendors. - */ +/** More precise than tagging, which is generic when a part spans vendors. */ export function parseVendorFromPartNumber( partNumber: string | undefined ): Vendor | undefined { return parseVendor(VENDOR_PREFIX.exec(clean(partNumber) ?? "")?.[1]); } -/** - * The vendor's page for a part, or its search for one where that is all the - * site offers. Most vendors have no url derivable from a part number at all. - */ +/** Most vendors have no url derivable from a part number. */ export function getVendorPartUrl( vendor: Vendor | undefined, partNumber: string | undefined @@ -130,8 +117,7 @@ export function getVendorPartUrl( case Vendor.WCP: return `https://wcproducts.com/products/${query.toLowerCase()}`; case Vendor.AM: - // AndyMark redirects a bare part number to the product page for it, - // and 404s when it sells no such part. + // Redirects to the product, or 404s. return `https://andymark.com/${query.toLowerCase()}`; case Vendor.REV: return `https://www.revrobotics.com/search.php?search_query=${query}§ion=product`; diff --git a/src/backend/features/live/contract.ts b/src/backend/features/live/contract.ts index f9cf205b3..497563752 100644 --- a/src/backend/features/live/contract.ts +++ b/src/backend/features/live/contract.ts @@ -1,8 +1,4 @@ -/** - * What the server pushes to open clients, so they need not poll for it. None - * of it is private: it says that something changed, and a client that cares - * asks the usual routes for what, under its own access. - */ +/** Nothing private: a push says what changed, and the client refetches under its own access. */ import type { LibraryId } from "../library/library-id"; import type { JobStatus } from "../load/contract"; import type { ConfigurationKey } from "../configurations/contract"; @@ -10,10 +6,7 @@ import type { ConfigurationKey } from "../configurations/contract"; export enum LiveMessageType { /** A library's load jobs started or finished. */ JOBS = "jobs", - /** - * A library changed under a new cache version: its contents, or who is on - * its admin team. - */ + /** Its contents or its admin team changed. */ LIBRARY = "library", /** A configuration's thumbnail finished rendering. */ THUMBNAIL = "thumbnail" diff --git a/src/backend/features/live/live-updates.ts b/src/backend/features/live/live-updates.ts index 3da59ad78..1fec689eb 100644 --- a/src/backend/features/live/live-updates.ts +++ b/src/backend/features/live/live-updates.ts @@ -1,11 +1,7 @@ /** - * Holds every open client's WebSocket and relays what the server pushes. One - * instance for the whole app: the pushes are few and small, and one place to - * send them keeps a sender from having to know who is listening where. - * - * Sockets are accepted for hibernation, so an idle instance costs nothing - * while its clients stay connected; each is tagged with the library its client - * shows, which is what a library's messages are sent to. + * Holds every client's WebSocket and relays pushes. One instance for the app, + * since pushes are few and small. Sockets hibernate, so idle clients cost + * nothing, and are tagged with the library they show. */ import { DurableObject } from "cloudflare:workers"; import type { AppBindings } from "../../lib/context"; diff --git a/src/backend/features/live/notify.ts b/src/backend/features/live/notify.ts index f38bdc860..c0b0b54b3 100644 --- a/src/backend/features/live/notify.ts +++ b/src/backend/features/live/notify.ts @@ -1,8 +1,4 @@ -/** - * Pushes to open clients. A push is a courtesy on top of work already done, so - * one that fails is logged and dropped rather than failing that work: a client - * that missed it resyncs when it reconnects. - */ +/** A failed push is logged, not thrown: a client that missed it resyncs on reconnect. */ import type { AppBindings } from "../../lib/context"; import type { LibraryId } from "../library/library-id"; import type { JobStatus } from "../load/contract"; diff --git a/src/backend/features/live/routes.ts b/src/backend/features/live/routes.ts index b77516dae..79c8e315d 100644 --- a/src/backend/features/live/routes.ts +++ b/src/backend/features/live/routes.ts @@ -5,10 +5,7 @@ import { LIVE_PATH } from "./contract"; export const liveRoutes = getApp(); -/** - * GET /api/live?library= — a WebSocket of what the server pushes. Open to - * anyone, since nothing sent over it is private (see `contract.ts`). - */ +/** GET /api/live?library=: open to anyone, since nothing pushed is private. */ liveRoutes.get(LIVE_PATH.replace(/^\/api/, ""), (c) => { if (c.req.header("Upgrade") !== "websocket") { throw handledError( diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index b25dd381f..06cfe4465 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -8,25 +8,12 @@ import type { LibraryId } from "../library/library-id"; import type { ElementPath, InstancePath } from "../../lib/onshape/path"; /** - * How many insertables a load probes Onshape for at once — see - * `probeInsertable`, which is the part of a load that asks Onshape anything - * beyond a thumbnail. What bounds this is Onshape's rate limit rather than - * anything here: past it the extra calls come back 429 and wait out their - * `Retry-After` (see `ONSHAPE_STEP_RETRIES`), and a step whose five attempts run - * out fails its insertable. - * - * Back to 15 after 40 drove a load into a rate limit it never climbed out of: - * six attempts on one step, all 429, across five minutes. Still not measured - * against where Onshape actually starts pushing back, so this is the number that - * was working before rather than a considered one. + * Bounded by Onshape's rate limit: at 40 a load hit 429s it never recovered + * from. 15 is what worked before, not a measured limit. */ export const LOAD_CONCURRENCY = 15; -/** - * How many thumbnails a load waits on at once. A separate limiter from - * probing's, because a thumbnail step holds its slot through minutes of - * retries, and on the probing limiter that would stall probes behind it. - */ +/** Separate from probing, since a thumbnail step holds its slot through minutes of retries. */ const THUMBNAIL_CONCURRENCY = 10; /** The runtime plumbing a load runs against. */ diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index 0b0ad2b86..5e298ec7f 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -1,12 +1,7 @@ /** - * Document loads, one per group at a time. A load asked for while one runs is - * marked on the running one's row instead, and that load starts it as it - * finishes: two loads writing one group's rows at once would interleave, and - * the second may well be the one with the newer version to load. - * - * Rows in D1 rather than a list in KV, since loads start and finish - * concurrently — a full reload starts one per document — and KV loses writes - * that race. + * One load per group at a time: two writing the same rows would interleave. + * A load requested meanwhile is marked on the running row and started when it + * finishes. In D1 since concurrent KV writes lose updates. */ import { eq, inArray } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; @@ -39,10 +34,7 @@ const ACTIVE_STATUSES = new Set<InstanceStatus["status"]>([ "waitingForPause" ]); -/** - * How long a claimed row may go without an instance before it is taken for - * one whose start failed partway. - */ +/** After this, a claimed row with no instance is treated as a failed start. */ const CLAIM_GRACE_MS = 60_000; /** Workflows create at most this many instances a call. */ @@ -66,7 +58,6 @@ async function isAlive(env: AppBindings, job: LoadJob): Promise<boolean> { } } -/** Clears the rows of loads that are no longer running. */ async function clearDead( env: AppBindings, jobs: LoadJob[] @@ -142,8 +133,7 @@ export async function requestLoads( ); } - // Started together: each load stands alone, down to its library's search - // index, so nothing waits on any other. + // Each load stands alone, so they all start at once. const batches: (typeof toStart)[] = []; for (let i = 0; i < toStart.length; i += CREATE_BATCH) { batches.push(toStart.slice(i, i + CREATE_BATCH)); @@ -165,19 +155,14 @@ export async function requestLoads( /** What a finished load does next: nothing, or its group's queued load. */ type FinishOutcome = "done" | "rerun"; -/** - * Called by a load as it finishes, whether it failed or not. Publishes what it - * wrote, when it wrote anything, and starts the load queued behind it or lets - * the group go. - */ +/** Whether it failed or not. Publishes what it wrote, then starts the queued load or releases the group. */ export async function finishLoad( env: AppBindings, params: LoadDocumentParams, changed: boolean ): Promise<FinishOutcome> { const db = getDb(env.DB); - // Rebuilt before the bump: the new version makes /search-db immutable, so - // a client fetching in between would pin the stale index for a year. + // Before the bump, which makes /search-db immutable for a year. if (changed) { await rebuildSearchDb(env.BLOB, db, params.libraryId); await bumpLibraryVersion(db, params.libraryId); @@ -231,10 +216,7 @@ async function runningStatus( return { loadingGroupIds: rows.map((row) => row.groupId) }; } -/** - * The groups loading, clearing any left behind by a load that crashed first. - * For the client's first look; pushes keep it current after that. - */ +/** Also clears rows left by crashed loads. Pushes keep the client current after this. */ export async function getJobStatus( env: AppBindings, libraryId: LibraryId diff --git a/src/backend/features/load/load-group.ts b/src/backend/features/load/load-group.ts index 9794d614b..3fb71e8e4 100644 --- a/src/backend/features/load/load-group.ts +++ b/src/backend/features/load/load-group.ts @@ -62,8 +62,7 @@ export async function loadGroup( ): Promise<GroupLoadResult> { const { groupId, versionPath } = group; - // Here rather than when resolving the group, so a skipped group branches - // nothing. + // Here rather than when resolving, so a skipped group branches nothing. const thumbnailPath = await ctx.step.do( `thumbnail-workspace-${groupId}`, { retries: ONSHAPE_STEP_RETRIES }, @@ -75,8 +74,6 @@ export async function loadGroup( ); const target: LoadingGroup = { ...group, thumbnailPath }; - // Read once and derive from it: the loadable tabs, and the element the - // group's own thumbnail comes from, which is often not one of them. const contents = await ctx.step.do( `document-contents-${groupId}`, { retries: ONSHAPE_STEP_RETRIES }, @@ -125,8 +122,7 @@ export async function loadGroup( }) ); - // Only once the row has moved to this version. Never fatal: a leftover - // branch is clutter, not breakage. + // A leftover branch is clutter, not breakage, so this is never fatal. if (failedInsertableIds.length === 0) { await ctx.step .do(`delete-stale-workspaces-${groupId}`, async () => @@ -152,9 +148,8 @@ export async function loadGroup( } /** - * Starts every selected insertable at once, returning the ids of the ones that - * failed. `loadInsertable` holds a limiter slot for the part of itself that - * asks Onshape anything, so what runs in parallel here is bounded there. + * Returns the ids that failed. `loadInsertable` takes a limiter slot for its + * Onshape calls, which bounds the parallelism. */ async function loadInsertables( ctx: LoadContext, @@ -166,8 +161,7 @@ async function loadInsertables( try { await loadInsertable(ctx, target); } catch (error) { - // The only record of why: the row stores that it failed, never - // what failed. + // The row records only that it failed, so this is the only record of why. console.error( `Failed to load insertable ${target.insertableId} (${target.name})`, error @@ -179,10 +173,6 @@ async function loadInsertables( return failedInsertableIds; } -/** - * The group's own thumbnail. Which element it comes from is its own question, - * asked here rather than in the renderer, which only renders. - */ async function loadDocumentThumbnail( ctx: LoadContext, target: LoadingGroup, @@ -190,8 +180,8 @@ async function loadDocumentThumbnail( ): Promise<ThumbnailUrls | null> { const { groupId, thumbnailPath } = target; - // Never fatal: `checkGroup` already flags a missing thumbnail, and failing - // the load over a cosmetic one would lose the group's insertables. + // Not fatal: `checkGroup` flags a missing thumbnail, and failing here would + // lose the group's insertables. const element = documentThumbnailElement(target, contents); if (!element) { return null; @@ -210,12 +200,7 @@ async function loadDocumentThumbnail( ); } -/** - * The element a group's thumbnail is taken from: the one the document - * designates, or the first it has. Which element that is, and the document's - * name, both came back with the document when the group was resolved, so this - * asks Onshape nothing. - */ +/** The element the document designates, or its first. */ function documentThumbnailElement( target: GroupTarget, contents: OnshapeDocumentContents @@ -236,10 +221,6 @@ interface SaveGroupInput { failedInsertableIds: string[]; } -/** - * Writes the group row, applies the document's tab order, drops the insertables - * whose tabs are gone, and flags the ones that failed to load. - */ async function saveGroup( db: Db, target: LoadingGroup, @@ -258,8 +239,7 @@ async function saveGroup( smallThumbnailUrl: thumbnailUrls?.small ?? null, largeThumbnailUrl: thumbnailUrls?.large ?? null, buildIssues, - // Stamp the successful load; failures never reach here, so a failed - // reload leaves the group's last-good time untouched. + // Failed loads never get here, so they keep the last good time. lastLoadedAt: new Date() }; if (!hasFailedInsertables) { @@ -272,8 +252,7 @@ async function saveGroup( db.update(groups).set(parsed).where(eq(groups.id, target.groupId)) ]; if (!hasFailedInsertables) { - // A skipped tab never reaches saveInsertable, so move the whole group - // forward: the stale id is what insertion and document links use. + // Skipped tabs are never saved, so move the whole group to the new version. writes.push( db .update(insertables) @@ -292,8 +271,7 @@ async function saveGroup( .where(eq(insertables.id, insertableId)) ); } - // Configurations and favorites follow deleted insertables via their - // cascading foreign keys. + // Configurations and favorites cascade. for (const ids of chunkForInArray(removedInsertableIds)) { writes.push(db.delete(insertables).where(inArray(insertables.id, ids))); } @@ -305,15 +283,14 @@ async function saveGroup( } /** - * Keeps the issues the last good load recorded. A brand-new insertable has no - * row yet, so the group's `INSERTABLES_FAILED` covers it instead. + * Keeps the issues the last good load recorded. A new insertable has no row, + * so the group's `INSERTABLES_FAILED` covers it. */ async function flagFailedInsertables( db: Db, failedInsertableIds: string[] ): Promise<BatchItem<"sqlite">[]> { - // Chunked: a rate-limited load can fail more insertables at once than one - // statement can bind ids for. + // Chunked: a rate-limited load can fail more ids than one statement binds. const reads = await Promise.all( chunkForInArray(failedInsertableIds).map((ids) => db @@ -338,15 +315,11 @@ async function flagFailedInsertables( ); } -/** - * What an existing insertable row contributes to the reload decision: its id, so - * a reload keeps it, and its microversion, to tell whether it changed. - */ export interface StoredInsertable { id: string; elementId: string; microversionId: string; - /** Read so a row the last load failed on is retried rather than skipped. */ + /** So a row the last load failed on is retried. */ buildIssues: BuildIssue[]; /** Where the row sits now, which is what the tab order is compared against. */ sortOrder: number; @@ -369,13 +342,8 @@ async function fetchStoredInsertables( } /** - * New tabs, stored ones whose microversion changed, and stored ones the last - * load failed on. A stored insertable keeps its id so favorites and links - * survive. - * - * The failed ones are picked up because a failure writes no microversion: the - * tab looks unchanged next time, so matching on it alone would leave a - * transient Onshape failure flagged until someone forced a reload. + * New tabs, changed ones, and ones the last load failed on: a failure writes no + * microversion, so the tab would otherwise look unchanged. */ export function selectInsertablesToLoad( target: LoadingGroup, @@ -416,10 +384,6 @@ export function selectInsertablesToLoad( return insertableTargets; } -/** - * Finds the stored insertables whose tab no longer exists in the document; - * their ids are the rows to delete. - */ export function findRemovedInsertables( insertableTabs: OnshapeElement[], storedInsertables: StoredInsertable[] @@ -437,12 +401,8 @@ interface InsertableOrder { } /** - * The stored rows the tab order has moved, with the positions to write. - * - * Reordering tabs changes no microversion, so the moved rows are usually ones - * the load skips entirely: the order has to be written from the tab list rather - * than fall out of saving an insertable. A new row already carries its position - * from `selectInsertablesToLoad`, and a removed one is not in the tab list. + * Stored rows the tab order moved. Reordering changes no microversion, so these + * are usually rows the load skips, and the order is written here instead. */ export function findMovedInsertables( insertableTabs: OnshapeElement[], diff --git a/src/backend/features/load/load-group.worker.test.ts b/src/backend/features/load/load-group.worker.test.ts index 61d438c8d..c3b6441a8 100644 --- a/src/backend/features/load/load-group.worker.test.ts +++ b/src/backend/features/load/load-group.worker.test.ts @@ -214,8 +214,6 @@ describe("findMovedInsertables", () => { expect(moved).toEqual([]); }); - // A new tab is inserted with its position, and a removed one is about to be - // deleted, so neither belongs in the reorder. it("names only stored rows the document still has a tab for", () => { const moved = findMovedInsertables( [tab("new"), tab("e1")], @@ -292,8 +290,7 @@ describe("loadGroup", () => { beforeEach(async () => { await resetDb(db); await seedGroup(db); - // The real one reads OAuth tokens out of KV; every Onshape call these - // tests reach is mocked at the endpoint wrapper instead. + // Onshape calls are mocked at the endpoint wrappers instead. vi.spyOn( LoadCommonModule, "getOnshapeApiFromContext" @@ -319,8 +316,7 @@ describe("loadGroup", () => { expect(result).toMatchObject({ loadedElements: 2, failedElements: 0 }); const groupRow = await readGroup(); expect(groupRow?.versionId).toBe("v-2"); - // The version's date moves with the version, not with the sync: the - // card dates the version, and reloading an old one does not freshen it. + // Dates the version, not the sync. expect(groupRow?.versionCreatedAt).toEqual(LOADED_VERSION_CREATED_AT); expect(groupRow?.name).toBe("Reloaded Group"); expect(groupRow?.lastLoadedAt).toEqual(expect.any(Date)); @@ -391,9 +387,6 @@ describe("loadGroup", () => { expect(deleted.mock.calls.map((call) => call[2])).toEqual(["w-old"]); }); - // The element a group's thumbnail comes from is often not a loadable tab, - // and which one it is already came back with the document, so resolving it - // should cost nothing. it("takes the group thumbnail from the designated element without re-reading the document", async () => { mockContents([tab("e1"), drawing("cover")]); vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockResolvedValue( @@ -417,8 +410,7 @@ describe("loadGroup", () => { expect(document).not.toHaveBeenCalled(); }); - // A skipped tab never reaches saveInsertable, but its version still has to - // move: that id is what insertion and every document link are built from. + // Its id is what inserts and document links are built from. it("advances a skipped insertable's version along with the group's", async () => { mockContents([tab("e1")]); // Same microversion as the tab, so the load skips it entirely. @@ -439,7 +431,6 @@ describe("loadGroup", () => { expect(result).toMatchObject({ loadedElements: 0 }); // Nothing was reloaded... expect(configurationSpy).not.toHaveBeenCalled(); - // ...but it no longer points at the version the group just left. const row = await db .select() .from(insertables) @@ -449,8 +440,7 @@ describe("loadGroup", () => { expect((await readGroup())?.versionId).toBe("v-2"); }); - // The reason the order has to be written from the tab list: moving a tab - // changes no microversion, so every row that moved is one the load skips. + // Moving a tab changes no microversion, so the load skips these rows. it("applies the document's tab order to insertables it did not reload", async () => { mockContents([tab("e2"), tab("e1")]); await seedInsertable(db, { @@ -484,8 +474,6 @@ describe("loadGroup", () => { expect(rows.map((row) => row.elementId)).toEqual(["e2", "e1"]); }); - // A new tab is inserted at its own position, which moves everything below - // it: the rows that shift are saved by the group, not by their own load. it("makes room in the tab order for a newly added tab", async () => { mockContents([tab("new"), tab("e1")]); await seedInsertable(db, { @@ -510,8 +498,7 @@ describe("loadGroup", () => { expect(rows.map((row) => row.sortOrder)).toEqual([0, 1]); }); - // The version is what makes a failure self-healing: leaving it stale is what - // brings the next reload back to retry only the insertable that failed. + // The stale version brings the next reload back to retry it. it("holds the version back and flags the insertable that failed", async () => { mockContents([tab("e1"), tab("e2")]); vi.spyOn(ConfigurationEndpoints, "getConfiguration").mockImplementation( diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index b3a50f44a..2c41f3473 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -40,10 +40,7 @@ import { } from "./context"; import { ONSHAPE_STEP_RETRIES, uploadThumbnailsStep } from "./steps"; -/** - * Exactly the columns a reload overwrites; the rest of the row is identity or - * user-owned. - */ +/** The columns a reload overwrites; the rest is identity or user-owned. */ export interface ParsedInsertable { vendors: Vendor[]; thumbnailUrls: ThumbnailUrls | null; @@ -61,10 +58,7 @@ interface InsertableFlags extends IndexingSettings { supportsFasten: boolean; } -/** - * What a load reads under the limiter: every Onshape call an insertable makes - * except the thumbnail's. - */ +/** Every Onshape call but the thumbnail's, all under the limiter. */ interface ProbedInsertable { vendors: Vendor[]; fastenInfo: FastenInfo | null; @@ -83,8 +77,7 @@ export async function loadInsertable( ): Promise<void> { const { insertableId } = target; - // Bounded, because this is where an insertable's Onshape calls are: an - // indexed element probes once per configuration. + // Limited here since an indexed element probes once per configuration. const probed = await ctx.limit(() => probeInsertable(ctx, target)); // Nothing is asked for an empty studio, which renders to nothing at all. @@ -141,8 +134,6 @@ async function probeInsertable( const parts = await readPartsStep(ctx, target); const { isOpenComposite } = parts; - // An empty studio renders nothing and probes to nothing, so what it raises - // decides how much of the rest of the load is worth running. const hasParts = !hasBuildIssue(parts.buildIssues, BuildIssueType.NO_PARTS); const indexing = decideIndexing(target.elementType, parameters, flags); @@ -172,10 +163,7 @@ async function probeInsertable( }; } -/** - * Reads the flags that decide how much of the load runs. A brand-new insertable - * has no row yet, so it gets the same defaults the save writes. - */ +/** A new insertable has no row, so it gets the defaults the save writes. */ function readFlagsStep( ctx: LoadContext, insertableId: string @@ -224,10 +212,7 @@ interface PartsSummary { buildIssues: BuildIssue[]; } -/** - * Runs on every load, not just under indexing, so the insert path always asks - * for the right part types. Assemblies have nothing to read, so they skip it. - */ +/** Every load, so inserts always ask for the right part types. */ function readPartsStep( ctx: LoadContext, { insertableId, elementPath, elementType }: InsertableTarget @@ -270,9 +255,8 @@ function parseFastenInfoStep( } /** - * Everything outside `parsed` is written only on insert, so a reload preserves - * the user's flags. Sort order is seeded here and maintained by the group's save - * instead, which is the only place the document's tab order is known. + * Only `parsed` is overwritten, so a reload keeps the user's flags. Sort order + * is maintained by the group's save, which knows the tab order. */ export async function saveInsertable( db: Db, @@ -305,8 +289,7 @@ export async function saveInsertable( documentId: target.elementPath.documentId, elementId: target.elementPath.elementId, sortOrder: target.sortOrder, - // A new insertable starts hidden with its features off. An existing - // one keeps the user's choices, since `set` omits these. + // Only on insert, so an existing row keeps the user's choices. isVisible: false, supportsFasten: false, indexConfigurations: false, @@ -336,10 +319,7 @@ export async function saveInsertable( await db.batch([insertableWrite, configurationWrite]); } -/** - * One durable step per batch. An exhausted batch throws rather than saving a - * half-built list. - */ +/** One durable step per batch. An exhausted batch throws rather than save a partial list. */ function loadConfigurationRecords( ctx: LoadContext, insertableId: string, diff --git a/src/backend/features/load/load-insertable.worker.test.ts b/src/backend/features/load/load-insertable.worker.test.ts index c8abf6b9f..5002730b8 100644 --- a/src/backend/features/load/load-insertable.worker.test.ts +++ b/src/backend/features/load/load-insertable.worker.test.ts @@ -84,7 +84,6 @@ describe("saveInsertable", () => { }) .where(eq(insertables.id, TEST_PART_STUDIO_ID)); - // A reload finds it renamed and no longer a composite. await saveInsertable( db, insertableTarget({ @@ -129,8 +128,7 @@ describe("saveInsertable", () => { expect(config?.records).toEqual(records); }); - // The element's own part number is not a configuration of it, so probing an - // unconfigurable element must not manufacture a configurations row. + // Unconfigurable elements get no configurations row. it("stores part data on the insertable without a configuration row", async () => { await saveInsertable( db, @@ -150,8 +148,7 @@ describe("saveInsertable", () => { expect(await db.select().from(configurations).all()).toHaveLength(0); }); - // features/library/db.ts treats the row's existence as "configurable", so an - // insertable that stops being configurable must lose the row, not blank it. + // `library/db.ts` reads the row's existence as "configurable". it("drops the configuration row when there are no parameters", async () => { await saveInsertable( db, @@ -197,11 +194,7 @@ describe("loadInsertable", () => { afterEach(() => vi.restoreAllMocks()); - // A render can take half an hour to land. Holding a limiter slot while - // waiting on one stalls every insertable queued behind it, which is most of - // what a slow load spends its time on. - // A thumbnail neither instance will give up is a build issue, not a - // failed insertable: the row is still worth having without a picture. + // A missing thumbnail is a build issue; the row is still worth having. it("records a failed thumbnail rather than failing the insertable", async () => { vi.spyOn(ThumbnailStore, "uploadThumbnails").mockRejectedValue( new Error("no thumbnail anywhere") diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts index 6e77c8fb3..2b833ced9 100644 --- a/src/backend/features/load/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -43,7 +43,6 @@ const MANY = [{ type: BuildIssueType.MANUAL_INDEXING_REQUIRED }]; const TOO_MANY = [{ type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED }]; describe("decideIndexing", () => { - // Vendors no longer enter into it: the configuration count is the only gate. it.each([ // Below the auto line it indexes on its own. { configs: 127, force: false, index: true, issues: [] }, @@ -51,8 +50,7 @@ describe("decideIndexing", () => { { configs: 128, force: false, index: false, issues: MANY }, // Enabling it overrides the count, and clears the flag. { configs: 128, force: true, index: true, issues: [] }, - // Past the hard cap there is nothing to enumerate, so enabling it can't - // help — it stays unindexed and flagged either way. + // Past the cap there is nothing to enumerate, forced or not. { configs: 600, force: false, index: false, issues: TOO_MANY }, { configs: 600, force: true, index: false, issues: TOO_MANY } ])("configs=$configs force=$force", ({ configs, force, index, issues }) => { @@ -86,8 +84,6 @@ describe("decideIndexing", () => { ).toBe(true); }); - // A part studio's exclusions trim the count; an assembly cannot exclude - // any, so a stray list on one changes nothing. it("applies exclusions to a part studio but not an assembly", () => { const parameters = [ enumParam("A", ["a1", "a2"]), @@ -211,10 +207,7 @@ describe("parseAssemblyRecord", () => { }); }); -/** - * Mocks the parts endpoint, deriving a studio's parts from what the probe - * overrode — only that is sent, Onshape filling in the defaults. - */ +/** Parts derive from the overrides alone, as Onshape defaults the rest. */ function mockParts(partsFor: (overrides: Selection) => OnshapePart[]) { return vi .spyOn(PartsEndpoints, "getParts") @@ -261,8 +254,7 @@ describe("parseConfigurationRecords", () => { const result = await probeRecords([enumParam("A", ["a1", "a2"])]); expect(result.buildIssues).toEqual([]); - // "a1" is A's default, so that combination is the element's own probe - // under another name and is not probed again. + // "a1" is A's default, so that combination repeats the default probe. expect(result.partMetadata?.partNumber).toBe("PN-default"); expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a2"]); }); @@ -313,8 +305,7 @@ describe("parseConfigurationRecords", () => { expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a1"]); }); - // The element's own defaults hold, so only the configurations that break - // are at fault, and the first of them is what the build card opens. + // The defaults hold, so only the breaking configurations are at fault. it("blames the configurations that resolve to more than one part", async () => { mockParts((configuration) => configuration.A === "a2" || configuration.A === "a3" @@ -336,8 +327,7 @@ describe("parseConfigurationRecords", () => { ]); }); - // Every configuration inherits a broken default, so there is nothing - // narrower to blame or to open. + // Every configuration inherits a broken default. it("blames the part itself when its own defaults resolve to more than one part", async () => { mockParts(() => [ { partId: "p1", partNumber: "PN-1" }, @@ -378,8 +368,6 @@ describe("parseConfigurationRecords", () => { ]); }); - // Past the cap decideIndexing turns indexing off and raises the issue, so - // this only ever runs with nothing to enumerate. it("records just the default when there are no combinations", async () => { const spy = mockParts(() => [ { partId: "p", partNumber: "PN-default" } diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index 4eb71b5af..b30750e61 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -1,7 +1,4 @@ -/** - * Probes an insertable's configurations for the metadata we store. Every probe - * is kept: search dedupes itself, and build checks read the ones it drops. - */ +/** Every probe is kept: search dedupes, and build checks read the duplicates. */ import { OnshapeApi } from "../../lib/onshape/client"; import { parseRecordVendor } from "./parse-vendors"; import { ElementPath } from "../../lib/onshape/path"; @@ -52,10 +49,7 @@ export const NO_RECORDS: ConfigurationRecordsResult = { buildIssues: [] }; -/** - * The issue types indexing owns. A caller merging a fresh result into stored - * issues clears these first, so a resolved issue doesn't stick around. - */ +/** Cleared before merging a fresh result, so a resolved issue doesn't stick. */ export const INDEXING_ISSUE_TYPES = [ BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED, BuildIssueType.MANUAL_INDEXING_REQUIRED, @@ -123,10 +117,7 @@ interface PartsEvaluation { partToUse: OnshapePart | undefined; } -/** - * The one place that reads meaning out of a `/parts` response. A studio holds - * one part; an open composite is the exception, and its constituents are ignored. - */ +/** A studio holds one part; an open composite's constituents are ignored. */ function evaluateParts(parts: OnshapePart[]): PartsEvaluation { const composites = parts.filter((part) => part.bodyType === "composite"); if (parts.length > 1 && composites.length > 0) { @@ -148,18 +139,13 @@ export function computeOpenComposite(parts: OnshapePart[]): boolean { return evaluateParts(parts).isOpenComposite; } -/** - * Reads the studio's single part, or its composite when open. A configuration - * that loses the composite its default has stores no part at all. - */ export function parsePartStudioRecord( parts: OnshapePart[], values: PartialSelection, isOpenComposite: boolean ): ConfigurationRecord { const evaluation = evaluateParts(parts); - // An element that is an open composite everywhere else has no part to read - // in a configuration that loses it; toResult raises the build issue. + // A configuration that loses the default's composite has no part; toResult flags it. if (isOpenComposite && !evaluation.isOpenComposite) { return { values, @@ -221,7 +207,6 @@ export function parseAssemblyRecord( return record; } -/** The element a probe reads, carried together rather than threaded apart. */ export interface ProbeTarget { elementPath: ElementPath; elementType: ElementType; @@ -229,11 +214,9 @@ export interface ProbeTarget { } /** - * How one Onshape read is run. A request awaits it directly; a load wraps each - * in a durable step (`loadConfigurationRecords`), so a rate-limited retry - * re-fetches only that batch. - * The client is fetched per read rather than held, since a step that retries - * hours later needs a token that has not expired. + * A load wraps each read in a durable step, so a rate-limited retry refetches + * one batch. The client is fetched per read since a retry hours later needs a + * fresh token. */ type ProbeRunner = ( name: string, @@ -247,8 +230,7 @@ export async function indexRecords( parameters: ConfigurationParameter[], configurations: PartialSelection[] ): Promise<ConfigurationRecordsResult> { - // The element's own defaults, probed as a batch of one so every read the - // runner sees has the same shape. + // A batch of one, so every read has the same shape. const [defaultRecord] = await run("default", async () => fetchBatch(await getClient(), target, parameters, [{}]) ); @@ -281,16 +263,11 @@ export function parseConfigurationRecords( ); } -/** - * Splits the combinations to fetch into batches, minus anything the separate - * default probe already covers. - */ function planBatches( configurations: PartialSelection[], parameters: ConfigurationParameter[] ): PartialSelection[][] { - // A combination overriding nothing is the default probe again, so drop - // every all-defaults one, not just the empty one. + // Any all-defaults combination repeats the default probe. const toFetch = configurations.filter( (values) => Object.keys(overridesOf(values, parameters)).length > 0 ); @@ -365,8 +342,6 @@ function toResult( batches: ConfigurationRecord[][], parameters: ConfigurationParameter[] ): ConfigurationRecordsResult { - // The element's own probe describes the element, not a configuration of it, - // so it sheds the (empty) values that produced it. const partMetadata: PartMetadata = { partNumber: defaultRecord.partNumber, name: defaultRecord.name, @@ -382,14 +357,11 @@ function toResult( vendor: resolveVendor(record, parameters) })); - // A capped insertable never reaches here: decideIndexing turns indexing off - // past the cap, and raises CONFIGURATION_LIMIT_EXCEEDED itself. + // Capped insertables never get here; decideIndexing flags those. let buildIssues: BuildIssue[] = []; - // Which probe fails decides whose problem it is. The element's own defaults - // failing is the part being wrong, and every configuration inherits it, so - // there is nothing narrower to report; a configuration failing where the - // defaults hold is that configuration's problem, and can be opened. + // If the defaults fail, every configuration does, so report the part. If only + // a configuration fails, report that configuration. if (partMetadata.hasMultipleParts) { buildIssues = addBuildIssue(buildIssues, { type: BuildIssueType.MULTIPLE_PARTS @@ -407,8 +379,6 @@ function toResult( } } - // The element's own probe sets the expectation; losing the composite in any - // configuration is what makes it unstable. if (partMetadata.isOpenComposite) { const offenders = records.filter((record) => !record.isOpenComposite); if (offenders.length > 0) { diff --git a/src/backend/features/load/parse-configuration.test.ts b/src/backend/features/load/parse-configuration.test.ts index 630c37d1e..6ae862681 100644 --- a/src/backend/features/load/parse-configuration.test.ts +++ b/src/backend/features/load/parse-configuration.test.ts @@ -84,8 +84,7 @@ const RESPONSE: OnshapeConfigurationResponse = { } ] }, - // A logical wrapper whose only child is the no-op condition, which the - // parser drops — exercising the empty-children path. + // Its only child is a no-op the parser drops. visibilityCondition: { btType: OnshapeVisibilityConditionType.LOGICAL, operation: LogicalOp.AND, @@ -189,8 +188,6 @@ describe("parseOnshapeConfiguration", () => { ]); }); - // A logical left with no children says nothing about when to show the - // parameter, so it is dropped rather than stored as a condition of its own. it("drops a logical condition whose children were all no-ops", () => { expect(parameters[2].condition).toBeUndefined(); }); diff --git a/src/backend/features/load/parse-configuration.ts b/src/backend/features/load/parse-configuration.ts index 1decf7174..7eecc6eff 100644 --- a/src/backend/features/load/parse-configuration.ts +++ b/src/backend/features/load/parse-configuration.ts @@ -33,8 +33,7 @@ function parseVisibilityCondition( (condition): condition is VisibilityCondition => !!condition ); - // Nothing left that says when to show the parameter, so it is not a - // condition: stored as one, an OR of nothing would read as never. + // Stored, an OR of nothing would read as never. if (children.length === 0) { return undefined; } diff --git a/src/backend/features/load/parse-document-contents.ts b/src/backend/features/load/parse-document-contents.ts index bbb24503d..873d7e54c 100644 --- a/src/backend/features/load/parse-document-contents.ts +++ b/src/backend/features/load/parse-document-contents.ts @@ -1,6 +1,3 @@ -/** - * Extracts the insertable tabs from a document's contents listing. - */ import { ElementType } from "../../lib/onshape/element-type"; import { type OnshapeDocumentContents, @@ -14,10 +11,7 @@ const VALID_ELEMENT_TYPES = new Set<string>([ ElementType.PART_STUDIO ]); -/** - * Tabs in tab-bar order: `elements` is unordered, so the folder tree defines it. - * Onshape sometimes omits a tab from the tree, so leftovers are appended. - */ +/** In tab-bar order, from the folder tree; tabs Onshape omits from the tree are appended. */ export function parseInsertableTabs( contents: OnshapeDocumentContents ): OnshapeElement[] { diff --git a/src/backend/features/load/parse-vendors.test.ts b/src/backend/features/load/parse-vendors.test.ts index b3f5450f9..1e1c580d3 100644 --- a/src/backend/features/load/parse-vendors.test.ts +++ b/src/backend/features/load/parse-vendors.test.ts @@ -26,8 +26,7 @@ describe("parseNameVendor", () => { expect(parseNameVendor("REDUX module")).toBe(Vendor.REDUX); }); - // FTC vendors spell themselves out in element names rather than using the - // code a part number carries. + // FTC vendors spell out their names. it("detects a vendor written as its whole name", () => { expect(parseNameVendor("goBILDA 5203 Motor")).toBe(Vendor.GB); expect(parseNameVendor("Misumi Extrusion")).toBe(Vendor.MIS); @@ -97,8 +96,6 @@ describe("parseVendors", () => { expect(parseVendors("Generic Part", parameters)).toEqual([]); }); - // Custom marks a part nobody sells, so a missing part number is expected - // rather than a warning. The name is the only thing that sets it. it("reads Custom out of a name, whatever its case", () => { expect(parseVendors("Custom Bracket", [])).toEqual([Vendor.CUSTOM]); expect(parseVendors("CUSTOM gusset", [])).toEqual([Vendor.CUSTOM]); diff --git a/src/backend/features/load/parse-vendors.ts b/src/backend/features/load/parse-vendors.ts index 3aee8e068..f2d9c6d15 100644 --- a/src/backend/features/load/parse-vendors.ts +++ b/src/backend/features/load/parse-vendors.ts @@ -38,10 +38,7 @@ export function parseVendors( return [...vendors]; } -/** - * The vendor one configuration resolves to. Its selected options name it more - * precisely than the part does, so they are read before the part's own name. - */ +/** The selected options name it more precisely than the part name, so they're read first. */ export function parseRecordVendor( partName: string | undefined, selection: PartialSelection, @@ -49,8 +46,7 @@ export function parseRecordVendor( ): Vendor | undefined { for (const param of parameters) { if (param.type !== ParameterType.ENUM) continue; - // An absent value is the parameter's default, which is what the - // element's own probe — configured with nothing — resolves to. + // Absent means the default, which the element's own probe resolves to. const selected = selection[param.id] ?? param.default; const option = param.options.find((o) => o.id === selected); const vendor = option && parseOptionVendor(option.name); diff --git a/src/backend/features/load/routes.ts b/src/backend/features/load/routes.ts index 8ff7fbe57..c22c7975d 100644 --- a/src/backend/features/load/routes.ts +++ b/src/backend/features/load/routes.ts @@ -8,10 +8,8 @@ import { requestLoads } from "./jobs"; export const loadRoutes = getApp(); /** - * POST /api/reload-all — force reloads every document in every library: starts - * one load per group, all at once, and returns without waiting on any. New versions reload themselves through their webhooks, so - * this is for what they cannot catch: a change in how the app reads documents, - * or a document that has never been loaded with a webhook to register. + * POST /api/reload-all: starts a forced load per group and returns. For what + * webhooks can't catch, like a change in how the app reads documents. */ loadRoutes.post("/reload-all", requireOwnerMiddleware, async (c) => { const sessionId = getSessionId(c); diff --git a/src/backend/features/load/steps.test.ts b/src/backend/features/load/steps.test.ts index f4269553c..a84b7b99e 100644 --- a/src/backend/features/load/steps.test.ts +++ b/src/backend/features/load/steps.test.ts @@ -7,8 +7,7 @@ const secondsOf = (delay: string) => Number.parseInt(delay, 10); /** The spread `rateLimitDelay` adds on top of what Onshape asked for. */ const JITTER_SECONDS = 20; -// A poll capped at two minutes waited four hours between attempts until these -// were pinned; see CONSTANT_BACKOFF for what the platform was adding. +// Any curve multiplies the delay; see CONSTANT_BACKOFF. describe("every retry config", () => { it("leaves the platform no curve to apply on top", () => { for (const retries of [ONSHAPE_STEP_RETRIES]) { @@ -43,9 +42,7 @@ describe("ONSHAPE_STEP_RETRIES", () => { expect(delay).toBeLessThanOrEqual(7 + JITTER_SECONDS); }); - // What the platform actually hands the callback. Passing the instance above - // is what let an `instanceof` check pass here and fail in production, where - // every 429 fell through to the curve and hammered Onshape six times. + // What Workflows actually passes the callback: a rebuilt Error, not the instance. it("waits out a rate limit Workflows rebuilt as a plain Error", () => { const rebuilt = new Error( new OnshapeRateLimitError("slow down", 450).message diff --git a/src/backend/features/load/steps.ts b/src/backend/features/load/steps.ts index 145fa2934..eae712979 100644 --- a/src/backend/features/load/steps.ts +++ b/src/backend/features/load/steps.ts @@ -4,11 +4,8 @@ import { type ThumbnailUrls } from "../thumbnails/contract"; import type { LoadContext } from "./context"; /** - * Pinned because the platform's curve compounds with the callbacks below: - * `backoff` defaults to exponential and multiplies what `delay` returned, which - * turned the thumbnail poll's capped 120 seconds into 120 × 2^7 — a run waiting - * 4h16m between attempts. Cloudflare documents the two settings separately and, - * as far as I saw, not how they combine, so that is read off a run. + * Workflows multiplies what `delay` returns by the backoff curve, which is + * undocumented; exponential turned a 120s delay into hours. */ const CONSTANT_BACKOFF: WorkflowBackoff = "constant"; @@ -19,20 +16,14 @@ interface RetryDelayInput { } /** - * Spread added on top of Onshape's `Retry-After`. Every step caught in one - * burst is handed the same number to wait, so without this they all wake at the - * same instant and re-send together — the burst that earned the 429. Twenty - * seconds trickles a full set of probe slots back in at a few per second. + * Every step in a 429 burst gets the same `Retry-After`; without jitter they + * all retry at once and trip it again. */ const RATE_LIMIT_JITTER_SECONDS = 20; /** - * How long Onshape asked us to wait plus jitter, or undefined when the error - * wasn't a rate limit. - * - * Read off the message: this runs on an error Workflows rebuilt, which is no - * longer an `OnshapeRateLimitError`, so an `instanceof` here answered false for - * every real 429 and quietly handed back the curve below instead. + * Undefined when the error wasn't a rate limit. Reads the message because + * Workflows rebuilds the error, so `instanceof` fails. */ export function rateLimitDelay(error: Error): `${number} seconds` | undefined { const retryAfterSeconds = readRetryAfterSeconds(error); @@ -44,10 +35,7 @@ export function rateLimitDelay(error: Error): `${number} seconds` | undefined { return `${retryAfterSeconds + jitter} seconds`; } -/** - * Retry delay honoring Onshape's `Retry-After` on a 429, with an - * exponential-ish fallback for other transient errors. - */ +/** Honors `Retry-After` on a 429; exponential otherwise. */ function onshapeRetryDelay(input: RetryDelayInput): `${number} seconds` { const rateLimited = rateLimitDelay(input.error); if (rateLimited) { @@ -57,22 +45,14 @@ function onshapeRetryDelay(input: RetryDelayInput): `${number} seconds` { return `${seconds} seconds`; } -/** - * Every step that calls Onshape takes this. The platform default would retry - * too, but on its own curve — a 429 carries a `Retry-After` and this is what - * honors it. Five attempts, matching that default rather than shortening it. - */ +/** For every step that calls Onshape, so a 429's `Retry-After` is honored. */ export const ONSHAPE_STEP_RETRIES = { limit: 5, delay: onshapeRetryDelay, backoff: CONSTANT_BACKOFF }; -/** - * A freshly branched workspace takes minutes to render its thumbnails. Waits - * 30, 60, 90 seconds, then two minutes a try: about 17 minutes in all. A rate - * limit waits what Onshape asks instead. - */ +/** A freshly branched workspace takes minutes to render: about 17 minutes in all. */ const THUMBNAIL_RETRIES = { limit: 10, delay: (input: RetryDelayInput): `${number} seconds` => @@ -82,11 +62,8 @@ const THUMBNAIL_RETRIES = { }; /** - * `null` when Onshape never renders them, which the caller records as a build - * issue rather than failing the load. - * - * Slot first, step inside: a step's timeout covers its whole callback, so - * waiting for a slot inside one would count against it. + * `null` when Onshape never renders them, which becomes a build issue. The slot + * is taken outside the step so waiting for it doesn't count against its timeout. */ export async function uploadThumbnailsStep( ctx: LoadContext, diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index ffce34a15..1fdb4f9d7 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -49,11 +49,7 @@ export type LoadResult = failedElements: number; }; -/** - * Loads one group's document: when its version has moved on, or always on a - * forced reload. New versions, added documents and forced reloads all come - * through here, one instance per group at a time; see `jobs.ts`. - */ +/** Loads one group's document when its version moved, or on a forced reload. One per group at a time; see `jobs.ts`. */ export class LoadDocumentWorkflow extends WorkflowEntrypoint< AppBindings, LoadDocumentParams @@ -70,8 +66,7 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< result = await loadDocument(ctx, params); return result; } finally { - // Whatever happened, so the group is let go and whatever queued - // behind this load starts. + // Always, so whatever queued behind this load starts. const changed = result.status === "loaded" || result.status === "failed"; await step.do("finish", () => @@ -122,8 +117,7 @@ async function loadDocument( }; } } catch (error) { - // The only record of why: the group row stores that it failed, never - // what failed. + // The row records only that it failed, so this is the only record of why. console.error(`Failed to load group ${groupId}`, error); await ctx.step.do("flag-failed", () => flagFailedGroup(ctx.env, groupId) @@ -131,9 +125,8 @@ async function loadDocument( result = { status: "failed" }; } - // After the load rather than before, so a document that cannot be read - // does not get a webhook. Its failure is logged rather than failing the - // load: the document is loaded either way, and the next load tries again. + // After the load, so an unreadable document gets no webhook. Not fatal: the + // next load retries. try { await ctx.step.do( "register-webhook", @@ -157,11 +150,8 @@ async function loadDocument( } /** - * Whether the group's stored issues record a load that did not finish. The - * version alone cannot decide a skip: a failure leaves the row's version where - * it was, so a group that failed while already on the latest version — a forced - * reload, or a blip in the version probe below, which runs even for a group that - * is about to be skipped — would keep its flag until someone forced another. + * A failure leaves the version where it was, so a group that failed on the + * latest version would otherwise be skipped until a forced reload. */ function hasFailedLoad(buildIssues: BuildIssue[]): boolean { return hasBuildIssue( @@ -183,8 +173,7 @@ async function resolveGroupTarget( async () => getDocument(await getOnshapeApiFromContext(ctx), { documentId }) ); - // The step hands back what Onshape sent, `createdAt` still an ISO string: - // a step's result is persisted for replay, which a Date does not survive. + // `createdAt` stays a string: step results are persisted, and a Date isn't. const version = await ctx.step.do( "version", { retries: ONSHAPE_STEP_RETRIES }, @@ -219,11 +208,7 @@ export interface ShellGroup { selectedGroupId?: string; } -/** - * Writes the group row a load then fills in, creating the library if this is - * its first group. Written before the load is asked for, so an add whose load - * fails still leaves a group an editor can see, retry or delete. - */ +/** Written before the load, so a failed add still leaves a group to retry or delete. */ export async function createShellGroup( env: AppBindings, params: ShellGroup @@ -247,18 +232,13 @@ export async function createShellGroup( sortOrder }) .onConflictDoNothing(); - // Without this the row is unreachable until the load finishes: every - // library response is pinned to the version, immutably. No search rebuild - // to go with it, since buildSearchDb indexes insertables and the shell has - // none — the index the old version served is still right for the new one. + // Library responses are pinned to the version, so the row is unreachable until + // it bumps. The search index is unaffected: the shell has no insertables. await bumpLibraryVersion(db, params.libraryId); await pushLibraryChanged(env, params.libraryId); } -/** - * Records the failure on the group row, so the library flags it rather than - * showing an empty group. A later successful load recomputes the issues afresh. - */ +/** So the library flags the group rather than showing it empty. */ async function flagFailedGroup( env: AppBindings, groupId: string diff --git a/src/backend/features/load/workflows.worker.test.ts b/src/backend/features/load/workflows.worker.test.ts index cd59a6086..ab372ec15 100644 --- a/src/backend/features/load/workflows.worker.test.ts +++ b/src/backend/features/load/workflows.worker.test.ts @@ -56,9 +56,8 @@ describe("createShellGroup", () => { expect(shell.lastLoadedAt).toBeNull(); }); - // The row lands before the load runs, but every library response is pinned - // to the cache version, so without a bump nothing can fetch the group until - // the load finishes — hours, for a large document. + // Library responses are pinned to the version, so without a bump the group + // is unreachable until the load finishes. it("bumps the library version, so the group is reachable", async () => { await seedGroup(db); const startVersion = await readVersion(); diff --git a/src/backend/features/search/build.test.ts b/src/backend/features/search/build.test.ts index a587d2a7e..a61da9c3b 100644 --- a/src/backend/features/search/build.test.ts +++ b/src/backend/features/search/build.test.ts @@ -57,8 +57,6 @@ describe("toSearchRecords", () => { expect(unconfigured([record({})])).toHaveLength(0); }); - // The placeholder an admin writes in identifies nothing, so it is dropped - // here rather than indexed and shown. it("drops a placeholder part number", () => { const [result] = unconfigured([ record({ partNumber: "N/A", name: "Spacer" }) diff --git a/src/backend/features/search/build.ts b/src/backend/features/search/build.ts index 460811c9b..e361f20e7 100644 --- a/src/backend/features/search/build.ts +++ b/src/backend/features/search/build.ts @@ -1,7 +1,4 @@ -/** - * Builds the index a library is served as. Worker-side: it reads the whole - * library, which the client never holds. - */ +/** Server-side, since it reads the whole library. */ import MiniSearch from "minisearch"; import { LibraryOut } from "../library/contract"; import { type SearchRecord } from "../configurations/contract"; diff --git a/src/backend/features/search/contract.ts b/src/backend/features/search/contract.ts index f384755d3..b1ba6eb13 100644 --- a/src/backend/features/search/contract.ts +++ b/src/backend/features/search/contract.ts @@ -1,8 +1,4 @@ -/** - * What the index holds and how it is queried. The backend builds the index with - * these options and the frontend deserializes it with the same ones, so they - * are the one thing both ends must agree on exactly. - */ +/** The index options, shared so the backend and frontend agree exactly. */ import { Options } from "minisearch"; import { Vendor } from "../library/vendors"; import { SearchRecord } from "../configurations/contract"; @@ -21,25 +17,18 @@ export interface SearchDocument { vendors: Vendor[]; name: string; groupName: string; - // Space-joined, deduped part numbers (a searchable field); empty when the - // insertable has no indexed part numbers. + // Space-joined and deduped; empty when none are indexed. partNumbers: string; - // Space-joined, deduped configuration (part) names (a searchable field); - // empty when the insertable has no indexed records. + // Space-joined and deduped; empty when none are indexed. partNames: string; - // Stored, not indexed: picks the best-matching configuration for a hit and - // launches it in the insert menu. + // Stored, not indexed: picks a hit's configuration for the insert menu. records: SearchRecord[]; } /** What the insertable itself is called, and where it lives. */ export const INSERTABLE_FIELDS = [NAME_FIELD, GROUP_NAME_FIELD]; -/** - * What its individual configurations are called and numbered. Separated so a - * surface can leave them out: they describe every configuration at once, which - * a list showing one specific configuration has no way to represent. - */ +/** Separate so a surface showing one configuration can leave them out. */ const CONFIGURATION_FIELDS = [PART_NUMBER_FIELD, PART_NAME_FIELD]; export const SEARCH_OPTIONS: Options<SearchDocument> = { @@ -54,8 +43,7 @@ export const SEARCH_OPTIONS: Options<SearchDocument> = { "records" ], searchOptions: { - // The insertable's own title leads; part names and the group name are - // weaker signals, so a title match outranks them. + // The title outranks part names and the group name. boost: { partNames: 0.7, groupName: 0.5 }, prefix: true }, diff --git a/src/backend/features/search/fields.ts b/src/backend/features/search/fields.ts index 7503bea7c..5214a26ed 100644 --- a/src/backend/features/search/fields.ts +++ b/src/backend/features/search/fields.ts @@ -1,8 +1,4 @@ -/** - * The indexed field names, named once. `tokenize` splits by field, `contract` - * groups them, and `records` reads which ones a hit matched — so a bare string - * in any of the three has to agree with the other two. - */ +/** Named once, since `tokenize`, `contract` and `records` must agree. */ /** The insertable's own title. */ export const NAME_FIELD = "name"; diff --git a/src/backend/features/search/records.ts b/src/backend/features/search/records.ts index e875c0286..b4ff0148a 100644 --- a/src/backend/features/search/records.ts +++ b/src/backend/features/search/records.ts @@ -1,8 +1,4 @@ -/** - * Which configuration a hit names. Kept beside the tokenizers it scores with, - * and MiniSearch-free: the caller says which fields matched, so the same - * scoring can answer for anything holding records. - */ +/** Picks which configuration a hit names. MiniSearch-free, so any caller holding records can use it. */ import { type ConfigurationParameter, type ConfigurationRecord, @@ -22,11 +18,8 @@ import { import { PART_NAME_FIELD, PART_NUMBER_FIELD } from "./fields"; /** - * The element's own defaults first: the record naming no values. - * - * Records arrive in the order `enumerateConfigurations` produced them, which is - * option declaration order — the default lands wherever Onshape happens to - * declare it, and for a boolean parameter defaulting to false it is never first. + * Moves the record naming no values to the front. Records come in Onshape's + * declaration order, where the default can land anywhere. */ function defaultFirst(records: SearchRecord[]): SearchRecord[] { const index = records.findIndex( @@ -42,17 +35,13 @@ function defaultFirst(records: SearchRecord[]): SearchRecord[] { ]; } -/** - * The best record by part number or name, whichever the query describes better, - * else the default — so a row shows one even when only the title matched. - */ +/** Falls back to the default, so a row shows one even when only the title matched. */ export function matchedRecord( query: string, documentRecords: SearchRecord[], matchedFields: string[] ): SearchRecord | undefined { - // Reordered once, so the tie-break inside findBestRecord and the fallback - // below both land on the configuration the insert menu opens with. + // So ties and the fallback land on what the insert menu opens with. const records = defaultFirst(documentRecords); const byNumber = matchedFields.includes(PART_NUMBER_FIELD) ? findBestRecord(query, records, (r) => r.partNumber, LITERAL) @@ -73,10 +62,6 @@ interface RecordMatch { score: number; } -/** - * How a field's text is read for scoring: a part number is compared as typed, - * a name around the decimals its sizes are indexed as. - */ interface FieldReader { normalize: (text: string) => string; terms: (text: string) => string[]; @@ -94,10 +79,7 @@ const DESCRIPTIVE: FieldReader = { terms: (text) => tokenizeName(text).map((term) => term.toLowerCase()) }; -/** - * A term matched whole beats one matched as a prefix, which every longer number - * satisfies too: `1` names the size `1"`, but only starts `16`. - */ +/** A whole match beats a prefix: `1` names `1"` but only starts `16`. */ function termScore(valueTerms: string[], queryTerm: string): number { // A unit is not part of the number's spelling, so `1` still names `1"`. if ( @@ -124,10 +106,7 @@ function coveredTerms( ); } -/** - * How well a value answers the query: a whole-query match ranks above any - * number of loose terms, so a part number typed out in full still wins. - */ +/** A whole-query match beats any number of loose terms. */ function matchScore( value: string, normalizedQuery: string, @@ -149,19 +128,13 @@ function matchScore( ); } -/** - * Scored by term rather than by the whole query, which "maxspline 24t" matches - * no record as. Ties go to whichever came first in `records`, which - * {@link defaultFirst} has already put the element's defaults at the front of. - */ +/** Scored per term, since a query like "maxspline 24t" matches no record whole. Ties go to the earliest. */ function findBestRecord( query: string, records: SearchRecord[], selector: (record: SearchRecord) => string | undefined, field: FieldReader ): RecordMatch | undefined { - // Read the query the way the field was indexed, so a `.5` query lines up - // with a stored "1/2 Bearing" and a typed part number with itself. const normalizedQuery = field.normalize(query.trim()); if (records.length === 0 || normalizedQuery === "") { return undefined; @@ -180,10 +153,7 @@ function findBestRecord( return best; } -/** - * Records as a client reads them, one per probe. One identifying nothing is - * dropped, having nothing to show. - */ +/** Drops records that identify nothing. */ export function toSearchRecords( records: ConfigurationRecord[], parameters: ConfigurationParameter[], @@ -211,9 +181,8 @@ export function toSearchRecords( } /** - * First of each distinct (part number, name) in enumeration order, which keeps - * the latest revision. What the index stores, which only picks a hit's record; - * something showing the record of a particular selection wants them all. + * First of each distinct (part number, name), which keeps the latest revision. + * Enough for the index, which only picks a hit's record. */ export function distinctRecords(records: SearchRecord[]): SearchRecord[] { const seen = new Set<string>(); @@ -237,11 +206,7 @@ export interface StoredConfiguration { vendors: Vendor[]; } -/** - * Every record an insertable can be shown or found as: its own part data - * first — the record an unset configuration falls back to — then one per - * indexed configuration. - */ +/** The element's own part data first, then one per indexed configuration. */ export function searchRecordsOf(stored: StoredConfiguration): SearchRecord[] { const own: ConfigurationRecord[] = stored.partMetadata ? [{ ...stored.partMetadata, values: {} }] diff --git a/src/backend/features/search/tokenize.test.ts b/src/backend/features/search/tokenize.test.ts index 2d350859a..2a70cbfdd 100644 --- a/src/backend/features/search/tokenize.test.ts +++ b/src/backend/features/search/tokenize.test.ts @@ -8,8 +8,7 @@ import { tokenizeQuery } from "./tokenize"; -// A part number identifies the part; splitting or folding it makes it name a -// different one, so it is indexed as typed alongside its segments. +// Indexed as typed, plus segments: splitting it would name a different part. describe("tokenizePartNumber", () => { it("keeps the number whole, and adds its segments", () => { expect(tokenizePartNumber("WCP-1025")).toEqual([ @@ -66,8 +65,6 @@ describe("tokenizeName", () => { ]); }); - // The standards write the same measurement both ways, so one decimal form - // is what lets either spelling find the other. it("canonicalizes fractions and decimals to a 2-dp decimal", () => { expect(tokenizeName("1/2")).toEqual(["0.5"]); expect(tokenizeName(".5")).toEqual(["0.5"]); @@ -102,8 +99,7 @@ describe("tokenizeName", () => { ]); }); - // One vendor writes .196 as .2 and the next writes .19, so the part is - // stored as both and either spelling finds it. + // Vendors write .196 as both .2 and .19. it("spells a measurement as what it rounds to and what it starts", () => { expect(tokenizeName(".196 ID Hub")).toEqual([ "0.2", @@ -114,7 +110,6 @@ describe("tokenizeName", () => { expect(tokenizeName('2.140" L')).toEqual(['2.14"', "L"]); }); - // The mark is what makes `1"` a size rather than a prefix of 1.5 and 16T. it("keeps an inch mark on the number it measures", () => { expect(tokenizeName('1" Hex Shaft')).toEqual(['1"', "Hex", "Shaft"]); expect(tokenizeName('1/2" Hex')).toEqual(['0.5"', "Hex"]); @@ -145,8 +140,6 @@ describe("processTerm", () => { expect(processTerm(term)).toEqual(expect.arrayContaining(words)); }); - // Its segments are already separate tokens; splitting the code again would - // only invent words inside it. it("leaves a part number whole", () => { expect(processTerm("WCP-1025", "partNumbers")).toEqual(["wcp-1025"]); }); @@ -164,8 +157,7 @@ describe("tokenize", () => { }); }); -// A query has no field, so it has to offer both readings: the caller may have -// typed a size or a part number. +// A query could be a size or a part number, so it's read both ways. describe("tokenizeQuery", () => { it("offers the part number as typed, and as a name would read it", () => { expect( @@ -173,8 +165,7 @@ describe("tokenizeQuery", () => { ).toEqual(expect.arrayContaining(["ttb", "16", "0016", "ttb-0016"])); }); - // `1` prefix-matches every number in the library, so a size is not split - // into the segments a part number would be. + // A bare `1` would prefix-match every number. it("does not split a bare size into its digits", () => { expect(tokenizeQuery("1/2")).toEqual(["0.5", "1/2"]); }); @@ -183,8 +174,7 @@ describe("tokenizeQuery", () => { expect(tokenizeQuery("bearing")).toEqual(["bearing"]); }); - // Nothing carries the placeholder, and splitting it leaves `n` and `a` — - // a one-letter prefix, which matches most of the library. + // Splitting it leaves one-letter prefixes that match most of the library. it.each(["n/a", "N/A"])("has nothing to search for in %s", (query) => { expect(tokenizeQuery(query)).toEqual([]); }); @@ -193,8 +183,7 @@ describe("tokenizeQuery", () => { expect(tokenizeQuery("n/a bearing")).toEqual(["bearing"]); }); - // Answering as the caller types is the point, and the first keystroke is - // one character. + // Search answers from the first keystroke. it.each(["l", "L", "1"])("still searches for a typed %s", (query) => { expect(tokenizeQuery(query)).toEqual([query]); }); diff --git a/src/backend/features/search/tokenize.ts b/src/backend/features/search/tokenize.ts index 5c6fecf4d..66b638fe2 100644 --- a/src/backend/features/search/tokenize.ts +++ b/src/backend/features/search/tokenize.ts @@ -1,8 +1,4 @@ -/** - * How search reads text: where names and part numbers break into terms, and how - * a measurement is spelled. The index is built with these and queried with - * them, so a change here is a change to both ends at once. - */ +/** How names and part numbers become terms, at both index and query time. */ import { isPlaceholderPartNumber } from "../configurations/part-number"; import { clean } from "../../lib/text"; import { PART_NUMBER_FIELD } from "./fields"; @@ -19,8 +15,7 @@ const WORD_BOUNDARIES = new RegExp( "g" ); -// A mixed number, fraction, decimal or integer. Ordered longest-first so `1-1/2` -// is consumed whole rather than as `1` + `1/2`. +// Longest first, so `1-1/2` isn't read as `1` + `1/2`. const NUMERIC_PATTERN = /(\d+)-(\d+)\/(\d+)|(\d+)\/(\d+)|\d*\.\d+|\d+\.\d*|\d+/g; @@ -37,16 +32,10 @@ const rounded: DecimalSpelling = (value) => const truncated: DecimalSpelling = (value) => String(Math.trunc(value * 100) / 100); -/** - * Both spellings, since the library writes the same measurement either way: one - * vendor's `.2` is the next one's `.19`. Storing both lets either find the part. - */ +// Vendors spell the same size both ways (`.2` and `.19`), so index both. const DECIMAL_SPELLINGS: DecimalSpelling[] = [rounded, truncated]; -/** - * One 2-dp decimal at index and query time alike, which is what lets the raw - * fragments go unstored. Names only: `217-2600` is not two thousand six hundred. - */ +/** Names only: `217-2600` is not a number. */ function canonicalizeNumbers(text: string, toDecimal: DecimalSpelling): string { return text.replace( NUMERIC_PATTERN, @@ -66,8 +55,7 @@ function canonicalizeNumbers(text: string, toDecimal: DecimalSpelling): string { let value: number; if (mixedWhole !== undefined) { const fraction = Number(mixedNum) / Number(mixedDen); - // A leading zero marks a part number segment, not a quantity: `TTB-0016-5/32` is - // part 16 in 5/32", not 16 and 5/32. Each half still canonicalizes on its own. + // A leading zero marks a part number: `TTB-0016-5/32` is part 16 in 5/32". if (mixedWhole.startsWith("0")) { return Number.isFinite(fraction) ? `${withoutLeadingZeros(mixedWhole)}-${toDecimal(fraction)}` @@ -87,22 +75,16 @@ function canonicalizeNumbers(text: string, toDecimal: DecimalSpelling): string { ); } -/** - * For direct, non-tokenized comparison: the index's canonicalization, - * lowercased, so a `.5` query lines up with a stored `"1/2 Bearing"`. - */ +/** For direct comparison, so a `.5` query matches a stored `"1/2 Bearing"`. */ export function normalizeForMatch(text: string): string { return canonicalizeNumbers(text, rounded).toLowerCase(); } -/** - * A name's words, with its sizes in the one decimal spelling. The inch mark - * stays on its number, so `1"` is a size rather than a prefix of `1.5` and `16t`. - */ +/** The inch mark stays on its number, so `1"` isn't a prefix of `1.5`. */ export function tokenizeName(text: string): string[] { const tokens = new Set<string>(); - // Canonicalized before splitting: fractions span `/` and `-`. Casing stays, - // since processTerm splits on camelCase. + // Before splitting, since fractions span `/` and `-`. Case is kept for + // processTerm's camelCase split. for (const toDecimal of DECIMAL_SPELLINGS) { for (const token of splitWithMarks( canonicalizeNumbers(text, toDecimal) @@ -117,8 +99,7 @@ export function tokenizeName(text: string): string[] { function splitWithMarks(text: string): string[] { const tokens: string[] = []; for (const piece of text.split(NAME_SEPARATORS)) { - // The split consumed the separators, so an inch mark left inside a - // piece ends the token it measures: `1"x2"` is two sizes. + // `1"x2"` is two sizes. for (const token of piece.split(/(?<=")/)) { if (token) tokens.push(token); } @@ -126,10 +107,7 @@ function splitWithMarks(text: string): string[] { return tokens; } -/** - * A part number identifies, it does not describe: it is indexed as typed, plus - * its segments, so `WCP-1025` is found by the whole number or either half. - */ +/** Whole plus segments, so `WCP-1025` is found by either half. */ export function tokenizePartNumber(text: string): string[] { const whole = clean(text)?.toLowerCase(); if (!whole) { @@ -139,7 +117,6 @@ export function tokenizePartNumber(text: string): string[] { return Array.from(new Set([whole, ...segments])); } -/** The fields holding an identifier rather than a description. */ function isPartNumberField(field?: string): boolean { return field === PART_NUMBER_FIELD; } @@ -154,23 +131,16 @@ export function tokenize(text: string, field?: string): string[] { : tokenizeName(text); } -/** - * A query is split both ways, since the caller may have typed either kind of - * text: the words of a name, and the literal a part number is indexed as. - */ +/** Read both as a name and as a part number, since either may be typed. */ export function tokenizeQuery(text: string): string[] { const tokens: string[] = []; - // The name reading keeps its case for processTerm to split camelCase on, so the - // literal reading of one word is a duplicate rather than a second term. const seen = new Set<string>(); for (const word of text.trim().split(/\s+/)) { - // Typed, it is still the word for a part number nobody has, and searching its - // letters would answer with whatever starts with `n` or `a`. + // A placeholder like "n/a" would match anything starting with its letters. if (!word || isPlaceholderPartNumber(word)) { continue; } - // Segments only what carries a letter, as a part number does: splitting a bare - // `1/2` would search `1`, and a prefix that short matches every number. + // Only words with a letter: splitting a bare `1/2` would search `1`. const literal = /[a-z]/i.test(word) ? tokenizePartNumber(word) : [word.toLowerCase()]; @@ -185,10 +155,7 @@ export function tokenizeQuery(text: string): string[] { return tokens; } -/** - * Adds the words inside a compound term, so `MAXSpline` is found by `spline`. - * A part number is left whole: its segments are already separate tokens. - */ +/** Adds the words in a compound, so `MAXSpline` is found by `spline`. */ export function processTerm(term: string, field?: string): string[] { const base = term.toLowerCase(); if (isPartNumberField(field)) { diff --git a/src/backend/features/settings/app-tab.test.ts b/src/backend/features/settings/app-tab.test.ts index 4c63ca039..eee736cf7 100644 --- a/src/backend/features/settings/app-tab.test.ts +++ b/src/backend/features/settings/app-tab.test.ts @@ -3,8 +3,6 @@ import { LibraryId } from "../library/library-id"; import { getTabPath, isLibraryTab, toAppTab, UtilityTab } from "./app-tab"; describe("app tabs", () => { - // The two kinds are addressed differently: libraries share one route and - // are named by id, a utility has a route of its own. it("gives each kind of tab its own path", () => { expect(getTabPath(LibraryId.FTC_DESIGN_LIB)).toBe( "/app/library/ftc-design-lib" diff --git a/src/backend/features/settings/app-tab.ts b/src/backend/features/settings/app-tab.ts index b075188c6..9e271df94 100644 --- a/src/backend/features/settings/app-tab.ts +++ b/src/backend/features/settings/app-tab.ts @@ -1,7 +1,4 @@ -/** - * What the navbar offers, and so what the app resumes into. Onshape calls its - * elements tabs too, which is what the `App` in the name holds off. - */ +/** "App" tab, since Onshape calls its elements tabs too. */ import { LibraryId } from "../library/library-id"; /** A tab that is not a library, having a page of the app's own instead. */ @@ -20,18 +17,11 @@ export function isLibraryTab(tab: AppTab): tab is LibraryId { return Object.values(LibraryId).includes(tab as LibraryId); } -/** - * Where a tab opens. A library is one of many under one route, so it is named - * by its id; a utility is a page of its own, and its id is that page's path. - */ export function getTabPath(tabId: AppTab): string { return isLibraryTab(tabId) ? `/app/library/${tabId}` : `/app/${tabId}`; } -/** - * A stored tab, or the default when it is not one the app still knows: the - * column is plain text under its `$type`, so a row can name anything. - */ +/** The column is plain text, so a row can name anything. */ export function toAppTab(tabId: string | undefined, fallback: AppTab): AppTab { return APP_TABS.includes(tabId as AppTab) ? (tabId as AppTab) : fallback; } diff --git a/src/backend/features/settings/routes.ts b/src/backend/features/settings/routes.ts index 570360219..63aab8e04 100644 --- a/src/backend/features/settings/routes.ts +++ b/src/backend/features/settings/routes.ts @@ -30,8 +30,7 @@ settingsRoutes.post( const db = getDb(c.env.DB); - // The row's dead `library_id` still defaults to this one and still - // points at `libraries`, so the insert needs it to be there. + // The dead `library_id` column still references the default library. await ensureLibrary(db, DEFAULT_LIBRARY); // No tab: a caller who has not chosen one has nothing to record. await db.insert(users).values({ id: userId }).onConflictDoNothing(); diff --git a/src/backend/features/settings/settings.ts b/src/backend/features/settings/settings.ts index a19acdb58..6b18c3690 100644 --- a/src/backend/features/settings/settings.ts +++ b/src/backend/features/settings/settings.ts @@ -9,8 +9,7 @@ export enum Theme { /** User settings, which the entry redirect reads and seeds the app with. */ export interface Settings { theme: Theme; - /** The tab they last opened, and land in next time; null until they pick - * one, which is what the welcome asks for. */ + /** Null until picked, which the welcome asks for. */ tabId: AppTab | null; /** The group they last opened in that tab; null for the tab itself. */ groupId: string | null; diff --git a/src/backend/features/thumbnails/contract.ts b/src/backend/features/thumbnails/contract.ts index 4b7182c24..ee4b8e210 100644 --- a/src/backend/features/thumbnails/contract.ts +++ b/src/backend/features/thumbnails/contract.ts @@ -1,7 +1,4 @@ -/** - * The two thumbnail sizes we generate and store, as the `WxH` Onshape wants. - * SMALL fills list rows; LARGE fills the hover card and the insert preview. - */ +/** As the `WxH` Onshape wants. SMALL is for list rows; LARGE for hover cards and the insert preview. */ export enum ThumbnailSize { SMALL = "70x40", LARGE = "300x300" diff --git a/src/backend/features/thumbnails/keys.ts b/src/backend/features/thumbnails/keys.ts index 7f7f962e2..0f6252c50 100644 --- a/src/backend/features/thumbnails/keys.ts +++ b/src/backend/features/thumbnails/keys.ts @@ -8,10 +8,7 @@ import { ThumbnailSize } from "./contract"; /** Everything a thumbnail is stored under, and what reconciliation scans. */ export const THUMBNAIL_PREFIX = "thumbnails/"; -/** - * Defaults get their own prefix, everything falling back to them so they never - * expire. A configuration is url-encoded, keeping `/` and `;` out of the path. - */ +/** Defaults get their own prefix. The configuration is url-encoded to keep `/` and `;` out of the path. */ export function thumbnailKey( elementId: string, microversionId: string, @@ -25,10 +22,7 @@ export function thumbnailKey( return `${THUMBNAIL_PREFIX}config/${elementId}/${microversionId}/${segment}/${size}`; } -/** - * What a stored thumbnail depicts. Both prefixes carry it in the same two - * segments, so a configuration render lives and dies with its element's. - */ +/** Both prefixes share these two segments, so a render is cleaned up with its element's. */ export interface ThumbnailSubject { elementId: string; microversionId: string; @@ -39,11 +33,7 @@ export function subjectKey(subject: ThumbnailSubject): string { return `${subject.elementId}/${subject.microversionId}`; } -/** - * The element and microversion a key was written for, or undefined when the key - * is not one {@link thumbnailKey} produces. Reconciliation deletes what this - * resolves, so anything it does not recognize is left alone. - */ +/** Undefined for a key it didn't write, which reconciliation then leaves alone. */ export function parseThumbnailKey(key: string): ThumbnailSubject | undefined { if (!key.startsWith(THUMBNAIL_PREFIX)) { return undefined; @@ -65,11 +55,7 @@ interface ThumbnailUrlOptions { size: ThumbnailSize; /** Empty (the default) serves the element's own thumbnail. */ configurationKey: ConfigurationKey; - /** - * The insertable to render a miss from, for a surface the configuration - * was picked on; absent serves what is stored and starts nothing, or a - * search would start a render per row. - */ + /** Starts a render on a miss; see `ThumbnailTarget`. */ insertableId?: string; } @@ -81,8 +67,7 @@ export function thumbnailUrl({ configurationKey, insertableId }: ThumbnailUrlOptions): string { - // `v` is the one abbreviation: it is the cache version every immutable url - // carries, and a render is pinned to the microversion it was taken from. + // `v` is the cache version every immutable url carries. const query = new URLSearchParams({ v: microversionId }); if (configurationKey !== DEFAULT_CONFIGURATION_KEY) { query.set("configurationKey", configurationKey); @@ -93,11 +78,7 @@ export function thumbnailUrl({ return `/api/thumbnail/${size}/${elementId}?${query.toString()}`; } -/** - * The subject of a url {@link thumbnailUrl} built. Groups record their document - * thumbnail only as these two urls, so this is what tells reconciliation which - * element and microversion they still stand for; `keys.test.ts` pins the pair. - */ +/** Groups store only these urls, so reconciliation reads the subject back from them. */ export function parseThumbnailUrl(url: string): ThumbnailSubject | undefined { // Relative, so it needs a base to parse against; the origin is discarded. const parsed = URL.parse(url, "https://x.invalid"); diff --git a/src/backend/features/thumbnails/reconcile.ts b/src/backend/features/thumbnails/reconcile.ts index 041fe3830..842300085 100644 --- a/src/backend/features/thumbnails/reconcile.ts +++ b/src/backend/features/thumbnails/reconcile.ts @@ -1,8 +1,6 @@ /** - * Deletes stored thumbnails nothing in the library still points at. Renders are - * kept indefinitely by design — the element default is what every unrendered - * configuration falls back to — so nothing expires them, and an element edited - * or removed would otherwise leave its thumbnails behind for good. + * Deletes stored thumbnails nothing points at. Nothing else expires them, so an + * edited or removed element would leave its thumbnails behind for good. */ import { isNotNull, or } from "drizzle-orm"; import { type Db } from "../../db/client"; @@ -18,19 +16,12 @@ import { /** R2 returns at most this many per call, and takes at most this many to delete. */ const R2_BATCH = 1000; -/** - * A bucket large enough to need more than this is reconciled over several runs. - * Bounds one step's work rather than the total, which repeated reloads reach. - */ +/** Bounds one run; a bigger bucket is finished by later runs. */ const MAX_PAGES = 50; /** - * How long a thumbnail is left alone regardless of the live set. A render is - * stored before the row naming it is written — a group load uploads as it goes - * and commits its rows at the end, and a configuration render is started by a - * user opening the insert menu, outside any job this could wait on. Either one - * would look orphaned while it is in flight. A day, the longest a load is - * expected to take; anything genuinely orphaned is collected by a later run. + * Renders are stored before the row naming them is written, so anything newer + * than a load could take is left alone. */ const MIN_AGE_MS = 24 * 60 * 60 * 1000; @@ -48,11 +39,7 @@ interface ReconcileResult { skipped: boolean; } -/** - * Every element and microversion the library still shows. Spans every library: - * a thumbnail key names no library, so a set built from one would read every - * other library's thumbnails as orphaned. - */ +/** Across every library, since a thumbnail key names none. */ async function liveSubjects(db: Db): Promise<Set<string>> { const [insertableRows, groupRows] = await Promise.all([ db @@ -61,8 +48,7 @@ async function liveSubjects(db: Db): Promise<Set<string>> { microversionId: insertables.microversionId }) .from(insertables), - // A group's document thumbnail is recorded only as the urls serving it, - // and its element is often not one of the group's own insertables. + // A group's thumbnail element is often not one of its insertables. db .select({ smallThumbnailUrl: groups.smallThumbnailUrl, @@ -95,10 +81,8 @@ function isLive(live: Set<string>, subject: ThumbnailSubject): boolean { } /** - * Idempotent, so a retried workflow step only re-deletes what is already gone. - * Deletes nothing when the live set is empty: a library really can have no - * elements, but so can a read that failed, and only one of those is worth - * emptying the bucket over. + * Idempotent. An empty live set deletes nothing: it could equally be a failed + * read. */ export async function reconcileThumbnails( bucket: R2Bucket, diff --git a/src/backend/features/thumbnails/reconcile.worker.test.ts b/src/backend/features/thumbnails/reconcile.worker.test.ts index 91fa6cf20..e2e3fcca5 100644 --- a/src/backend/features/thumbnails/reconcile.worker.test.ts +++ b/src/backend/features/thumbnails/reconcile.worker.test.ts @@ -66,8 +66,6 @@ describe("reconcileThumbnails", () => { ); }); - // A configuration render is addressed by the same element and microversion, - // so it has to follow the element rather than outlive it. it("deletes a configuration render whose microversion moved on", async () => { await seedPartStudio(db, { elementId: LIVE_ELEMENT, @@ -93,8 +91,7 @@ describe("reconcileThumbnails", () => { expect(await storedKeys()).toEqual([liveConfig]); }); - // A group's document thumbnail is usually not one of its own insertables, - // and the urls on the row are the only record of which element it is. + // The row's urls are the only record of which element it is. it("keeps the document thumbnail a group's urls still point at", async () => { const subject = { elementId: "doc-thumbnail-element", @@ -154,8 +151,6 @@ describe("reconcileThumbnails", () => { ); }); - // An empty read and an empty library look identical, and only one of them - // is worth emptying the bucket over. it("deletes nothing when the live set is empty", async () => { await store(...defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION)); @@ -181,9 +176,7 @@ describe("reconcileThumbnails", () => { expect(second.scanned).toBe(0); }); - // A group load stores thumbnails as it goes and writes its rows at the - // end, and a configuration render is started by a user rather than a job — - // so something in flight is indistinguishable from something orphaned. + // Renders are stored before their rows are written, so a new one could be in flight. it("keeps an orphan too new to tell apart from a render in flight", async () => { await seedPartStudio(db, { elementId: LIVE_ELEMENT, @@ -212,8 +205,6 @@ describe("reconcileThumbnails", () => { expect(result.deleted).toBe(2); }); - // The key names no library, so a set built from one library would read - // every other library's thumbnails as orphaned. it("keeps a thumbnail belonging to another library", async () => { await seedPartStudio(db, { elementId: LIVE_ELEMENT, diff --git a/src/backend/features/thumbnails/reload.ts b/src/backend/features/thumbnails/reload.ts index aedbbd8f9..efb975fde 100644 --- a/src/backend/features/thumbnails/reload.ts +++ b/src/backend/features/thumbnails/reload.ts @@ -1,7 +1,3 @@ -/** - * Re-fetching one stored thumbnail on demand: a thumbnail a load gave up on is - * missing until the next load, and this asks again for just the one. - */ import { eq } from "drizzle-orm"; import { type Db } from "../../db/client"; import { groups, insertables } from "../../db/schema"; @@ -56,10 +52,7 @@ async function thumbnailWorkspace( return workspace; } -/** - * Deletes first, since `uploadThumbnails` skips a size the bucket already holds. - * A branch made moments ago has nothing rendered yet, so that failure says so. - */ +/** Deletes first, since `uploadThumbnails` skips stored sizes. */ async function replaceThumbnails( bucket: R2Bucket, onshapeApi: OnshapeApi, @@ -139,16 +132,11 @@ export async function reloadInsertableThumbnail( .update(insertables) .set(reloaded(urls, row.buildIssues)) .where(eq(insertables.id, insertableId)); - // The urls are unchanged — they are built from the element and its - // microversion — so this is for the build issue the row no longer has. + // The urls don't change; this clears the build issue. await bumpLibraryVersion(db, row.libraryId); } -/** - * Re-fetches a group's own thumbnail. Which element it comes from is the - * document's to say, so unlike an insertable's this has to read the contents - * to find it and the microversion its key is built on. - */ +/** Reads the document's contents to find the element and microversion. */ export async function reloadGroupThumbnail( db: Db, bucket: R2Bucket, diff --git a/src/backend/features/thumbnails/reload.worker.test.ts b/src/backend/features/thumbnails/reload.worker.test.ts index 2cb87ec0b..1b3763154 100644 --- a/src/backend/features/thumbnails/reload.worker.test.ts +++ b/src/backend/features/thumbnails/reload.worker.test.ts @@ -137,8 +137,6 @@ describe("reloading a thumbnail", () => { expect(group?.thumbnailWorkspaceId).toBe("w-new"); }); - // `uploadThumbnails` skips a size the bucket already holds, which would - // make asking again do nothing at all. it("replaces what is already stored", async () => { const key = thumbnailKey( target.elementPath.elementId, diff --git a/src/backend/features/thumbnails/render-workflow.ts b/src/backend/features/thumbnails/render-workflow.ts index 1290422f7..9cfabf6df 100644 --- a/src/backend/features/thumbnails/render-workflow.ts +++ b/src/backend/features/thumbnails/render-workflow.ts @@ -1,10 +1,7 @@ /** - * Renders a configuration's thumbnails. Onshape renders one when its bytes are - * first asked for, answering 404 until they are ready, so the workflow asks - * until they land and stores them; the route that started it serves them. - * - * An element's own thumbnail never comes through here: Onshape renders those - * when a document is saved, and a load fetches them directly. + * Onshape renders a configuration's thumbnail when first asked, answering 404 + * until it's ready, so this asks until the bytes land and stores them. Element + * defaults render on save and never come through here. */ import { WorkflowEntrypoint, @@ -41,12 +38,7 @@ export interface RenderThumbnailParams { sessionId: string; } -/** - * How long a render is waited on: a minute, at a steady five seconds a try — - * renders normally land well inside that, and one that does not is abandoned - * rather than left spending the account's allocation. A rate limit waits out - * whatever Onshape says instead. - */ +/** About a minute; a render that takes longer is abandoned rather than spend the allocation. */ const RENDER_RETRIES = { limit: 12, delay: (input: { error: Error }) => diff --git a/src/backend/features/thumbnails/render-workflow.worker.test.ts b/src/backend/features/thumbnails/render-workflow.worker.test.ts index 47d852712..474104268 100644 --- a/src/backend/features/thumbnails/render-workflow.worker.test.ts +++ b/src/backend/features/thumbnails/render-workflow.worker.test.ts @@ -11,8 +11,6 @@ afterEach(() => vi.restoreAllMocks()); const key = (size: ThumbnailSize) => thumbnailKey("e1", "mv1", size, "a=1"); -// Onshape answers 404 until the render lands, so that is waited out rather -// than treated as a failure. it("stores both sizes once Onshape has rendered them", async () => { vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockResolvedValue( {} as OAuthApi diff --git a/src/backend/features/thumbnails/render.ts b/src/backend/features/thumbnails/render.ts index 8c0649794..18020a27f 100644 --- a/src/backend/features/thumbnails/render.ts +++ b/src/backend/features/thumbnails/render.ts @@ -1,8 +1,4 @@ -/** - * Starts a configuration's render, once. A client waiting on one may ask again - * — at its deadline, or on a push it missed — so asking while a render is under - * way has to start nothing more. - */ +/** Starts a configuration's render once; asking again while it runs starts nothing. */ import { eq } from "drizzle-orm"; import { HttpStatus } from "http-status-ts"; import type { AppContext } from "../../lib/context"; @@ -34,11 +30,7 @@ const ACTIVE = new Set<InstanceStatus["status"]>([ "waitingForPause" ]); -/** - * Starts the render unless one is under way. Throws a handled 422 when Onshape - * has no insertable for the configuration, which the client shows as a part - * that failed to regenerate. - */ +/** Throws a handled 422 when Onshape has no part for the configuration. */ export async function requestRender( c: AppContext, request: RenderRequest @@ -61,8 +53,7 @@ export async function requestRender( const existing = await findInstance(workflow, id); if (existing) { const { status } = await existing.status(); - // Only a miss asks, so a finished instance left no bytes behind: its - // render never came, or what it stored has since been deleted. + // Only a miss gets here, so a finished instance left no bytes behind. if (!ACTIVE.has(status)) { await existing.restart(); } @@ -98,10 +89,8 @@ export async function requestRender( } /** - * Named by Onshape's own id for the render, so every request for it finds the - * same instance. Onshape serves the bytes by that id alone, so as far as we - * can tell it already pins the element, version and configuration. Its format - * is undocumented, so anything an instance id may not hold is replaced. + * Onshape serves the bytes by this id alone, so it pins element, version and + * configuration. Its format is undocumented, so disallowed characters are replaced. */ function renderInstanceId(thumbnailId: string): string { return `render-${thumbnailId.replace(/[^\w-]/g, "_")}`.slice(0, 100); @@ -119,11 +108,7 @@ async function findInstance( } } -/** - * The version's branch (see `workspace.ts`), or the version for a group no - * load has branched yet. Read rather than passed in: a request can carry a - * version the group has moved past. - */ +/** Read rather than passed in, since a request can carry a version the group has moved past. */ async function elementPathOf( c: AppContext, insertableId: string diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index ea68f2335..9dce43576 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -33,10 +33,7 @@ const storedThumbnailQuery = z.object({ insertableId: z.string().optional() }); -/** - * GET /api/thumbnail/:size/:elementId?v=&configurationKey=&insertableId= - * Each answer caches itself: stored bytes are pinned by the url, a miss is not. - */ +/** GET /api/thumbnail/:size/:elementId?v=&configurationKey=&insertableId= */ thumbnailRoutes.get( "/thumbnail/:size/:elementId", validate("param", storedThumbnailParams), @@ -52,20 +49,15 @@ thumbnailRoutes.get( thumbnailKey(elementId, microversionId, size, configurationKey) ); if (object) { - // The microversion and the configuration are both in the url, so - // these bytes are the only ones it will ever mean. + // The url pins microversion and configuration. return setCache( thumbnailResponse(object), CachePolicy.PUBLIC_CACHE ); } - // A configuration this has not rendered is a miss, not the element's - // own thumbnail: standing that in shows a part the caller did not ask - // for, and a favorite pinned to a configuration would show the wrong - // one. A caller that wants the element default asks for it by key. - // Signed out, there is no session to render under, so the caller just - // keeps missing. + // Never answer with the element's default, which would show the wrong part. + // Signed out there is no session to render under. if ( configurationKey !== DEFAULT_CONFIGURATION_KEY && insertableId && @@ -112,14 +104,7 @@ const requireThumbnailEditor = requireEditor(async (c) => { return body.groupId ? libraryOfGroup(db, body.groupId) : undefined; }); -/** - * POST /api/reload-thumbnail - * - * Asks Onshape for a thumbnail again and replaces what is stored. A load does - * not wait for one, so a thumbnail that was not there at the time stays missing - * until the next reload of the whole document — this is the way to ask for just - * the one. - */ +/** POST /api/reload-thumbnail: refetches one thumbnail, since loads don't wait for them. */ thumbnailRoutes.post( "/reload-thumbnail", requireThumbnailEditor, diff --git a/src/backend/features/thumbnails/routes.worker.test.ts b/src/backend/features/thumbnails/routes.worker.test.ts index 55f9667d5..62ceaa1a1 100644 --- a/src/backend/features/thumbnails/routes.worker.test.ts +++ b/src/backend/features/thumbnails/routes.worker.test.ts @@ -51,8 +51,7 @@ describe("thumbnailKey", () => { ); }); - // A configuration's separators would otherwise open path segments of their - // own, so two different selections could name one key. + // Otherwise two selections could name one key. it("encodes a configuration into a single segment", () => { const key = thumbnailKey("e1", MICROVERSION, SIZE, "a=1;b=2/3"); expect(key).toBe( @@ -68,8 +67,7 @@ describe("thumbnailKey", () => { }); }); -// Reconciliation reads keys and urls back to decide what to delete, so the -// readers have to keep pace with the builders above them. +// Reconciliation deletes by what these read back. describe("reading a thumbnail address back", () => { const SUBJECT = { elementId: "e1", microversionId: MICROVERSION }; @@ -171,8 +169,7 @@ describe("thumbnail serving", () => { expect(res.status).toBe(400); }); - // Standing the element in would show a part nobody asked for: a favorite - // pinned to a configuration would render as the default one. + // The element's default would show a part nobody asked for. it("misses rather than standing the element default in", async () => { const elementId = "unrendered-configuration"; await env.BLOB.put( @@ -308,8 +305,6 @@ describe("rendering a configuration's thumbnail", () => { await seedPartStudio(db); }); - // A waiting client can ask again — at its deadline, or on a push it - // missed — and that has to start nothing more. it("starts one render on a miss, however often it is asked", async () => { await seedDefaultOnly("warm-element"); const thumbnailId = mockThumbnailId(); @@ -342,8 +337,7 @@ describe("rendering a configuration's thumbnail", () => { }); }); - // A miss is a render still coming; this is one that never will be, and the - // client shows different wording for each. + // The client words "still rendering" and "never will" differently. it("answers a configuration Onshape cannot resolve with its own status", async () => { vi.spyOn(ThumbnailEndpoints, "getThumbnailId").mockResolvedValue( undefined @@ -370,8 +364,7 @@ describe("rendering a configuration's thumbnail", () => { expect(thumbnailId).not.toHaveBeenCalled(); }); - // Search results show many configurations at once; one cold search must not - // start a render per row. + // One cold search mustn't start a render per row. it("starts nothing when no insertable is named", async () => { const started = await startedDuring(async () => { const res = await get( diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts index a9c59f4b0..2ff810214 100644 --- a/src/backend/features/thumbnails/store.ts +++ b/src/backend/features/thumbnails/store.ts @@ -1,7 +1,3 @@ -/** - * Where thumbnails live in R2, and how a caller reads one back. - */ - import { CachePolicy, immutableCacheControl } from "../../lib/cache"; import { getElementThumbnail } from "../../lib/onshape/endpoints/thumbnails"; @@ -15,10 +11,6 @@ import { } from "../configurations/contract"; import { OnshapeApi } from "../../lib/onshape/client"; -/** - * What produced a stored thumbnail, tagged onto the R2 object. The key already - * addresses it; this is for reading an object back and telling what it is. - */ interface ThumbnailMetadata extends Record<string, string> { microversionId: string; /** Empty for an element's own thumbnail, as everywhere else. */ @@ -44,12 +36,9 @@ export async function putThumbnail( const BOTH_SIZES = [ThumbnailSize.SMALL, ThumbnailSize.LARGE]; /** - * Stores both sizes, skipping any the bucket already holds; throws while - * Onshape has not rendered one. - * - * Keyed by the version's microversion though read from its branch: an unedited - * branch should show the same part. That is assumed, not checked against - * Onshape. + * Skips sizes already stored; throws while Onshape hasn't rendered. Keyed by + * the version's microversion though read from its branch, assuming an unedited + * branch renders the same. */ export async function uploadThumbnails( bucket: R2Bucket, @@ -59,8 +48,7 @@ export async function uploadThumbnails( ): Promise<ThumbnailUrls> { const { elementId } = thumbnailPath; - // One size at a time: an attempt that fails should cost one call rather - // than two, and what runs in parallel is elements, not their sizes. + // Sequential, so a failed attempt costs one call. for (const size of BOTH_SIZES) { const key = thumbnailKey(elementId, microversionId, size); if (await bucket.head(key)) { diff --git a/src/backend/features/thumbnails/workspace.ts b/src/backend/features/thumbnails/workspace.ts index 7ae96baef..3f212333d 100644 --- a/src/backend/features/thumbnails/workspace.ts +++ b/src/backend/features/thumbnails/workspace.ts @@ -1,11 +1,7 @@ /** - * Onshape sometimes never renders an element's thumbnail in a version, and the - * document's own workspace drifts from the version the library shows. So each - * loaded version gets a workspace branched off it, which nobody edits, and its - * thumbnails are read from there. - * - * A fresh branch has no thumbnails for a few minutes, which is why a load - * reading them retries for a long while. + * Each loaded version gets a branched workspace to read thumbnails from: + * Onshape sometimes never renders them in a version, and the document's own + * workspace drifts. A fresh branch takes minutes to render. */ import { type OnshapeApi } from "../../lib/onshape/client"; import { type DocumentPath, type InstancePath } from "../../lib/onshape/path"; @@ -19,10 +15,7 @@ import { type OnshapeWorkspaceInfo } from "../../lib/onshape/types"; /** Shared by every branch; cleanup deletes nothing without it. */ const WORKSPACE_NAME = "FRCDesignApp Thumbnails (DO NOT EDIT)"; -/** - * The name is the same for every version, so the description is what records - * which one a branch came from. - */ +/** Records which version the branch came from; the name is the same for all. */ function workspaceDescription(versionId: string): string { return `Made by the FRCDesignApp to read version ${versionId}'s thumbnails from.`; } @@ -31,10 +24,7 @@ function isOurs(workspace: OnshapeWorkspaceInfo): boolean { return workspace.name === WORKSPACE_NAME; } -/** - * Found before it is created, so a retried step or a forced reload reuses the - * branch rather than making another. - */ +/** Reuses an existing branch, so a retry doesn't make another. */ export async function ensureThumbnailWorkspace( client: OnshapeApi, versionPath: InstancePath @@ -58,10 +48,7 @@ export async function ensureThumbnailWorkspace( }; } -/** - * Deletes our branches of other versions. Only safe once the group row has - * moved to `keepWorkspaceId`'s version, since renders read the stored branch. - */ +/** Only safe once the group row has moved to `keepWorkspaceId`'s version. */ export async function deleteStaleThumbnailWorkspaces( client: OnshapeApi, documentPath: DocumentPath, diff --git a/src/backend/features/webhooks/registration.ts b/src/backend/features/webhooks/registration.ts index 20b2264cb..067e6e19e 100644 --- a/src/backend/features/webhooks/registration.ts +++ b/src/backend/features/webhooks/registration.ts @@ -1,8 +1,6 @@ /** - * The Onshape webhooks this deployment registers: one per library document, - * for its new versions, and one per admin team, for its members. Each is - * registered with `isTransient: false`, which Onshape documents as exempting - * it from cleanup, so once one is on record it is taken to stand. + * One per library document, for new versions, and one per admin team. Each is + * registered with `isTransient: false`, which exempts it from Onshape's cleanup. */ import { and, eq } from "drizzle-orm"; import type { AppBindings } from "../../lib/context"; @@ -33,11 +31,7 @@ function whereSubject(subject: WebhookSubject, subjectId: string) { ); } -/** - * What to ask Onshape for. A document's names the document, from which Onshape - * infers the company. A team's events are company-wide and name no team, so - * the company is the registering user's, and the receiver picks out the team. - */ +/** Team events are company-wide, so a team's webhook names the company and the receiver filters. */ async function subjectParams( onshapeApi: OAuthApi, subject: WebhookSubject, @@ -79,8 +73,7 @@ export async function ensureWebhook( return; } - // Stored first: Onshape posts webhook.register to the url before create - // returns, and the token is how the receiver recognizes it. + // Stored first: Onshape posts webhook.register before create returns. const token = crypto.randomUUID(); await db .insert(onshapeWebhooks) @@ -144,10 +137,7 @@ export function findWebhookByToken( .get(); } -/** - * Drops the record of a webhook Onshape unregistered, so the next load of its - * document, or setting of its team, registers another. - */ +/** So the next load or team change registers another. */ export async function forgetWebhook( env: AppBindings, webhook: RegisteredWebhook diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index 9f89a5cc5..e13bb9d2a 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -1,11 +1,6 @@ /** - * Where Onshape delivers; `registration.ts` registers what it delivers for. - * A new version of a library document reloads its groups, and a change to an - * admin team's members pulls that team again. - * - * A delivery is recognized by the token in its url, and acted on for the - * subject that token was registered for, never for what the payload names: the - * url is all that keeps anyone else from making the server do this work. + * Acts on the subject the url's token was registered for, never on what the + * payload names: the token is all that keeps others from triggering this. */ import { eq } from "drizzle-orm"; import { type AppBindings, getApp } from "../../lib/context"; @@ -59,16 +54,12 @@ webhookRoutes.post(RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { case WebhookEvent.UNREGISTER: await forgetWebhook(c.env, webhook); break; - // webhook.register and webhook.ping only want a 200, which registration - // fails without. + // Registration fails without a 200. } return c.json({}); }); -/** - * Loads the document's groups, in every library holding it, under the owner's - * session: nobody is signed in behind a webhook. - */ +/** Under the owner's session, since nobody is signed in behind a webhook. */ async function reloadDocument( env: AppBindings, documentId: string, diff --git a/src/backend/index.ts b/src/backend/index.ts index 89d0306a4..1a2728f4e 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,8 +1,4 @@ -/** - * Re-exported because the runtime looks them up here: a Durable Object or - * Workflow class has to be an export of the Worker's entrypoint for the - * `class_name`s in wrangler.jsonc to resolve. - */ +/** Workflow and Durable Object classes must be exported here for wrangler.jsonc's `class_name`s to resolve. */ export { LoadDocumentWorkflow } from "./features/load/workflows"; export { RenderThumbnailWorkflow } from "./features/thumbnails/render-workflow"; export { LiveUpdates } from "./features/live/live-updates"; @@ -16,10 +12,7 @@ const app = createApp(productionAuth); export default { fetch: app.fetch, - /** - * The daily cron in wrangler.jsonc. Thumbnails outlive what shows them, and - * no load sees the whole library any more to clear them as it finishes. - */ + /** The daily cron in wrangler.jsonc. */ scheduled(_controller, env, ctx) { ctx.waitUntil( reconcileThumbnails(env.BLOB, getDb(env.DB)).then((result) => { diff --git a/src/backend/lib/api-error.ts b/src/backend/lib/api-error.ts index d04c6f001..6f7e11cb8 100644 --- a/src/backend/lib/api-error.ts +++ b/src/backend/lib/api-error.ts @@ -1,10 +1,6 @@ -/** - * The one shape every failed /api response takes; `kind` tells the client what - * to do about it. A leaf module the frontend imports. - */ +/** Every failed /api response; `kind` tells the client what to do. A leaf the frontend imports. */ import { HttpStatus } from "http-status-ts"; -// Type-only, so this stays a leaf the frontend can import: the statuses that -// can carry a body, which is every status an error of ours is sent with. +// Type-only, to stay a leaf. import type { ContentfulStatusCode } from "hono/utils/http-status"; export enum ApiErrorKind { diff --git a/src/backend/lib/cache.ts b/src/backend/lib/cache.ts index b1ca9c308..9cf5c7724 100644 --- a/src/backend/lib/cache.ts +++ b/src/backend/lib/cache.ts @@ -30,10 +30,7 @@ function cacheControl(policy: CachePolicy): string { : immutableCacheControl(policy); } -/** - * For a route whose answers differ: the same url can serve bytes it pins and a - * stand-in it does not. One whose answers are alike takes {@link cacheMiddleware}. - */ +/** For a route whose answers differ in cacheability; otherwise use {@link cacheMiddleware}. */ export function setCache(response: Response, policy: CachePolicy): Response { response.headers.set("Cache-Control", cacheControl(policy)); return response; @@ -51,8 +48,7 @@ export function cacheMiddleware( } return async (c, next) => { - // An immutable response has to be pinned by something, or the next - // version of it is unreachable behind the cache. + // Unpinned, the next version would be unreachable behind the cache. if (!c.req.query("v")) { throw internalError( "Missing cache version", diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 978bf19bd..673a2ab5d 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -45,10 +45,7 @@ export interface AppContextEnv { export type AppContext = Context<AppContextEnv>; -/** - * Who is making the request and what they may do. Resolved lazily, so a route that - * asks nothing calls Onshape not at all, and per request, so a test can answer. - */ +/** Lazy, so a route that asks nothing never calls Onshape; per request, so tests can stub it. */ interface RequestAuth { getOnshapeApi: () => Promise<OAuthApi>; getUserId: () => Promise<string>; diff --git a/src/backend/lib/errors.ts b/src/backend/lib/errors.ts index f5a3a9149..638a35c21 100644 --- a/src/backend/lib/errors.ts +++ b/src/backend/lib/errors.ts @@ -12,10 +12,7 @@ import { } from "./api-error"; import type { AppContextEnv } from "./context"; -/** - * What we are willing to say about an Onshape failure. Anything not named here - * is ours to explain, not Onshape's, so it stays generic. - */ +/** Anything not named here stays generic. */ function fromOnshapeError(error: OnshapeApiError): ApiError { if (error instanceof OnshapeRateLimitError) { return handledError( @@ -50,8 +47,7 @@ export const errorHandler: ErrorHandler<AppContextEnv> = (err, c) => { } return c.json(apiError.body, apiError.status); } - // A raw HTTPException is a validator rejecting a malformed request, which - // is our bug rather than something the user can act on. + // A validator rejecting a malformed request: our bug, not the user's. if (err instanceof HTTPException) { console.error(err); return c.json( diff --git a/src/backend/lib/errors.worker.test.ts b/src/backend/lib/errors.worker.test.ts index 67deec27d..b60e0bccf 100644 --- a/src/backend/lib/errors.worker.test.ts +++ b/src/backend/lib/errors.worker.test.ts @@ -46,8 +46,6 @@ describe("api error responses", () => { }); }); - // A malformed request is our bug, so the client falls back to its own - // wording rather than showing a validator's message. it("marks a rejected request as internal", async () => { const app = createTestApp({ accessLevel: AccessLevel.ADMIN }); diff --git a/src/backend/lib/limiter.ts b/src/backend/lib/limiter.ts index 406411d53..38596bb20 100644 --- a/src/backend/lib/limiter.ts +++ b/src/backend/lib/limiter.ts @@ -1,10 +1,7 @@ /** Runs a task, waiting for a slot when the limiter is full. */ export type Limiter = <T>(task: () => Promise<T>) => Promise<T>; -/** - * Runs at most `max` tasks at once, queueing the rest in call order, so a - * rate-limit burst only hits the running few. - */ +/** Queues the rest in call order, so a rate-limit burst only hits the running few. */ export function createLimiter(max: number): Limiter { let active = 0; const queue: (() => void)[] = []; diff --git a/src/backend/lib/onshape/client.test.ts b/src/backend/lib/onshape/client.test.ts index 9735b3110..8dd3dc0fe 100644 --- a/src/backend/lib/onshape/client.test.ts +++ b/src/backend/lib/onshape/client.test.ts @@ -44,8 +44,6 @@ describe("OnshapeApi error handling", () => { it("aborts a call that never answers", async () => { const api = new TestApi(new Response("{}")); await api.get("/x"); - // Nothing here waits a minute, so this asserts the signal is armed - // rather than that it fires. expect(api.lastInit?.signal).toBeInstanceOf(AbortSignal); expect(api.lastInit?.signal?.aborted).toBe(false); }); diff --git a/src/backend/lib/onshape/client.ts b/src/backend/lib/onshape/client.ts index ce8004bac..f5e79047b 100644 --- a/src/backend/lib/onshape/client.ts +++ b/src/backend/lib/onshape/client.ts @@ -5,8 +5,6 @@ import { type PostOptions } from "../query-params"; -// Constant across all environments (dev/cert/production), so hardcoded here -// rather than duplicated as a per-environment var in wrangler.jsonc. const ONSHAPE_API_BASE_PATH = "https://cad.onshape.com"; const ONSHAPE_API_VERSION = 16; @@ -27,22 +25,12 @@ export class OnshapeApiError extends Error { /** Fallback wait when a 429 response omits (or malforms) the Retry-After header. */ const DEFAULT_RETRY_AFTER_SECONDS = 60; -/** - * Ceiling on a single Onshape call, so a socket that never answers surfaces as a - * retryable failure instead of being left to whatever is waiting on it. Well - * above any call we make — a rate limit answers in milliseconds — and well under - * the ten minutes a workflow step gets, so the step still has room to retry. - */ +/** Well under a workflow step's ten minutes, so the step can still retry. */ const REQUEST_TIMEOUT_MS = 60_000; /** - * Thrown on a 429, carrying Onshape's `Retry-After` seconds so callers can wait - * it out. Extends {@link OnshapeApiError}, so `status` handling still works. - * - * The wait is also spelled into the message, because the message is all that - * survives a Workflows retry: the `delay` callback is handed an error rebuilt - * across the RPC layer, which keeps `name` and `message` but neither the - * prototype nor any own property. {@link readRetryAfterSeconds} reads it back. + * The wait is also in the message, since that's all that survives Workflows + * rebuilding the error; {@link readRetryAfterSeconds} reads it back. */ export class OnshapeRateLimitError extends OnshapeApiError { constructor( @@ -60,11 +48,7 @@ export class OnshapeRateLimitError extends OnshapeApiError { /** Matches what {@link OnshapeRateLimitError} spells into its message. */ const RETRY_AFTER_PATTERN = /Onshape API error 429 \(retry after (\d+)s\)/; -/** - * The seconds a 429 asked us to wait, or undefined when the error is not one. Reads - * the message rather than the instance, so it answers the same for an error - * Workflows rebuilt as for the one that was thrown. - */ +/** Reads the message, so it works on an error Workflows rebuilt. */ export function readRetryAfterSeconds(error: Error): number | undefined { const match = RETRY_AFTER_PATTERN.exec(error.message); return match ? Number.parseInt(match[1], 10) : undefined; @@ -88,13 +72,8 @@ export abstract class OnshapeApi { return this._call("GET", path, options); } - // Accepts any media type rather than `image/*`, matching the Python - // implementation this was ported from, which set no Accept header at all. - // Onshape answers a thumbnail it has not rendered yet with a JSON error, - // which it cannot send under `image/*` — so it replies 406 rather than the - // 404 that means "still rendering", and a caller reading status codes takes - // a slow render for a dead one. That is my best explanation for the - // intermittent 406s this code recorded and could not account for. + // Any media type: under `image/*`, a thumbnail still rendering seems to come + // back 406 instead of 404, since its JSON error can't be sent as an image. async getImage(path: string, options?: QueryOptions): Promise<ArrayBuffer> { const res = await this._call("GET", path, { ...options, @@ -205,12 +184,8 @@ export class OAuthApi extends OnshapeApi { } /** - * Signs with an API key pair instead of a user's session, for the callers that - * have no session to borrow: scripts, and reproducing a request the app made. - * - * Onshape verifies an HMAC over the request line rather than a bearer token, so - * every header the signature covers has to be the one actually sent — which is - * why these are built together rather than merged in afterwards. + * Signs with an API key pair, for scripts. Onshape verifies an HMAC over the + * request, so the signed headers have to be the ones sent. */ export class ApiKeyApi extends OnshapeApi { constructor( @@ -242,10 +217,7 @@ export class ApiKeyApi extends OnshapeApi { const contentType = "application/json"; const { pathname, search } = new URL(url); - // Lowercased because Onshape signs the folded form on both ends, so the - // request keeps its own casing. The trailing newline after the query is - // in Onshape's sample clients but not in its docs, and the keys in .env - // are dead, so this half is unverified against a live pair. + // Lowercased, as Onshape signs the folded form. Untested against a live key. const signature = await sign( this._secretKey, [ diff --git a/src/backend/lib/onshape/element-type.ts b/src/backend/lib/onshape/element-type.ts index f1771e544..f05f1b002 100644 --- a/src/backend/lib/onshape/element-type.ts +++ b/src/backend/lib/onshape/element-type.ts @@ -1,6 +1,3 @@ -/** - * The type of the Onshape tab the app is open in. - */ export enum ElementType { PART_STUDIO = "PARTSTUDIO", ASSEMBLY = "ASSEMBLY" diff --git a/src/backend/lib/onshape/endpoints/assemblies.ts b/src/backend/lib/onshape/endpoints/assemblies.ts index 12779354f..3176554f4 100644 --- a/src/backend/lib/onshape/endpoints/assemblies.ts +++ b/src/backend/lib/onshape/endpoints/assemblies.ts @@ -34,10 +34,7 @@ export function getAssembly( }); } -/** - * Adds the contents of an element tab to an assembly. For a part studio, - * `options.partTypes` defaults to PARTS and COMPOSITE_PARTS. - */ +/** For a part studio, `options.partTypes` defaults to PARTS and COMPOSITE_PARTS. */ export function addElementToAssembly( client: OnshapeApi, assemblyPath: ElementPath, @@ -59,9 +56,7 @@ export function addElementToAssembly( ...toElementApiObject(elementPath) }; - // An empty configuration is left off rather than sent as "", which Onshape - // treats the same way. A caller Onshape does need told something — a part - // studio insert is one — passes a non-empty configuration instead. + // Onshape treats an empty configuration as absent. if (configuration) { instance.configuration = configuration; } @@ -79,10 +74,7 @@ export function addElementToAssembly( return insertInstance(client, assemblyPath, instance, transform); } -/** - * What the assembly's geometry spans, in metres. Sketches are left out, so a - * marker already in the assembly does not widen it. - */ +/** In metres. Excludes sketches, so a marker doesn't widen it. */ export function getAssemblyBoundingBox( client: OnshapeApi, assemblyPath: ElementPath @@ -93,11 +85,7 @@ export function getAssemblyBoundingBox( ); } -/** - * Inserts one part studio feature — a sketch — as an instance of its own. - * Onshape takes the same instance definition as a part insert, naming the - * feature in place of the part types to include. - */ +/** Inserts a single part studio feature, such as a sketch. */ export function addFeatureToAssembly( client: OnshapeApi, assemblyPath: ElementPath, diff --git a/src/backend/lib/onshape/endpoints/metadata.ts b/src/backend/lib/onshape/endpoints/metadata.ts index 67c6f4f2e..95165e421 100644 --- a/src/backend/lib/onshape/endpoints/metadata.ts +++ b/src/backend/lib/onshape/endpoints/metadata.ts @@ -10,8 +10,7 @@ export function getElementMetadata( elementPath: ElementPath, configuration: Selection ): Promise<OnshapeMetadataObject> { - // Computed properties are expensive and unused, and indexing probes this - // once per configuration. + // Computed properties are slow, and indexing probes once per configuration. const query: Record<string, string> = { includeComputedProperties: "false" }; diff --git a/src/backend/lib/onshape/endpoints/parts.ts b/src/backend/lib/onshape/endpoints/parts.ts index 78c648648..a44cdaa03 100644 --- a/src/backend/lib/onshape/endpoints/parts.ts +++ b/src/backend/lib/onshape/endpoints/parts.ts @@ -4,10 +4,6 @@ import { type Selection } from "../../../features/configurations/contract"; import { encodeQueryConfiguration } from "../../../features/configurations/utils"; import type { OnshapePart } from "../types"; -/** - * Returns the parts of a part studio, configured by what `configuration` - * changes from the element's defaults. - */ export function getParts( client: OnshapeApi, elementPath: ElementPath, diff --git a/src/backend/lib/onshape/endpoints/thumbnails.ts b/src/backend/lib/onshape/endpoints/thumbnails.ts index c1f273462..f01d5fb55 100644 --- a/src/backend/lib/onshape/endpoints/thumbnails.ts +++ b/src/backend/lib/onshape/endpoints/thumbnails.ts @@ -16,11 +16,7 @@ export function getElementThumbnail( return client.getImage(path); } -/** - * The id Onshape renders a configured element's thumbnail under: fixed for an - * element and configuration, and asking for its bytes is what starts a render. - * Undefined when the configuration matches no insertable. - */ +/** Asking for its bytes starts the render. Undefined when no part matches the configuration. */ export async function getThumbnailId( client: OnshapeApi, elementPath: ElementPath, diff --git a/src/backend/lib/onshape/endpoints/versions.ts b/src/backend/lib/onshape/endpoints/versions.ts index 529680d31..15c4cc4b0 100644 --- a/src/backend/lib/onshape/endpoints/versions.ts +++ b/src/backend/lib/onshape/endpoints/versions.ts @@ -2,11 +2,7 @@ import { OnshapeApi } from "../client"; import { DocumentPath, toDocumentApiPath } from "../path"; import { OnshapeVersionInfo } from "../types"; -/** - * Fetches a list of versions of a document. - * - * Versions are returned in chronological order, with the oldest version ("Start") first. - */ +/** Oldest ("Start") first. */ function getVersions( client: OnshapeApi, documentPath: DocumentPath diff --git a/src/backend/lib/onshape/objects/assembly-features.ts b/src/backend/lib/onshape/objects/assembly-features.ts index c65566cf9..5d84902a9 100644 --- a/src/backend/lib/onshape/objects/assembly-features.ts +++ b/src/backend/lib/onshape/objects/assembly-features.ts @@ -46,10 +46,7 @@ function mateTypeParameter(value: string): object { }; } -/** - * Takes up to two queries. With neither instance constrained, Onshape tends to - * preserve the second one's location. - */ +/** With neither instance constrained, Onshape tends to keep the second in place. */ export function fastenMate(name: string, queries: Iterable<object>): object { return { btType: "BTMMate-64", diff --git a/src/backend/lib/onshape/objects/derive-feature.ts b/src/backend/lib/onshape/objects/derive-feature.ts index 19621e5ab..7cfe20083 100644 --- a/src/backend/lib/onshape/objects/derive-feature.ts +++ b/src/backend/lib/onshape/objects/derive-feature.ts @@ -66,8 +66,7 @@ export class DerivedFeature { return { btType: "BTMParameterQuantity-147", parameterId: parameter.id, - // As it was entered: the feature dialog shows this, so a - // typed "(2 + 3) in" should read that way there too. + // As entered, so the feature dialog shows what was typed. expression: value }; case ParameterType.BOOLEAN: diff --git a/src/backend/lib/onshape/objects/transform.ts b/src/backend/lib/onshape/objects/transform.ts index 936ebe315..9b79046ea 100644 --- a/src/backend/lib/onshape/objects/transform.ts +++ b/src/backend/lib/onshape/objects/transform.ts @@ -1,9 +1,6 @@ /** The 4×4 transforms Onshape places an assembly instance with. */ -/** - * Row-major, with the translation in the last column: Onshape's own example of a - * 1.3 m shift along x is `[1,0,0,1.3, 0,1,0,0, 0,0,1,0, 0,0,0,1]`. - */ +/** Row-major, translation in the last column. */ // prettier-ignore export const IDENTITY_TRANSFORM = [ 1, 0, 0, 0, diff --git a/src/backend/lib/onshape/path.ts b/src/backend/lib/onshape/path.ts index bd84f48ea..538cad0aa 100644 --- a/src/backend/lib/onshape/path.ts +++ b/src/backend/lib/onshape/path.ts @@ -1,5 +1,4 @@ -/** The instance kinds an Onshape path can address, as one definition: the type - * and the runtime list validators check against both derive from it. */ +/** Both the type and the runtime list derive from this. */ export const INSTANCE_TYPES = ["w", "v", "m"] as const; export type InstanceType = (typeof INSTANCE_TYPES)[number]; @@ -43,8 +42,7 @@ export function isInstancePath(path: unknown): path is InstancePath { return ( isDocumentPath(path) && typeof (path as InstancePath).instanceId === "string" && - // Checked against the literals: an unrecognized instance type builds a - // path Onshape rejects, which is worth catching at the boundary. + // An unrecognized type builds a path Onshape rejects. INSTANCE_TYPES.includes((path as InstancePath).instanceType) ); } @@ -81,10 +79,7 @@ function toInstanceTypeKey(instanceType: InstanceType): InstanceTypeKey { } } -/** - * Returns the named-ID object that Onshape API bodies/query params expect, - * e.g. `{ documentId, workspaceId }` rather than the `/d/.../w/...` path form. - */ +/** `{ documentId, workspaceId }`, as API bodies and query params expect. */ function toInstanceApiObject(path: InstancePath): Record<string, string> { return { documentId: path.documentId, diff --git a/src/backend/lib/onshape/types.ts b/src/backend/lib/onshape/types.ts index 614a37c03..e87814617 100644 --- a/src/backend/lib/onshape/types.ts +++ b/src/backend/lib/onshape/types.ts @@ -1,7 +1,4 @@ -/** - * Hand-authored subsets of the Onshape responses we use. To find a field's real - * shape, regenerate `onshape-api-reference/` — see `openapi-ts.config.ts`. - */ +/** Hand-written subsets. For a field's real shape, regenerate `onshape-api-reference/`; see `openapi-ts.config.ts`. */ import { LogicalOp, QuantityType, @@ -185,10 +182,7 @@ export interface OnshapeDocumentInfo { id: string; name: string; documentThumbnailElementId?: string; - /** - * Optional because nothing here has confirmed Onshape always sends it; the - * load throws rather than guessing when it is absent. - */ + /** Not confirmed to always be sent; the load throws when it's absent. */ defaultWorkspace?: { id: string }; } @@ -269,10 +263,7 @@ interface OnshapeSubAssembly { features: OnshapeAssemblyFeature[]; } -/** - * A part studio feature an instance was inserted from, in the assembly's - * flattened `partStudioFeatures` list — what `parts` is for part instances. - */ +/** What `parts` is for part instances. */ interface OnshapeAssemblyPsFeature { documentId?: string; elementId?: string; diff --git a/src/backend/lib/validate.ts b/src/backend/lib/validate.ts index 2ef8f34b8..fb0840cc6 100644 --- a/src/backend/lib/validate.ts +++ b/src/backend/lib/validate.ts @@ -4,10 +4,7 @@ import { HttpStatus } from "http-status-ts"; import type { ZodType } from "zod"; import { internalError } from "./api-error"; -/** - * `zValidator` with our error shape; its own body is the one that would not - * match. A malformed request is our bug, so the detail is for the logs. - */ +/** `zValidator` with our error shape. A malformed request is our bug, so details go to the logs. */ export function validate< T extends ZodType, Target extends keyof ValidationTargets diff --git a/src/frontend/components/alerts.tsx b/src/frontend/components/alerts.tsx index 1ddf4bff3..f1d6ea110 100644 --- a/src/frontend/components/alerts.tsx +++ b/src/frontend/components/alerts.tsx @@ -25,9 +25,8 @@ function openWarningAlert(props: OpenWarningAlertProps): void { /> ), children: ( - // Takes the focus the trap would otherwise land on Close, which - // reads as that button being pre-selected. No outline: a focus ring - // around a paragraph reads as a text box the reader can type in. + // Takes focus from Close, which would look pre-selected. No outline, which + // would make the text look editable. <Text data-autofocus tabIndex={-1} diff --git a/src/frontend/components/app-brand.tsx b/src/frontend/components/app-brand.tsx index 485ca9f0a..e05cf61af 100644 --- a/src/frontend/components/app-brand.tsx +++ b/src/frontend/components/app-brand.tsx @@ -16,8 +16,7 @@ const FRC_DESIGN_URL = "https://frcdesign.org"; const BOOK_SCALE = 2 / 3; interface AppBrandMarkProps { - /** The tile's side; the book is drawn to two thirds of it. - * @default IconSize.CONTROL */ + /** @default IconSize.CONTROL */ size?: IconSize; } @@ -30,8 +29,7 @@ export function AppBrandMark(props: AppBrandMarkProps): ReactNode { w={size} h={size} bg={PrimaryColor.FILLED} - // White on every library rather than the tile's contrast color, - // which flips to black on the lighter ones. + // The contrast color flips to black on lighter libraries. c="white" bdrs="sm" > @@ -64,7 +62,6 @@ export function AppBrand(): ReactNode { target="_blank" fw={FontWeight.BOLD} size="sm" - // The navbar's own text color, rather than a link's blue. c="inherit" td="none" > diff --git a/src/frontend/components/app-hover-card.test.tsx b/src/frontend/components/app-hover-card.test.tsx index 891f0059e..7378d3b59 100644 --- a/src/frontend/components/app-hover-card.test.tsx +++ b/src/frontend/components/app-hover-card.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../__test_utils__/render"; @@ -15,8 +15,7 @@ function renderInRow() { return openRow; } -describe("AppHoverCard", () => { - // A tap on a phone used to open the card and the row it sits in at once. +describe("AppHoverCard on a touchscreen", () => { it("opens on a click without the row seeing it", async () => { const user = userEvent.setup(); const openRow = renderInRow(); @@ -37,8 +36,6 @@ describe("AppHoverCard", () => { expect(openRow).not.toHaveBeenCalled(); }); - // That the dismissing click lands on the overlay rather than a row is - // layout, which jsdom does not do; it was checked in a touch browser. it("closes on a click outside", async () => { const user = userEvent.setup(); renderInRow(); @@ -51,3 +48,40 @@ describe("AppHoverCard", () => { }); }); }); + +describe("AppHoverCard with a mouse", () => { + beforeEach(() => { + vi.spyOn(window, "matchMedia").mockImplementation( + (query) => + ({ + matches: query === "(hover: hover)", + media: query, + addEventListener: () => undefined, + removeEventListener: () => undefined + }) as unknown as MediaQueryList + ); + }); + afterEach(() => vi.restoreAllMocks()); + + it("opens on hover and closes when the pointer leaves", async () => { + const user = userEvent.setup(); + renderInRow(); + + await user.hover(screen.getByText("badge")); + expect(await screen.findByText("card")).not.toBeNull(); + + await user.unhover(screen.getByText("badge")); + await waitFor(() => { + expect(screen.queryByText("card")).toBeNull(); + }); + }); + + it("keeps a click on the target from the row", async () => { + const user = userEvent.setup(); + const openRow = renderInRow(); + + await user.click(screen.getByText("badge")); + + expect(openRow).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/components/app-hover-card.tsx b/src/frontend/components/app-hover-card.tsx index e955c4cee..5b50f7dd7 100644 --- a/src/frontend/components/app-hover-card.tsx +++ b/src/frontend/components/app-hover-card.tsx @@ -1,25 +1,6 @@ -import { Box, Popover, type PopoverProps } from "@mantine/core"; -import { - createContext, - type MouseEvent, - type PointerEvent, - type ReactNode, - use, - useCallback, - useEffect, - useRef, - useState -} from "react"; - -const CloseHoverCardContext = createContext<() => void>(() => undefined); - -/** - * Closes the card a control is rendered inside. For a control that opens a - * modal: the pointer never leaves a card an overlay covers, so it would stay. - */ -export function useCloseHoverCard(): () => void { - return use(CloseHoverCardContext); -} +import { Box, HoverCard, Popover, type PopoverProps } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import type { MouseEvent, ReactNode } from "react"; interface AppHoverCardProps extends Pick< PopoverProps, @@ -27,7 +8,6 @@ interface AppHoverCardProps extends Pick< > { /** What is hovered or tapped. Wrapped, so it need not take a ref. */ target: ReactNode; - /** The card's content. */ children: ReactNode; /** @default "md" */ padding?: string; @@ -37,121 +17,66 @@ interface AppHoverCardProps extends Pick< closeDelay?: number; } -/** - * A card that opens on hover and on a click or tap, which is the only way to - * reach one on a touchscreen. A click pins it open until clicked again or - * dismissed, so a mouse leaving it does not close what was asked for. - * - * The click is kept from the row underneath: Mantine's `HoverCard` opens on - * the mouse events a tap emulates and lets the tap through, so on a phone the - * same tap opened the card and the row. - */ -/** - * For what is portaled out of the row: React bubbles through the portal, so a - * click on the card or its overlay would otherwise reach the row too. - */ +// The card sits inside clickable rows, and React bubbles clicks out of portals. const stopPropagation = (event: MouseEvent) => event.stopPropagation(); +/** Opens on hover, or on a tap where there is no hover. */ export function AppHoverCard(props: AppHoverCardProps): ReactNode { const { target, children, padding = "md", - openDelay = 0, - closeDelay = 150, + openDelay, + closeDelay, ...popoverProps } = props; - const [opened, setOpened] = useState(false); - const [pinned, setPinned] = useState(false); - // Outlives the pin until the card has faded out: a tap's click arrives - // after the touchstart that closed the card, and has to land here too. - const [overlaid, setOverlaid] = useState(false); - const timer = useRef<number | undefined>(undefined); - - useEffect(() => () => window.clearTimeout(timer.current), []); + const canHover = useMediaQuery("(hover: hover)", undefined, { + getInitialValueInEffect: false + }); - const close = useCallback(() => { - window.clearTimeout(timer.current); - setOpened(false); - setPinned(false); - }, []); - - const schedule = (next: boolean, delay: number) => { - window.clearTimeout(timer.current); - timer.current = window.setTimeout(() => setOpened(next), delay); - }; - - // Hover is a mouse's alone: a touch's pointer events arrive with its tap, - // and the click that follows decides. - const handleEnter = (event: PointerEvent) => { - if (event.pointerType === "mouse") { - schedule(true, openDelay); - } + const shared = { + ...popoverProps, + // So a card beside a row on a phone is pushed on screen, not cut off. + middlewares: { flip: true, shift: { crossAxis: true, padding: 8 } }, + withinPortal: true, + shadow: "md", + withArrow: true }; - - const handleLeave = (event: PointerEvent) => { - if (event.pointerType === "mouse" && !pinned) { - schedule(false, closeDelay); - } - }; - - const handleClick = (event: MouseEvent) => { - event.stopPropagation(); - if (pinned) { - close(); - } else { - window.clearTimeout(timer.current); - setOpened(true); - setPinned(true); - setOverlaid(true); - } + const targetBox = ( + <Box component="span" display="inline-flex" onClick={stopPropagation}> + {target} + </Box> + ); + const dropdownProps = { + p: padding, + maw: "calc(100vw - 16px)", + onClick: stopPropagation }; + if (canHover) { + return ( + <HoverCard + {...shared} + openDelay={openDelay} + closeDelay={closeDelay} + > + <HoverCard.Target>{targetBox}</HoverCard.Target> + <HoverCard.Dropdown {...dropdownProps}> + {children} + </HoverCard.Dropdown> + </HoverCard> + ); + } return ( <Popover - {...popoverProps} - opened={opened} - // A click outside or Escape, whichever pinned it. - onDismiss={close} - // Invisible, and only for a pinned card: the tap that dismisses it - // lands here rather than opening whatever row is underneath. A - // hovered card has none, since the pointer leaving it is what - // closes it. - withOverlay={overlaid} - overlayProps={{ - backgroundOpacity: 0, - onClick: stopPropagation - }} - onExitTransitionEnd={() => setOverlaid(false)} - // Across as well as along, so a card beside a row on a phone is - // pushed back on screen rather than cut off at its edge. - middlewares={{ flip: true, shift: { crossAxis: true, padding: 8 } }} - withinPortal - shadow="md" - withArrow + {...shared} + // Takes the dismissing tap, so the row underneath doesn't get it too. + withOverlay + overlayProps={{ backgroundOpacity: 0, onClick: stopPropagation }} + clickOutsideEvents={["click"]} > - <Popover.Target> - <Box - component="span" - display="inline-flex" - onPointerEnter={handleEnter} - onPointerLeave={handleLeave} - onClick={handleClick} - > - {target} - </Box> - </Popover.Target> - <Popover.Dropdown - p={padding} - maw="calc(100vw - 16px)" - onPointerEnter={handleEnter} - onPointerLeave={handleLeave} - onClick={stopPropagation} - > - <CloseHoverCardContext value={close}> - {children} - </CloseHoverCardContext> - </Popover.Dropdown> + <Popover.Target>{targetBox}</Popover.Target> + <Popover.Dropdown {...dropdownProps}>{children}</Popover.Dropdown> </Popover> ); } diff --git a/src/frontend/components/app-icon.tsx b/src/frontend/components/app-icon.tsx index 1e8e79053..1ac5a502d 100644 --- a/src/frontend/components/app-icon.tsx +++ b/src/frontend/components/app-icon.tsx @@ -17,10 +17,7 @@ export interface AppIconProps label?: string; } -/** - * A Phosphor icon in a theme color. Box resolves the name, and sizes through - * `fz` because its own `style` would drop the icon's. - */ +/** Sized through `fz`, since Box's own `style` would drop the icon's. */ export function AppIcon({ icon, size = IconSize.SMALL, diff --git a/src/frontend/components/app-menu.tsx b/src/frontend/components/app-menu.tsx index 22ac90785..7b5ec6d42 100644 --- a/src/frontend/components/app-menu.tsx +++ b/src/frontend/components/app-menu.tsx @@ -8,19 +8,12 @@ import classes from "./app-menu.module.css"; interface AppContextMenuProps { menuItems: ReactNode; children: ReactNode; - /** Set when a button owns the menu, rather than a right-click on a row. */ controlledByButton?: boolean; wideMenu?: boolean; - /** - * Caps the dropdown to the room it has and scrolls it, for a list that can - * outgrow the viewport. - */ + /** Caps the dropdown to the available room and scrolls it. */ scrollable?: boolean; } -/** - * A wrapper around Menu which displays a ContextMenu. - */ export function AppContextMenu(props: AppContextMenuProps): ReactNode { const { menuItems, @@ -50,8 +43,6 @@ export function AppContextMenu(props: AppContextMenuProps): ReactNode { "contextmenu" ]} position={position} - // `size` caps the dropdown to the room Floating UI measures for it, - // so a long list scrolls itself rather than running off the bottom. middlewares={{ size: scrollable }} > {menuChildren} @@ -65,10 +56,7 @@ export function AppContextMenu(props: AppContextMenuProps): ReactNode { ); } -/** - * An explicit button which opens a menu with the given items. Used alongside - * the right-click context menu so the menu is reachable without a right-click. - */ +/** So the menu is reachable without a right-click. */ interface MenuButtonProps extends PropsWithChildren { /** Sizes the button to sit beside a full-height button, not in a card row. */ large?: boolean; @@ -100,10 +88,6 @@ interface MenuSectionProps extends PropsWithChildren { color?: StatusColor; } -/** - * A run of menu items under a label naming them. Every item in a menu belongs - * to one, so a dropdown reads as a few short lists rather than one long one. - */ export function MenuSection(props: MenuSectionProps): ReactNode { const { label, color, children } = props; return ( @@ -114,10 +98,7 @@ export function MenuSection(props: MenuSectionProps): ReactNode { ); } -/** - * The admin-only items of a menu, listed in place rather than behind a submenu: - * one hover less to reach them, and the label is what marks them as admin. - */ +/** In place rather than in a submenu; the label marks them as admin. */ export function AdminMenuSection(props: PropsWithChildren): ReactNode { return ( <RequireAccessLevel> diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx index a616be602..91f658ff5 100644 --- a/src/frontend/components/app-modal.tsx +++ b/src/frontend/components/app-modal.tsx @@ -13,8 +13,7 @@ export const APP_MODAL_CLASSES = { /** What every modal's content sits in, so its body can scroll. */ export function AppModalContent(props: PropsWithChildren): ReactNode { - // `data-autofocus` takes the focus the trap would otherwise land on the - // first control, which reads as that one being pre-selected. + // Otherwise the focus trap lands on the first control, which looks pre-selected. return ( <div data-autofocus tabIndex={-1} className={classes.fill}> {props.children} @@ -32,11 +31,7 @@ interface AppModalProps extends PropsWithChildren { dismissible?: boolean; } -/** - * A modal held open by state rather than by the manager, framed like the rest - * of the app; `openAppModal` is the imperative half. Its body belongs in an - * `AppModalBody`, and its actions, when it has any, in an `AppModalFooter`. - */ +/** Held open by state; `openAppModal` is the imperative version. */ export function AppModal(props: AppModalProps): ReactNode { const { opened, @@ -64,10 +59,7 @@ export function AppModal(props: AppModalProps): ReactNode { ); } -/** - * Content pinned between the header and the scrolling body, like a preview - * image. The body below supplies the space under it. - */ +/** Pinned above the scrolling body, like a preview image. */ export function AppModalTop(props: PropsWithChildren): ReactNode { return ( <Box p="sm" pb={0} flex="0 0 auto"> @@ -81,10 +73,7 @@ interface AppModalBodyProps extends PropsWithChildren { gap?: MantineSpacing; } -/** - * A modal's content, padded away from the header and footer framing it: the - * one part of a modal that scrolls. - */ +/** The part that scrolls. */ export function AppModalBody(props: AppModalBodyProps): ReactNode { const { gap = "sm", children } = props; return ( diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 1bb70c7f1..fd0ca3da4 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -48,11 +48,7 @@ import { import { InsertLocationStatus } from "../features/insert-location/components/insert-location-status"; import styles from "../lib/styles.module.css"; -/** - * The bar every page is topped by: the brand, then whatever that page puts - * beside it. Stretched so a full-height child lands its underline on the row's - * own border. - */ +/** Stretched so a full-height child's underline lands on the row's border. */ export function NavbarRow(props: PropsWithChildren): ReactNode { const { children } = props; return ( @@ -66,8 +62,7 @@ export function NavbarRow(props: PropsWithChildren): ReactNode { > <AppBrand /> {children && ( - // Closes the brand off, so the name reads as the app rather - // than the first tab. Mantine's own all but vanishes on gray. + // Mantine's own divider all but vanishes on gray. <Divider orientation="vertical" my="sm" @@ -79,10 +74,6 @@ export function NavbarRow(props: PropsWithChildren): ReactNode { ); } -/** - * Provides top-level navigation for the app: a row of library tabs with the - * brand and settings alongside, over a row holding search and its filters. - */ export function AppNavbar(): ReactNode { return ( <Stack gap={0}> @@ -103,14 +94,9 @@ export function AppNavbar(): ReactNode { ); } -/** - * Shown only when not signed in; starts the Onshape OAuth flow and returns to - * the current location, after which access-data reports the caller signed in. - */ function SignInButton(): ReactNode { const { signedIn, isPending } = useAccessData(); - // Waiting rather than assuming signed out: the placeholder would flash the - // button on every load for a caller who is already signed in. + // Otherwise the button flashes on every load for someone signed in. if (isPending || signedIn) return null; return ( @@ -160,27 +146,22 @@ function AppTabs(): ReactNode { return; } const tabId = value as AppTab; - // Write-behind: the url displays it, this only decides where - // `/init` lands next time. + // Only decides where `/init` lands next time; the url is the source of truth. updateUiState({ tabId }); navigateToTab(tabId); }} styles={{ - // Hides the line under the tab list alone; the row owns one - // that spans it. The active indicator is colored separately. + // The row draws the line under the tabs. root: { "--tab-border-color": "transparent", minWidth: 0 }, - // Three full names outgrow a narrow panel; scrolling beats - // reflowing the navbar into two rows. + // Scroll rather than wrap onto a second row in a narrow panel. list: { - // Full height, so the underline lands on the row's border - // rather than partway up a taller bar. + // So the underline lands on the row's border. height: "100%", flexWrap: "nowrap", overflowX: "auto", scrollbarWidth: "none" }, - // Pulled onto that divider, so the active tab's indicator - // replaces it rather than stacking a line above it. + // Overlaps the row's border, so the active indicator replaces it. tab: { marginBottom: -1, paddingInline: "var(--mantine-spacing-sm)" @@ -205,8 +186,7 @@ export function SettingsButton() { color={StatusColor.NEUTRAL} title="Settings" my="auto" - // The filter button's size and icon, so the navbar's two rows read - // as one set of controls. + // Matches the filter button. size="input-sm" onClick={() => openSettingsMenu()} > @@ -224,19 +204,13 @@ function selectAllInputText(ref: RefObject<HTMLInputElement | null>) { input.setSelectionRange(0, length); } -/** - * How long typing pauses before the search runs. Each query re-searches the - * index and rebuilds the list, which is enough work to be felt between - * keystrokes. - */ const SEARCH_DEBOUNCE_MS = 200; function SearchBar() { const ref = useRef<HTMLInputElement>(null); const wasFocused = useRef(false); const libraryId = useLibraryId(); - // The box owns what is typed and the stored query follows a pause later, so - // a keystroke re-renders this input rather than every list reading the query. + // Local state, so a keystroke re-renders only the input. const [query, setQuery] = useState(() => getUiState().searchQuery ?? ""); const runSearch = useDebouncedCallback( (value: string) => { @@ -246,8 +220,7 @@ function SearchBar() { { delay: SEARCH_DEBOUNCE_MS, flushOnUnmount: true } ); - // `autoFocus` fires before the ref attaches, so onFocus has nothing to select - // through on the first open and last time's query keeps the caret after it. + // `autoFocus` fires before the ref attaches, so onFocus can't select. useEffect(() => { selectAllInputText(ref); }, []); @@ -277,12 +250,8 @@ function SearchBar() { onFocus={() => { selectAllInputText(ref); }} - // A click on an unfocused input focuses it — selecting everything - // above — and then places the caret on mouseup, which collapses - // that selection again. Preventing the default only on the click - // that did the focusing keeps the select-all while leaving a click - // inside an already-focused field to put the caret where it was - // aimed. + // The mouseup of the click that focuses the input would collapse the + // select-all; later clicks place the caret normally. onMouseDown={() => { wasFocused.current = document.activeElement === ref.current; }} diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index bdff4c263..1159bd2b5 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -42,13 +42,11 @@ export function AppTitle(props: AppTitleProps): ReactNode { {rightSection} </Group> {subtitle && ( - // lh, because inheriting the title's 1 leaves no leading - // under the last line, reading low in an evenly padded header. + // Inheriting the title's line height of 1 reads low. <Group gap={4} wrap="nowrap" - // Shrinkable, so a part number long enough to overrun - // the header ellipsizes instead. + // So a long part number ellipsizes. miw={0} fz="xs" lh="xs" @@ -69,8 +67,6 @@ interface MenuTitleProps { icon?: ReactNode; } -/** A menu's header: the element name is how the part was found, the part - * number is what identifies what gets inserted. */ export function MenuTitle(props: MenuTitleProps): ReactNode { const { name, record, icon } = props; const partNumber = meaningfulPartNumber(record?.partNumber, name); @@ -92,10 +88,7 @@ interface UseMenuTitleProps extends Omit<MenuTitleProps, "name"> { name: string | undefined; } -/** - * Keeps a modal's header on the selection in view. The header is updated rather - * than rendered, being the modal's rather than the content's. - */ +/** Updates the modal's header, which belongs to the modal rather than the content. */ export function useMenuTitle(modalId: string, props: UseMenuTitleProps): void { const { name, record, icon } = props; useEffect(() => { @@ -126,8 +119,7 @@ function CopyPartNumberButton(props: CopyPartNumberButtonProps): ReactNode { <ActionIcon variant="subtle" color={copied ? "teal" : "gray"} - // Sized to the text line: taller, and the row grows, - // shifting the title above it. + // Any taller and the row grows, shifting the title. size={COPY_BUTTON_SIZE} aria-label="Copy part number" onClick={copy} diff --git a/src/frontend/components/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx index 1c969df97..c2adb88fb 100644 --- a/src/frontend/components/app-zero-state.tsx +++ b/src/frontend/components/app-zero-state.tsx @@ -16,11 +16,7 @@ interface ZeroStateProps { className?: string; } -/** - * The centered block every empty, loading and error state is built from, and - * what anything else standing in for content should use rather than laying one - * out again. - */ +/** Every empty, loading and error state is built from this. */ export function ZeroState(props: ZeroStateProps): ReactNode { const { icon, title, description, action, className } = props; @@ -70,10 +66,7 @@ function resolveDescription( return description; } -/** - * Whatever a section shows in place of its content: a failure by default, and an - * empty result or a prompt when given an `icon` and `description` of its own. - */ +/** A failure by default; pass `icon` and `description` for an empty result or a prompt. */ export function SectionNotice(props: NoticeProps): ReactNode { const { title, action, className, icon = DEFAULT_ERROR_ICON } = props; return ( @@ -92,7 +85,7 @@ interface PageNoticeProps extends NoticeProps { justifyUp?: boolean; } -/** The same, standing in for a whole page rather than one section of one. */ +/** For a whole page. */ export function PageNotice(props: PageNoticeProps): ReactNode { const { title, diff --git a/src/frontend/components/breadcrumbs.tsx b/src/frontend/components/breadcrumbs.tsx index 32601aa59..3d5a31caa 100644 --- a/src/frontend/components/breadcrumbs.tsx +++ b/src/frontend/components/breadcrumbs.tsx @@ -2,8 +2,7 @@ import { Breadcrumbs, type MantineSpacing } from "@mantine/core"; import { type ReactNode } from "react"; interface AppBreadcrumbsProps { - /** The trail in order, separated where they meet. Bare text is wrapped for - * you; an element keeps its own typography. */ + /** Bare text is wrapped; elements keep their own typography. */ children: ReactNode; /** Spacing off whatever the trail sits above. */ mb?: MantineSpacing; diff --git a/src/frontend/components/callout.tsx b/src/frontend/components/callout.tsx index fe068ac7c..3a423676b 100644 --- a/src/frontend/components/callout.tsx +++ b/src/frontend/components/callout.tsx @@ -17,11 +17,7 @@ interface CalloutProps { action?: CalloutAction; } -/** - * A note above a list or a preview, saying something about what is under it. It - * builds its own button, so no caller can style one of its own. Blue rather - * than the library accent, so it reads as a remark beside the content. - */ +/** Blue, so it reads as a remark rather than library content. */ export function Callout(props: CalloutProps): ReactNode { const { text, action } = props; @@ -43,7 +39,6 @@ export function Callout(props: CalloutProps): ReactNode { {text} </Text> {action && ( - // Outlined rather than filled, which would shout on a note. <Button variant="outline" color={StatusColor.INFO} diff --git a/src/frontend/components/change-order.tsx b/src/frontend/components/change-order.tsx index 880b76d40..0c4858bda 100644 --- a/src/frontend/components/change-order.tsx +++ b/src/frontend/components/change-order.tsx @@ -141,9 +141,6 @@ function applyMoveOperation( return result; } -/** - * Given a target and an order, returns a list of currently valid operations. - */ function getValidOperations(target: string, order: string[]): MoveOperation[] { const index = order.indexOf(target); if (index === -1) { diff --git a/src/frontend/components/get-app.tsx b/src/frontend/components/get-app.tsx index 5863e4550..49bb074d9 100644 --- a/src/frontend/components/get-app.tsx +++ b/src/frontend/components/get-app.tsx @@ -5,11 +5,7 @@ import { IconSize } from "../lib/style-constants"; import { openUrlInNewTab, SETUP_URL } from "../lib/url"; import { Callout } from "./callout"; -/** - * Offers the app over the insert menu's preview, where a part somebody cannot - * insert is in front of them. Inside Onshape's panel they are already running - * it, so nothing renders there. - */ +/** Offers the app where someone sees a part they can't insert. Hidden inside Onshape. */ export function GetAppCallout(): ReactNode { const isConnected = useIsConnectedToOnshape(); diff --git a/src/frontend/components/input-row.tsx b/src/frontend/components/input-row.tsx index 5c6a7fcb9..ff0e32b6c 100644 --- a/src/frontend/components/input-row.tsx +++ b/src/frontend/components/input-row.tsx @@ -9,11 +9,7 @@ interface InputRowProps { children: ReactNode; } -/** - * A label at one end of a row and its control at the other, as a menu of - * differently shaped controls wants. Given an input's height so the row stays - * level whatever the control is. - */ +/** Given an input's height so rows stay level whatever the control. */ export function InputRow(props: InputRowProps): ReactNode { const { label, htmlFor, children } = props; return ( diff --git a/src/frontend/components/item-row.tsx b/src/frontend/components/item-row.tsx index b722277ae..55698cc91 100644 --- a/src/frontend/components/item-row.tsx +++ b/src/frontend/components/item-row.tsx @@ -1,7 +1,3 @@ -/** - * The anatomy of a list row, shared by the library, favorites and search: the - * table that holds rows, a row itself, and the title block inside it. - */ import { Group, Stack, Table, Text } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; import { meaningfulPartNumber } from "@backend/features/configurations/part-number"; @@ -12,12 +8,7 @@ import { PartNumberLink } from "./part-number"; import { mergePositions, type Position } from "../lib/highlight"; import styles from "../lib/styles.module.css"; -/** - * The configuration a row stands for, and where a query matched inside it. - * Structural rather than the search feature's own `SearchHit`: a row displays a - * match, it does not search — and a favorites row fills this from the favorite - * rather than from any query, with no positions at all. - */ +/** Not `SearchHit`: a favorites row fills this without a query. */ export interface RowMatch { /** Where the query matched inside the row's title; empty when none ran. */ positions: Position[]; @@ -162,10 +153,7 @@ function CardPartNumber(props: CardPartNumberProps): ReactNode { ); } -/** - * Groups `ItemRow`s into a single dense, hoverable table. Loading/empty/error - * states should be rendered outside of this. - */ +/** Render loading, empty and error states outside it. */ export function ItemTable(props: PropsWithChildren): ReactNode { return ( <Table @@ -191,10 +179,7 @@ interface ItemRowProps { moreButton?: boolean; } -/** - * A clickable table row with a hover state and a right-click context menu. - * Used for documents, insertables, and favorites. Render inside an `ItemTable`. - */ +/** Render inside an `ItemTable`. */ export function ItemRow(props: ItemRowProps): ReactNode { const { left, menuItems, onClick, rightSection, moreButton = true } = props; @@ -231,8 +216,7 @@ function HighlightedText(props: HighlightedTextProps): ReactNode { const result: ReactNode[] = []; let currentIndex = 0; - // `mergePositions` walks an index map upward, so its runs come out - // ascending and disjoint; this reads the string in one pass on that. + // `mergePositions` returns ascending, disjoint runs. for (const { start, length } of mergePositions(positions)) { const end = start + length; if (currentIndex < start) { diff --git a/src/frontend/components/open-app-modal.tsx b/src/frontend/components/open-app-modal.tsx index 63450905e..93fdf47aa 100644 --- a/src/frontend/components/open-app-modal.tsx +++ b/src/frontend/components/open-app-modal.tsx @@ -11,10 +11,7 @@ interface OpenAppModalProps { onClose?: () => void; } -/** - * Opens a modal framed like the rest of the app. Its body is unpadded, so content - * belongs in an `AppModalBody` and actions in an `AppModalFooter`. - */ +/** The body is unpadded: put content in `AppModalBody` and actions in `AppModalFooter`. */ export function openAppModal(props: OpenAppModalProps): void { const { title, children, modalId, size, onClose } = props; modals.open({ diff --git a/src/frontend/components/open-document-items.test.tsx b/src/frontend/components/open-document-items.test.tsx index 1a52856af..5e30e780a 100644 --- a/src/frontend/components/open-document-items.test.tsx +++ b/src/frontend/components/open-document-items.test.tsx @@ -39,7 +39,6 @@ describe("OpenDocumentItems", () => { updateUiState({ server: undefined }); }); - // A favorite's link used to open the part at its defaults. it("links to the configuration it is given, as it was typed", async () => { expect(await copiedLink({ size: "large", length: "(2 + 3) in" })).toBe( "https://cad.onshape.com/documents/doc/v/ver/e/el" + diff --git a/src/frontend/components/part-number.tsx b/src/frontend/components/part-number.tsx index e62d045e1..f1cb90017 100644 --- a/src/frontend/components/part-number.tsx +++ b/src/frontend/components/part-number.tsx @@ -8,19 +8,10 @@ interface PartNumberLinkProps { /** Already-rendered text, so a caller can underline what a query matched. */ children: ReactNode; url: string; - /** - * Holds the link at its own width beside text that can outgrow the row, - * which a list row wants. A header wants the opposite: it lets a long part - * number shrink and ellipsize rather than push the title around. - */ + /** For a list row; a header lets a long part number ellipsize instead. */ noShrink?: boolean; } -/** - * A part number pointing at the vendor's page for it. `inline-flex` so the icon - * centres on the text rather than sitting on its baseline, and takes the link's - * colour by being inside it. - */ export function PartNumberLink(props: PartNumberLinkProps): ReactNode { const { children, url, noShrink = false } = props; return ( diff --git a/src/frontend/components/reload-thumbnail-item.tsx b/src/frontend/components/reload-thumbnail-item.tsx index 80e1529a8..29d5f1624 100644 --- a/src/frontend/components/reload-thumbnail-item.tsx +++ b/src/frontend/components/reload-thumbnail-item.tsx @@ -9,11 +9,7 @@ interface ReloadThumbnailMenuItemProps { target: { groupId: string } | { insertableId: string }; } -/** - * Asks Onshape for a thumbnail again. A load does not wait for one, so a - * thumbnail Onshape had not written out yet stays missing until the whole - * document is reloaded — which is a lot to do for one picture. - */ +/** A load doesn't wait for thumbnails, so this refetches one without reloading the document. */ export function ReloadThumbnailMenuItem( props: ReloadThumbnailMenuItemProps ): ReactNode { diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index 6eddc5c32..4723b728b 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -16,9 +16,6 @@ import { ReloadAllButton } from "../features/library/components/reload-all-butto import { AccessLevel } from "@backend/features/auth/access-level"; import { DEFAULT_LIBRARY } from "@backend/features/library/library-id"; -/** - * Catch-all error state for when a route below the root fails to load. - */ export function RootAppError(): ReactNode { return ( <PageNotice @@ -35,9 +32,7 @@ export function RootAppError(): ReactNode { ); } -/** - * Last-resort fallback for the ROOT route's errorComponent. - */ +/** The root route's errorComponent. */ export function RootCrash(): ReactNode { return ( <div @@ -57,21 +52,13 @@ export function RootCrash(): ReactNode { ); } -/** - * The address that missed, to hand to a developer. Onshape's panel has no - * address bar, so this page is the only place the caller can read it — and - * which url reached it is the whole diagnosis. - */ +/** Onshape's panel has no address bar, so show the url for a bug report. */ function MissedUrl(): ReactNode { - // Read at render: reaching this page is the end of a navigation, and - // leaving it unmounts rather than updates. const url = window.location.href; return ( <Group gap={4} wrap="nowrap" align="center" mt="xs" maw="100%"> <Code - // Long, and the panel is narrow, so it breaks anywhere rather - // than widening the page past its gutters. style={{ overflowWrap: "anywhere", textAlign: "left", diff --git a/src/frontend/components/status-icon.tsx b/src/frontend/components/status-icon.tsx index f2a3a3cec..16af6a7dc 100644 --- a/src/frontend/components/status-icon.tsx +++ b/src/frontend/components/status-icon.tsx @@ -23,20 +23,9 @@ const SUBJECT_SIZE = IconSize.CONTROL; /** Big enough that a tick and a warning can be told apart at a glance. */ const BADGE_SIZE = 14; -/** - * How far the badge hangs past the subject's corner. The rest of it overlaps, - * and the subject has to stay recognizable under that. - */ const BADGE_OVERHANG = 4; -/** - * A subject icon with its state badged on its bottom-right corner, told by - * shape as well as by color — a tick and a warning stay apart where green and - * yellow do not. - * - * The box is the subject's own size, so the subject lines up with the plain - * icons either side of it and the badge hangs outside it. - */ +/** A tick and a warning differ by shape, not just color. Sized to the subject so it lines up with plain icons. */ export function StatusIcon(props: StatusIconProps): ReactNode { const { icon, status, color } = props; return ( @@ -44,8 +33,7 @@ export function StatusIcon(props: StatusIconProps): ReactNode { pos="relative" w={SUBJECT_SIZE} h={SUBJECT_SIZE} - // Zero line height, or the box takes a text row's height and the - // badge sits proud of the corner it is meant to hug. + // Or the box takes a text row's height and the badge floats off the corner. className={styles.noShrink} lh={0} > diff --git a/src/frontend/components/truncated-text.tsx b/src/frontend/components/truncated-text.tsx index 5ae594945..a9a31c895 100644 --- a/src/frontend/components/truncated-text.tsx +++ b/src/frontend/components/truncated-text.tsx @@ -8,10 +8,7 @@ interface TruncatedTextProps extends TextProps { children: ReactNode; } -/** - * Names itself on hover when there is more of it than fits. Measured as the - * pointer arrives, so a list pays nothing for a question only one row asks. - */ +/** Measured on hover, so a list pays only for the row being hovered. */ export function TruncatedText(props: TruncatedTextProps): ReactNode { const { hoverText, children, ...others } = props; const ref = useRef<HTMLParagraphElement>(null); diff --git a/src/frontend/features/admin-team/components/admin-team-setting.tsx b/src/frontend/features/admin-team/components/admin-team-setting.tsx index 211780a8b..6272d064d 100644 --- a/src/frontend/features/admin-team/components/admin-team-setting.tsx +++ b/src/frontend/features/admin-team/components/admin-team-setting.tsx @@ -2,10 +2,7 @@ import { Button, Group, Stack, Text, TextInput } from "@mantine/core"; import { type ReactNode, useId, useState } from "react"; import { useAdminTeamQuery, useSetAdminTeamMutation } from "../queries"; -/** - * The owner's choice of the Onshape team that may edit this library. Its - * members, and changes to them, are what give anyone else editor access. - */ +/** The team's members get editor access. */ export function AdminTeamSetting(): ReactNode { const query = useAdminTeamQuery(); const mutation = useSetAdminTeamMutation(); diff --git a/src/frontend/features/admin-team/queries.ts b/src/frontend/features/admin-team/queries.ts index d362f0ce3..5478b8f0c 100644 --- a/src/frontend/features/admin-team/queries.ts +++ b/src/frontend/features/admin-team/queries.ts @@ -17,10 +17,7 @@ export function useAdminTeamQuery() { }); } -/** - * Sets the library's admin team, which the server pulls the members of. The - * change reaches everyone's access through the library version it bumps. - */ +/** The version bump it causes refreshes everyone's access. */ export function useSetAdminTeamMutation() { const libraryId = useLibraryId(); return useMutation({ diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index 3512f4d7f..2137dfa76 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -18,10 +18,7 @@ const DEFAULT_ACCESS_LEVEL = (import.meta.env.VITE_ACCESS_LEVEL_OVERRIDE as AccessLevel | undefined) ?? AccessLevel.USER; -/** - * What the app assumes until the server answers. Granted as well as viewed, or - * the clamp below would drop a dev override while the query is pending. - */ +/** Granted as well as viewed, or the clamp would drop a dev override while pending. */ const DEFAULT_ACCESS_DATA: AccessData = { maxAccessLevel: DEFAULT_ACCESS_LEVEL, signedIn: false @@ -38,17 +35,11 @@ export function getAccessDataQuery(libraryId: LibraryId) { /** Server access plus the level the app is currently viewed as. */ interface ResolvedAccessData extends AccessData { currentAccessLevel: AccessLevel; - /** - * While set, the rest are the placeholder — so anything rendered for a - * *signed-out* caller must wait or it flashes. Positive gates need not. - */ + /** While set, the rest are placeholders, so signed-out UI must wait or it flashes. */ isPending: boolean; } -/** - * The caller's access. The viewed level is a local choice (the settings menu can - * drop below the granted max), so it survives the query refetching on navigation. - */ +/** The viewed level is a local choice, so it survives refetches. */ export function useAccessData(): ResolvedAccessData { const libraryId = useLibraryId(); const { data, isPending } = useQuery(getAccessDataQuery(libraryId)); @@ -66,10 +57,7 @@ export function useAccessData(): ResolvedAccessData { }, [serverData, chosenLevel, isPending]); } -/** - * Whether the caller is signed in to Onshape. Reads signed out while access-data - * is pending, so a signed-out render wants useAccessData().isPending as well. - */ +/** Reads signed out while pending; see `isPending`. */ export function useIsSignedIn(): boolean { const accessData = useAccessData(); return accessData.signedIn; @@ -103,11 +91,6 @@ export function RequireSignIn(props: PropsWithChildren) { return useIsSignedIn() ? props.children : null; } -/** - * Whether the caller is shown what is hidden. The rule was spelled out at each - * of its call sites, half of them negated, so changing who counts as privileged - * meant finding four of them and getting the negation right at each. - */ export function useShowHidden(): boolean { const accessData = useAccessData(); return hasEditorAccess(accessData.currentAccessLevel); diff --git a/src/frontend/features/auth/sign-in.ts b/src/frontend/features/auth/sign-in.ts index 49a7aed2e..7fa091496 100644 --- a/src/frontend/features/auth/sign-in.ts +++ b/src/frontend/features/auth/sign-in.ts @@ -1,19 +1,14 @@ import { updateUiState } from "../../lib/ui-state"; -/** - * Redirects to the Onshape OAuth flow, returning to the app's entry point, - * which resumes where the caller left off and confirms the sign-in. - */ +/** Returns to the entry, which resumes and confirms the sign-in. */ export function startSignIn(): void { updateUiState({ justSignedIn: true }); const search = new URLSearchParams(window.location.search); const query = new URLSearchParams({ redirectUrl: "/" + window.location.search }); - // The entry redirect leaves Onshape's company in the app's url. It has to - // ride as a parameter of its own: nested inside redirectUrl the sign-in - // route never reads it, and the token comes back scoped to whichever - // account Onshape picks rather than the enterprise the caller is in. + // Its own parameter: nested in redirectUrl the sign-in route never reads it, + // and the token comes back scoped to the wrong account. const sessionCompanyId = search.get("sessionCompanyId"); if (sessionCompanyId) { query.set("sessionCompanyId", sessionCompanyId); diff --git a/src/frontend/features/auth/sign-out.ts b/src/frontend/features/auth/sign-out.ts index a7b77318d..ceee538cf 100644 --- a/src/frontend/features/auth/sign-out.ts +++ b/src/frontend/features/auth/sign-out.ts @@ -1,7 +1,4 @@ -/** - * Ends the app's own session and reloads where the caller stands, which is what - * makes the next load fetch access data as a signed-out one. - */ +/** Reloads in place, so access data is refetched signed out. */ export function startSignOut(): void { const url = new URL(window.location.href); const redirectUrl = url.pathname + url.search; diff --git a/src/frontend/features/build-status/components/admin-section.tsx b/src/frontend/features/build-status/components/admin-section.tsx index c554cae93..2bb0ef3be 100644 --- a/src/frontend/features/build-status/components/admin-section.tsx +++ b/src/frontend/features/build-status/components/admin-section.tsx @@ -118,10 +118,7 @@ interface IndexingRowProps { band: IndexingBand; } -/** - * A switch only where enabling indexing is the admin's call, an icon saying why - * not otherwise — past the cap it can't run, under the threshold it already has. - */ +/** A switch only where enabling is the admin's call; otherwise an icon says why. */ function IndexingRow(props: IndexingRowProps): ReactNode { const { insertableId, status, band } = props; const mutation = useIndexConfigurationsMutation(insertableId); @@ -168,10 +165,7 @@ interface IndexingIconProps { tooltip: string; } -/** - * Stands in for the switch where there is nothing to toggle, reusing the - * build-check icons so the state reads the same as the callouts above it. - */ +/** Reuses the build-check icons so it reads like the callouts. */ function IndexingIcon(props: IndexingIconProps): ReactNode { const { severity, tooltip } = props; return ( diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index d8b599948..429d4a19d 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -53,10 +53,6 @@ interface BuildStatusCardProps extends BuildStatusSubject { children: ReactNode; } -/** - * The hover-card content: a header (name, severity summary, last-loaded time), - * the build checks (when any), and the wrapped group/insertable admin menu. - */ function BuildStatusCard(props: BuildStatusCardProps): ReactNode { const { name, @@ -94,11 +90,7 @@ interface BuildStatusBadgeProps extends BuildStatusSubject { hoverMenu: ReactNode; } -/** - * A severity icon whose hover card shows the build-status card wrapping the - * given admin menu. Gated first, so the card and its admin controls only exist - * for an editor. - */ +/** Gated first, so the card and its admin controls only exist for an editor. */ function BuildStatusBadge(props: BuildStatusBadgeProps): ReactNode { return ( <RequireAccessLevel> @@ -130,8 +122,7 @@ function BuildStatusHoverCard({ loading ? ( <Loader size={IconSize.SMALL} /> ) : isHidden ? ( - // Nobody but an editor sees a hidden insertable, so what - // its checks say about it does not matter yet. + // Only editors see hidden insertables, so its checks don't matter yet. <AppIcon icon={EyeSlashIcon} color={StatusColor.WARNING} @@ -197,11 +188,7 @@ interface VersionAgeProps { versionCreatedAt?: number; } -/** - * How old the pinned Onshape version is — when the version was cut, not when we - * last synced it. A spinner (with a tooltip) stands in while its group loads; - * otherwise the version icon and a day count say it without a label. - */ +/** When the pinned version was cut, not when it was synced. */ function VersionAge(props: VersionAgeProps): ReactNode { const { groupId, versionCreatedAt } = props; const loading = useIsGroupLoading(groupId); diff --git a/src/frontend/features/build-status/components/issues.tsx b/src/frontend/features/build-status/components/issues.tsx index 72181d045..6bda62d6d 100644 --- a/src/frontend/features/build-status/components/issues.tsx +++ b/src/frontend/features/build-status/components/issues.tsx @@ -31,10 +31,7 @@ import { SectionHeader } from "./sections"; import { useOnshapeOrigin } from "../../../lib/onshape-params"; import styles from "../../../lib/styles.module.css"; -/** - * Stored issues plus the live "no unhidden insertables" check, which needs the - * per-insertable visibility in the same response. - */ +/** Adds the live "no unhidden insertables" check, which needs visibility. */ export function useGroupBuildIssues( groupStatus: GroupBuildStatus | undefined, insertableStatuses: Record<string, InsertableBuildStatus> | undefined @@ -44,8 +41,7 @@ export function useGroupBuildIssues( const hasUnhidden = groupStatus.insertableOrder.some( (id) => insertableStatuses?.[id]?.isVisible ); - // A group that never loaded has no insertables to unhide, so the failure - // is the whole story. + // A group that never loaded has nothing to unhide. if ( hasUnhidden || hasBuildIssue(groupStatus.buildIssues, BuildIssueType.LOAD_FAILED) @@ -193,10 +189,7 @@ function countSeverities(issues: BuildIssue[]): SeverityCounts { return counts; } -/** - * What a configuration issue opens: the tab it belongs to, and the parameters - * its values are made whole against. An element with no configurations has none. - */ +/** Undefined for an element with no configurations. */ export interface ConfigurationTarget { elementPath: ElementPath; parameters: ConfigurationParameter[]; @@ -257,11 +250,7 @@ interface IssueCalloutProps { url?: string; } -/** - * A single build issue rendered as a tinted callout box in its severity color. - * An issue that names a configuration is the link to it, whole box included — - * there is nothing else in the callout to click. - */ +/** When the issue names a configuration, the whole box links to it. */ function IssueCallout(props: IssueCalloutProps): ReactNode { const { issue, url } = props; const severity = getIssueSeverity(issue); @@ -277,8 +266,6 @@ function IssueCallout(props: IssueCalloutProps): ReactNode { } return ( - // The box is the link, so the anchor drops its own color and rule and - // lets the callout keep the severity's. <Anchor href={url} target="_blank" diff --git a/src/frontend/features/build-status/components/parsed-section.tsx b/src/frontend/features/build-status/components/parsed-section.tsx index d447f5500..cca2281e1 100644 --- a/src/frontend/features/build-status/components/parsed-section.tsx +++ b/src/frontend/features/build-status/components/parsed-section.tsx @@ -44,10 +44,7 @@ type StateRowValue = | { kind: "text"; text: string; dimmed?: boolean } | { kind: "vendors"; vendors: Vendor[] }; -/** - * Enumerated rather than stored: the same shared routine the load path uses, - * and it only runs when a hover card opens. - */ +/** Enumerated on demand, with the load path's routine, when a hover card opens. */ export function useConfigurationCount( status: InsertableBuildStatus ): ConfigurationCount { @@ -181,11 +178,7 @@ function ParameterRow(props: ParameterRowProps): ReactNode { ); } -/** - * A parameter with a role is never indexed and says which role. Otherwise only - * enums and booleans are enumerated: a part studio's can be excluded by hand, - * which an assembly's cannot. - */ +/** Only enums and booleans are enumerated, and only a part studio's can be excluded. */ function IndexedControl(props: ParameterRowProps): ReactNode { const { insertableId, status, parameter } = props; const mutation = useExcludedParametersMutation(insertableId); @@ -246,10 +239,7 @@ interface ParameterTypeBadgeProps { parameter: ConfigurationParameter; } -/** - * The parameter's type. An enum also carries its option count, and lists the - * options on hover — the values that drive its share of the configuration count. - */ +/** An enum lists its options on hover. */ function ParameterTypeBadge(props: ParameterTypeBadgeProps): ReactNode { const { parameter } = props; const isEnum = parameter.type === ParameterType.ENUM; diff --git a/src/frontend/features/build-status/components/sections.tsx b/src/frontend/features/build-status/components/sections.tsx index 0f6eb8df3..cd5282a6f 100644 --- a/src/frontend/features/build-status/components/sections.tsx +++ b/src/frontend/features/build-status/components/sections.tsx @@ -22,10 +22,7 @@ interface ControlRowProps { control: ReactNode; } -/** - * A label (+ description) and a right-aligned control. Usually a Switch, but a - * setting that isn't the admin's to make shows an icon saying why instead. - */ +/** Usually a Switch; an icon when the setting isn't the admin's to make. */ export function ControlRow(props: ControlRowProps): ReactNode { return ( <Group justify="space-between" wrap="nowrap" gap="md" align="center"> diff --git a/src/frontend/features/build-status/queries.ts b/src/frontend/features/build-status/queries.ts index f437e988b..dd07fbba7 100644 --- a/src/frontend/features/build-status/queries.ts +++ b/src/frontend/features/build-status/queries.ts @@ -16,7 +16,6 @@ import { getAppErrorHandler } from "../../lib/errors"; import { patchQuery } from "../../lib/query-cache"; import { useRefreshLibrary } from "../../lib/refresh"; import { toInsertablePath, toLibraryPath } from "../../lib/api-paths"; -import { useCloseHoverCard } from "../../components/app-hover-card"; import { type LibraryBuildStatus } from "@backend/features/build-checker/contract"; import { LibraryId } from "@backend/features/library/library-id"; import { useLibraryId } from "../../lib/library"; @@ -30,8 +29,7 @@ function getBuildStatusQuery(libraryId: LibraryId, cacheVersion: number) { apiGet("/build-status/library/" + libraryId, { cacheId: cacheVersion }), - // A toggle bumps cacheVersion (and thus this key); keep the old data on - // screen while the new version refetches so the hover card doesn't close. + // So the hover card doesn't close while the new version loads. placeholderData: keepPreviousData, staleTime: Infinity, gcTime: Infinity @@ -59,8 +57,6 @@ export function useSetVisibilityMutation( const refreshLibrary = useRefreshLibrary(); const key = useBuildStatusKey(); - const closeCard = useCloseHoverCard(); - const mutation = useMutation({ mutationKey: ["set-insertable-visibility", ...insertableIds], mutationFn: async () => { @@ -105,7 +101,6 @@ export function useSetVisibilityMutation( mutation.mutate(); return; } - closeCard(); modals.openConfirmModal({ title: "Hide elements", children: @@ -114,7 +109,7 @@ export function useSetVisibilityMutation( confirmProps: { color: "red" }, onConfirm: () => mutation.mutate() }); - }, [isVisible, closeCard, mutation]); + }, [isVisible, mutation]); return { mutate, isPending: mutation.isPending }; } @@ -159,10 +154,7 @@ export function useToggleInsertAndFastenMutation(insertableId: string) { }); } -/** - * Toggles indexing for an insertable. The Onshape call behind it - * runs long, so the toast reports the switch rather than sitting on the response. - */ +/** The Onshape call runs long, so the toast reports the switch without waiting. */ export function useIndexConfigurationsMutation(insertableId: string) { const key = useBuildStatusKey(); const refreshLibrary = useRefreshLibrary(); @@ -202,10 +194,7 @@ export function useIndexConfigurationsMutation(insertableId: string) { }); } -/** - * Sets which of a part studio's parameters indexing leaves out. Re-probes the - * part like toggling indexing does, so the toast reports the change first. - */ +/** Re-probes the part, so the toast reports the change first. */ export function useExcludedParametersMutation(insertableId: string) { const key = useBuildStatusKey(); const refreshLibrary = useRefreshLibrary(); diff --git a/src/frontend/features/dashboard/change-indicator.tsx b/src/frontend/features/dashboard/change-indicator.tsx index 8f7995051..4ebd38a7a 100644 --- a/src/frontend/features/dashboard/change-indicator.tsx +++ b/src/frontend/features/dashboard/change-indicator.tsx @@ -14,10 +14,7 @@ interface ChangeIndicatorProps { format?: (value: number) => string; } -/** - * How a measure changed, always beside the number and never without naming the - * baseline: these tiles mix windows, so a bare "+82%" would be unreadable. - */ +/** Always names the baseline, since the tiles mix windows. */ export function ChangeIndicator({ comparison, format = formatCount diff --git a/src/frontend/features/dashboard/configuration-breakdown.tsx b/src/frontend/features/dashboard/configuration-breakdown.tsx index 5833f6fad..ea3efa102 100644 --- a/src/frontend/features/dashboard/configuration-breakdown.tsx +++ b/src/frontend/features/dashboard/configuration-breakdown.tsx @@ -27,11 +27,7 @@ interface ConfigurationBreakdownProps { parameters: ConfigurationParameterUsage[]; } -/** - * Per-parameter value counts, which is how a wrong default shows itself: the - * default sitting below another value, or options nobody ever picks. One card - * per instance, so a list another choice filters is read one branch at a time. - */ +/** Shows a wrong default: one below another value, or options nobody picks. One card per instance. */ export function ConfigurationBreakdown({ parameters }: ConfigurationBreakdownProps): ReactNode { @@ -46,8 +42,6 @@ export function ConfigurationBreakdown({ return ( <Stack> {parameters.map((parameter) => ( - /* The path is what tells two instances of one parameter - apart, and what the card is titled with. */ <ParameterCard key={`${parameter.parameterId}-${parameter.path.join(">")}`} parameter={parameter} @@ -99,7 +93,6 @@ function ParameterCard({ parameter }: ParameterCardProps): ReactNode { ); } -/** Six rows or so, past which the card scrolls rather than the page. */ const VALUES_HEIGHT = 260; interface ValueRowProps { @@ -109,8 +102,7 @@ interface ValueRowProps { function ValueRow({ value, total }: ValueRowProps): ReactNode { const percent = total === 0 ? 0 : (value.count / total) * 100; - // Both are what an insert lands on with nothing picked, so both read as - // the value the rest of the list is measured against. + // Either is what an insert lands on untouched. const lands = value.isDefault || value.isImplicitDefault; return ( @@ -143,10 +135,6 @@ interface DefaultBadgeProps { value: ConfigurationValueUsage; } -/** - * Which kind of default this is, if either: the one the parameter declares, or - * the one the app falls to because the declared one is not offered here. - */ function DefaultBadge({ value }: DefaultBadgeProps): ReactNode { if (value.isImplicitDefault) { return ( diff --git a/src/frontend/features/dashboard/dashboard-navbar.tsx b/src/frontend/features/dashboard/dashboard-navbar.tsx index 86509e4f9..661455794 100644 --- a/src/frontend/features/dashboard/dashboard-navbar.tsx +++ b/src/frontend/features/dashboard/dashboard-navbar.tsx @@ -36,9 +36,6 @@ interface DashboardTabsProps { current: DashboardKey; } -/** - * Two tiers, like the panel's navbar: the dashboard over the library it reads. - */ export function DashboardNavbar(): ReactNode { const pathname = useRouterState({ select: (s) => s.location.pathname }); const current = toDashboardKey(pathname); @@ -140,8 +137,7 @@ function LibraryMenu({ dashboard }: LibraryMenuProps): ReactNode { target?.to ?? "/dashboard/library/$libraryId", params: { libraryId }, - // Dropped, not retained: the part being - // reported on belongs to the old library. + // The part belongs to the old library. search: { element: undefined } }) } @@ -219,8 +215,7 @@ function RefreshButton(): ReactNode { const TAB_STYLES = { // Hides the line under the tab list alone; the row owns one that spans it. root: { "--tab-border-color": "transparent", minWidth: 0 }, - // Full height, so the underline lands on the row's border rather than - // partway up a taller bar. + // So the underline lands on the row's border. list: { height: "100%", flexWrap: "nowrap", diff --git a/src/frontend/features/dashboard/dashboard-state.tsx b/src/frontend/features/dashboard/dashboard-state.tsx index 0c4119157..d83663cf2 100644 --- a/src/frontend/features/dashboard/dashboard-state.tsx +++ b/src/frontend/features/dashboard/dashboard-state.tsx @@ -8,10 +8,7 @@ interface DashboardStateProps { query: UseQueryResult<unknown>; } -/** - * Renders the loading/error state of a dashboard query. Views call this when - * `data` is absent, so each one doesn't repeat the same two branches. - */ +/** For when a dashboard query has no `data`. */ export function DashboardState({ query }: DashboardStateProps): ReactNode { if (query.isError) { return ( diff --git a/src/frontend/features/dashboard/derived.ts b/src/frontend/features/dashboard/derived.ts index c9ac87f48..ee9da3c2b 100644 --- a/src/frontend/features/dashboard/derived.ts +++ b/src/frontend/features/dashboard/derived.ts @@ -7,18 +7,13 @@ function ratio(numerator: number, denominator: number): number { return denominator === 0 ? 0 : numerator / denominator; } -/** - * One comparison divided by another. A rate is never more knowable than its - * counts, so an unavailable term makes the rate unavailable for the same reason. - */ +/** An unavailable term makes the rate unavailable for the same reason. */ export function perUnit( numerator: PeriodComparison, denominator: PeriodComparison ): PeriodComparison { const current = ratio(numerator.current, denominator.current); const previous = ratio(numerator.previous, denominator.previous); - // Windows and their labels come from the numerator: dividing two measures - // of the same window does not change which window it is. const base = { ...numerator, current, previous }; const unavailable = numerator.unavailable ?? denominator.unavailable; diff --git a/src/frontend/features/dashboard/format.ts b/src/frontend/features/dashboard/format.ts index b237a685c..4adc674ee 100644 --- a/src/frontend/features/dashboard/format.ts +++ b/src/frontend/features/dashboard/format.ts @@ -14,10 +14,7 @@ export function formatPercent(value: number): string { return `${value.toFixed(1)}%`; } -/** - * One number as a fraction of another. A total that is absent and one that is - * zero read the same: there is nothing to take a fraction of either way. - */ +/** An absent total reads the same as zero. */ export function formatFraction( part: number, total: number | undefined diff --git a/src/frontend/features/dashboard/growth-section.tsx b/src/frontend/features/dashboard/growth-section.tsx index 973303217..5f27d59a3 100644 --- a/src/frontend/features/dashboard/growth-section.tsx +++ b/src/frontend/features/dashboard/growth-section.tsx @@ -17,10 +17,7 @@ interface RecentSectionProps { series: DailyMetricPoint[]; } -/** - * The trailing month against the one before it: what says something useful - * before there is a second season to compare against. - */ +/** Useful before there's a second season to compare. */ export function RecentSection({ growth, series diff --git a/src/frontend/features/dashboard/health-report.tsx b/src/frontend/features/dashboard/health-report.tsx index 4ab30e557..dda27508b 100644 --- a/src/frontend/features/dashboard/health-report.tsx +++ b/src/frontend/features/dashboard/health-report.tsx @@ -13,8 +13,7 @@ interface HealthTilesProps { export function HealthTiles({ counts }: HealthTilesProps): ReactNode { const total = counts.groupCount + counts.insertableCount; - // Info issues are counted in the breakdown below rather than given a tile: - // a number nobody acts on does not deserve a quarter of the row. + // Info issues get no tile: nobody acts on them. const tiles = [ { label: "Parts", diff --git a/src/frontend/features/dashboard/implicit-default.tsx b/src/frontend/features/dashboard/implicit-default.tsx index 691ceccad..045e0914f 100644 --- a/src/frontend/features/dashboard/implicit-default.tsx +++ b/src/frontend/features/dashboard/implicit-default.tsx @@ -6,8 +6,7 @@ const REASON = "This option is the default because it is the first visible option and the default option is not present."; interface ImplicitDefaultBadgeProps { - /** Taken from the badges it stands beside, which differ between the - * breakdown cards and the low-usage table. */ + /** Matches the badges beside it. */ color?: BadgeProps["color"]; size?: BadgeProps["size"]; variant?: BadgeProps["variant"]; diff --git a/src/frontend/features/dashboard/lifetime-tiles.tsx b/src/frontend/features/dashboard/lifetime-tiles.tsx index a86f72968..5f89a849f 100644 --- a/src/frontend/features/dashboard/lifetime-tiles.tsx +++ b/src/frontend/features/dashboard/lifetime-tiles.tsx @@ -19,10 +19,6 @@ interface LifetimeTilesProps { withOpens?: boolean; } -/** - * The page's headline: an all-time value with a season-over-season change, the - * two windows a maintainer actually asks about. - */ export function LifetimeTiles({ totals, growth, diff --git a/src/frontend/features/dashboard/metrics.ts b/src/frontend/features/dashboard/metrics.ts index 491977e55..48a056443 100644 --- a/src/frontend/features/dashboard/metrics.ts +++ b/src/frontend/features/dashboard/metrics.ts @@ -17,10 +17,6 @@ type MetricKey = | "quickFraction" | "assemblyFraction"; -/** - * How one number is derived, formatted and trended, so a metric reads the same - * way wherever it appears. - */ export interface MetricDefinition { key: MetricKey; label: string; @@ -92,10 +88,7 @@ export function rangeTerms( return { numerator, denominator }; } -/** - * Folded from the same points the sparkline plots, so a tile can never disagree - * with the chart behind it. - */ +/** From the sparkline's points, so a tile can't disagree with its chart. */ function metricValue( { numerator, denominator }: MetricTerms, metric: MetricDefinition @@ -113,7 +106,6 @@ export function rangeValue( return metricValue(rangeTerms(points, metric), metric); } -/** True when the metric reads as a percentage rather than a count. */ export function isPercentage(metric: MetricDefinition): boolean { return metric.denominator !== undefined; } @@ -122,10 +114,7 @@ export interface TrendPoint extends BucketPoint { value: number; } -/** - * The value per bucket. Shares are ratioed after bucketing, or an average of - * daily percentages would over-weight quiet days. - */ +/** Shares are computed after bucketing, or quiet days would be over-weighted. */ export function toTrend( points: DailyMetricPoint[], metric: MetricDefinition, diff --git a/src/frontend/features/dashboard/parameter-path.tsx b/src/frontend/features/dashboard/parameter-path.tsx index 07beb84c6..d5e460852 100644 --- a/src/frontend/features/dashboard/parameter-path.tsx +++ b/src/frontend/features/dashboard/parameter-path.tsx @@ -6,15 +6,11 @@ import { StatusColor } from "../../lib/style-constants"; interface ParameterPathProps { /** The controlling choices, outermost first. */ path: string[]; - /** The parameter's own name, which ends the trail. The caller supplies it - * so a card can title it and a table cell can leave it as text. */ + /** Supplied by the caller, so a card can title it. */ children: ReactNode; } -/** - * A parameter under the choices it is shown beneath — "Generic › Tube size". - * The choices are dimmed so the parameter's own name is what the eye lands on. - */ +/** "Generic › Tube size", with the choices dimmed. */ export function ParameterPath({ path, children diff --git a/src/frontend/features/dashboard/parts-sort.test.ts b/src/frontend/features/dashboard/parts-sort.test.ts index 96f2c7caa..4f007f18b 100644 --- a/src/frontend/features/dashboard/parts-sort.test.ts +++ b/src/frontend/features/dashboard/parts-sort.test.ts @@ -41,8 +41,7 @@ const names = (parts: PartUsageOut[]) => parts.map((p) => p.name); describe("filterAndSort", () => { it("puts the highest rate first by default, not the highest total", () => { - // Tube has the most insertions but the lowest rate, so it drops below - // the two parts earning three a month. + // Tube has the most inserts but the lowest rate. expect(names(filterAndSort(PARTS, "", DEFAULT_SORT))).toEqual([ "Bearing", "Spacer", diff --git a/src/frontend/features/dashboard/parts-sort.ts b/src/frontend/features/dashboard/parts-sort.ts index 6eec3c508..1c5d1d067 100644 --- a/src/frontend/features/dashboard/parts-sort.ts +++ b/src/frontend/features/dashboard/parts-sort.ts @@ -7,10 +7,7 @@ export interface SortState { descending: boolean; } -/** - * Most used first, by rate rather than lifetime total: a part added last month - * should not sit below one that earned its total over three seasons. - */ +/** By rate, so a new part isn't buried under one with three seasons' total. */ export const DEFAULT_SORT: SortState = { column: "usesPerMonth", descending: true diff --git a/src/frontend/features/dashboard/parts-table.tsx b/src/frontend/features/dashboard/parts-table.tsx index 420e550f6..37b5d150d 100644 --- a/src/frontend/features/dashboard/parts-table.tsx +++ b/src/frontend/features/dashboard/parts-table.tsx @@ -26,10 +26,7 @@ import { TablePagination, usePagedRows } from "./table-pagination"; /** Small enough to sit in a row without stretching it. */ const ROW_SPARKLINE = { h: 24, w: 80 }; -/** - * Widths for every column but the first, which takes what is left: unset, the - * text columns take all the slack and strand the numbers from the sparkline. - */ +/** The first column takes the rest; unset, text columns would take the slack. */ const COLUMN_WIDTH = { group: 180, // Wide enough that the two longest headings stay on one line. diff --git a/src/frontend/features/dashboard/range-control.tsx b/src/frontend/features/dashboard/range-control.tsx index de93a12ec..aaea765fa 100644 --- a/src/frontend/features/dashboard/range-control.tsx +++ b/src/frontend/features/dashboard/range-control.tsx @@ -23,8 +23,7 @@ export function RangeControl(): ReactNode { size="xs" value={preset} onChange={(value) => { - // Mantine hands back a bare string; the presets are the only - // values it can be, but the router wants the narrower type. + // Narrows Mantine's string for the router. if (!isRangePreset(value)) return; void navigate({ to: ".", search: { range: value } }); }} diff --git a/src/frontend/features/dashboard/range.ts b/src/frontend/features/dashboard/range.ts index 2bfa554f6..ce3eb75c1 100644 --- a/src/frontend/features/dashboard/range.ts +++ b/src/frontend/features/dashboard/range.ts @@ -30,17 +30,12 @@ export function isRangePreset(value: unknown): value is RangePreset { return typeof value === "string" && value in RANGE_PRESETS; } -/** - * Resolves a preset to the concrete day bounds the API expects, ending on the - * last complete day: today is still filling, and a preset that reached into it - * put a dip at the end of every chart. - */ +/** Ends on the last complete day, since today is still filling. */ export function toDayRange(preset: RangePreset): DayRange { const to = toReportingDay(Date.now()); const { days } = RANGE_PRESETS[preset]; return { - // The app has no data before 2026, so "all time" just reaches back far. - // Both bounds are inclusive, so the -1 is what makes "7 days" seven. + // Inclusive bounds, so -1 makes "7 days" seven. from: days === undefined ? "2000-01-01" : addDays(to, -(days - 1)), to }; diff --git a/src/frontend/features/dashboard/series.ts b/src/frontend/features/dashboard/series.ts index a5fb5fc57..6bbcccd07 100644 --- a/src/frontend/features/dashboard/series.ts +++ b/src/frontend/features/dashboard/series.ts @@ -1,7 +1,4 @@ -/** - * Every series the dashboard plots, and the bucketing they share. Points keep - * the raw key beside the label: a reference line matches "2026-01", not "Jan 2026". - */ +/** Points keep the raw key beside the label, so a reference line can match "2026-01". */ import type { DailyInsertPoint, @@ -29,10 +26,7 @@ export interface BucketPoint { label: string; } -/** - * From the span the days cover, not how many there are: a sparse three-year - * series has few points but must still bucket, or its gaps compress silently. - */ +/** From the span, not the count: a sparse series must still bucket. */ export function pickGranularity(days: string[]): Granularity { if (days.length === 0) return Granularity.DAY; let first = days[0]; @@ -60,10 +54,6 @@ function weekStart(day: string): string { return date.toISOString().slice(0, 10); } -/** - * Points folded into their buckets, in key order. The three series the dashboard - * plots differ only in what they accumulate, so that is all a caller supplies. - */ export function bucketBy<Point extends { day: string }, Totals>( points: Point[], granularity: Granularity, @@ -108,10 +98,7 @@ export function formatBucket(bucket: string, granularity: Granularity): string { type ChartPoint = BucketPoint & Record<string, string | number>; -/** - * Flattens the API's per-day/per-library counts into the one-record-per-x-value - * shape charts expect, keyed by library display name so the legend reads well. - */ +/** One record per x value, keyed by library name for the legend. */ export function toChartData( series: DailyInsertPoint[], libraryIds: LibraryId[], @@ -155,10 +142,7 @@ interface Bucket { days: number; } -/** - * Folded by the chart's own rule, since two years of raw days is a smear. Users - * are averaged over a bucket, not summed: one person all week is one user. - */ +/** Users are averaged over a bucket: one person all week is one user. */ export function toSparkSeries(points: DailyMetricPoint[]): SparkSeries { const ordered = bucketBy( points, diff --git a/src/frontend/features/dashboard/sparkline.tsx b/src/frontend/features/dashboard/sparkline.tsx index f519fb1f6..e45d7cded 100644 --- a/src/frontend/features/dashboard/sparkline.tsx +++ b/src/frontend/features/dashboard/sparkline.tsx @@ -2,8 +2,7 @@ import { Sparkline } from "@mantine/charts"; import { type ReactNode } from "react"; import { PrimaryColor } from "../../lib/style-constants"; -// The charts' styles, imported where the charts are so they land in the same -// route chunk rather than the panel's bundle. +// Imported here so the styles land in the dashboard's chunk. import "@mantine/charts/styles.layer.css"; interface AppSparklineProps { @@ -13,10 +12,7 @@ interface AppSparklineProps { w?: number; } -/** - * A shape, not a chart: no axes, nothing to read a value off. Flat rather than - * absent at zero, so a row never changes height. - */ +/** Flat at zero rather than absent, so a row never changes height. */ export function AppSparkline({ data, h, w }: AppSparklineProps): ReactNode { return ( <Sparkline @@ -25,8 +21,6 @@ export function AppSparkline({ data, h, w }: AppSparklineProps): ReactNode { data={data} // Mantine's own default is blue, whatever the theme says. color={PrimaryColor.FILLED} - // The rest are departures from its defaults: a smooth curve, and a - // fainter, thinner line, since this sits behind a number. curveType="monotone" fillOpacity={0.15} strokeWidth={1.5} diff --git a/src/frontend/features/dashboard/stat-tiles.tsx b/src/frontend/features/dashboard/stat-tiles.tsx index 5e2fb1e3c..918353c58 100644 --- a/src/frontend/features/dashboard/stat-tiles.tsx +++ b/src/frontend/features/dashboard/stat-tiles.tsx @@ -12,11 +12,9 @@ interface StatTileProps { value: number; /** Rates need a decimal; counts do not. Also formats the change tooltip. */ format?: (value: number) => string; - /** How the measure changed, drawn to the right of the number — never below - * it, so a row of tiles scans as one line of numbers. */ + /** Beside the number, so a row of tiles scans as one line. */ change?: PeriodComparison; - /** The shape over the selected window, which follows the picker even when - * the value above is all time: a sparkline claims no total. */ + /** Follows the picker even when the value is all time. */ spark?: number[]; } diff --git a/src/frontend/features/dashboard/table-pagination.tsx b/src/frontend/features/dashboard/table-pagination.tsx index 24684500d..84acdb38c 100644 --- a/src/frontend/features/dashboard/table-pagination.tsx +++ b/src/frontend/features/dashboard/table-pagination.tsx @@ -11,11 +11,7 @@ interface Paged<T> { setPage: (page: number) => void; } -/** - * One page of a list. The page is clamped rather than reset, so a filter that - * shortens the list lands on its last page instead of an empty one — there is - * no effect to run, and so no render showing rows that are gone. - */ +/** Clamped rather than reset, so a shorter list lands on its last page without an effect. */ export function usePagedRows<T>(rows: T[]): Paged<T> { const [requestedPage, setRequestedPage] = useState(1); diff --git a/src/frontend/features/dashboard/treemap-chart.tsx b/src/frontend/features/dashboard/treemap-chart.tsx index 103918a90..9f7ccb0ab 100644 --- a/src/frontend/features/dashboard/treemap-chart.tsx +++ b/src/frontend/features/dashboard/treemap-chart.tsx @@ -3,8 +3,7 @@ import { type ReactNode } from "react"; import { formatCount } from "./format"; import { type TreemapNode } from "./treemap-data"; -// The charts' styles, imported where the charts are so they land in the same -// route chunk rather than the panel's bundle. +// Imported here so the styles land in the dashboard's chunk. import "@mantine/charts/styles.layer.css"; interface AppTreemapProps { @@ -16,8 +15,7 @@ interface AppTreemapProps { export function AppTreemap({ nodes, h, onSelect }: AppTreemapProps): ReactNode { return ( <Treemap - // `TreemapData` is an open record, which no union satisfies - // implicitly; the extra keys are exactly what we want carried. + // `TreemapData` is an open record, which no union satisfies implicitly. data={nodes as unknown as TreemapData[]} height={h} valueFormatter={formatCount} @@ -25,8 +23,6 @@ export function AppTreemap({ nodes, h, onSelect }: AppTreemapProps): ReactNode { treemapProps={{ // Long enough to read as a zoom, short enough not to wait. animationDuration: 300, - // Recharts types the node as an open record, so the keys the - // data carried come back untyped rather than missing. onClick: (node) => onSelect(node as unknown as TreemapNode) }} /> diff --git a/src/frontend/features/dashboard/treemap-data.test.ts b/src/frontend/features/dashboard/treemap-data.test.ts index ca5db89a8..210f3d148 100644 --- a/src/frontend/features/dashboard/treemap-data.test.ts +++ b/src/frontend/features/dashboard/treemap-data.test.ts @@ -88,8 +88,6 @@ describe("toNodes at the group level", () => { }); it("never darkens as the tiles get smaller", () => { - // Color has to reinforce area, not fight it: a lighter tile always - // means a smaller one. const many = Array.from({ length: 10 }, (_, index) => part({ groupName: `g-${index}`, insertCount: 10 - index }) ); diff --git a/src/frontend/features/dashboard/treemap-data.ts b/src/frontend/features/dashboard/treemap-data.ts index 4be7c3c72..34d16fe7a 100644 --- a/src/frontend/features/dashboard/treemap-data.ts +++ b/src/frontend/features/dashboard/treemap-data.ts @@ -9,10 +9,7 @@ export interface UsagePart extends PartUsageOut { libraryId: LibraryId; } -/** - * How far in the treemap is looking: every library, one library's groups, or - * one group's parts. Parts are leaves — clicking one leaves the chart. - */ +/** Parts are leaves: clicking one leaves the chart. */ export interface TreemapPath { libraryId?: LibraryId; groupName?: string; @@ -32,10 +29,6 @@ interface TileBase { color: string; } -/** - * One tile. Discriminated rather than a bag of optional ids, so a click reads - * the level it is on instead of guessing from which keys are set. - */ export type TreemapNode = | (TileBase & { kind: TreemapKind.LIBRARY; libraryId: LibraryId }) | (TileBase & { kind: TreemapKind.GROUP; groupName: string }) @@ -45,20 +38,14 @@ export type TreemapNode = elementId: string; }); -/** - * Shades by rank off one hue, darkest first: monotone rather than cycling, so a - * lighter tile always means a smaller one. - */ +/** Darkest first, so a lighter tile is always a smaller one. */ const SHADES = [9, 8, 7, 6, 5, 4, 3]; function shade(color: string, rank: number): string { return colorVar(color, SHADES[Math.min(rank, SHADES.length - 1)]); } -/** - * A part with no uses in the window is dropped rather than drawn: a zero-value - * tile has no area but still sits in the DOM catching clicks. - */ +/** Drops unused parts: a zero-area tile still catches clicks. */ function within(parts: UsagePart[], path: TreemapPath): UsagePart[] { return parts.filter( (part) => @@ -84,10 +71,7 @@ function totalsBy<K extends string>( .map(([key, value]) => ({ key, value })); } -/** - * The tiles at `path`. Libraries keep the colors the charts give them, and - * everything inside one shades off that library's hue. - */ +/** Inside a library, tiles shade off its chart color. */ export function toNodes(parts: UsagePart[], path: TreemapPath): TreemapNode[] { const shown = within(parts, path); diff --git a/src/frontend/features/dashboard/trend-chart.tsx b/src/frontend/features/dashboard/trend-chart.tsx index 59ef289de..3a88598fc 100644 --- a/src/frontend/features/dashboard/trend-chart.tsx +++ b/src/frontend/features/dashboard/trend-chart.tsx @@ -14,8 +14,7 @@ import { } from "./metrics"; import { formatCount, formatPercent } from "./format"; -// The charts' styles, imported where the charts are so they land in the same -// route chunk rather than the panel's bundle. +// Imported here so the styles land in the dashboard's chunk. import "@mantine/charts/styles.layer.css"; // Keeps the hover panel short enough to fit beside a tile on a laptop. @@ -102,10 +101,7 @@ export function LibraryInsertsChart({ ); } -/** - * Season markers, placed by month: a season opens and closes on month bounds, - * and the championship closing it moves within its month every year. - */ +/** By month, since the championship moves within its month each year. */ function seasonLines( programs: Program[] | undefined, points: BucketPoint[] @@ -117,8 +113,6 @@ function seasonLines( const labels = new Map<string, string[]>(); const mark = (month: string, label: string) => { - // A monthly bucket is the month; a finer one falls inside it, and the - // first such bucket is where the month begins on the axis. const bucket = buckets.find((candidate) => candidate.startsWith(month)); if (bucket === undefined) return; const existing = labels.get(bucket) ?? []; diff --git a/src/frontend/features/dashboard/trend-tile.tsx b/src/frontend/features/dashboard/trend-tile.tsx index 5bb09e3d3..9fe704254 100644 --- a/src/frontend/features/dashboard/trend-tile.tsx +++ b/src/frontend/features/dashboard/trend-tile.tsx @@ -22,10 +22,6 @@ interface TrendTileProps { series: DailyMetricPoint[]; } -/** - * One number and its trend. Leads with the range so it agrees with the - * sparkline beneath it. - */ export function TrendTile({ metric, totals, diff --git a/src/frontend/features/dashboard/usage-treemap.tsx b/src/frontend/features/dashboard/usage-treemap.tsx index 4f4a68ffe..deb88714c 100644 --- a/src/frontend/features/dashboard/usage-treemap.tsx +++ b/src/frontend/features/dashboard/usage-treemap.tsx @@ -22,10 +22,7 @@ interface UsageTreemapProps { /** Tall enough that the smaller slices still get a readable tile. */ const CHART_HEIGHT = 360; -/** - * Insertions as area, drilled by clicking. `root` is the level the breadcrumb - * cannot climb above: every library, or one of them. - */ +/** `root` is the highest level the breadcrumb can climb to. */ export function UsageTreemap({ parts, root = {} diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index 3038560bc..35a95278e 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -34,8 +34,7 @@ interface UpdateFavoritesArgs { favoriteId: string; /** The selection to store; absent means the element's own default. */ selection?: PartialSelection; - /** That selection's key, so the new row's thumbnail is right before the - * refetch answers. */ + /** So the new row's thumbnail is right before the refetch. */ configurationKey?: ConfigurationKey; } @@ -100,8 +99,7 @@ function useUpdateFavoritesMutation() { updateFavorites(data, args, libraryId) ) ); - // No router.invalidate(): the route loader prefetches favorites, - // and that fetch would race the mutation and undo this update. + // No router.invalidate(): the loader's prefetch would race this and undo it. }, onError: (error, args) => { const action = @@ -118,17 +116,11 @@ function useUpdateFavoritesMutation() { interface FavoriteButtonProps { favorite: Favorite | undefined; insertable: InsertableOut; - /** - * The selection the new favorite opens with: what the caller is showing, - * rather than the element's own default. - */ + /** Defaults to the element's own. */ selection?: PartialSelection; /** That selection's key, when the caller knows it. */ configurationKey?: ConfigurationKey; - /** - * Sizes the button to sit beside a full-height button rather than in a card row. - * @default false - */ + /** @default false */ large?: boolean; } @@ -220,20 +212,15 @@ export function FavoriteInsertableItem(props: FavoriteInsertableItemProps) { } interface FavoriteIconProps { - /** - * @default true - */ + /** @default true */ full?: boolean; - /** - * @default IconSize.SMALL - */ + /** @default IconSize.SMALL */ size?: IconSize; } export function FavoriteIcon(props: FavoriteIconProps): ReactNode { const { full = true, size = IconSize.SMALL } = props; - // fz, not size: Box builds its own `style`, dropping the font-size that - // Phosphor's `size` sets, which shrank the icon to 1em. + // fz, not size: Box drops the font-size Phosphor's `size` sets. return full ? ( <AppIcon icon={HeartIcon} @@ -247,9 +234,7 @@ export function FavoriteIcon(props: FavoriteIconProps): ReactNode { } interface UnfavoriteIconProps { - /** - * @default IconSize.SMALL - */ + /** @default IconSize.SMALL */ size?: IconSize; } diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index 51b1f6f74..f08eace3f 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -37,10 +37,6 @@ interface FavoriteCardProps { searchHit?: SearchHit; } -/** - * A card for displaying a favorited insertable directly to the user. - * Very similar in nature to an InsertableCard but with a few tweaks. - */ export function FavoriteCard(props: FavoriteCardProps): ReactNode { const { insertable, favorite, searchHit } = props; @@ -53,11 +49,8 @@ export function FavoriteCard(props: FavoriteCardProps): ReactNode { return null; } - // The part number and name come from the favorite's own configuration, not - // from whatever the query matched — the two must never disagree with the - // thumbnail beside them, which is that same configuration's. Only the title - // underlining is the search's, and favorites do not search the part-number - // or part-name fields, so nothing in those ever matched to underline. + // From the favorite's own configuration, so it matches the thumbnail. Only + // title underlining comes from the search. const rowMatch: RowMatch = { positions: searchHit?.positions ?? [], partNumber: favorite.record?.partNumber, diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 8b2bd763d..1de8f8a9c 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -48,8 +48,7 @@ export function FavoriteMenuContent( const [selection, setSelection] = useState< PartialSelection | Selection | undefined >(initialSelection); - // Undefined until the panel settles the selection, which gates saving: - // saving before then would store nothing, wiping the favorite's selection. + // Saving before the panel settles would wipe the favorite's selection. const [report, setReport] = useState<SelectionReport>(); const favorite = favoritesData?.favorites[favoriteId]; diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index 47b27ace5..c37cc2267 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -32,10 +32,7 @@ import { FavoriteIcon } from "./favorite-button"; import { startSignIn } from "../../auth/sign-in"; import { useVendorFilters } from "../../settings/components/vendor-filters"; -/** - * A list of current favorite cards. - * Unlike the normal DocumentList, this list can be searched directly. - */ +/** Unlike DocumentList, this list can be searched directly. */ export function FavoritesList(): ReactNode { const { searchQuery } = useGetUiState(); const vendorFilters = useVendorFilters(); @@ -44,8 +41,7 @@ export function FavoritesList(): ReactNode { const favoritesQuery = useFavoritesQuery(); const libraryQuery = useLibraryQuery(); - // Only once known, and ahead of the pending branch, which favorites never - // leaves while signed out: the query stays disabled rather than 401. + // Before the pending branch, which never resolves while signed out. if (!isPending && !signedIn) { return <SignInToViewFavorites />; } else if ( @@ -144,10 +140,7 @@ function FavoriteSearchResults(props: FavoriteSearchResultsProps): ReactNode { favoritedInsertableIds: new Set( Object.values(favoritesData.favorites).map((f) => f.insertableId) ), - // A favorite is one configuration, but the index's configuration fields - // describe all of them at once, so matching on those pulls a favorite up - // for a query naming a configuration the user never saved. Off until - // favorites are indexed as themselves. + // A favorite is one configuration, so other configurations' fields mustn't match. searchConfigurations: false, showHidden }); diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts index 45d5a6129..92364155b 100644 --- a/src/frontend/features/favorites/queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -28,7 +28,7 @@ function getFavoritesQuery(libraryId: LibraryId) { }); } -/** Awaits access rather than blocking the loader on it: signed out has none. */ +/** Signed out has none, so this doesn't block the loader. */ export async function prefetchFavorites(libraryId: LibraryId): Promise<void> { const { signedIn } = await queryClient.ensureQueryData( getAccessDataQuery(libraryId) @@ -38,10 +38,7 @@ export async function prefetchFavorites(libraryId: LibraryId): Promise<void> { } } -/** - * Disabled until access says signed in, the endpoint 401ing otherwise — so it - * stays pending while signed out, and callers check sign-in rather than wait. - */ +/** Stays pending while signed out, so callers check sign-in rather than wait. */ export function useFavoritesQuery() { const libraryId = useLibraryId(); const isSignedIn = useIsSignedIn(); @@ -91,8 +88,7 @@ export function useSetDefaultConfigurationMutation(favoriteId: string) { return data; }) ); - // No router.invalidate(): the route loader prefetches favorites, - // and that fetch would race the mutation and undo this update. + // No router.invalidate(): the loader's prefetch would race this and undo it. }, onError: () => { showErrorToast( @@ -130,8 +126,7 @@ export function useSetFavoriteOrderMutation() { return data; }) ); - // No router.invalidate(): the route loader prefetches favorites, - // and that fetch would race the mutation and undo this update. + // No router.invalidate(): the loader's prefetch would race this and undo it. }, onError: getAppErrorHandler( "Unexpectedly failed to reorder favorites." diff --git a/src/frontend/features/insert-location/components/insert-location-status.tsx b/src/frontend/features/insert-location/components/insert-location-status.tsx index ce7658546..d48d36a55 100644 --- a/src/frontend/features/insert-location/components/insert-location-status.tsx +++ b/src/frontend/features/insert-location/components/insert-location-status.tsx @@ -17,18 +17,12 @@ import { useInsertLocationTarget } from "../queries"; -/** - * Whether the assembly has somewhere to insert to, as a badged icon saying - * which. Renders nowhere but an assembly the caller is signed in to: a derive - * has no insert location, and the query needs a session. - */ +/** Only for an assembly the caller is signed in to. */ export function InsertLocationStatus(): ReactNode { const target = useInsertLocationTarget(); const { data, isPending, isError } = useInsertLocationQuery(target); - // Waiting rather than assuming: a badge that flips from a warning to a tick - // on every open would read as the assembly having changed. A failed read - // has not established there is none, so it says nothing at all. + // Say nothing until known: a badge that flips on every open looks like a change. if (!target || isPending || isError) { return null; } @@ -53,8 +47,6 @@ function InsertLocationHoverCard( const { target, instanceId } = props; const found = instanceId !== undefined; - // The bubble shows the state alone: unlike the bar, it is already about - // one thing, and its title says which. const stateIcon = found ? CheckIcon : WarningIcon; const stateColor = found ? StatusColor.SUCCESS : StatusColor.WARNING; diff --git a/src/frontend/features/insert-location/queries.ts b/src/frontend/features/insert-location/queries.ts index c2852b052..be2acf8f1 100644 --- a/src/frontend/features/insert-location/queries.ts +++ b/src/frontend/features/insert-location/queries.ts @@ -10,23 +10,15 @@ import { queryClient } from "../../lib/query-client"; import { insertLocationQueryKey } from "../../lib/query-keys"; import { showSuccessToast } from "../../lib/notifications"; -/** - * The assembly the insert location belongs to. Only an assembly has one: a - * derive into a part studio places itself, and there is nothing to mate to. - */ +/** Only an assembly has one; a derive places itself. */ export function useInsertLocationTarget(): TargetElement | undefined { const target = useTargetElement(); return target?.elementType === ElementType.ASSEMBLY ? target : undefined; } -/** - * Whether the open assembly has an insert location, asked once when the app - * opens. It changes only when somebody adds or deletes the connector, and the - * add below is the answer we have for the first of those. - */ +/** Asked once when the app opens; the add mutation updates it. */ export function useInsertLocationQuery(target: TargetElement | undefined) { - // Reading the assembly is Onshape's, so the endpoint needs a session; while - // signed out the query stays idle rather than answering 401. + // Idle while signed out rather than answering 401. const isSignedIn = useIsSignedIn(); return useQuery<InsertLocationOut>({ queryKey: insertLocationQueryKey(target), diff --git a/src/frontend/features/insert/components/configurations.test.tsx b/src/frontend/features/insert/components/configurations.test.tsx index 91dea541e..54063d483 100644 --- a/src/frontend/features/insert/components/configurations.test.tsx +++ b/src/frontend/features/insert/components/configurations.test.tsx @@ -71,8 +71,7 @@ function renderPanel( } describe("ConfigurationWrapper", () => { - // What was typed is what Onshape is sent, so that is what the panel keeps; - // the key it reports is canonical, for the thumbnail alone. + // Onshape is sent what was typed; the key is canonical, for the thumbnail. it("keeps a typed expression, and shows what it evaluates to", async () => { const user = userEvent.setup(); const { lastReport } = renderPanel({ @@ -157,7 +156,6 @@ describe("ConfigurationWrapper", () => { expect(lastReport().record?.partNumber).toBe("PN-LARGE"); }); - // Filled in for the person rather than by them, and kept out of the url. it("fills a derivation variable itself and keeps it read-only", async () => { const { lastReport } = renderPanel({ parameters: [derivationParam("dv"), size], diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 92ad86943..79cde5bfc 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -58,17 +58,11 @@ import { } from "../parameter-value"; import { seedFrom } from "../quantity-box"; -/** - * What the panel settled a selection into, reported because only the panel - * has the parameters each of these is measured against. - */ +/** Reported by the panel, since only it has the parameters. */ export interface SelectionReport { /** Whole, and settled against the parameters' conditions. */ selection: Selection; - /** - * Only what differs from the element's defaults, as entered, and without - * derivation variables: this is what the url keeps. - */ + /** What the url keeps. */ overrides: PartialSelection; /** Names the selection's thumbnail. */ configurationKey: ConfigurationKey; @@ -83,10 +77,7 @@ interface ConfigurationWrapperProps { selection?: PartialSelection; setSelection: Dispatch<Selection>; onReport?: (report: SelectionReport) => void; - /** - * A row was moved, as against the panel settling the selection on load. - * Any interaction counts, including picking what was already picked. - */ + /** A person changed a row, as opposed to the panel settling on load. */ onEdit?: () => void; } @@ -126,9 +117,8 @@ export function ConfigurationWrapper( const query = useConfigurationQuery(insertableId, microversionId); const parameters = query.data?.parameters; - // Whole the moment the parameters are known, since a search hit names only - // its overrides, and settled against the conditions so a row never has to - // write its own value back through an effect. + // A search hit names only its overrides, so fill in the rest and apply the + // conditions here, rather than each row writing back its own value. const whole = useMemo( () => parameters @@ -144,10 +134,8 @@ export function ConfigurationWrapper( [parameters, selection] ); - // The one place the panel writes back: the menu inserts the selection it - // holds, so settling has to reach it. `sameSelection` is what stops the - // loop, and it stops after one write only while normalizeSelection reaches - // a fixed point — see the cap it can bail out at. + // The menu inserts the selection it holds, so settling has to reach it. + // `sameSelection` stops the loop once normalizeSelection reaches a fixed point. useEffect(() => { if (whole && !sameSelection(selection, whole)) { setSelection(whole); @@ -156,8 +144,6 @@ export function ConfigurationWrapper( useReportSelection(query.data, whole, onReport); - // The rows' own writes, as against the settle above: same selection, but - // only this one is somebody configuring the part. const editSelection = useCallback( (newSelection: Selection) => { onEdit?.(); @@ -166,8 +152,7 @@ export function ConfigurationWrapper( [onEdit, setSelection] ); - // Before the spinner: a failed fetch leaves `whole` undefined too, so - // testing that first would spin forever instead of reporting the failure. + // A failed fetch also leaves `whole` undefined, so check this first. if (query.isError) { return <SectionNotice title="Failed to load selection." />; } @@ -232,11 +217,7 @@ function ParameterCells(props: ParameterCellsProps): ReactNode { ); } -/** - * At least as wide as the input and at most as wide as the screen, so a long - * option reads on one line where there is room and wraps where there is not, - * rather than wrapping inside a dropdown as narrow as a squeezed input. - */ +// Lets a long option sit on one line where there is room. const DROPDOWN_PROPS: ComboboxProps = { width: "max-content", position: "bottom-end", @@ -261,17 +242,12 @@ interface ParameterRowProps { parameters: ConfigurationParameter[]; } -/** - * One row, given its own component so its handler is a stable value. Built inside - * the `.map` it replaces, it changed identity every render — and effects name it. - */ +/** Its own component so its handler keeps a stable identity. */ function ParameterRow(props: ParameterRowProps): ReactNode { const { parameter, selection, setSelection, parameters } = props; const handleValueChange = useCallback( (newValue: string | undefined) => { - // Hands back the same selection when nothing moves, which React - // treats as no change at all. setSelection(withParameterValue(selection, parameter, newValue)); }, [parameter, selection, setSelection] @@ -306,7 +282,6 @@ function ParameterInput( return null; } - // Narrowed on `parameter` rather than `props`, which carries the union. switch (parameter.type) { case ParameterType.ENUM: return <EnumInput {...props} parameter={parameter} />; @@ -374,10 +349,6 @@ function BooleanInput(props: ParameterProps<BooleanParameter>): ReactNode { ); } -/** - * Why the field is filled in and fixed, beside it where somebody wondering - * will look. - */ const DERIVATION_VARIABLE_NOTE = "Onshape does not allow deriving the same part with the same configuration multiple times into a part studio. To avoid this limitation, Derivation Variable has been populated with a unique value."; @@ -415,12 +386,9 @@ function StringInput(props: ParameterProps<StringParameter>): ReactNode { } function QuantityInput(props: ParameterProps<QuantityParameter>): ReactNode { - // Alone among the inputs in holding its own state: the box keeps what was - // typed, and `value` re-seeds it only when it changes somewhere else. + // Keeps what was typed; `value` re-seeds it only when it changes elsewhere. const { parameter, value, onValueChange } = props; - // Its own query per box: they all share the one cached answer, and a box - // shows its own unit until the document's arrive rather than holding the - // panel up for them. + // Doesn't hold up the panel: the box shows its own unit until these arrive. const unitInfo = useUnitInfo(); const evaluateOptions = useMemo( @@ -430,17 +398,14 @@ function QuantityInput(props: ParameterProps<QuantityParameter>): ReactNode { const inputRef = useRef<HTMLInputElement>(null); const [focused, setFocused] = useState(false); - // Set when a click is what focuses the box. That click's mouseup would - // otherwise drop the selection focusing made — some browsers do, some do - // not — so a click in reads the same everywhere: the whole expression. + // Some browsers drop the focus selection on the click's mouseup, so reselect. const selectOnMouseUp = useRef(false); const [box, setBox] = useState(() => seedFrom(value, parameter, evaluateOptions) ); - // A value this box did not submit came from elsewhere — a favorite, a - // search hit — so the typed expression it replaces is no longer the value. + // A value this box didn't submit came from elsewhere, such as a favorite. const [emitted, setEmitted] = useState(value); if (value !== emitted) { setEmitted(value); @@ -463,8 +428,7 @@ function QuantityInput(props: ParameterProps<QuantityParameter>): ReactNode { expression: result.expression, display: result.displayExpression }); - // The expression, not its value: it is what Onshape is sent, so a - // typed "(2 + 3) in" reaches the derived feature as that. + // The expression, so the derived feature shows what was typed. setEmitted(result.expression); onValueChange(result.expression); }; @@ -498,8 +462,7 @@ function QuantityInput(props: ParameterProps<QuantityParameter>): ReactNode { } }} onChange={(event) => { - // Read before the updater runs: React nulls `currentTarget` - // once the handler returns, and an updater runs after that. + // React clears `currentTarget` before the updater runs. const expression = event.currentTarget.value; setBox((current) => ({ ...current, expression })); }} diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index e72847be1..19fa0ccdb 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -47,8 +47,7 @@ interface InsertMenuContentProps { /** The modal this renders in, so the header can track the selection. */ modalId: string; initialSelection?: PartialSelection; - /** That selection's key, so the preview has it before the parameters load - * and the panel reports its own. */ + /** So the preview has it before the parameters load. */ initialConfigurationKey?: ConfigurationKey; /** Every selection the menu settles on, the last being what it closed on. */ onSelectionChange?: (selection: Selection) => void; @@ -72,11 +71,9 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { DEFAULT_CONFIGURATION_KEY; // What the preview stops following for a signed-out caller. const [isEdited, setIsEdited] = useState(false); - // Whether an insert would be one a right-click could have done: cleared - // by an edit below, and by the menu having been up long enough to read. + // Cleared by an edit, or once the menu has been up long enough. const [canShowQuickInsertTip, setCanShowQuickInsertTip] = useState(true); - // A part with no parameters has one record — the element's own part data — - // which no ConfigurationWrapper is mounted to report, but the title wants. + // No ConfigurationWrapper reports a part with no parameters, but the title needs its record. const soleRecord = useConfigurationQuery( insertable.id, insertable.microversionId, @@ -94,8 +91,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { setCanShowQuickInsertTip(false); }, []); - // What the url carries, so a relaunch reopens the configuration on screen - // rather than the one the menu was opened with. + // So a relaunch reopens the configuration on screen. useEffect(() => { if (report) { updateUiState({ @@ -224,9 +220,6 @@ interface InsertButtonsProps { source: InsertSource; } -/** - * The derive/insert button plus the insert and fasten checkbox. - */ function InsertButtons(props: InsertButtonsProps): ReactNode { const { insertable, @@ -237,8 +230,6 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { onInsert } = props; - // Inserting targets the current Onshape document; there's nothing to insert - // into when the app is open standalone. const targetElementType = useTargetElementType(); const insertMutation = useInsertMutation(insertable, selection, { isFavorite, @@ -283,10 +274,7 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { /> )} <Button - // Filled rather than light: this is the menu's one action, and - // light paints the color's own shade on a tint of itself, which - // leaves green on pale green. `autoContrast` picks the label - // against a filled background, so it lands readable either way. + // Filled: light would put green on pale green. leftSection={<PlusIcon size={IconSize.SMALL} />} loading={isLoadingConfiguration || insertMutation.isPending} onClick={handleClick} diff --git a/src/frontend/features/insert/insert-tips.ts b/src/frontend/features/insert/insert-tips.ts index 2d2d5ecb5..070310a06 100644 --- a/src/frontend/features/insert/insert-tips.ts +++ b/src/frontend/features/insert/insert-tips.ts @@ -8,11 +8,7 @@ import { startSignIn } from "../auth/sign-in"; /** An insert this soon after opening didn't need anything from the menu. */ export const QUICK_INSERT_WINDOW_MS = 1500; -/** - * How long a render has to keep the menu waiting before the wait is worth - * naming. Half the window the preview itself gives up after, so this reaches - * someone mid-spinner rather than someone who merely took their time. - */ +/** Half the preview's timeout, to catch someone mid-spinner. */ const THUMBNAIL_WAIT_MS = 15000; /** Long enough to read, short enough not to follow them around. */ @@ -27,13 +23,8 @@ export function showQuickInsertTip(): void { } /** - * Points out, while they are still waiting, that the render was never what the - * insert needed. Raised on a timer rather than at the click: by the time - * somebody gives up and inserts they have already spent the wait, and telling - * them then is too late to save it. - * - * The timer restarts whenever a render does, so this is fifteen seconds on one - * selection rather than fifteen spread across several. + * Tells someone waiting that inserting doesn't need the render. On a timer, + * since by the time they insert the wait is spent; it restarts with each render. */ export function useThumbnailWaitTip(): void { const isRendering = useIsThumbnailRendering(); @@ -55,9 +46,8 @@ export function useThumbnailWaitTip(): void { } /** - * Points out to a signed-out viewer that the preview has stopped following - * them: with no Onshape session the box falls back to the stored thumbnail of - * the default. Raised on the first change, not on opening, where they agree. + * Signed out, the preview stays on the default's stored thumbnail, so say so + * on the first change. */ export function useSignInPreviewTip(isSelectionEdited: boolean): void { const { signedIn, isPending } = useAccessData(); diff --git a/src/frontend/features/insert/open-insert-menu.tsx b/src/frontend/features/insert/open-insert-menu.tsx index 1b80e9cd2..444166e22 100644 --- a/src/frontend/features/insert/open-insert-menu.tsx +++ b/src/frontend/features/insert/open-insert-menu.tsx @@ -22,8 +22,7 @@ interface OpenInsertMenuProps { insertable: InsertableOut; /** Partial for a search hit or a link, which name only some parameters. */ initialSelection?: PartialSelection; - /** That selection's key, when the caller knows it, so the preview need not - * wait on the parameters loading. */ + /** So the preview needn't wait for the parameters. */ configurationKey?: ConfigurationKey; /** The favorite this was opened from, so a relaunch can reopen it as one. */ favoriteId?: string; @@ -48,9 +47,7 @@ export function openInsertMenu(props: OpenInsertMenuProps) { let didInsert = false; // What the menu shows when it closes, for the restore toast to reopen. let lastSelection = initialSelection; - // Recorded rather than merely rendered: the url mirrors this, and a - // relaunch — an Onshape tab switch among them — reopens what it names. - // The menu narrows it to its overrides once the parameters load. + // Recorded so the url mirrors it and a relaunch reopens it. updateUiState({ openInsertableId: insertable.id, openConfiguration: initialSelection @@ -58,8 +55,7 @@ export function openInsertMenu(props: OpenInsertMenuProps) { : undefined, openFavoriteId: favoriteId }); - // Minted here so the content can address the modal it lives in, which is - // what lets the header follow the selected configuration. + // So the content can address its modal, and the header follow the selection. const id = crypto.randomUUID(); openAppModal({ modalId: id, @@ -106,8 +102,7 @@ function showRestoreToast( }) }; - // Keyed on the insertable, so opening and cancelling the same one repeatedly - // refreshes one toast rather than stacking up a column of them. + // Keyed on the insertable, so repeats refresh one toast. showInfoToast( renderNotification(`Cancelled ${insertable.name}.`, restoreButton), { id: "restore-" + insertable.id, autoClose: 3000 } diff --git a/src/frontend/features/insert/parameter-value.test.ts b/src/frontend/features/insert/parameter-value.test.ts index 3b39490f3..a4dc5ec0e 100644 --- a/src/frontend/features/insert/parameter-value.test.ts +++ b/src/frontend/features/insert/parameter-value.test.ts @@ -51,8 +51,7 @@ describe("withParameterValue", () => { it("hands back the same selection when the value already stands", () => { const selection = toSelection({ size: "large" }, PARAMS); - // Identity, not equality: it is what React compares to decide there is - // nothing to re-render. + // Identity is what React compares. expect(withParameterValue(selection, SIZE, "large")).toBe(selection); expect(withParameterValue(selection, REINFORCED, undefined)).toBe( selection @@ -60,10 +59,7 @@ describe("withParameterValue", () => { }); }); -/** - * The panel's cycle, run with the app's own functions: an effect clears a hidden - * parameter and `toSelection` puts it back. Comparing presence never settled. - */ +/** Runs the panel's settle loop with the app's own functions. */ function passesToSettle(limit = 50): number | null { let stored: PartialSelection | undefined = undefined; for (let pass = 1; pass <= limit; pass++) { @@ -150,8 +146,6 @@ describe("normalizeSelection", () => { }); it("settles a chain where one parameter decides the next", () => { - // `reinforced` is hidden unless size is large, and `bolts` unless - // reinforced — so clearing size has to reach bolts too. const bolts: ConfigurationParameter = { id: "bolts", name: "Bolts", diff --git a/src/frontend/features/insert/parameter-value.ts b/src/frontend/features/insert/parameter-value.ts index de0d4be7e..4caad05bc 100644 --- a/src/frontend/features/insert/parameter-value.ts +++ b/src/frontend/features/insert/parameter-value.ts @@ -11,10 +11,7 @@ import { getVisibleOptions } from "@backend/features/configurations/utils"; -/** - * The selection a row's change produces, or the same one when nothing moves, - * which is what lets React stop. Compares the value: presence never settles. - */ +/** Returns the same selection when nothing changes, so React can bail out. */ export function withParameterValue( selection: Selection, parameter: ConfigurationParameter, @@ -27,10 +24,7 @@ export function withParameterValue( return { ...selection, [parameter.id]: wanted }; } -/** - * The option an enum lands on: the one selected when visibility still allows it, - * then the parameter's default, then whatever is left to pick. - */ +/** The selected option if still visible, else the default, else the first. */ export function resolveSelectedOption( visibleOptions: EnumOption[], currentOptionId: string | undefined, @@ -86,15 +80,9 @@ export function sameSelection( } /** - * What the panel actually shows, with each parameter settled against the others. - * Repeated because resolving one can change what conditions on it allow. - * - * The pass cap is what bounds it, not convergence: two parameters whose - * conditions name each other could alternate forever, and no configuration - * Onshape has handed us does, but nothing here rules it out. Bailing out that - * way returns a selection that is not a fixed point, which is why the caller - * writing this back has to guard against re-running rather than assume one - * write settles it. + * Repeated because settling one parameter can change another's options. The + * pass cap stops parameters whose conditions name each other, so the result + * isn't always a fixed point. */ export function normalizeSelection( selection: Selection, diff --git a/src/frontend/features/insert/quantity-box.ts b/src/frontend/features/insert/quantity-box.ts index 84d6e4b83..3603a51c1 100644 --- a/src/frontend/features/insert/quantity-box.ts +++ b/src/frontend/features/insert/quantity-box.ts @@ -15,10 +15,7 @@ interface QuantityBox { errorMessage?: string; } -/** - * What the box shows for a value, and the error if it does not evaluate: the - * expression to edit while focused, and what it evaluates to otherwise. - */ +/** The expression while focused, its value otherwise. */ export function seedFrom( value: string | undefined, parameter: QuantityParameter, @@ -33,8 +30,7 @@ export function seedFrom( return { expression: display, display }; } const result = evaluateExpression(value, options); - // Reported in the field rather than as a toast: the field is where the - // value is, and seeding happens during render. + // In the field, since this runs during render. return result.hasError ? { expression: result.expression, diff --git a/src/frontend/features/insert/queries.ts b/src/frontend/features/insert/queries.ts index b7a7eaa78..5483a188e 100644 --- a/src/frontend/features/insert/queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -37,16 +37,11 @@ interface InsertArgs { isQuickInsert?: boolean; } -/** - * The current document's units, or undefined outside a document — and while - * they load, or should they fail to — when each quantity shows its own. - */ +/** Undefined outside a document, or until they load; quantities show their own unit meanwhile. */ export function useUnitInfo(): UnitInfo | undefined { const target = useTargetElement(); const query = useQuery<UnitInfo>({ queryKey: unitInfoQueryKey(target), - // Narrowed here rather than guarded inside, as the thumbnail queries - // are: the query function should not restate what stops it running. queryFn: target ? () => apiGet("/unit-info", { @@ -63,10 +58,7 @@ export function useUnitInfo(): UnitInfo | undefined { return query.data; } -/** - * An insertable's parameters and the records probed for them. Pinned to the - * microversion, so it is never refetched under a user mid-configuration. - */ +/** Pinned to the microversion, so it never refetches mid-configuration. */ export function useConfigurationQuery( insertableId: string, microversionId: string, @@ -102,8 +94,7 @@ export function useInsertMutation( insertArgs: InsertArgs ) { const target = useTargetElement(); - // Named, not resolved: the backend asks Onshape where the connector is now, - // since it moves whenever somebody drags it. + // The backend resolves where it is now, since it moves when dragged. const insertLocationId = useInsertLocationId(); const toastId = "insert-" + insertable.id; @@ -114,13 +105,10 @@ export function useInsertMutation( let endpoint: string; let body: Record<string, unknown>; - // Only reachable from a panel that has one: the buttons that - // start an insert do not render without a target. + // Insert buttons don't render without a target. if (!target) { throw new Error("Nothing to insert into."); } - // The tab being inserted into, sent whole so the instance type - // travels with its id rather than being reassembled. const targetPath: ElementPath = { documentId: target.documentId, instanceId: target.instanceId, @@ -165,22 +153,18 @@ export function useInsertMutation( `Unexpectedly failed to insert ${insertable.name}.`, toastId ), - // On the mate that was built, not the one that was asked for: only the - // assembly path builds one, and it answers with null when it did not. onSuccess: (result) => { - if (result.featureId === null) { - showSuccessToast( - `Successfully inserted ${insertable.name}.`, - toastId - ); - return; - } - // Always set: the insert that built the mate had one. - if (target) { + if (target && result.featureId !== undefined) { sendOpenFeatureMessage(target, result.featureId); } + // In an assembly, the only feature an insert builds is the mate. + const fastened = + result.featureId !== undefined && + target?.elementType === ElementType.ASSEMBLY; showSuccessToast( - `Successfully inserted ${insertable.name} and created a Fasten mate.`, + fastened + ? `Successfully inserted ${insertable.name} and created a Fasten mate.` + : `Successfully inserted ${insertable.name}.`, toastId ); } diff --git a/src/frontend/features/insert/restore-insert-menu.ts b/src/frontend/features/insert/restore-insert-menu.ts index cc0c434f5..fb17b44b8 100644 --- a/src/frontend/features/insert/restore-insert-menu.ts +++ b/src/frontend/features/insert/restore-insert-menu.ts @@ -7,17 +7,10 @@ import { useLibraryQuery } from "../library/queries"; import { useFavoritesQuery } from "../favorites/queries"; import { openInsertMenu } from "./open-insert-menu"; -/** - * Once per load rather than per mount: the menu is reopened because the app - * started with one recorded, and a caller who then closes it has closed it. - */ +/** Once per load: a caller who closes the restored menu has closed it. */ let restored = false; -/** - * Reopens the insert menu the app was left with — after an Onshape tab switch, - * a relaunch, or a link somebody shared. Waits for the library, which is what - * turns the stored id back into a part. - */ +/** Reopens the menu after an Onshape tab switch, a relaunch, or a shared link. */ export function useRestoreInsertMenu(): void { const { openInsertableId, openConfiguration, openFavoriteId } = useGetUiState(); @@ -29,10 +22,7 @@ export function useRestoreInsertMenu(): void { if (restored) { return; } - // Nothing was recorded, so there is nothing to wait for. Staying armed - // instead would have the first menu the caller opens themselves — - // which writes this same field — reopened on top of itself, leaving a - // second copy behind when they close the one they can see. + // Staying armed would reopen the first menu the caller opens, on top of itself. if (!openInsertableId) { restored = true; return; @@ -40,9 +30,7 @@ export function useRestoreInsertMenu(): void { if (!libraryQuery.isSuccess) { return; } - // A favorite is worth waiting for, being what decides how the menu - // opens; signed out there is nothing coming, and the query stays - // pending forever. + // Signed out, the favorites query stays pending forever. if (openFavoriteId && isSignedIn && favoritesQuery.isPending) { return; } @@ -50,8 +38,7 @@ export function useRestoreInsertMenu(): void { const insertable = libraryQuery.data.insertables[openInsertableId]; if (!insertable) { - // The library no longer has it: a part that was hidden or removed, - // or a link from a library this caller is not in. + // Hidden, removed, or from another library. updateUiState({ openInsertableId: undefined, openConfiguration: undefined, @@ -60,14 +47,12 @@ export function useRestoreInsertMenu(): void { return; } - // Somebody else's favorite resolves to nothing, which leaves the part - // and its configuration — the plain insert menu, on the same part. + // Someone else's favorite resolves to nothing, leaving the plain menu. const favorite = openFavoriteId ? favoritesQuery.data?.favorites[openFavoriteId] : undefined; - // What the url names wins over the favorite's own: it is what was on - // screen, which an edit can have moved off the favorite's selection. + // The url wins: it's what was on screen. openInsertMenu({ insertable, ...(openConfiguration diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index 8d120f469..4291e0d2f 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -30,10 +30,6 @@ import { RequireSignIn } from "../../auth/access-level"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { InsertSource } from "@backend/features/analytics/usage"; -/** - * What a search found in this row. Structural rather than the search feature's - * own `SearchHit`, which a card has no other reason to know about. - */ interface InsertableMatch extends RowMatch { /** The values of the configuration it names, for the menu. */ values?: PartialSelection; @@ -50,9 +46,6 @@ interface InsertableCardProps extends PropsWithChildren { source?: InsertSource; } -/** - * A card representing a part studio or assembly. - */ export function InsertableCard(props: InsertableCardProps): ReactNode { const { insertable, match, source = InsertSource.BROWSE } = props; @@ -94,8 +87,7 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { microversionId: insertable.microversionId, configurationKey: match?.configurationKey ?? DEFAULT_CONFIGURATION_KEY - // No renderSource: a cold search would otherwise start a render - // per row. + // No insertableId: a cold search would otherwise start a render per row. }} /> ); @@ -146,8 +138,7 @@ interface InsertableMenuItemsProps { favorite: Favorite | undefined; insertable: InsertableOut; inInsertMenu?: boolean; - /** What quick insert inserts and "Open document" opens: a search hit's - * values on a card, the selected configuration inside the insert menu. */ + /** A search hit's values on a card; the selected configuration inside the menu. */ selection?: PartialSelection; /** That selection's key, so favoriting can name its thumbnail. */ configurationKey?: ConfigurationKey; diff --git a/src/frontend/features/library/components/program-select.tsx b/src/frontend/features/library/components/program-select.tsx index 7d54674a4..f26bbac03 100644 --- a/src/frontend/features/library/components/program-select.tsx +++ b/src/frontend/features/library/components/program-select.tsx @@ -98,11 +98,7 @@ function TrademarkDisclaimer(): ReactNode { ); } -/** - * What a new user is met with: which program they build for, which becomes the - * tab the app opens in. Stored like any other synced setting, so it is asked - * once per account rather than once per browser. - */ +/** Asks a new user's program, which becomes their tab. Synced, so asked once per account. */ export function ProgramSelect(): ReactNode { const { tabId } = useGetUiState(); const navigateToTab = useNavigateToTab(); @@ -120,8 +116,7 @@ export function ProgramSelect(): ReactNode { title="Welcome to the FRCDesignApp!" description="To get started, select your library. You can switch between libraries at any time using the top navbar." action={ - // Stacked and full width: two side by side would each - // be narrower than their own name in Onshape's panel. + // Side by side, each would be narrower than its name in Onshape's panel. <Stack gap="sm" w="100%" maw={320}> {PROGRAMS.map((program) => ( <ProgramCard diff --git a/src/frontend/features/library/components/reload-all-button.tsx b/src/frontend/features/library/components/reload-all-button.tsx index 276e8d471..1d635a39c 100644 --- a/src/frontend/features/library/components/reload-all-button.tsx +++ b/src/frontend/features/library/components/reload-all-button.tsx @@ -7,11 +7,7 @@ import { AppTitle } from "../../../components/app-title"; import { IconSize, StatusColor } from "../../../lib/style-constants"; import { ReactNode } from "react"; -/** - * Force reloads every document in every library. New versions reload - * themselves, so this is for a change in how documents are read. Spoken in - * red: it spends a great deal of the account's Onshape allocation. - */ +/** Red: it spends a lot of the account's Onshape allocation. */ export function ReloadAllButton(): ReactNode { const mutation = useReloadAllMutation(); diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 68d5cf94d..19591460c 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -38,9 +38,7 @@ export function getLibraryQuery(libraryId: LibraryId, cacheVersion: number) { apiGet("/library-data/library/" + libraryId, { cacheId: cacheVersion }), - // An admin change bumps cacheVersion (and thus this key); keep the old - // snapshot on screen while the new one loads, so an edit reads as a - // merge rather than dropping the whole list back to a spinner. + // Keeps the old list up while an edit's new version loads. placeholderData: keepPreviousData, staleTime: Infinity, gcTime: Infinity @@ -74,27 +72,20 @@ export function useCacheVersion(): number { return versionQuery.data ?? 0; } -/** - * Checked once on load, then kept current by the server's pushes. `canAsk` is - * the caller's gate: the route is editor-only. - */ +/** Kept current by pushes after the first fetch. The route is editor-only. */ function getJobStatusQuery(libraryId: LibraryId, canAsk: boolean) { return queryOptions<JobStatus>({ queryKey: jobStatusQueryKey(libraryId), queryFn: () => apiGet("/job-status/library/" + libraryId), enabled: canAsk, - // Every status badge observes this, so rows mounting as the user - // scrolls would each trigger a fetch. Only a push should change it. + // Every status badge observes this; only a push should change it. staleTime: Infinity }); } const NOTHING_LOADING: string[] = []; -/** - * The groups loading in the library on screen. The endpoint is editor-only and - * needs an Onshape session, so callers who have neither see none. - */ +/** Empty for callers who aren't editors with an Onshape session. */ function useLoadingGroupIds(): string[] { const libraryId = useLibraryId(); const { signedIn, currentAccessLevel } = useAccessData(); @@ -157,8 +148,7 @@ export function useSetGroupOrderMutation() { onError: () => { showErrorToast("Unexpectedly failed to reorder group."); }, - // The bump reconciles it; a failure bumps nothing, so the patch has to - // be dropped explicitly. + // A failure bumps nothing, so the patch has to be dropped explicitly. onSettled: (_result, error) => refreshLibrary({ discardPatches: error !== null }) }); @@ -203,11 +193,7 @@ export function useAddGroupMutation(selectedGroupId?: string) { }); } -/** - * Asks Onshape for one thumbnail again. A load does not wait for thumbnails, - * so one that was not there at the time stays missing until the whole document - * is reloaded; this is how to ask for just the one. - */ +/** A load doesn't wait for thumbnails, so this refetches one that was missing. */ export function useReloadThumbnailMutation( target: { groupId: string } | { insertableId: string } ) { diff --git a/src/frontend/features/search/components/search-errors.tsx b/src/frontend/features/search/components/search-errors.tsx index 203400a7e..fd21ceaba 100644 --- a/src/frontend/features/search/components/search-errors.tsx +++ b/src/frontend/features/search/components/search-errors.tsx @@ -47,9 +47,6 @@ interface FilterCalloutProps { filtered: FilterResult; } -/** - * A callout which renders whenever there are items hidden by filters. - */ export function SearchCallout(props: FilterCalloutProps): ReactNode { const { filtered, objectLabel } = props; const searchAllDocuments = useSearchAllDocuments(); diff --git a/src/frontend/features/search/components/search-results.tsx b/src/frontend/features/search/components/search-results.tsx index 60ff7632a..304466e77 100644 --- a/src/frontend/features/search/components/search-results.tsx +++ b/src/frontend/features/search/components/search-results.tsx @@ -16,16 +16,10 @@ import { InsertSource } from "@backend/features/analytics/usage"; interface SearchResultsProps { query: string; filters: SearchFilters; - /** - * Which search this is. Required rather than defaulted: the whole point of - * telling them apart is that neither is the obvious one. - */ + /** Required: neither is the obvious default. */ source: InsertSource.SEARCH | InsertSource.GROUP_SEARCH; } -/** - * Given a valid search query and filters, returns the list of current elements. - */ export function SearchResults(props: SearchResultsProps): ReactNode { const { query, filters, source } = props; diff --git a/src/frontend/features/search/filter.ts b/src/frontend/features/search/filter.ts index 32b44bdab..aeb4aaa81 100644 --- a/src/frontend/features/search/filter.ts +++ b/src/frontend/features/search/filter.ts @@ -14,10 +14,7 @@ interface FilterArgs { visibleOnly?: boolean; } -/** - * Insertables narrowed for display, plus what the narrowing cost. Searching and - * plain filtering both produce one, so a list renders the same either way. - */ +/** Search and plain filtering both produce one, so a list renders the same either way. */ export interface FilteredInsertables { insertables: InsertableOut[]; filtered: FilterResult; @@ -25,8 +22,7 @@ export interface FilteredInsertables { hits: Record<string, SearchHit>; } -/** Ordered insertables plus the vendor-filtered count. Browsing only: an - * active search goes through `searchInsertables` instead. */ +/** Browsing only; a search goes through `searchInsertables`. */ export function filterInsertables( insertables: InsertableOut[], args: FilterArgs diff --git a/src/frontend/features/search/queries.ts b/src/frontend/features/search/queries.ts index c6e26b424..965075882 100644 --- a/src/frontend/features/search/queries.ts +++ b/src/frontend/features/search/queries.ts @@ -33,8 +33,7 @@ export function getSearchDbQuery(libraryId: LibraryId, cacheVersion: number) { SEARCH_OPTIONS ); }, - // Keyed by cacheVersion, which an admin change bumps: keep the old - // index searchable while the new one downloads. + // Keeps the old index searchable while the new one downloads. placeholderData: keepPreviousData, staleTime: Infinity, gcTime: Infinity diff --git a/src/frontend/features/search/search.test.ts b/src/frontend/features/search/search.test.ts index 127c73634..4953d89dc 100644 --- a/src/frontend/features/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -92,9 +92,7 @@ describe("doSearch part-number matching", () => { expect(hits[0].values).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. This - // insertable has no default record, so the fallback is the first listed. + // No single record matches the whole query, and there is no default record. it("falls back to the first record when no one record matches the query", () => { const searchDb = buildSearchDb(library(), recordsMap); const { hits } = search(searchDb, "Bracket 217"); @@ -102,8 +100,6 @@ describe("doSearch part-number matching", () => { expect(hits[0].partNumber).toBe("217-2600"); }); - // Neither record is the element's own defaults — every configuration here - // overrides `length` — so the first listed is all there is to show. it("attaches the first record for a title match", () => { const searchDb = buildSearchDb(library(), recordsMap); const { hits } = search(searchDb, "Bracket"); @@ -112,8 +108,7 @@ describe("doSearch part-number matching", () => { expect(hits[0].partNumber).toBe("217-2600"); }); - // Older revisions share a part number with the latest, which enumerates - // first. First-wins folding must keep that latest configuration. + // The latest revision enumerates first and shares its part number. it("resolves a shared part number to the latest (first-listed) configuration", () => { const searchDb = buildSearchDb(library(), { i1: [ @@ -127,9 +122,7 @@ describe("doSearch part-number matching", () => { }); }); -// Records arrive in option declaration order, which puts the element's own -// defaults wherever Onshape declared them — here, last. Nothing may depend on -// the default leading the list. +// The default is declared last here, so nothing may assume it leads. describe("doSearch configuration matching", () => { const searchDb = buildSearchDb(library("MAXSpline Gear"), { i1: [ @@ -150,16 +143,12 @@ describe("doSearch configuration matching", () => { expect(hits[0].values).toEqual({ teeth: "36" }); }); - // Every record's name carries the whole query, so they tie — and the tie - // has to go to the configuration the insert menu opens with. it("keeps the default when no term distinguishes a configuration", () => { const { hits } = search(searchDb, "maxspline gear"); expect(hits[0].values).toEqual({}); expect(hits[0].partNumber).toBe("WCP-1234"); }); - // The title matched and nothing else did, so there is no best record to - // pick — the row falls back, and the default is what it must fall back to. it("falls back to the default on a title-only match", () => { const { hits } = search(searchDb, "maxspline"); expect(hits[0].values).toEqual({}); @@ -172,8 +161,7 @@ describe("doSearch configuration matching", () => { }); }); -// A bare `1` prefix-matches every 1.5", 10-32 and 16T in the library; typing -// the inch mark is how a user says they mean one inch exactly. +// The inch mark says "exactly one inch"; a bare 1 prefixes 1.5", 10-32 and 16T. describe("doSearch inch sizes", () => { const names = [ '1" Hex Shaft', @@ -210,8 +198,6 @@ describe("doSearch inch sizes", () => { expect(namesFor('.5"')).toEqual(['1/2" Hex Shaft']); }); - // Without the mark there is nothing to say 1 is a size, so it stays a - // prefix — of 1.5, 10 and 16 alike, but not of the 1/2 stored as 0.5. it("leaves a bare number matching every number it starts", () => { expect(namesFor("1").sort()).toEqual( ['1" Hex Shaft', '1.5" Spacer', "10-32 Screw", "16T Pulley"].sort() @@ -219,8 +205,7 @@ describe("doSearch inch sizes", () => { }); }); -// A part number carries digits of its own, so `1` prefix-matches a segment of -// every one of them; the size in the name is what the query actually named. +// Every part number has a segment starting with 1; the name's size decides. describe("doSearch size matching", () => { const searchDb = buildSearchDb(library("Hex Standoff"), { i1: [ @@ -251,8 +236,7 @@ describe("doSearch size matching", () => { }); }); -// The same measurement is written .196, .2 and .19 across the library, so a -// part is stored as both spellings and either one finds it. +// The library writes one size as .196, .2 and .19. describe("doSearch measurements", () => { const searchDb = buildSearchDb(library("MotionX Hub"), { i1: [record("WCP-1", {}, ".196 ID x SplineXL OD")] @@ -274,8 +258,6 @@ describe("doSearch single letters", () => { }); }); -// A part number is a code: it retrieves its part whole, by either half, and -// with the zeros and separators it was written with. describe("doSearch part numbers", () => { const searchDb = buildSearchDb(library("Hex Standoff"), { i1: [ @@ -307,8 +289,6 @@ describe("doSearch part numbers", () => { ); }); - // The placeholder never reaches the index, so it matches nothing rather - // than every part an admin left it on. it("returns nothing for the placeholder", () => { const withPlaceholders = buildSearchDb(library("Spacer"), { i1: [record("N/A", {}, "Spacer")] @@ -360,8 +340,6 @@ describe("doSearch highlighting", () => { 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<string, ConfigurationRecord[]> = { i1: [record("217-2600", { length: "short" }, "Long Bearing")] @@ -383,8 +361,6 @@ describe("doSearch highlighting", () => { ).toBe("217"); }); - // A part number is indexed as typed, so the whole of what was typed - // is there in the text to underline, dash and zeros included. it("underlines a leading-zero segment of the part number", () => { const { hits } = search( buildSearchDb(library(), { @@ -447,9 +423,8 @@ describe("doSearch name matching", () => { }); }); -// A favorite names one configuration, but `partNumbers` and `partNames` cover -// every configuration the insertable has. Matching on them pulls a favorite up -// for a query describing a configuration its owner never saved. +// A favorite names one configuration, so other configurations' part numbers +// mustn't pull it up. describe("doSearch without configuration matching", () => { const searchDb = buildSearchDb(library("MAXSpline Gear"), { i1: [ @@ -482,8 +457,6 @@ describe("doSearch without configuration matching", () => { expect(titleOnly("24t").hits).toHaveLength(0); }); - // The row still needs a part number to show, and the default is the one - // that agrees with what inserting it would produce. it("still shows the default record on a name match", () => { const { hits } = titleOnly("maxspline"); expect(hits[0].values).toEqual({}); diff --git a/src/frontend/features/search/search.ts b/src/frontend/features/search/search.ts index a5bf3d6ad..0b008df4d 100644 --- a/src/frontend/features/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -23,10 +23,7 @@ export interface SearchFilters { export interface SearchHit { id: string; positions: Position[]; - /** - * The best-matching record for this hit, used to pre-fill the insert menu — - * its part number, name, and the values producing it. - */ + /** The best-matching record's values, which pre-fill the insert menu. */ values?: PartialSelection; /** Those values' key, for the row's thumbnail. */ configurationKey?: ConfigurationKey; @@ -40,14 +37,8 @@ export interface SearchHit { } export interface FilterResult { - /** - * The number of items filtered out by vendor filters. - */ byVendor: number; - /** - * The number of items filtered out by being in a different group. - * Does not include results that would have been filtered out by vendors. - */ + /** Excludes results already filtered out by vendor. */ byGroup: number; } @@ -65,10 +56,8 @@ export interface SearchArgs { /** @default false */ showHidden?: boolean; /** - * Whether a configuration's own part number or name can match. Favorites - * turn it off: a favorite names one configuration, but the fields cover - * every configuration the insertable has, so a query describing one the - * user never favorited would still pull their favorite up. + * Off for favorites, which name one configuration: other configurations' + * fields would pull them up. * @default true */ searchConfigurations?: boolean; @@ -92,8 +81,7 @@ export function doSearch(args: SearchArgs): SearchResult { const miniSearchResults: MiniSearchResult[] = searchDb.search(query, { fields: searchConfigurations ? undefined : INSERTABLE_FIELDS, filter: (result) => { - // MiniSearch types a hit's stored fields as `any`; they are the - // document that was indexed. + // MiniSearch types stored fields as `any`. const searchResult = result as unknown as Omit< MiniSearchResult, "id" @@ -140,8 +128,7 @@ export function doSearch(args: SearchArgs): SearchResult { }); const hits: SearchHit[] = miniSearchResults - // Sliced before mapping: the rest are never shown, and each one costs a - // record match and a highlight pass per field. + // Before mapping, since each hit costs a record match and highlighting. .slice(0, MAX_HITS) .map((miniSearchResult) => { const document = searchDb.getStoredFields( @@ -191,10 +178,7 @@ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** - * Underlines the longest query term the match starts with, so a prefix search - * underlines only what was typed. Falls back to the whole term. - */ +/** The longest query term the match starts with, so a prefix search underlines only what was typed. */ function matchedPrefixLength(term: string, queryTerms: string[]): number { let length = 0; for (const queryTerm of queryTerms) { @@ -205,10 +189,7 @@ function matchedPrefixLength(term: string, queryTerms: string[]): number { return length || term.length; } -/** - * `match` is keyed by matched document terms and `queryTerms` by what was typed. - * Based on https://github.com/lucaong/minisearch/issues/37 - */ +/** Based on https://github.com/lucaong/minisearch/issues/37 */ function generateHighlightPositions( result: MiniSearchResult, text: string, diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 6b3482404..ed9f823e3 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -154,15 +154,11 @@ function UserSettings(): ReactNode { ); } -/** - * The app's own url for the tab, free of Onshape's launch params, which are - * what would keep it embedded. Settings follow on their own, being this browser's. - */ +/** Without Onshape's launch params, which keep the app embedded. */ function standaloneUrl(tabId: AppTab): string { return new URL(getTabPath(tabId), window.location.origin).href; } -/** Whether the dashboard is showing, rather than the app itself. */ function useIsDashboard(): boolean { return useMatch({ from: "/dashboard", shouldThrow: false }) !== undefined; } @@ -171,7 +167,7 @@ interface OpenAppButtonProps { tabId: AppTab; } -/** Leaves the dashboard for the app, in place rather than in a second tab. */ +/** In place, not in a new tab. */ function OpenAppButton(props: OpenAppButtonProps): ReactNode { return ( <Button diff --git a/src/frontend/features/settings/components/vendor-filters.tsx b/src/frontend/features/settings/components/vendor-filters.tsx index 1c3bc082f..8cbf26988 100644 --- a/src/frontend/features/settings/components/vendor-filters.tsx +++ b/src/frontend/features/settings/components/vendor-filters.tsx @@ -23,11 +23,9 @@ export function useVendorFilters(): Vendor[] | undefined { return uiState.vendorFilters[libraryId]; } -/** Replaces one library's filters, leaving what the others have picked. An - * empty list is no filter at all, so it is stored as absent. */ +/** An empty list is no filter, so it's stored as absent. */ function setVendorFilters(libraryId: LibraryId, vendors: Vendor[]): void { - // Read at call time rather than from a render, so two changes in a tick - // cannot drop one another's library. + // Read at call time, so two changes in a tick don't drop each other. const vendorFilters = { ...getUiState().vendorFilters }; if (vendors.length > 0) { vendorFilters[libraryId] = vendors; @@ -37,8 +35,6 @@ function setVendorFilters(libraryId: LibraryId, vendors: Vendor[]): void { updateUiState({ vendorFilters }); } -/** A vendor's name, and the code a part number writes it as when that differs - * — Custom names itself, so it does not repeat. */ function vendorLabel(vendor: Vendor): string { const name = getVendorName(vendor); return name === vendor ? name : `${name} (${vendor})`; @@ -72,10 +68,7 @@ export function ClearFiltersButton(props: ClearFiltersButtonProps): ReactNode { ); } -/** - * Vendor filter control: an icon button on the header that opens a menu of - * vendor checkbox items. `undefined` filters mean "all vendors active". - */ +/** `undefined` filters mean every vendor is active. */ export function VendorMenu(): ReactNode { const libraryId = useLibraryId(); const vendorFilters = useVendorFilters(); diff --git a/src/frontend/features/settings/open-settings-menu.tsx b/src/frontend/features/settings/open-settings-menu.tsx index b4fad6a49..d7e3eeffe 100644 --- a/src/frontend/features/settings/open-settings-menu.tsx +++ b/src/frontend/features/settings/open-settings-menu.tsx @@ -2,10 +2,7 @@ import { openAppModal } from "../../components/open-app-modal"; import { AppModalBody } from "../../components/app-modal"; import { SettingsMenuContent } from "./components/settings-menu"; -/** - * Kept out of the component file so that file exports only components, which - * is what lets React Refresh swap it in place instead of reloading its callers. - */ +/** Separate so the component file exports only components, which React Refresh needs. */ export function openSettingsMenu() { openAppModal({ title: "Settings", diff --git a/src/frontend/features/settings/settings.ts b/src/frontend/features/settings/settings.ts index d0caa0be9..20428ac3c 100644 --- a/src/frontend/features/settings/settings.ts +++ b/src/frontend/features/settings/settings.ts @@ -8,9 +8,7 @@ import { setSettingsSync } from "../../lib/ui-state"; /** Writes the caller's row, which the entry redirect starts their next browser from. */ async function postSettings(newSettings: SettingsUpdate): Promise<void> { - // Resolved here rather than read off a render: a placeholder that says - // signed out would skip the save for a user who has a server-side row. - // Any library answers, being signed in or not the same in all of them. + // Resolved rather than read from a render, whose placeholder says signed out. const { signedIn } = await queryClient.ensureQueryData( getAccessDataQuery(DEFAULT_LIBRARY) ); @@ -20,11 +18,7 @@ async function postSettings(newSettings: SettingsUpdate): Promise<void> { await apiPost("/settings", { body: newSettings }); } -/** - * Hands the store somewhere to put a synced field, so writing one is an - * ordinary `updateUiState` from wherever it is set — a menu, or a route that - * cannot hold a hook. - */ +/** Lets any `updateUiState` call sync a field, including from routes that can't use hooks. */ export function installSettingsSync(): void { setSettingsSync((settings) => { void postSettings(settings).catch(() => { diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index b5ebdb98b..4d2fb6d03 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -25,7 +25,6 @@ import { useIsFetchingConfiguration } from "../../insert/queries"; import { useAccessData } from "../../auth/access-level"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; -/** Letterbox rather than stretch, in case the render is not the size we asked for. */ const FIT_INSIDE_BOX = { objectFit: "contain", maxWidth: "100%", @@ -54,17 +53,14 @@ interface ThumbnailTarget { microversionId: string; /** Empty means the element default. */ configurationKey: ConfigurationKey; - /** - * The insertable to render a miss from, set where the user picked the - * configuration. A search would otherwise start a render per row. - */ + /** Starts a render on a miss. Only set where the user picked the configuration, so a search doesn't start one per row. */ insertableId?: string; } interface CardThumbnailProps { smallThumbnailUrl?: string; largeThumbnailUrl?: string; - /** Set to show a specific configuration rather than the element default. */ + /** Omit for the element's default. */ target?: ThumbnailTarget; } @@ -72,10 +68,8 @@ interface CardThumbnailProps { export function CardThumbnail(props: CardThumbnailProps): ReactNode { const { smallThumbnailUrl, largeThumbnailUrl, target } = props; - // Asked for by key whether or not this row may start one: the route serves - // what is already stored either way, so a row that cannot start one still shows - // a configuration something else rendered. It costs that row a 404 when - // nothing has, and it falls back to the element's own. + // Always asked for by key, so a row that can't start a render still shows one + // that something else started. const configuredTarget = target && target.configurationKey !== DEFAULT_CONFIGURATION_KEY ? target @@ -84,13 +78,10 @@ export function CardThumbnail(props: CardThumbnailProps): ReactNode { const urlFor = (size: ThumbnailSize, stored?: string) => configuredTarget ? thumbnailUrl({ ...configuredTarget, size }) : stored; - // Only while a configuration is rendering: without a target the stored url - // is what `urlFor` already returns, and falling back to it means nothing. const fallbackFor = (stored?: string) => configuredTarget ? stored : undefined; - // Only a row that started the render has one coming; anything else takes the - // miss for the answer rather than waiting on a render nobody started. + // Only a row that started the render waits for one. const isRendering = configuredTarget?.insertableId !== undefined; return ( @@ -126,12 +117,7 @@ const STORED_RETRIES = 1; interface ThumbnailProps { url?: string; - /** - * The element's own thumbnail, shown until `url` renders — a render takes - * minutes, and the unconfigured part is closer to the row than a spinner. - * Loaded through a query of its own so a url whose bytes are gone shows the - * same placeholder as anything else, rather than a broken image. - */ + /** Shown until `url` renders, which can take minutes. */ fallbackUrl?: string; spinnerSize: number; heightAndWidth: HeightAndWidth; @@ -145,16 +131,13 @@ function Thumbnail(props: ThumbnailProps): ReactNode { const imageQuery = useQuery({ queryKey: storedThumbnailQueryKey(url), - // Narrowed here rather than guarded inside: `enabled` is what keeps it - // from running, and the query function should not restate that. queryFn: url ? ({ signal }) => isRendering ? loadRenderedImage(url, signal) : loadImage(url, signal) : skipToken, - // A render waits itself out; asking again after it gives up would - // only start the wait over. + // A render waits itself out; retrying would restart the wait. retry: isRendering ? false : STORED_RETRIES }); const fallbackQuery = useQuery({ @@ -166,8 +149,6 @@ function Thumbnail(props: ThumbnailProps): ReactNode { enabled: !imageQuery.isSuccess }); - // The configuration's own render once it lands, and nothing after that: - // the fallback stands in for it, it does not replace it. const shownUrl = imageQuery.data ?? fallbackQuery.data; let content; @@ -190,8 +171,6 @@ function Thumbnail(props: ThumbnailProps): ReactNode { export function PreviewImageCard(props: PreviewImageProps): ReactNode { return ( - // No margin: the modal body it sits in supplies the inset, and the - // padding stays tight so the preview is not lost inside its frame. <Card pos="relative" p="xs" radius="sm" bg={RENDER_BACKGROUND}> <Center> <PreviewImage {...props} /> @@ -215,14 +194,9 @@ interface PreviewImageProps { /** A stored size, so the bytes a preview fetch returns are worth caching. */ const PREVIEW_SIZE = ThumbnailSize.LARGE; -/** Sized to the preview's footprint rather than to a row's. */ const PREVIEW_SPINNER_SIZE = 36; -/** - * Waits out a configuration's render. The first ask starts it, and asking - * again while it runs starts nothing more, so a wait can ask as often as it - * needs to. - */ +/** The first ask starts the render; later asks start nothing more. */ function usePreviewThumbnail(props: PreviewImageProps, enabled: boolean) { const { path, insertableId, microversionId, configurationKey } = props; const url = thumbnailUrl({ @@ -236,8 +210,7 @@ function usePreviewThumbnail(props: PreviewImageProps, enabled: boolean) { return useQuery({ queryKey: renderQueryKey(url), queryFn: ({ signal }) => loadRenderedImage(url, signal), - // The previous configuration's render, so the box does not blank out - // while this one is still being waited on. + // Keeps the previous render up while this one is waited on. placeholderData: (previousData) => previousData, retry: false, enabled @@ -279,14 +252,12 @@ function PreviewImage(props: PreviewImageProps): ReactNode { </PreviewBox> ); - // Not known yet: the stored thumbnail would be swapped for the live preview - // a moment later. + // Otherwise the stored thumbnail flashes before the live preview. if (isPending) { return spinner; } - // Not signed in: no live Onshape preview, so show the stored thumbnail - // (Thumbnail falls back to a placeholder when there's none). + // Live previews need an Onshape session. if (!signedIn) { return ( <Thumbnail @@ -298,9 +269,7 @@ function PreviewImage(props: PreviewImageProps): ReactNode { } if (query.isError) { - // Onshape had no insertable for the selection, which is as far as a - // render gets: the part itself is what did not come out, so say that - // rather than blaming the thumbnail. + // The part itself failed to regenerate, not the thumbnail. if (isInvalidConfiguration(query.error)) { return ( <PreviewBox heightAndWidth={heightAndWidth}> @@ -318,8 +287,7 @@ function PreviewImage(props: PreviewImageProps): ReactNode { <PreviewBox heightAndWidth={heightAndWidth}> <SectionNotice title="The thumbnail could not be loaded." - // Standalone has no insert button to fall back on, and - // null suppresses the generic "contact the developers". + // Null suppresses the generic "contact the developers". description={ isConnected ? `You can still ${action} the part.` : null } diff --git a/src/frontend/features/thumbnails/queries.ts b/src/frontend/features/thumbnails/queries.ts index 7f8b37e07..ba383eb85 100644 --- a/src/frontend/features/thumbnails/queries.ts +++ b/src/frontend/features/thumbnails/queries.ts @@ -1,11 +1,7 @@ import { useIsFetching } from "@tanstack/react-query"; import { renderQueryPrefix } from "../../lib/query-keys"; -/** - * Whether a render the insert preview asked for is still being waited on. The - * polling is one fetch retrying, so this stays true across the gaps between - * polls rather than flickering with each one. - */ +/** One fetch waits out the whole render, so this doesn't flicker. */ export function useIsThumbnailRendering(): boolean { return useIsFetching({ queryKey: renderQueryPrefix() }) > 0; } diff --git a/src/frontend/features/thumbnails/render-wait.ts b/src/frontend/features/thumbnails/render-wait.ts index 3cfb0828b..f17da7c65 100644 --- a/src/frontend/features/thumbnails/render-wait.ts +++ b/src/frontend/features/thumbnails/render-wait.ts @@ -1,8 +1,4 @@ -/** - * Waiting out a configuration's render. Until it lands the route answers 404; - * the server pushes when it has, so a miss waits for that push rather than - * asking again on a timer. - */ +/** The server pushes when a render lands, so a miss waits for that rather than polling. */ import { HttpStatus } from "http-status-ts"; import { DEFAULT_CONFIGURATION_KEY } from "@backend/features/configurations/contract"; import { @@ -14,17 +10,10 @@ import { loadImage } from "../../lib/api-client"; import { ImageLoadError } from "../../lib/errors"; import { subscribeLiveMessages } from "../../lib/live-updates"; -/** - * How long any surface waits out a render before calling it failed: as long as - * `RenderThumbnailWorkflow` does, after which nothing more is coming. - */ +/** As long as `RenderThumbnailWorkflow` tries. */ const RENDER_TIMEOUT_MS = 60_000; -/** - * Onshape has no insertable for the configuration, which the route answers with - * its own status: the part did not regenerate, so no render is coming and - * waiting for one only delays saying so. - */ +/** The part didn't regenerate, so no render is coming. */ export function isInvalidConfiguration(error: unknown): boolean { return ( error instanceof ImageLoadError && @@ -71,17 +60,13 @@ function sleep( }); } -/** - * The render `url` serves, once there is one. Throws once no render is coming: - * the configuration is invalid, or the window has passed. - */ +/** Throws once no render is coming. */ export async function loadRenderedImage( url: string, signal?: AbortSignal ): Promise<string> { const deadline = Date.now() + RENDER_TIMEOUT_MS; - // Watched from before the first ask, so a push landing while one is in - // flight is not missed and waited out to the deadline. + // Subscribed before the first ask, so a push during it isn't missed. const waiting = { pushed: false, wake: undefined as (() => void) | undefined diff --git a/src/frontend/lib/api-client.ts b/src/frontend/lib/api-client.ts index 262714beb..87989f96d 100644 --- a/src/frontend/lib/api-client.ts +++ b/src/frontend/lib/api-client.ts @@ -7,11 +7,7 @@ import { import { fromApiErrorBody, ImageLoadError } from "./errors"; import { HttpStatus } from "http-status-ts"; -/** - * Bumped when the shape of an immutably cached response changes. Those are - * cached for a year against their `v`, so without this a browser would keep - * reading the old shape until the content itself next changed. - */ +/** Bump when an immutably cached response changes shape, or browsers keep the old one for a year. */ const RESPONSE_SHAPE = 2; function getUrl( @@ -26,10 +22,6 @@ function getUrl( return "/api" + path + `?${searchParams}`; } -/** - * The route's response, as the route says it is. `T` is inferred from the call - * site, so a contract that stops matching is an error there, not an `any`. - */ export async function apiPost<T>( path: string, options?: PostOptions @@ -61,9 +53,6 @@ export async function apiGet<T>( return handleResponse<T>(response); } -/** - * Gets a response formatted as a raw string from a backend /api route. - */ export async function apiGetText( path: string, options?: QueryOptionsWithCacheId @@ -82,11 +71,9 @@ export async function apiGetText( } /** - * Fetching here surfaces failures as a rejected query and warms the browser - * cache. Returns the url, not a blob url, which has no safe moment to revoke. - * A configuration still rendering answers 404, so it rejects and the caller - * retries; the status travels with the rejection so a caller can tell that - * apart from a refusal worth giving up on. + * Fetched to warm the cache and surface failures; returns the url, since a + * blob url has no safe moment to revoke. The status rides the rejection so a + * render's 404 can be told from a refusal. */ export async function loadImage( url: string, @@ -111,10 +98,7 @@ export async function apiDelete<T>( return handleResponse<T>(response); } -/** - * The body, or the error it describes. Asserted rather than parsed: the contract - * is the backend's, and nothing here can check it at runtime without a schema. - */ +/** Asserted, not parsed: the backend owns the contract. */ async function handleResponse<T>(response: Response): Promise<T> { const json: unknown = await response.json().catch(() => undefined); if (!response.ok) { diff --git a/src/frontend/lib/api-paths.ts b/src/frontend/lib/api-paths.ts index d97bcc763..25a47ad9e 100644 --- a/src/frontend/lib/api-paths.ts +++ b/src/frontend/lib/api-paths.ts @@ -1,7 +1,4 @@ -/** - * The `/api` path segments a resource is addressed by, matching the routes the - * worker mounts in `lib/route-params.ts`. - */ +/** Matches `lib/route-params.ts` on the backend. */ import { LibraryId } from "@backend/features/library/library-id"; export function toLibraryPath(libraryId: LibraryId): string { diff --git a/src/frontend/lib/app-params.ts b/src/frontend/lib/app-params.ts index 94946c224..e35b8afc9 100644 --- a/src/frontend/lib/app-params.ts +++ b/src/frontend/lib/app-params.ts @@ -1,11 +1,7 @@ /** - * The app's own url parameters, beside the ones Onshape launches with: what is - * being searched, and the part the insert menu has open. - * - * The url is adopted once, on the load that carries it, so a link opens what it - * points at. After that the stored state is what the app reads, and every - * change is mirrored back — so the url a caller copies is the one they are - * looking at, and a relaunch that carries no parameters resumes from the store. + * The url is adopted once, so a link opens what it points at. After that the + * stored state is the source of truth and is mirrored back, so a copied url + * matches the screen. */ import { useEffect } from "react"; import { useNavigate } from "@tanstack/react-router"; @@ -28,11 +24,7 @@ export type AppParams = z.infer<typeof AppParamsType>; /** Kept across in-app navigation, like the parameters Onshape launched with. */ export const APP_PARAM_KEYS = ["q", "part", "config", "favorite"] as const; -/** - * Once per load, not per navigation: `beforeLoad` runs on every one of them, - * and re-reading the url after the mirror wrote it would undo nothing useful - * while making the url the source of truth for the rest of the session. - */ +/** Once per load, not per navigation, or the url would become the source of truth. */ let adopted = false; /** Takes what the url names into the stored state, leaving the rest alone. */ @@ -43,8 +35,7 @@ export function adoptAppParams(params: AppParams): void { adopted = true; updateUiState({ ...(params.q !== undefined && { searchQuery: params.q }), - // A part names the whole menu, so its configuration and favorite come - // with it — including when they are absent, which is a plain part. + // A part names the whole menu, so absent fields mean a plain part. ...(params.part !== undefined && { openInsertableId: params.part, openConfiguration: params.config, @@ -62,13 +53,10 @@ export function useAppParamMirror(): void { useEffect(() => { void navigate({ to: ".", - // The url trails the app rather than being navigated to: a typed - // query should not be a page to go back through. + // A typed query shouldn't be history to go back through. replace: true, search: (previous: AppParams) => ({ ...previous, - // Empty reads as absent: a parameter with nothing in it is - // noise in a url somebody is about to copy. q: searchQuery || undefined, part: openInsertableId, config: openConfiguration || undefined, diff --git a/src/frontend/lib/errors.ts b/src/frontend/lib/errors.ts index 50d9608ad..a21543b85 100644 --- a/src/frontend/lib/errors.ts +++ b/src/frontend/lib/errors.ts @@ -2,10 +2,7 @@ import { ApiErrorKind, type ApiErrorBody } from "@backend/lib/api-error"; import { renderNotification, showErrorToast } from "./notifications"; import { startSignIn } from "../features/auth/sign-in"; -/** - * A failure worth telling the user about, from the backend or raised here. - * `body` is the discriminated shape the backend sends, read by its kind. - */ +/** `body` is the discriminated shape the backend sends. */ export class AppError extends Error { constructor(readonly body: ApiErrorBody) { super(body.message); @@ -14,11 +11,7 @@ export class AppError extends Error { } } -/** - * A failed image fetch. Carries the status because an image route answers with - * bytes or nothing at all, so the status is the only thing it can say — and a - * thumbnail still rendering and one that cannot render are different answers. - */ +/** The status is all an image route can say, and it tells "still rendering" from "can't render". */ export class ImageLoadError extends Error { constructor(readonly status: number) { super(`Image request failed with ${status}.`); @@ -54,10 +47,7 @@ export function getAppErrorHandler(defaultMessage: string, toastId?: string) { return (error: Error) => handleAppError(error, defaultMessage, toastId); } -/** - * Only an error worded for the user shows its own message; anything else gets - * `defaultMessage`. One the caller can act on offers them that action. - */ +/** Only messages worded for the user are shown; anything else gets `defaultMessage`. */ export function handleAppError( error: Error, defaultMessage: string, diff --git a/src/frontend/lib/format-time.ts b/src/frontend/lib/format-time.ts index 60534d519..28d4cdacd 100644 --- a/src/frontend/lib/format-time.ts +++ b/src/frontend/lib/format-time.ts @@ -2,10 +2,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; -/** - * How long ago, counted in whole elapsed days rather than calendar ones — this - * says how stale a load is, which no clock boundary changes. - */ +/** Whole elapsed days, not calendar days. */ export function formatDaysAgo(timestamp: number): string { const days = Math.floor((Date.now() - timestamp) / DAY_MS); if (days < 1) { diff --git a/src/frontend/lib/highlight.ts b/src/frontend/lib/highlight.ts index 7c35abc1b..6c86c2120 100644 --- a/src/frontend/lib/highlight.ts +++ b/src/frontend/lib/highlight.ts @@ -6,11 +6,7 @@ export interface Position { length: number; } -/** - * Overlapping runs merged into the fewest that cover the same characters, in - * ascending order — walking an index map is what makes both true, and callers - * render off the pair. - */ +/** Merges overlapping runs; the result is ascending and disjoint. */ export function mergePositions(positions: Position[]): Position[] { // Mapping where indexMap[i] = true means i is in a range. const indexMap: boolean[] = []; diff --git a/src/frontend/lib/library.ts b/src/frontend/lib/library.ts index 305902dea..321917342 100644 --- a/src/frontend/lib/library.ts +++ b/src/frontend/lib/library.ts @@ -6,18 +6,12 @@ import { LibraryId } from "@backend/features/library/library-id"; -/** - * Returns the library being displayed, which the url is the source of truth - * for. Callers can sit outside the library route — modals mount at the root - * and error components replace the match — so it falls back rather than throw. - */ +/** Falls back rather than throws, since modals and error components sit outside the library route. */ export function useLibraryId(): LibraryId { const params = useParams({ from: "/app/library/$libraryId", shouldThrow: false }); - // The dashboard scopes to a library of its own, which its settings menu - // offers the app for. const dashboardParams = useParams({ from: "/dashboard/library/$libraryId", shouldThrow: false @@ -27,11 +21,7 @@ export function useLibraryId(): LibraryId { const LibraryIdType = z.enum(LibraryId); -/** - * Reads a library id out of a url — the dashboard's, which scopes to one - * directly. An unknown one 404s here rather than falling back, which would - * hide the bad url and strand the caller elsewhere. - */ +/** 404s an unknown id rather than hiding the bad url. */ export function parseLibraryId(libraryId: string): LibraryId { const parsed = LibraryIdType.safeParse(libraryId); if (!parsed.success) { @@ -61,7 +51,6 @@ export function getLibraryStatus(libraryId: string): string | undefined { return undefined; } -/** Whether the tab's own page is showing, rather than one of its groups. */ export function useIsHome(): boolean { return ( useMatch({ diff --git a/src/frontend/lib/live-sync.ts b/src/frontend/lib/live-sync.ts index d1ce0ae30..e4efffae7 100644 --- a/src/frontend/lib/live-sync.ts +++ b/src/frontend/lib/live-sync.ts @@ -1,7 +1,4 @@ -/** - * Applies the server's pushes to what the app has cached, for the library on - * screen. Mounted once, by the app shell. - */ +/** Applies the server's pushes to the cache. Mounted once, by the app shell. */ import { useEffect, useRef } from "react"; import { type LiveMessage, @@ -25,8 +22,7 @@ export function useLiveSync(): void { const libraryId = useLibraryId(); const refreshLibrary = useRefreshLibrary(); const { signedIn, currentAccessLevel } = useAccessData(); - // Only an editor's job status is asked for at all; anyone else seeing - // one pushed would show a spinner for work they cannot see. + // Non-editors would otherwise show spinners for work they can't see. const showsJobs = signedIn && hasEditorAccess(currentAccessLevel); const hasConnected = useRef(false); @@ -49,9 +45,7 @@ export function useLiveSync(): void { } break; case LiveMessageType.THUMBNAIL: - // A row that took a miss for its answer, now that there - // is something to show. Anything waiting on the render - // hears the push itself; see `loadRenderedImage`. + // Rows that took a miss; anything waiting on the render hears the push itself. void queryClient.refetchQueries({ predicate: (query) => { const [kind, url] = query.queryKey; @@ -69,8 +63,7 @@ export function useLiveSync(): void { return subscribeLiveMessages(apply); }, [libraryId, refreshLibrary, showsJobs]); - // Pushes sent while the connection was down are gone, so a reconnect asks - // for what they would have said. The first connection has nothing missed. + // Pushes during the outage are lost, so a reconnect refetches. useEffect( () => subscribeLiveConnection(() => { diff --git a/src/frontend/lib/live-updates.ts b/src/frontend/lib/live-updates.ts index 81aa28616..5e4c54f19 100644 --- a/src/frontend/lib/live-updates.ts +++ b/src/frontend/lib/live-updates.ts @@ -1,8 +1,4 @@ -/** - * The app's one WebSocket to the server's pushes (`features/live` on the - * backend). Reconnects on its own, backing off; what was pushed while it was - * down is asked for again on reconnecting (see `live-sync.ts`). - */ +/** The app's one WebSocket to the server's pushes. Reconnects with backoff; `live-sync.ts` catches up after. */ import { LIVE_LIBRARY_PARAM, LIVE_PATH, diff --git a/src/frontend/lib/messages.ts b/src/frontend/lib/messages.ts index 9dfd6050a..68e7c6737 100644 --- a/src/frontend/lib/messages.ts +++ b/src/frontend/lib/messages.ts @@ -1,7 +1,4 @@ -/** - * The Onshape Client Messaging API, for a right-panel extension (not a tab one). - * https://onshape-public.github.io/docs/app-dev/clientmessaging/ - */ +/** Onshape's Client Messaging API: https://onshape-public.github.io/docs/app-dev/clientmessaging/ */ import { type ElementPath } from "@backend/lib/onshape/path"; import { useEffect } from "react"; @@ -51,22 +48,15 @@ export function sendOpenFeatureMessage( } /* - * Selecting and highlighting are not here. Lighting the insert location up in - * the viewport was tried and set aside: `requestSelectionHighlight` answered - * every payload we sent it `statusCode: "SUCCESS"` and painted nothing, and we - * never established whether it paints anything at all in an assembly. What was - * learned along the way, none of which Onshape documents: + * Highlighting the insert location was tried and dropped: + * `requestSelectionHighlight` answered SUCCESS and painted nothing. Undocumented + * findings from that: * - * - A `requestSelection` filter is one specifier per level, not the single - * `entityTypeSpecifier` the docs describe. A mate connector is - * `selectionTypeSpecifier: ["BODY"]` with - * `bodyTypeSpecifier: ["MATE_CONNECTOR"]`, and `geometryTypeSpecifier` sits - * under a `GEOMETRY` one. - * - A highlight cancels whatever selection request is outstanding rather than - * sitting alongside it. - * - An inbound SELECTION reports an assembly instance as a `selectionType` of - * `OCCURRENCE`, carrying an `occurrencePath`. Do not send that back: Onshape - * takes it and the instance list falls over. + * - A `requestSelection` filter takes one specifier per level. A mate connector + * is `selectionTypeSpecifier: ["BODY"]` with `bodyTypeSpecifier: ["MATE_CONNECTOR"]`. + * - A highlight cancels any outstanding selection request. + * - Don't send back the `OCCURRENCE` selection Onshape reports for an assembly + * instance; the instance list breaks. */ enum MessageType { diff --git a/src/frontend/lib/notifications.tsx b/src/frontend/lib/notifications.tsx index 91513d06a..84762f98e 100644 --- a/src/frontend/lib/notifications.tsx +++ b/src/frontend/lib/notifications.tsx @@ -10,9 +10,6 @@ export interface NotificationAction { onClick: () => void; } -/** - * Renders a Notification with an added Action button. - */ export function renderNotification( message: ReactNode, action: NotificationAction | undefined @@ -49,7 +46,6 @@ interface ToastConfig { withCloseButton?: boolean; } -/** Ids currently on screen, so a repeat updates rather than replaces. */ const liveToasts = new Set<string>(); /** Shows a toast, updating any existing toast with the same id. */ @@ -64,8 +60,7 @@ function showToast(config: ToastConfig): string { withCloseButton: config.withCloseButton }; - // Updating keeps the toast in place, so a loading toast becoming a success - // one reads as the same toast rather than one leaving and another arriving. + // Updated in place, so a loading toast turning into a success reads as one toast. if (config.id && liveToasts.has(config.id)) { notifications.update(props); return config.id; @@ -80,7 +75,7 @@ function showToast(config: ToastConfig): string { } interface InfoToastOptions { - /** Repeats with the same id update the toast rather than stacking one up. */ + /** A repeat with the same id updates the toast. */ id?: string; autoClose?: number | false; } diff --git a/src/frontend/lib/onshape-launch.ts b/src/frontend/lib/onshape-launch.ts index 5bab82b7c..0dd8047d3 100644 --- a/src/frontend/lib/onshape-launch.ts +++ b/src/frontend/lib/onshape-launch.ts @@ -1,10 +1,6 @@ /** - * What Onshape launches the panel with. Kept for the tab rather than left in the - * url: the document is the caller's own, so a url carrying it is one nobody can - * usefully share. - * - * A leaf, so the store can declare these fields without reaching the hooks that - * read them back — those are in `onshape-params`. + * Kept per tab rather than in the url, since a url carrying the caller's + * document isn't shareable. A leaf, so the store can declare these fields. */ import * as z from "zod"; import { ElementType } from "@backend/lib/onshape/element-type"; @@ -15,10 +11,7 @@ const ColorThemeType = z.enum(["light", "dark"]); export type ColorTheme = z.infer<typeof ColorThemeType>; -/** - * Every field optional: the app is opened standalone as well, and a launch we - * cannot read in full is one to treat as no launch rather than half of one. - */ +/** All optional, since the app also opens standalone. */ export const OnshapeLaunchType = z.object({ documentId: z.string().optional().catch(undefined), instanceId: z.string().optional().catch(undefined), @@ -43,10 +36,7 @@ export interface TargetElement extends ElementPath { elementType: ElementType; } -/** - * The element the panel can insert into: a workspace and nothing else, since a - * version and a microversion are snapshots with nothing to put a part in. - */ +/** Only a workspace; a version can't be inserted into. */ export function toTargetElement( launch: OnshapeLaunch ): TargetElement | undefined { diff --git a/src/frontend/lib/onshape-params.ts b/src/frontend/lib/onshape-params.ts index cf8fc73e2..9e173d6fd 100644 --- a/src/frontend/lib/onshape-params.ts +++ b/src/frontend/lib/onshape-params.ts @@ -10,12 +10,8 @@ import { import { toOnshapeOrigin } from "./url"; /** - * Takes a launch off the url into the store, which the app reads it from for - * the rest of the tab's life. Called before the url is stripped of it. - * - * The launch's own fields, since the search also carries what entry seeded off - * the caller's row, and writing one of those here posts it straight back. Only - * the ones the url names, since this runs again on the strip. + * Only the launch's own fields, and only those present: the search also + * carries what entry seeded, which would otherwise post straight back. */ export function adoptOnshapeLaunch(search: OnshapeLaunch): void { updateUiState( @@ -32,10 +28,7 @@ export function useTargetElement(): TargetElement | undefined { return toTargetElement(useGetUiState()); } -/** - * Whether the app is running in an Onshape document it can insert into. A - * signed-in caller opening the app directly is not. - */ +/** A signed-in caller opening the app directly isn't. */ export function useIsConnectedToOnshape(): boolean { return useTargetElement() !== undefined; } @@ -49,10 +42,7 @@ export function useOnshapeOrigin(): string { return toOnshapeOrigin(useOnshapeServer()); } -/** - * `systemTheme` is Onshape's, taken off the launch; standalone there is none, - * so the caller passes the OS preference instead. - */ +/** `systemTheme` comes from Onshape; standalone passes the OS preference. */ export function getColorTheme( theme: Theme, systemTheme: ColorTheme diff --git a/src/frontend/lib/query-cache.ts b/src/frontend/lib/query-cache.ts index 9f46d292c..595549d67 100644 --- a/src/frontend/lib/query-cache.ts +++ b/src/frontend/lib/query-cache.ts @@ -12,10 +12,7 @@ export function getQueryUpdater<T>(recipe: (draft: T) => void): Updater<T> { }; } -/** - * Shows a mutation's result before the server confirms it. There is no snapshot - * to roll back to: callers invalidate on settle, and the refetch is the undo. - */ +/** No rollback: callers invalidate on settle, and the refetch is the undo. */ export async function patchQuery<T>( queryKey: QueryKey, recipe: (draft: T) => void diff --git a/src/frontend/lib/query-keys.test.ts b/src/frontend/lib/query-keys.test.ts index 9398d8540..63d8e8dbf 100644 --- a/src/frontend/lib/query-keys.test.ts +++ b/src/frontend/lib/query-keys.test.ts @@ -13,8 +13,7 @@ import { LibraryId } from "@backend/features/library/library-id"; const LIBRARY = Object.values(LibraryId)[0]; describe("isVersionedLibraryQuery", () => { - // A refresh leaves these alone: their urls are immutable, so refetching the - // version a bump is replacing serves what that version already said. + // Immutable urls: refetching would serve the old version's answer. it.each([ ["library data", libraryDataQueryKey(LIBRARY, 3)], ["search db", searchDbQueryKey(LIBRARY, 3)], diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts index 4a67c067b..3385d81e0 100644 --- a/src/frontend/lib/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -1,7 +1,4 @@ -/** - * Every query key in one place. Everything scoped to a library hangs off - * {@link libraryQueryKey}, so the refresh flows invalidate that one prefix. - */ +/** Library-scoped keys hang off {@link libraryQueryKey}, so a refresh invalidates one prefix. */ import { LibraryId } from "@backend/features/library/library-id"; import { ElementPath, InstancePath } from "@backend/lib/onshape/path"; @@ -34,11 +31,7 @@ export function libraryQueryKey(libraryId: LibraryId) { return ["library", libraryId]; } -/** - * The library-scoped queries pinned to a cache version. Their urls are - * immutable, so one answers for its own version and no other — which is what - * {@link isVersionedLibraryQuery} exists to keep a refresh from forgetting. - */ +/** Immutable urls; see {@link isVersionedLibraryQuery}. */ const LIBRARY_DATA = "library-data"; const SEARCH_DB = "search-db"; const BUILD_STATUS = "build-status"; @@ -81,11 +74,7 @@ export function jobStatusQueryKey(libraryId: LibraryId) { return [...libraryQueryKey(libraryId), "job-status"]; } -/** - * A render the insert preview is waiting on. Everything hangs off the prefix: - * an insert cancels the lot, and the insert buttons ask it whether one is - * still running. - */ +/** Inserting cancels everything under this prefix. */ const RENDER = "thumbnail"; export function renderQueryPrefix() { diff --git a/src/frontend/lib/refresh.ts b/src/frontend/lib/refresh.ts index d35429d4a..065c6d66f 100644 --- a/src/frontend/lib/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -10,23 +10,13 @@ import { import { useLibraryId } from "./library"; interface RefreshLibraryOptions { - /** - * Refetch the version-keyed queries too, dropping whatever an optimistic - * patch left on them. For a mutation that failed: it bumped nothing, so the - * version on screen is still the one the server can answer for. - */ + /** For a failed mutation: refetches versioned queries to drop optimistic patches. */ discardPatches?: boolean; } /** - * Refreshes the current library and the caller's access. - * - * The version-keyed queries are left alone, because a versioned url answers for - * its own version and keeps answering for it — immutably, out of the browser's - * cache. Refetching the version a bump is replacing therefore reinstates the - * state that version had, which is what put an optimistic patch back to its old - * value for one round trip before the new version landed. Moving the pointer is - * enough: the new version is a new key, and it fetches. + * Leaves versioned queries alone: their urls are immutable, so refetching the + * old version restores its old state. The bump moves to a new key, which fetches. */ export function useRefreshLibrary(): ( options?: RefreshLibraryOptions diff --git a/src/frontend/lib/search-params.ts b/src/frontend/lib/search-params.ts index 15e4f218e..160bf9f14 100644 --- a/src/frontend/lib/search-params.ts +++ b/src/frontend/lib/search-params.ts @@ -1,9 +1,6 @@ import type { ZodType } from "zod"; -/** - * Validates url search params, omitting what fails rather than leaving it - * undefined: `retainSearchParams` tests `key in search`, reading that as cleared. - */ +/** Omits what fails, since `retainSearchParams` reads an undefined key as cleared. */ export function parseSearch<T extends object>( schema: ZodType<T>, search: unknown diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 08d2c7175..c89781e97 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -1,7 +1,3 @@ -/** - * Sizes for Phosphor icons. The first three are general magnitudes for an icon - * in a line of content; the rest each name the one place they are used. - */ export enum IconSize { /** Beside xs text: badge labels and metadata rows. */ TINY = 12, @@ -23,10 +19,6 @@ export enum FontWeight { BOLD = 700 } -/** - * The colors state is spoken in, as Mantine names them. Named here rather than - * written at each control, so an error looks like an error everywhere. - */ export enum StatusColor { ERROR = "red", WARNING = "yellow", @@ -38,51 +30,28 @@ export enum StatusColor { DIMMED = "dimmed" } -/** - * A badge naming a kind rather than a state — a configuration parameter's type, - * say. Off every {@link StatusColor} so it never reads as one, and never gray, - * which on a badge reads as disabled rather than as a label. - */ +/** For a badge naming a kind; not a status color, and not gray, which reads as disabled. */ export const CATEGORY_COLOR = "violet"; -/** - * Mantine's default step for a color: what a bare color name renders as, and - * so the step anything picking its own color should match. - */ +/** Mantine's default shade. */ export const FILLED_SHADE = 6; -/** A Mantine color at one shade, for props that take a CSS value rather than - * Mantine's own `color.shade` shorthand. */ +/** For props that take a CSS value. */ export function colorVar(color: string, shade: number): string { return `var(--mantine-color-${color}-${shade})`; } -/** - * A mark that must read as secondary but stay legible on both themes, which - * bare "gray" does not: a reference line, a bar for an unremarkable value. - */ +/** Secondary but legible in both themes, which bare "gray" isn't. */ export const MUTED_MARK = `${StatusColor.NEUTRAL}.5`; -/** - * A surface for a render to sit on. Onshape renders a part light, on white, so - * a white card leaves the thumbnail with no edge to it; light mode steps down - * far enough to give it one. Dark mode already has the contrast, so it keeps - * the card's own background. - */ +/** Onshape renders parts light on white, so light mode needs a darker card. */ export const RENDER_BACKGROUND = "light-dark(var(--mantine-color-gray-1), var(--mantine-color-body))"; -/** - * What a subtle gray control draws its icon in — the color Mantine resolves - * `variant="subtle"` to. A bare icon standing beside one has to be given it, - * or it takes the body text color and reads darker than its neighbours. - */ +/** Matches a subtle gray control's icon, for a bare icon beside one. */ export const CONTROL_ICON_COLOR = "var(--mantine-color-gray-light-color)"; -/** - * Paints an image in the current text color rather than its own. The url needs - * quoting: Vite inlines an asset as a data uri, which can contain apostrophes. - */ +/** Paints an image in the text color. Quoted since data uris can hold apostrophes. */ export function maskedImage(url: string) { return { backgroundColor: "currentColor", @@ -96,16 +65,9 @@ export function maskedImage(url: string) { /** The height of a default-sized Mantine input, for aligning beside one. */ export const INPUT_HEIGHT = "36px"; -/** - * One height for every navbar row, so the app's two tiers and the dashboard's - * read as the same bar rather than three sizes of one. - */ export const NAVBAR_ROW_HEIGHT = 48; -/** - * A rule that has to read against the navbar's frame rather than a white page, - * so it takes the same step off the frame in either theme. - */ +/** Stands out against the navbar's frame in either theme. */ export const NAVBAR_DIVIDER_COLOR = "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))"; diff --git a/src/frontend/lib/tabs.ts b/src/frontend/lib/tabs.ts index 98db3503c..a0cab3d42 100644 --- a/src/frontend/lib/tabs.ts +++ b/src/frontend/lib/tabs.ts @@ -10,10 +10,7 @@ import { } from "@backend/features/settings/app-tab"; import { getLibraryName } from "./library"; -/** - * The tabs the app can open, in the order the navbar offers them. A utility - * joins this once it has a page; `AppTab` already lets one be stored. - */ +/** In navbar order. A utility joins once it has a page. */ export const APP_TABS: AppTab[] = Object.values(LibraryId); /** A tab id as the entry redirect spells it into the url. */ @@ -22,11 +19,7 @@ export const AppTabType = z.enum([ ...Object.values(UtilityTab) ]); -/** - * Navigates to a tab. A library is one route with a parameter, so it is named; - * a utility is a route of its own, which `href` reaches without this route tree - * having to know it yet — a relative one still navigates in place. - */ +/** A utility is reached by `href`, so this route tree needn't know it yet. */ export function useNavigateToTab(): (tabId: AppTab) => void { const navigate = useNavigate(); diff --git a/src/frontend/lib/ui-state.test.ts b/src/frontend/lib/ui-state.test.ts index 5ab82198b..aaad18abc 100644 --- a/src/frontend/lib/ui-state.test.ts +++ b/src/frontend/lib/ui-state.test.ts @@ -73,8 +73,6 @@ describe("ui state", () => { }); }); - // The split moved a field out of the local blob and added three more, which - // the stored shape tolerates: nobody's theme or filters reset over it. it("keeps what a store written before the split holds", async () => { local.setItem( "uiState", @@ -132,8 +130,6 @@ describe("synced fields", () => { expect(stored(local, "uiState").theme).toBe("dark"); }); - // The entry redirect writes the group the caller resumed in, which is the - // one their row already holds. it("sends nothing for a field that did not move", async () => { const { setSettingsSync, updateUiState } = await loadUiState(); updateUiState({ groupId: "group-1" }); diff --git a/src/frontend/lib/ui-state.ts b/src/frontend/lib/ui-state.ts index fb8f2913e..ed69bac4d 100644 --- a/src/frontend/lib/ui-state.ts +++ b/src/frontend/lib/ui-state.ts @@ -16,15 +16,10 @@ const ThemeType = z.enum(Object.values(Theme)); const LibraryIdType = z.enum(Object.values(LibraryId)); const AppTabType = z.union([LibraryIdType, z.enum(Object.values(UtilityTab))]); -/** - * Kept locally and pushed to the caller's row, so a browser that has never run - * the app starts where their last one left off. The store is still what the app - * reads: the row is the copy, and the entry redirect is what seeds it back. - */ +/** Also saved to the user's row, so a new browser starts where they left off. */ const SyncedStateSchema = z.object({ theme: ThemeType.default(DEFAULT_THEME), - /** The tab last opened; null until one is picked, which the welcome asks - * for. */ + /** Null until one is picked, which the welcome asks for. */ tabId: AppTabType.nullable().default(null), /** The group last opened in that tab; null for the tab itself. */ groupId: z.string().nullable().default(null) @@ -34,8 +29,7 @@ const SyncedStateSchema = z.object({ const LocalStateSchema = z.object({ isFavoritesOpen: z.boolean().default(false), isLibraryOpen: z.boolean().default(true), - /** Vendor filters per library, so switching libraries keeps each one's; - * a library with no entry has every vendor active. */ + /** Per library; a library with no entry has every vendor active. */ vendorFilters: z .partialRecord(LibraryIdType, z.array(VendorType)) .default({}), @@ -43,23 +37,18 @@ const LocalStateSchema = z.object({ fasten: z.boolean().default(true), /** The access level to view the app as; absent means the granted default. */ accessLevel: AccessLevelType.optional(), - /** The insertable whose insert menu is open, and what it is configured to. - * Written as the menu opens and closes, so a relaunch can reopen it. */ + /** So a relaunch can reopen the insert menu. */ openInsertableId: z.string().optional(), - /** What its configuration changes from the element's defaults, encoded - * as `id=value;id=value`; absent for the defaults themselves. */ + /** Overrides as `id=value;id=value`; absent for the defaults. */ openConfiguration: z.string().optional(), - /** Set when the menu was opened from a favorite rather than a row. */ openFavoriteId: z.string().optional() }); -/** Kept for this tab only, being about this visit rather than this browser. */ +/** This tab only. */ const SessionStateSchema = z.object({ - /** Set on leaving for Onshape, so the app can confirm the sign-in on - * return — and only in the tab that left, which a second one did not. */ + /** Set on leaving for Onshape, so the returning tab can confirm the sign-in. */ justSignedIn: z.boolean().default(false), - // What Onshape launched this panel with: a tab switch replaces it, and - // another browser tab is another document, with a store of its own. + // Per tab: each browser tab is a different document. ...OnshapeLaunchType.shape }); @@ -68,10 +57,6 @@ type SessionState = z.infer<typeof SessionStateSchema>; type SyncedState = z.infer<typeof SyncedStateSchema>; type UiState = LocalState & SessionState & SyncedState; -/** - * One store's half of the state: which fields it owns, and how long they last. - * Held apart so a field's scope is declared once, beside the field. - */ interface StateArea { storageKey: string; schema: z.ZodObject; @@ -81,9 +66,8 @@ interface StateArea { const LOCAL_AREA: StateArea = { storageKey: "uiState", - // Synced fields are local too, and in the same blob: their scope is about - // where else they go, not where they are kept — and moving them to a blob - // of their own would reset the preferences already stored in this one. + // Synced fields share the local blob; splitting them out would reset + // preferences already stored. schema: z.object({ ...LocalStateSchema.shape, ...SyncedStateSchema.shape @@ -105,10 +89,7 @@ type SettingsSync = (settings: Partial<SyncedState>) => void; let settingsSync: SettingsSync | undefined; -/** - * Installed at startup by the feature that owns the caller's row, which keeps - * the endpoint — and the sign-in it needs — out of here. - */ +/** Installed at startup, keeping the endpoint and its sign-in out of here. */ export function setSettingsSync(sync: SettingsSync): void { settingsSync = sync; } @@ -137,12 +118,7 @@ function writeStorage(area: StateArea, value: string): void { } } -/** - * What one area stored, or its defaults when that cannot be used — older, - * hand-edited, or naming something dropped. Losing a preference beats failing - * to start. The version is read off the raw blob rather than the schema, being - * about the stored shape rather than anything the app reads. - */ +/** Falls back to defaults for anything old or malformed: losing a preference beats failing to start. */ function readArea(area: StateArea): Record<string, unknown> { const defaults = () => area.schema.parse({}); const raw = readStorage(area); @@ -193,8 +169,7 @@ function changedKeys( ): string[] { return Object.keys(partialState).filter((key) => { const typedKey = key as keyof UiState; - // Compared by value for the one object-valued field, which is rebuilt - // rather than mutated; the rest are primitives. + // The one object-valued field. return typedKey === "vendorFilters" ? JSON.stringify(current[typedKey]) !== JSON.stringify(partialState[typedKey]) @@ -204,8 +179,7 @@ function changedKeys( interface UpdateOptions { /** - * False for a value the caller's row already holds — the entry redirect - * seeding the theme — which would otherwise be posted straight back. + * False for a value the row already holds, like the theme entry seeds. * @default true */ sync?: boolean; @@ -228,8 +202,6 @@ export function updateUiState( writeArea(area, newState); } } - // Only what moved: the row is written for a field the caller changed, not - // for every field that happened to ride along with it. const syncedChanges = changed.filter((key) => SYNCED_KEYS.includes(key)); if ((options.sync ?? true) && syncedChanges.length > 0) { settingsSync?.( diff --git a/src/frontend/lib/url.test.ts b/src/frontend/lib/url.test.ts index 2dbe53c67..9d122a976 100644 --- a/src/frontend/lib/url.test.ts +++ b/src/frontend/lib/url.test.ts @@ -18,8 +18,7 @@ describe("makeUrl", () => { ); }); - // Once, by the url: a quantity that reaches Onshape as `%2520m` is the - // value `0.381%20m`, which is no quantity. + // Escaped twice, `0.381 m` would reach Onshape as `0.381%20m`. it("escapes a configuration once", () => { const url = makeUrl(DEFAULT_ONSHAPE_ORIGIN, element, { Effective_Length: "0.381 m", diff --git a/src/frontend/lib/url.tsx b/src/frontend/lib/url.tsx index 46c070d72..fba593156 100644 --- a/src/frontend/lib/url.tsx +++ b/src/frontend/lib/url.tsx @@ -14,10 +14,7 @@ import { IconSize } from "./style-constants"; /** Onshape for anyone outside a company, and for a caller who never launched. */ export const DEFAULT_ONSHAPE_ORIGIN = "https://cad.onshape.com"; -/** - * The app's listing in the Onshape App Store, where it is subscribed to. A - * path, so it opens on the caller's own Onshape; see `useOnshapeOrigin`. - */ +/** A path, so it opens on the caller's own Onshape; see `useOnshapeOrigin`. */ export const APP_STORE_PATH = "/appstore/apps/Manufacturers%20Models/6004ec5e83c40b107c183347"; @@ -25,10 +22,8 @@ export const APP_STORE_PATH = export const APPLICATIONS_PATH = "/user/applications"; /** - * The origin of the Onshape a launch came from — a company's own domain, such - * as frcdesign.onshape.com, for a company session — or cad's without one. Only - * an https onshape.com origin: the launch is a url anyone can write, and links - * built on this are opened as Onshape's. + * The company's domain for a company session, else cad's. Only https + * onshape.com origins: anyone can write a launch url. */ export function toOnshapeOrigin(server: string | undefined): string { const url = server ? URL.parse(server) : null; @@ -39,16 +34,10 @@ export function toOnshapeOrigin(server: string | undefined): string { return isOnshape ? url.origin : DEFAULT_ONSHAPE_ORIGIN; } -/** - * The setup instructions. Opened in a window of their own: a navigation would - * take the insert menu they are offered from with it. - */ +/** Opened in a new window so the insert menu stays. */ export const SETUP_URL = "/setup"; -/** - * The Onshape url for a path. A configuration applies only to an element, and - * only what it names changes: Onshape fills in the rest from the defaults. - */ +/** Onshape fills in whatever the configuration leaves out. */ export function makeUrl( origin: string, path: DocumentPath | InstancePath | ElementPath, @@ -63,18 +52,13 @@ export function makeUrl( } const encoded = encodeQueryConfiguration(configuration); if (isElementPath(path) && encoded) { - // Onshape's own parameter, so it keeps Onshape's name. The query form, - // this escape being the one layer Onshape unwraps. + // Onshape unwraps exactly this one layer of escaping. url += "?configuration=" + encodeURIComponent(encoded); } return url; } -/** - * The document a pasted Onshape url names, or undefined when it names none. - * Only the document id: a url pointing at a workspace or a tab carries more, - * but a link to the document itself does not, and both are worth accepting. - */ +/** Accepts a document link as well as one to a workspace or tab. */ export function parseOnshapeDocumentId(urlString: string): string | undefined { // Example pathname: /documents/{documentId}/w/{workspaceId}/e/{elementId} const url = URL.parse(urlString); @@ -85,9 +69,6 @@ export function parseOnshapeDocumentId(urlString: string): string | undefined { return documents === "documents" && documentId ? documentId : undefined; } -/** - * Opens the given URL in a new tab. - */ export function openUrlInNewTab(url: string) { window.open(url, "_blank"); } diff --git a/src/frontend/router.ts b/src/frontend/router.ts index 74b57d6c7..b67d84762 100644 --- a/src/frontend/router.ts +++ b/src/frontend/router.ts @@ -5,8 +5,7 @@ import { RootAppSpinner } from "./components/root-spinner"; export const router = createRouter({ routeTree, scrollRestoration: true, - // Render misses at the root instead of inside `/app`, whose navbar needs a - // library in the url to render at all. + // `/app`'s navbar needs a library in the url. notFoundMode: "root", defaultPendingComponent: RootAppSpinner }); diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index a0fac8d46..f0b0070aa 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -16,8 +16,7 @@ export const Route = createRootRoute({ component: RootComponent, // notFoundComponent renders inside the root Outlet, so it has the provider. notFoundComponent: NotFoundError, - // errorComponent replaces the root component (no provider), so it must not use - // Mantine. It only fires if the always-on root component itself throws. + // Replaces the root component, provider included, so no Mantine here. errorComponent: RootCrash }); @@ -31,8 +30,7 @@ function RootComponent(): ReactNode { [params.libraryId, tabId] ); - // Onshape's own scheme, taken off the launch; standalone there is none, - // and the OS is what "system" means. + // Standalone there's no Onshape scheme, so "system" means the OS. const osColorScheme = useColorScheme(); const colorTheme = getColorTheme(savedTheme, systemTheme ?? osColorScheme); @@ -46,8 +44,7 @@ function RootComponent(): ReactNode { position="bottom-center" limit={3} autoClose={4000} - // Otherwise pinned at 440px, wrapping a message with - // an action button. Still clamped to a narrow viewport. + // Otherwise pinned at 440px. containerWidth="max-content" /> <Outlet /> diff --git a/src/frontend/routes/_pages/beta-complete.tsx b/src/frontend/routes/_pages/beta-complete.tsx index b98347240..9ca7ff272 100644 --- a/src/frontend/routes/_pages/beta-complete.tsx +++ b/src/frontend/routes/_pages/beta-complete.tsx @@ -5,11 +5,7 @@ import { PageNotice } from "../../components/app-zero-state"; import { APP_STORE_PATH } from "../../lib/url"; import { useOnshapeOrigin } from "../../lib/onshape-params"; -/** - * Where the beta-era app extension still points. Nothing links here anymore, - * but an install old enough to predate the cutover launches straight at it, and - * without this route those callers land on the not-found page instead. - */ +/** Old installs of the beta extension still launch here. */ export const Route = createFileRoute("/_pages/beta-complete")({ component: BetaComplete }); diff --git a/src/frontend/routes/_pages/version-error.tsx b/src/frontend/routes/_pages/version-error.tsx index 6f9bdc57d..b77648acd 100644 --- a/src/frontend/routes/_pages/version-error.tsx +++ b/src/frontend/routes/_pages/version-error.tsx @@ -2,11 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { type ReactNode } from "react"; import { PageNotice } from "../../components/app-zero-state"; -/** - * Where a panel opened in a version or a microversion lands. Onshape launches - * us in one happily enough, but a snapshot cannot be changed, so there is - * nothing for the app to insert into. - */ +/** A version can't be changed, so there's nothing to insert into. */ export const Route = createFileRoute("/_pages/version-error")({ component: VersionError }); diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index d5c842fb1..b18d0a5ad 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -92,8 +92,7 @@ function useHomeSections(): Section[] { setOpened: (opened) => updateUiState({ isLibraryOpen: opened }) }; - // One slot below favorites, showing search results while a query is active - // and the library otherwise. The differing `value` remounts it on the swap. + // The differing `value` remounts the slot when search starts or ends. return [favorites, uiState.searchQuery ? search : library]; } @@ -118,8 +117,7 @@ function SectionAccordion(props: SectionAccordionProps): ReactNode { .filter((section) => section.opened) .map((section) => section.value)} onChange={handleChange} - // The divider is on the control, so a collapsed section still - // divides from the next one; content closes off an open one. + // On the control, so a collapsed section still has a divider. classNames={{ control: `${classes.control} ${styles.sectionHeader} ${styles.dividerBottom}`, item: classes.item, diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index f6a22279d..0cd1f30d4 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -19,13 +19,8 @@ export const Route = createFileRoute("/app/library/$libraryId")({ stringify: ({ libraryId }) => ({ libraryId }) }, /** - * A library is a tab, and the url selects it, so the store follows the url - * here as it does the group. Written by the tabs alone it drifted — a - * resume lands elsewhere, and a panel whose storage Onshape partitioned - * away reads as the default — and switching back to the tab it still named - * then posted nothing, leaving the row where it was. - * - * Not while the tab is null: being shown the default is not choosing it. + * The url selects the library, so the store follows it. Skipped while the tab + * is null: being shown the default isn't choosing it. */ onEnter: (match) => { if (getUiState().tabId) { @@ -48,11 +43,6 @@ export const Route = createFileRoute("/app/library/$libraryId")({ } }); -/** - * The library's pages, plus the two things that follow the library rather than - * any one of them: the url the app keeps current, and the insert menu it was - * left with. - */ function Library(): ReactNode { useAppParamMirror(); useRestoreInsertMenu(); diff --git a/src/frontend/routes/app/route.tsx b/src/frontend/routes/app/route.tsx index 6615737ee..58444bff9 100644 --- a/src/frontend/routes/app/route.tsx +++ b/src/frontend/routes/app/route.tsx @@ -53,20 +53,17 @@ export const Route = createFileRoute("/app")({ ...parseSearch(AppParamsType, search) }), search: { - // Only the app's own: a launch is taken into the store below and struck - // off, so navigating never carries the caller's document around. + // The launch is stripped below, so navigation never carries the caller's document. middlewares: [retainSearchParams([...APP_PARAM_KEYS])] }, beforeLoad: ({ search, location }) => { adoptAppParams(search); adoptOnshapeLaunch(search); - // The entry redirect seeds the account's saved theme; ui-state is what - // the app reads, so take it rather than leave a second answer in the url. + // Moved into ui-state, so the url doesn't hold a second answer. if (search.theme) { updateUiState({ theme: search.theme }, { sync: false }); } - // Seeded only when the row names one, so a tab chosen here while - // signed out is not cleared. + // Only when the row names one, so a tab picked signed out isn't cleared. if (search.tabId) { updateUiState({ tabId: search.tabId }, { sync: false }); } @@ -92,10 +89,7 @@ function isLaunch(search: LaunchSearch): boolean { ); } -/** - * The url with the launch taken out. Undefined rather than absent: - * `retainSearchParams` reads a missing key as one it should put back. - */ +/** Undefined, not absent: `retainSearchParams` restores missing keys. */ function strippedOfLaunch(search: LaunchSearch & AppParams): AppParams { const cleared = Object.fromEntries( [...LAUNCH_KEYS, ...ENTRY_KEYS].map((key) => [key, undefined]) @@ -104,8 +98,6 @@ function strippedOfLaunch(search: LaunchSearch & AppParams): AppParams { } function App() { - // The navbar (control row + always-open filters) is self-sizing, so measure - // it and feed its height to AppShell rather than hardcoding one. const { ref: headerRef, height: headerHeight } = useElementSize(); useMessageListener(); diff --git a/src/frontend/routes/dashboard/index.tsx b/src/frontend/routes/dashboard/index.tsx index 99afcfc36..9cc66a54c 100644 --- a/src/frontend/routes/dashboard/index.tsx +++ b/src/frontend/routes/dashboard/index.tsx @@ -44,8 +44,7 @@ function taggedParts( } function DashboardOverview(): ReactNode { - // No range picker: each section names the window it reports, which is how - // one page mixes a trailing month, a season and all time. + // No range picker: each section names its own window. const range = toDayRange(RangePreset.ALL); const query = useQuery(getOverviewQuery(range)); const allParts = useAllParts(range); diff --git a/src/frontend/routes/dashboard/library/$libraryId/index.tsx b/src/frontend/routes/dashboard/library/$libraryId/index.tsx index d2e677f9a..f90340860 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/index.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/index.tsx @@ -84,8 +84,6 @@ function LibraryBody({ <SectionCard title="Most used parts"> {parts.data ? ( - // Sorted most used first, so the head of the distribution - // is the first page and the zeroes are pages away. <PartsTable libraryId={libraryId} parts={parts.data} @@ -103,8 +101,7 @@ function LibraryBody({ )} {parts.data ? ( - /* Keyed so switching library drops a zoom into a group that - the next library does not have. */ + /* Keyed so a zoom into a group doesn't carry to a library without it. */ <UsageTreemap key={libraryId} root={{ libraryId }} diff --git a/src/frontend/routes/dashboard/library/$libraryId/route.tsx b/src/frontend/routes/dashboard/library/$libraryId/route.tsx index 4b4e70d0a..625827775 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/route.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/route.tsx @@ -10,8 +10,7 @@ export const Route = createFileRoute("/dashboard/library/$libraryId")({ parse: ({ libraryId }) => ({ libraryId: parseLibraryId(libraryId) }), stringify: ({ libraryId }) => ({ libraryId }) }, - // Awaited as the app's library route awaits it: what is keyed on the version - // must not fetch once at zero and again at the real one. + // Awaited, so version-keyed queries don't fetch at zero first. loader: ({ params }) => queryClient.ensureQueryData(getLibraryVersionQuery(params.libraryId)) }); diff --git a/src/frontend/routes/dashboard/route.tsx b/src/frontend/routes/dashboard/route.tsx index c8a06585b..2d1694c21 100644 --- a/src/frontend/routes/dashboard/route.tsx +++ b/src/frontend/routes/dashboard/route.tsx @@ -10,8 +10,7 @@ import { DashboardNavbar } from "../../features/dashboard/dashboard-navbar"; import { RangePreset } from "../../features/dashboard/range"; import { parseSearch } from "../../lib/search-params"; -// Every field is caught rather than required: a hand-edited url should drop the -// bad param, not fail the whole route. +// Caught, so a hand-edited url drops the bad param instead of failing. const DashboardSearchType = z.object({ /** Preset window for the range chart; kept in the URL so views are shareable. */ range: z.enum(RangePreset).optional().catch(undefined), @@ -30,10 +29,7 @@ export const Route = createFileRoute("/dashboard")({ } }); -/** - * The dashboard is a sibling of `/app`, so it inherits none of the Onshape - * panel's shell or authed loaders — it is a full-screen public page. - */ +/** A sibling of `/app`, so it's a public full-screen page without the panel's shell. */ function DashboardLayout(): ReactNode { return ( <> diff --git a/src/frontend/routes/index.tsx b/src/frontend/routes/index.tsx index d47fd922c..2eab30564 100644 --- a/src/frontend/routes/index.tsx +++ b/src/frontend/routes/index.tsx @@ -5,16 +5,14 @@ import { getUiState, updateUiState } from "../lib/ui-state"; import { showSuccessToast } from "../lib/notifications"; import { RootAppError } from "../components/root-error"; -// Direct entry from outside Onshape, and where signing in returns to; Onshape's -// own launch is served before this route. +// Entry from outside Onshape, and where sign-in returns. export const Route = createFileRoute("/")({ beforeLoad: ({ search }) => { const { tabId, groupId, justSignedIn } = getUiState(); const tab = tabId ?? DEFAULT_LIBRARY; if (justSignedIn) { updateUiState({ justSignedIn: false }); - // Onshape only sends the caller back here on success, so arriving - // with the flag set is the confirmation. + // Onshape only returns here on success. showSuccessToast("Signed in to Onshape."); } // Whatever Onshape launched with rides along; only the path is ours. diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index aa0a62f48..7aa6302de 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -7,10 +7,7 @@ import { import { LibraryId } from "@backend/features/library/library-id"; import { FILLED_SHADE } from "./lib/style-constants"; -/** - * FRCDesign brand green ramp (index 6 = #4cae4f, the brand color). - * Generate replacements with https://mantine.dev/colors-generator if tuning. - */ +/** Index 6 is the brand color; https://mantine.dev/colors-generator to tune. */ const frcGreen: MantineColorsTuple = [ "#eef9ee", "#dcf1dc", @@ -24,11 +21,7 @@ const frcGreen: MantineColorsTuple = [ "#236b28" ]; -/** - * Falls back rather than throwing: the root themes the app even when the url - * names a library that does not exist — which the route 404s separately — or - * a tab that is not a library, which has no library color of its own. - */ +/** Falls back for unknown libraries and non-library tabs. */ export function getLibraryColor(libraryId: string): string { switch (libraryId) { case LibraryId.FTC_DESIGN_LIB: @@ -40,8 +33,6 @@ export function getLibraryColor(libraryId: string): string { } } -/** A library's color as Mantine's `color.shade`, for a chart series, a tile, - * or text that should read as the library. */ export function getLibraryShade(libraryId: string): string { return `${getLibraryColor(libraryId)}.${FILLED_SHADE}`; } @@ -55,19 +46,15 @@ export function createAppTheme(libraryId: string) { // Mantine's "md" default reads soft for a dense CAD panel. defaultRadius: "sm", cursorType: "pointer", - // Drops the class carrying Mantine's 1px press-down translate, which - // nudged every button and icon button down on click. + // Drops Mantine's 1px press-down nudge. activeClassName: "", - // How every one of these is drawn here, so a call site names only - // what makes it different. components: { Tooltip: Tooltip.extend({ defaultProps: { withArrow: true, multiline: true, maw: 260, - // Off by Mantine's default, which leaves a touchscreen no - // way to read one. + // Touch is off by default, leaving touchscreens no way to read one. events: { hover: true, focus: true, touch: true } } }), From de2b16c45403aca952bd6eb9633f761bb1fdc0e6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:03:40 +0000 Subject: [PATCH 31/88] Rename notices, share external links, tidy row parts, test change order - app-zero-state becomes app-notice. The base is SectionNotice. A description is shown only when passed, and SectionError/PageError add the "contact the developers" line, replacing the null-vs-undefined rule. "All elements are hidden by filters" no longer gets that line. - AppBreadcrumbs takes crumbs and a current item, linking the crumbs that have an onClick. ParameterPath is folded into it. - Callout's action is a CalloutButton component. - ExternalLink gives every outbound link its new tab, noreferrer, stopped click and centred arrow. PartNumber is one component for rows and the menu header, and never shrinks for its neighbours. - ChangeOrderItems is table-driven, and now tested. - AppIcon's label and the app's aria-labels are gone; AGENTS.md says accessibility isn't a goal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- AGENTS.md | 5 + src/frontend/components/app-brand.tsx | 7 +- src/frontend/components/app-icon.tsx | 12 +- src/frontend/components/app-navbar.tsx | 3 +- src/frontend/components/app-notice.tsx | 83 +++++++++++++ src/frontend/components/app-title.tsx | 21 ++-- src/frontend/components/app-zero-state.tsx | 113 ------------------ src/frontend/components/breadcrumbs.tsx | 46 +++++-- src/frontend/components/callout.tsx | 45 +++---- src/frontend/components/change-order.test.tsx | 77 ++++++++++++ src/frontend/components/change-order.tsx | 108 ++++++----------- src/frontend/components/external-link.tsx | 37 ++++++ src/frontend/components/get-app.tsx | 15 ++- src/frontend/components/item-row.tsx | 63 +++------- src/frontend/components/part-number.tsx | 48 ++++---- src/frontend/components/root-error.tsx | 5 +- .../build-status/components/build-status.tsx | 6 +- .../build-status/components/issues.tsx | 15 +-- .../components/parsed-section.tsx | 1 - .../dashboard/configuration-breakdown.tsx | 9 +- .../features/dashboard/dashboard-navbar.tsx | 4 +- .../features/dashboard/options-table.tsx | 9 +- .../features/dashboard/parameter-path.tsx | 32 ----- .../features/dashboard/parts-table.tsx | 22 ++-- .../features/dashboard/usage-treemap.tsx | 28 ++--- .../favorites/components/favorite-menu.tsx | 9 +- .../favorites/components/favorites-list.tsx | 11 +- .../insert/components/configurations.test.tsx | 1 - .../insert/components/configurations.tsx | 5 +- .../library/components/program-select.tsx | 11 +- .../search/components/search-errors.tsx | 38 +++--- .../search/components/search-results.tsx | 11 +- .../thumbnails/components/thumbnail.tsx | 7 +- src/frontend/lib/styles.module.css | 8 ++ src/frontend/routes/_pages/beta-complete.tsx | 2 +- src/frontend/routes/_pages/cookie-error.tsx | 2 +- src/frontend/routes/_pages/grant-denied.tsx | 2 +- src/frontend/routes/_pages/safari-error.tsx | 2 +- src/frontend/routes/_pages/version-error.tsx | 2 +- .../library/$libraryId/groups/$groupId.tsx | 15 +-- .../app/library/$libraryId/index.module.css | 4 +- .../routes/app/library/$libraryId/index.tsx | 8 +- src/frontend/routes/app/route.tsx | 2 +- .../dashboard/library/$libraryId/part.tsx | 23 +--- 44 files changed, 469 insertions(+), 508 deletions(-) create mode 100644 src/frontend/components/app-notice.tsx delete mode 100644 src/frontend/components/app-zero-state.tsx create mode 100644 src/frontend/components/change-order.test.tsx create mode 100644 src/frontend/components/external-link.tsx delete mode 100644 src/frontend/features/dashboard/parameter-path.tsx diff --git a/AGENTS.md b/AGENTS.md index f53ee5163..00188ab98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,11 @@ function useIsDashboard(): boolean { } ``` +## Accessibility + +Not a goal. Don't add `aria-*` attributes, screen-reader labels or keyboard +handling for their own sake; the app runs in Onshape's mouse-driven panel. + ## Layout `src/` has two sides, `backend/` (the Worker) and `frontend/` (the SPA). There diff --git a/src/frontend/components/app-brand.tsx b/src/frontend/components/app-brand.tsx index e05cf61af..cd0f07c39 100644 --- a/src/frontend/components/app-brand.tsx +++ b/src/frontend/components/app-brand.tsx @@ -48,12 +48,7 @@ export function AppBrandMark(props: AppBrandMarkProps): ReactNode { export function AppBrand(): ReactNode { return ( <Group gap="xs" wrap="nowrap" h="100%"> - <Center - component="a" - href={FRC_DESIGN_URL} - target="_blank" - aria-label="FRCDesign.org" - > + <Center component="a" href={FRC_DESIGN_URL} target="_blank"> <AppBrandMark /> </Center> <Text diff --git a/src/frontend/components/app-icon.tsx b/src/frontend/components/app-icon.tsx index 1ac5a502d..3519e2548 100644 --- a/src/frontend/components/app-icon.tsx +++ b/src/frontend/components/app-icon.tsx @@ -13,8 +13,6 @@ export interface AppIconProps color?: StatusColor | string; /** @default "regular" */ weight?: IconWeight; - /** What a screen reader calls an icon that carries meaning on its own. */ - label?: string; } /** Sized through `fz`, since Box's own `style` would drop the icon's. */ @@ -23,17 +21,9 @@ export function AppIcon({ size = IconSize.SMALL, color, weight, - label, ...others }: AppIconProps): ReactNode { return ( - <Box - component={icon} - fz={size} - c={color} - weight={weight} - aria-label={label} - {...others} - /> + <Box component={icon} fz={size} c={color} weight={weight} {...others} /> ); } diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index fd0ca3da4..6f8a668a3 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -168,7 +168,7 @@ function AppTabs(): ReactNode { } }} > - <Tabs.List aria-label="Tabs"> + <Tabs.List> {APP_TABS.map((tabId) => ( <Tabs.Tab key={tabId} value={tabId}> {getTabName(tabId)} @@ -227,7 +227,6 @@ function SearchBar() { const clearButton = query ? ( <Input.ClearButton - aria-label="Clear input" onClick={() => { setQuery(""); // Nothing to wait out: the list should empty on the click. diff --git a/src/frontend/components/app-notice.tsx b/src/frontend/components/app-notice.tsx new file mode 100644 index 000000000..1b4876241 --- /dev/null +++ b/src/frontend/components/app-notice.tsx @@ -0,0 +1,83 @@ +import { Center, EmptyState, Loader } from "@mantine/core"; +import { XIcon } from "@phosphor-icons/react"; +import { IconSize, StatusColor } from "../lib/style-constants"; +import { ReactNode } from "react"; +import { AppIcon } from "./app-icon"; + +const ERROR_ICON = ( + <AppIcon icon={XIcon} size={IconSize.PAGE} color={StatusColor.ERROR} /> +); + +const CONTACT_DEVELOPERS = + "If the problem persists, contact the FRCDesignApp developers."; + +interface NoticeProps { + /** Ends with a period whenever there is a description. */ + title: string; + description?: ReactNode; + /** @default a red cross */ + icon?: ReactNode; + action?: ReactNode; + className?: string; +} + +/** What content is replaced by when it is empty, loading or failed. */ +export function SectionNotice(props: NoticeProps): ReactNode { + const { title, description, icon = ERROR_ICON, action, className } = props; + return ( + <EmptyState + icon={icon} + title={title} + description={description} + size="sm" + className={className} + pt={24} + pb={24} + // What a sticky section header checks for, to not stick over one. + data-notice + > + <EmptyState.Actions>{action}</EmptyState.Actions> + </EmptyState> + ); +} + +interface SectionLoadingProps { + /** Takes the form "Loading {thing}...". */ + title: string; +} + +export function SectionLoading(props: SectionLoadingProps): ReactNode { + return <SectionNotice title={props.title} icon={<Loader />} />; +} + +type ErrorProps = Omit<NoticeProps, "description">; + +/** A failure with nothing more specific to say. */ +export function SectionError(props: ErrorProps): ReactNode { + return <SectionNotice {...props} description={CONTACT_DEVELOPERS} />; +} + +interface PageNoticeProps extends NoticeProps { + /** Keeps the notice nearer the top of the page. @default false */ + justifyUp?: boolean; +} + +/** A notice standing in for a whole page. */ +export function PageNotice(props: PageNoticeProps): ReactNode { + const { justifyUp = false, ...notice } = props; + if (justifyUp) { + return <SectionNotice {...notice} />; + } + return ( + <Center mih="80vh"> + <SectionNotice {...notice} /> + </Center> + ); +} + +/** {@link SectionError} for a whole page. */ +export function PageError( + props: Omit<PageNoticeProps, "description"> +): ReactNode { + return <PageNotice {...props} description={CONTACT_DEVELOPERS} />; +} diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 1159bd2b5..a6f37cfca 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -13,7 +13,7 @@ import { modals } from "@mantine/modals"; import type { SearchRecord } from "@backend/features/configurations/contract"; import { FontWeight, IconSize, StatusColor } from "../lib/style-constants"; import { meaningfulPartNumber } from "@backend/features/configurations/part-number"; -import { PartNumberLink } from "./part-number"; +import { PartNumber } from "./part-number"; import styles from "../lib/styles.module.css"; interface AppTitleProps { @@ -76,7 +76,7 @@ export function MenuTitle(props: MenuTitleProps): ReactNode { title={name} subtitle={ partNumber && ( - <PartNumber partNumber={partNumber} url={record?.url} /> + <PartNumberLine partNumber={partNumber} url={record?.url} /> ) } /> @@ -121,7 +121,6 @@ function CopyPartNumberButton(props: CopyPartNumberButtonProps): ReactNode { color={copied ? "teal" : "gray"} // Any taller and the row grows, shifting the title. size={COPY_BUTTON_SIZE} - aria-label="Copy part number" onClick={copy} > {copied ? ( @@ -136,24 +135,18 @@ function CopyPartNumberButton(props: CopyPartNumberButtonProps): ReactNode { ); } -interface PartNumberProps { +interface PartNumberLineProps { partNumber: string; url?: string; } -/** The part number, linked to the vendor's page for it when there is one. */ -function PartNumber(props: PartNumberProps): ReactNode { +function PartNumberLine(props: PartNumberLineProps): ReactNode { const { partNumber, url } = props; - if (url) { - return <PartNumberLink url={url}>{partNumber}</PartNumberLink>; - } - // Nowhere to send them, so offer the number itself to search with. return ( <> - <Text inherit truncate miw={0}> - {partNumber} - </Text> - <CopyPartNumberButton partNumber={partNumber} /> + <PartNumber url={url}>{partNumber}</PartNumber> + {/* Nowhere to send them, so offer the number to search with. */} + {!url && <CopyPartNumberButton partNumber={partNumber} />} </> ); } diff --git a/src/frontend/components/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx deleted file mode 100644 index c2adb88fb..000000000 --- a/src/frontend/components/app-zero-state.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Center, EmptyState, Loader } from "@mantine/core"; -import { XIcon } from "@phosphor-icons/react"; -import { IconSize, StatusColor } from "../lib/style-constants"; -import { type JSX, ReactNode } from "react"; -import { AppIcon } from "./app-icon"; - -const DEFAULT_ERROR_ICON = ( - <AppIcon icon={XIcon} size={IconSize.PAGE} color={StatusColor.ERROR} /> -); - -interface ZeroStateProps { - icon?: ReactNode; - title: string; - description?: ReactNode; - action?: ReactNode; - className?: string; -} - -/** Every empty, loading and error state is built from this. */ -export function ZeroState(props: ZeroStateProps): ReactNode { - const { icon, title, description, action, className } = props; - - return ( - <EmptyState - icon={icon} - title={title} - description={description} - size="sm" - className={className} - pt={24} - pb={24} - // What a sticky section header checks for, to not stick over one. - data-zero-state - > - <EmptyState.Actions>{action}</EmptyState.Actions> - </EmptyState> - ); -} - -interface SectionLoadingProps { - /** Takes the form "Loading {thing}...". */ - title: string; -} - -export function SectionLoading(props: SectionLoadingProps): ReactNode { - return <ZeroState title={props.title} icon={<Loader />} />; -} - -interface NoticeProps { - /** Ends with a period whenever there is a description. */ - title: string; - /** Null for none at all; omitted falls back to the contact-us line. */ - description?: string | null | JSX.Element; - className?: string; - /** @default a danger-colored cross */ - icon?: ReactNode; - action?: JSX.Element; -} - -function resolveDescription( - description: NoticeProps["description"] -): ReactNode { - if (description === undefined) { - return "If the problem persists, contact the FRCDesignApp developers."; - } - return description; -} - -/** A failure by default; pass `icon` and `description` for an empty result or a prompt. */ -export function SectionNotice(props: NoticeProps): ReactNode { - const { title, action, className, icon = DEFAULT_ERROR_ICON } = props; - return ( - <ZeroState - className={className} - title={title} - icon={icon} - description={resolveDescription(props.description)} - action={action} - /> - ); -} - -interface PageNoticeProps extends NoticeProps { - /** Keeps the notice nearer the top of the page. @default false */ - justifyUp?: boolean; -} - -/** For a whole page. */ -export function PageNotice(props: PageNoticeProps): ReactNode { - const { - title, - action, - className, - icon = DEFAULT_ERROR_ICON, - justifyUp = false - } = props; - - const notice = ( - <ZeroState - className={className} - title={title} - icon={icon} - description={resolveDescription(props.description)} - action={action} - /> - ); - - if (justifyUp) { - return notice; - } - - return <Center mih="80vh">{notice}</Center>; -} diff --git a/src/frontend/components/breadcrumbs.tsx b/src/frontend/components/breadcrumbs.tsx index 3d5a31caa..e3a596e97 100644 --- a/src/frontend/components/breadcrumbs.tsx +++ b/src/frontend/components/breadcrumbs.tsx @@ -1,21 +1,47 @@ -import { Breadcrumbs, type MantineSpacing } from "@mantine/core"; +import { Anchor, Breadcrumbs, Text, type MantineSpacing } from "@mantine/core"; import { type ReactNode } from "react"; +import { StatusColor } from "../lib/style-constants"; + +export interface Crumb { + label: string; + /** Makes the crumb a link back to its level; without one it's dimmed text. */ + onClick?: () => void; +} interface AppBreadcrumbsProps { - /** Bare text is wrapped; elements keep their own typography. */ - children: ReactNode; - /** Spacing off whatever the trail sits above. */ + /** Outermost first. */ + crumbs: Crumb[]; + /** Where the trail ends; a string is set as plain text. */ + current: ReactNode; mb?: MantineSpacing; } -/** A trail of crumbs, so every one in the app is separated the same way. */ -export function AppBreadcrumbs({ - children, - mb -}: AppBreadcrumbsProps): ReactNode { +/** With no crumbs, just `current`. */ +export function AppBreadcrumbs(props: AppBreadcrumbsProps): ReactNode { + const { crumbs, current, mb } = props; + const end = + typeof current === "string" ? ( + <Text size="sm">{current}</Text> + ) : ( + current + ); + if (crumbs.length === 0) { + return end; + } return ( <Breadcrumbs separator="›" mb={mb}> - {children} + {crumbs.map((crumb) => + crumb.onClick ? ( + <Anchor key={crumb.label} size="sm" onClick={crumb.onClick}> + {crumb.label} + </Anchor> + ) : ( + <Text key={crumb.label} size="sm" c={StatusColor.DIMMED}> + {crumb.label} + </Text> + ) + )} + {end} </Breadcrumbs> ); } diff --git a/src/frontend/components/callout.tsx b/src/frontend/components/callout.tsx index 3a423676b..a8f2dccb7 100644 --- a/src/frontend/components/callout.tsx +++ b/src/frontend/components/callout.tsx @@ -3,18 +3,11 @@ import { InfoIcon } from "@phosphor-icons/react"; import { ReactNode } from "react"; import { IconSize, StatusColor } from "../lib/style-constants"; -interface CalloutAction { - /** A verb or a destination, e.g. "Instructions". */ - text: string; - icon: ReactNode; - onClick: () => void; -} - interface CalloutProps { /** A whole sentence, ending in a period. */ text: string; - /** Omitted for a note that only reports something. */ - action?: CalloutAction; + /** A `CalloutButton`; omitted for a note that only reports something. */ + action?: ReactNode; } /** Blue, so it reads as a remark rather than library content. */ @@ -38,18 +31,30 @@ export function Callout(props: CalloutProps): ReactNode { <Text size="sm" flex="1 1 12rem"> {text} </Text> - {action && ( - <Button - variant="outline" - color={StatusColor.INFO} - size="compact-sm" - leftSection={action.icon} - onClick={action.onClick} - > - {action.text} - </Button> - )} + {action} </Group> </Alert> ); } + +interface CalloutButtonProps { + /** A verb or a destination, e.g. "Instructions". */ + children: string; + icon: ReactNode; + onClick: () => void; +} + +export function CalloutButton(props: CalloutButtonProps): ReactNode { + const { children, icon, onClick } = props; + return ( + <Button + variant="outline" + color={StatusColor.INFO} + size="compact-sm" + leftSection={icon} + onClick={onClick} + > + {children} + </Button> + ); +} diff --git a/src/frontend/components/change-order.test.tsx b/src/frontend/components/change-order.test.tsx new file mode 100644 index 000000000..f5de5dd85 --- /dev/null +++ b/src/frontend/components/change-order.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { Menu } from "@mantine/core"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../__test_utils__/render"; +import { ChangeOrderItems } from "./change-order"; + +const ORDER = ["a", "b", "c", "d"]; + +function renderItems(id: string, order = ORDER) { + const onOrderChange = vi.fn(); + renderWithProviders( + <Menu opened> + <Menu.Target> + <button>menu</button> + </Menu.Target> + <Menu.Dropdown> + <ChangeOrderItems + id={id} + order={order} + onOrderChange={onOrderChange} + /> + </Menu.Dropdown> + </Menu> + ); + return onOrderChange; +} + +const shownItems = () => + screen.queryAllByRole("menuitem").map((item) => item.textContent); + +describe("ChangeOrderItems", () => { + it.each([ + ["a", ["Move down", "Move to bottom"]], + // One from an end, the double move would repeat the single one. + ["b", ["Move up", "Move down", "Move to bottom"]], + ["c", ["Move up", "Move down", "Move to top"]], + ["d", ["Move up", "Move to top"]] + ])("offers %s only the moves that change something", (id, items) => { + renderItems(id); + expect(shownItems()).toEqual(items); + }); + + it("offers nothing for a lone item", () => { + renderItems("a", ["a"]); + expect(shownItems()).toEqual([]); + }); + + it("offers nothing for an id not in the list", () => { + renderItems("z"); + expect(shownItems()).toEqual([]); + }); + + it.each([ + ["Move up", "c", ["a", "c", "b", "d"]], + ["Move down", "b", ["a", "c", "b", "d"]], + ["Move to top", "c", ["c", "a", "b", "d"]], + ["Move to bottom", "b", ["a", "c", "d", "b"]] + ])("%s moves %s", async (label, id, expected) => { + const user = userEvent.setup(); + const onOrderChange = renderItems(id); + + await user.click(screen.getByText(label)); + + expect(onOrderChange).toHaveBeenCalledWith(expected); + }); + + it("leaves the order it was given alone", async () => { + const user = userEvent.setup(); + const order = [...ORDER]; + renderItems("d", order); + + await user.click(screen.getByText("Move to top")); + + expect(order).toEqual(ORDER); + }); +}); diff --git a/src/frontend/components/change-order.tsx b/src/frontend/components/change-order.tsx index 0c4858bda..edc86670d 100644 --- a/src/frontend/components/change-order.tsx +++ b/src/frontend/components/change-order.tsx @@ -17,76 +17,19 @@ interface ChangeOrderMenuProps { /** The move items a list's order allows. */ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { const { id, order, onOrderChange } = props; - const operations = getValidOperations(id, order); - - if (operations.length === 0) { - return null; - } - - return ( - <> - {operations.includes(MoveOperation.MOVE_UP) && ( - <Menu.Item - leftSection={<CaretUpIcon size={IconSize.SMALL} />} - onClick={() => { - onOrderChange( - applyMoveOperation(id, order, MoveOperation.MOVE_UP) - ); - }} - > - Move up - </Menu.Item> - )} - {operations.includes(MoveOperation.MOVE_DOWN) && ( - <Menu.Item - leftSection={<CaretDownIcon size={IconSize.SMALL} />} - onClick={() => { - onOrderChange( - applyMoveOperation( - id, - order, - MoveOperation.MOVE_DOWN - ) - ); - }} - > - Move down - </Menu.Item> - )} - {operations.includes(MoveOperation.MOVE_TO_TOP) && ( - <Menu.Item - leftSection={<CaretDoubleUpIcon size={IconSize.SMALL} />} - onClick={() => { - onOrderChange( - applyMoveOperation( - id, - order, - MoveOperation.MOVE_TO_TOP - ) - ); - }} - > - Move to top - </Menu.Item> - )} - {operations.includes(MoveOperation.MOVE_TO_BOTTOM) && ( - <Menu.Item - leftSection={<CaretDoubleDownIcon size={IconSize.SMALL} />} - onClick={() => { - onOrderChange( - applyMoveOperation( - id, - order, - MoveOperation.MOVE_TO_BOTTOM - ) - ); - }} - > - Move to bottom - </Menu.Item> - )} - </> + return MOVE_ITEMS.filter((item) => operations.includes(item.operation)).map( + (item) => ( + <Menu.Item + key={item.label} + leftSection={<item.icon size={IconSize.SMALL} />} + onClick={() => + onOrderChange(applyMoveOperation(id, order, item.operation)) + } + > + {item.label} + </Menu.Item> + ) ); } @@ -97,13 +40,32 @@ enum MoveOperation { MOVE_TO_BOTTOM } +const MOVE_ITEMS = [ + { operation: MoveOperation.MOVE_UP, label: "Move up", icon: CaretUpIcon }, + { + operation: MoveOperation.MOVE_DOWN, + label: "Move down", + icon: CaretDownIcon + }, + { + operation: MoveOperation.MOVE_TO_TOP, + label: "Move to top", + icon: CaretDoubleUpIcon + }, + { + operation: MoveOperation.MOVE_TO_BOTTOM, + label: "Move to bottom", + icon: CaretDoubleDownIcon + } +]; + function applyMoveOperation( target: string, order: string[], operation: MoveOperation ): string[] { const index = order.indexOf(target); - if (index === -1) return order; // target not found, return unchanged + if (index === -1) return order; const result = [...order]; @@ -151,7 +113,8 @@ function getValidOperations(target: string, order: string[]): MoveOperation[] { if (index > 0) { if (index === 1) { - operations.push(MoveOperation.MOVE_UP); // only "up" since it goes straight to top + // Up is already the top. + operations.push(MoveOperation.MOVE_UP); } else { operations.push(MoveOperation.MOVE_UP, MoveOperation.MOVE_TO_TOP); } @@ -159,7 +122,8 @@ function getValidOperations(target: string, order: string[]): MoveOperation[] { if (index < lastIndex) { if (index === lastIndex - 1) { - operations.push(MoveOperation.MOVE_DOWN); // only "down" since it goes straight to bottom + // Down is already the bottom. + operations.push(MoveOperation.MOVE_DOWN); } else { operations.push( MoveOperation.MOVE_DOWN, diff --git a/src/frontend/components/external-link.tsx b/src/frontend/components/external-link.tsx new file mode 100644 index 000000000..0e0e17524 --- /dev/null +++ b/src/frontend/components/external-link.tsx @@ -0,0 +1,37 @@ +import { Anchor, type AnchorProps } from "@mantine/core"; +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { type MouseEvent, type ReactNode } from "react"; +import { IconSize } from "../lib/style-constants"; +import styles from "../lib/styles.module.css"; + +interface ExternalLinkProps extends AnchorProps { + href: string; + children?: ReactNode; + /** Adds the out-arrow after the text at this size. */ + iconSize?: IconSize; +} + +// Links sit inside clickable rows, whose click they aren't meant for. +const stopPropagation = (event: MouseEvent) => event.stopPropagation(); + +/** Opens in a new tab. */ +export function ExternalLink(props: ExternalLinkProps): ReactNode { + const { href, children, iconSize, className, ...others } = props; + return ( + <Anchor + href={href} + target="_blank" + rel="noreferrer" + onClick={stopPropagation} + className={ + iconSize + ? `${styles.externalLink} ${className ?? ""}` + : className + } + {...others} + > + {children} + {iconSize && <ArrowSquareOutIcon size={iconSize} />} + </Anchor> + ); +} diff --git a/src/frontend/components/get-app.tsx b/src/frontend/components/get-app.tsx index 49bb074d9..08eccf2b1 100644 --- a/src/frontend/components/get-app.tsx +++ b/src/frontend/components/get-app.tsx @@ -3,7 +3,7 @@ import { ReactNode } from "react"; import { useIsConnectedToOnshape } from "../lib/onshape-params"; import { IconSize } from "../lib/style-constants"; import { openUrlInNewTab, SETUP_URL } from "../lib/url"; -import { Callout } from "./callout"; +import { Callout, CalloutButton } from "./callout"; /** Offers the app where someone sees a part they can't insert. Hidden inside Onshape. */ export function GetAppCallout(): ReactNode { @@ -16,11 +16,14 @@ export function GetAppCallout(): ReactNode { return ( <Callout text="To use this part, get the FRCDesignApp." - action={{ - text: "Instructions", - icon: <ArrowSquareOutIcon size={IconSize.SMALL} />, - onClick: () => openUrlInNewTab(SETUP_URL) - }} + action={ + <CalloutButton + icon={<ArrowSquareOutIcon size={IconSize.SMALL} />} + onClick={() => openUrlInNewTab(SETUP_URL)} + > + Instructions + </CalloutButton> + } /> ); } diff --git a/src/frontend/components/item-row.tsx b/src/frontend/components/item-row.tsx index 55698cc91..d2c04a460 100644 --- a/src/frontend/components/item-row.tsx +++ b/src/frontend/components/item-row.tsx @@ -4,7 +4,8 @@ import { meaningfulPartNumber } from "@backend/features/configurations/part-numb import { StatusColor } from "../lib/style-constants"; import { AppContextMenu, MenuButton } from "./app-menu"; import { TruncatedText } from "./truncated-text"; -import { PartNumberLink } from "./part-number"; +import { PartNumber } from "./part-number"; +import { equalsIgnoreCase } from "@backend/lib/text"; import { mergePositions, type Position } from "../lib/highlight"; import styles from "../lib/styles.module.css"; @@ -65,7 +66,7 @@ export function CardTitle(props: CardTitleProps): ReactNode { </TruncatedText> {/* The line under the title, so it sits beside it in the stack rather than inside the paragraph the title renders as. */} - <PartNameAndNumber title={title} match={match} /> + {match && <PartNameAndNumber title={title} match={match} />} </Stack> {buildStatusBadge} </Group> @@ -75,24 +76,20 @@ export function CardTitle(props: CardTitleProps): ReactNode { interface PartNameAndNumberProps { /** The row's own title, which neither line repeats. */ title: string; - match?: RowMatch; + match: RowMatch; } /** The matched selection's name and part number, beneath the title. */ function PartNameAndNumber(props: PartNameAndNumberProps): ReactNode { const { title, match } = props; - - // The match's best selection, minus a value repeating the title. - const partName = - match?.partName?.toLowerCase() !== title.toLowerCase() - ? match?.partName - : undefined; - const partNumber = meaningfulPartNumber(match?.partNumber, title); + const partName = equalsIgnoreCase(match.partName, title) + ? undefined + : match.partName; + const partNumber = meaningfulPartNumber(match.partNumber, title); if (!partName && !partNumber) { return null; } - return ( <Group gap={4} @@ -106,53 +103,23 @@ function PartNameAndNumber(props: PartNameAndNumberProps): ReactNode { <TruncatedText hoverText={partName} inherit miw={0}> <HighlightedText text={partName} - positions={match?.partNamePositions} + positions={match.partNamePositions} /> </TruncatedText> )} {partName && partNumber && <Text inherit>·</Text>} {partNumber && ( - <CardPartNumber - partNumber={partNumber} - positions={match?.partNumberPositions} - url={match?.url} - /> + <PartNumber url={match.url}> + <HighlightedText + text={partNumber} + positions={match.partNumberPositions} + /> + </PartNumber> )} </Group> ); } -interface CardPartNumberProps { - partNumber: string; - /** Where the query matched inside it, for underlining. */ - positions?: Position[]; - url?: string; -} - -/** The part number, linked to the vendor's page for it when there is one. */ -function CardPartNumber(props: CardPartNumberProps): ReactNode { - const { partNumber, positions, url } = props; - const text = <HighlightedText text={partNumber} positions={positions} />; - if (!url) { - return ( - <Text - inherit - truncate - miw={0} - maw="100%" - className={styles.noShrink} - > - {text} - </Text> - ); - } - return ( - <PartNumberLink url={url} noShrink> - {text} - </PartNumberLink> - ); -} - /** Render loading, empty and error states outside it. */ export function ItemTable(props: PropsWithChildren): ReactNode { return ( diff --git a/src/frontend/components/part-number.tsx b/src/frontend/components/part-number.tsx index f1cb90017..db275b6c5 100644 --- a/src/frontend/components/part-number.tsx +++ b/src/frontend/components/part-number.tsx @@ -1,36 +1,40 @@ -import { Anchor, Text } from "@mantine/core"; -import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { Text } from "@mantine/core"; import { ReactNode } from "react"; import { IconSize } from "../lib/style-constants"; import styles from "../lib/styles.module.css"; +import { ExternalLink } from "./external-link"; -interface PartNumberLinkProps { - /** Already-rendered text, so a caller can underline what a query matched. */ +interface PartNumberProps { + /** Already rendered, so a caller can underline what a query matched. */ children: ReactNode; - url: string; - /** For a list row; a header lets a long part number ellipsize instead. */ - noShrink?: boolean; + /** The vendor's page for it. */ + url?: string; } -export function PartNumberLink(props: PartNumberLinkProps): ReactNode { - const { children, url, noShrink = false } = props; +/** Keeps its width beside anything that can shrink, but still ellipsizes past its row's. */ +export function PartNumber(props: PartNumberProps): ReactNode { + const { children, url } = props; + const text = ( + <Text component="span" inherit truncate miw={0}> + {children} + </Text> + ); + if (!url) { + return ( + <Text inherit truncate maw="100%" className={styles.noShrink}> + {children} + </Text> + ); + } return ( - <Anchor + <ExternalLink href={url} - target="_blank" inherit - // The row inserts on click, which is not what the link is for. - onClick={(event) => event.stopPropagation()} - display="inline-flex" - miw={0} maw="100%" - className={noShrink ? styles.noShrink : undefined} - style={{ alignItems: "center", gap: 2 }} + className={styles.noShrink} + iconSize={IconSize.TINY} > - <Text component="span" inherit truncate miw={0}> - {children} - </Text> - <ArrowSquareOutIcon size={IconSize.TINY} /> - </Anchor> + {text} + </ExternalLink> ); } diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index 4723b728b..7aaec16da 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -1,5 +1,5 @@ import { RequireAccessLevel } from "../features/auth/access-level"; -import { PageNotice } from "./app-zero-state"; +import { PageNotice, PageError } from "./app-notice"; import { ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { @@ -18,7 +18,7 @@ import { DEFAULT_LIBRARY } from "@backend/features/library/library-id"; export function RootAppError(): ReactNode { return ( - <PageNotice + <PageError title="The app has crashed due to an unexpected error." action={ <RequireAccessLevel @@ -74,7 +74,6 @@ function MissedUrl(): ReactNode { variant="subtle" color={copied ? "teal" : "gray"} size={IconSize.SMALL} - aria-label="Copy address" onClick={copy} > {copied ? ( diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index 429d4a19d..eba718695 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -123,11 +123,7 @@ function BuildStatusHoverCard({ <Loader size={IconSize.SMALL} /> ) : isHidden ? ( // Only editors see hidden insertables, so its checks don't matter yet. - <AppIcon - icon={EyeSlashIcon} - color={StatusColor.WARNING} - label="Hidden" - /> + <AppIcon icon={EyeSlashIcon} color={StatusColor.WARNING} /> ) : ( <IssueIcon severity={maxSeverity} /> ) diff --git a/src/frontend/features/build-status/components/issues.tsx b/src/frontend/features/build-status/components/issues.tsx index 6bda62d6d..00349bcdd 100644 --- a/src/frontend/features/build-status/components/issues.tsx +++ b/src/frontend/features/build-status/components/issues.tsx @@ -1,4 +1,5 @@ -import { Anchor, Badge, Group, Stack, Text } from "@mantine/core"; +import { ExternalLink } from "../../../components/external-link"; +import { Badge, Group, Stack, Text } from "@mantine/core"; import { ArrowSquareOutIcon, CheckIcon, @@ -266,15 +267,7 @@ function IssueCallout(props: IssueCalloutProps): ReactNode { } return ( - <Anchor - href={url} - target="_blank" - rel="noreferrer" - display="block" - underline="never" - c="inherit" - aria-label={`${getIssueDescription(issue)} — open the configuration in Onshape`} - > + <ExternalLink href={url} display="block" underline="never" c="inherit"> <Group {...CALLOUT_LAYOUT} bg={background} bdrs="sm"> <CalloutIcon severity={severity} /> <Text size="sm" flex={1}> @@ -286,7 +279,7 @@ function IssueCallout(props: IssueCalloutProps): ReactNode { style={CALLOUT_ICON_NUDGE} /> </Group> - </Anchor> + </ExternalLink> ); } diff --git a/src/frontend/features/build-status/components/parsed-section.tsx b/src/frontend/features/build-status/components/parsed-section.tsx index cca2281e1..75a2b0973 100644 --- a/src/frontend/features/build-status/components/parsed-section.tsx +++ b/src/frontend/features/build-status/components/parsed-section.tsx @@ -220,7 +220,6 @@ function IndexedControl(props: ParameterRowProps): ReactNode { <Tooltip label={isIndexed ? "Indexed" : "Not indexed"}> <Checkbox size="xs" - aria-label={`Index ${parameter.name}`} checked={isIndexed} disabled={mutation.isPending} onChange={() => diff --git a/src/frontend/features/dashboard/configuration-breakdown.tsx b/src/frontend/features/dashboard/configuration-breakdown.tsx index ea3efa102..f1c3b77e0 100644 --- a/src/frontend/features/dashboard/configuration-breakdown.tsx +++ b/src/frontend/features/dashboard/configuration-breakdown.tsx @@ -21,7 +21,7 @@ import { } from "../../lib/style-constants"; import { formatCount, formatPercent } from "./format"; import { ImplicitDefaultBadge } from "./implicit-default"; -import { ParameterPath } from "./parameter-path"; +import { AppBreadcrumbs } from "../../components/breadcrumbs"; interface ConfigurationBreakdownProps { parameters: ConfigurationParameterUsage[]; @@ -60,9 +60,10 @@ function ParameterCard({ parameter }: ParameterCardProps): ReactNode { <Card padding="md"> <Group justify="space-between" mb="sm" wrap="wrap"> <Group gap="xs"> - <ParameterPath path={parameter.path}> - <Title order={5}>{parameter.name} - + ({ label }))} + current={{parameter.name}} + /> {parameter.type} diff --git a/src/frontend/features/dashboard/dashboard-navbar.tsx b/src/frontend/features/dashboard/dashboard-navbar.tsx index 661455794..9de789eeb 100644 --- a/src/frontend/features/dashboard/dashboard-navbar.tsx +++ b/src/frontend/features/dashboard/dashboard-navbar.tsx @@ -92,7 +92,7 @@ function DashboardTabs({ current }: DashboardTabsProps): ReactNode { }} styles={TAB_STYLES} > - + {DASHBOARDS.map((entry) => ( {entry.label} @@ -167,7 +167,6 @@ function ThresholdControl(): ReactNode { } leftSectionWidth={THRESHOLD_LABEL_WIDTH} - aria-label="Low-usage threshold" value={threshold ?? DEFAULT_THRESHOLD} onChange={(value) => void navigate({ @@ -198,7 +197,6 @@ function RefreshButton(): ReactNode { my="auto" variant="subtle" color="gray" - aria-label="Refresh" loading={fetching} onClick={() => void queryClient.invalidateQueries({ diff --git a/src/frontend/features/dashboard/options-table.tsx b/src/frontend/features/dashboard/options-table.tsx index 8fcc536fb..31a706b33 100644 --- a/src/frontend/features/dashboard/options-table.tsx +++ b/src/frontend/features/dashboard/options-table.tsx @@ -9,7 +9,7 @@ import { LibraryId } from "@backend/features/library/library-id"; import { StatusColor } from "../../lib/style-constants"; import { formatCount, formatFraction } from "./format"; import { ImplicitDefaultBadge } from "./implicit-default"; -import { ParameterPath } from "./parameter-path"; +import { AppBreadcrumbs } from "../../components/breadcrumbs"; import { TablePagination, usePagedRows } from "./table-pagination"; /** Below this the part and parameter names wrap into each other. */ @@ -104,9 +104,10 @@ function OptionRow({ libraryId, option }: OptionRowProps): ReactNode { > {option.partName} - - {option.parameterName} - + ({ label }))} + current={{option.parameterName}} + /> diff --git a/src/frontend/features/dashboard/parameter-path.tsx b/src/frontend/features/dashboard/parameter-path.tsx deleted file mode 100644 index d5e460852..000000000 --- a/src/frontend/features/dashboard/parameter-path.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Text } from "@mantine/core"; -import { type ReactNode } from "react"; -import { AppBreadcrumbs } from "../../components/breadcrumbs"; -import { StatusColor } from "../../lib/style-constants"; - -interface ParameterPathProps { - /** The controlling choices, outermost first. */ - path: string[]; - /** Supplied by the caller, so a card can title it. */ - children: ReactNode; -} - -/** "Generic › Tube size", with the choices dimmed. */ -export function ParameterPath({ - path, - children -}: ParameterPathProps): ReactNode { - if (path.length === 0) { - return children; - } - - return ( - - {path.map((label) => ( - - {label} - - ))} - {children} - - ); -} diff --git a/src/frontend/features/dashboard/parts-table.tsx b/src/frontend/features/dashboard/parts-table.tsx index 37b5d150d..d9fce1a72 100644 --- a/src/frontend/features/dashboard/parts-table.tsx +++ b/src/frontend/features/dashboard/parts-table.tsx @@ -1,9 +1,6 @@ -import { Anchor, Badge, Group, Table, Text } from "@mantine/core"; -import { - ArrowSquareOutIcon, - CaretDownIcon, - CaretUpIcon -} from "@phosphor-icons/react"; +import { ExternalLink } from "../../components/external-link"; +import { Badge, Group, Table, Text } from "@mantine/core"; +import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react"; import { useMemo, useState, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { AppSparkline } from "./sparkline"; @@ -217,16 +214,11 @@ function PartRow({ libraryId, part }: PartRowProps): ReactNode { - {/* Stops the row's own navigation: this link leaves the app. */} - event.stopPropagation()}> - + - - + iconSize={IconSize.SMALL} + /> ); diff --git a/src/frontend/features/dashboard/usage-treemap.tsx b/src/frontend/features/dashboard/usage-treemap.tsx index deb88714c..6763047ef 100644 --- a/src/frontend/features/dashboard/usage-treemap.tsx +++ b/src/frontend/features/dashboard/usage-treemap.tsx @@ -1,4 +1,4 @@ -import { Anchor, Text } from "@mantine/core"; +import { Text } from "@mantine/core"; import { useNavigate } from "@tanstack/react-router"; import { useMemo, useState, type ReactNode } from "react"; import { getLibraryName } from "../../lib/library"; @@ -90,24 +90,16 @@ function TreemapTrail({ root, path, onSelect }: TreemapTrailProps): ReactNode { return null; } + const current = steps[steps.length - 1]; return ( // Spaced off the chart below, which otherwise sits on the trail. - - {steps.map((step, index) => - index === steps.length - 1 ? ( - - {step.label} - - ) : ( - onSelect(step.to)} - > - {step.label} - - ) - )} - + ({ + label: step.label, + onClick: () => onSelect(step.to) + }))} + current={current.label} + /> ); } diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 1de8f8a9c..4ce133a7b 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -25,7 +25,7 @@ import { useSetDefaultConfigurationMutation } from "../queries"; import { useLibraryQuery } from "../../library/queries"; -import { PageNotice } from "../../../components/app-zero-state"; +import { PageNotice } from "../../../components/app-notice"; interface FavoriteMenuContentProps { favoriteId: string; @@ -70,12 +70,7 @@ export function FavoriteMenuContent( return null; } if (!insertable.isConfigurable) { - return ( - - ); + return ; } return ( diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index c37cc2267..50a221237 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -15,9 +15,10 @@ import { import type { Insertables } from "@backend/features/library/contract"; import { useGetUiState } from "../../../lib/ui-state"; import { + SectionNotice, SectionLoading, - SectionNotice -} from "../../../components/app-zero-state"; + SectionError +} from "../../../components/app-notice"; import { NoSearchResultError, SearchCallout @@ -52,7 +53,7 @@ export function FavoritesList(): ReactNode { return ; } else if (libraryQuery.isError || favoritesQuery.isError) { return ( - ; } else if (searchDbQuery.isError) { - return ; + return ; } else if (!searchDbQuery.data) { - return ; + return ; } const result = searchInsertables({ diff --git a/src/frontend/features/insert/components/configurations.test.tsx b/src/frontend/features/insert/components/configurations.test.tsx index 54063d483..ced07e63b 100644 --- a/src/frontend/features/insert/components/configurations.test.tsx +++ b/src/frontend/features/insert/components/configurations.test.tsx @@ -165,7 +165,6 @@ describe("ConfigurationWrapper", () => { const input = await screen.findByLabelText("Derivation Variable"); expect(input).toHaveProperty("readOnly", true); expect((input as HTMLInputElement).value).toMatch(/^[0-9a-f-]{36}$/); - expect(screen.getByLabelText("Why this is filled in")).toBeTruthy(); expect(lastReport().overrides).toEqual({}); }); }); diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 79cde5bfc..9f8f7e80d 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -48,7 +48,7 @@ import { import { isDerivationVariable } from "@backend/features/configurations/roles"; import { evaluateExpression } from "@backend/features/configurations/input-parser"; import { useConfigurationQuery, useUnitInfo } from "../queries"; -import { SectionNotice } from "../../../components/app-zero-state"; +import { SectionError } from "../../../components/app-notice"; import classes from "./configurations.module.css"; import { normalizeSelection, @@ -154,7 +154,7 @@ export function ConfigurationWrapper( // A failed fetch also leaves `whole` undefined, so check this first. if (query.isError) { - return ; + return ; } if (query.isPending || !whole) { return ( @@ -366,7 +366,6 @@ function StringInput(props: ParameterProps): ReactNode { } diff --git a/src/frontend/features/library/components/program-select.tsx b/src/frontend/features/library/components/program-select.tsx index f26bbac03..3f88a83dc 100644 --- a/src/frontend/features/library/components/program-select.tsx +++ b/src/frontend/features/library/components/program-select.tsx @@ -1,4 +1,5 @@ -import { Anchor, Group, Stack, Text, UnstyledButton } from "@mantine/core"; +import { ExternalLink } from "../../../components/external-link"; +import { Group, Stack, Text, UnstyledButton } from "@mantine/core"; import { ArrowRightIcon, BooksIcon } from "@phosphor-icons/react"; import { ReactNode } from "react"; import { LibraryId } from "@backend/features/library/library-id"; @@ -6,7 +7,7 @@ import { type AppTab } from "@backend/features/settings/app-tab"; import { AppBrandMark } from "../../../components/app-brand"; import { AppModal, AppModalBody } from "../../../components/app-modal"; import { AppIcon } from "../../../components/app-icon"; -import { ZeroState } from "../../../components/app-zero-state"; +import { SectionNotice } from "../../../components/app-notice"; import { FontWeight, IconSize, @@ -89,9 +90,9 @@ function TrademarkDisclaimer(): ReactNode { FIRST ( - + www.firstinspires.org - + ) which is not overseeing, involved with, or responsible for this activity, product, or service. @@ -111,7 +112,7 @@ export function ProgramSelect(): ReactNode { return ( - } title="Welcome to the FRCDesignApp!" description="To get started, select your library. You can switch between libraries at any time using the top navbar." diff --git a/src/frontend/features/search/components/search-errors.tsx b/src/frontend/features/search/components/search-errors.tsx index fd21ceaba..e654c3d7e 100644 --- a/src/frontend/features/search/components/search-errors.tsx +++ b/src/frontend/features/search/components/search-errors.tsx @@ -6,7 +6,7 @@ import { } from "@phosphor-icons/react"; import { IconSize, StatusColor } from "../../../lib/style-constants"; import { ReactNode } from "react"; -import { Callout } from "../../../components/callout"; +import { Callout, CalloutButton } from "../../../components/callout"; import { ClearFiltersButton, useClearVendorFilters @@ -20,7 +20,7 @@ function plural(objectLabel: ObjectLabel): string { return objectLabel + "s"; } import { useNavigate } from "@tanstack/react-router"; -import { SectionNotice } from "../../../components/app-zero-state"; +import { SectionNotice } from "../../../components/app-notice"; import { useLibraryId } from "../../../lib/library"; import { AppIcon } from "../../../components/app-icon"; @@ -60,22 +60,28 @@ export function SearchCallout(props: FilterCalloutProps): ReactNode { return ( , - onClick: searchAllDocuments - }} + action={ + } + onClick={searchAllDocuments} + > + Search all + + } /> ); } return ( , - onClick: clearVendorFilters - }} + action={ + } + onClick={clearVendorFilters} + > + Clear filters + + } /> ); } @@ -124,13 +130,7 @@ export function NoSearchResultError( /> ); } - return ( - - ); + return ; } /** Leaves a group's search for the one across the whole library. */ diff --git a/src/frontend/features/search/components/search-results.tsx b/src/frontend/features/search/components/search-results.tsx index 304466e77..231e23188 100644 --- a/src/frontend/features/search/components/search-results.tsx +++ b/src/frontend/features/search/components/search-results.tsx @@ -4,10 +4,7 @@ import { SearchFilters } from "../search"; import { searchInsertables } from "../filter"; import { InsertableCard } from "../../library/components/insertable-card"; import { ItemTable } from "../../../components/item-row"; -import { - SectionNotice, - SectionLoading -} from "../../../components/app-zero-state"; +import { SectionLoading, SectionError } from "../../../components/app-notice"; import { NoSearchResultError, SearchCallout } from "./search-errors"; import { useLibraryQuery } from "../../library/queries"; import { useSearchDbQuery } from "../queries"; @@ -30,11 +27,11 @@ export function SearchResults(props: SearchResultsProps): ReactNode { if (searchDbQuery.isPending || libraryQuery.isPending) { return ; } else if (libraryQuery.isError) { - return ; + return ; } else if (searchDbQuery.isError) { - return ; + return ; } else if (!searchDbQuery.data) { - return ; + return ; } const result = searchInsertables({ searchDb: searchDbQuery.data, diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index 4d2fb6d03..b678f87db 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -18,7 +18,7 @@ import { DEFAULT_CONFIGURATION_KEY } from "@backend/features/configurations/contract"; import { thumbnailUrl } from "@backend/features/thumbnails/keys"; -import { SectionNotice } from "../../../components/app-zero-state"; +import { SectionNotice } from "../../../components/app-notice"; import { RENDER_BACKGROUND } from "../../../lib/style-constants"; import { useTargetElementType } from "../../insert/insert-hooks"; import { useIsFetchingConfiguration } from "../../insert/queries"; @@ -287,9 +287,10 @@ function PreviewImage(props: PreviewImageProps): ReactNode { diff --git a/src/frontend/lib/styles.module.css b/src/frontend/lib/styles.module.css index 7a62fcc19..ef7f0718c 100644 --- a/src/frontend/lib/styles.module.css +++ b/src/frontend/lib/styles.module.css @@ -61,3 +61,11 @@ .titleIcon { transform: translateY(-1px); } + +/* Centres an external link's arrow on its text rather than its baseline. */ +.externalLink { + display: inline-flex; + align-items: center; + gap: 0.25em; + min-width: 0; +} diff --git a/src/frontend/routes/_pages/beta-complete.tsx b/src/frontend/routes/_pages/beta-complete.tsx index 9ca7ff272..9dd152f66 100644 --- a/src/frontend/routes/_pages/beta-complete.tsx +++ b/src/frontend/routes/_pages/beta-complete.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { OpenUrlButton } from "../../components/open-url-button"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; import { APP_STORE_PATH } from "../../lib/url"; import { useOnshapeOrigin } from "../../lib/onshape-params"; diff --git a/src/frontend/routes/_pages/cookie-error.tsx b/src/frontend/routes/_pages/cookie-error.tsx index 668a1dc5b..1dbba18c9 100644 --- a/src/frontend/routes/_pages/cookie-error.tsx +++ b/src/frontend/routes/_pages/cookie-error.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; export const Route = createFileRoute("/_pages/cookie-error")({ component: CookieError diff --git a/src/frontend/routes/_pages/grant-denied.tsx b/src/frontend/routes/_pages/grant-denied.tsx index 84092437d..0402c83eb 100644 --- a/src/frontend/routes/_pages/grant-denied.tsx +++ b/src/frontend/routes/_pages/grant-denied.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { OpenUrlButton } from "../../components/open-url-button"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; import { APPLICATIONS_PATH } from "../../lib/url"; import { useOnshapeOrigin } from "../../lib/onshape-params"; diff --git a/src/frontend/routes/_pages/safari-error.tsx b/src/frontend/routes/_pages/safari-error.tsx index 689e78eb9..7a283306d 100644 --- a/src/frontend/routes/_pages/safari-error.tsx +++ b/src/frontend/routes/_pages/safari-error.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { OpenUrlButton } from "../../components/open-url-button"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; export const Route = createFileRoute("/_pages/safari-error")({ component: SafariError diff --git a/src/frontend/routes/_pages/version-error.tsx b/src/frontend/routes/_pages/version-error.tsx index b77648acd..be249ee2e 100644 --- a/src/frontend/routes/_pages/version-error.tsx +++ b/src/frontend/routes/_pages/version-error.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { type ReactNode } from "react"; -import { PageNotice } from "../../components/app-zero-state"; +import { PageNotice } from "../../components/app-notice"; /** A version can't be changed, so there's nothing to insert into. */ export const Route = createFileRoute("/_pages/version-error")({ diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 70400c6e4..d7b42854c 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -24,10 +24,11 @@ import { ItemTable } from "../../../../../components/item-row"; import { AppContextMenu, MenuButton } from "../../../../../components/app-menu"; import { SearchCallout } from "../../../../../features/search/components/search-errors"; import { - PageNotice, SectionNotice, - SectionLoading -} from "../../../../../components/app-zero-state"; + SectionLoading, + SectionError, + PageNotice +} from "../../../../../components/app-notice"; import { ClearFiltersButton, useVendorFilters @@ -61,7 +62,7 @@ function GroupList(): ReactNode { if (libraryQuery.isPending) { return ; } else if (libraryQuery.isError) { - return ; + return ; } const groups = libraryQuery.data.groups; const insertables = libraryQuery.data.insertables; @@ -72,7 +73,6 @@ function GroupList(): ReactNode { return ( + ) : ( ; } else if (libraryQuery.isError) { - return ; + return ; } const groups = libraryQuery.data.groups; @@ -189,7 +190,6 @@ function LibraryList() { return ( diff --git a/src/frontend/routes/app/route.tsx b/src/frontend/routes/app/route.tsx index 58444bff9..ce357d2bf 100644 --- a/src/frontend/routes/app/route.tsx +++ b/src/frontend/routes/app/route.tsx @@ -27,7 +27,7 @@ import { import { parseSearch } from "../../lib/search-params"; import { AppNavbar } from "../../components/app-navbar"; import { ProgramSelect } from "../../features/library/components/program-select"; -import { SectionLoading } from "../../components/app-zero-state"; +import { SectionLoading } from "../../components/app-notice"; import { useMessageListener } from "../../lib/messages"; import { useLiveSync } from "../../lib/live-sync"; import { updateUiState } from "../../lib/ui-state"; diff --git a/src/frontend/routes/dashboard/library/$libraryId/part.tsx b/src/frontend/routes/dashboard/library/$libraryId/part.tsx index d529e550f..4c19ce86c 100644 --- a/src/frontend/routes/dashboard/library/$libraryId/part.tsx +++ b/src/frontend/routes/dashboard/library/$libraryId/part.tsx @@ -1,13 +1,6 @@ -import { - Anchor, - Card, - SimpleGrid, - Stack, - Text, - TextInput, - Title -} from "@mantine/core"; -import { ArrowSquareOutIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { ExternalLink } from "../../../../components/external-link"; +import { Card, SimpleGrid, Stack, Text, TextInput, Title } from "@mantine/core"; +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, retainSearchParams } from "@tanstack/react-router"; import { useState, type ReactNode } from "react"; @@ -160,17 +153,13 @@ function PartTitle({ report }: PartTitleProps): ReactNode { const origin = useOnshapeOrigin(); return ( - <Anchor + <ExternalLink inherit href={makeUrl(origin, report.path)} - target="_blank" - rel="noreferrer" - // Centres the icon on the text rather than on its baseline. - style={{ display: "inline-flex", alignItems: "center", gap: 6 }} + iconSize={IconSize.MEDIUM} > {report.name} - <ArrowSquareOutIcon size={IconSize.MEDIUM} /> - </Anchor> + </ExternalLink> ); } From cf28dba42195c4e89a2e7e4ba3b60bf605940ee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:12:33 +0000 Subject: [PATCH 32/88] Drop TruncatedText, move the thumbnail reload item into library Truncated names pass `title` directly, including AppTitle and PartNumber, which now take the plain text. ReloadThumbnailMenuItem lives with the library cards that use it. The not-found page loses its HTML entity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/frontend/components/app-title.tsx | 11 +++++-- src/frontend/components/item-row.tsx | 14 ++++---- src/frontend/components/part-number.tsx | 17 +++++++--- src/frontend/components/root-error.tsx | 2 +- src/frontend/components/truncated-text.tsx | 33 ------------------- .../build-status/components/build-status.tsx | 8 ++--- .../library/components/group-card.tsx | 2 +- .../library/components/insertable-card.tsx | 2 +- .../components/reload-thumbnail-item.tsx | 4 +-- 9 files changed, 36 insertions(+), 57 deletions(-) delete mode 100644 src/frontend/components/truncated-text.tsx rename src/frontend/{ => features/library}/components/reload-thumbnail-item.tsx (85%) diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index a6f37cfca..1e6585263 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -17,7 +17,7 @@ import { PartNumber } from "./part-number"; import styles from "../lib/styles.module.css"; interface AppTitleProps { - title: ReactNode; + title: string; /** Leading icon, at `IconSize.MEDIUM` to match the title's size. */ icon?: ReactNode; /** A quieter second line, laid out as a row so it can hold controls. */ @@ -36,7 +36,12 @@ export function AppTitle(props: AppTitleProps): ReactNode { {icon &&
{icon}
} - + {title} {rightSection} @@ -144,7 +149,7 @@ function PartNumberLine(props: PartNumberLineProps): ReactNode { const { partNumber, url } = props; return ( <> - {partNumber} + {/* Nowhere to send them, so offer the number to search with. */} {!url && } diff --git a/src/frontend/components/item-row.tsx b/src/frontend/components/item-row.tsx index d2c04a460..4195fe139 100644 --- a/src/frontend/components/item-row.tsx +++ b/src/frontend/components/item-row.tsx @@ -3,7 +3,6 @@ import { PropsWithChildren, ReactNode } from "react"; import { meaningfulPartNumber } from "@backend/features/configurations/part-number"; import { StatusColor } from "../lib/style-constants"; import { AppContextMenu, MenuButton } from "./app-menu"; -import { TruncatedText } from "./truncated-text"; import { PartNumber } from "./part-number"; import { equalsIgnoreCase } from "@backend/lib/text"; import { mergePositions, type Position } from "../lib/highlight"; @@ -54,8 +53,9 @@ export function CardTitle(props: CardTitleProps): ReactNode { {/* Shrinks to truncate, but never grows: the badge belongs beside the name, not at the row's edge. */} - @@ -63,7 +63,7 @@ export function CardTitle(props: CardTitleProps): ReactNode { text={title} positions={match?.positions} /> - + {/* The line under the title, so it sits beside it in the stack rather than inside the paragraph the title renders as. */} {match && } @@ -100,16 +100,16 @@ function PartNameAndNumber(props: PartNameAndNumberProps): ReactNode { c={StatusColor.DIMMED} > {partName && ( - + - + )} {partName && partNumber && ·} {partNumber && ( - + + {children} ); if (!url) { return ( - + {children} ); diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index 7aaec16da..eafcc7039 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -111,7 +111,7 @@ export function NotFoundError(): ReactNode { title="Failed to find page." description={ <> - Click this button to fix the issue. If it doesn't, + Click this button to fix the issue. If that does not work, contact the FRCDesignApp developers with the address below. diff --git a/src/frontend/components/truncated-text.tsx b/src/frontend/components/truncated-text.tsx deleted file mode 100644 index a9a31c895..000000000 --- a/src/frontend/components/truncated-text.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Text, type TextProps } from "@mantine/core"; -import { type ReactNode, useRef, useState } from "react"; - -interface TruncatedTextProps extends TextProps { - /** The whole text, offered as hover text only once the render clips it. */ - hoverText: string; - /** What is drawn, e.g. the same text with its search matches underlined. */ - children: ReactNode; -} - -/** Measured on hover, so a list pays only for the row being hovered. */ -export function TruncatedText(props: TruncatedTextProps): ReactNode { - const { hoverText, children, ...others } = props; - const ref = useRef(null); - const [isClipped, setIsClipped] = useState(false); - - const measure = () => { - const element = ref.current; - setIsClipped(!!element && element.scrollWidth > element.clientWidth); - }; - - return ( - - {children} - - ); -} diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index eba718695..dad251024 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -15,7 +15,6 @@ import { import { AppIcon } from "../../../components/app-icon"; import { AppHoverCard } from "../../../components/app-hover-card"; import { RequireAccessLevel } from "../../auth/access-level"; -import { TruncatedText } from "../../../components/truncated-text"; import { useBuildStatusQuery } from "../queries"; import { useIsGroupLoading } from "../../library/queries"; import { @@ -160,15 +159,16 @@ function CardHeader(props: CardHeaderProps): ReactNode { wrap="nowrap" gap="sm" > - {name} - + Date: Thu, 24 Sep 2026 14:38:23 +0000 Subject: [PATCH 33/88] Move component defaults into the theme; decide redirects on the server The theme now sets what most instances repeated: small body text, nowrap groups, light buttons and badges, subtle gray action icons, floating shadows and arrows, centred modals, hoverable tables, and a 16px icon in button, input and menu sections. Call sites drop those props; the few exceptions say so. /auth/sign-in only stores the local path it is given, "/" otherwise. /init sends version launches to /version-error itself. The client adopts the launch once per load instead of redirecting to strip it, sign-in returns to the page it started on, and sessionCompanyId is kept with the launch so sign-in can find it after navigation. The only client redirect left is "/" to the last tab and group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 33 +++------ src/backend/features/auth/routes.ts | 67 +++-------------- .../features/auth/routes.worker.test.ts | 64 +++-------------- src/backend/features/entry/routes.ts | 5 ++ .../features/entry/routes.worker.test.ts | 12 ++++ src/frontend/components/alerts.tsx | 7 +- src/frontend/components/app-brand.tsx | 3 +- src/frontend/components/app-hover-card.tsx | 12 +--- src/frontend/components/app-menu.tsx | 3 - src/frontend/components/app-modal.tsx | 2 - src/frontend/components/app-navbar.tsx | 12 ++-- src/frontend/components/app-title.tsx | 7 +- src/frontend/components/breadcrumbs.tsx | 9 +-- src/frontend/components/callout.tsx | 6 +- src/frontend/components/change-order.tsx | 3 +- src/frontend/components/input-row.tsx | 3 +- src/frontend/components/item-row.tsx | 21 +----- .../components/open-document-items.tsx | 6 +- src/frontend/components/open-url-button.tsx | 4 +- src/frontend/components/parameter-role.tsx | 6 +- src/frontend/components/root-error.tsx | 3 +- .../components/admin-team-setting.tsx | 3 +- src/frontend/features/auth/sign-in.ts | 11 ++- .../build-status/components/build-status.tsx | 9 +-- .../build-status/components/issues.tsx | 14 +--- .../components/parsed-section.tsx | 24 +++---- .../build-status/components/sections.tsx | 4 +- .../features/dashboard/change-indicator.tsx | 8 +-- .../dashboard/configuration-breakdown.tsx | 19 ++--- .../features/dashboard/dashboard-navbar.tsx | 7 +- .../features/dashboard/health-report.tsx | 4 +- .../features/dashboard/insert-mix.tsx | 4 +- .../features/dashboard/options-table.tsx | 10 +-- .../features/dashboard/parts-table.tsx | 12 +--- .../features/dashboard/stat-tiles.tsx | 4 +- .../features/dashboard/trend-tile.tsx | 2 +- .../favorites/components/favorite-button.tsx | 2 - .../favorites/components/favorite-card.tsx | 3 +- .../favorites/components/favorite-menu.tsx | 3 +- .../components/insert-location-status.tsx | 3 +- .../insert/components/insert-menu.tsx | 6 +- .../insert/components/quick-insert-items.tsx | 6 +- .../library/components/add-group-menu.tsx | 12 +--- .../library/components/group-card.tsx | 8 +-- .../library/components/program-select.tsx | 4 +- .../library/components/reload-all-button.tsx | 5 +- .../components/reload-thumbnail-item.tsx | 3 +- .../search/components/search-errors.tsx | 2 +- .../settings/components/settings-menu.tsx | 8 +-- .../settings/components/vendor-filters.tsx | 4 +- src/frontend/lib/app-params.ts | 7 -- src/frontend/lib/notifications.tsx | 2 +- src/frontend/lib/onshape-launch.ts | 11 ++- src/frontend/routes/_pages/license.tsx | 4 +- src/frontend/routes/_pages/setup.tsx | 4 +- .../library/$libraryId/groups/$groupId.tsx | 2 +- src/frontend/routes/app/route.tsx | 72 +++++++------------ .../dashboard/library/$libraryId/part.tsx | 4 +- src/frontend/routes/index.tsx | 12 +--- src/frontend/theme.ts | 47 +++++++++++- 60 files changed, 231 insertions(+), 426 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 1cfd3488e..f8270a21b 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -112,36 +112,23 @@ The asset binding is configured with `single-page-application` mode, which means ## How Users Get Into the App -This app runs inside an Onshape iframe, which adds some authentication complexity. Here is the complete flow from first page load to seeing the part library. +The server decides where a caller lands before the app loads. The client has one redirect of its own: `/` resumes the last tab and group from `localStorage`. -### Normal flow (already logged in) +### From Onshape (`/init`) -1. Onshape loads the app's iframe by navigating to `/init?documentId=...&workspaceId=...&elementId=...` with the current document's identifiers in the URL. -2. The Worker checks if the request has a valid session cookie (`frc-design-app-cookie`) and whether the stored tokens are still valid. -3. If everything checks out, the Worker serves the React app (`index.html`), which then loads and navigates to `/app/groups`. -4. The React app calls `/api/context-data` to get user info and access level, then prefetches the library data, search index, and favorites. -5. The UI renders. +Onshape opens the panel at `/init?documentId=…&instanceType=…&elementId=…&elementType=…&server=…&sessionCompanyId=…` (`features/entry/routes.ts`). -### First-time flow (OAuth) +1. A version or microversion is sent to `/version-error`: there is nothing to insert into. +2. If Onshape won't take the caller's session, `/init` sends them through sign-in and back to itself, marked with `signInAttempted` so it never bounces twice. +3. Otherwise it redirects into the app: to the tab and group the caller's row last recorded, with Onshape's parameters kept and their saved `theme` and `tabId` added. -1. Same as above — Onshape loads `/init`. -2. The Worker finds no valid session cookie. It redirects to `/auth/sign-in?redirectUrl=`. -3. The sign-in handler generates a random `state` string (a security measure), stores `{ state, redirectUrl }` in KV under a random session ID, sets the session cookie, and redirects the user to Onshape's OAuth login page. -4. The user sees the Onshape "Authorize App" screen and clicks approve. -5. Onshape redirects back to `/auth/callback?code=...&state=...`. The sign-in names that callback as the `redirect_uri`, taken from the origin the request came in on, so Onshape returns the user to the host they signed in on rather than to whichever redirect URL it would otherwise pick — which is what lets two hosts share one OAuth app during a cutover. Every origin the app answers on has to be one of the redirect URLs registered on the OAuth app, spelled exactly. -6. The callback handler reads the session from KV, verifies the `state` matches (preventing CSRF attacks), and exchanges the `code` for real access and refresh tokens using the [Arctic](https://arcticjs.dev/) OAuth library. The exchange repeats the same `redirect_uri`, as OAuth requires. -7. The tokens are saved to KV under the session ID. The temporary login-session entry is deleted. -8. The user is redirected back to the original `/init` URL, which now succeeds because the session cookie and tokens are in place. +The `/app` route reads all of that off the url once per page load, into `ui-state` (`routes/app/route.tsx`). From then on the store is what the app reads, and in-app navigation keeps only the app's own parameters (`q`, `part`, `config`, `favorite`) in the url. -### What happens on the client after auth +### Signing in -Once the backend confirms authentication and serves the React app, the frontend takes over: +`/auth/sign-in?redirectUrl=&sessionCompanyId=…` stores `{ state, redirectUrl }` under a login cookie and sends the caller to Onshape's authorize page. An absent or offsite `redirectUrl` becomes `/`. Onshape returns to the OAuth app's registered callback, `/auth/callback`, which checks `state`, exchanges the code for tokens (via [Arctic](https://arcticjs.dev/)), starts a session, and redirects to the stored path. -1. The `/init` route normalizes the Onshape URL parameters (document ID, workspace ID, element ID, theme, etc.) and stores them in the URL query string. TanStack Router carries these parameters forward automatically via `retainSearchParams`, so child routes can always read them. -2. `/init`'s `beforeLoad` immediately redirects to `/app/groups` (or to the last-opened group if one is saved in `localStorage`). -3. The `/app` route's `beforeLoad` calls `getContextDataQuery()` — a blocking fetch that retrieves user settings (including the `libraryId` and `cacheVersion`) and access level before any child route renders. -4. The `/app` route's `loader` uses the `cacheVersion` to kick off three prefetches in parallel: the full library data, the search index, and the user's favorites. -5. With all data already in the React Query cache, the groups page renders immediately with no loading spinners. +`/init` passes itself as the path. The app's own sign-in button passes the page it is on, so a caller comes back where they were. ## Storage at a Glance diff --git a/src/backend/features/auth/routes.ts b/src/backend/features/auth/routes.ts index a007d2033..de8b3bdd5 100644 --- a/src/backend/features/auth/routes.ts +++ b/src/backend/features/auth/routes.ts @@ -1,5 +1,3 @@ -import { HttpStatus } from "http-status-ts"; -import { internalError } from "../../lib/api-error"; import { getApp } from "../../lib/context"; import { cacheMiddleware } from "../../lib/cache"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; @@ -26,73 +24,28 @@ accessRoutes.get( } ); -/** The app's own entry, which re-runs the gate and opens wherever it lands. */ -const ENTRY_PATH = "/init"; - -/** Onshape's own hosts. An enterprise is a subdomain, so the zone is allowed. */ -function isOnshapeUrl(url: URL): boolean { - return ( - url.protocol === "https:" && - (url.hostname === "onshape.com" || - url.hostname.endsWith(".onshape.com")) - ); -} - -/** - * `redirectUrl` is ours, built by `/init`, and wins. `redirectOnshapeUri` is - * Onshape's and is only taken as an absolute Onshape url, since the callback - * redirects to it unread. Anything else falls back to the entry: Onshape - * doesn't document what it sends, and opening the app beats failing sign-in. - */ -function getSignInRedirect(query: Record): string | undefined { - const { redirectUrl, redirectOnshapeUri } = query; - if (redirectUrl?.startsWith("/") && !redirectUrl.startsWith("//")) { - return redirectUrl; - } - if (!redirectOnshapeUri) { - return undefined; - } - try { - if (isOnshapeUrl(new URL(redirectOnshapeUri))) { - return redirectOnshapeUri; - } - } catch { - // Not a url at all, which the fallback covers along with a bad one. +/** A path within the app, so the parameter cannot forward a caller offsite. */ +function toLocalPath(redirectUrl: string | undefined): string { + if (!redirectUrl?.startsWith("/") || redirectUrl.startsWith("//")) { + return "/"; } - return ENTRY_PATH; + return redirectUrl; } +/** GET /auth/sign-in?redirectUrl=&sessionCompanyId= */ authRoutes.get("/sign-in", async (c) => { - const query = c.req.query(); - - const redirectUrl = getSignInRedirect(query); - if (!redirectUrl) { - throw internalError( - "Failed to find valid redirectUrl", - HttpStatus.BAD_REQUEST - ); - } - + const redirectUrl = toLocalPath(c.req.query("redirectUrl")); // Absent standalone, so the user can pick their account on Onshape. - const companyId = query.sessionCompanyId; - const authorizationUrl = await doSignIn(c, redirectUrl, companyId); - return c.redirect(authorizationUrl); + const companyId = c.req.query("sessionCompanyId"); + return c.redirect(await doSignIn(c, redirectUrl, companyId)); }); /** Standalone only: inside Onshape, the session is Onshape's to end. */ authRoutes.get("/sign-out", async (c) => { await endSession(c); - return c.redirect(getLocalRedirect(c.req.query("redirectUrl"))); + return c.redirect(toLocalPath(c.req.query("redirectUrl"))); }); -/** A path within the app, so the parameter cannot forward a caller offsite. */ -function getLocalRedirect(redirectUrl: string | undefined): string { - if (!redirectUrl?.startsWith("/") || redirectUrl.startsWith("//")) { - return "/"; - } - return redirectUrl; -} - authRoutes.get("/callback", async (c) => { return doCallback(c); }); diff --git a/src/backend/features/auth/routes.worker.test.ts b/src/backend/features/auth/routes.worker.test.ts index b216614b8..83bbf28df 100644 --- a/src/backend/features/auth/routes.worker.test.ts +++ b/src/backend/features/auth/routes.worker.test.ts @@ -176,63 +176,21 @@ describe("GET /auth/sign-in redirect target", () => { return (JSON.parse(raw!) as { redirectUrl: string }).redirectUrl; } - it("returns the caller to the launch /init named", async () => { + it("returns the caller to the path it was given", async () => { expect( await storedRedirect("redirectUrl=%2Finit%3FdocumentId%3Ddoc-1") ).toBe("/init?documentId=doc-1"); }); - // Onshape's own value names Onshape, and an enterprise is a subdomain. - it("takes an Onshape url from Onshape", async () => { - expect( - await storedRedirect( - "redirectOnshapeUri=https%3A%2F%2Fcompany.onshape.com%2Fdocuments%2Fdoc-1" - ) - ).toBe("https://company.onshape.com/documents/doc-1"); - }); - - // Ours carries the element the panel was opened on; Onshape's does not. - it("prefers the launch over Onshape's own value", async () => { - expect( - await storedRedirect( - "redirectUrl=%2Finit%3FdocumentId%3Ddoc-1&redirectOnshapeUri=https%3A%2F%2Fcad.onshape.com%2Fdocuments" - ) - ).toBe("/init?documentId=doc-1"); - }); - - // Resolved against this app it has no route, stranding the caller. - it("refuses a bare path, which would resolve against this app", async () => { - expect( - await storedRedirect( - "redirectOnshapeUri=%2Fdocuments%2Fdoc-1%2Fw%2Fws-1%2Fe%2Fel-1" - ) - ).toBe("/init"); - }); - - it("refuses an origin that is not Onshape", async () => { - expect( - await storedRedirect( - "redirectOnshapeUri=https%3A%2F%2Fevil.com%2Fx" - ) - ).toBe("/init"); - // A host that merely ends in the zone's spelling is not in it. - expect( - await storedRedirect( - "redirectOnshapeUri=https%3A%2F%2Fnotonshape.com%2Fx" - ) - ).toBe("/init"); - }); - - // Opening the app beats blocking sign-in if this reading is too narrow. - it("opens the app rather than refusing a value it cannot place", async () => { - expect(await storedRedirect("redirectOnshapeUri=not-a-url")).toBe( - "/init" - ); - }); - - it("refuses a protocol-relative url, which names another host", async () => { - expect( - await storedRedirect("redirectUrl=%2F%2Fevil.com") - ).toBeUndefined(); + it.each([ + ["nothing", ""], + ["an absolute url", "redirectUrl=https%3A%2F%2Fevil.com%2Fx"], + ["a protocol-relative url", "redirectUrl=%2F%2Fevil.com"], + [ + "Onshape's own redirect", + "redirectOnshapeUri=https%3A%2F%2Fcad.onshape.com%2Fdocuments" + ] + ])("sends the caller home given %s", async (_, query) => { + expect(await storedRedirect(query)).toBe("/"); }); }); diff --git a/src/backend/features/entry/routes.ts b/src/backend/features/entry/routes.ts index ca3db1825..d47651353 100644 --- a/src/backend/features/entry/routes.ts +++ b/src/backend/features/entry/routes.ts @@ -105,6 +105,11 @@ export const entryRoutes = getApp(); /** GET /init */ entryRoutes.get("/init", cacheMiddleware(), async (c) => { + // A version can't be changed, so there is nothing to insert into. + const instanceType = c.req.query("instanceType"); + if (instanceType === "v" || instanceType === "m") { + return c.redirect("/version-error"); + } if (await needsSignIn(c)) { return c.redirect(getSignInUrl(c)); } diff --git a/src/backend/features/entry/routes.worker.test.ts b/src/backend/features/entry/routes.worker.test.ts index 739997387..b909a9e96 100644 --- a/src/backend/features/entry/routes.worker.test.ts +++ b/src/backend/features/entry/routes.worker.test.ts @@ -290,6 +290,18 @@ describe("GET /init", () => { expect(await db.select().from(events).get()).toBeUndefined(); }); + it.each(["v", "m"])( + "sends a launch in a %s instance to the version error", + async (instanceType) => { + const res = await createTestApp().request( + `/init?documentId=doc&instanceType=${instanceType}`, + jsonRequest("GET"), + env + ); + expect(res.headers.get("Location")).toBe("/version-error"); + } + ); + it("never caches the gate's verdict", async () => { const res = await createTestApp().request( "/init", diff --git a/src/frontend/components/alerts.tsx b/src/frontend/components/alerts.tsx index f1d6ea110..679f06ef9 100644 --- a/src/frontend/components/alerts.tsx +++ b/src/frontend/components/alerts.tsx @@ -27,12 +27,7 @@ function openWarningAlert(props: OpenWarningAlertProps): void { children: ( // Takes focus from Close, which would look pre-selected. No outline, which // would make the text look editable. - + {props.text} ), diff --git a/src/frontend/components/app-brand.tsx b/src/frontend/components/app-brand.tsx index cd0f07c39..6b4d6f2f0 100644 --- a/src/frontend/components/app-brand.tsx +++ b/src/frontend/components/app-brand.tsx @@ -47,7 +47,7 @@ export function AppBrandMark(props: AppBrandMarkProps): ReactNode { /** The book and the app's name, in every navbar, linking out to FRCDesign.org. */ export function AppBrand(): ReactNode { return ( - +
@@ -56,7 +56,6 @@ export function AppBrand(): ReactNode { href={FRC_DESIGN_URL} target="_blank" fw={FontWeight.BOLD} - size="sm" c="inherit" td="none" > diff --git a/src/frontend/components/app-hover-card.tsx b/src/frontend/components/app-hover-card.tsx index 5b50f7dd7..55f308491 100644 --- a/src/frontend/components/app-hover-card.tsx +++ b/src/frontend/components/app-hover-card.tsx @@ -34,14 +34,6 @@ export function AppHoverCard(props: AppHoverCardProps): ReactNode { getInitialValueInEffect: false }); - const shared = { - ...popoverProps, - // So a card beside a row on a phone is pushed on screen, not cut off. - middlewares: { flip: true, shift: { crossAxis: true, padding: 8 } }, - withinPortal: true, - shadow: "md", - withArrow: true - }; const targetBox = ( {target} @@ -56,7 +48,7 @@ export function AppHoverCard(props: AppHoverCardProps): ReactNode { if (canHover) { return ( @@ -69,7 +61,7 @@ export function AppHoverCard(props: AppHoverCardProps): ReactNode { } return ( e.stopPropagation()} diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx index 91f658ff5..a9e292808 100644 --- a/src/frontend/components/app-modal.tsx +++ b/src/frontend/components/app-modal.tsx @@ -48,7 +48,6 @@ export function AppModal(props: AppModalProps): ReactNode { onClose={onClose ?? (() => undefined)} title={title} size={size} - centered withCloseButton={dismissible} closeOnClickOutside={dismissible} closeOnEscape={dismissible} @@ -88,7 +87,6 @@ export function AppModalFooter(props: PropsWithChildren): ReactNode { return ( @@ -79,14 +77,14 @@ export function AppNavbar(): ReactNode { - + - + @@ -182,8 +180,6 @@ function AppTabs(): ReactNode { export function SettingsButton() { return ( } + leftSection={} placeholder={`Search ${getLibraryName(libraryId)}...`} ref={ref} value={query} diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 1e6585263..11f6b85d2 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -30,13 +30,14 @@ interface AppTitleProps { export function AppTitle(props: AppTitleProps): ReactNode { const { title, icon, subtitle, rightSection } = props; return ( - + {/* Centred, not wrapped in a block, where the icon would go back to sitting on the text baseline several pixels low. */} {icon &&
{icon}
} - + ( {current} - ) : ( - current - ); + const end = typeof current === "string" ? {current} : current; if (crumbs.length === 0) { return end; } @@ -36,7 +31,7 @@ export function AppBreadcrumbs(props: AppBreadcrumbsProps): ReactNode { {crumb.label} ) : ( - + {crumb.label} ) diff --git a/src/frontend/components/callout.tsx b/src/frontend/components/callout.tsx index a8f2dccb7..93e7a1bbb 100644 --- a/src/frontend/components/callout.tsx +++ b/src/frontend/components/callout.tsx @@ -27,10 +27,8 @@ export function Callout(props: CalloutProps): ReactNode { > {/* Wraps rather than squeezing: on a narrow panel the button drops under the text instead of running off the edge. */} - - - {text} - + + {text} {action} diff --git a/src/frontend/components/change-order.tsx b/src/frontend/components/change-order.tsx index edc86670d..726d88e19 100644 --- a/src/frontend/components/change-order.tsx +++ b/src/frontend/components/change-order.tsx @@ -5,7 +5,6 @@ import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react"; -import { IconSize } from "../lib/style-constants"; import { type ReactNode } from "react"; interface ChangeOrderMenuProps { @@ -22,7 +21,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { (item) => ( } + leftSection={} onClick={() => onOrderChange(applyMoveOperation(id, order, item.operation)) } diff --git a/src/frontend/components/input-row.tsx b/src/frontend/components/input-row.tsx index ff0e32b6c..ecaede8d5 100644 --- a/src/frontend/components/input-row.tsx +++ b/src/frontend/components/input-row.tsx @@ -13,9 +13,8 @@ interface InputRowProps { export function InputRow(props: InputRowProps): ReactNode { const { label, htmlFor, children } = props; return ( - + + {thumbnail} {/* Shrinks to truncate, but never grows: the badge belongs beside the name, not at the row's edge. */} @@ -56,7 +50,6 @@ export function CardTitle(props: CardTitleProps): ReactNode { + {partName && ( - + {left} } + leftSection={} onClick={() => openUrlInNewTab(url)} > Open document } + leftSection={} onClick={() => { void copyUrlToClipboard(url); }} diff --git a/src/frontend/components/open-url-button.tsx b/src/frontend/components/open-url-button.tsx index c52613fc7..e176fc6d8 100644 --- a/src/frontend/components/open-url-button.tsx +++ b/src/frontend/components/open-url-button.tsx @@ -1,6 +1,5 @@ import { Button } from "@mantine/core"; import { ArrowSquareOutIcon } from "@phosphor-icons/react"; -import { IconSize } from "../lib/style-constants"; import { openUrlInNewTab } from "../lib/url"; interface UrlButtonProps { @@ -11,9 +10,8 @@ interface UrlButtonProps { export function OpenUrlButton(props: UrlButtonProps) { return ( diff --git a/src/frontend/components/parameter-role.tsx b/src/frontend/components/parameter-role.tsx index 39642c9ca..f9e80ce2e 100644 --- a/src/frontend/components/parameter-role.tsx +++ b/src/frontend/components/parameter-role.tsx @@ -35,11 +35,9 @@ export function ParameterRoleLabel(props: ParameterRoleLabelProps): ReactNode { const { role, suffix = "" } = props; const RoleIcon = ROLE_ICONS[role]; return ( - + - - {ROLE_LABELS[role] + suffix} - + {ROLE_LABELS[role] + suffix} ); } diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index eafcc7039..f2a46430d 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -57,7 +57,7 @@ function MissedUrl(): ReactNode { const url = window.location.href; return ( - + ( - + @@ -195,8 +194,6 @@ function RefreshButton(): ReactNode { void queryClient.invalidateQueries({ diff --git a/src/frontend/features/dashboard/health-report.tsx b/src/frontend/features/dashboard/health-report.tsx index dda27508b..684383aff 100644 --- a/src/frontend/features/dashboard/health-report.tsx +++ b/src/frontend/features/dashboard/health-report.tsx @@ -41,10 +41,10 @@ export function HealthTiles({ counts }: HealthTilesProps): ReactNode { return ( {tiles.map((tile) => ( - + {tile.icon} - + {tile.label} diff --git a/src/frontend/features/dashboard/insert-mix.tsx b/src/frontend/features/dashboard/insert-mix.tsx index cfcc92f67..bbb4eab59 100644 --- a/src/frontend/features/dashboard/insert-mix.tsx +++ b/src/frontend/features/dashboard/insert-mix.tsx @@ -34,8 +34,8 @@ export function InsertSourceBreakdown({ {sources.map((source) => (
- {SOURCE_LABELS[source.source]} - + {SOURCE_LABELS[source.source]} + {formatCount(source.count)} ( {formatFraction(source.count, total)}) diff --git a/src/frontend/features/dashboard/options-table.tsx b/src/frontend/features/dashboard/options-table.tsx index 31a706b33..3526d3f00 100644 --- a/src/frontend/features/dashboard/options-table.tsx +++ b/src/frontend/features/dashboard/options-table.tsx @@ -44,7 +44,7 @@ export function OptionsTable({ return ( <> - +
{COLUMNS.map((column, index) => ( @@ -130,9 +130,7 @@ function OptionLabel({ value }: OptionLabelProps): ReactNode { {value.label} {value.count === 0 && ( - - Never used - + Never used )} {/* A default nobody picks is the strongest signal the parameter is wrong. An implicit one is stronger still: it is what this @@ -141,9 +139,7 @@ function OptionLabel({ value }: OptionLabelProps): ReactNode { )} {value.isDefault && ( - - Default - + Default )} ); diff --git a/src/frontend/features/dashboard/parts-table.tsx b/src/frontend/features/dashboard/parts-table.tsx index d9fce1a72..695c78131 100644 --- a/src/frontend/features/dashboard/parts-table.tsx +++ b/src/frontend/features/dashboard/parts-table.tsx @@ -97,7 +97,7 @@ export function PartsTable({ return ( <> -
+
{SORTABLE_COLUMNS.map((heading) => ( @@ -159,11 +159,7 @@ function SortableTh({ onClick={() => onToggle(column)} style={{ cursor: "pointer", userSelect: "none" }} > - + {label} {/* Reserved even when inactive, so the header never reflows. */} {part.name} {!part.isVisible && ( - - Hidden - + Hidden )} diff --git a/src/frontend/features/dashboard/stat-tiles.tsx b/src/frontend/features/dashboard/stat-tiles.tsx index 918353c58..0a5fd4fbb 100644 --- a/src/frontend/features/dashboard/stat-tiles.tsx +++ b/src/frontend/features/dashboard/stat-tiles.tsx @@ -27,9 +27,9 @@ export function StatTile({ }: StatTileProps): ReactNode { return ( - +
- + {label} {format(value)} diff --git a/src/frontend/features/dashboard/trend-tile.tsx b/src/frontend/features/dashboard/trend-tile.tsx index 9fe704254..c2fb5b218 100644 --- a/src/frontend/features/dashboard/trend-tile.tsx +++ b/src/frontend/features/dashboard/trend-tile.tsx @@ -43,7 +43,7 @@ export function TrendTile({ return ( - + {metric.label} {value} diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index 35a95278e..0ecca4e02 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -147,8 +147,6 @@ export function FavoriteButton(props: FavoriteButtonProps): ReactNode { return ( { event.stopPropagation(); diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index f08eace3f..f21255276 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -4,7 +4,6 @@ import { Favorite } from "@backend/features/favorites/contract"; import { InsertableOut } from "@backend/features/library/contract"; import { Menu } from "@mantine/core"; import { PencilIcon } from "@phosphor-icons/react"; -import { IconSize } from "../../../lib/style-constants"; import { openInsertMenu } from "../../insert/open-insert-menu"; import { openFavoriteMenu } from "../open-favorite-menu"; import { FavoriteButton, FavoriteInsertableItem } from "./favorite-button"; @@ -136,7 +135,7 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { )} } + leftSection={} onClick={() => { if (!insertable.isConfigurable) { openCannotEditDefaultConfigurationAlert(); diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 4ce133a7b..bb9c845fb 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -97,9 +97,8 @@ export function FavoriteMenuContent( ); @@ -67,7 +61,7 @@ interface AddGroupItemProps { export function AddGroupItem(props: AddGroupItemProps): ReactNode { return ( } + leftSection={} onClick={() => openAddGroupMenu(props.selectedGroupId)} > Add group diff --git a/src/frontend/features/library/components/group-card.tsx b/src/frontend/features/library/components/group-card.tsx index a7ea60ed7..e04e838e9 100644 --- a/src/frontend/features/library/components/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -144,7 +144,7 @@ function ShowAllElementsMenuItem(props: AllElementsVisibilityProps): ReactNode { return ( } + leftSection={} onClick={() => mutation.mutate()} > Show all elements @@ -157,7 +157,7 @@ function HideAllElementsMenuItem(props: AllElementsVisibilityProps): ReactNode { return ( } + leftSection={} onClick={() => mutation.mutate()} > Hide all elements @@ -189,7 +189,7 @@ function DeleteGroupMenuItem(props: DeleteGroupMenuItemProps): ReactNode { /> ), children: ( - + {`Are you sure you want to delete ${name}? Its elements are deleted with it, and permanently removed from every user's favorites.`} ), @@ -202,7 +202,7 @@ function DeleteGroupMenuItem(props: DeleteGroupMenuItemProps): ReactNode { return ( } + leftSection={} color={StatusColor.ERROR} onClick={confirmDelete} > diff --git a/src/frontend/features/library/components/program-select.tsx b/src/frontend/features/library/components/program-select.tsx index 3f88a83dc..ab235065e 100644 --- a/src/frontend/features/library/components/program-select.tsx +++ b/src/frontend/features/library/components/program-select.tsx @@ -58,7 +58,7 @@ function ProgramCard(props: ProgramCardProps): ReactNode { onSelect(libraryId); }} > - + {/* In the library's own color, so the two choices read as the two libraries they open. */} - {getLibraryName(libraryId)} + {getLibraryName(libraryId)} diff --git a/src/frontend/features/library/components/reload-all-button.tsx b/src/frontend/features/library/components/reload-all-button.tsx index 1d635a39c..b4ad88470 100644 --- a/src/frontend/features/library/components/reload-all-button.tsx +++ b/src/frontend/features/library/components/reload-all-button.tsx @@ -26,7 +26,7 @@ export function ReloadAllButton(): ReactNode { /> ), children: ( - + Are you sure you want to reload every document in every library? This is an expensive operation. New versions of documents are already reloaded on their own. @@ -41,9 +41,8 @@ export function ReloadAllButton(): ReactNode { return ( - ); -} diff --git a/src/frontend/features/library/components/reload-buttons.tsx b/src/frontend/features/library/components/reload-buttons.tsx new file mode 100644 index 000000000..8946f7948 --- /dev/null +++ b/src/frontend/features/library/components/reload-buttons.tsx @@ -0,0 +1,71 @@ +import { ReloadScope, useReloadMutation } from "../queries"; +import { Button, Group, Text } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { ArrowsClockwiseIcon, WarningIcon } from "@phosphor-icons/react"; +import { AppIcon } from "../../../components/app-icon"; +import { AppTitle } from "../../../components/app-title"; +import { IconSize, StatusColor } from "../../../lib/style-constants"; +import { ReactNode } from "react"; + +interface ReloadButtonsProps { + scope: ReloadScope; +} + +/** + * A reload picks up versions a webhook missed and reruns failed loads. A + * forced one rereads every document, which spends a lot of the account's + * Onshape allocation, so it asks first. + */ +export function ReloadButtons(props: ReloadButtonsProps): ReactNode { + const { scope } = props; + const mutation = useReloadMutation(scope); + const what = + scope === ReloadScope.ALL + ? "every document in every library" + : "every document in this library"; + + const confirmForce = () => { + modals.openConfirmModal({ + title: ( + + } + title="Force reload" + /> + ), + children: ( + + Force reload {what}, whether or not it changed? This is an + expensive operation. + + ), + labels: { confirm: "Force reload", cancel: "Cancel" }, + confirmProps: { color: StatusColor.ERROR }, + onConfirm: () => mutation.mutate(true) + }); + }; + + return ( + + + + + ); +} diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 19591460c..9fbb7391e 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -7,7 +7,10 @@ import { } from "@tanstack/react-query"; import { apiDelete, apiGet, apiPost } from "../../lib/api-client"; import { type LibraryOut } from "@backend/features/library/contract"; -import { type JobStatus } from "@backend/features/load/contract"; +import { + type JobStatus, + type ReloadOut +} from "@backend/features/load/contract"; import { hasEditorAccess } from "@backend/features/auth/access-level"; import { LibraryId } from "@backend/features/library/library-id"; import { useAccessData } from "../auth/access-level"; @@ -154,12 +157,18 @@ export function useSetGroupOrderMutation() { }); } -/** Force reloads every document in every library; the owner's alone. */ -export function useReloadAllMutation() { +/** One library's documents for its admins, or every library's for the owner. */ +export function useReloadMutation(scope: ReloadScope) { + const libraryId = useLibraryId(); return useMutation({ - mutationKey: ["reload-all"], - mutationFn: (): Promise<{ documents: number }> => - apiPost("/reload-all"), + mutationKey: ["reload", scope, libraryId], + mutationFn: (force: boolean): Promise => + apiPost( + scope === ReloadScope.ALL + ? "/reload-all" + : "/reload" + toLibraryPath(libraryId), + { body: { force } } + ), onError: getAppErrorHandler("Failed to reload documents!"), onSuccess: (data) => { showInfoToast(`Reloading ${data.documents} documents...`); @@ -167,6 +176,11 @@ export function useReloadAllMutation() { }); } +export enum ReloadScope { + LIBRARY = "library", + ALL = "all" +} + /** Adds an Onshape document to the library, by its url. */ export function useAddGroupMutation(selectedGroupId?: string) { const libraryId = useLibraryId(); diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 75251b5f9..d039e2e45 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -23,7 +23,8 @@ import { useGetUiState, updateUiState } from "../../../lib/ui-state"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { SETUP_URL } from "../../../lib/url"; import { useLibraryId } from "../../../lib/library"; -import { ReloadAllButton } from "../../library/components/reload-all-button"; +import { ReloadButtons } from "../../library/components/reload-buttons"; +import { ReloadScope } from "../../library/queries"; import { AdminTeamSetting } from "../../admin-team/components/admin-team-setting"; /** The FRCDesign Discord, where feedback and support now live. */ @@ -199,10 +200,15 @@ function AdminSettings(): ReactNode { {/* Always show the access level select so admins can change access level if needed */} + + + + + - - + + diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 1a1951271..5f7d5472f 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -184,7 +184,7 @@ function GroupListContent(props: GroupListCardsProps): ReactNode { ) : ( ); } From 668eacc2bbb22b5dd6ebbbb0679531feef218047 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:01:57 +0000 Subject: [PATCH 36/88] Loads find an admin session themselves; drop team webhooks; restore reload buttons - A load calls Onshape as whoever asked for it while their session works, and otherwise as the owner or a team admin of the library, found once a run. A webhook's load names nobody, so the version creator lookup and VERSION_NOT_LOADED go; a load with no session fails as LOAD_FAILED. - Sessions are kept by user id only for the owner and team admins, under admin-session:. - Team webhooks need a company id a personal account lacks, so they are gone. Library admins get Refresh members, which pulls the team again. - Reload is per library again: "Reload outdated documents" for admins, "Reload all documents" (forced) for the owner. The every-library route is gone. - The unused Onshape team access-level endpoint is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 16 +- src/backend/db/schema.ts | 5 +- src/backend/features/admin-team/routes.ts | 46 ++--- .../features/admin-team/routes.worker.test.ts | 59 +++--- src/backend/features/admin-team/sync.ts | 12 -- src/backend/features/auth/admin-sessions.ts | 76 ++++++++ .../auth/admin-sessions.worker.test.ts | 69 +++++++ src/backend/features/auth/request-auth.ts | 17 +- .../features/auth/request-auth.worker.test.ts | 8 +- src/backend/features/auth/user-sessions.ts | 56 ------ src/backend/features/build-checker/issues.ts | 11 +- src/backend/features/load/context.ts | 37 +++- .../features/load/context.worker.test.ts | 61 ++++++ src/backend/features/load/flag.ts | 13 +- src/backend/features/load/jobs.ts | 12 +- .../features/load/load-group.worker.test.ts | 1 + .../load/load-insertable.worker.test.ts | 2 + src/backend/features/load/routes.ts | 68 ++++--- .../features/load/routes.worker.test.ts | 57 +++--- src/backend/features/load/workflows.ts | 14 +- src/backend/features/webhooks/registration.ts | 37 +--- .../webhooks/registration.worker.test.ts | 19 -- src/backend/features/webhooks/routes.ts | 118 +----------- .../features/webhooks/routes.worker.test.ts | 175 ++---------------- src/backend/lib/onshape/endpoints/users.ts | 21 +-- src/frontend/components/root-error.tsx | 5 +- .../components/refresh-admin-team-button.tsx | 17 ++ src/frontend/features/admin-team/queries.ts | 21 +++ .../library/components/reload-button.tsx | 62 +++++++ .../library/components/reload-buttons.tsx | 71 ------- src/frontend/features/library/queries.ts | 22 +-- .../settings/components/settings-menu.tsx | 17 +- .../library/$libraryId/groups/$groupId.tsx | 2 +- 33 files changed, 555 insertions(+), 672 deletions(-) create mode 100644 src/backend/features/auth/admin-sessions.ts create mode 100644 src/backend/features/auth/admin-sessions.worker.test.ts delete mode 100644 src/backend/features/auth/user-sessions.ts create mode 100644 src/backend/features/load/context.worker.test.ts create mode 100644 src/frontend/features/admin-team/components/refresh-admin-team-button.tsx create mode 100644 src/frontend/features/library/components/reload-button.tsx delete mode 100644 src/frontend/features/library/components/reload-buttons.tsx diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index b1351f544..6047c1089 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -91,21 +91,19 @@ Cloudflare Workflows let you run a long-running background job that survives bey Loading a group means walking the document structure, downloading metadata for every part and assembly, probing each indexed configuration, generating thumbnails, and writing it all to D1 — far too long for a single HTTP request. The request kicks the workflow off and returns immediately. -Every load is one document: adding a document, a new version of one (see Webhooks below), and the owner's "reload everything", which starts one per group. `features/load/jobs.ts` keeps at most one load per group running, in the `load_jobs` table: a load asked for while one runs is marked on that row, and the running load starts it as it finishes. Each load that wrote to its group rebuilds its library's search index and bumps its version, so every load stands alone and "reload everything" simply starts them all at once. +Every load is one document: adding a document, a new version of one (see Webhooks below), and a library admin's reload of the library's outdated documents (or the owner's reload of all of them), which starts one per group. `features/load/jobs.ts` keeps at most one load per group running, in the `load_jobs` table: a load asked for while one runs is marked on that row, and the running load starts it as it finishes. Each load that wrote to its group rebuilds its library's search index and bumps its version, so every load stands alone and a reload simply starts them all at once. -Each workflow carries a `sessionId` whose tokens it calls Onshape under after the request has ended: the requesting user's, or for a webhook, the session chosen as described below. +A load calls Onshape as whoever asked for it, while their session works. A webhook's load has nobody, and a requester's session can expire mid-load, so the load then finds a session itself: the owner's, or else a team admin's of the library (`getOnshapeApiFromContext`). Only the owner's and team admins' latest sessions are kept by user id, in KV under `admin-session:`, written as their access is checked (`features/auth/admin-sessions.ts`). ### Webhooks and live updates -Onshape pushes two things, registered with `isTransient: false` and recorded in the `onshape_webhooks` table, each with its own token in the delivery url (`features/webhooks`): +Onshape pushes one thing, registered with `isTransient: false` and recorded in the `onshape_webhooks` table with its own token in the delivery url (`features/webhooks`): -- **A new version of a library document.** Registered by the document's load; removed with the last group loaded from it. Reloads that document's groups, as whoever made the version if their latest session still works, else as the owner. Reading the version to learn its creator takes a working session too: the owner's, or failing that, an admin's of a library holding the document. With none, the groups are flagged `VERSION_NOT_LOADED` for an admin to reload. +- **A new version of a library document.** Registered by the document's load; removed with the last group loaded from it. Reloads that document's groups. -Every user's latest session is kept in KV under `user-session:`, written as their access is checked (`features/auth/user-sessions.ts`). +Onshape's team webhooks need a company id, which a personal account lacks, so an admin team's membership is pulled again only when the owner sets the team or an admin presses **Refresh members** in the settings menu. -A load that fails is flagged `LOAD_FAILED`, including one whose workflow crashed before it could say so; the next look at the library's jobs notices. Both flags ask for a reload, which a library's admins can start from the settings menu (the owner can for every library), with or without forcing it. - -- **A change to an admin team's members.** Registered when the owner sets a library's admin team. Pulls the team's members again. +A load that fails is flagged `LOAD_FAILED`, including one whose workflow crashed before it could say so; the next look at the library's jobs notices. Reloading the library's outdated documents reruns it. The server pushes to open clients over a WebSocket held by the `LiveUpdates` Durable Object (`features/live`): jobs starting and finishing, a library's new version, and a configuration's render landing. Nothing polls: a client that reconnects asks again for what it may have missed. @@ -191,6 +189,6 @@ Other top-level files: The app has four access levels, checked on every protected API call: **OWNER**, **ADMIN**, **EDITOR**, and **USER**. Access is per library. Admin and editor access currently grant the same permissions in their library (adding, removing, and renaming groups, toggling insertable visibility), but they are kept separate so permissions can be tightened in the future if needed. USER access allows anyone who logs in via OAuth to browse the library, insert parts, and manage their own favorites. -The **owner** is the one Onshape user named by `OWNER_USER_ID`, with every library. The owner sets each library's admin team; its members are stored in `admin_team_members` (team admins as ADMIN, members as EDITOR) and kept current by the team's webhook, so a user's access is a database lookup in `src/backend/features/auth/request-auth.ts`. Routes that require elevated access are wrapped with `requireEditor`, `requireAdminMiddleware` or `requireOwnerMiddleware` from `src/backend/features/auth/guards.ts`; one naming an insertable rather than a library looks the library up from it. +The **owner** is the one Onshape user named by `OWNER_USER_ID`, with every library. The owner sets each library's admin team; its members are stored in `admin_team_members` (team admins as ADMIN, members as EDITOR) and pulled again on demand, so a user's access is a database lookup in `src/backend/features/auth/request-auth.ts`. Routes that require elevated access are wrapped with `requireEditor`, `requireAdminMiddleware` or `requireOwnerMiddleware` from `src/backend/features/auth/guards.ts`; one naming an insertable rather than a library looks the library up from it. During local development, you can bypass the team membership check by setting `ACCESS_LEVEL_OVERRIDE=admin` (or `editor`/`user`) in your `.env` file. diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 1bb628f6d..501460425 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -210,8 +210,7 @@ export const favorites = sqliteTable( ); export enum WebhookSubject { - DOCUMENT = "document", - TEAM = "team" + DOCUMENT = "document" } /** @@ -222,7 +221,7 @@ export const onshapeWebhooks = sqliteTable( "onshape_webhooks", { subject: text("subject").$type().notNull(), - // A document id or a team id, by subject. + // The document id. subjectId: text("subject_id").notNull(), webhookId: text("webhook_id"), token: text("token").notNull().unique() diff --git a/src/backend/features/admin-team/routes.ts b/src/backend/features/admin-team/routes.ts index 169ee3f8c..69315f898 100644 --- a/src/backend/features/admin-team/routes.ts +++ b/src/backend/features/admin-team/routes.ts @@ -6,13 +6,12 @@ import { handledError } from "../../lib/api-error"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { validate } from "../../lib/validate"; import { type Db, getDb } from "../../db/client"; -import { adminTeamMembers, libraries, WebhookSubject } from "../../db/schema"; -import { requireOwnerMiddleware } from "../auth/guards"; +import { adminTeamMembers, libraries } from "../../db/schema"; +import { requireAdminMiddleware, requireOwnerMiddleware } from "../auth/guards"; import { ensureLibrary } from "../library/db"; import type { LibraryId } from "../library/library-id"; -import { ensureWebhook, removeWebhook } from "../webhooks/registration"; import type { AdminTeamOut } from "./contract"; -import { librariesOfTeam, syncAdminTeam } from "./sync"; +import { syncAdminTeam } from "./sync"; export const adminTeamRoutes = getApp(); @@ -50,7 +49,7 @@ adminTeamRoutes.get( async (c) => c.json(await getAdminTeam(getDb(c.env.DB), getLibraryParam(c))) ); -/** POST /api/admin-team/library/:libraryId: sets the team, pulls its members, registers its webhook. */ +/** POST /api/admin-team/library/:libraryId: sets the team and pulls its members. */ adminTeamRoutes.post( "/admin-team" + libraryRoute(), requireOwnerMiddleware, @@ -83,28 +82,21 @@ adminTeamRoutes.post( ); } - const origin = new URL(c.req.url).origin; - if (teamId) { - await ensureWebhook( - c.env, - onshapeApi, - WebhookSubject.TEAM, - teamId, - origin - ); - } - if ( - previous && - previous !== teamId && - (await librariesOfTeam(c.env, previous)).length === 0 - ) { - await removeWebhook( - c.env, - onshapeApi, - WebhookSubject.TEAM, - previous - ); - } return c.json(await getAdminTeam(db, libraryId)); } ); + +/** + * POST /api/admin-team/refresh/library/:libraryId: pulls the team's members + * again. Onshape's team webhooks need a company, which a personal account + * lacks, so a change in Onshape waits for this. + */ +adminTeamRoutes.post( + "/admin-team/refresh" + libraryRoute(), + requireAdminMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + await syncAdminTeam(c.env, await c.var.getOnshapeApi(), libraryId); + return c.json(await getAdminTeam(getDb(c.env.DB), libraryId)); + } +); diff --git a/src/backend/features/admin-team/routes.worker.test.ts b/src/backend/features/admin-team/routes.worker.test.ts index 6088fee2b..429da108d 100644 --- a/src/backend/features/admin-team/routes.worker.test.ts +++ b/src/backend/features/admin-team/routes.worker.test.ts @@ -10,12 +10,7 @@ import { } from "../../../__test_utils__"; import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; import { getDb } from "../../db/client"; -import { - adminTeamMembers, - libraries, - onshapeWebhooks, - WebhookSubject -} from "../../db/schema"; +import { adminTeamMembers, libraries } from "../../db/schema"; import { OnshapeApiError } from "../../lib/onshape/client"; import { AccessLevel } from "../auth/access-level"; @@ -39,10 +34,7 @@ function mockOnshape() { } return Promise.reject(new OnshapeApiError("no such team", 404)); }); - const post = vi - .spyOn(onshapeApi, "post") - .mockResolvedValue({ id: "team-webhook" }); - return { onshapeApi, post }; + return { onshapeApi }; } function setTeam(teamId: string | null, onshapeApi: MockOnshapeApi) { @@ -66,8 +58,8 @@ describe("setting a library's admin team", () => { expect(res.status).toBe(403); }); - it("stores the team's members and registers its webhook", async () => { - const { onshapeApi, post } = mockOnshape(); + it("stores the team's members", async () => { + const { onshapeApi } = mockOnshape(); const res = await setTeam("team", onshapeApi); @@ -84,13 +76,6 @@ describe("setting a library's admin team", () => { { userId: "member", isTeamAdmin: false }, { userId: "team-admin", isTeamAdmin: true } ]); - expect(post).toHaveBeenCalledOnce(); - expect( - await db - .select({ subject: onshapeWebhooks.subject }) - .from(onshapeWebhooks) - .all() - ).toEqual([{ subject: WebhookSubject.TEAM }]); }); // A mistyped id should not lock everyone but the owner out. @@ -109,16 +94,42 @@ describe("setting a library's admin team", () => { expect(library?.adminTeamId).toBe("team"); }); - it("takes the team away, and its webhook with it", async () => { + it("takes the team away", async () => { const { onshapeApi } = mockOnshape(); - const remove = vi - .spyOn(onshapeApi, "deleteNone") - .mockResolvedValue(undefined); await setTeam("team", onshapeApi); const res = await setTeam(null, onshapeApi); expect(await res.json()).toEqual({ memberCount: 0 }); - expect(remove).toHaveBeenCalledWith("/webhooks/team-webhook"); + }); +}); + +describe("refreshing a library's admin team", () => { + const REFRESH_PATH = `/api/admin-team/refresh/library/${TEST_LIBRARY_ID}`; + + beforeEach(async () => { + await resetDb(db); + await seedLibrary(db); + await db + .update(libraries) + .set({ adminTeamId: "team" }) + .where(eq(libraries.id, TEST_LIBRARY_ID)); + }); + afterEach(() => vi.restoreAllMocks()); + + const refresh = (accessLevel: AccessLevel) => + createTestApp({ + accessLevel, + onshapeApi: mockOnshape().onshapeApi + }).request(REFRESH_PATH, jsonRequest("POST"), env); + + it("pulls the members again for an admin", async () => { + const res = await refresh(AccessLevel.ADMIN); + + expect(await res.json()).toEqual({ teamId: "team", memberCount: 2 }); + }); + + it("turns away an editor", async () => { + expect((await refresh(AccessLevel.EDITOR)).status).toBe(403); }); }); diff --git a/src/backend/features/admin-team/sync.ts b/src/backend/features/admin-team/sync.ts index 64dff3df8..ea30a7e19 100644 --- a/src/backend/features/admin-team/sync.ts +++ b/src/backend/features/admin-team/sync.ts @@ -51,15 +51,3 @@ export async function syncAdminTeam( await bumpLibraryVersion(db, libraryId); await pushLibraryChanged(env, libraryId); } - -/** Every library a team administers, for when that team changes. */ -export async function librariesOfTeam( - env: AppBindings, - teamId: string -): Promise { - const rows = await getDb(env.DB) - .select({ id: libraries.id }) - .from(libraries) - .where(eq(libraries.adminTeamId, teamId)); - return rows.map((row) => row.id); -} diff --git a/src/backend/features/auth/admin-sessions.ts b/src/backend/features/auth/admin-sessions.ts new file mode 100644 index 000000000..9b720110b --- /dev/null +++ b/src/backend/features/auth/admin-sessions.ts @@ -0,0 +1,76 @@ +/** + * Work nobody is signed in behind, like a webhook's load, still calls Onshape + * as someone: the owner or one of the library's team admins, whoever still has + * a session Onshape takes. Only their sessions are kept by user id. + */ +import { type OAuthApi } from "../../lib/onshape/client"; +import { getSessionInfo } from "../../lib/onshape/endpoints/users"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { adminTeamMembers } from "../../db/schema"; +import { and, eq, inArray } from "drizzle-orm"; +import type { LibraryId } from "../library/library-id"; +import { getOnshapeApiFromSessionId } from "./request-auth"; + +function adminSessionKey(userId: string): string { + return `admin-session:${userId}`; +} + +/** Only writes when the session changed. */ +export async function rememberAdminSession( + kv: KVNamespace, + userId: string, + sessionId: string +): Promise { + const key = adminSessionKey(userId); + if ((await kv.get(key)) !== sessionId) { + await kv.put(key, sessionId); + } +} + +async function getLiveApi( + kv: KVNamespace, + userId: string +): Promise { + const sessionId = await kv.get(adminSessionKey(userId)); + if (!sessionId) { + return undefined; + } + try { + const onshapeApi = await getOnshapeApiFromSessionId(kv, sessionId); + // Refreshing proves the refresh token; this proves the access token. + await getSessionInfo(onshapeApi); + return onshapeApi; + } catch { + return undefined; + } +} + +/** The owner's session, else the first working one of the libraries' team admins. */ +export async function getAdminOnshapeApi( + env: AppBindings, + libraryIds: LibraryId[] +): Promise { + const owner = env.OWNER_USER_ID + ? await getLiveApi(env.KV, env.OWNER_USER_ID) + : undefined; + if (owner || libraryIds.length === 0) { + return owner; + } + const admins = await getDb(env.DB) + .selectDistinct({ userId: adminTeamMembers.userId }) + .from(adminTeamMembers) + .where( + and( + inArray(adminTeamMembers.libraryId, libraryIds), + eq(adminTeamMembers.isTeamAdmin, true) + ) + ); + for (const { userId } of admins) { + const api = await getLiveApi(env.KV, userId); + if (api) { + return api; + } + } + return undefined; +} diff --git a/src/backend/features/auth/admin-sessions.worker.test.ts b/src/backend/features/auth/admin-sessions.worker.test.ts new file mode 100644 index 000000000..34dc4743b --- /dev/null +++ b/src/backend/features/auth/admin-sessions.worker.test.ts @@ -0,0 +1,69 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TEST_LIBRARY_ID, resetDb, seedLibrary } from "../../../__test_utils__"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import { getDb } from "../../db/client"; +import { adminTeamMembers } from "../../db/schema"; +import { getAdminOnshapeApi, rememberAdminSession } from "./admin-sessions"; +import * as RequestAuth from "./request-auth"; + +const db = getDb(env.DB); +const OWNER = "owner"; + +/** Each session id answers with its own api, unless it is in `dead`. */ +function sessions(dead: string[] = []) { + const apis = new Map(); + vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockImplementation( + (_kv, sessionId) => { + if (dead.includes(sessionId)) { + return Promise.reject(new Error("expired")); + } + const api = new MockOnshapeApi(); + vi.spyOn(api, "get").mockResolvedValue({ id: sessionId }); + apis.set(sessionId, api); + return Promise.resolve(api); + } + ); + return apis; +} + +describe("finding an admin's session", () => { + beforeEach(async () => { + await resetDb(db); + await seedLibrary(db); + await env.KV.delete(`admin-session:${OWNER}`); + await db.insert(adminTeamMembers).values([ + { + libraryId: TEST_LIBRARY_ID, + userId: "member", + isTeamAdmin: false + }, + { libraryId: TEST_LIBRARY_ID, userId: "admin", isTeamAdmin: true } + ]); + await rememberAdminSession(env.KV, "member", "member-session"); + await rememberAdminSession(env.KV, "admin", "admin-session"); + }); + afterEach(() => vi.restoreAllMocks()); + + const find = () => + getAdminOnshapeApi({ ...env, OWNER_USER_ID: OWNER }, [TEST_LIBRARY_ID]); + + it("prefers the owner's", async () => { + await rememberAdminSession(env.KV, OWNER, "owner-session"); + const apis = sessions(); + + expect(await find()).toBe(apis.get("owner-session")); + }); + + it("falls back to a team admin's, never a member's", async () => { + await rememberAdminSession(env.KV, OWNER, "owner-session"); + const apis = sessions(["owner-session"]); + + expect(await find()).toBe(apis.get("admin-session")); + }); + + it("finds nothing when no session works", async () => { + sessions(["admin-session"]); + expect(await find()).toBeUndefined(); + }); +}); diff --git a/src/backend/features/auth/request-auth.ts b/src/backend/features/auth/request-auth.ts index bd5c8ec98..bc70a8310 100644 --- a/src/backend/features/auth/request-auth.ts +++ b/src/backend/features/auth/request-auth.ts @@ -7,8 +7,8 @@ import { and, eq } from "drizzle-orm"; import { getDb } from "../../db/client"; import { adminTeamMembers } from "../../db/schema"; import type { LibraryId } from "../library/library-id"; -import { AccessLevel } from "./access-level"; -import { rememberUserSession } from "./user-sessions"; +import { AccessLevel, isWithinAccessLevel } from "./access-level"; +import { rememberAdminSession } from "./admin-sessions"; import { getOauthClient, makeAuthTokens, @@ -136,7 +136,18 @@ async function getLibraryAccessLevel( libraryId: LibraryId ): Promise { const userId = await getCachedUserId(c); - await rememberUserSession(c.env.KV, userId, getSessionId(c)); + const level = await lookUpAccessLevel(c, libraryId, userId); + if (isWithinAccessLevel(AccessLevel.ADMIN, level)) { + await rememberAdminSession(c.env.KV, userId, getSessionId(c)); + } + return level; +} + +async function lookUpAccessLevel( + c: AppContext, + libraryId: LibraryId, + userId: string +): Promise { if (c.env.OWNER_USER_ID && userId === c.env.OWNER_USER_ID) { return AccessLevel.OWNER; } diff --git a/src/backend/features/auth/request-auth.worker.test.ts b/src/backend/features/auth/request-auth.worker.test.ts index 4c3aea9e3..6104ae2eb 100644 --- a/src/backend/features/auth/request-auth.worker.test.ts +++ b/src/backend/features/auth/request-auth.worker.test.ts @@ -100,10 +100,12 @@ describe("access from a library's admin team", () => { expect(await accessLevelOf(OWNER)).toBe(AccessLevel.OWNER); }); - // So work the server starts can run as them. - it("keeps each user's latest session", async () => { + // So a load nobody is signed in behind can run as them. + it("keeps an admin's session, and nobody else's", async () => { + await accessLevelOf("team-admin"); await accessLevelOf("member"); - expect(await env.KV.get("user-session:member")).toBeTruthy(); + expect(await env.KV.get("admin-session:team-admin")).toBeTruthy(); + expect(await env.KV.get("admin-session:member")).toBeNull(); }); it("makes a member an editor, and a team admin an admin", async () => { diff --git a/src/backend/features/auth/user-sessions.ts b/src/backend/features/auth/user-sessions.ts deleted file mode 100644 index ac362fc0b..000000000 --- a/src/backend/features/auth/user-sessions.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Work the server starts on its own, like a webhook load, still calls Onshape - * as someone, so each user's latest session is kept by their Onshape id. - */ -import { type OAuthApi } from "../../lib/onshape/client"; -import { getSessionInfo } from "../../lib/onshape/endpoints/users"; -import type { AppBindings } from "../../lib/context"; -import { getOnshapeApiFromSessionId } from "./request-auth"; - -function userSessionKey(userId: string): string { - return `user-session:${userId}`; -} - -/** Only writes when the session changed. */ -export async function rememberUserSession( - kv: KVNamespace, - userId: string, - sessionId: string -): Promise { - const key = userSessionKey(userId); - if ((await kv.get(key)) !== sessionId) { - await kv.put(key, sessionId); - } -} - -export interface UserSession { - sessionId: string; - onshapeApi: OAuthApi; -} - -/** The user's latest session, if Onshape still takes it. */ -export async function getLiveSession( - kv: KVNamespace, - userId: string -): Promise { - const sessionId = await kv.get(userSessionKey(userId)); - if (!sessionId) { - return undefined; - } - try { - const onshapeApi = await getOnshapeApiFromSessionId(kv, sessionId); - // Refreshing proves the refresh token; this proves the access token. - await getSessionInfo(onshapeApi); - return { sessionId, onshapeApi }; - } catch { - return undefined; - } -} - -export async function getOwnerSession( - env: AppBindings -): Promise { - return env.OWNER_USER_ID - ? getLiveSession(env.KV, env.OWNER_USER_ID) - : undefined; -} diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index a605e4eb1..d789cb39d 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -27,8 +27,7 @@ export enum BuildIssueType { CONFIGURATION_MULTIPLE_PARTS = "configuration-multiple-parts", UNSTABLE_COMPOSITE = "unstable-composite", INSERTABLES_FAILED = "insertables-failed", - LOAD_FAILED = "load-failed", - VERSION_NOT_LOADED = "version-not-loaded" + LOAD_FAILED = "load-failed" } interface BuildIssueOf { @@ -61,8 +60,7 @@ export type BuildIssue = | ConfigurationBuildIssueOf | ConfigurationBuildIssueOf | BuildIssueOf - | BuildIssueOf - | BuildIssueOf; + | BuildIssueOf; /** The card links to the first; the rest are counted. */ export function toConfigurationIssue( @@ -120,9 +118,7 @@ export function getIssueDescription(issue: BuildIssue): string { case BuildIssueType.INSERTABLES_FAILED: return "Some child insertables failed to load"; case BuildIssueType.LOAD_FAILED: - return "Failed to load from Onshape. Reload the library to try again"; - case BuildIssueType.VERSION_NOT_LOADED: - return "A new version could not be loaded automatically. Reload the library to load it"; + return "Failed to load from Onshape. Reload outdated documents to try again"; } } @@ -137,7 +133,6 @@ export function getIssueSeverity(issue: BuildIssue): BuildIssueSeverity { case BuildIssueType.UNSTABLE_COMPOSITE: case BuildIssueType.INSERTABLES_FAILED: case BuildIssueType.LOAD_FAILED: - case BuildIssueType.VERSION_NOT_LOADED: return BuildIssueSeverity.ERROR; case BuildIssueType.NO_THUMBNAIL_TAB: case BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED: diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index 06cfe4465..42cd7066a 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -2,6 +2,7 @@ import type { WorkflowStep } from "cloudflare:workers"; import { createLimiter, type Limiter } from "../../lib/limiter"; import type { AppBindings } from "../../lib/context"; import { getOnshapeApiFromSessionId } from "../auth/request-auth"; +import { getAdminOnshapeApi } from "../auth/admin-sessions"; import type { OAuthApi } from "../../lib/onshape/client"; import type { ElementType } from "../../lib/onshape/element-type"; import type { LibraryId } from "../library/library-id"; @@ -19,20 +20,26 @@ const THUMBNAIL_CONCURRENCY = 10; /** The runtime plumbing a load runs against. */ export interface LoadContext { env: AppBindings; - sessionId: string; + libraryId: LibraryId; + /** Whoever asked for the load; absent for a webhook's. */ + sessionId?: string; step: WorkflowStep; /** Bounds concurrent Onshape probing across the whole run. */ limit: Limiter; thumbnailLimit: Limiter; + /** Resolved once a run needs it; see {@link getOnshapeApiFromContext}. */ + adminApi?: Promise; } export function createLoadContext( env: AppBindings, - sessionId: string, + libraryId: LibraryId, + sessionId: string | undefined, step: WorkflowStep ): LoadContext { return { env, + libraryId, sessionId, step, limit: createLimiter(LOAD_CONCURRENCY), @@ -40,8 +47,30 @@ export function createLoadContext( }; } -export function getOnshapeApiFromContext(ctx: LoadContext): Promise { - return getOnshapeApiFromSessionId(ctx.env.KV, ctx.sessionId); +/** The requester's session while it works, else an admin's. */ +export async function getOnshapeApiFromContext( + ctx: LoadContext +): Promise { + if (ctx.sessionId) { + try { + return await getOnshapeApiFromSessionId(ctx.env.KV, ctx.sessionId); + } catch { + // Signed out or expired since asking; an admin carries on. + } + } + ctx.adminApi ??= getAdminOnshapeApi(ctx.env, [ctx.libraryId]).then( + (api) => { + if (!api) { + throw new Error("No owner or admin session to load with"); + } + return api; + } + ); + // A failure is retried by the step, so it must not be memoized. + return ctx.adminApi.catch((error: unknown) => { + ctx.adminApi = undefined; + throw error; + }); } /** A group a load reads, and what the document told us about it. */ diff --git a/src/backend/features/load/context.worker.test.ts b/src/backend/features/load/context.worker.test.ts new file mode 100644 index 000000000..7702ede2e --- /dev/null +++ b/src/backend/features/load/context.worker.test.ts @@ -0,0 +1,61 @@ +import { env } from "cloudflare:workers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FAKE_STEP, TEST_LIBRARY_ID } from "../../../__test_utils__"; +import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; +import * as RequestAuth from "../auth/request-auth"; +import * as AdminSessions from "../auth/admin-sessions"; +import { createLoadContext, getOnshapeApiFromContext } from "./context"; + +const REQUESTER = new MockOnshapeApi(); +const ADMIN = new MockOnshapeApi(); + +const context = (sessionId?: string) => + createLoadContext(env, TEST_LIBRARY_ID, sessionId, FAKE_STEP); + +describe("the session a load calls Onshape with", () => { + afterEach(() => vi.restoreAllMocks()); + + it("is the requester's while it works", async () => { + vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockResolvedValue( + REQUESTER + ); + expect(await getOnshapeApiFromContext(context("s"))).toBe(REQUESTER); + }); + + it("is an admin's once the requester's stops working", async () => { + vi.spyOn(RequestAuth, "getOnshapeApiFromSessionId").mockRejectedValue( + new Error("expired") + ); + const admin = vi + .spyOn(AdminSessions, "getAdminOnshapeApi") + .mockResolvedValue(ADMIN); + + expect(await getOnshapeApiFromContext(context("s"))).toBe(ADMIN); + expect(admin).toHaveBeenCalledWith(env, [TEST_LIBRARY_ID]); + }); + + // A webhook's load has no requester. + it("is an admin's when nobody asked, found once a run", async () => { + const admin = vi + .spyOn(AdminSessions, "getAdminOnshapeApi") + .mockResolvedValue(ADMIN); + const ctx = context(); + + await getOnshapeApiFromContext(ctx); + expect(await getOnshapeApiFromContext(ctx)).toBe(ADMIN); + expect(admin).toHaveBeenCalledOnce(); + }); + + // So the step's retry looks again, rather than repeat the same failure. + it("fails without an admin session, and looks again next time", async () => { + const admin = vi + .spyOn(AdminSessions, "getAdminOnshapeApi") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(ADMIN); + const ctx = context(); + + await expect(getOnshapeApiFromContext(ctx)).rejects.toThrow(); + expect(await getOnshapeApiFromContext(ctx)).toBe(ADMIN); + expect(admin).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/backend/features/load/flag.ts b/src/backend/features/load/flag.ts index 5c73d4837..80d0e8654 100644 --- a/src/backend/features/load/flag.ts +++ b/src/backend/features/load/flag.ts @@ -7,17 +7,12 @@ import type { LibraryId } from "../library/library-id"; import { pushLibraryChanged } from "../live/notify"; import { addBuildIssue, BuildIssueType } from "../build-checker/issues"; -/** A load that did not happen, which an admin has to rerun by reloading. */ -export type LoadFailure = - | BuildIssueType.LOAD_FAILED - | BuildIssueType.VERSION_NOT_LOADED; - -/** Adds the issue to each group; publishing the change is the caller's. */ -export async function flagGroups( +/** Marks each group for an admin to reload; publishing the change is the caller's. */ +export async function flagFailedLoads( env: AppBindings, - groupIds: string[], - type: LoadFailure + groupIds: string[] ): Promise { + const type = BuildIssueType.LOAD_FAILED; const db = getDb(env.DB); for (const groupId of groupIds) { const row = await db diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index f543f7a9b..26b794f42 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -13,14 +13,13 @@ import { bumpLibraryVersion, rebuildSearchDb } from "../library/db"; import type { LibraryId } from "../library/library-id"; import { pushJobStatus, pushLibraryChanged } from "../live/notify"; import type { JobStatus } from "./contract"; -import { flagGroups, publishLibraries } from "./flag"; -import { BuildIssueType } from "../build-checker/issues"; +import { flagFailedLoads, publishLibraries } from "./flag"; export interface LoadDocumentParams { libraryId: LibraryId; groupId: string; - /** Whose Onshape session the load calls Onshape with. */ - sessionId: string; + /** Who asked for the load, whose session it tries first; see `getOnshapeApiFromContext`. */ + sessionId?: string; /** Reloads insertables whose version has not changed, too. */ forceReload: boolean; /** This deployment's, which the document's webhook is delivered to. */ @@ -72,10 +71,9 @@ async function clearDead( } if (dead.length > 0) { // It crashed before it could record its own failure. - await flagGroups( + await flagFailedLoads( env, - dead.map((job) => job.groupId), - BuildIssueType.LOAD_FAILED + dead.map((job) => job.groupId) ); await publishLibraries( env, diff --git a/src/backend/features/load/load-group.worker.test.ts b/src/backend/features/load/load-group.worker.test.ts index 033db767d..9b49eb748 100644 --- a/src/backend/features/load/load-group.worker.test.ts +++ b/src/backend/features/load/load-group.worker.test.ts @@ -248,6 +248,7 @@ const WORKSPACE = { const CTX: LoadContext = { env, + libraryId: TEST_LIBRARY_ID, sessionId: "test-session", step: FAKE_STEP, limit: createLimiter(LOAD_CONCURRENCY), diff --git a/src/backend/features/load/load-insertable.worker.test.ts b/src/backend/features/load/load-insertable.worker.test.ts index 5002730b8..76ceb5ede 100644 --- a/src/backend/features/load/load-insertable.worker.test.ts +++ b/src/backend/features/load/load-insertable.worker.test.ts @@ -7,6 +7,7 @@ import type { PartMetadata } from "../configurations/contract"; import { configurationRecord } from "../../../__test_utils__/configuration-fixtures"; import { FAKE_STEP, + TEST_LIBRARY_ID, MOCK_ONSHAPE_API, TEST_PARAMETERS, TEST_PART_STUDIO_ID, @@ -168,6 +169,7 @@ describe("saveInsertable", () => { const ctx = (): LoadContext => ({ env, + libraryId: TEST_LIBRARY_ID, sessionId: "test-session", step: FAKE_STEP, limit: createLimiter(LOAD_CONCURRENCY), diff --git a/src/backend/features/load/routes.ts b/src/backend/features/load/routes.ts index b58834ffe..62e941ef6 100644 --- a/src/backend/features/load/routes.ts +++ b/src/backend/features/load/routes.ts @@ -1,13 +1,14 @@ import { eq } from "drizzle-orm"; import * as z from "zod"; -import { type AppContext, getApp } from "../../lib/context"; +import { getApp } from "../../lib/context"; +import { forbiddenError } from "../../lib/api-error"; import { getDb } from "../../db/client"; import { groups } from "../../db/schema"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { validate } from "../../lib/validate"; -import { requireAdminMiddleware, requireOwnerMiddleware } from "../auth/guards"; +import { AccessLevel } from "../auth/access-level"; +import { requireAdminMiddleware } from "../auth/guards"; import { getSessionId } from "../auth/session"; -import type { LibraryId } from "../library/library-id"; import { requestLoads } from "./jobs"; import type { ReloadOut } from "./contract"; @@ -18,44 +19,39 @@ const reloadBody = z.object({ force: z.boolean() }); -async function reloadGroups( - c: AppContext, - force: boolean, - libraryId?: LibraryId -): Promise { - const selected = getDb(c.env.DB) - .select({ groupId: groups.id, libraryId: groups.libraryId }) - .from(groups); - const rows = await (libraryId - ? selected.where(eq(groups.libraryId, libraryId)) - : selected); - const sessionId = getSessionId(c); - const origin = new URL(c.req.url).origin; - await requestLoads( - c.env, - rows.map((row) => ({ ...row, sessionId, forceReload: force, origin })) - ); - return { documents: rows.length }; -} - -/** POST /api/reload/library/:libraryId: every document in one library. */ +/** + * POST /api/reload/library/:libraryId: the documents with a new version or a + * failed load, or every one when forced. Forcing spends a lot of the Onshape + * allocation, so it is the owner's alone. + */ loadRoutes.post( "/reload" + libraryRoute(), requireAdminMiddleware, validate("json", reloadBody), async (c) => { + const libraryId = getLibraryParam(c); const { force } = c.req.valid("json"); - return c.json(await reloadGroups(c, force, getLibraryParam(c))); - } -); - -/** POST /api/reload-all: every document in every library. */ -loadRoutes.post( - "/reload-all", - requireOwnerMiddleware, - validate("json", reloadBody), - async (c) => { - const { force } = c.req.valid("json"); - return c.json(await reloadGroups(c, force)); + if ( + force && + (await c.var.getAccessLevel(libraryId)) !== AccessLevel.OWNER + ) { + throw forbiddenError("Only the owner can reload all documents"); + } + const rows = await getDb(c.env.DB) + .select({ groupId: groups.id, libraryId: groups.libraryId }) + .from(groups) + .where(eq(groups.libraryId, libraryId)); + const sessionId = getSessionId(c); + const origin = new URL(c.req.url).origin; + await requestLoads( + c.env, + rows.map((row) => ({ + ...row, + sessionId, + forceReload: force, + origin + })) + ); + return c.json({ documents: rows.length } satisfies ReloadOut); } ); diff --git a/src/backend/features/load/routes.worker.test.ts b/src/backend/features/load/routes.worker.test.ts index dbad01f3c..fd0f0ecf7 100644 --- a/src/backend/features/load/routes.worker.test.ts +++ b/src/backend/features/load/routes.worker.test.ts @@ -12,11 +12,12 @@ import { LibraryId } from "../library/library-id"; import * as Jobs from "./jobs"; const db = getDb(env.DB); +const PATH = `/api/reload/library/${LibraryId.FRC_DESIGN_LIB}`; -function reload(path: string, accessLevel: AccessLevel, force: boolean) { +function reload(accessLevel: AccessLevel, force: boolean) { const init = jsonRequest("POST", { force }); return createTestApp({ accessLevel }).request( - path, + PATH, { ...init, headers: { ...init.headers, Cookie: "frc-design-app-cookie=s" } @@ -25,13 +26,7 @@ function reload(path: string, accessLevel: AccessLevel, force: boolean) { ); } -const requested = (load: ReturnType) => - (load.mock.calls[0]?.[1] as Jobs.LoadDocumentParams[]).map((request) => [ - request.groupId, - request.forceReload - ]); - -describe("reloading", () => { +describe("reloading a library", () => { beforeEach(async () => { await resetDb(db); await seedGroup(db, "frc", LibraryId.FRC_DESIGN_LIB); @@ -39,38 +34,36 @@ describe("reloading", () => { }); afterEach(() => vi.restoreAllMocks()); - const LIBRARY_PATH = `/api/reload/library/${LibraryId.FRC_DESIGN_LIB}`; - - it.each([false, true])( - "reloads one library's documents for its admin (force=%s)", - async (force) => { - const load = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); - - const res = await reload(LIBRARY_PATH, AccessLevel.ADMIN, force); + it("reloads the library's outdated documents for an admin", async () => { + const load = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); - expect(await res.json()).toEqual({ documents: 1 }); - expect(requested(load)).toEqual([["frc", force]]); - } - ); + const res = await reload(AccessLevel.ADMIN, false); - it("turns away an editor", async () => { - const res = await reload(LIBRARY_PATH, AccessLevel.EDITOR, false); - expect(res.status).toBe(403); + expect(await res.json()).toEqual({ documents: 1 }); + expect(load.mock.calls[0][1]).toEqual([ + expect.objectContaining({ + groupId: "frc", + sessionId: "s", + forceReload: false + }) + ]); }); - it("reloads every library for the owner", async () => { + it("reloads every document for the owner", async () => { const load = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); - await reload("/api/reload-all", AccessLevel.OWNER, false); + await reload(AccessLevel.OWNER, true); - expect(requested(load).sort()).toEqual([ - ["frc", false], - ["ftc", false] + expect(load.mock.calls[0][1]).toEqual([ + expect.objectContaining({ groupId: "frc", forceReload: true }) ]); }); - it("keeps reloading every library to the owner", async () => { - const res = await reload("/api/reload-all", AccessLevel.ADMIN, true); - expect(res.status).toBe(403); + it("keeps reloading every document to the owner", async () => { + expect((await reload(AccessLevel.ADMIN, true)).status).toBe(403); + }); + + it("turns away an editor", async () => { + expect((await reload(AccessLevel.EDITOR, false)).status).toBe(403); }); }); diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 01155392e..820d71b0c 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -34,7 +34,7 @@ import { } from "./context"; import { finishLoad, type LoadDocumentParams } from "./jobs"; import { pushLibraryChanged } from "../live/notify"; -import { flagGroups } from "./flag"; +import { flagFailedLoads } from "./flag"; import { loadGroup } from "./load-group"; import { ONSHAPE_STEP_RETRIES } from "./steps"; import { ensureWebhook } from "../webhooks/registration"; @@ -59,7 +59,12 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< step: WorkflowStep ): Promise { const params = event.payload; - const ctx = createLoadContext(this.env, params.sessionId, step); + const ctx = createLoadContext( + this.env, + params.libraryId, + params.sessionId, + step + ); // Anything but a skip wrote to the group: a failure flags it. let result: LoadResult = { status: "failed" }; try { @@ -131,7 +136,7 @@ async function loadDocument( // The row records only that it failed, so this is the only record of why. console.error(`Failed to load group ${groupId}`, error); await ctx.step.do("flag-failed", () => - flagGroups(ctx.env, [groupId], BuildIssueType.LOAD_FAILED) + flagFailedLoads(ctx.env, [groupId]) ); result = { status: "failed" }; } @@ -168,8 +173,7 @@ function hasFailedLoad(buildIssues: BuildIssue[]): boolean { return hasBuildIssue( buildIssues, BuildIssueType.LOAD_FAILED, - BuildIssueType.INSERTABLES_FAILED, - BuildIssueType.VERSION_NOT_LOADED + BuildIssueType.INSERTABLES_FAILED ); } diff --git a/src/backend/features/webhooks/registration.ts b/src/backend/features/webhooks/registration.ts index 067e6e19e..fc0052d69 100644 --- a/src/backend/features/webhooks/registration.ts +++ b/src/backend/features/webhooks/registration.ts @@ -1,13 +1,12 @@ /** - * One per library document, for new versions, and one per admin team. Each is - * registered with `isTransient: false`, which exempts it from Onshape's cleanup. + * One per library document, for its new versions, registered with + * `isTransient: false`, which exempts it from Onshape's cleanup. */ import { and, eq } from "drizzle-orm"; import type { AppBindings } from "../../lib/context"; import { getDb } from "../../db/client"; import { onshapeWebhooks, WebhookSubject } from "../../db/schema"; import { type OAuthApi, OnshapeApiError } from "../../lib/onshape/client"; -import { getSessionInfo } from "../../lib/onshape/endpoints/users"; import { createWebhook, deleteWebhook @@ -17,8 +16,6 @@ export const RECEIVE_PATH = "/api/webhooks/onshape"; export enum WebhookEvent { CREATE_VERSION = "onshape.model.lifecycle.createversion", - TEAM_ADD_MEMBER = "onshape.team.addmember", - TEAM_REMOVE_MEMBER = "onshape.team.removemember", UNREGISTER = "webhook.unregister" } @@ -31,28 +28,14 @@ function whereSubject(subject: WebhookSubject, subjectId: string) { ); } -/** Team events are company-wide, so a team's webhook names the company and the receiver filters. */ -async function subjectParams( - onshapeApi: OAuthApi, - subject: WebhookSubject, - subjectId: string -) { - if (subject === WebhookSubject.DOCUMENT) { - return { - documentId: subjectId, - events: [WebhookEvent.CREATE_VERSION] - }; - } - const companyId = (await getSessionInfo(onshapeApi)).company?.id; - if (!companyId) { - throw new Error( - "A team's webhook needs a company; open the app from your company's Onshape." - ); +function subjectParams(subject: WebhookSubject, subjectId: string) { + switch (subject) { + case WebhookSubject.DOCUMENT: + return { + documentId: subjectId, + events: [WebhookEvent.CREATE_VERSION] + }; } - return { - companyId, - events: [WebhookEvent.TEAM_ADD_MEMBER, WebhookEvent.TEAM_REMOVE_MEMBER] - }; } /** Registers a webhook for the subject unless one is already on record. */ @@ -86,7 +69,7 @@ export async function ensureWebhook( url.searchParams.set("token", token); const webhook = await createWebhook(onshapeApi, { - ...(await subjectParams(onshapeApi, subject, subjectId)), + ...subjectParams(subject, subjectId), url: url.href, name: "FRCDesignApp", description: `Keeps the FRCDesignApp in step with this ${subject}.`, diff --git a/src/backend/features/webhooks/registration.worker.test.ts b/src/backend/features/webhooks/registration.worker.test.ts index d824c7717..b4897e0c8 100644 --- a/src/backend/features/webhooks/registration.worker.test.ts +++ b/src/backend/features/webhooks/registration.worker.test.ts @@ -54,25 +54,6 @@ describe("registering webhooks", () => { }); }); - it("registers a team's for the company's membership changes", async () => { - const { onshapeApi, post } = mockOnshape(); - - await ensureWebhook( - env, - onshapeApi, - WebhookSubject.TEAM, - "team", - ORIGIN - ); - - expect(post).toHaveBeenCalledWith("/webhooks", { - body: expect.objectContaining({ - companyId: "company", - events: ["onshape.team.addmember", "onshape.team.removemember"] - }) as unknown - }); - }); - // Registered as never transient, so one on record is taken to stand. it("registers nothing for a subject already registered", async () => { const { onshapeApi, post } = mockOnshape(); diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index 8bc6e2904..e68058edd 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -2,22 +2,12 @@ * Acts on the subject the url's token was registered for, never on what the * payload names: the token is all that keeps others from triggering this. */ -import { eq, inArray } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { type AppBindings, getApp } from "../../lib/context"; import { forbiddenError } from "../../lib/api-error"; import { getDb } from "../../db/client"; -import { adminTeamMembers, groups, WebhookSubject } from "../../db/schema"; -import { - getLiveSession, - getOwnerSession, - type UserSession -} from "../auth/user-sessions"; -import { getVersion } from "../../lib/onshape/endpoints/versions"; -import { flagGroups, publishLibraries } from "../load/flag"; -import { BuildIssueType } from "../build-checker/issues"; -import { librariesOfTeam, syncAdminTeam } from "../admin-team/sync"; +import { groups, WebhookSubject } from "../../db/schema"; import { requestLoads } from "../load/jobs"; -import type { LibraryId } from "../library/library-id"; import { findWebhookByToken, forgetWebhook, @@ -30,8 +20,6 @@ export const webhookRoutes = getApp(); /** The fields read off a notification; Onshape sends more. */ interface WebhookNotification { event: string; - versionId?: string; - teamId?: string; } /** POST /api/webhooks/onshape?token= */ @@ -43,28 +31,16 @@ webhookRoutes.post(RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { } const notification = await c.req.json(); - const origin = new URL(c.req.url).origin; switch (notification.event) { case WebhookEvent.CREATE_VERSION: if (webhook.subject === WebhookSubject.DOCUMENT) { await reloadDocument( c.env, webhook.subjectId, - notification.versionId, - origin + new URL(c.req.url).origin ); } break; - case WebhookEvent.TEAM_ADD_MEMBER: - case WebhookEvent.TEAM_REMOVE_MEMBER: - // A team's webhook hears every team in the company. - if ( - webhook.subject === WebhookSubject.TEAM && - notification.teamId === webhook.subjectId - ) { - await resyncTeam(c.env, webhook.subjectId); - } - break; case WebhookEvent.UNREGISTER: await forgetWebhook(c.env, webhook); break; @@ -73,108 +49,22 @@ webhookRoutes.post(RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { return c.json({}); }); -/** - * Loads the document's groups as whoever made the version, if their session - * still works, else as the owner. Without either, the groups are flagged for - * an admin to reload. - */ +/** Nobody is signed in behind a webhook, so each load finds an admin's session. */ async function reloadDocument( env: AppBindings, documentId: string, - versionId: string | undefined, origin: string ): Promise { const documentGroups = await getDb(env.DB) .select({ groupId: groups.id, libraryId: groups.libraryId }) .from(groups) .where(eq(groups.documentId, documentId)); - const session = await chooseLoadSession( - env, - documentId, - versionId, - documentGroups.map((group) => group.libraryId) - ); - if (!session) { - console.warn(`No live session to reload ${documentId} with.`); - await flagGroups( - env, - documentGroups.map((group) => group.groupId), - BuildIssueType.VERSION_NOT_LOADED - ); - await publishLibraries( - env, - documentGroups.map((group) => group.libraryId) - ); - return; - } await requestLoads( env, documentGroups.map((group) => ({ ...group, - sessionId: session.sessionId, forceReload: false, origin })) ); } - -async function chooseLoadSession( - env: AppBindings, - documentId: string, - versionId: string | undefined, - libraryIds: string[] -): Promise { - const fallback = - (await getOwnerSession(env)) ?? - (await getAdminSession(env, libraryIds)); - if (!fallback || !versionId) { - return fallback; - } - // The version's creator can only be read with a session that works. - const creatorId = await getVersion( - fallback.onshapeApi, - { documentId }, - versionId - ) - .then((version) => version.creator?.id) - .catch(() => undefined); - const creator = creatorId - ? await getLiveSession(env.KV, creatorId) - : undefined; - return creator ?? fallback; -} - -/** Any admin of the libraries holding the document, for when the owner's session is gone. */ -async function getAdminSession( - env: AppBindings, - libraryIds: string[] -): Promise { - if (libraryIds.length === 0) { - return undefined; - } - const admins = await getDb(env.DB) - .selectDistinct({ userId: adminTeamMembers.userId }) - .from(adminTeamMembers) - .where(inArray(adminTeamMembers.libraryId, libraryIds as LibraryId[])); - for (const { userId } of admins) { - const session = await getLiveSession(env.KV, userId); - if (session) { - return session; - } - } - return undefined; -} - -/** Pulls the team's members again for every library it administers. */ -async function resyncTeam(env: AppBindings, teamId: string): Promise { - const onshapeApi = (await getOwnerSession(env))?.onshapeApi; - if (!onshapeApi) { - console.warn( - `No owner session to pull team ${teamId} with; the owner has not used the app yet.` - ); - return; - } - for (const libraryId of await librariesOfTeam(env, teamId)) { - await syncAdminTeam(env, onshapeApi, libraryId); - } -} diff --git a/src/backend/features/webhooks/routes.worker.test.ts b/src/backend/features/webhooks/routes.worker.test.ts index 6d2953771..fb4ffdcaa 100644 --- a/src/backend/features/webhooks/routes.worker.test.ts +++ b/src/backend/features/webhooks/routes.worker.test.ts @@ -1,5 +1,4 @@ import { env } from "cloudflare:workers"; -import { eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TEST_GROUP_ID, @@ -10,19 +9,8 @@ import { seedGroup } from "../../../__test_utils__"; import { getDb } from "../../db/client"; -import { - adminTeamMembers, - groups, - libraries, - onshapeWebhooks, - WebhookSubject -} from "../../db/schema"; -import * as UserSessions from "../auth/user-sessions"; -import * as Versions from "../../lib/onshape/endpoints/versions"; -import { BuildIssueType } from "../build-checker/issues"; -import * as Sync from "../admin-team/sync"; +import { onshapeWebhooks, WebhookSubject } from "../../db/schema"; import * as Jobs from "../load/jobs"; -import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; import { WebhookEvent } from "./registration"; const db = getDb(env.DB); @@ -65,154 +53,25 @@ describe("receiving a webhook", () => { expect(res.status).toBe(200); }); - describe("a new version", () => { - const session = (sessionId: string) => ({ - sessionId, - onshapeApi: new MockOnshapeApi() - }); - const loadedAs = (load: ReturnType) => - load.mock.calls[0]?.[1].map((request) => request.sessionId); - const mockLoads = () => - vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); - const liveSessions = (byUser: Record) => - vi - .spyOn(UserSessions, "getLiveSession") - .mockImplementation((_kv, userId) => - Promise.resolve( - byUser[userId] ? session(byUser[userId]) : undefined - ) - ); - const createdBy = (creatorId: string) => - vi.spyOn(Versions, "getVersion").mockResolvedValue({ - id: "v-1", - name: "V1", - createdAt: "", - creator: { id: creatorId } - }); - const deliverVersion = async () => - deliver( - // What the payload names is not trusted; the token's subject is. - { - event: WebhookEvent.CREATE_VERSION, - documentId: "elsewhere", - versionId: "v-1" - }, - await registered(WebhookSubject.DOCUMENT, DOCUMENT) - ); - - it("loads the document's groups as whoever made the version", async () => { - vi.spyOn(UserSessions, "getOwnerSession").mockResolvedValue( - session("owner-session") - ); - createdBy("maker"); - liveSessions({ maker: "maker-session" }); - const load = mockLoads(); - - await deliverVersion(); - - expect(load).toHaveBeenCalledWith(expect.anything(), [ - { - libraryId: TEST_LIBRARY_ID, - groupId: TEST_GROUP_ID, - sessionId: "maker-session", - forceReload: false, - origin: "http://localhost" - } - ]); - }); - - it("falls back to the owner when the creator's session is gone", async () => { - vi.spyOn(UserSessions, "getOwnerSession").mockResolvedValue( - session("owner-session") - ); - createdBy("maker"); - liveSessions({}); - const load = mockLoads(); - - await deliverVersion(); + // Nobody is signed in behind a webhook, so the load finds a session itself. + it("loads the document's groups on a new version, with no session", async () => { + const token = await registered(WebhookSubject.DOCUMENT, DOCUMENT); + const load = vi.spyOn(Jobs, "requestLoads").mockResolvedValue(); - expect(loadedAs(load)).toEqual(["owner-session"]); - }); + await deliver( + // What the payload names is not trusted; the token's subject is. + { event: WebhookEvent.CREATE_VERSION, documentId: "elsewhere" }, + token + ); - // Someone has to be able to read the version to learn who made it. - it("reads the version as an admin when the owner's session is gone", async () => { - vi.spyOn(UserSessions, "getOwnerSession").mockResolvedValue( - undefined - ); - await db.insert(adminTeamMembers).values({ + expect(load).toHaveBeenCalledWith(expect.anything(), [ + { libraryId: TEST_LIBRARY_ID, - userId: "admin", - isTeamAdmin: true - }); - createdBy("maker"); - liveSessions({ admin: "admin-session" }); - const load = mockLoads(); - - await deliverVersion(); - - expect(loadedAs(load)).toEqual(["admin-session"]); - }); - - it("flags the groups for a reload when no session works", async () => { - vi.spyOn(UserSessions, "getOwnerSession").mockResolvedValue( - undefined - ); - const load = mockLoads(); - - await deliverVersion(); - - expect(load).not.toHaveBeenCalled(); - const group = await db - .select({ buildIssues: groups.buildIssues }) - .from(groups) - .where(eq(groups.id, TEST_GROUP_ID)) - .get(); - expect(group?.buildIssues).toContainEqual({ - type: BuildIssueType.VERSION_NOT_LOADED - }); - }); - }); - - describe("an admin team change", () => { - beforeEach(async () => { - await db - .update(libraries) - .set({ adminTeamId: "team" }) - .where(eq(libraries.id, TEST_LIBRARY_ID)); - vi.spyOn(UserSessions, "getOwnerSession").mockResolvedValue({ - sessionId: "owner-session", - onshapeApi: new MockOnshapeApi() - }); - }); - - it("pulls the team again for each library it administers", async () => { - const token = await registered(WebhookSubject.TEAM, "team"); - const sync = vi.spyOn(Sync, "syncAdminTeam").mockResolvedValue(); - - await deliver( - { event: WebhookEvent.TEAM_ADD_MEMBER, teamId: "team" }, - token - ); - - expect(sync).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - TEST_LIBRARY_ID - ); - }); - - // A team's webhook hears every team in the company. - it("ignores another team's change", async () => { - const token = await registered(WebhookSubject.TEAM, "team"); - const sync = vi.spyOn(Sync, "syncAdminTeam").mockResolvedValue(); - - await deliver( - { event: WebhookEvent.TEAM_REMOVE_MEMBER, teamId: "other" }, - token - ); - - expect(sync).not.toHaveBeenCalled(); - }); + groupId: TEST_GROUP_ID, + forceReload: false, + origin: "http://localhost" + } + ]); }); // So the next load of the document registers a new one. diff --git a/src/backend/lib/onshape/endpoints/users.ts b/src/backend/lib/onshape/endpoints/users.ts index 26301a2c1..92c07cd24 100644 --- a/src/backend/lib/onshape/endpoints/users.ts +++ b/src/backend/lib/onshape/endpoints/users.ts @@ -1,5 +1,4 @@ -import { OAuthApi, OnshapeApi } from "../client"; -import { AccessLevel } from "../../../features/auth/access-level"; +import { OAuthApi } from "../client"; interface SessionInfo { id: string; @@ -15,21 +14,3 @@ export function getSessionInfo(client: OAuthApi): Promise { export function getUserId(client: OAuthApi): Promise { return getSessionInfo(client).then((info) => info.id); } - -/** Returns the access level of the authenticated user relative to a given team. */ -export async function getAccessLevel( - client: OnshapeApi, - teamId: string -): Promise { - try { - const teamInfo = await client.get( - `/teams/${encodeURIComponent(teamId)}` - ); - if (teamInfo.admin) return AccessLevel.ADMIN; - if (teamInfo.member) return AccessLevel.EDITOR; - return AccessLevel.USER; - } catch { - // Onshape returns an error for teams the user isn't a member of - return AccessLevel.USER; - } -} diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index 70ba0de50..2e8a2acec 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -12,8 +12,7 @@ import { } from "@mantine/core"; import { CheckIcon, CopyIcon, HouseIcon } from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; -import { ReloadButtons } from "../features/library/components/reload-buttons"; -import { ReloadScope } from "../features/library/queries"; +import { ReloadButton } from "../features/library/components/reload-button"; import { AccessLevel } from "@backend/features/auth/access-level"; import { DEFAULT_LIBRARY } from "@backend/features/library/library-id"; @@ -26,7 +25,7 @@ export function RootAppError(): ReactNode { accessLevel={AccessLevel.OWNER} useMaxAccessLevel > - + } /> diff --git a/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx b/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx new file mode 100644 index 000000000..1ffd1cb11 --- /dev/null +++ b/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx @@ -0,0 +1,17 @@ +import { Button } from "@mantine/core"; +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { type ReactNode } from "react"; +import { useRefreshAdminTeamMutation } from "../queries"; + +export function RefreshAdminTeamButton(): ReactNode { + const mutation = useRefreshAdminTeamMutation(); + return ( + + ); +} diff --git a/src/frontend/features/admin-team/queries.ts b/src/frontend/features/admin-team/queries.ts index 5478b8f0c..3f308978e 100644 --- a/src/frontend/features/admin-team/queries.ts +++ b/src/frontend/features/admin-team/queries.ts @@ -37,3 +37,24 @@ export function useSetAdminTeamMutation() { } }); } + +/** Pulls the team's members again, for a change made in Onshape. */ +export function useRefreshAdminTeamMutation() { + const libraryId = useLibraryId(); + return useMutation({ + mutationKey: ["refresh-admin-team", libraryId], + mutationFn: () => + apiPost( + "/admin-team/refresh" + toLibraryPath(libraryId) + ), + onError: getAppErrorHandler("Failed to refresh the admin team!"), + onSuccess: (team) => { + queryClient.setQueryData(adminTeamQueryKey(libraryId), team); + showSuccessToast( + team.teamId + ? `Admin team refreshed: ${team.memberCount} members.` + : "This library has no admin team." + ); + } + }); +} diff --git a/src/frontend/features/library/components/reload-button.tsx b/src/frontend/features/library/components/reload-button.tsx new file mode 100644 index 000000000..44648ef4d --- /dev/null +++ b/src/frontend/features/library/components/reload-button.tsx @@ -0,0 +1,62 @@ +import { useReloadMutation } from "../queries"; +import { Button, Text } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { ArrowsClockwiseIcon, WarningIcon } from "@phosphor-icons/react"; +import { AppIcon } from "../../../components/app-icon"; +import { AppTitle } from "../../../components/app-title"; +import { IconSize, StatusColor } from "../../../lib/style-constants"; +import { ReactNode } from "react"; + +interface ReloadButtonProps { + /** Every document, not just the outdated ones. @default false */ + all?: boolean; +} + +export function ReloadButton(props: ReloadButtonProps): ReactNode { + const { all = false } = props; + // Reloading everything spends the account's Onshape allocation. + const color = all ? StatusColor.ERROR : StatusColor.INFO; + const mutation = useReloadMutation(all); + + const handleClick = () => { + modals.openConfirmModal({ + title: ( + + } + title={ + all + ? "Reload all documents" + : "Reload outdated documents" + } + /> + ), + children: ( + + {all + ? "Are you sure you want to reload every document in this library? This is an expensive operation." + : "Reload the documents in this library with a new version or a failed load?"} + + ), + labels: { confirm: "Reload documents", cancel: "Cancel" }, + confirmProps: { color }, + onConfirm: () => mutation.mutate() + }); + }; + + return ( + + ); +} diff --git a/src/frontend/features/library/components/reload-buttons.tsx b/src/frontend/features/library/components/reload-buttons.tsx deleted file mode 100644 index 8946f7948..000000000 --- a/src/frontend/features/library/components/reload-buttons.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { ReloadScope, useReloadMutation } from "../queries"; -import { Button, Group, Text } from "@mantine/core"; -import { modals } from "@mantine/modals"; -import { ArrowsClockwiseIcon, WarningIcon } from "@phosphor-icons/react"; -import { AppIcon } from "../../../components/app-icon"; -import { AppTitle } from "../../../components/app-title"; -import { IconSize, StatusColor } from "../../../lib/style-constants"; -import { ReactNode } from "react"; - -interface ReloadButtonsProps { - scope: ReloadScope; -} - -/** - * A reload picks up versions a webhook missed and reruns failed loads. A - * forced one rereads every document, which spends a lot of the account's - * Onshape allocation, so it asks first. - */ -export function ReloadButtons(props: ReloadButtonsProps): ReactNode { - const { scope } = props; - const mutation = useReloadMutation(scope); - const what = - scope === ReloadScope.ALL - ? "every document in every library" - : "every document in this library"; - - const confirmForce = () => { - modals.openConfirmModal({ - title: ( - - } - title="Force reload" - /> - ), - children: ( - - Force reload {what}, whether or not it changed? This is an - expensive operation. - - ), - labels: { confirm: "Force reload", cancel: "Cancel" }, - confirmProps: { color: StatusColor.ERROR }, - onConfirm: () => mutation.mutate(true) - }); - }; - - return ( - - - - - ); -} diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 9fbb7391e..35243b937 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -157,18 +157,15 @@ export function useSetGroupOrderMutation() { }); } -/** One library's documents for its admins, or every library's for the owner. */ -export function useReloadMutation(scope: ReloadScope) { +/** Reloads the library's documents with a new version or a failed load, or every one. */ +export function useReloadMutation(all: boolean) { const libraryId = useLibraryId(); return useMutation({ - mutationKey: ["reload", scope, libraryId], - mutationFn: (force: boolean): Promise => - apiPost( - scope === ReloadScope.ALL - ? "/reload-all" - : "/reload" + toLibraryPath(libraryId), - { body: { force } } - ), + mutationKey: ["reload", libraryId], + mutationFn: (): Promise => + apiPost("/reload" + toLibraryPath(libraryId), { + body: { force: all } + }), onError: getAppErrorHandler("Failed to reload documents!"), onSuccess: (data) => { showInfoToast(`Reloading ${data.documents} documents...`); @@ -176,11 +173,6 @@ export function useReloadMutation(scope: ReloadScope) { }); } -export enum ReloadScope { - LIBRARY = "library", - ALL = "all" -} - /** Adds an Onshape document to the library, by its url. */ export function useAddGroupMutation(selectedGroupId?: string) { const libraryId = useLibraryId(); diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index d039e2e45..8ceda3bfd 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -23,8 +23,8 @@ import { useGetUiState, updateUiState } from "../../../lib/ui-state"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { SETUP_URL } from "../../../lib/url"; import { useLibraryId } from "../../../lib/library"; -import { ReloadButtons } from "../../library/components/reload-buttons"; -import { ReloadScope } from "../../library/queries"; +import { ReloadButton } from "../../library/components/reload-button"; +import { RefreshAdminTeamButton } from "../../admin-team/components/refresh-admin-team-button"; import { AdminTeamSetting } from "../../admin-team/components/admin-team-setting"; /** The FRCDesign Discord, where feedback and support now live. */ @@ -201,14 +201,19 @@ function AdminSettings(): ReactNode { {/* Always show the access level select so admins can change access level if needed */} - - + + + + + - - + + + + diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 5f7d5472f..43211442e 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -184,7 +184,7 @@ function GroupListContent(props: GroupListCardsProps): ReactNode { ) : ( ); } From ad1e9bc8984235299ceba58b17c19c5bf13e51a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:21:48 +0000 Subject: [PATCH 37/88] Store a library's admin team on its row The members move from admin_team_members to libraries.admin_team, a JSON array of { userId, isTeamAdmin } replaced whole on each sync. An access check reads the library row it already keys on, and a sync is one update. The migration carries existing members over before dropping the table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 2 +- drizzle/0007_admin_team_column.sql | 14 + drizzle/meta/0007_snapshot.json | 1332 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/__test_utils__/seed.ts | 2 - src/backend/db/schema.ts | 21 +- src/backend/features/admin-team/contract.ts | 6 + src/backend/features/admin-team/routes.ts | 26 +- .../features/admin-team/routes.worker.test.ts | 17 +- src/backend/features/admin-team/sync.ts | 35 +- src/backend/features/auth/admin-sessions.ts | 26 +- .../auth/admin-sessions.worker.test.ts | 20 +- src/backend/features/auth/request-auth.ts | 18 +- .../features/auth/request-auth.worker.test.ts | 24 +- 14 files changed, 1438 insertions(+), 112 deletions(-) create mode 100644 drizzle/0007_admin_team_column.sql create mode 100644 drizzle/meta/0007_snapshot.json diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 6047c1089..0f10bedfb 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -189,6 +189,6 @@ Other top-level files: The app has four access levels, checked on every protected API call: **OWNER**, **ADMIN**, **EDITOR**, and **USER**. Access is per library. Admin and editor access currently grant the same permissions in their library (adding, removing, and renaming groups, toggling insertable visibility), but they are kept separate so permissions can be tightened in the future if needed. USER access allows anyone who logs in via OAuth to browse the library, insert parts, and manage their own favorites. -The **owner** is the one Onshape user named by `OWNER_USER_ID`, with every library. The owner sets each library's admin team; its members are stored in `admin_team_members` (team admins as ADMIN, members as EDITOR) and pulled again on demand, so a user's access is a database lookup in `src/backend/features/auth/request-auth.ts`. Routes that require elevated access are wrapped with `requireEditor`, `requireAdminMiddleware` or `requireOwnerMiddleware` from `src/backend/features/auth/guards.ts`; one naming an insertable rather than a library looks the library up from it. +The **owner** is the one Onshape user named by `OWNER_USER_ID`, with every library. The owner sets each library's admin team; its members are stored on the library row, in `libraries.admin_team` (team admins as ADMIN, members as EDITOR) and pulled again on demand, so a user's access is a database lookup in `src/backend/features/auth/request-auth.ts`. Routes that require elevated access are wrapped with `requireEditor`, `requireAdminMiddleware` or `requireOwnerMiddleware` from `src/backend/features/auth/guards.ts`; one naming an insertable rather than a library looks the library up from it. During local development, you can bypass the team membership check by setting `ACCESS_LEVEL_OVERRIDE=admin` (or `editor`/`user`) in your `.env` file. diff --git a/drizzle/0007_admin_team_column.sql b/drizzle/0007_admin_team_column.sql new file mode 100644 index 000000000..d6431cbc7 --- /dev/null +++ b/drizzle/0007_admin_team_column.sql @@ -0,0 +1,14 @@ +ALTER TABLE `libraries` ADD `admin_team` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +UPDATE `libraries` SET `admin_team` = ( + SELECT json_group_array(json_object( + 'userId', `user_id`, + 'isTeamAdmin', json(CASE WHEN `is_team_admin` THEN 'true' ELSE 'false' END) + )) + FROM `admin_team_members` + WHERE `admin_team_members`.`library_id` = `libraries`.`id` +) +WHERE EXISTS ( + SELECT 1 FROM `admin_team_members` + WHERE `admin_team_members`.`library_id` = `libraries`.`id` +);--> statement-breakpoint +DROP TABLE `admin_team_members`; diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 000000000..292c1d2d9 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1332 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7bfdd317-a1be-4641-b2fe-989132108c63", + "prevId": "cac6acf8-d0f8-448b-a93f-5ae1ec06ac95", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_insertable_id_insertables_id_fk": { + "name": "configurations_insertable_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_selection": { + "name": "default_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "favorites_user_id_library_id_insertable_id_unique": { + "name": "favorites_user_id_library_id_insertable_id_unique", + "columns": ["user_id", "library_id", "insertable_id"], + "isUnique": true + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_library_id_libraries_id_fk": { + "name": "favorites_library_id_libraries_id_fk", + "tableFrom": "favorites", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_insertable_id_insertables_id_fk": { + "name": "favorites_insertable_id_insertables_id_fk", + "tableFrom": "favorites", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_workspace_id": { + "name": "thumbnail_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_alphabetically": { + "name": "sort_alphabetically", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groups_document_id_library_id_unique": { + "name": "groups_document_id_library_id_unique", + "columns": ["document_id", "library_id"], + "isUnique": true + } + }, + "foreignKeys": { + "groups_library_id_libraries_id_fk": { + "name": "groups_library_id_libraries_id_fk", + "tableFrom": "groups", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertables": { + "name": "insertables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_type": { + "name": "element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "microversion_id": { + "name": "microversion_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_open_composite": { + "name": "is_open_composite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "supports_fasten": { + "name": "supports_fasten", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "excluded_parameter_ids": { + "name": "excluded_parameter_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vendors": { + "name": "vendors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_metadata": { + "name": "part_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "insertables_group_id_groups_id_fk": { + "name": "insertables_group_id_groups_id_fk", + "tableFrom": "insertables", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "insertables_library_id_libraries_id_fk": { + "name": "insertables_library_id_libraries_id_fk", + "tableFrom": "insertables", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "libraries": { + "name": "libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "admin_team_id": { + "name": "admin_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_team": { + "name": "admin_team", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "load_jobs": { + "name": "load_jobs", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rerun": { + "name": "rerun", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rerun_force": { + "name": "rerun_force", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "load_jobs_group_id_groups_id_fk": { + "name": "load_jobs_group_id_groups_id_fk", + "tableFrom": "load_jobs", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "load_jobs_library_id_libraries_id_fk": { + "name": "load_jobs_library_id_libraries_id_fk", + "tableFrom": "load_jobs", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onshape_webhooks": { + "name": "onshape_webhooks", + "columns": { + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "onshape_webhooks_token_unique": { + "name": "onshape_webhooks_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onshape_webhooks_subject_subject_id_pk": { + "columns": ["subject", "subject_id"], + "name": "onshape_webhooks_subject_subject_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "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'" + }, + "tab_id": { + "name": "tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_library_id_libraries_id_fk": { + "name": "users_library_id_libraries_id_fk", + "tableFrom": "users", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_configuration_metrics": { + "name": "daily_configuration_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parameter_id": { + "name": "parameter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_configuration_metrics_day_idx": { + "name": "daily_configuration_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { + "columns": [ + "library_id", + "element_id", + "parameter_id", + "value", + "day" + ], + "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_metrics": { + "name": "daily_insertable_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_insertable_metrics_day_idx": { + "name": "daily_insertable_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { + "columns": [ + "library_id", + "element_id", + "day", + "target_element_type" + ], + "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_users": { + "name": "daily_insertable_users", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_users_library_id_element_id_day_user_id_pk": { + "columns": ["library_id", "element_id", "day", "user_id"], + "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_metrics": { + "name": "daily_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fasten_count": { + "name": "fasten_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_metrics_day_library_id_type_pk": { + "columns": ["day", "library_id", "type"], + "name": "daily_metrics_day_library_id_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_source_metrics": { + "name": "daily_source_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_source_metrics_day_library_id_source_pk": { + "columns": ["day", "library_id", "source"], + "name": "daily_source_metrics_day_library_id_source_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_target_metrics": { + "name": "daily_target_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_target_metrics_day_library_id_target_element_type_pk": { + "columns": ["day", "library_id", "target_element_type"], + "name": "daily_target_metrics_day_library_id_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_user_activity": { + "name": "daily_user_activity", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "daily_user_activity_day_idx": { + "name": "daily_user_activity_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_user_activity_day_library_id_user_id_pk": { + "columns": ["day", "library_id", "user_id"], + "name": "daily_user_activity_day_library_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_quick_insert": { + "name": "is_quick_insert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten": { + "name": "fasten", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "events_day_idx": { + "name": "events_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertable_stats": { + "name": "insertable_stats", + "columns": { + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_inserted_at": { + "name": "first_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_inserted_at": { + "name": "last_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "insertable_stats_count_idx": { + "name": "insertable_stats_count_idx", + "columns": ["library_id", "insert_count"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "insertable_stats_library_id_element_id_pk": { + "columns": ["library_id", "element_id"], + "name": "insertable_stats_library_id_element_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_stats_user_id_library_id_pk": { + "columns": ["user_id", "library_id"], + "name": "user_stats_user_id_library_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 001984b3e..7c7fe0068 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1790215505598, "tag": "0006_admin_teams_webhooks_jobs", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1790270424041, + "tag": "0007_admin_team_column", + "breakpoints": true } ] } diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 7ed1ad3a2..4feec3821 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -7,7 +7,6 @@ import { insertables, libraries, users, - adminTeamMembers, loadJobs, onshapeWebhooks } from "@backend/db/schema"; @@ -77,7 +76,6 @@ export async function resetDb(db: Db): Promise { db.delete(insertables), db.delete(groups), db.delete(users), - db.delete(adminTeamMembers), db.delete(libraries), db.delete(onshapeWebhooks), // Analytics has no foreign keys, so nothing cascades these away. diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 501460425..af7ebb021 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -11,6 +11,7 @@ import { FastenInfo } from "../features/library/insertables/fasten"; import { DEFAULT_LIBRARY, LibraryId } from "../features/library/library-id"; import { AppTab } from "../features/settings/app-tab"; import { DEFAULT_THEME, Theme } from "../features/settings/settings"; +import type { AdminTeamMember } from "../features/admin-team/contract"; import { Vendor } from "../features/library/vendors"; import { ConfigurationParameter, @@ -58,22 +59,14 @@ export const libraries = sqliteTable("libraries", { // The search index is in R2, keyed by library id; see rebuildSearchDb. cacheVersion: integer("cache_version").notNull().default(0), // Null until the owner sets one; until then only the owner can edit. - adminTeamId: text("admin_team_id") + adminTeamId: text("admin_team_id"), + // As of the last sync, replaced whole each time. + adminTeam: text("admin_team", { mode: "json" }) + .$type() + .notNull() + .default([]) }); -/** The admin team's members as of the last sync, replaced whole each time. */ -export const adminTeamMembers = sqliteTable( - "admin_team_members", - { - libraryId: libraryId().references(() => libraries.id, { - onDelete: "cascade" - }), - userId: text("user_id").notNull(), - isTeamAdmin: integer("is_team_admin", { mode: "boolean" }).notNull() - }, - (t) => [primaryKey({ columns: [t.libraryId, t.userId] })] -); - /** Before a load pins a real version, so a failed group can still be retried. */ export const PLACEHOLDER_VERSION_ID = "placeholder"; diff --git a/src/backend/features/admin-team/contract.ts b/src/backend/features/admin-team/contract.ts index b74cdd217..2fc7a9ca7 100644 --- a/src/backend/features/admin-team/contract.ts +++ b/src/backend/features/admin-team/contract.ts @@ -4,3 +4,9 @@ export interface AdminTeamOut { teamId?: string; memberCount: number; } + +export interface AdminTeamMember { + userId: string; + /** An admin of the Onshape team, which makes them an admin of the library. */ + isTeamAdmin: boolean; +} diff --git a/src/backend/features/admin-team/routes.ts b/src/backend/features/admin-team/routes.ts index 69315f898..49f09490d 100644 --- a/src/backend/features/admin-team/routes.ts +++ b/src/backend/features/admin-team/routes.ts @@ -1,4 +1,4 @@ -import { count, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { HttpStatus } from "http-status-ts"; import { z } from "zod"; import { getApp } from "../../lib/context"; @@ -6,7 +6,7 @@ import { handledError } from "../../lib/api-error"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { validate } from "../../lib/validate"; import { type Db, getDb } from "../../db/client"; -import { adminTeamMembers, libraries } from "../../db/schema"; +import { libraries } from "../../db/schema"; import { requireAdminMiddleware, requireOwnerMiddleware } from "../auth/guards"; import { ensureLibrary } from "../library/db"; import type { LibraryId } from "../library/library-id"; @@ -24,21 +24,17 @@ async function getAdminTeam( db: Db, libraryId: LibraryId ): Promise { - const [library, members] = await Promise.all([ - db - .select({ teamId: libraries.adminTeamId }) - .from(libraries) - .where(eq(libraries.id, libraryId)) - .get(), - db - .select({ count: count() }) - .from(adminTeamMembers) - .where(eq(adminTeamMembers.libraryId, libraryId)) - .get() - ]); + const library = await db + .select({ + teamId: libraries.adminTeamId, + members: libraries.adminTeam + }) + .from(libraries) + .where(eq(libraries.id, libraryId)) + .get(); return { teamId: library?.teamId ?? undefined, - memberCount: members?.count ?? 0 + memberCount: library?.members.length ?? 0 }; } diff --git a/src/backend/features/admin-team/routes.worker.test.ts b/src/backend/features/admin-team/routes.worker.test.ts index 429da108d..4b393b4da 100644 --- a/src/backend/features/admin-team/routes.worker.test.ts +++ b/src/backend/features/admin-team/routes.worker.test.ts @@ -10,7 +10,7 @@ import { } from "../../../__test_utils__"; import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; import { getDb } from "../../db/client"; -import { adminTeamMembers, libraries } from "../../db/schema"; +import { libraries } from "../../db/schema"; import { OnshapeApiError } from "../../lib/onshape/client"; import { AccessLevel } from "../auth/access-level"; @@ -64,15 +64,12 @@ describe("setting a library's admin team", () => { const res = await setTeam("team", onshapeApi); expect(await res.json()).toEqual({ teamId: "team", memberCount: 2 }); - expect( - await db - .select({ - userId: adminTeamMembers.userId, - isTeamAdmin: adminTeamMembers.isTeamAdmin - }) - .from(adminTeamMembers) - .all() - ).toEqual([ + const library = await db + .select({ adminTeam: libraries.adminTeam }) + .from(libraries) + .where(eq(libraries.id, TEST_LIBRARY_ID)) + .get(); + expect(library?.adminTeam).toEqual([ { userId: "member", isTeamAdmin: false }, { userId: "team-admin", isTeamAdmin: true } ]); diff --git a/src/backend/features/admin-team/sync.ts b/src/backend/features/admin-team/sync.ts index ea30a7e19..49cb35e05 100644 --- a/src/backend/features/admin-team/sync.ts +++ b/src/backend/features/admin-team/sync.ts @@ -1,18 +1,14 @@ /** Access is read from the stored team, and a sync bumps the library version so clients refresh. */ import { eq } from "drizzle-orm"; -import type { BatchItem } from "drizzle-orm/batch"; import type { AppBindings } from "../../lib/context"; import { getDb } from "../../db/client"; -import { adminTeamMembers, libraries } from "../../db/schema"; +import { libraries } from "../../db/schema"; import type { OnshapeApi } from "../../lib/onshape/client"; import { getTeamMembers } from "../../lib/onshape/endpoints/teams"; import { bumpLibraryVersion } from "../library/db"; import type { LibraryId } from "../library/library-id"; import { pushLibraryChanged } from "../live/notify"; -/** Three columns a row, under D1's 100 bound parameters a statement. */ -const ROWS_PER_INSERT = 30; - export async function syncAdminTeam( env: AppBindings, onshapeApi: OnshapeApi, @@ -27,26 +23,15 @@ export async function syncAdminTeam( const teamId = library?.adminTeamId; const members = teamId ? await getTeamMembers(onshapeApi, teamId) : []; - const rows = members.map((member) => ({ - libraryId, - userId: member.member.id, - isTeamAdmin: member.admin - })); - // Replaced whole, in one batch, so nobody reads a team half written. - const writes: BatchItem<"sqlite">[] = [ - db - .delete(adminTeamMembers) - .where(eq(adminTeamMembers.libraryId, libraryId)) - ]; - for (let i = 0; i < rows.length; i += ROWS_PER_INSERT) { - writes.push( - db - .insert(adminTeamMembers) - .values(rows.slice(i, i + ROWS_PER_INSERT)) - .onConflictDoNothing() - ); - } - await db.batch(writes as [BatchItem<"sqlite">, ...BatchItem<"sqlite">[]]); + await db + .update(libraries) + .set({ + adminTeam: members.map((member) => ({ + userId: member.member.id, + isTeamAdmin: member.admin + })) + }) + .where(eq(libraries.id, libraryId)); await bumpLibraryVersion(db, libraryId); await pushLibraryChanged(env, libraryId); diff --git a/src/backend/features/auth/admin-sessions.ts b/src/backend/features/auth/admin-sessions.ts index 9b720110b..9381c1ad7 100644 --- a/src/backend/features/auth/admin-sessions.ts +++ b/src/backend/features/auth/admin-sessions.ts @@ -7,8 +7,8 @@ import { type OAuthApi } from "../../lib/onshape/client"; import { getSessionInfo } from "../../lib/onshape/endpoints/users"; import type { AppBindings } from "../../lib/context"; import { getDb } from "../../db/client"; -import { adminTeamMembers } from "../../db/schema"; -import { and, eq, inArray } from "drizzle-orm"; +import { libraries } from "../../db/schema"; +import { inArray } from "drizzle-orm"; import type { LibraryId } from "../library/library-id"; import { getOnshapeApiFromSessionId } from "./request-auth"; @@ -57,16 +57,18 @@ export async function getAdminOnshapeApi( if (owner || libraryIds.length === 0) { return owner; } - const admins = await getDb(env.DB) - .selectDistinct({ userId: adminTeamMembers.userId }) - .from(adminTeamMembers) - .where( - and( - inArray(adminTeamMembers.libraryId, libraryIds), - eq(adminTeamMembers.isTeamAdmin, true) - ) - ); - for (const { userId } of admins) { + const rows = await getDb(env.DB) + .select({ adminTeam: libraries.adminTeam }) + .from(libraries) + .where(inArray(libraries.id, libraryIds)); + const admins = new Set( + rows.flatMap((row) => + row.adminTeam + .filter((member) => member.isTeamAdmin) + .map((member) => member.userId) + ) + ); + for (const userId of admins) { const api = await getLiveApi(env.KV, userId); if (api) { return api; diff --git a/src/backend/features/auth/admin-sessions.worker.test.ts b/src/backend/features/auth/admin-sessions.worker.test.ts index 34dc4743b..d549792d0 100644 --- a/src/backend/features/auth/admin-sessions.worker.test.ts +++ b/src/backend/features/auth/admin-sessions.worker.test.ts @@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TEST_LIBRARY_ID, resetDb, seedLibrary } from "../../../__test_utils__"; import { MockOnshapeApi } from "../../../__test_utils__/mock-onshape-api"; import { getDb } from "../../db/client"; -import { adminTeamMembers } from "../../db/schema"; +import { libraries } from "../../db/schema"; +import { eq } from "drizzle-orm"; import { getAdminOnshapeApi, rememberAdminSession } from "./admin-sessions"; import * as RequestAuth from "./request-auth"; @@ -32,14 +33,15 @@ describe("finding an admin's session", () => { await resetDb(db); await seedLibrary(db); await env.KV.delete(`admin-session:${OWNER}`); - await db.insert(adminTeamMembers).values([ - { - libraryId: TEST_LIBRARY_ID, - userId: "member", - isTeamAdmin: false - }, - { libraryId: TEST_LIBRARY_ID, userId: "admin", isTeamAdmin: true } - ]); + await db + .update(libraries) + .set({ + adminTeam: [ + { userId: "member", isTeamAdmin: false }, + { userId: "admin", isTeamAdmin: true } + ] + }) + .where(eq(libraries.id, TEST_LIBRARY_ID)); await rememberAdminSession(env.KV, "member", "member-session"); await rememberAdminSession(env.KV, "admin", "admin-session"); }); diff --git a/src/backend/features/auth/request-auth.ts b/src/backend/features/auth/request-auth.ts index bc70a8310..9ea664291 100644 --- a/src/backend/features/auth/request-auth.ts +++ b/src/backend/features/auth/request-auth.ts @@ -3,9 +3,9 @@ import { env as processEnv } from "process"; import { OAuthApi } from "../../lib/onshape/client"; import { getSessionInfo, getUserId } from "../../lib/onshape/endpoints/users"; import { type AppContext, type AuthResolver } from "../../lib/context"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { getDb } from "../../db/client"; -import { adminTeamMembers } from "../../db/schema"; +import { libraries } from "../../db/schema"; import type { LibraryId } from "../library/library-id"; import { AccessLevel, isWithinAccessLevel } from "./access-level"; import { rememberAdminSession } from "./admin-sessions"; @@ -151,16 +151,12 @@ async function lookUpAccessLevel( if (c.env.OWNER_USER_ID && userId === c.env.OWNER_USER_ID) { return AccessLevel.OWNER; } - const member = await getDb(c.env.DB) - .select({ isTeamAdmin: adminTeamMembers.isTeamAdmin }) - .from(adminTeamMembers) - .where( - and( - eq(adminTeamMembers.libraryId, libraryId), - eq(adminTeamMembers.userId, userId) - ) - ) + const library = await getDb(c.env.DB) + .select({ adminTeam: libraries.adminTeam }) + .from(libraries) + .where(eq(libraries.id, libraryId)) .get(); + const member = library?.adminTeam.find((entry) => entry.userId === userId); if (!member) { return AccessLevel.USER; } diff --git a/src/backend/features/auth/request-auth.worker.test.ts b/src/backend/features/auth/request-auth.worker.test.ts index 6104ae2eb..622d14908 100644 --- a/src/backend/features/auth/request-auth.worker.test.ts +++ b/src/backend/features/auth/request-auth.worker.test.ts @@ -6,7 +6,8 @@ import { productionAuth } from "./request-auth"; import { createApp } from "../../app"; import { jsonRequest, resetDb, seedLibrary } from "../../../__test_utils__"; import { getDb } from "../../db/client"; -import { adminTeamMembers } from "../../db/schema"; +import { libraries } from "../../db/schema"; +import { eq } from "drizzle-orm"; import { LibraryId } from "../library/library-id"; import { saveSession } from "./session"; @@ -58,18 +59,15 @@ describe("access from a library's admin team", () => { await resetDb(db); await seedLibrary(db, LibraryId.FRC_DESIGN_LIB); await seedLibrary(db, LibraryId.FTC_DESIGN_LIB); - await db.insert(adminTeamMembers).values([ - { - libraryId: LibraryId.FRC_DESIGN_LIB, - userId: "member", - isTeamAdmin: false - }, - { - libraryId: LibraryId.FRC_DESIGN_LIB, - userId: "team-admin", - isTeamAdmin: true - } - ]); + await db + .update(libraries) + .set({ + adminTeam: [ + { userId: "member", isTeamAdmin: false }, + { userId: "team-admin", isTeamAdmin: true } + ] + }) + .where(eq(libraries.id, LibraryId.FRC_DESIGN_LIB)); }); /** A signed-in session whose user is already resolved, so Onshape is not asked. */ From d72a2f7a611ec2161c0b9e1806f09f604ec51fbd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:34:12 +0000 Subject: [PATCH 38/88] Cache workspace units in KV behind a transient webhook; declare every KV store A workspace's units are kept in KV for a week. On a miss the route asks Onshape and registers a transient updateworkspaceunits webhook in the background, best effort; a delivery drops the entry. The webhook's url carries the workspace, signed with SESSION_SECRET, so nothing records or removes it: Onshape cleans transient webhooks up, and the expiry covers one dropped quietly. A version's units never change, so it is not watched. Every KV key now belongs to a kvStore (lib/kv-store.ts) with its prefix, value type and lifetime: sessions, login sessions, admin sessions and unit info. AGENTS.md keeps KV to what may expire or be lost. runInBackground (lib/background.ts) is what tracking already did, shared. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- AGENTS.md | 5 + docs/REFERENCE.md | 12 +- src/backend/features/analytics/tracking.ts | 13 +- src/backend/features/auth/admin-sessions.ts | 13 +- src/backend/features/auth/session.ts | 39 +++--- src/backend/features/configurations/routes.ts | 48 +------- src/backend/features/configurations/units.ts | 94 +++++++++++++++ .../configurations/units.worker.test.ts | 111 ++++++++++++++++++ src/backend/features/webhooks/routes.ts | 16 +++ src/backend/features/webhooks/transient.ts | 70 +++++++++++ src/backend/lib/background.ts | 20 ++++ src/backend/lib/context.ts | 2 + src/backend/lib/kv-store.ts | 39 ++++++ src/backend/lib/onshape/endpoints/webhooks.ts | 2 + src/backend/lib/signed.test.ts | 21 ++++ src/backend/lib/signed.ts | 46 ++++++++ 16 files changed, 460 insertions(+), 91 deletions(-) create mode 100644 src/backend/features/configurations/units.ts create mode 100644 src/backend/features/configurations/units.worker.test.ts create mode 100644 src/backend/features/webhooks/transient.ts create mode 100644 src/backend/lib/background.ts create mode 100644 src/backend/lib/kv-store.ts create mode 100644 src/backend/lib/signed.test.ts create mode 100644 src/backend/lib/signed.ts diff --git a/AGENTS.md b/AGENTS.md index 00188ab98..3327532cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,11 @@ D1 tables live in `db/schema.ts`, except a feature's own: tracking's are in they hold no foreign key into the rest. `drizzle.config.ts` lists every schema file, so a new one has to be added there or its tables generate no migration. +KV is for what may expire or be lost: sessions, and caches that save Onshape +calls. Every key belongs to a `kvStore` (`lib/kv-store.ts`) declared beside the +code that owns it, with its prefix, value type and lifetime; don't read or +write `KV` directly. Anything that must last goes in D1. + ## Configurations A configuration takes exactly two forms, and `features/configurations/selection.ts` diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 0f10bedfb..dce6dc9b9 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -30,14 +30,14 @@ D1 is Cloudflare's managed SQLite database. It is the app's primary persistent s Queries go through **Drizzle ORM** so you write TypeScript instead of raw SQL. The schema is defined in `src/backend/db/schema.ts`. SQL migration files live in `drizzle/` and are applied automatically on deploy. -### KV — Session & Token Storage (`c.env.KV`) +### KV — Sessions and Caches (`c.env.KV`) -KV is a key-value store (like a global dictionary). The app uses it exclusively for **authentication**: +KV holds what may expire or be lost. Every key belongs to a `kvStore` (`src/backend/lib/kv-store.ts`) with its own prefix, value type and lifetime: -- During the OAuth login flow, it briefly stores the OAuth `state` and the URL to redirect back to after login. -- After login succeeds, it stores the user's access token and refresh token, keyed to their session cookie. - -KV serves as a cheap, lightweight way to persist user data across multiple Cloudflare Workers (which Cloudflare automatically scales and provisions based on the app's current traffic). Because Workers are stateless — there is no in-memory session that persists between requests — KV is the right place to stash tokens between requests. +- `login-session:` — the OAuth `state` and where to return, for the ten minutes of a sign-in. +- `tokens:` — a signed-in session's access and refresh tokens, keyed by its cookie, for 30 days. +- `admin-session:` — the owner's and team admins' latest session ids, so a load nobody is signed in behind can run as one of them. +- `unit-info:` — a workspace's units, for a week. On a miss the route asks Onshape and registers a transient `updateworkspaceunits` webhook, whose delivery drops the entry. Transient webhooks are cleaned up by Onshape after a while without events, so nothing records or removes them; their url carries the workspace, signed with `SESSION_SECRET`, and the expiry covers one Onshape drops quietly (`features/webhooks/transient.ts`). ### R2 — Blob Storage (`c.env.BLOB`) diff --git a/src/backend/features/analytics/tracking.ts b/src/backend/features/analytics/tracking.ts index e4cc2ef3b..cc998f78a 100644 --- a/src/backend/features/analytics/tracking.ts +++ b/src/backend/features/analytics/tracking.ts @@ -1,4 +1,5 @@ import type { BatchItem } from "drizzle-orm/batch"; +import { runInBackground } from "../../lib/background"; import { type AppContext } from "../../lib/context"; import { getDb, type Db } from "../../db/client"; import { events, type LoggedEvent } from "./schema"; @@ -40,19 +41,11 @@ interface AppOpenEvent { } /** Tracking never fails an insert, so errors are logged. Awaits without an execution context. */ -export async function trackInBackground( +export function trackInBackground( c: AppContext, work: () => Promise ): Promise { - const guarded = work().catch((error) => { - console.error("Failed to record usage event", error); - }); - - try { - c.executionCtx.waitUntil(guarded); - } catch { - await guarded; - } + return runInBackground(c, "record usage event", work); } export async function trackInsert( diff --git a/src/backend/features/auth/admin-sessions.ts b/src/backend/features/auth/admin-sessions.ts index 9381c1ad7..8b8149821 100644 --- a/src/backend/features/auth/admin-sessions.ts +++ b/src/backend/features/auth/admin-sessions.ts @@ -11,10 +11,10 @@ import { libraries } from "../../db/schema"; import { inArray } from "drizzle-orm"; import type { LibraryId } from "../library/library-id"; import { getOnshapeApiFromSessionId } from "./request-auth"; +import { kvStore } from "../../lib/kv-store"; -function adminSessionKey(userId: string): string { - return `admin-session:${userId}`; -} +/** Each admin's latest session id, by user id. */ +const adminSessions = kvStore("admin-session"); /** Only writes when the session changed. */ export async function rememberAdminSession( @@ -22,9 +22,8 @@ export async function rememberAdminSession( userId: string, sessionId: string ): Promise { - const key = adminSessionKey(userId); - if ((await kv.get(key)) !== sessionId) { - await kv.put(key, sessionId); + if ((await adminSessions.get(kv, userId)) !== sessionId) { + await adminSessions.put(kv, userId, sessionId); } } @@ -32,7 +31,7 @@ async function getLiveApi( kv: KVNamespace, userId: string ): Promise { - const sessionId = await kv.get(adminSessionKey(userId)); + const sessionId = await adminSessions.get(kv, userId); if (!sessionId) { return undefined; } diff --git a/src/backend/features/auth/session.ts b/src/backend/features/auth/session.ts index 0035adc2b..37fc100af 100644 --- a/src/backend/features/auth/session.ts +++ b/src/backend/features/auth/session.ts @@ -3,6 +3,7 @@ import { HttpStatus } from "http-status-ts"; import { internalError } from "../../lib/api-error"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { type AppContext } from "../../lib/context"; +import { kvStore } from "../../lib/kv-store"; const SESSION_COOKIE = "frc-design-app-cookie"; /** Held only for the OAuth round trip, so an abandoned one costs the session nothing. */ @@ -49,17 +50,11 @@ interface Session extends AuthTokens { } /** Still `tokens:`, so sessions signed in before this held a userId survive. */ -function sessionKey(sessionId: string): string { - return `tokens:${sessionId}`; -} - -function loginKey(loginId: string): string { - return `login-session:${loginId}`; -} +const sessions = kvStore("tokens", { ttlSeconds: SESSION_TTL }); /** The cookie is the caller's to clear. */ -async function dropSession(kv: KVNamespace, sessionId: string): Promise { - await kv.delete(sessionKey(sessionId)); +function dropSession(kv: KVNamespace, sessionId: string): Promise { + return sessions.delete(kv, sessionId); } export async function endSession(c: AppContext): Promise { @@ -93,23 +88,21 @@ export async function saveSession( sessionId: string, session: Session ) { - await kv.put(sessionKey(sessionId), JSON.stringify(session), { - expirationTtl: SESSION_TTL - }); + await sessions.put(kv, sessionId, session); } export async function getSession( kv: KVNamespace, sessionId: string ): Promise { - const raw = await kv.get(sessionKey(sessionId)); - if (!raw) { + const session = await sessions.get(kv, sessionId); + if (!session) { throw internalError( "Failed to find valid auth tokens to use", HttpStatus.UNAUTHORIZED ); } - return JSON.parse(raw) as Session; + return session; } /** What the callback needs to finish a sign-in it did not start. */ @@ -118,19 +111,21 @@ interface LoginSession { redirectUrl: string; } +const loginSessions = kvStore("login-session", { + ttlSeconds: LOGIN_TTL +}); + /** Single-use: reading it also clears it, so a state cannot be replayed. */ export async function takeLoginSession( c: AppContext ): Promise { const loginId = getCookie(c, LOGIN_COOKIE); if (!loginId) return undefined; - const raw = await c.env.KV.get(loginKey(loginId)); - if (!raw) return undefined; - - const session = JSON.parse(raw) as LoginSession; + const session = await loginSessions.get(c.env.KV, loginId); + if (!session) return undefined; deleteCookie(c, LOGIN_COOKIE, COOKIE_OPTIONS); - void c.env.KV.delete(loginKey(loginId)); + void loginSessions.delete(c.env.KV, loginId); return session; } @@ -148,7 +143,5 @@ export async function startLoginSession( maxAge: LOGIN_TTL }); - await c.env.KV.put(loginKey(loginId), JSON.stringify(data), { - expirationTtl: LOGIN_TTL - }); + await loginSessions.put(c.env.KV, loginId, data); } diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 8e8c42f63..5ab10c535 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -5,12 +5,10 @@ import { validate } from "../../lib/validate"; import { getApp } from "../../lib/context"; import { getInsertableParam, insertableRoute } from "../../lib/route-params"; import { getDb } from "../../db/client"; -import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; import { configurations, insertables } from "../../db/schema"; -import { type ConfigurationResult, type UnitInfo } from "./contract"; +import { type ConfigurationResult } from "./contract"; +import { getUnitInfoCached } from "./units"; import { searchRecordsOf } from "../search/records"; -import { DEFAULT_QUANTITY_PRECISION } from "./utils"; -import { QuantityType, type Unit } from "./enums"; import { INSTANCE_TYPES } from "../../lib/onshape/path"; import { internalError } from "../../lib/api-error"; import { HttpStatus } from "http-status-ts"; @@ -61,50 +59,10 @@ configurationRoutes.get( } ); -/** One entry of Onshape's `defaultUnits`: which unit a quantity type is in. */ -interface OnshapeUnit { - key: QuantityType; - value: Unit; -} - -/** Onshape names a unit for every type, so a missing one is a response we don't understand. */ -function getDefaultUnit( - units: OnshapeUnit[], - quantityType: QuantityType -): Unit { - const unit = units.find((entry) => entry.key === quantityType); - if (!unit) { - throw internalError( - `Onshape named no default unit for ${quantityType}`, - HttpStatus.BAD_GATEWAY - ); - } - return unit.value; -} - /** GET /api/unit-info?documentId=X&instanceId=Y&instanceType=v */ configurationRoutes.get( "/unit-info", cacheMiddleware(), validate("query", instancePathQuery), - async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const instancePath = c.req.valid("query"); - - const rawUnitInfo = await getUnitInfo(onshapeApi, instancePath); - const units = rawUnitInfo.defaultUnits.units as OnshapeUnit[]; - - const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); - const lengthUnit = getDefaultUnit(units, QuantityType.LENGTH); - - const result: UnitInfo = { - angleUnit, - lengthUnit, - anglePrecision: rawUnitInfo.unitsDisplayPrecision[angleUnit], - lengthPrecision: rawUnitInfo.unitsDisplayPrecision[lengthUnit], - // Onshape carries no display precision for a unitless real. - realPrecision: DEFAULT_QUANTITY_PRECISION - }; - return c.json(result); - } + async (c) => c.json(await getUnitInfoCached(c, c.req.valid("query"))) ); diff --git a/src/backend/features/configurations/units.ts b/src/backend/features/configurations/units.ts new file mode 100644 index 000000000..2ae6b66a3 --- /dev/null +++ b/src/backend/features/configurations/units.ts @@ -0,0 +1,94 @@ +/** + * A workspace's units, cached so each panel open needn't ask Onshape. A + * transient webhook drops the entry when they change; since Onshape may drop + * the webhook quietly, entries also expire, and the next miss watches again. + */ +import { HttpStatus } from "http-status-ts"; +import type { AppContext } from "../../lib/context"; +import { internalError } from "../../lib/api-error"; +import { runInBackground } from "../../lib/background"; +import { kvStore } from "../../lib/kv-store"; +import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; +import type { InstancePath } from "../../lib/onshape/path"; +import { watchWorkspaceUnits } from "../webhooks/transient"; +import type { UnitInfo } from "./contract"; +import { QuantityType, type Unit } from "./enums"; +import { DEFAULT_QUANTITY_PRECISION } from "./utils"; + +const unitInfos = kvStore("unit-info", { + ttlSeconds: 7 * 24 * 3600 +}); + +function unitInfoId(path: InstancePath): string { + return `${path.documentId}:${path.instanceType}:${path.instanceId}`; +} + +/** One entry of Onshape's `defaultUnits`: which unit a quantity type is in. */ +interface OnshapeUnit { + key: QuantityType; + value: Unit; +} + +/** Onshape names a unit for every type, so a missing one is a response we don't understand. */ +function getDefaultUnit( + units: OnshapeUnit[], + quantityType: QuantityType +): Unit { + const unit = units.find((entry) => entry.key === quantityType); + if (!unit) { + throw internalError( + `Onshape named no default unit for ${quantityType}`, + HttpStatus.BAD_GATEWAY + ); + } + return unit.value; +} + +async function fetchUnitInfo( + c: AppContext, + path: InstancePath +): Promise { + const raw = await getUnitInfo(await c.var.getOnshapeApi(), path); + const units = raw.defaultUnits.units as OnshapeUnit[]; + const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); + const lengthUnit = getDefaultUnit(units, QuantityType.LENGTH); + return { + angleUnit, + lengthUnit, + anglePrecision: raw.unitsDisplayPrecision[angleUnit], + lengthPrecision: raw.unitsDisplayPrecision[lengthUnit], + // Onshape carries no display precision for a unitless real. + realPrecision: DEFAULT_QUANTITY_PRECISION + }; +} + +export async function getUnitInfoCached( + c: AppContext, + path: InstancePath +): Promise { + const cached = await unitInfos.get(c.env.KV, unitInfoId(path)); + if (cached) { + return cached; + } + const unitInfo = await fetchUnitInfo(c, path); + await unitInfos.put(c.env.KV, unitInfoId(path), unitInfo); + // A version's units never change, so only a workspace is watched. + if (path.instanceType === "w") { + await runInBackground(c, "watch workspace units", async () => + watchWorkspaceUnits( + c.env, + await c.var.getOnshapeApi(), + path, + new URL(c.req.url).origin + ) + ); + } + return unitInfo; +} + +export function forgetUnitInfo( + kv: KVNamespace, + workspace: InstancePath +): Promise { + return unitInfos.delete(kv, unitInfoId(workspace)); +} diff --git a/src/backend/features/configurations/units.worker.test.ts b/src/backend/features/configurations/units.worker.test.ts new file mode 100644 index 000000000..9a86e8978 --- /dev/null +++ b/src/backend/features/configurations/units.worker.test.ts @@ -0,0 +1,111 @@ +import { env } from "cloudflare:workers"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi +} from "vitest"; +import { createTestApp, jsonRequest } from "../../../__test_utils__"; +import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents"; +import * as WebhookEndpoints from "../../lib/onshape/endpoints/webhooks"; +import { QuantityType, Unit } from "./enums"; + +const TEST_ENV = { ...env, SESSION_SECRET: "test-secret" }; + +function mockUnits() { + return vi.spyOn(DocumentEndpoints, "getUnitInfo").mockResolvedValue({ + defaultUnits: { + units: [ + { key: QuantityType.ANGLE, value: Unit.DEGREE }, + { key: QuantityType.LENGTH, value: Unit.INCH } + ] + }, + unitsDisplayPrecision: { [Unit.DEGREE]: 1, [Unit.INCH]: 3 } + }); +} + +const unitInfo = (instanceType: "w" | "v", documentId = "doc") => + createTestApp().request( + `http://localhost/api/unit-info?documentId=${documentId}&instanceId=ws&instanceType=${instanceType}`, + jsonRequest("GET"), + TEST_ENV + ); + +const deliver = (url: string, event: string) => + createTestApp().request(url, jsonRequest("POST", { event }), TEST_ENV); + +describe("a workspace's units", () => { + let watch: MockInstance; + beforeEach(() => { + watch = vi + .spyOn(WebhookEndpoints, "createWebhook") + .mockResolvedValue({ id: "hook", url: "", events: [] }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("asks Onshape once, then serves what it kept", async () => { + const fetch = mockUnits(); + + await unitInfo("w", "doc-once"); + const res = await unitInfo("w", "doc-once"); + + expect(await res.json()).toMatchObject({ lengthUnit: Unit.INCH }); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("watches a workspace with a transient webhook", async () => { + mockUnits(); + + await unitInfo("w", "doc-watch"); + + expect(watch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + documentId: "doc-watch", + workspaceId: "ws", + events: ["onshape.model.lifecycle.updateworkspaceunits"], + isTransient: true + }) + ); + }); + + // A version's units never change. + it("watches nothing for a version", async () => { + mockUnits(); + await unitInfo("v", "doc-version"); + expect(watch).not.toHaveBeenCalled(); + }); + + it("asks again once the webhook says they changed", async () => { + const fetch = mockUnits(); + await unitInfo("w", "doc-changed"); + const url = watch.mock.calls[0][1].url; + + await deliver(url, "onshape.model.lifecycle.updateworkspaceunits"); + await unitInfo("w", "doc-changed"); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("keeps its copy through Onshape's registration check", async () => { + const fetch = mockUnits(); + await unitInfo("w", "doc-register"); + const url = watch.mock.calls[0][1].url; + + expect((await deliver(url, "webhook.register")).status).toBe(200); + await unitInfo("w", "doc-register"); + + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("turns away a delivery for a workspace it did not sign", async () => { + const res = await deliver( + "http://localhost/api/webhooks/units?documentId=doc&workspaceId=ws&signature=00", + "onshape.model.lifecycle.updateworkspaceunits" + ); + expect(res.status).toBe(403); + }); +}); diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index e68058edd..4a3386826 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -14,6 +14,8 @@ import { RECEIVE_PATH, WebhookEvent } from "./registration"; +import { readUnitsDelivery, UNITS_RECEIVE_PATH } from "./transient"; +import { forgetUnitInfo } from "../configurations/units"; export const webhookRoutes = getApp(); @@ -49,6 +51,20 @@ webhookRoutes.post(RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { return c.json({}); }); +/** POST /api/webhooks/units?documentId=&workspaceId=&signature= */ +webhookRoutes.post(UNITS_RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { + const workspace = await readUnitsDelivery(c.env, c.req.query()); + if (!workspace) { + throw forbiddenError("Unrecognized webhook"); + } + // Registration and pings only want a 200; any other event is the change. + const { event } = await c.req.json(); + if (!event.startsWith("webhook.")) { + await forgetUnitInfo(c.env.KV, workspace); + } + return c.json({}); +}); + /** Nobody is signed in behind a webhook, so each load finds an admin's session. */ async function reloadDocument( env: AppBindings, diff --git a/src/backend/features/webhooks/transient.ts b/src/backend/features/webhooks/transient.ts new file mode 100644 index 000000000..43e1336c8 --- /dev/null +++ b/src/backend/features/webhooks/transient.ts @@ -0,0 +1,70 @@ +/** + * Transient webhooks, for caches that save Onshape calls. Onshape deletes one + * after a while without events, so they are registered best effort and never + * removed or recorded: the url carries its subject, signed so a delivery can be + * trusted without a lookup. + */ +import type { AppBindings } from "../../lib/context"; +import type { OnshapeApi } from "../../lib/onshape/client"; +import { createWebhook } from "../../lib/onshape/endpoints/webhooks"; +import type { InstancePath } from "../../lib/onshape/path"; +import { sign, verify } from "../../lib/signed"; + +export const UNITS_RECEIVE_PATH = "/api/webhooks/units"; + +const UPDATE_WORKSPACE_UNITS = "onshape.model.lifecycle.updateworkspaceunits"; + +function unitsSubject(workspace: InstancePath): string { + return `${workspace.documentId}:${workspace.instanceId}`; +} + +/** Tells the units cache when the workspace's units change. */ +export async function watchWorkspaceUnits( + env: AppBindings, + onshapeApi: OnshapeApi, + workspace: InstancePath, + origin: string +): Promise { + if (!env.SESSION_SECRET) { + return; + } + const url = new URL(UNITS_RECEIVE_PATH, origin); + url.searchParams.set("documentId", workspace.documentId); + url.searchParams.set("workspaceId", workspace.instanceId); + url.searchParams.set( + "signature", + await sign(env.SESSION_SECRET, unitsSubject(workspace)) + ); + await createWebhook(onshapeApi, { + documentId: workspace.documentId, + workspaceId: workspace.instanceId, + events: [UPDATE_WORKSPACE_UNITS], + url: url.href, + name: "FRCDesignApp units", + description: "Keeps the FRCDesignApp's copy of these units current.", + options: { collapseEvents: true }, + isTransient: true + }); +} + +/** The workspace a units delivery names, if its signature is ours. */ +export async function readUnitsDelivery( + env: AppBindings, + query: Record +): Promise { + const { documentId, workspaceId, signature } = query; + if (!env.SESSION_SECRET || !documentId || !workspaceId || !signature) { + return undefined; + } + const workspace: InstancePath = { + documentId, + instanceId: workspaceId, + instanceType: "w" + }; + const valid = await verify( + env.SESSION_SECRET, + unitsSubject(workspace), + signature + ); + return valid ? workspace : undefined; +} diff --git a/src/backend/lib/background.ts b/src/backend/lib/background.ts new file mode 100644 index 000000000..5bebb4dcb --- /dev/null +++ b/src/backend/lib/background.ts @@ -0,0 +1,20 @@ +import type { AppContext } from "./context"; + +/** + * Runs `work` after the response, for what the caller need not wait on. Its + * errors are logged, never thrown. Awaits when there is no execution context. + */ +export async function runInBackground( + c: AppContext, + description: string, + work: () => Promise +): Promise { + const guarded = work().catch((error: unknown) => { + console.error(`Failed to ${description}`, error); + }); + try { + c.executionCtx.waitUntil(guarded); + } catch { + await guarded; + } +} diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 673a2ab5d..a4ce6698f 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -24,6 +24,8 @@ export interface AppBindings { VITE_ACCESS_LEVEL_OVERRIDE?: string; /** Testing-only: treat requests as signed in with a fake user. Not for production. */ FORCE_SIGNED_IN?: string; + /** Signs the urls transient webhooks deliver to; unset registers none. */ + SESSION_SECRET?: string; } interface AppVariables { diff --git a/src/backend/lib/kv-store.ts b/src/backend/lib/kv-store.ts new file mode 100644 index 000000000..9dcf32989 --- /dev/null +++ b/src/backend/lib/kv-store.ts @@ -0,0 +1,39 @@ +/** + * Every KV key belongs to one of these, so the namespace stays a set of + * declared stores rather than keys written from anywhere. Values are JSON. + */ +export interface KvStore { + get(kv: KVNamespace, id: string): Promise; + put(kv: KVNamespace, id: string, value: T): Promise; + delete(kv: KVNamespace, id: string): Promise; +} + +interface KvStoreOptions { + /** Omitted, entries last until deleted. */ + ttlSeconds?: number; +} + +export function kvStore( + prefix: string, + options: KvStoreOptions = {} +): KvStore { + const key = (id: string) => `${prefix}:${id}`; + return { + async get(kv, id) { + try { + return (await kv.get(key(id), "json")) ?? undefined; + } catch { + // Written in another shape; a miss is what to make of it. + return undefined; + } + }, + put(kv, id, value) { + return kv.put(key(id), JSON.stringify(value), { + expirationTtl: options.ttlSeconds + }); + }, + delete(kv, id) { + return kv.delete(key(id)); + } + }; +} diff --git a/src/backend/lib/onshape/endpoints/webhooks.ts b/src/backend/lib/onshape/endpoints/webhooks.ts index 9991a32a1..a50856d25 100644 --- a/src/backend/lib/onshape/endpoints/webhooks.ts +++ b/src/backend/lib/onshape/endpoints/webhooks.ts @@ -11,6 +11,8 @@ export interface CreateWebhookParams { companyId?: string; /** The document whose events it hears, for a document's events. */ documentId?: string; + /** Narrows a document's events to one workspace's. */ + workspaceId?: string; events: string[]; url: string; name: string; diff --git a/src/backend/lib/signed.test.ts b/src/backend/lib/signed.test.ts new file mode 100644 index 000000000..8cc829af8 --- /dev/null +++ b/src/backend/lib/signed.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { sign, verify } from "./signed"; + +describe("signing", () => { + it("verifies what it signed", async () => { + const signature = await sign("secret", "doc:ws"); + expect(await verify("secret", "doc:ws", signature)).toBe(true); + }); + + it.each([ + ["another value", "secret", "doc:other"], + ["another secret", "guess", "doc:ws"] + ])("rejects %s", async (_, secret, value) => { + const signature = await sign("secret", "doc:ws"); + expect(await verify(secret, value, signature)).toBe(false); + }); + + it("rejects a signature that is not hex", async () => { + expect(await verify("secret", "doc:ws", "not-hex")).toBe(false); + }); +}); diff --git a/src/backend/lib/signed.ts b/src/backend/lib/signed.ts new file mode 100644 index 000000000..89dcd8dc6 --- /dev/null +++ b/src/backend/lib/signed.ts @@ -0,0 +1,46 @@ +/** HMAC signatures, for urls we hand out and must recognize without storing them. */ + +const encoder = new TextEncoder(); + +function importKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); +} + +function toHex(bytes: ArrayBuffer): string { + return Array.from(new Uint8Array(bytes), (byte) => + byte.toString(16).padStart(2, "0") + ).join(""); +} + +function fromHex(hex: string): ArrayBuffer | undefined { + if (!/^(?:[0-9a-f]{2})+$/.test(hex)) { + return undefined; + } + const pairs = hex.match(/../g) ?? []; + return new Uint8Array(pairs.map((pair) => parseInt(pair, 16))).buffer; +} + +export async function sign(secret: string, value: string): Promise { + const key = await importKey(secret); + return toHex(await crypto.subtle.sign("HMAC", key, encoder.encode(value))); +} + +/** Constant-time, as `verify` is. */ +export async function verify( + secret: string, + value: string, + signature: string +): Promise { + const bytes = fromHex(signature); + if (!bytes) { + return false; + } + const key = await importKey(secret); + return crypto.subtle.verify("HMAC", key, bytes, encoder.encode(value)); +} From 3b1f52b9216571d7e3b1a7324bc22e98559f8631 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 19:55:19 +0000 Subject: [PATCH 39/88] Tag analytics parts with their library; plain admin team field; unsigned units webhook - PartUsageOut carries its libraryId, so the overview treemap no longer tags each library's parts by query index (taggedParts, UsagePart). - The admin team id is an ordinary settings field: the owner edits it and it saves on blur or Enter; any other editor sees it read-only. Reading the team is editor-level; setting it stays the owner's. - Onshape doesn't sign webhooks registered through the API, and a forged units delivery only costs a refetch, so its url names the workspace plainly. lib/signed.ts and the SESSION_SECRET binding go. - Settings rows are shortened so each control fits beside its label. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 4 +- src/backend/features/admin-team/routes.ts | 8 ++- src/backend/features/analytics/contract.ts | 1 + .../features/analytics/part-queries.ts | 3 + src/backend/features/configurations/units.ts | 1 - .../configurations/units.worker.test.ts | 14 +--- src/backend/features/webhooks/routes.ts | 4 +- src/backend/features/webhooks/transient.ts | 41 +++-------- src/backend/lib/context.ts | 2 - src/backend/lib/signed.test.ts | 21 ------ src/backend/lib/signed.ts | 46 ------------ .../components/admin-team-setting.test.tsx | 70 +++++++++++++++++++ .../components/admin-team-setting.tsx | 69 ++++++++---------- .../components/refresh-admin-team-button.tsx | 2 +- .../features/dashboard/parts-sort.test.ts | 2 + .../features/dashboard/treemap-data.test.ts | 5 +- .../features/dashboard/treemap-data.ts | 15 ++-- .../features/dashboard/usage-treemap.tsx | 6 +- .../library/components/reload-button.tsx | 2 +- .../settings/components/settings-menu.tsx | 16 ++--- src/frontend/lib/style-constants.ts | 3 + src/frontend/routes/dashboard/index.tsx | 15 +--- 22 files changed, 156 insertions(+), 194 deletions(-) delete mode 100644 src/backend/lib/signed.test.ts delete mode 100644 src/backend/lib/signed.ts create mode 100644 src/frontend/features/admin-team/components/admin-team-setting.test.tsx diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index dce6dc9b9..d766d023e 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -37,7 +37,7 @@ KV holds what may expire or be lost. Every key belongs to a `kvStore` (`src/back - `login-session:` — the OAuth `state` and where to return, for the ten minutes of a sign-in. - `tokens:` — a signed-in session's access and refresh tokens, keyed by its cookie, for 30 days. - `admin-session:` — the owner's and team admins' latest session ids, so a load nobody is signed in behind can run as one of them. -- `unit-info:` — a workspace's units, for a week. On a miss the route asks Onshape and registers a transient `updateworkspaceunits` webhook, whose delivery drops the entry. Transient webhooks are cleaned up by Onshape after a while without events, so nothing records or removes them; their url carries the workspace, signed with `SESSION_SECRET`, and the expiry covers one Onshape drops quietly (`features/webhooks/transient.ts`). +- `unit-info:` — a workspace's units, for a week. On a miss the route asks Onshape and registers a transient `updateworkspaceunits` webhook, whose delivery drops the entry. Onshape cleans transient webhooks up after a while without events, so nothing records or removes them, and the expiry covers one it drops quietly. Onshape doesn't sign webhooks registered through the API, so the url names the workspace plainly: a forged delivery only costs a refetch (`features/webhooks/transient.ts`). ### R2 — Blob Storage (`c.env.BLOB`) @@ -101,7 +101,7 @@ Onshape pushes one thing, registered with `isTransient: false` and recorded in t - **A new version of a library document.** Registered by the document's load; removed with the last group loaded from it. Reloads that document's groups. -Onshape's team webhooks need a company id, which a personal account lacks, so an admin team's membership is pulled again only when the owner sets the team or an admin presses **Refresh members** in the settings menu. +Onshape's team webhooks need a company id, which a personal account lacks, so an admin team's membership is pulled again only when the owner sets the team or an admin presses **Refresh** beside "Admin team members" in the settings menu. A load that fails is flagged `LOAD_FAILED`, including one whose workflow crashed before it could say so; the next look at the library's jobs notices. Reloading the library's outdated documents reruns it. diff --git a/src/backend/features/admin-team/routes.ts b/src/backend/features/admin-team/routes.ts index 49f09490d..8734e64dd 100644 --- a/src/backend/features/admin-team/routes.ts +++ b/src/backend/features/admin-team/routes.ts @@ -7,7 +7,11 @@ import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { validate } from "../../lib/validate"; import { type Db, getDb } from "../../db/client"; import { libraries } from "../../db/schema"; -import { requireAdminMiddleware, requireOwnerMiddleware } from "../auth/guards"; +import { + requireAdminMiddleware, + requireEditorMiddleware, + requireOwnerMiddleware +} from "../auth/guards"; import { ensureLibrary } from "../library/db"; import type { LibraryId } from "../library/library-id"; import type { AdminTeamOut } from "./contract"; @@ -41,7 +45,7 @@ async function getAdminTeam( /** GET /api/admin-team/library/:libraryId */ adminTeamRoutes.get( "/admin-team" + libraryRoute(), - requireOwnerMiddleware, + requireEditorMiddleware, async (c) => c.json(await getAdminTeam(getDb(c.env.DB), getLibraryParam(c))) ); diff --git a/src/backend/features/analytics/contract.ts b/src/backend/features/analytics/contract.ts index 4b77417b9..f94679590 100644 --- a/src/backend/features/analytics/contract.ts +++ b/src/backend/features/analytics/contract.ts @@ -132,6 +132,7 @@ export interface AnalyticsOverviewOut { /** A row of the parts table. Only parts still in the library are listed. */ export interface PartUsageOut { + libraryId: LibraryId; /** The version-pinned tab, which is both the analytics key and the link. */ path: ElementPath; name: string; diff --git a/src/backend/features/analytics/part-queries.ts b/src/backend/features/analytics/part-queries.ts index 434dbbff8..756674f28 100644 --- a/src/backend/features/analytics/part-queries.ts +++ b/src/backend/features/analytics/part-queries.ts @@ -20,6 +20,7 @@ import { toElementPath } from "../../lib/onshape/path"; import { type ConfigurationParameter } from "../configurations/contract"; export interface PartRow { + libraryId: LibraryId; elementId: string; name: string; groupName: string; @@ -42,6 +43,7 @@ export function getPartRows( return ( db .select({ + libraryId: insertables.libraryId, elementId: insertables.elementId, name: insertables.name, groupName: groups.name, @@ -78,6 +80,7 @@ export function toWindowedPart( const firstUsed = Math.max(row.firstInsertedAt?.getTime() ?? from, from); return { + libraryId: row.libraryId, path: toElementPath(row), name: row.name, groupName: row.groupName, diff --git a/src/backend/features/configurations/units.ts b/src/backend/features/configurations/units.ts index 2ae6b66a3..31d1c7aa2 100644 --- a/src/backend/features/configurations/units.ts +++ b/src/backend/features/configurations/units.ts @@ -76,7 +76,6 @@ export async function getUnitInfoCached( if (path.instanceType === "w") { await runInBackground(c, "watch workspace units", async () => watchWorkspaceUnits( - c.env, await c.var.getOnshapeApi(), path, new URL(c.req.url).origin diff --git a/src/backend/features/configurations/units.worker.test.ts b/src/backend/features/configurations/units.worker.test.ts index 9a86e8978..783f358b7 100644 --- a/src/backend/features/configurations/units.worker.test.ts +++ b/src/backend/features/configurations/units.worker.test.ts @@ -13,8 +13,6 @@ import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents"; import * as WebhookEndpoints from "../../lib/onshape/endpoints/webhooks"; import { QuantityType, Unit } from "./enums"; -const TEST_ENV = { ...env, SESSION_SECRET: "test-secret" }; - function mockUnits() { return vi.spyOn(DocumentEndpoints, "getUnitInfo").mockResolvedValue({ defaultUnits: { @@ -31,11 +29,11 @@ const unitInfo = (instanceType: "w" | "v", documentId = "doc") => createTestApp().request( `http://localhost/api/unit-info?documentId=${documentId}&instanceId=ws&instanceType=${instanceType}`, jsonRequest("GET"), - TEST_ENV + env ); const deliver = (url: string, event: string) => - createTestApp().request(url, jsonRequest("POST", { event }), TEST_ENV); + createTestApp().request(url, jsonRequest("POST", { event }), env); describe("a workspace's units", () => { let watch: MockInstance; @@ -100,12 +98,4 @@ describe("a workspace's units", () => { expect(fetch).toHaveBeenCalledOnce(); }); - - it("turns away a delivery for a workspace it did not sign", async () => { - const res = await deliver( - "http://localhost/api/webhooks/units?documentId=doc&workspaceId=ws&signature=00", - "onshape.model.lifecycle.updateworkspaceunits" - ); - expect(res.status).toBe(403); - }); }); diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index 4a3386826..7434502fb 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -51,9 +51,9 @@ webhookRoutes.post(RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { return c.json({}); }); -/** POST /api/webhooks/units?documentId=&workspaceId=&signature= */ +/** POST /api/webhooks/units?documentId=&workspaceId= */ webhookRoutes.post(UNITS_RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { - const workspace = await readUnitsDelivery(c.env, c.req.query()); + const workspace = readUnitsDelivery(c.req.query()); if (!workspace) { throw forbiddenError("Unrecognized webhook"); } diff --git a/src/backend/features/webhooks/transient.ts b/src/backend/features/webhooks/transient.ts index 43e1336c8..1d736b749 100644 --- a/src/backend/features/webhooks/transient.ts +++ b/src/backend/features/webhooks/transient.ts @@ -1,40 +1,26 @@ /** * Transient webhooks, for caches that save Onshape calls. Onshape deletes one * after a while without events, so they are registered best effort and never - * removed or recorded: the url carries its subject, signed so a delivery can be - * trusted without a lookup. + * recorded or removed. Onshape doesn't sign what the API registers, and the + * url carries its subject unsigned: a forged delivery only costs a refetch. */ -import type { AppBindings } from "../../lib/context"; import type { OnshapeApi } from "../../lib/onshape/client"; import { createWebhook } from "../../lib/onshape/endpoints/webhooks"; import type { InstancePath } from "../../lib/onshape/path"; -import { sign, verify } from "../../lib/signed"; export const UNITS_RECEIVE_PATH = "/api/webhooks/units"; const UPDATE_WORKSPACE_UNITS = "onshape.model.lifecycle.updateworkspaceunits"; -function unitsSubject(workspace: InstancePath): string { - return `${workspace.documentId}:${workspace.instanceId}`; -} - /** Tells the units cache when the workspace's units change. */ export async function watchWorkspaceUnits( - env: AppBindings, onshapeApi: OnshapeApi, workspace: InstancePath, origin: string ): Promise { - if (!env.SESSION_SECRET) { - return; - } const url = new URL(UNITS_RECEIVE_PATH, origin); url.searchParams.set("documentId", workspace.documentId); url.searchParams.set("workspaceId", workspace.instanceId); - url.searchParams.set( - "signature", - await sign(env.SESSION_SECRET, unitsSubject(workspace)) - ); await createWebhook(onshapeApi, { documentId: workspace.documentId, workspaceId: workspace.instanceId, @@ -47,24 +33,13 @@ export async function watchWorkspaceUnits( }); } -/** The workspace a units delivery names, if its signature is ours. */ -export async function readUnitsDelivery( - env: AppBindings, +/** The workspace a units delivery names. */ +export function readUnitsDelivery( query: Record -): Promise { - const { documentId, workspaceId, signature } = query; - if (!env.SESSION_SECRET || !documentId || !workspaceId || !signature) { +): InstancePath | undefined { + const { documentId, workspaceId } = query; + if (!documentId || !workspaceId) { return undefined; } - const workspace: InstancePath = { - documentId, - instanceId: workspaceId, - instanceType: "w" - }; - const valid = await verify( - env.SESSION_SECRET, - unitsSubject(workspace), - signature - ); - return valid ? workspace : undefined; + return { documentId, instanceId: workspaceId, instanceType: "w" }; } diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index a4ce6698f..673a2ab5d 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -24,8 +24,6 @@ export interface AppBindings { VITE_ACCESS_LEVEL_OVERRIDE?: string; /** Testing-only: treat requests as signed in with a fake user. Not for production. */ FORCE_SIGNED_IN?: string; - /** Signs the urls transient webhooks deliver to; unset registers none. */ - SESSION_SECRET?: string; } interface AppVariables { diff --git a/src/backend/lib/signed.test.ts b/src/backend/lib/signed.test.ts deleted file mode 100644 index 8cc829af8..000000000 --- a/src/backend/lib/signed.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { sign, verify } from "./signed"; - -describe("signing", () => { - it("verifies what it signed", async () => { - const signature = await sign("secret", "doc:ws"); - expect(await verify("secret", "doc:ws", signature)).toBe(true); - }); - - it.each([ - ["another value", "secret", "doc:other"], - ["another secret", "guess", "doc:ws"] - ])("rejects %s", async (_, secret, value) => { - const signature = await sign("secret", "doc:ws"); - expect(await verify(secret, value, signature)).toBe(false); - }); - - it("rejects a signature that is not hex", async () => { - expect(await verify("secret", "doc:ws", "not-hex")).toBe(false); - }); -}); diff --git a/src/backend/lib/signed.ts b/src/backend/lib/signed.ts deleted file mode 100644 index 89dcd8dc6..000000000 --- a/src/backend/lib/signed.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** HMAC signatures, for urls we hand out and must recognize without storing them. */ - -const encoder = new TextEncoder(); - -function importKey(secret: string): Promise { - return crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign", "verify"] - ); -} - -function toHex(bytes: ArrayBuffer): string { - return Array.from(new Uint8Array(bytes), (byte) => - byte.toString(16).padStart(2, "0") - ).join(""); -} - -function fromHex(hex: string): ArrayBuffer | undefined { - if (!/^(?:[0-9a-f]{2})+$/.test(hex)) { - return undefined; - } - const pairs = hex.match(/../g) ?? []; - return new Uint8Array(pairs.map((pair) => parseInt(pair, 16))).buffer; -} - -export async function sign(secret: string, value: string): Promise { - const key = await importKey(secret); - return toHex(await crypto.subtle.sign("HMAC", key, encoder.encode(value))); -} - -/** Constant-time, as `verify` is. */ -export async function verify( - secret: string, - value: string, - signature: string -): Promise { - const bytes = fromHex(signature); - if (!bytes) { - return false; - } - const key = await importKey(secret); - return crypto.subtle.verify("HMAC", key, bytes, encoder.encode(value)); -} diff --git a/src/frontend/features/admin-team/components/admin-team-setting.test.tsx b/src/frontend/features/admin-team/components/admin-team-setting.test.tsx new file mode 100644 index 000000000..ec0a75713 --- /dev/null +++ b/src/frontend/features/admin-team/components/admin-team-setting.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AccessLevel } from "@backend/features/auth/access-level"; +import { renderWithProviders } from "../../../../__test_utils__/render"; + +const mocks = vi.hoisted(() => ({ + level: "owner", + mutate: vi.fn() +})); + +vi.mock("../../auth/access-level", () => ({ + useAccessData: () => ({ currentAccessLevel: mocks.level }) +})); +vi.mock("../queries", () => ({ + useAdminTeamQuery: () => ({ + data: { teamId: "team-1", memberCount: 2 }, + isPending: false + }), + useSetAdminTeamMutation: () => ({ mutate: mocks.mutate, isPending: false }) +})); + +const { AdminTeamSetting } = await import("./admin-team-setting"); + +describe("the admin team setting", () => { + afterEach(() => mocks.mutate.mockReset()); + + it("saves what the owner types once they leave the field", async () => { + mocks.level = AccessLevel.OWNER; + const user = userEvent.setup(); + renderWithProviders(); + + const field = screen.getByLabelText("Admin team id"); + await user.clear(field); + await user.type(field, " team-2 {Enter}"); + + expect(mocks.mutate).toHaveBeenCalledWith("team-2"); + }); + + it("clears the team when the owner empties the field", async () => { + mocks.level = AccessLevel.OWNER; + const user = userEvent.setup(); + renderWithProviders(); + + await user.clear(screen.getByLabelText("Admin team id")); + await user.tab(); + + expect(mocks.mutate).toHaveBeenCalledWith(null); + }); + + it("saves nothing when the team did not change", async () => { + mocks.level = AccessLevel.OWNER; + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByLabelText("Admin team id")); + await user.tab(); + + expect(mocks.mutate).not.toHaveBeenCalled(); + }); + + it("shows the team to anyone else without letting them change it", () => { + mocks.level = AccessLevel.ADMIN; + renderWithProviders(); + + const field = screen.getByLabelText("Admin team id"); + expect((field as HTMLInputElement).value).toBe("team-1"); + expect(field).toHaveProperty("readOnly", true); + }); +}); diff --git a/src/frontend/features/admin-team/components/admin-team-setting.tsx b/src/frontend/features/admin-team/components/admin-team-setting.tsx index 8a7cb870c..26532aa97 100644 --- a/src/frontend/features/admin-team/components/admin-team-setting.tsx +++ b/src/frontend/features/admin-team/components/admin-team-setting.tsx @@ -1,50 +1,43 @@ -import { Button, Group, Stack, Text, TextInput } from "@mantine/core"; -import { type ReactNode, useId, useState } from "react"; +import { TextInput } from "@mantine/core"; +import { type FocusEvent, type ReactNode, useId } from "react"; +import { AccessLevel } from "@backend/features/auth/access-level"; +import { InputRow } from "../../../components/input-row"; +import { SETTING_CONTROL_WIDTH } from "../../../lib/style-constants"; +import { useAccessData } from "../../auth/access-level"; import { useAdminTeamQuery, useSetAdminTeamMutation } from "../queries"; -/** The team's members get editor access. */ +/** The Onshape team whose members edit this library; only the owner sets it. */ export function AdminTeamSetting(): ReactNode { + const { currentAccessLevel } = useAccessData(); const query = useAdminTeamQuery(); const mutation = useSetAdminTeamMutation(); - const inputId = useId(); - // Unset until edited, so the stored team shows once it has loaded. - const [draft, setDraft] = useState(); - + const id = useId(); + const isOwner = currentAccessLevel === AccessLevel.OWNER; const stored = query.data?.teamId ?? ""; - const value = draft ?? stored; - const trimmed = value.trim(); - const save = () => { - mutation.mutate(trimmed || null, { - onSuccess: () => setDraft(undefined) - }); + const save = (event: FocusEvent) => { + const teamId = event.currentTarget.value.trim(); + if (teamId !== stored) { + mutation.mutate(teamId || null); + } }; return ( - - - setDraft(event.currentTarget.value)} - disabled={query.isPending} - flex={1} - /> - - - {query.data?.teamId && ( - - {query.data.memberCount} members can edit this library. - - )} - + + { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + ); } diff --git a/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx b/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx index 1ffd1cb11..bbe5af97b 100644 --- a/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx +++ b/src/frontend/features/admin-team/components/refresh-admin-team-button.tsx @@ -11,7 +11,7 @@ export function RefreshAdminTeamButton(): ReactNode { onClick={() => mutation.mutate()} loading={mutation.isPending} > - Refresh members + Refresh ); } diff --git a/src/frontend/features/dashboard/parts-sort.test.ts b/src/frontend/features/dashboard/parts-sort.test.ts index 4f007f18b..8c11b832f 100644 --- a/src/frontend/features/dashboard/parts-sort.test.ts +++ b/src/frontend/features/dashboard/parts-sort.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { PartUsageOut } from "@backend/features/analytics/contract"; +import { LibraryId } from "@backend/features/library/library-id"; import { DEFAULT_SORT, filterAndSort, @@ -9,6 +10,7 @@ import { function part(overrides: Partial = {}): PartUsageOut { return { + libraryId: LibraryId.FRC_DESIGN_LIB, path: { documentId: "doc-1", instanceId: "v-1", diff --git a/src/frontend/features/dashboard/treemap-data.test.ts b/src/frontend/features/dashboard/treemap-data.test.ts index 210f3d148..a9b34a18d 100644 --- a/src/frontend/features/dashboard/treemap-data.test.ts +++ b/src/frontend/features/dashboard/treemap-data.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; import { LibraryId } from "@backend/features/library/library-id"; -import { toNodes, TreemapKind, type UsagePart } from "./treemap-data"; +import type { PartUsageOut } from "@backend/features/analytics/contract"; +import { toNodes, TreemapKind } from "./treemap-data"; function part({ elementId = "e-1", ...overrides -}: Partial & { elementId?: string } = {}): UsagePart { +}: Partial & { elementId?: string } = {}): PartUsageOut { return { libraryId: LibraryId.FRC_DESIGN_LIB, path: { diff --git a/src/frontend/features/dashboard/treemap-data.ts b/src/frontend/features/dashboard/treemap-data.ts index 34d16fe7a..c508a87dd 100644 --- a/src/frontend/features/dashboard/treemap-data.ts +++ b/src/frontend/features/dashboard/treemap-data.ts @@ -5,10 +5,6 @@ import { getLibraryColor } from "../../theme"; import { colorVar, FILLED_SHADE } from "../../lib/style-constants"; /** A part tagged with the library it came from, so one list spans them all. */ -export interface UsagePart extends PartUsageOut { - libraryId: LibraryId; -} - /** Parts are leaves: clicking one leaves the chart. */ export interface TreemapPath { libraryId?: LibraryId; @@ -46,7 +42,7 @@ function shade(color: string, rank: number): string { } /** Drops unused parts: a zero-area tile still catches clicks. */ -function within(parts: UsagePart[], path: TreemapPath): UsagePart[] { +function within(parts: PartUsageOut[], path: TreemapPath): PartUsageOut[] { return parts.filter( (part) => part.insertCount > 0 && @@ -58,8 +54,8 @@ function within(parts: UsagePart[], path: TreemapPath): UsagePart[] { /** Insertions summed by a key, largest first — so an index is a shade rank. */ function totalsBy( - parts: UsagePart[], - keyOf: (part: UsagePart) => K + parts: PartUsageOut[], + keyOf: (part: PartUsageOut) => K ): { key: K; value: number }[] { const totals = new Map(); for (const part of parts) { @@ -72,7 +68,10 @@ function totalsBy( } /** Inside a library, tiles shade off its chart color. */ -export function toNodes(parts: UsagePart[], path: TreemapPath): TreemapNode[] { +export function toNodes( + parts: PartUsageOut[], + path: TreemapPath +): TreemapNode[] { const shown = within(parts, path); if (path.libraryId === undefined) { diff --git a/src/frontend/features/dashboard/usage-treemap.tsx b/src/frontend/features/dashboard/usage-treemap.tsx index 6763047ef..d3ff36d09 100644 --- a/src/frontend/features/dashboard/usage-treemap.tsx +++ b/src/frontend/features/dashboard/usage-treemap.tsx @@ -1,4 +1,5 @@ import { Text } from "@mantine/core"; +import type { PartUsageOut } from "@backend/features/analytics/contract"; import { useNavigate } from "@tanstack/react-router"; import { useMemo, useState, type ReactNode } from "react"; import { getLibraryName } from "../../lib/library"; @@ -9,12 +10,11 @@ import { toNodes, TreemapKind, type TreemapNode, - type TreemapPath, - type UsagePart + type TreemapPath } from "./treemap-data"; interface UsageTreemapProps { - parts: UsagePart[]; + parts: PartUsageOut[]; /** The level this instance starts at and will not go above. */ root?: TreemapPath; } diff --git a/src/frontend/features/library/components/reload-button.tsx b/src/frontend/features/library/components/reload-button.tsx index 44648ef4d..09fe91c50 100644 --- a/src/frontend/features/library/components/reload-button.tsx +++ b/src/frontend/features/library/components/reload-button.tsx @@ -56,7 +56,7 @@ export function ReloadButton(props: ReloadButtonProps): ReactNode { onClick={handleClick} loading={mutation.isPending} > - Reload documents + Reload ); } diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 8ceda3bfd..6d7497c28 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -2,7 +2,10 @@ import { DEFAULT_THEME, Theme } from "@backend/features/settings/settings"; import { Box, Button, Select, Stack } from "@mantine/core"; import { ArrowLeftIcon, SignOutIcon } from "@phosphor-icons/react"; import { useMatch } from "@tanstack/react-router"; -import { StatusColor } from "../../../lib/style-constants"; +import { + SETTING_CONTROL_WIDTH, + StatusColor +} from "../../../lib/style-constants"; import { ReactNode, useId } from "react"; import { AccessLevel, @@ -73,9 +76,6 @@ function SettingSelect(props: SettingSelectProps) { ); } -/** Wide enough for "Open dashboard", so every control ends on one line. */ -const SETTING_CONTROL_WIDTH = 170; - export function SettingsMenuContent(): ReactNode { const { maxAccessLevel } = useAccessData(); @@ -201,18 +201,18 @@ function AdminSettings(): ReactNode { {/* Always show the access level select so admins can change access level if needed */} - + - + - + - + diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index c89781e97..ae6635775 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -78,3 +78,6 @@ export enum PrimaryColor { /** What reads on top of it, typically white. */ CONTRAST = "var(--mantine-primary-color-contrast)" } + +/** Wide enough for "Open dashboard", so every settings control ends on one line. */ +export const SETTING_CONTROL_WIDTH = 170; diff --git a/src/frontend/routes/dashboard/index.tsx b/src/frontend/routes/dashboard/index.tsx index 9cc66a54c..2f6048cc1 100644 --- a/src/frontend/routes/dashboard/index.tsx +++ b/src/frontend/routes/dashboard/index.tsx @@ -3,7 +3,6 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { type ReactNode } from "react"; import { LibraryId } from "@backend/features/library/library-id"; -import type { PartUsageOut } from "@backend/features/analytics/contract"; import { type DayRange } from "@backend/features/analytics/day"; import { getOverviewQuery, @@ -18,7 +17,6 @@ import { LifetimeTiles } from "../../features/dashboard/lifetime-tiles"; import { METRICS } from "../../features/dashboard/metrics"; import { Section } from "../../components/section"; import { UsageTreemap } from "../../features/dashboard/usage-treemap"; -import { type UsagePart } from "../../features/dashboard/treemap-data"; import { TrendTile } from "../../features/dashboard/trend-tile"; export const Route = createFileRoute("/dashboard/")({ @@ -34,15 +32,6 @@ function useAllParts(range: DayRange) { }); } -/** Tags a library's parts with which library they came from. */ -function taggedParts( - query: { data?: PartUsageOut[] }, - index: number -): UsagePart[] { - const libraryId = Object.values(LibraryId)[index]; - return (query.data ?? []).map((part) => ({ ...part, libraryId })); -} - function DashboardOverview(): ReactNode { // No range picker: each section names its own window. const range = toDayRange(RangePreset.ALL); @@ -93,7 +82,9 @@ function DashboardOverview(): ReactNode { {allParts.every((query) => query.data) ? ( - + query.data ?? [])} + /> ) : ( )} From 3f4c666f7ac3dd4d04279464f1e80f02c42636a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:17:23 +0000 Subject: [PATCH 40/88] Save and reset the favorite's configuration from the insert menu Drop the favorite's default-configuration modal. The insert menu footer gets a save button beside the favorite heart, and its menu gains a Configuration section to reset to the defaults or to the favorite's configuration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/frontend/components/alerts.tsx | 7 -- .../favorites/components/favorite-card.tsx | 20 --- .../favorites/components/favorite-menu.tsx | 115 ------------------ ...ave-favorite-configuration-button.test.tsx | 93 ++++++++++++++ .../save-favorite-configuration-button.tsx | 53 ++++++++ .../features/favorites/open-favorite-menu.tsx | 36 ------ src/frontend/features/favorites/queries.ts | 6 +- .../insert/components/configurations.tsx | 3 + .../insert/components/insert-menu.tsx | 16 +++ .../reset-configuration-items.test.tsx | 60 +++++++++ .../components/reset-configuration-items.tsx | 38 ++++++ .../library/components/insertable-card.tsx | 12 +- 12 files changed, 276 insertions(+), 183 deletions(-) delete mode 100644 src/frontend/features/favorites/components/favorite-menu.tsx create mode 100644 src/frontend/features/favorites/components/save-favorite-configuration-button.test.tsx create mode 100644 src/frontend/features/favorites/components/save-favorite-configuration-button.tsx delete mode 100644 src/frontend/features/favorites/open-favorite-menu.tsx create mode 100644 src/frontend/features/insert/components/reset-configuration-items.test.tsx create mode 100644 src/frontend/features/insert/components/reset-configuration-items.tsx diff --git a/src/frontend/components/alerts.tsx b/src/frontend/components/alerts.tsx index 679f06ef9..f4931e375 100644 --- a/src/frontend/components/alerts.tsx +++ b/src/frontend/components/alerts.tsx @@ -51,10 +51,3 @@ export function openCannotReorderAlert(): void { text: "To prevent confusion, favorites cannot be reordered while filters are active." }); } - -export function openCannotEditDefaultConfigurationAlert(): void { - openWarningAlert({ - title: "Cannot edit configuration", - text: "This element is not configurable, so its default configuration cannot be changed." - }); -} diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index f21255276..bdcf4c628 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -2,10 +2,7 @@ import { DEFAULT_CONFIGURATION_KEY } from "@backend/features/configurations/cont import { ReactNode } from "react"; import { Favorite } from "@backend/features/favorites/contract"; import { InsertableOut } from "@backend/features/library/contract"; -import { Menu } from "@mantine/core"; -import { PencilIcon } from "@phosphor-icons/react"; import { openInsertMenu } from "../../insert/open-insert-menu"; -import { openFavoriteMenu } from "../open-favorite-menu"; import { FavoriteButton, FavoriteInsertableItem } from "./favorite-button"; import { CardTitle, @@ -22,7 +19,6 @@ import { MenuSection } from "../../../components/app-menu"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { openCannotDeriveAssemblyAlert, - openCannotEditDefaultConfigurationAlert, openCannotReorderAlert } from "../../../components/alerts"; import { useFavoritesQuery, useSetFavoriteOrderMutation } from "../queries"; @@ -134,22 +130,6 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { )} - } - onClick={() => { - if (!insertable.isConfigurable) { - openCannotEditDefaultConfigurationAlert(); - return; - } - openFavoriteMenu({ - favoriteId: favorite.id, - insertableName: insertable.name, - selection: favorite.defaultSelection - }); - }} - > - Edit default configuration - (initialSelection); - // Saving before the panel settles would wipe the favorite's selection. - const [report, setReport] = useState(); - - const favorite = favoritesData?.favorites[favoriteId]; - const insertable = - favorite && insertables - ? insertables[favorite.insertableId] - : undefined; - - useMenuTitle(modalId, { - name: insertable?.name, - record: report?.record, - icon: - }); - - const setDefaultConfigurationMutation = - useSetDefaultConfigurationMutation(favoriteId); - - if (!insertable) { - return null; - } - if (!insertable.isConfigurable) { - return ; - } - - return ( - <> - - - - - - - - - - - ); -} diff --git a/src/frontend/features/favorites/components/save-favorite-configuration-button.test.tsx b/src/frontend/features/favorites/components/save-favorite-configuration-button.test.tsx new file mode 100644 index 000000000..7c66c66f0 --- /dev/null +++ b/src/frontend/features/favorites/components/save-favorite-configuration-button.test.tsx @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Favorite } from "@backend/features/favorites/contract"; +import { LibraryId } from "@backend/features/library/library-id"; +import { renderWithProviders } from "../../../../__test_utils__/render"; +import type { SelectionReport } from "../../insert/components/configurations"; + +const mutate = vi.hoisted(() => vi.fn()); +vi.mock("../queries", () => ({ + useSetDefaultConfigurationMutation: () => ({ mutate, isPending: false }) +})); + +const { SaveFavoriteConfigurationButton } = + await import("./save-favorite-configuration-button"); + +const FAVORITE: Favorite = { + id: "fav", + insertableId: "ins", + libraryId: LibraryId.FRC_DESIGN_LIB, + defaultSelection: { size: "small", length: "1 in" } +}; + +function report(selection: Record): SelectionReport { + return { + // A derivation variable rides along, but the favorite never keeps one. + selection: { ...selection, dv: "uuid" }, + stored: selection, + overrides: {}, + configurationKey: "key", + record: undefined + }; +} + +describe("saving the favorite's configuration", () => { + afterEach(() => mutate.mockReset()); + + it("saves the configuration on screen", async () => { + const user = userEvent.setup(); + const onScreen = report({ size: "large", length: "1 in" }); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button")); + + expect(mutate).toHaveBeenCalledWith({ + selection: onScreen.selection, + configurationKey: "key" + }); + }); + + it("has nothing to save when the favorite already opens with it", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("button")).toHaveProperty("disabled", true); + }); + + // Saved as entered, so a different spelling of the same size still saves. + it("saves an expression that spells the saved value differently", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("button")).toHaveProperty("disabled", false); + }); + + it("counts a favorite with no selection as saved on the defaults", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("button")).toHaveProperty("disabled", true); + }); +}); diff --git a/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx b/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx new file mode 100644 index 000000000..6ad1805f5 --- /dev/null +++ b/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx @@ -0,0 +1,53 @@ +import { ActionIcon, Tooltip } from "@mantine/core"; +import { FloppyDiskIcon } from "@phosphor-icons/react"; +import { type ReactNode } from "react"; +import type { Favorite } from "@backend/features/favorites/contract"; +import type { ConfigurationKey } from "@backend/features/configurations/contract"; +import { IconSize } from "../../../lib/style-constants"; +import { sameSelection } from "../../insert/parameter-value"; +import type { SelectionReport } from "../../insert/components/configurations"; +import { useSetDefaultConfigurationMutation } from "../queries"; + +interface SaveFavoriteConfigurationButtonProps { + favorite: Favorite; + /** The configuration on screen. */ + report: SelectionReport; + configurationKey: ConfigurationKey; +} + +/** Makes the configuration on screen the one the favorite opens with. */ +export function SaveFavoriteConfigurationButton( + props: SaveFavoriteConfigurationButtonProps +): ReactNode { + const { favorite, report, configurationKey } = props; + const mutation = useSetDefaultConfigurationMutation(favorite.id); + // Compared as entered: saving "(2 + 3) in" over "5 in" changes what is stored. + // A favorite with no selection opens on the defaults. + const isSaved = favorite.defaultSelection + ? sameSelection(report.stored, favorite.defaultSelection) + : Object.keys(report.overrides).length === 0; + + return ( + + + mutation.mutate({ + selection: report.selection, + configurationKey + }) + } + > + + + + ); +} diff --git a/src/frontend/features/favorites/open-favorite-menu.tsx b/src/frontend/features/favorites/open-favorite-menu.tsx deleted file mode 100644 index 3a0257c78..000000000 --- a/src/frontend/features/favorites/open-favorite-menu.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { openAppModal } from "../../components/open-app-modal"; -import { type PartialSelection } from "@backend/features/configurations/contract"; -import { FavoriteMenuContent } from "./components/favorite-menu"; -import { MenuTitle } from "../../components/app-title"; -import { FavoriteIcon } from "./components/favorite-button"; -import { IconSize } from "../../lib/style-constants"; - -interface OpenFavoriteMenuProps { - favoriteId: string; - insertableName: string; - /** What the favorite opens with today. */ - selection?: PartialSelection; -} - -export function openFavoriteMenu(props: OpenFavoriteMenuProps) { - const { favoriteId, insertableName, selection } = props; - // Minted here so the content can update the header as the selection changes. - const modalId = crypto.randomUUID(); - openAppModal({ - modalId, - title: ( - } - /> - ), - size: 500, - children: ( - - ) - }); -} diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts index 92364155b..f02e80f91 100644 --- a/src/frontend/features/favorites/queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -91,12 +91,10 @@ export function useSetDefaultConfigurationMutation(favoriteId: string) { // No router.invalidate(): the loader's prefetch would race this and undo it. }, onError: () => { - showErrorToast( - "Unexpectedly failed to update default configuration." - ); + showErrorToast("Failed to save the favorite's configuration."); }, onSuccess: () => { - showSuccessToast("Successfully updated default configuration."); + showSuccessToast("The favorite now opens with this configuration."); }, onSettled: refreshFavorites }); diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 9f8f7e80d..6d7765da6 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -64,6 +64,8 @@ export interface SelectionReport { selection: Selection; /** What the url keeps. */ overrides: PartialSelection; + /** What a favorite keeps: the whole selection, less its derivation variables. */ + stored: PartialSelection; /** Names the selection's thumbnail. */ configurationKey: ConfigurationKey; /** The part the selection produces, for the menu's header. */ @@ -96,6 +98,7 @@ function useReportSelection( onshapeOverrides(selection, result.parameters), result.parameters ), + stored: toStoredSelection(selection, result.parameters), configurationKey: toKey(selection, result.parameters), record: findRecord(selection, result.records) }); diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 4ea0b95cd..decd03579 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -18,6 +18,7 @@ import { } from "../insert-tips"; import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; import { FavoriteButton } from "../../favorites/components/favorite-button"; +import { SaveFavoriteConfigurationButton } from "../../favorites/components/save-favorite-configuration-button"; import { MenuButton } from "../../../components/app-menu"; import { GetAppCallout } from "../../../components/get-app"; import { InsertableMenuItems } from "../../library/components/insertable-card"; @@ -142,6 +143,8 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { insertable={insertable} favorite={favorite} selection={selection} + report={report} + onResetSelection={setSelection} configurationKey={configurationKey} canShowQuickInsertTip={canShowQuickInsertTip} source={source} @@ -155,6 +158,9 @@ interface InsertMenuFooterProps { insertable: InsertableOut; favorite: Favorite | undefined; selection?: PartialSelection; + /** Undefined until the panel settles, or for a part with nothing to configure. */ + report: SelectionReport | undefined; + onResetSelection: (selection: PartialSelection) => void; configurationKey: ConfigurationKey; /** Whether an insert now is worth pointing out a right-click for. */ canShowQuickInsertTip: boolean; @@ -169,6 +175,8 @@ function InsertMenuFooter(props: InsertMenuFooterProps): ReactNode { insertable, favorite, selection, + report, + onResetSelection, configurationKey, canShowQuickInsertTip, source, @@ -185,12 +193,20 @@ function InsertMenuFooter(props: InsertMenuFooterProps): ReactNode { configurationKey={configurationKey} large /> + {favorite && report && ( + + )} + + + + + + + + ); + return onReset; +} + +const favorite = (defaultSelection?: Record): Favorite => ({ + id: "fav", + insertableId: "ins", + libraryId: LibraryId.FRC_DESIGN_LIB, + defaultSelection +}); + +describe("resetting the configuration", () => { + it("goes back to the element's defaults", async () => { + const user = userEvent.setup(); + const onReset = renderItems(); + + await user.click(screen.getByText("Reset to defaults")); + + expect(onReset).toHaveBeenCalledWith({}); + }); + + it("goes back to what the favorite opens with", async () => { + const user = userEvent.setup(); + const onReset = renderItems(favorite({ size: "large" })); + + await user.click(screen.getByText("Reset to favorite configuration")); + + expect(onReset).toHaveBeenCalledWith({ size: "large" }); + }); + + it("offers no favorite reset where the favorite is the defaults", () => { + renderItems(favorite()); + expect( + screen.queryByText("Reset to favorite configuration") + ).toBeNull(); + }); +}); diff --git a/src/frontend/features/insert/components/reset-configuration-items.tsx b/src/frontend/features/insert/components/reset-configuration-items.tsx new file mode 100644 index 000000000..2ae101f0a --- /dev/null +++ b/src/frontend/features/insert/components/reset-configuration-items.tsx @@ -0,0 +1,38 @@ +import { Menu } from "@mantine/core"; +import { ArrowCounterClockwiseIcon } from "@phosphor-icons/react"; +import { type ReactNode } from "react"; +import type { PartialSelection } from "@backend/features/configurations/contract"; +import type { Favorite } from "@backend/features/favorites/contract"; +import { MenuSection } from "../../../components/app-menu"; +import { FavoriteIcon } from "../../favorites/components/favorite-button"; + +interface ResetConfigurationItemsProps { + favorite: Favorite | undefined; + onReset: (selection: PartialSelection) => void; +} + +export function ResetConfigurationItems( + props: ResetConfigurationItemsProps +): ReactNode { + const { favorite, onReset } = props; + const favoriteSelection = favorite?.defaultSelection; + return ( + + } + onClick={() => onReset({})} + > + Reset to defaults + + {/* A favorite with no selection opens on the defaults. */} + {favoriteSelection && ( + } + onClick={() => onReset(favoriteSelection)} + > + Reset to favorite configuration + + )} + + ); +} diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index 1e4bf4aa1..128d527c6 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -1,4 +1,5 @@ import { PropsWithChildren, ReactNode } from "react"; +import { ResetConfigurationItems } from "../../insert/components/reset-configuration-items"; import { Favorite } from "@backend/features/favorites/contract"; import { InsertableOut } from "@backend/features/library/contract"; import { @@ -143,6 +144,8 @@ interface InsertableMenuItemsProps { /** That selection's key, so favoriting can name its thumbnail. */ configurationKey?: ConfigurationKey; source: InsertSource; + /** Inside the insert menu: puts the panel back on another configuration. */ + onResetSelection?: (selection: PartialSelection) => void; } export function InsertableMenuItems( @@ -154,7 +157,8 @@ export function InsertableMenuItems( inInsertMenu, selection, configurationKey, - source + source, + onResetSelection } = props; const isConnected = useIsConnectedToOnshape(); @@ -170,6 +174,12 @@ export function InsertableMenuItems( /> )} + {onResetSelection && insertable.isConfigurable && ( + + )} Date: Thu, 24 Sep 2026 22:27:33 +0000 Subject: [PATCH 41/88] Simplify the insert menu's configuration plumbing - evaluateExpression returns what a quantity box shows, so seedFrom goes. - Selection normalizing and option resolution move to the backend's configurations module, which instances.ts now shares. - openAppModal keeps its own id; content reaches it through useAppModal. - The thumbnail wait tip restarts with each configuration. - useInsertMutation builds one request per target type. - Favorite default configuration wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- .../configurations/input-parser.test.ts | 23 ++- .../features/configurations/input-parser.ts | 142 +++++++----------- .../features/configurations/instances.ts | 19 ++- .../features/configurations/selection.test.ts | 120 +++++++++++++++ .../features/configurations/selection.ts | 61 +++++++- src/backend/features/configurations/utils.ts | 18 +++ src/frontend/components/app-title.tsx | 15 +- src/frontend/components/open-app-modal.tsx | 37 ++++- .../save-favorite-configuration-button.tsx | 6 +- src/frontend/features/favorites/queries.ts | 4 +- .../insert/components/configurations.tsx | 31 ++-- .../insert/components/insert-menu.tsx | 16 +- .../reset-configuration-items.test.tsx | 8 +- .../components/reset-configuration-items.tsx | 4 +- src/frontend/features/insert/insert-tips.ts | 7 +- .../features/insert/open-insert-menu.tsx | 9 +- .../features/insert/parameter-value.test.ts | 90 +---------- .../features/insert/parameter-value.ts | 83 ---------- .../features/insert/quantity-box.test.ts | 60 -------- src/frontend/features/insert/quantity-box.ts | 44 ------ src/frontend/features/insert/queries.ts | 71 ++++----- 21 files changed, 384 insertions(+), 484 deletions(-) delete mode 100644 src/frontend/features/insert/quantity-box.test.ts delete mode 100644 src/frontend/features/insert/quantity-box.ts diff --git a/src/backend/features/configurations/input-parser.test.ts b/src/backend/features/configurations/input-parser.test.ts index 2092b1b4e..345d1116e 100644 --- a/src/backend/features/configurations/input-parser.test.ts +++ b/src/backend/features/configurations/input-parser.test.ts @@ -3,7 +3,6 @@ import { QuantityType, Unit } from "./enums"; import { evaluateExpression, EvaluateOptions, - Result, valueWithUnits } from "./input-parser"; @@ -36,11 +35,12 @@ describe("evaluateExpression", () => { ["90 deg", DEGREES, "90 deg"], // The display unit fills in for an expression that names none. ["5", LENGTH, "5 mm"], + ["1 in", LENGTH, "25.4 mm"], [" 7 mm + 3 mm ", LENGTH, "10 mm"] ])("evaluates %s", (expression, options, display) => { const result = evaluateExpression(expression, options); - expect(result.hasError).toBe(false); - expect((result as Result).displayExpression).toBe(display); + expect(result.errorMessage).toBeUndefined(); + expect(result.display).toBe(display); }); it("evaluates an angle in radians", () => { @@ -48,8 +48,8 @@ describe("evaluateExpression", () => { "3.14159265359 rad", defaultOptions(QuantityType.ANGLE, Unit.RADIAN) ); - expect(result.hasError).toBe(false); - expect((result as Result).displayExpression).toContain("rad"); + expect(result.errorMessage).toBeUndefined(); + expect(result.display).toContain("rad"); }); it.each([ @@ -66,11 +66,13 @@ describe("evaluateExpression", () => { ["-100.001 mm", "it falls below the minimum"], ["100.001 mm", "it rises above the maximum"] ])("rejects %s, since %s", (expression) => { - expect(evaluateExpression(expression, LENGTH).hasError).toBe(true); + expect( + evaluateExpression(expression, LENGTH).errorMessage + ).toBeDefined(); }); it("rejects a length where an angle is wanted", () => { - expect(evaluateExpression("2 mm", DEGREES).hasError).toBe(true); + expect(evaluateExpression("2 mm", DEGREES).errorMessage).toBeDefined(); }); // The stored expression has to re-parse. @@ -82,11 +84,8 @@ describe("evaluateExpression", () => { ["-(2 + 3) mm"] ])("re-parses its own output for %s", (input) => { const first = evaluateExpression(input, LENGTH); - expect(first.hasError).toBe(false); + expect(first.errorMessage).toBeUndefined(); const second = evaluateExpression(first.expression, LENGTH); - expect(second.hasError).toBe(false); - expect((second as Result).displayExpression).toBe( - (first as Result).displayExpression - ); + expect(second).toEqual(first); }); }); diff --git a/src/backend/features/configurations/input-parser.ts b/src/backend/features/configurations/input-parser.ts index 71cdd6971..ee888355e 100644 --- a/src/backend/features/configurations/input-parser.ts +++ b/src/backend/features/configurations/input-parser.ts @@ -564,82 +564,38 @@ function expectedType(quantityType: QuantityType): UnitType { } } -function formatExpression( - expr: Expr, +/** Why a value the parser accepted can't go in this box, if it can't. */ +function checkValue( value: ValueWithUnits, options: EvaluateOptions -): Result | ErrorResult { - const { quantityType, displayUnit, displayPrecision } = options; - - let expression = stringify(expr); - if ( - (quantityType === QuantityType.LENGTH || - quantityType === QuantityType.ANGLE) && - value.type === "number" - ) { - value = valueWithUnits(value.value, displayUnit); - - if (expr.kind === "binary") { - expression = `(${expression})`; - } - expression = expression + " " + getUnitDisplayStr(displayUnit); - } - - // "2 deg" parses in a length box; the bounds check below would throw on it. - const expected = expectedType(quantityType); +): string | undefined { + const expected = expectedType(options.quantityType); + // "2 deg" parses in a length box, and the bounds check would throw on it. if (value.type !== expected) { - return { - hasError: true, - expression, - errorMessage: `Expected ${expected === "number" ? "a number" : `a ${expected}`}` - }; + return `Expected ${expected === "number" ? "a number" : `a ${expected}`}`; } - + const format = (bound: ValueWithUnits) => + formatValueWithUnits( + bound, + options.displayUnit, + options.displayPrecision + ); if (tolerantLessThan(value, options.min)) { - return { - hasError: true, - expression, - errorMessage: `Value must be greater than or equal to ${formatValueWithUnits( - options.min, - options.displayUnit, - options.displayPrecision - )}` - }; - } else if (tolerantGreaterThan(value, options.max)) { - return { - hasError: true, - expression, - errorMessage: `Value must be less than or equal to ${formatValueWithUnits( - options.max, - options.displayUnit, - options.displayPrecision - )}` - }; + return `Value must be greater than or equal to ${format(options.min)}`; } - - return { - hasError: false, - displayExpression: formatValueWithUnits( - value, - displayUnit, - displayPrecision - ), - expression - }; + if (tolerantGreaterThan(value, options.max)) { + return `Value must be less than or equal to ${format(options.max)}`; + } + return undefined; } -export interface Result { - hasError: false; - /** Rounded to display precision, in the display unit: `12.00 in`. */ - displayExpression: string; +/** What a quantity box shows for an input. */ +export interface EvaluatedExpression { /** The input with clean spacing, and the display unit if it had none: "(3.5 + 8.5) in". */ expression: string; -} - -interface ErrorResult { - hasError: true; - expression: string; - errorMessage: string; + /** Rounded to display precision, in the display unit: `12.00 in`; the expression when it has an error. */ + display: string; + errorMessage?: string; } export interface EvaluateOptions { @@ -677,35 +633,49 @@ export function evaluateBaseValue( export function evaluateExpression( input: string, options: EvaluateOptions -): Result | ErrorResult { - const quantityType = options.quantityType; +): EvaluatedExpression { + const { quantityType, displayUnit, displayPrecision } = options; + const failed = (expression: string, errorMessage: string) => ({ + expression, + display: expression, + errorMessage + }); if (input.trim().length === 0) { - return { - hasError: true, - expression: input, - errorMessage: "Enter an expression" - }; + return failed(input, "Enter an expression"); } - let expr; - let value; + let expr: Expr; + let value: ValueWithUnits; try { expr = parseExpression(input); value = evaluateExpressionValue(expr, quantityType); } catch (error) { - let errorMessage; - if (error instanceof ParseError) { - errorMessage = error.message; - } else { - errorMessage = "Invalid expression"; + return failed( + input, + error instanceof ParseError ? error.message : "Invalid expression" + ); + } + + let expression = stringify(expr); + if ( + (quantityType === QuantityType.LENGTH || + quantityType === QuantityType.ANGLE) && + value.type === "number" + ) { + value = valueWithUnits(value.value, displayUnit); + if (expr.kind === "binary") { + expression = `(${expression})`; } - return { - hasError: true, - expression: input, - errorMessage - }; + expression += " " + getUnitDisplayStr(displayUnit); } - return formatExpression(expr, value, options); + const errorMessage = checkValue(value, options); + if (errorMessage) { + return failed(expression, errorMessage); + } + return { + expression, + display: formatValueWithUnits(value, displayUnit, displayPrecision) + }; } diff --git a/src/backend/features/configurations/instances.ts b/src/backend/features/configurations/instances.ts index df8eb4a5a..9c43b5689 100644 --- a/src/backend/features/configurations/instances.ts +++ b/src/backend/features/configurations/instances.ts @@ -12,7 +12,12 @@ import { } from "./contract"; import { parameterValues } from "./combinations"; import { formatValue } from "./selection"; -import { evaluateCondition, getOption, getVisibleOptions } from "./utils"; +import { + evaluateCondition, + getOption, + getVisibleOptions, + resolveSelectedOption +} from "./utils"; // Past either cap a parameter is reported whole: too many instances is // unreadable. @@ -267,11 +272,13 @@ function toImplicitDefault( parameter: ConfigurationParameter, options: EnumOption[] ): string | undefined { - if (parameter.type !== ParameterType.ENUM || options.length === 0) { - return undefined; - } - if (options.some((option) => option.id === parameter.default)) { + if (parameter.type !== ParameterType.ENUM) { return undefined; } - return options[0].id; + const settled = resolveSelectedOption( + options, + undefined, + parameter.default + ); + return settled?.id === parameter.default ? undefined : settled?.id; } diff --git a/src/backend/features/configurations/selection.test.ts b/src/backend/features/configurations/selection.test.ts index ddb847d2f..bcf00d139 100644 --- a/src/backend/features/configurations/selection.test.ts +++ b/src/backend/features/configurations/selection.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { type ConfigurationParameter, DEFAULT_CONFIGURATION_KEY, + OptionVisibilityType, + ParameterType, VisibilityType } from "./contract"; import { @@ -9,6 +11,7 @@ import { canonicalValues, findRecord, formatValue, + normalizeSelection, onshapeOverrides, toKey, toSelection, @@ -255,3 +258,120 @@ describe("derivation variables", () => { }); }); }); + +const NORMALIZE_SIZE: ConfigurationParameter = { + id: "size", + name: "Size", + default: "small", + type: ParameterType.ENUM, + options: [ + { id: "small", name: "Small" }, + { id: "large", name: "Large" } + ], + optionConditions: [] +}; + +/** Only shown for the large size, so it is hidden by default. */ +const NORMALIZE_REINFORCED: ConfigurationParameter = { + id: "reinforced", + name: "Reinforced", + default: "false", + type: ParameterType.BOOLEAN, + condition: { type: VisibilityType.EQUAL, id: "size", value: "large" } +}; + +const NORMALIZE_PARAMS = [NORMALIZE_SIZE, NORMALIZE_REINFORCED]; + +describe("normalizeSelection", () => { + it("settles a hidden parameter on its default", () => { + const selection = toSelection( + { size: "small", reinforced: "true" }, + NORMALIZE_PARAMS + ); + expect(normalizeSelection(selection, NORMALIZE_PARAMS).reinforced).toBe( + "false" + ); + }); + + it("leaves a shown parameter's value alone", () => { + const selection = toSelection( + { size: "large", reinforced: "true" }, + NORMALIZE_PARAMS + ); + expect(normalizeSelection(selection, NORMALIZE_PARAMS).reinforced).toBe( + "true" + ); + }); + + it("is idempotent, which is what lets the panel stop", () => { + const once = normalizeSelection( + toSelection({}, NORMALIZE_PARAMS), + NORMALIZE_PARAMS + ); + // Identity: the second pass finds nothing to change. + expect(normalizeSelection(once, NORMALIZE_PARAMS)).toBe(once); + }); + + it("falls back to a visible option when the selected one is hidden", () => { + const material: ConfigurationParameter = { + id: "material", + name: "Material", + default: "alu", + type: ParameterType.ENUM, + options: [ + { id: "alu", name: "Aluminium" }, + { id: "steel", name: "Steel" } + ], + // Steel is only offered on the large size. + optionConditions: [ + { + type: OptionVisibilityType.LIST, + controlledOptions: ["steel"], + condition: { + type: VisibilityType.EQUAL, + id: "size", + value: "large" + } + }, + { + type: OptionVisibilityType.LIST, + controlledOptions: ["alu"], + condition: { type: VisibilityType.ALWAYS_SHOWN } + } + ] + }; + const params = [NORMALIZE_SIZE, material]; + const selection = toSelection( + { size: "small", material: "steel" }, + params + ); + expect(normalizeSelection(selection, params).material).toBe("alu"); + }); + + it("settles a chain where one parameter decides the next", () => { + const bolts: ConfigurationParameter = { + id: "bolts", + name: "Bolts", + default: "2", + type: ParameterType.ENUM, + options: [ + { id: "2", name: "Two" }, + { id: "4", name: "Four" } + ], + optionConditions: [], + condition: { + type: VisibilityType.EQUAL, + id: "reinforced", + value: "true" + } + }; + const params = [NORMALIZE_SIZE, NORMALIZE_REINFORCED, bolts]; + const selection = toSelection( + { size: "small", reinforced: "true", bolts: "4" }, + params + ); + const settled = normalizeSelection(selection, params); + expect(settled.reinforced).toBe("false"); + expect(settled.bolts).toBe("2"); + }); +}); diff --git a/src/backend/features/configurations/selection.ts b/src/backend/features/configurations/selection.ts index d1b10015f..8923f7cb0 100644 --- a/src/backend/features/configurations/selection.ts +++ b/src/backend/features/configurations/selection.ts @@ -16,7 +16,9 @@ import { isDerivationVariable } from "./roles"; import { DEFAULT_QUANTITY_PRECISION, encodeConfiguration, - evaluateCondition + evaluateCondition, + getVisibleOptions, + resolveSelectedOption } from "./utils"; import { evaluateBaseValue, @@ -254,3 +256,60 @@ export function formatValue( DEFAULT_QUANTITY_PRECISION ); } + +/** One pass: hidden parameters take their default, enums the option they land on. */ +function normalizeOnce( + selection: Selection, + parameters: ConfigurationParameter[] +): Selection { + const next = { ...selection }; + for (const parameter of parameters) { + if (!evaluateCondition(parameter.condition, next, parameters)) { + next[parameter.id] = parameter.default; + continue; + } + if (parameter.type !== ParameterType.ENUM) { + continue; + } + const visible = getVisibleOptions(parameter, next, parameters); + next[parameter.id] = + resolveSelectedOption( + visible, + next[parameter.id], + parameter.default + )?.id ?? parameter.default; + } + return next; +} + +export function sameSelection( + a: PartialSelection | undefined, + b: Selection +): boolean { + if (!a) return false; + const keys = Object.keys(b); + return ( + keys.length === Object.keys(a).length && + keys.every((key) => a[key] === b[key]) + ); +} + +/** + * Repeated because settling one parameter can change another's options. The + * pass cap stops parameters whose conditions name each other, so the result + * isn't always a fixed point. + */ +export function normalizeSelection( + selection: Selection, + parameters: ConfigurationParameter[] +): Selection { + let current = selection; + for (let pass = 0; pass <= parameters.length; pass++) { + const next = normalizeOnce(current, parameters); + if (sameSelection(current, next)) { + return current; + } + current = next; + } + return current; +} diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index 4b343824c..d94b4a48a 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -211,6 +211,24 @@ export function getVisibleOptions( ); } +/** The selected option if still visible, else the default, else the first. */ +export function resolveSelectedOption( + visibleOptions: EnumOption[], + currentOptionId: string | undefined, + defaultOptionId: string +): EnumOption | undefined { + if (visibleOptions.length === 0) { + return undefined; + } + return ( + (currentOptionId + ? getOption(visibleOptions, currentOptionId) + : undefined) ?? + getOption(visibleOptions, defaultOptionId) ?? + visibleOptions[0] + ); +} + /** Display precision used when the document's units aren't available. */ export const DEFAULT_QUANTITY_PRECISION = 3; diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 11f6b85d2..76a7ff6ae 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -9,7 +9,7 @@ import { } from "@mantine/core"; import { CheckIcon, CopyIcon } from "@phosphor-icons/react"; import { type ReactNode, useEffect } from "react"; -import { modals } from "@mantine/modals"; +import { useAppModal } from "./open-app-modal"; import type { SearchRecord } from "@backend/features/configurations/contract"; import { FontWeight, IconSize, StatusColor } from "../lib/style-constants"; import { meaningfulPartNumber } from "@backend/features/configurations/part-number"; @@ -94,17 +94,14 @@ interface UseMenuTitleProps extends Omit { } /** Updates the modal's header, which belongs to the modal rather than the content. */ -export function useMenuTitle(modalId: string, props: UseMenuTitleProps): void { +export function useMenuTitle(props: UseMenuTitleProps): void { const { name, record, icon } = props; + const { setTitle } = useAppModal(); useEffect(() => { - if (name === undefined) { - return; + if (name !== undefined) { + setTitle(); } - modals.updateModal({ - modalId, - title: - }); - }, [modalId, name, record, icon]); + }, [setTitle, name, record, icon]); } /** The xs line box the subtitle row is otherwise sized by, floored. */ diff --git a/src/frontend/components/open-app-modal.tsx b/src/frontend/components/open-app-modal.tsx index 93fdf47aa..1393317c5 100644 --- a/src/frontend/components/open-app-modal.tsx +++ b/src/frontend/components/open-app-modal.tsx @@ -1,26 +1,53 @@ import { modals } from "@mantine/modals"; -import type { ReactNode } from "react"; +import { randomId } from "@mantine/hooks"; +import { createContext, type ReactNode, use, useMemo } from "react"; import { APP_MODAL_CLASSES, AppModalContent } from "./app-modal"; interface OpenAppModalProps { title: ReactNode; children: ReactNode; - /** Pass one minted by the caller to update the modal while it is open. */ - modalId?: string; size?: string | number; onClose?: () => void; } +const ModalIdContext = createContext(undefined); + /** The body is unpadded: put content in `AppModalBody` and actions in `AppModalFooter`. */ export function openAppModal(props: OpenAppModalProps): void { - const { title, children, modalId, size, onClose } = props; + const { title, children, size, onClose } = props; + const modalId = randomId(); modals.open({ modalId, title, size, - children: {children}, + children: ( + + {children} + + ), onClose, centered: true, classNames: APP_MODAL_CLASSES }); } + +interface AppModalControls { + setTitle: (title: ReactNode) => void; + close: () => void; +} + +/** For content opened by `openAppModal`, to change the modal around it. */ +export function useAppModal(): AppModalControls { + const modalId = use(ModalIdContext); + return useMemo( + () => ({ + setTitle: (title) => { + if (modalId) modals.updateModal({ modalId, title }); + }, + close: () => { + if (modalId) modals.close(modalId); + } + }), + [modalId] + ); +} diff --git a/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx b/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx index 6ad1805f5..94b49fe4c 100644 --- a/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx +++ b/src/frontend/features/favorites/components/save-favorite-configuration-button.tsx @@ -4,7 +4,7 @@ import { type ReactNode } from "react"; import type { Favorite } from "@backend/features/favorites/contract"; import type { ConfigurationKey } from "@backend/features/configurations/contract"; import { IconSize } from "../../../lib/style-constants"; -import { sameSelection } from "../../insert/parameter-value"; +import { sameSelection } from "@backend/features/configurations/selection"; import type { SelectionReport } from "../../insert/components/configurations"; import { useSetDefaultConfigurationMutation } from "../queries"; @@ -31,8 +31,8 @@ export function SaveFavoriteConfigurationButton( { - showErrorToast("Failed to save the favorite's configuration."); + showErrorToast("Failed to save favorite default configuration."); }, onSuccess: () => { - showSuccessToast("The favorite now opens with this configuration."); + showSuccessToast("Favorite default configuration set."); }, onSettled: refreshFavorites }); diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 6d7765da6..0090d5211 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -35,11 +35,14 @@ import { import { evaluateCondition, getEvaluateOptions, - getVisibleOptions + getVisibleOptions, + resolveSelectedOption } from "@backend/features/configurations/utils"; import { findRecord, + normalizeSelection, onshapeOverrides, + sameSelection, toKey, toSelection, toStoredSelection, @@ -50,13 +53,7 @@ import { evaluateExpression } from "@backend/features/configurations/input-parse import { useConfigurationQuery, useUnitInfo } from "../queries"; import { SectionError } from "../../../components/app-notice"; import classes from "./configurations.module.css"; -import { - normalizeSelection, - resolveSelectedOption, - sameSelection, - withParameterValue -} from "../parameter-value"; -import { seedFrom } from "../quantity-box"; +import { withParameterValue } from "../parameter-value"; /** Reported by the panel, since only it has the parameters. */ export interface SelectionReport { @@ -404,32 +401,24 @@ function QuantityInput(props: ParameterProps): ReactNode { const selectOnMouseUp = useRef(false); const [box, setBox] = useState(() => - seedFrom(value, parameter, evaluateOptions) + evaluateExpression(value ?? parameter.default, evaluateOptions) ); // A value this box didn't submit came from elsewhere, such as a favorite. const [emitted, setEmitted] = useState(value); if (value !== emitted) { setEmitted(value); - setBox(seedFrom(value, parameter, evaluateOptions)); + setBox(evaluateExpression(value ?? parameter.default, evaluateOptions)); } const handleSubmit = () => { setFocused(false); const result = evaluateExpression(box.expression, evaluateOptions); - if (result.hasError) { - // Don't change the value so the thumbnail is still okay - setBox({ - expression: result.expression, - display: result.expression, - errorMessage: result.errorMessage - }); + setBox(result); + // An error keeps the last good value, so the thumbnail still matches it. + if (result.errorMessage) { return; } - setBox({ - expression: result.expression, - display: result.displayExpression - }); // The expression, so the derived feature shows what was typed. setEmitted(result.expression); onValueChange(result.expression); diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index decd03579..a3c1bf453 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -10,6 +10,7 @@ import { AppModalTop } from "../../../components/app-modal"; import { useMenuTitle } from "../../../components/app-title"; +import { useAppModal } from "../../../components/open-app-modal"; import { QUICK_INSERT_WINDOW_MS, showQuickInsertTip, @@ -44,21 +45,20 @@ import { InsertLocationStatus } from "../../insert-location/components/insert-lo interface InsertMenuContentProps { insertable: InsertableOut; - /** The modal this renders in, so the header can track the selection. */ - modalId: string; initialSelection?: PartialSelection; /** So the preview has it before the parameters load. */ initialConfigurationKey?: ConfigurationKey; /** Every selection the menu settles on, the last being what it closed on. */ onSelectionChange?: (selection: Selection) => void; + /** Before the menu closes itself. */ onInsert: () => void; source: InsertSource; } export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { - const { insertable, modalId, onSelectionChange, onInsert, source } = props; + const { insertable, onSelectionChange, onInsert, source } = props; const favorite = useFavorite(insertable.id); - useThumbnailWaitTip(); + const modal = useAppModal(); const [selection, setSelection] = useState< PartialSelection | Selection | undefined @@ -69,6 +69,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { report?.configurationKey ?? props.initialConfigurationKey ?? DEFAULT_CONFIGURATION_KEY; + useThumbnailWaitTip(configurationKey); // What the preview stops following for a signed-out caller. const [isEdited, setIsEdited] = useState(false); // Cleared by an edit, or once the menu has been up long enough. @@ -80,7 +81,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { !insertable.isConfigurable ).data?.records[0]; - useMenuTitle(modalId, { + useMenuTitle({ name: insertable.name, record: report?.record ?? soleRecord }); @@ -148,7 +149,10 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { configurationKey={configurationKey} canShowQuickInsertTip={canShowQuickInsertTip} source={source} - onInsert={onInsert} + onInsert={() => { + onInsert(); + modal.close(); + }} /> ); diff --git a/src/frontend/features/insert/components/reset-configuration-items.test.tsx b/src/frontend/features/insert/components/reset-configuration-items.test.tsx index 6adade077..8d720e3dc 100644 --- a/src/frontend/features/insert/components/reset-configuration-items.test.tsx +++ b/src/frontend/features/insert/components/reset-configuration-items.test.tsx @@ -37,7 +37,7 @@ describe("resetting the configuration", () => { const user = userEvent.setup(); const onReset = renderItems(); - await user.click(screen.getByText("Reset to defaults")); + await user.click(screen.getByText("Reset to default configuration")); expect(onReset).toHaveBeenCalledWith({}); }); @@ -46,7 +46,9 @@ describe("resetting the configuration", () => { const user = userEvent.setup(); const onReset = renderItems(favorite({ size: "large" })); - await user.click(screen.getByText("Reset to favorite configuration")); + await user.click( + screen.getByText("Reset to favorite default configuration") + ); expect(onReset).toHaveBeenCalledWith({ size: "large" }); }); @@ -54,7 +56,7 @@ describe("resetting the configuration", () => { it("offers no favorite reset where the favorite is the defaults", () => { renderItems(favorite()); expect( - screen.queryByText("Reset to favorite configuration") + screen.queryByText("Reset to favorite default configuration") ).toBeNull(); }); }); diff --git a/src/frontend/features/insert/components/reset-configuration-items.tsx b/src/frontend/features/insert/components/reset-configuration-items.tsx index 2ae101f0a..519ef61c7 100644 --- a/src/frontend/features/insert/components/reset-configuration-items.tsx +++ b/src/frontend/features/insert/components/reset-configuration-items.tsx @@ -22,7 +22,7 @@ export function ResetConfigurationItems( leftSection={} onClick={() => onReset({})} > - Reset to defaults + Reset to default configuration {/* A favorite with no selection opens on the defaults. */} {favoriteSelection && ( @@ -30,7 +30,7 @@ export function ResetConfigurationItems( leftSection={} onClick={() => onReset(favoriteSelection)} > - Reset to favorite configuration + Reset to favorite default configuration )} diff --git a/src/frontend/features/insert/insert-tips.ts b/src/frontend/features/insert/insert-tips.ts index 070310a06..e89d95adb 100644 --- a/src/frontend/features/insert/insert-tips.ts +++ b/src/frontend/features/insert/insert-tips.ts @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { type ConfigurationKey } from "@backend/features/configurations/contract"; import { renderNotification, showInfoToast } from "../../lib/notifications"; import { useIsThumbnailRendering } from "../thumbnails/queries"; import { useIsConnectedToOnshape } from "../../lib/onshape-params"; @@ -24,9 +25,9 @@ export function showQuickInsertTip(): void { /** * Tells someone waiting that inserting doesn't need the render. On a timer, - * since by the time they insert the wait is spent; it restarts with each render. + * since by the time they insert the wait is spent; it restarts with each configuration. */ -export function useThumbnailWaitTip(): void { +export function useThumbnailWaitTip(configurationKey: ConfigurationKey): void { const isRendering = useIsThumbnailRendering(); // Standalone has no insert button for the tip to point at. const isConnected = useIsConnectedToOnshape(); @@ -42,7 +43,7 @@ export function useThumbnailWaitTip(): void { ); }, THUMBNAIL_WAIT_MS); return () => clearTimeout(timer); - }, [isRendering, isConnected]); + }, [isRendering, isConnected, configurationKey]); } /** diff --git a/src/frontend/features/insert/open-insert-menu.tsx b/src/frontend/features/insert/open-insert-menu.tsx index 444166e22..27fc2bee4 100644 --- a/src/frontend/features/insert/open-insert-menu.tsx +++ b/src/frontend/features/insert/open-insert-menu.tsx @@ -1,4 +1,3 @@ -import { modals } from "@mantine/modals"; import { openAppModal } from "../../components/open-app-modal"; import type { InsertableOut } from "@backend/features/library/contract"; @@ -44,8 +43,9 @@ export function openInsertMenu(props: OpenInsertMenuProps) { favoriteId, source } = props; + // Plain variables, not state: they belong to this one opening, which is outside React. let didInsert = false; - // What the menu shows when it closes, for the restore toast to reopen. + // For the restore toast to reopen. let lastSelection = initialSelection; // Recorded so the url mirrors it and a relaunch reopens it. updateUiState({ @@ -55,10 +55,7 @@ export function openInsertMenu(props: OpenInsertMenuProps) { : undefined, openFavoriteId: favoriteId }); - // So the content can address its modal, and the header follow the selection. - const id = crypto.randomUUID(); openAppModal({ - modalId: id, title: , size: 500, onClose: () => { @@ -70,7 +67,6 @@ export function openInsertMenu(props: OpenInsertMenuProps) { children: ( { @@ -79,7 +75,6 @@ export function openInsertMenu(props: OpenInsertMenuProps) { source={source} onInsert={() => { didInsert = true; - modals.close(id); }} /> ) diff --git a/src/frontend/features/insert/parameter-value.test.ts b/src/frontend/features/insert/parameter-value.test.ts index a4dc5ec0e..13c17b246 100644 --- a/src/frontend/features/insert/parameter-value.test.ts +++ b/src/frontend/features/insert/parameter-value.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - OptionVisibilityType, ParameterType, VisibilityType, type ConfigurationParameter, @@ -8,7 +7,7 @@ import { } from "@backend/features/configurations/contract"; import { toSelection } from "@backend/features/configurations/selection"; import { evaluateCondition } from "@backend/features/configurations/utils"; -import { normalizeSelection, withParameterValue } from "./parameter-value"; +import { withParameterValue } from "./parameter-value"; const SIZE: ConfigurationParameter = { id: "size", @@ -85,90 +84,3 @@ describe("the panel's hidden-parameter cycle", () => { expect(passesToSettle()).toBe(1); }); }); - -describe("normalizeSelection", () => { - it("settles a hidden parameter on its default", () => { - const selection = toSelection( - { size: "small", reinforced: "true" }, - PARAMS - ); - expect(normalizeSelection(selection, PARAMS).reinforced).toBe("false"); - }); - - it("leaves a shown parameter's value alone", () => { - const selection = toSelection( - { size: "large", reinforced: "true" }, - PARAMS - ); - expect(normalizeSelection(selection, PARAMS).reinforced).toBe("true"); - }); - - it("is idempotent, which is what lets the panel stop", () => { - const once = normalizeSelection(toSelection({}, PARAMS), PARAMS); - // Identity: the second pass finds nothing to change. - expect(normalizeSelection(once, PARAMS)).toBe(once); - }); - - it("falls back to a visible option when the selected one is hidden", () => { - const material: ConfigurationParameter = { - id: "material", - name: "Material", - default: "alu", - type: ParameterType.ENUM, - options: [ - { id: "alu", name: "Aluminium" }, - { id: "steel", name: "Steel" } - ], - // Steel is only offered on the large size. - optionConditions: [ - { - type: OptionVisibilityType.LIST, - controlledOptions: ["steel"], - condition: { - type: VisibilityType.EQUAL, - id: "size", - value: "large" - } - }, - { - type: OptionVisibilityType.LIST, - controlledOptions: ["alu"], - condition: { type: VisibilityType.ALWAYS_SHOWN } - } - ] - }; - const params = [SIZE, material]; - const selection = toSelection( - { size: "small", material: "steel" }, - params - ); - expect(normalizeSelection(selection, params).material).toBe("alu"); - }); - - it("settles a chain where one parameter decides the next", () => { - const bolts: ConfigurationParameter = { - id: "bolts", - name: "Bolts", - default: "2", - type: ParameterType.ENUM, - options: [ - { id: "2", name: "Two" }, - { id: "4", name: "Four" } - ], - optionConditions: [], - condition: { - type: VisibilityType.EQUAL, - id: "reinforced", - value: "true" - } - }; - const params = [SIZE, REINFORCED, bolts]; - const selection = toSelection( - { size: "small", reinforced: "true", bolts: "4" }, - params - ); - const settled = normalizeSelection(selection, params); - expect(settled.reinforced).toBe("false"); - expect(settled.bolts).toBe("2"); - }); -}); diff --git a/src/frontend/features/insert/parameter-value.ts b/src/frontend/features/insert/parameter-value.ts index 4caad05bc..0427f4c0f 100644 --- a/src/frontend/features/insert/parameter-value.ts +++ b/src/frontend/features/insert/parameter-value.ts @@ -1,15 +1,7 @@ import { type ConfigurationParameter, - type EnumOption, - ParameterType, - type PartialSelection, type Selection } from "@backend/features/configurations/contract"; -import { - evaluateCondition, - getOption, - getVisibleOptions -} from "@backend/features/configurations/utils"; /** Returns the same selection when nothing changes, so React can bail out. */ export function withParameterValue( @@ -23,78 +15,3 @@ export function withParameterValue( } return { ...selection, [parameter.id]: wanted }; } - -/** The selected option if still visible, else the default, else the first. */ -export function resolveSelectedOption( - visibleOptions: EnumOption[], - currentOptionId: string | undefined, - defaultOptionId: string -): EnumOption | undefined { - if (visibleOptions.length === 0) { - return undefined; - } - return ( - (currentOptionId - ? getOption(visibleOptions, currentOptionId) - : undefined) ?? - getOption(visibleOptions, defaultOptionId) ?? - visibleOptions[0] - ); -} - -/** One pass: hidden parameters take their default, enums the option they land on. */ -function normalizeOnce( - selection: Selection, - parameters: ConfigurationParameter[] -): Selection { - const next = { ...selection }; - for (const parameter of parameters) { - if (!evaluateCondition(parameter.condition, next, parameters)) { - next[parameter.id] = parameter.default; - continue; - } - if (parameter.type !== ParameterType.ENUM) { - continue; - } - const visible = getVisibleOptions(parameter, next, parameters); - next[parameter.id] = - resolveSelectedOption( - visible, - next[parameter.id], - parameter.default - )?.id ?? parameter.default; - } - return next; -} - -export function sameSelection( - a: PartialSelection | undefined, - b: Selection -): boolean { - if (!a) return false; - const keys = Object.keys(b); - return ( - keys.length === Object.keys(a).length && - keys.every((key) => a[key] === b[key]) - ); -} - -/** - * Repeated because settling one parameter can change another's options. The - * pass cap stops parameters whose conditions name each other, so the result - * isn't always a fixed point. - */ -export function normalizeSelection( - selection: Selection, - parameters: ConfigurationParameter[] -): Selection { - let current = selection; - for (let pass = 0; pass <= parameters.length; pass++) { - const next = normalizeOnce(current, parameters); - if (sameSelection(current, next)) { - return current; - } - current = next; - } - return current; -} diff --git a/src/frontend/features/insert/quantity-box.test.ts b/src/frontend/features/insert/quantity-box.test.ts deleted file mode 100644 index 3a638f2dd..000000000 --- a/src/frontend/features/insert/quantity-box.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - ParameterType, - type QuantityParameter -} from "@backend/features/configurations/contract"; -import { QuantityType, Unit } from "@backend/features/configurations/enums"; -import { getEvaluateOptions } from "@backend/features/configurations/utils"; -import { seedFrom } from "./quantity-box"; - -const SHAFT_LENGTH: QuantityParameter = { - id: "Length", - name: "Length", - type: ParameterType.QUANTITY, - quantityType: QuantityType.LENGTH, - default: "47 in", - defaultValue: 47, - min: 0, - max: 100, - unit: Unit.INCH -}; - -/** Standalone has no document to ask, so each quantity shows its own unit. */ -const STANDALONE = getEvaluateOptions(SHAFT_LENGTH, undefined); - -describe("seedFrom", () => { - // The box edits what was typed and shows what it evaluates to. - it("opens an expression for editing as it was entered", () => { - expect(seedFrom("(40 + 7) in", SHAFT_LENGTH, STANDALONE)).toEqual({ - expression: "(40 + 7) in", - display: "47 in" - }); - }); - - it("shows the value in the document's unit when there is one", () => { - const metric = getEvaluateOptions(SHAFT_LENGTH, { - lengthUnit: Unit.MILLIMETER, - angleUnit: Unit.DEGREE, - lengthPrecision: 1, - anglePrecision: 3, - realPrecision: 3 - }); - expect(seedFrom("47 in", SHAFT_LENGTH, metric)).toEqual({ - expression: "47 in", - display: "1193.8 mm" - }); - }); - - it("falls back to the parameter's default when nothing is selected", () => { - expect(seedFrom(undefined, SHAFT_LENGTH, STANDALONE)).toEqual({ - expression: "47 in", - display: "47 in" - }); - }); - - it("shows an unevaluable value back with its error", () => { - const box = seedFrom("not a length", SHAFT_LENGTH, STANDALONE); - expect(box.expression).toBe("not a length"); - expect(box.errorMessage).toBeTruthy(); - }); -}); diff --git a/src/frontend/features/insert/quantity-box.ts b/src/frontend/features/insert/quantity-box.ts deleted file mode 100644 index 3603a51c1..000000000 --- a/src/frontend/features/insert/quantity-box.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type QuantityParameter } from "@backend/features/configurations/contract"; -import { - type EvaluateOptions, - evaluateExpression, - formatValueWithUnits, - valueWithUnits -} from "@backend/features/configurations/input-parser"; - -/** Everything the quantity box shows: the expression, its display, and any error. */ -interface QuantityBox { - /** Shown while the input has focus: what was typed, or what to edit. */ - expression: string; - /** The evaluated value, shown while it does not. */ - display: string; - errorMessage?: string; -} - -/** The expression while focused, its value otherwise. */ -export function seedFrom( - value: string | undefined, - parameter: QuantityParameter, - options: EvaluateOptions -): QuantityBox { - if (value === undefined) { - const display = formatValueWithUnits( - valueWithUnits(parameter.defaultValue, parameter.unit), - options.displayUnit, - options.displayPrecision - ); - return { expression: display, display }; - } - const result = evaluateExpression(value, options); - // In the field, since this runs during render. - return result.hasError - ? { - expression: result.expression, - display: result.expression, - errorMessage: result.errorMessage - } - : { - expression: result.expression, - display: result.displayExpression - }; -} diff --git a/src/frontend/features/insert/queries.ts b/src/frontend/features/insert/queries.ts index 5483a188e..50af051d3 100644 --- a/src/frontend/features/insert/queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -10,7 +10,6 @@ import { type PartialSelection, type UnitInfo } from "@backend/features/configurations/contract"; -import { type ElementPath } from "@backend/lib/onshape/path"; import { InsertableOut, type InsertOut @@ -99,50 +98,38 @@ export function useInsertMutation( const toastId = "insert-" + insertable.id; + const toRequest = (fasten: boolean) => { + // Insert buttons don't render without a target. + if (!target) { + throw new Error("Nothing to insert into."); + } + const { elementType, ...targetPath } = target; + const common = { + targetPath, + selection, + isFavorite: insertArgs.isFavorite, + isQuickInsert: insertArgs.isQuickInsert ?? false, + source: insertArgs.source + }; + return elementType === ElementType.ASSEMBLY + ? { + endpoint: "/add-to-assembly", + body: { ...common, fasten, insertLocationId } + } + : { + endpoint: "/add-to-part-studio", + body: { + ...common, + useMateConnector: insertable.supportsFasten + } + }; + }; + return useMutation({ mutationKey: ["insert", insertable.id], mutationFn: async (fasten: boolean) => { - let endpoint: string; - let body: Record; - - // Insert buttons don't render without a target. - if (!target) { - throw new Error("Nothing to insert into."); - } - const targetPath: ElementPath = { - documentId: target.documentId, - instanceId: target.instanceId, - instanceType: target.instanceType, - elementId: target.elementId - }; - - if (target.elementType == ElementType.ASSEMBLY) { - endpoint = "/add-to-assembly"; - body = { - targetPath, - selection, - isFavorite: insertArgs.isFavorite, - isQuickInsert: insertArgs.isQuickInsert ?? false, - source: insertArgs.source, - fasten, - insertLocationId, - elementType: insertable.elementType - }; - } else { - endpoint = "/add-to-part-studio"; - body = { - targetPath, - selection, - isFavorite: insertArgs.isFavorite, - isQuickInsert: insertArgs.isQuickInsert ?? false, - source: insertArgs.source, - useMateConnector: insertable.supportsFasten - }; - } - await queryClient.cancelQueries({ - queryKey: renderQueryPrefix() - }); - + const { endpoint, body } = toRequest(fasten); + await queryClient.cancelQueries({ queryKey: renderQueryPrefix() }); showLoadingToast(`Inserting ${insertable.name}...`, toastId); return apiPost( endpoint + toInsertablePath(insertable.id), From 95d58bbb9c3e02fdbf5c2c4d70ea030a44d268a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 03:20:43 +0000 Subject: [PATCH 42/88] Hold new versions for an admin's approval A library admin can switch on version approval. A new version's webhook load then waits on a workflow event for up to a day, and Approve in the settings menu sends it to every held load. Switching approval off, or reloading outright, lets them through too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 2 + drizzle/0008_version_approval.sql | 2 + drizzle/meta/0008_snapshot.json | 1348 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/backend/db/schema.ts | 10 +- .../library/groups/routes.worker.test.ts | 4 +- src/backend/features/load/contract.ts | 12 + src/backend/features/load/jobs.ts | 102 +- src/backend/features/load/jobs.worker.test.ts | 69 +- src/backend/features/load/routes.ts | 60 +- .../features/load/routes.worker.test.ts | 53 + src/backend/features/load/workflows.ts | 35 +- src/backend/features/webhooks/routes.ts | 14 +- .../features/webhooks/routes.worker.test.ts | 1 + .../components/version-approval.test.tsx | 59 + .../library/components/version-approval.tsx | 38 + src/frontend/features/library/queries.ts | 70 +- .../settings/components/settings-menu.tsx | 12 + src/frontend/lib/query-keys.ts | 4 + 19 files changed, 1876 insertions(+), 26 deletions(-) create mode 100644 drizzle/0008_version_approval.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/frontend/features/library/components/version-approval.test.tsx create mode 100644 src/frontend/features/library/components/version-approval.tsx diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index d766d023e..744f8a3b8 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -101,6 +101,8 @@ Onshape pushes one thing, registered with `isTransient: false` and recorded in t - **A new version of a library document.** Registered by the document's load; removed with the last group loaded from it. Reloads that document's groups. +A library admin can switch on **Approve new versions** in the settings menu (`libraries.approve_versions`). A webhook's load in that library then holds a new version: it marks its `load_jobs` row `awaiting_approval` and waits on the workflow event `approve-version` for up to a day. **Approve** beside "Held versions" sends that event to every held load, and so does switching approval off. An admin's reload approves the groups it names. A version nobody approves within the day is dropped, and the next reload or new version picks it up again. + Onshape's team webhooks need a company id, which a personal account lacks, so an admin team's membership is pulled again only when the owner sets the team or an admin presses **Refresh** beside "Admin team members" in the settings menu. A load that fails is flagged `LOAD_FAILED`, including one whose workflow crashed before it could say so; the next look at the library's jobs notices. Reloading the library's outdated documents reruns it. diff --git a/drizzle/0008_version_approval.sql b/drizzle/0008_version_approval.sql new file mode 100644 index 000000000..b63b30894 --- /dev/null +++ b/drizzle/0008_version_approval.sql @@ -0,0 +1,2 @@ +ALTER TABLE `libraries` ADD `approve_versions` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `load_jobs` ADD `awaiting_approval` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..79196bbe9 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1348 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b14d2440-111d-40ba-b503-0e8b71409337", + "prevId": "7bfdd317-a1be-4641-b2fe-989132108c63", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_insertable_id_insertables_id_fk": { + "name": "configurations_insertable_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_selection": { + "name": "default_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "favorites_user_id_library_id_insertable_id_unique": { + "name": "favorites_user_id_library_id_insertable_id_unique", + "columns": ["user_id", "library_id", "insertable_id"], + "isUnique": true + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_library_id_libraries_id_fk": { + "name": "favorites_library_id_libraries_id_fk", + "tableFrom": "favorites", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_insertable_id_insertables_id_fk": { + "name": "favorites_insertable_id_insertables_id_fk", + "tableFrom": "favorites", + "tableTo": "insertables", + "columnsFrom": ["insertable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_workspace_id": { + "name": "thumbnail_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_alphabetically": { + "name": "sort_alphabetically", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groups_document_id_library_id_unique": { + "name": "groups_document_id_library_id_unique", + "columns": ["document_id", "library_id"], + "isUnique": true + } + }, + "foreignKeys": { + "groups_library_id_libraries_id_fk": { + "name": "groups_library_id_libraries_id_fk", + "tableFrom": "groups", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertables": { + "name": "insertables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_type": { + "name": "element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "microversion_id": { + "name": "microversion_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_open_composite": { + "name": "is_open_composite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "supports_fasten": { + "name": "supports_fasten", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "excluded_parameter_ids": { + "name": "excluded_parameter_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vendors": { + "name": "vendors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_metadata": { + "name": "part_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "insertables_group_id_groups_id_fk": { + "name": "insertables_group_id_groups_id_fk", + "tableFrom": "insertables", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "insertables_library_id_libraries_id_fk": { + "name": "insertables_library_id_libraries_id_fk", + "tableFrom": "insertables", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "libraries": { + "name": "libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "admin_team_id": { + "name": "admin_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_team": { + "name": "admin_team", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "approve_versions": { + "name": "approve_versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "load_jobs": { + "name": "load_jobs", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rerun": { + "name": "rerun", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rerun_force": { + "name": "rerun_force", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awaiting_approval": { + "name": "awaiting_approval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "load_jobs_group_id_groups_id_fk": { + "name": "load_jobs_group_id_groups_id_fk", + "tableFrom": "load_jobs", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "load_jobs_library_id_libraries_id_fk": { + "name": "load_jobs_library_id_libraries_id_fk", + "tableFrom": "load_jobs", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onshape_webhooks": { + "name": "onshape_webhooks", + "columns": { + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "onshape_webhooks_token_unique": { + "name": "onshape_webhooks_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onshape_webhooks_subject_subject_id_pk": { + "columns": ["subject", "subject_id"], + "name": "onshape_webhooks_subject_subject_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "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'" + }, + "tab_id": { + "name": "tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_library_id_libraries_id_fk": { + "name": "users_library_id_libraries_id_fk", + "tableFrom": "users", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_configuration_metrics": { + "name": "daily_configuration_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parameter_id": { + "name": "parameter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_configuration_metrics_day_idx": { + "name": "daily_configuration_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { + "columns": [ + "library_id", + "element_id", + "parameter_id", + "value", + "day" + ], + "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_metrics": { + "name": "daily_insertable_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "daily_insertable_metrics_day_idx": { + "name": "daily_insertable_metrics_day_idx", + "columns": ["library_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { + "columns": [ + "library_id", + "element_id", + "day", + "target_element_type" + ], + "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_insertable_users": { + "name": "daily_insertable_users", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_insertable_users_library_id_element_id_day_user_id_pk": { + "columns": ["library_id", "element_id", "day", "user_id"], + "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_metrics": { + "name": "daily_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fasten_count": { + "name": "fasten_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_metrics_day_library_id_type_pk": { + "columns": ["day", "library_id", "type"], + "name": "daily_metrics_day_library_id_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_source_metrics": { + "name": "daily_source_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quick_insert_count": { + "name": "quick_insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_source_metrics_day_library_id_source_pk": { + "columns": ["day", "library_id", "source"], + "name": "daily_source_metrics_day_library_id_source_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_target_metrics": { + "name": "daily_target_metrics", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_target_metrics_day_library_id_target_element_type_pk": { + "columns": ["day", "library_id", "target_element_type"], + "name": "daily_target_metrics_day_library_id_target_element_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_user_activity": { + "name": "daily_user_activity", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "daily_user_activity_day_idx": { + "name": "daily_user_activity_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_user_activity_day_library_id_user_id_pk": { + "columns": ["day", "library_id", "user_id"], + "name": "daily_user_activity_day_library_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_element_type": { + "name": "target_element_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_quick_insert": { + "name": "is_quick_insert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten": { + "name": "fasten", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "events_day_idx": { + "name": "events_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertable_stats": { + "name": "insertable_stats", + "columns": { + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_inserted_at": { + "name": "first_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_inserted_at": { + "name": "last_inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "insertable_stats_count_idx": { + "name": "insertable_stats_count_idx", + "columns": ["library_id", "insert_count"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "insertable_stats_library_id_element_id_pk": { + "columns": ["library_id", "element_id"], + "name": "insertable_stats_library_id_element_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insert_count": { + "name": "insert_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_stats_user_id_library_id_pk": { + "columns": ["user_id", "library_id"], + "name": "user_stats_user_id_library_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7c7fe0068..487435e28 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1790270424041, "tag": "0007_admin_team_column", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1790305992938, + "tag": "0008_version_approval", + "breakpoints": true } ] } diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index af7ebb021..bcd5b6b86 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -64,7 +64,11 @@ export const libraries = sqliteTable("libraries", { adminTeam: text("admin_team", { mode: "json" }) .$type() .notNull() - .default([]) + .default([]), + // Holds each new version's load until an admin approves it. + approveVersions: integer("approve_versions", { mode: "boolean" }) + .notNull() + .default(false) }); /** Before a load pins a real version, so a failed group can still be retried. */ @@ -237,6 +241,10 @@ export const loadJobs = sqliteTable("load_jobs", { rerun: integer("rerun", { mode: "boolean" }).notNull().default(false), // Whether the rerun reloads unchanged insertables too. rerunForce: integer("rerun_force", { mode: "boolean" }) + .notNull() + .default(false), + // Its load is waiting for an admin to approve the version. + awaitingApproval: integer("awaiting_approval", { mode: "boolean" }) .notNull() .default(false) }); diff --git a/src/backend/features/library/groups/routes.worker.test.ts b/src/backend/features/library/groups/routes.worker.test.ts index 1671b8cd5..b3889d8e1 100644 --- a/src/backend/features/library/groups/routes.worker.test.ts +++ b/src/backend/features/library/groups/routes.worker.test.ts @@ -182,8 +182,8 @@ describe("GET /job-status", () => { afterEach(() => vi.restoreAllMocks()); it.each([ - { loadingGroupIds: [TEST_GROUP_ID] }, - { loadingGroupIds: [] } + { loadingGroupIds: [TEST_GROUP_ID], awaitingApprovalGroupIds: [] }, + { loadingGroupIds: [], awaitingApprovalGroupIds: [TEST_GROUP_ID] } ])("reports $loadingGroupIds loading", async (status) => { vi.spyOn(Jobs, "getJobStatus").mockResolvedValue(status); diff --git a/src/backend/features/load/contract.ts b/src/backend/features/load/contract.ts index 9e4302ce5..265d69149 100644 --- a/src/backend/features/load/contract.ts +++ b/src/backend/features/load/contract.ts @@ -1,6 +1,18 @@ /** Which of a library's groups are loading, so each can show that it is. */ export interface JobStatus { loadingGroupIds: string[]; + /** Holding a new version until an admin approves it. */ + awaitingApprovalGroupIds: string[]; +} + +export interface VersionApprovalOut { + /** Whether new versions wait for an admin's approval before loading. */ + enabled: boolean; +} + +export interface ApproveVersionsOut { + /** How many documents were let through. */ + documents: number; } export interface ReloadOut { diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index 26b794f42..8934f7db0 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -3,7 +3,7 @@ * A load requested meanwhile is marked on the running row and started when it * finishes. In D1 since concurrent KV writes lose updates. */ -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; import type { AppBindings } from "../../lib/context"; import { getDb } from "../../db/client"; @@ -22,10 +22,18 @@ export interface LoadDocumentParams { sessionId?: string; /** Reloads insertables whose version has not changed, too. */ forceReload: boolean; + /** Waits for an admin to approve a new version before loading it. */ + awaitApproval?: boolean; /** This deployment's, which the document's webhook is delivered to. */ origin: string; } +/** What a held load waits for; see `approveHeldLoads`. */ +export const APPROVE_EVENT = "approve-version"; + +/** A version nobody approves in this long is dropped until the next one. */ +export const APPROVAL_TIMEOUT = "1 day"; + /** Instance statuses that mean a load is still live. */ const ACTIVE_STATUSES = new Set([ "queued", @@ -103,9 +111,15 @@ export async function requestLoads( .where(inArray(loadJobs.groupId, chunk))) ); } - const running = new Set( - (await clearDead(env, existing)).map((job) => job.groupId) + const live = await clearDead(env, existing); + const running = new Set(live.map((job) => job.groupId)); + + // Asking for a load outright approves the version one is holding. + const overridden = live.filter( + (job) => + job.awaitingApproval && !byGroup.get(job.groupId)?.awaitApproval ); + await releaseHeldLoads(env, overridden); // Running already: its load starts this one as it finishes. const queued = requests.filter((request) => running.has(request.groupId)); @@ -196,7 +210,8 @@ export async function finishLoad( instanceId, startedAt: new Date(), rerun: false, - rerunForce: false + rerunForce: false, + awaitingApproval: false }) .where(eq(loadJobs.groupId, params.groupId)); await env.LOAD_DOCUMENT_WORKFLOW.create({ @@ -222,10 +237,85 @@ async function runningStatus( libraryId: LibraryId ): Promise { const rows = await getDb(env.DB) - .select({ groupId: loadJobs.groupId }) + .select({ + groupId: loadJobs.groupId, + awaitingApproval: loadJobs.awaitingApproval + }) .from(loadJobs) .where(eq(loadJobs.libraryId, libraryId)); - return { loadingGroupIds: rows.map((row) => row.groupId) }; + return { + loadingGroupIds: rows + .filter((row) => !row.awaitingApproval) + .map((row) => row.groupId), + awaitingApprovalGroupIds: rows + .filter((row) => row.awaitingApproval) + .map((row) => row.groupId) + }; +} + +/** Marks a load as waiting for approval, from inside it. */ +export async function holdLoad( + env: AppBindings, + params: LoadDocumentParams +): Promise { + await getDb(env.DB) + .update(loadJobs) + .set({ awaitingApproval: true }) + .where(eq(loadJobs.groupId, params.groupId)); + await pushJobStatus( + env, + params.libraryId, + await runningStatus(env, params.libraryId) + ); +} + +/** An event sent before a load reaches its wait is kept for it. */ +async function releaseHeldLoads( + env: AppBindings, + jobs: LoadJob[] +): Promise { + await Promise.all( + jobs.map(async (job) => { + if (!job.instanceId) return; + try { + const instance = await env.LOAD_DOCUMENT_WORKFLOW.get( + job.instanceId + ); + await instance.sendEvent({ type: APPROVE_EVENT, payload: {} }); + } catch (error) { + console.error( + `Failed to approve the load of ${job.groupId}`, + error + ); + } + }) + ); + const db = getDb(env.DB); + for (const chunk of chunkForInArray(jobs.map((job) => job.groupId))) { + await db + .update(loadJobs) + .set({ awaitingApproval: false }) + .where(inArray(loadJobs.groupId, chunk)); + } +} + +/** Lets every load the library is holding through. Returns how many there were. */ +export async function approveHeldLoads( + env: AppBindings, + libraryId: LibraryId +): Promise { + const held = await getDb(env.DB) + .select() + .from(loadJobs) + .where( + and( + eq(loadJobs.libraryId, libraryId), + eq(loadJobs.awaitingApproval, true) + ) + ); + await releaseHeldLoads(env, held); + await pushJobStatus(env, libraryId, await runningStatus(env, libraryId)); + return held.length; } /** Also clears rows left by crashed loads. Pushes keep the client current after this. */ diff --git a/src/backend/features/load/jobs.worker.test.ts b/src/backend/features/load/jobs.worker.test.ts index e08096203..bbdd0c091 100644 --- a/src/backend/features/load/jobs.worker.test.ts +++ b/src/backend/features/load/jobs.worker.test.ts @@ -7,8 +7,11 @@ import { eq } from "drizzle-orm"; import { BuildIssueType } from "../build-checker/issues"; import * as LibraryDb from "../library/db"; import { + APPROVE_EVENT, + approveHeldLoads, finishLoad, getJobStatus, + holdLoad, requestLoads, type LoadDocumentParams } from "./jobs"; @@ -25,11 +28,14 @@ function params(groupId: string, forceReload = false): LoadDocumentParams { }; } -/** Every instance reports `status`, as far as the jobs can tell. */ +/** Every instance reports `status`, as far as the jobs can tell. Returns their `sendEvent`. */ function instancesAre(status: InstanceStatus["status"]) { + const sendEvent = vi.fn().mockResolvedValue(undefined); vi.spyOn(env.LOAD_DOCUMENT_WORKFLOW, "get").mockResolvedValue({ - status: () => Promise.resolve({ status }) + status: () => Promise.resolve({ status }), + sendEvent } as never); + return sendEvent; } const job = (groupId: string) => @@ -63,7 +69,8 @@ describe("document loads", () => { expect((await job("a"))?.instanceId).toBe(started[0].id); instancesAre("running"); expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ - loadingGroupIds: ["a", "b"] + loadingGroupIds: ["a", "b"], + awaitingApprovalGroupIds: [] }); }); @@ -102,6 +109,59 @@ describe("document loads", () => { }); }); + describe("approval", () => { + const held = { ...params("a"), awaitApproval: true }; + + beforeEach(async () => { + vi.spyOn( + env.LOAD_DOCUMENT_WORKFLOW, + "createBatch" + ).mockResolvedValue([]); + await requestLoads(env, [held]); + await holdLoad(env, held); + }); + + it("reports a held load apart from the running ones", async () => { + instancesAre("waiting"); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + loadingGroupIds: [], + awaitingApprovalGroupIds: ["a"] + }); + }); + + it("lets every held load through", async () => { + const sendEvent = instancesAre("waiting"); + + expect(await approveHeldLoads(env, TEST_LIBRARY_ID)).toBe(1); + + expect(sendEvent).toHaveBeenCalledWith({ + type: APPROVE_EVENT, + payload: {} + }); + expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ + loadingGroupIds: ["a"], + awaitingApprovalGroupIds: [] + }); + }); + + it("approves a held load someone asks for outright", async () => { + const sendEvent = instancesAre("waiting"); + await requestLoads(env, [params("a")]); + expect(sendEvent).toHaveBeenCalledOnce(); + expect(await job("a")).toMatchObject({ awaitingApproval: false }); + }); + + it("keeps holding for another version's webhook", async () => { + const sendEvent = instancesAre("waiting"); + await requestLoads(env, [held]); + expect(sendEvent).not.toHaveBeenCalled(); + expect(await job("a")).toMatchObject({ + awaitingApproval: true, + rerun: true + }); + }); + }); + describe("finishing", () => { beforeEach(() => { vi.spyOn( @@ -140,7 +200,8 @@ describe("document loads", () => { await finishLoad(env, params("b"), false); expect(rebuild).toHaveBeenCalledOnce(); expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ - loadingGroupIds: [] + loadingGroupIds: [], + awaitingApprovalGroupIds: [] }); }); }); diff --git a/src/backend/features/load/routes.ts b/src/backend/features/load/routes.ts index 62e941ef6..6a385470f 100644 --- a/src/backend/features/load/routes.ts +++ b/src/backend/features/load/routes.ts @@ -3,14 +3,19 @@ import * as z from "zod"; import { getApp } from "../../lib/context"; import { forbiddenError } from "../../lib/api-error"; import { getDb } from "../../db/client"; -import { groups } from "../../db/schema"; +import { groups, libraries } from "../../db/schema"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { validate } from "../../lib/validate"; import { AccessLevel } from "../auth/access-level"; import { requireAdminMiddleware } from "../auth/guards"; import { getSessionId } from "../auth/session"; -import { requestLoads } from "./jobs"; -import type { ReloadOut } from "./contract"; +import { approveHeldLoads, requestLoads } from "./jobs"; +import type { + ApproveVersionsOut, + ReloadOut, + VersionApprovalOut +} from "./contract"; +import { ensureLibrary } from "../library/db"; export const loadRoutes = getApp(); @@ -55,3 +60,52 @@ loadRoutes.post( return c.json({ documents: rows.length } satisfies ReloadOut); } ); + +const versionApprovalBody = z.object({ enabled: z.boolean() }); + +/** GET /api/version-approval/library/:libraryId */ +loadRoutes.get( + "/version-approval" + libraryRoute(), + requireAdminMiddleware, + async (c) => { + const library = await getDb(c.env.DB) + .select({ enabled: libraries.approveVersions }) + .from(libraries) + .where(eq(libraries.id, getLibraryParam(c))) + .get(); + return c.json({ + enabled: library?.enabled ?? false + } satisfies VersionApprovalOut); + } +); + +/** POST /api/version-approval/library/:libraryId: turning it off lets held versions through. */ +loadRoutes.post( + "/version-approval" + libraryRoute(), + requireAdminMiddleware, + validate("json", versionApprovalBody), + async (c) => { + const libraryId = getLibraryParam(c); + const { enabled } = c.req.valid("json"); + const db = getDb(c.env.DB); + await ensureLibrary(db, libraryId); + await db + .update(libraries) + .set({ approveVersions: enabled }) + .where(eq(libraries.id, libraryId)); + if (!enabled) { + await approveHeldLoads(c.env, libraryId); + } + return c.json({ enabled } satisfies VersionApprovalOut); + } +); + +/** POST /api/approve-versions/library/:libraryId */ +loadRoutes.post( + "/approve-versions" + libraryRoute(), + requireAdminMiddleware, + async (c) => { + const documents = await approveHeldLoads(c.env, getLibraryParam(c)); + return c.json({ documents } satisfies ApproveVersionsOut); + } +); diff --git a/src/backend/features/load/routes.worker.test.ts b/src/backend/features/load/routes.worker.test.ts index fd0f0ecf7..fb29aadc5 100644 --- a/src/backend/features/load/routes.worker.test.ts +++ b/src/backend/features/load/routes.worker.test.ts @@ -67,3 +67,56 @@ describe("reloading a library", () => { expect((await reload(AccessLevel.EDITOR, false)).status).toBe(403); }); }); + +describe("approving versions", () => { + const APPROVAL_PATH = `/api/version-approval/library/${LibraryId.FRC_DESIGN_LIB}`; + + function call(method: "GET" | "POST", path: string, body?: object) { + const init = body ? jsonRequest(method, body) : { method }; + return createTestApp({ accessLevel: AccessLevel.ADMIN }).request( + path, + { + ...init, + headers: { + ...("headers" in init ? init.headers : {}), + Cookie: "frc-design-app-cookie=s" + } + }, + env + ); + } + + beforeEach(async () => { + await resetDb(db); + await seedGroup(db, "frc", LibraryId.FRC_DESIGN_LIB); + }); + afterEach(() => vi.restoreAllMocks()); + + it("is off until an admin turns it on", async () => { + expect(await (await call("GET", APPROVAL_PATH)).json()).toEqual({ + enabled: false + }); + await call("POST", APPROVAL_PATH, { enabled: true }); + expect(await (await call("GET", APPROVAL_PATH)).json()).toEqual({ + enabled: true + }); + }); + + it("lets held versions through when turned off", async () => { + const approve = vi.spyOn(Jobs, "approveHeldLoads").mockResolvedValue(2); + await call("POST", APPROVAL_PATH, { enabled: false }); + expect(approve).toHaveBeenCalledWith( + expect.anything(), + LibraryId.FRC_DESIGN_LIB + ); + }); + + it("approves every held version", async () => { + vi.spyOn(Jobs, "approveHeldLoads").mockResolvedValue(2); + const res = await call( + "POST", + `/api/approve-versions/library/${LibraryId.FRC_DESIGN_LIB}` + ); + expect(await res.json()).toEqual({ documents: 2 }); + }); +}); diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 820d71b0c..d21e7c200 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -32,7 +32,13 @@ import { createLoadContext, getOnshapeApiFromContext } from "./context"; -import { finishLoad, type LoadDocumentParams } from "./jobs"; +import { + APPROVAL_TIMEOUT, + APPROVE_EVENT, + finishLoad, + holdLoad, + type LoadDocumentParams +} from "./jobs"; import { pushLibraryChanged } from "../live/notify"; import { flagFailedLoads } from "./flag"; import { loadGroup } from "./load-group"; @@ -110,12 +116,20 @@ async function loadDocument( groupId, documentId: stored.documentId }); + const isNewVersion = stored.versionId !== target.versionPath.instanceId; if ( - stored.versionId === target.versionPath.instanceId && + !isNewVersion && !forceReload && !hasFailedLoad(stored.buildIssues) ) { result = { status: "skipped" }; + } else if ( + isNewVersion && + params.awaitApproval && + !forceReload && + !(await waitForApproval(ctx, params)) + ) { + result = { status: "skipped" }; } else { result = { status: "loaded", @@ -165,6 +179,23 @@ async function loadDocument( return result; } +/** False once nobody has approved it for `APPROVAL_TIMEOUT`. */ +async function waitForApproval( + ctx: LoadContext, + params: LoadDocumentParams +): Promise { + await ctx.step.do("hold-for-approval", () => holdLoad(ctx.env, params)); + try { + await ctx.step.waitForEvent("approval", { + type: APPROVE_EVENT, + timeout: APPROVAL_TIMEOUT + }); + return true; + } catch { + return false; + } +} + /** * A failure leaves the version where it was, so a group that failed on the * latest version would otherwise be skipped until a forced reload. diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index 7434502fb..8e305ae39 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -6,7 +6,7 @@ import { eq } from "drizzle-orm"; import { type AppBindings, getApp } from "../../lib/context"; import { forbiddenError } from "../../lib/api-error"; import { getDb } from "../../db/client"; -import { groups, WebhookSubject } from "../../db/schema"; +import { groups, libraries, WebhookSubject } from "../../db/schema"; import { requestLoads } from "../load/jobs"; import { findWebhookByToken, @@ -65,15 +65,23 @@ webhookRoutes.post(UNITS_RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { return c.json({}); }); -/** Nobody is signed in behind a webhook, so each load finds an admin's session. */ +/** + * Nobody is signed in behind a webhook, so each load finds an admin's session. + * A library that approves versions holds the load until an admin does. + */ async function reloadDocument( env: AppBindings, documentId: string, origin: string ): Promise { const documentGroups = await getDb(env.DB) - .select({ groupId: groups.id, libraryId: groups.libraryId }) + .select({ + groupId: groups.id, + libraryId: groups.libraryId, + awaitApproval: libraries.approveVersions + }) .from(groups) + .innerJoin(libraries, eq(libraries.id, groups.libraryId)) .where(eq(groups.documentId, documentId)); await requestLoads( env, diff --git a/src/backend/features/webhooks/routes.worker.test.ts b/src/backend/features/webhooks/routes.worker.test.ts index fb4ffdcaa..31e10c4d6 100644 --- a/src/backend/features/webhooks/routes.worker.test.ts +++ b/src/backend/features/webhooks/routes.worker.test.ts @@ -68,6 +68,7 @@ describe("receiving a webhook", () => { { libraryId: TEST_LIBRARY_ID, groupId: TEST_GROUP_ID, + awaitApproval: false, forceReload: false, origin: "http://localhost" } diff --git a/src/frontend/features/library/components/version-approval.test.tsx b/src/frontend/features/library/components/version-approval.test.tsx new file mode 100644 index 000000000..81c4d7956 --- /dev/null +++ b/src/frontend/features/library/components/version-approval.test.tsx @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../../__test_utils__/render"; + +const mocks = vi.hoisted(() => ({ + enabled: false, + held: 0, + setApproval: vi.fn(), + approve: vi.fn() +})); + +vi.mock("../queries", () => ({ + useVersionApprovalQuery: () => ({ + data: { enabled: mocks.enabled }, + isPending: false + }), + useSetVersionApprovalMutation: () => ({ + mutate: mocks.setApproval, + isPending: false + }), + useAwaitingApprovalCount: () => mocks.held, + useApproveVersionsMutation: () => ({ + mutate: mocks.approve, + isPending: false + }) +})); + +const { ApproveVersionsButton, VersionApprovalSwitch } = + await import("./version-approval"); + +describe("version approval", () => { + afterEach(() => { + mocks.setApproval.mockReset(); + mocks.approve.mockReset(); + }); + + it("turns approval on", async () => { + mocks.enabled = false; + renderWithProviders(); + await userEvent.setup().click(screen.getByRole("switch")); + expect(mocks.setApproval).toHaveBeenCalledWith(true); + }); + + it("approves the held versions, counting them", async () => { + mocks.held = 3; + renderWithProviders(); + await userEvent.setup().click(screen.getByText("Approve 3")); + expect(mocks.approve).toHaveBeenCalledOnce(); + }); + + it("has nothing to approve with nothing held", () => { + mocks.held = 0; + renderWithProviders(); + expect(screen.getByText("Approve").closest("button")?.disabled).toBe( + true + ); + }); +}); diff --git a/src/frontend/features/library/components/version-approval.tsx b/src/frontend/features/library/components/version-approval.tsx new file mode 100644 index 000000000..a385700a5 --- /dev/null +++ b/src/frontend/features/library/components/version-approval.tsx @@ -0,0 +1,38 @@ +import { Button, Switch } from "@mantine/core"; +import { CheckIcon } from "@phosphor-icons/react"; +import { type ReactNode } from "react"; +import { + useApproveVersionsMutation, + useAwaitingApprovalCount, + useSetVersionApprovalMutation, + useVersionApprovalQuery +} from "../queries"; + +export function VersionApprovalSwitch(): ReactNode { + const query = useVersionApprovalQuery(); + const mutation = useSetVersionApprovalMutation(); + const enabled = query.data?.enabled ?? false; + return ( + mutation.mutate(!enabled)} + withThumbIndicator={false} + /> + ); +} + +export function ApproveVersionsButton(): ReactNode { + const count = useAwaitingApprovalCount(); + const mutation = useApproveVersionsMutation(); + return ( + + ); +} diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 35243b937..693cc7812 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -8,8 +8,10 @@ import { import { apiDelete, apiGet, apiPost } from "../../lib/api-client"; import { type LibraryOut } from "@backend/features/library/contract"; import { + type ApproveVersionsOut, type JobStatus, - type ReloadOut + type ReloadOut, + type VersionApprovalOut } from "@backend/features/load/contract"; import { hasEditorAccess } from "@backend/features/auth/access-level"; import { LibraryId } from "@backend/features/library/library-id"; @@ -19,7 +21,8 @@ import { useLibraryId } from "../../lib/library"; import { jobStatusQueryKey, libraryDataQueryKey, - libraryVersionQueryKey + libraryVersionQueryKey, + versionApprovalQueryKey } from "../../lib/query-keys"; import { queryClient } from "../../lib/query-client"; import { getQueryUpdater } from "../../lib/query-cache"; @@ -86,10 +89,13 @@ function getJobStatusQuery(libraryId: LibraryId, canAsk: boolean) { }); } -const NOTHING_LOADING: string[] = []; +const NO_JOBS: JobStatus = { + loadingGroupIds: [], + awaitingApprovalGroupIds: [] +}; /** Empty for callers who aren't editors with an Onshape session. */ -function useLoadingGroupIds(): string[] { +function useJobStatus(): JobStatus { const libraryId = useLibraryId(); const { signedIn, currentAccessLevel } = useAccessData(); const query = useQuery( @@ -98,7 +104,16 @@ function useLoadingGroupIds(): string[] { signedIn && hasEditorAccess(currentAccessLevel) ) ); - return query.data?.loadingGroupIds ?? NOTHING_LOADING; + return query.data ?? NO_JOBS; +} + +function useLoadingGroupIds(): string[] { + return useJobStatus().loadingGroupIds; +} + +/** How many documents have a new version waiting for an admin's approval. */ +export function useAwaitingApprovalCount(): number { + return useJobStatus().awaitingApprovalGroupIds.length; } /** Whether anything in the library is loading. */ @@ -173,6 +188,51 @@ export function useReloadMutation(all: boolean) { }); } +export function useVersionApprovalQuery() { + const libraryId = useLibraryId(); + return useQuery({ + queryKey: versionApprovalQueryKey(libraryId), + queryFn: () => + apiGet( + "/version-approval" + toLibraryPath(libraryId) + ) + }); +} + +/** Turning it off lets the held versions through. */ +export function useSetVersionApprovalMutation() { + const libraryId = useLibraryId(); + return useMutation({ + mutationKey: ["version-approval", libraryId], + mutationFn: (enabled: boolean) => + apiPost( + "/version-approval" + toLibraryPath(libraryId), + { body: { enabled } } + ), + onError: getAppErrorHandler("Failed to change version approval!"), + onSuccess: (approval) => + queryClient.setQueryData( + versionApprovalQueryKey(libraryId), + approval + ) + }); +} + +export function useApproveVersionsMutation() { + const libraryId = useLibraryId(); + return useMutation({ + mutationKey: ["approve-versions", libraryId], + mutationFn: () => + apiPost( + "/approve-versions" + toLibraryPath(libraryId) + ), + onError: getAppErrorHandler("Failed to approve versions!"), + onSuccess: (data) => { + showInfoToast(`Loading ${data.documents} approved documents...`); + } + }); +} + /** Adds an Onshape document to the library, by its url. */ export function useAddGroupMutation(selectedGroupId?: string) { const libraryId = useLibraryId(); diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 6d7497c28..bbc44ad9b 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -27,6 +27,10 @@ import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { SETUP_URL } from "../../../lib/url"; import { useLibraryId } from "../../../lib/library"; import { ReloadButton } from "../../library/components/reload-button"; +import { + ApproveVersionsButton, + VersionApprovalSwitch +} from "../../library/components/version-approval"; import { RefreshAdminTeamButton } from "../../admin-team/components/refresh-admin-team-button"; import { AdminTeamSetting } from "../../admin-team/components/admin-team-setting"; @@ -210,6 +214,14 @@ function AdminSettings(): ReactNode { + + + + + + + + diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts index 3385d81e0..9030c67c9 100644 --- a/src/frontend/lib/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -11,6 +11,10 @@ export function adminTeamQueryKey(libraryId: LibraryId) { return ["admin-team", libraryId]; } +export function versionApprovalQueryKey(libraryId: LibraryId) { + return ["version-approval", libraryId]; +} + export function configurationQueryKey( insertableId: string, microversionId: string From 68ff16380884ee7a137edb630d353f38c337afe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:57:43 +0000 Subject: [PATCH 43/88] Load held versions after two days, and badge them A version nobody approves now loads once the wait runs out, and a group holding one shows an Awaiting approval badge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 2 +- src/backend/features/load/jobs.ts | 13 ++-- src/backend/features/load/jobs.worker.test.ts | 4 +- src/backend/features/load/workflows.ts | 27 ++++---- .../build-status/components/build-status.tsx | 65 +++++++++++++++---- src/frontend/features/library/queries.ts | 4 ++ 6 files changed, 80 insertions(+), 35 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 744f8a3b8..bfe1a21bc 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -101,7 +101,7 @@ Onshape pushes one thing, registered with `isTransient: false` and recorded in t - **A new version of a library document.** Registered by the document's load; removed with the last group loaded from it. Reloads that document's groups. -A library admin can switch on **Approve new versions** in the settings menu (`libraries.approve_versions`). A webhook's load in that library then holds a new version: it marks its `load_jobs` row `awaiting_approval` and waits on the workflow event `approve-version` for up to a day. **Approve** beside "Held versions" sends that event to every held load, and so does switching approval off. An admin's reload approves the groups it names. A version nobody approves within the day is dropped, and the next reload or new version picks it up again. +A library admin can switch on **Approve new versions** in the settings menu (`libraries.approve_versions`). A webhook's load in that library then holds a new version: it marks its `load_jobs` row `awaiting_approval` and waits on the workflow event `approve-version` for up to two days, then loads anyway. The group's row shows an **Awaiting approval** badge, and **Approve** beside "Held versions" sends that event to every held load, and so does switching approval off. An admin's reload approves the groups it names. Onshape's team webhooks need a company id, which a personal account lacks, so an admin team's membership is pulled again only when the owner sets the team or an admin presses **Refresh** beside "Admin team members" in the settings menu. diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index 8934f7db0..9e9118dab 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -31,8 +31,8 @@ export interface LoadDocumentParams { /** What a held load waits for; see `approveHeldLoads`. */ export const APPROVE_EVENT = "approve-version"; -/** A version nobody approves in this long is dropped until the next one. */ -export const APPROVAL_TIMEOUT = "1 day"; +/** A version nobody approves in this long loads anyway. */ +export const APPROVAL_TIMEOUT = "2 days"; /** Instance statuses that mean a load is still live. */ const ACTIVE_STATUSES = new Set([ @@ -253,14 +253,15 @@ async function runningStatus( }; } -/** Marks a load as waiting for approval, from inside it. */ -export async function holdLoad( +/** From inside the load, as it starts and stops waiting for approval. */ +export async function setAwaitingApproval( env: AppBindings, - params: LoadDocumentParams + params: LoadDocumentParams, + awaitingApproval: boolean ): Promise { await getDb(env.DB) .update(loadJobs) - .set({ awaitingApproval: true }) + .set({ awaitingApproval }) .where(eq(loadJobs.groupId, params.groupId)); await pushJobStatus( env, diff --git a/src/backend/features/load/jobs.worker.test.ts b/src/backend/features/load/jobs.worker.test.ts index bbdd0c091..41ae8708d 100644 --- a/src/backend/features/load/jobs.worker.test.ts +++ b/src/backend/features/load/jobs.worker.test.ts @@ -11,7 +11,7 @@ import { approveHeldLoads, finishLoad, getJobStatus, - holdLoad, + setAwaitingApproval, requestLoads, type LoadDocumentParams } from "./jobs"; @@ -118,7 +118,7 @@ describe("document loads", () => { "createBatch" ).mockResolvedValue([]); await requestLoads(env, [held]); - await holdLoad(env, held); + await setAwaitingApproval(env, held, true); }); it("reports a held load apart from the running ones", async () => { diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index d21e7c200..7a44721a3 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -36,7 +36,7 @@ import { APPROVAL_TIMEOUT, APPROVE_EVENT, finishLoad, - holdLoad, + setAwaitingApproval, type LoadDocumentParams } from "./jobs"; import { pushLibraryChanged } from "../live/notify"; @@ -123,14 +123,10 @@ async function loadDocument( !hasFailedLoad(stored.buildIssues) ) { result = { status: "skipped" }; - } else if ( - isNewVersion && - params.awaitApproval && - !forceReload && - !(await waitForApproval(ctx, params)) - ) { - result = { status: "skipped" }; } else { + if (isNewVersion && params.awaitApproval && !forceReload) { + await waitForApproval(ctx, params); + } result = { status: "loaded", ...(await loadGroup( @@ -179,21 +175,26 @@ async function loadDocument( return result; } -/** False once nobody has approved it for `APPROVAL_TIMEOUT`. */ +/** Loads anyway once nobody has approved it for `APPROVAL_TIMEOUT`. */ async function waitForApproval( ctx: LoadContext, params: LoadDocumentParams -): Promise { - await ctx.step.do("hold-for-approval", () => holdLoad(ctx.env, params)); +): Promise { + await ctx.step.do("hold-for-approval", () => + setAwaitingApproval(ctx.env, params, true) + ); try { await ctx.step.waitForEvent("approval", { type: APPROVE_EVENT, timeout: APPROVAL_TIMEOUT }); - return true; } catch { - return false; + // Timed out. } + // An approval has cleared it already; a timeout hasn't. + await ctx.step.do("release-approval", () => + setAwaitingApproval(ctx.env, params, false) + ); } /** diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index 0aa663a2d..91389a2eb 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -1,5 +1,17 @@ -import { Divider, Group, Loader, Stack, Text, Tooltip } from "@mantine/core"; -import { EyeSlashIcon, GitBranchIcon } from "@phosphor-icons/react"; +import { + Badge, + Divider, + Group, + Loader, + Stack, + Text, + Tooltip +} from "@mantine/core"; +import { + EyeSlashIcon, + GitBranchIcon, + HourglassIcon +} from "@phosphor-icons/react"; import { ReactNode } from "react"; import { formatDaysAgo } from "../../../lib/format-time"; import { @@ -16,7 +28,10 @@ import { AppIcon } from "../../../components/app-icon"; import { AppHoverCard } from "../../../components/app-hover-card"; import { RequireAccessLevel } from "../../auth/access-level"; import { useBuildStatusQuery } from "../queries"; -import { useIsGroupLoading } from "../../library/queries"; +import { + useIsGroupAwaitingApproval, + useIsGroupLoading +} from "../../library/queries"; import { BuildChecksSection, type ConfigurationTarget, @@ -274,16 +289,40 @@ export function GroupStatusBadge(props: GroupStatusBadgeProps): ReactNode { const { data } = useBuildStatusQuery(); const groupStatus = data?.groups[groupId]; const issues = useGroupBuildIssues(groupStatus, data?.insertables); - if (!groupStatus) return null; return ( - - } - /> + <> + {groupStatus && ( + + } + /> + )} + + + ); +} + +interface AwaitingApprovalBadgeProps { + groupId: string; +} + +function AwaitingApprovalBadge(props: AwaitingApprovalBadgeProps): ReactNode { + const awaiting = useIsGroupAwaitingApproval(props.groupId); + if (!awaiting) return null; + return ( + } + > + Awaiting approval + ); } diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 693cc7812..3091846bd 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -111,6 +111,10 @@ function useLoadingGroupIds(): string[] { return useJobStatus().loadingGroupIds; } +export function useIsGroupAwaitingApproval(groupId: string): boolean { + return useJobStatus().awaitingApprovalGroupIds.includes(groupId); +} + /** How many documents have a new version waiting for an admin's approval. */ export function useAwaitingApprovalCount(): number { return useJobStatus().awaitingApprovalGroupIds.length; From f1ed59542d0107b103aa6abf5c34dfbd168d39a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:08:50 +0000 Subject: [PATCH 44/88] Rename the live feature to push and tighten its types - features/live becomes features/push; the Durable Object is PushHub, renamed by a v4 migration, behind a PUSH_HUB binding reached through getPushHub. - Each message is a named interface, and the hub derives a message's library tag from the message itself. - The hub tags a socket only with a real library id. - The route is registered under /api like every other, not by stripping it. - A connection listener is handed the state, so isLiveConnected goes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 4 +- src/backend/app.ts | 4 +- src/backend/features/admin-team/sync.ts | 2 +- src/backend/features/live/contract.ts | 27 ----------- src/backend/features/live/live-updates.ts | 32 ------------- src/backend/features/load/flag.ts | 2 +- src/backend/features/load/jobs.ts | 2 +- src/backend/features/load/workflows.ts | 2 +- src/backend/features/push/contract.ts | 37 +++++++++++++++ src/backend/features/{live => push}/notify.ts | 27 ++++------- src/backend/features/push/push-hub.ts | 46 +++++++++++++++++++ .../push-hub.worker.test.ts} | 27 ++++++----- src/backend/features/{live => push}/routes.ts | 11 +++-- .../features/thumbnails/render-workflow.ts | 2 +- src/backend/index.ts | 2 +- src/backend/lib/context.ts | 6 +-- .../features/thumbnails/render-wait.test.tsx | 21 ++++----- .../features/thumbnails/render-wait.ts | 18 +++----- .../lib/{live-updates.ts => push-socket.ts} | 40 ++++++++-------- .../lib/{live-sync.ts => push-sync.ts} | 32 ++++++------- src/frontend/routes/app/route.tsx | 4 +- worker-configuration.d.ts | 8 ++-- wrangler.jsonc | 10 ++-- 23 files changed, 184 insertions(+), 182 deletions(-) delete mode 100644 src/backend/features/live/contract.ts delete mode 100644 src/backend/features/live/live-updates.ts create mode 100644 src/backend/features/push/contract.ts rename src/backend/features/{live => push}/notify.ts (58%) create mode 100644 src/backend/features/push/push-hub.ts rename src/backend/features/{live/live-updates.worker.test.ts => push/push-hub.worker.test.ts} (74%) rename src/backend/features/{live => push}/routes.ts (56%) rename src/frontend/lib/{live-updates.ts => push-socket.ts} (64%) rename src/frontend/lib/{live-sync.ts => push-sync.ts} (79%) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index bfe1a21bc..4dd570870 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -95,7 +95,7 @@ Every load is one document: adding a document, a new version of one (see Webhook A load calls Onshape as whoever asked for it, while their session works. A webhook's load has nobody, and a requester's session can expire mid-load, so the load then finds a session itself: the owner's, or else a team admin's of the library (`getOnshapeApiFromContext`). Only the owner's and team admins' latest sessions are kept by user id, in KV under `admin-session:`, written as their access is checked (`features/auth/admin-sessions.ts`). -### Webhooks and live updates +### Webhooks and pushes Onshape pushes one thing, registered with `isTransient: false` and recorded in the `onshape_webhooks` table with its own token in the delivery url (`features/webhooks`): @@ -107,7 +107,7 @@ Onshape's team webhooks need a company id, which a personal account lacks, so an A load that fails is flagged `LOAD_FAILED`, including one whose workflow crashed before it could say so; the next look at the library's jobs notices. Reloading the library's outdated documents reruns it. -The server pushes to open clients over a WebSocket held by the `LiveUpdates` Durable Object (`features/live`): jobs starting and finishing, a library's new version, and a configuration's render landing. Nothing polls: a client that reconnects asks again for what it may have missed. +The server pushes to open clients over a WebSocket held by the `PushHub` Durable Object (`features/push`): jobs starting and finishing, a library's new version, and a configuration's render landing. Nothing polls: a client that reconnects asks again for what it may have missed. ### Assets — Static File Serving (`c.env.ASSETS`) diff --git a/src/backend/app.ts b/src/backend/app.ts index 6e97f9d37..53edeaec3 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -11,7 +11,7 @@ import { libraryRoutes } from "./features/library/routes"; import { settingsRoutes } from "./features/settings/routes"; import { thumbnailRoutes } from "./features/thumbnails/routes"; import { webhookRoutes } from "./features/webhooks/routes"; -import { liveRoutes } from "./features/live/routes"; +import { pushRoutes } from "./features/push/routes"; import { adminTeamRoutes } from "./features/admin-team/routes"; import { loadRoutes } from "./features/load/routes"; import { logger } from "hono/logger"; @@ -32,7 +32,7 @@ const apiRoutes = [ buildStatusRoutes, analyticsRoutes, webhookRoutes, - liveRoutes, + pushRoutes, adminTeamRoutes, loadRoutes ]; diff --git a/src/backend/features/admin-team/sync.ts b/src/backend/features/admin-team/sync.ts index 49cb35e05..3fc13d35f 100644 --- a/src/backend/features/admin-team/sync.ts +++ b/src/backend/features/admin-team/sync.ts @@ -7,7 +7,7 @@ import type { OnshapeApi } from "../../lib/onshape/client"; import { getTeamMembers } from "../../lib/onshape/endpoints/teams"; import { bumpLibraryVersion } from "../library/db"; import type { LibraryId } from "../library/library-id"; -import { pushLibraryChanged } from "../live/notify"; +import { pushLibraryChanged } from "../push/notify"; export async function syncAdminTeam( env: AppBindings, diff --git a/src/backend/features/live/contract.ts b/src/backend/features/live/contract.ts deleted file mode 100644 index 497563752..000000000 --- a/src/backend/features/live/contract.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Nothing private: a push says what changed, and the client refetches under its own access. */ -import type { LibraryId } from "../library/library-id"; -import type { JobStatus } from "../load/contract"; -import type { ConfigurationKey } from "../configurations/contract"; - -export enum LiveMessageType { - /** A library's load jobs started or finished. */ - JOBS = "jobs", - /** Its contents or its admin team changed. */ - LIBRARY = "library", - /** A configuration's thumbnail finished rendering. */ - THUMBNAIL = "thumbnail" -} - -export type LiveMessage = - | { type: LiveMessageType.JOBS; libraryId: LibraryId; status: JobStatus } - | { type: LiveMessageType.LIBRARY; libraryId: LibraryId } - | { - type: LiveMessageType.THUMBNAIL; - elementId: string; - microversionId: string; - configurationKey: ConfigurationKey; - }; - -/** Where a client connects, naming the library it is showing. */ -export const LIVE_PATH = "/api/live"; -export const LIVE_LIBRARY_PARAM = "library"; diff --git a/src/backend/features/live/live-updates.ts b/src/backend/features/live/live-updates.ts deleted file mode 100644 index 1fec689eb..000000000 --- a/src/backend/features/live/live-updates.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Holds every client's WebSocket and relays pushes. One instance for the app, - * since pushes are few and small. Sockets hibernate, so idle clients cost - * nothing, and are tagged with the library they show. - */ -import { DurableObject } from "cloudflare:workers"; -import type { AppBindings } from "../../lib/context"; -import type { LibraryId } from "../library/library-id"; -import { LIVE_LIBRARY_PARAM, type LiveMessage } from "./contract"; - -export class LiveUpdates extends DurableObject { - fetch(request: Request): Response { - const libraryId = new URL(request.url).searchParams.get( - LIVE_LIBRARY_PARAM - ); - const { 0: client, 1: server } = new WebSocketPair(); - this.ctx.acceptWebSocket(server, libraryId ? [libraryId] : []); - return new Response(null, { status: 101, webSocket: client }); - } - - /** To the clients showing `libraryId`, or to every client without one. */ - broadcast(message: LiveMessage, libraryId?: LibraryId): void { - const data = JSON.stringify(message); - for (const socket of this.ctx.getWebSockets(libraryId)) { - try { - socket.send(data); - } catch { - // Closing already; its client reconnects and resyncs. - } - } - } -} diff --git a/src/backend/features/load/flag.ts b/src/backend/features/load/flag.ts index 80d0e8654..2bbb41525 100644 --- a/src/backend/features/load/flag.ts +++ b/src/backend/features/load/flag.ts @@ -4,7 +4,7 @@ import { getDb } from "../../db/client"; import { groups } from "../../db/schema"; import { bumpLibraryVersion } from "../library/db"; import type { LibraryId } from "../library/library-id"; -import { pushLibraryChanged } from "../live/notify"; +import { pushLibraryChanged } from "../push/notify"; import { addBuildIssue, BuildIssueType } from "../build-checker/issues"; /** Marks each group for an admin to reload; publishing the change is the caller's. */ diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index 9e9118dab..4741560a9 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -11,7 +11,7 @@ import { chunkForInArray } from "../../db/chunk"; import { loadJobs } from "../../db/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../library/db"; import type { LibraryId } from "../library/library-id"; -import { pushJobStatus, pushLibraryChanged } from "../live/notify"; +import { pushJobStatus, pushLibraryChanged } from "../push/notify"; import type { JobStatus } from "./contract"; import { flagFailedLoads, publishLibraries } from "./flag"; diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 7a44721a3..6b0c85ac6 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -39,7 +39,7 @@ import { setAwaitingApproval, type LoadDocumentParams } from "./jobs"; -import { pushLibraryChanged } from "../live/notify"; +import { pushLibraryChanged } from "../push/notify"; import { flagFailedLoads } from "./flag"; import { loadGroup } from "./load-group"; import { ONSHAPE_STEP_RETRIES } from "./steps"; diff --git a/src/backend/features/push/contract.ts b/src/backend/features/push/contract.ts new file mode 100644 index 000000000..f244bedb7 --- /dev/null +++ b/src/backend/features/push/contract.ts @@ -0,0 +1,37 @@ +/** Nothing private: a push says what changed, and the client refetches under its own access. */ +import type { LibraryId } from "../library/library-id"; +import type { JobStatus } from "../load/contract"; +import type { ConfigurationKey } from "../configurations/contract"; + +export enum PushType { + JOBS = "jobs", + LIBRARY = "library", + THUMBNAIL = "thumbnail" +} + +/** A library's load jobs started or finished. */ +export interface JobsPush { + type: PushType.JOBS; + libraryId: LibraryId; + status: JobStatus; +} + +/** A library's contents or admin team changed. */ +export interface LibraryPush { + type: PushType.LIBRARY; + libraryId: LibraryId; +} + +/** A configuration's thumbnail finished rendering. */ +export interface ThumbnailPush { + type: PushType.THUMBNAIL; + elementId: string; + microversionId: string; + configurationKey: ConfigurationKey; +} + +export type PushMessage = JobsPush | LibraryPush | ThumbnailPush; + +/** Under `/api`. A client connects here naming the library it is showing. */ +export const PUSH_ROUTE = "/push"; +export const PUSH_LIBRARY_PARAM = "library"; diff --git a/src/backend/features/live/notify.ts b/src/backend/features/push/notify.ts similarity index 58% rename from src/backend/features/live/notify.ts rename to src/backend/features/push/notify.ts index c0b0b54b3..1a94a83a5 100644 --- a/src/backend/features/live/notify.ts +++ b/src/backend/features/push/notify.ts @@ -2,15 +2,15 @@ import type { AppBindings } from "../../lib/context"; import type { LibraryId } from "../library/library-id"; import type { JobStatus } from "../load/contract"; -import { type LiveMessage, LiveMessageType } from "./contract"; +import { type PushMessage, PushType, type ThumbnailPush } from "./contract"; +import { getPushHub } from "./push-hub"; async function broadcast( env: AppBindings, - message: LiveMessage, - libraryId?: LibraryId + message: PushMessage ): Promise { try { - await env.LIVE_UPDATES.getByName("all").broadcast(message, libraryId); + await getPushHub(env).broadcast(message); } catch (error) { console.error(`Failed to push ${message.type}`, error); } @@ -22,11 +22,7 @@ export function pushJobStatus( libraryId: LibraryId, status: JobStatus ): Promise { - return broadcast( - env, - { type: LiveMessageType.JOBS, libraryId, status }, - libraryId - ); + return broadcast(env, { type: PushType.JOBS, libraryId, status }); } /** Tells a library's viewers to move to its new cache version. */ @@ -34,19 +30,12 @@ export function pushLibraryChanged( env: AppBindings, libraryId: LibraryId ): Promise { - return broadcast( - env, - { type: LiveMessageType.LIBRARY, libraryId }, - libraryId - ); + return broadcast(env, { type: PushType.LIBRARY, libraryId }); } export function pushThumbnailRendered( env: AppBindings, - subject: Omit< - Extract, - "type" - > + thumbnail: Omit ): Promise { - return broadcast(env, { type: LiveMessageType.THUMBNAIL, ...subject }); + return broadcast(env, { type: PushType.THUMBNAIL, ...thumbnail }); } diff --git a/src/backend/features/push/push-hub.ts b/src/backend/features/push/push-hub.ts new file mode 100644 index 000000000..23927b8d1 --- /dev/null +++ b/src/backend/features/push/push-hub.ts @@ -0,0 +1,46 @@ +/** + * Holds every client's WebSocket and relays pushes. One instance for the app, + * since pushes are few and small. Sockets hibernate, so idle clients cost + * nothing, and are tagged with the library they show. + */ +import { DurableObject } from "cloudflare:workers"; +import type { AppBindings } from "../../lib/context"; +import { LibraryId } from "../library/library-id"; +import { PUSH_LIBRARY_PARAM, type PushMessage, PushType } from "./contract"; + +export class PushHub extends DurableObject { + fetch(request: Request): Response { + const libraryId = new URL(request.url).searchParams.get( + PUSH_LIBRARY_PARAM + ); + const [client, server] = Object.values(new WebSocketPair()); + this.ctx.acceptWebSocket( + server, + isLibraryId(libraryId) ? [libraryId] : [] + ); + return new Response(null, { status: 101, webSocket: client }); + } + + /** A library's message to the clients showing it; any other to every client. */ + broadcast(message: PushMessage): void { + const tag = + message.type === PushType.THUMBNAIL ? undefined : message.libraryId; + const data = JSON.stringify(message); + for (const socket of this.ctx.getWebSockets(tag)) { + try { + socket.send(data); + } catch { + // Closing already; its client reconnects and resyncs. + } + } + } +} + +function isLibraryId(value: string | null): value is LibraryId { + return Object.values(LibraryId).includes(value); +} + +/** The one instance every client connects to. */ +export function getPushHub(env: AppBindings) { + return env.PUSH_HUB.getByName("all"); +} diff --git a/src/backend/features/live/live-updates.worker.test.ts b/src/backend/features/push/push-hub.worker.test.ts similarity index 74% rename from src/backend/features/live/live-updates.worker.test.ts rename to src/backend/features/push/push-hub.worker.test.ts index 044f45639..28eec7163 100644 --- a/src/backend/features/live/live-updates.worker.test.ts +++ b/src/backend/features/push/push-hub.worker.test.ts @@ -2,12 +2,13 @@ import { env } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; import { createTestApp } from "../../../__test_utils__"; import { LibraryId } from "../library/library-id"; -import { LIVE_PATH, type LiveMessage, LiveMessageType } from "./contract"; +import { PUSH_ROUTE, type PushMessage, PushType } from "./contract"; +import { getPushHub } from "./push-hub"; /** A client connected for `libraryId`, collecting what it is sent. */ async function connect(libraryId: LibraryId) { const res = await createTestApp().request( - `${LIVE_PATH}?library=${libraryId}`, + `/api${PUSH_ROUTE}?library=${libraryId}`, { headers: { Upgrade: "websocket" } }, env ); @@ -17,9 +18,9 @@ async function connect(libraryId: LibraryId) { throw new Error("No WebSocket in the upgrade response"); } socket.accept(); - const received: LiveMessage[] = []; + const received: PushMessage[] = []; socket.addEventListener("message", (event) => { - received.push(JSON.parse(event.data as string) as LiveMessage); + received.push(JSON.parse(event.data as string) as PushMessage); }); return { socket, received }; } @@ -27,23 +28,21 @@ async function connect(libraryId: LibraryId) { /** Lets a message cross from the Durable Object to its sockets. */ const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); -const stub = () => env.LIVE_UPDATES.getByName("all"); - -describe("live updates", () => { +describe("the push hub", () => { it("wants a WebSocket upgrade", async () => { - const res = await createTestApp().request(LIVE_PATH, {}, env); + const res = await createTestApp().request(`/api${PUSH_ROUTE}`, {}, env); expect(res.status).toBe(426); }); it("sends a library's messages to its viewers alone", async () => { const frc = await connect(LibraryId.FRC_DESIGN_LIB); const ftc = await connect(LibraryId.FTC_DESIGN_LIB); - const message: LiveMessage = { - type: LiveMessageType.LIBRARY, + const message: PushMessage = { + type: PushType.LIBRARY, libraryId: LibraryId.FRC_DESIGN_LIB }; - await stub().broadcast(message, LibraryId.FRC_DESIGN_LIB); + await getPushHub(env).broadcast(message); await settle(); expect(frc.received).toEqual([message]); @@ -56,13 +55,13 @@ describe("live updates", () => { const frc = await connect(LibraryId.FRC_DESIGN_LIB); const ftc = await connect(LibraryId.FTC_DESIGN_LIB); - const message: LiveMessage = { - type: LiveMessageType.THUMBNAIL, + const message: PushMessage = { + type: PushType.THUMBNAIL, elementId: "e1", microversionId: "mv1", configurationKey: "" }; - await stub().broadcast(message); + await getPushHub(env).broadcast(message); await settle(); expect(frc.received).toEqual([message]); diff --git a/src/backend/features/live/routes.ts b/src/backend/features/push/routes.ts similarity index 56% rename from src/backend/features/live/routes.ts rename to src/backend/features/push/routes.ts index 79c8e315d..08e89a7e0 100644 --- a/src/backend/features/live/routes.ts +++ b/src/backend/features/push/routes.ts @@ -1,17 +1,18 @@ import { HttpStatus } from "http-status-ts"; import { getApp } from "../../lib/context"; import { handledError } from "../../lib/api-error"; -import { LIVE_PATH } from "./contract"; +import { PUSH_ROUTE } from "./contract"; +import { getPushHub } from "./push-hub"; -export const liveRoutes = getApp(); +export const pushRoutes = getApp(); -/** GET /api/live?library=: open to anyone, since nothing pushed is private. */ -liveRoutes.get(LIVE_PATH.replace(/^\/api/, ""), (c) => { +/** GET /api/push?library=: open to anyone, since nothing pushed is private. */ +pushRoutes.get(PUSH_ROUTE, (c) => { if (c.req.header("Upgrade") !== "websocket") { throw handledError( "Expected a WebSocket upgrade", HttpStatus.UPGRADE_REQUIRED ); } - return c.env.LIVE_UPDATES.getByName("all").fetch(c.req.raw); + return getPushHub(c.env).fetch(c.req.raw); }); diff --git a/src/backend/features/thumbnails/render-workflow.ts b/src/backend/features/thumbnails/render-workflow.ts index 9cfabf6df..a6bf0e6cb 100644 --- a/src/backend/features/thumbnails/render-workflow.ts +++ b/src/backend/features/thumbnails/render-workflow.ts @@ -15,7 +15,7 @@ import { rateLimitDelay } from "../load/steps"; import { type ConfigurationKey } from "../configurations/contract"; import { ThumbnailSize } from "./contract"; import { putThumbnail } from "./store"; -import { pushThumbnailRendered } from "../live/notify"; +import { pushThumbnailRendered } from "../push/notify"; /** One stored size: where it goes, and what to ask Onshape for. */ export interface RenderTarget { diff --git a/src/backend/index.ts b/src/backend/index.ts index 1a2728f4e..eb6c6b93a 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,7 +1,7 @@ /** Workflow and Durable Object classes must be exported here for wrangler.jsonc's `class_name`s to resolve. */ export { LoadDocumentWorkflow } from "./features/load/workflows"; export { RenderThumbnailWorkflow } from "./features/thumbnails/render-workflow"; -export { LiveUpdates } from "./features/live/live-updates"; +export { PushHub } from "./features/push/push-hub"; import { createApp } from "./app"; import { productionAuth } from "./features/auth/request-auth"; import type { AppBindings } from "./lib/context"; diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 673a2ab5d..aa5ec5261 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -1,7 +1,7 @@ import { type Context, type MiddlewareHandler, Hono } from "hono"; import type { LoadDocumentParams } from "../features/load/jobs"; import type { RenderThumbnailParams } from "../features/thumbnails/render-workflow"; -import type { LiveUpdates } from "../features/live/live-updates"; +import type { PushHub } from "../features/push/push-hub"; import { type AccessLevel } from "../features/auth/access-level"; import type { LibraryId } from "../features/library/library-id"; import { type OAuthApi } from "./onshape/client"; @@ -16,8 +16,8 @@ export interface AppBindings { LOAD_DOCUMENT_WORKFLOW: Workflow; /** One instance per configuration being rendered; see `requestRender`. */ RENDER_THUMBNAIL_WORKFLOW: Workflow; - /** Relays pushes to open clients; see `features/live`. */ - LIVE_UPDATES: DurableObjectNamespace; + /** Relays pushes to open clients; see `features/push`. */ + PUSH_HUB: DurableObjectNamespace; /** The Onshape user id granted `AccessLevel.OWNER`; unset grants nobody. */ OWNER_USER_ID?: string; /** Dev-only: the access level granted, bypassing Onshape. */ diff --git a/src/frontend/features/thumbnails/render-wait.test.tsx b/src/frontend/features/thumbnails/render-wait.test.tsx index 444714cd6..17c0d8297 100644 --- a/src/frontend/features/thumbnails/render-wait.test.tsx +++ b/src/frontend/features/thumbnails/render-wait.test.tsx @@ -1,19 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - type LiveMessage, - LiveMessageType -} from "@backend/features/live/contract"; +import { type PushMessage, PushType } from "@backend/features/push/contract"; import { thumbnailUrl } from "@backend/features/thumbnails/keys"; import { ThumbnailSize } from "@backend/features/thumbnails/contract"; -const live = vi.hoisted(() => ({ - listeners: new Set<(message: LiveMessage) => void>() +const pushes = vi.hoisted(() => ({ + listeners: new Set<(message: PushMessage) => void>() })); -vi.mock("../../lib/live-updates", () => ({ - subscribeLiveMessages: (listener: (message: LiveMessage) => void) => { - live.listeners.add(listener); - return () => live.listeners.delete(listener); +vi.mock("../../lib/push-socket", () => ({ + subscribePushes: (listener: (message: PushMessage) => void) => { + pushes.listeners.add(listener); + return () => pushes.listeners.delete(listener); } })); @@ -27,9 +24,9 @@ const URL_WAITED_ON = thumbnailUrl({ }); const push = (configurationKey: string) => - live.listeners.forEach((listener) => + pushes.listeners.forEach((listener) => listener({ - type: LiveMessageType.THUMBNAIL, + type: PushType.THUMBNAIL, elementId: "e1", microversionId: "mv1", configurationKey diff --git a/src/frontend/features/thumbnails/render-wait.ts b/src/frontend/features/thumbnails/render-wait.ts index f17da7c65..4a3290ae5 100644 --- a/src/frontend/features/thumbnails/render-wait.ts +++ b/src/frontend/features/thumbnails/render-wait.ts @@ -1,14 +1,11 @@ /** The server pushes when a render lands, so a miss waits for that rather than polling. */ import { HttpStatus } from "http-status-ts"; import { DEFAULT_CONFIGURATION_KEY } from "@backend/features/configurations/contract"; -import { - type LiveMessage, - LiveMessageType -} from "@backend/features/live/contract"; +import { type PushMessage, PushType } from "@backend/features/push/contract"; import { parseThumbnailUrl } from "@backend/features/thumbnails/keys"; import { loadImage } from "../../lib/api-client"; import { ImageLoadError } from "../../lib/errors"; -import { subscribeLiveMessages } from "../../lib/live-updates"; +import { subscribePushes } from "../../lib/push-socket"; /** As long as `RenderThumbnailWorkflow` tries. */ const RENDER_TIMEOUT_MS = 60_000; @@ -22,8 +19,8 @@ export function isInvalidConfiguration(error: unknown): boolean { } /** Whether a push says the render `url` serves has landed. */ -export function isRenderOf(url: string, message: LiveMessage): boolean { - if (message.type !== LiveMessageType.THUMBNAIL) { +export function isRenderOf(url: string, message: PushMessage): boolean { + if (message.type !== PushType.THUMBNAIL) { return false; } const subject = parseThumbnailUrl(url); @@ -67,11 +64,8 @@ export async function loadRenderedImage( ): Promise { const deadline = Date.now() + RENDER_TIMEOUT_MS; // Subscribed before the first ask, so a push during it isn't missed. - const waiting = { - pushed: false, - wake: undefined as (() => void) | undefined - }; - const stopMessages = subscribeLiveMessages((message) => { + const waiting: { pushed: boolean; wake?: () => void } = { pushed: false }; + const stopMessages = subscribePushes((message) => { if (isRenderOf(url, message)) { waiting.pushed = true; waiting.wake?.(); diff --git a/src/frontend/lib/live-updates.ts b/src/frontend/lib/push-socket.ts similarity index 64% rename from src/frontend/lib/live-updates.ts rename to src/frontend/lib/push-socket.ts index 5e4c54f19..8d2bf4959 100644 --- a/src/frontend/lib/live-updates.ts +++ b/src/frontend/lib/push-socket.ts @@ -1,13 +1,13 @@ -/** The app's one WebSocket to the server's pushes. Reconnects with backoff; `live-sync.ts` catches up after. */ +/** The app's one WebSocket to the server's pushes. Reconnects with backoff; `push-sync.ts` catches up after. */ import { - LIVE_LIBRARY_PARAM, - LIVE_PATH, - type LiveMessage -} from "@backend/features/live/contract"; + PUSH_LIBRARY_PARAM, + PUSH_ROUTE, + type PushMessage +} from "@backend/features/push/contract"; import type { LibraryId } from "@backend/features/library/library-id"; -type MessageListener = (message: LiveMessage) => void; -type ConnectionListener = () => void; +type MessageListener = (message: PushMessage) => void; +type ConnectionListener = (connected: boolean) => void; const FIRST_RETRY_MS = 1_000; const LAST_RETRY_MS = 30_000; @@ -25,24 +25,26 @@ function setConnected(next: boolean): void { return; } connected = next; - connectionListeners.forEach((listener) => listener()); + connectionListeners.forEach((listener) => listener(next)); } -function liveUrl(libraryId: LibraryId): string { - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const query = new URLSearchParams({ [LIVE_LIBRARY_PARAM]: libraryId }); - return `${protocol}//${window.location.host}${LIVE_PATH}?${query.toString()}`; +function pushUrl(libraryId: LibraryId): string { + const url = new URL("/api" + PUSH_ROUTE, window.location.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set(PUSH_LIBRARY_PARAM, libraryId); + return url.href; } function open(libraryId: LibraryId): void { - const current = new WebSocket(liveUrl(libraryId)); + const current = new WebSocket(pushUrl(libraryId)); socket = current; current.onopen = () => { retryMs = FIRST_RETRY_MS; setConnected(true); }; current.onmessage = (event: MessageEvent) => { - const message = JSON.parse(event.data) as LiveMessage; + // Sent by our own server, in the contract's shape. + const message = JSON.parse(event.data) as PushMessage; messageListeners.forEach((listener) => listener(message)); }; current.onclose = () => { @@ -58,7 +60,7 @@ function open(libraryId: LibraryId): void { } /** Connects for `libraryId`, dropping any connection for another; returns a disconnect. */ -export function connectLiveUpdates(libraryId: LibraryId): () => void { +export function connectPushes(libraryId: LibraryId): () => void { open(libraryId); return () => { window.clearTimeout(retryTimer); @@ -69,18 +71,14 @@ export function connectLiveUpdates(libraryId: LibraryId): () => void { }; } -export function subscribeLiveMessages(listener: MessageListener): () => void { +export function subscribePushes(listener: MessageListener): () => void { messageListeners.add(listener); return () => messageListeners.delete(listener); } -export function subscribeLiveConnection( +export function subscribePushConnection( listener: ConnectionListener ): () => void { connectionListeners.add(listener); return () => connectionListeners.delete(listener); } - -export function isLiveConnected(): boolean { - return connected; -} diff --git a/src/frontend/lib/live-sync.ts b/src/frontend/lib/push-sync.ts similarity index 79% rename from src/frontend/lib/live-sync.ts rename to src/frontend/lib/push-sync.ts index e4efffae7..c3d1dd6db 100644 --- a/src/frontend/lib/live-sync.ts +++ b/src/frontend/lib/push-sync.ts @@ -1,24 +1,20 @@ /** Applies the server's pushes to the cache. Mounted once, by the app shell. */ import { useEffect, useRef } from "react"; -import { - type LiveMessage, - LiveMessageType -} from "@backend/features/live/contract"; +import { type PushMessage, PushType } from "@backend/features/push/contract"; import { hasEditorAccess } from "@backend/features/auth/access-level"; import { useAccessData } from "../features/auth/access-level"; import { isRenderOf } from "../features/thumbnails/render-wait"; import { useLibraryId } from "./library"; import { - connectLiveUpdates, - isLiveConnected, - subscribeLiveConnection, - subscribeLiveMessages -} from "./live-updates"; + connectPushes, + subscribePushConnection, + subscribePushes +} from "./push-socket"; import { queryClient } from "./query-client"; import { jobStatusQueryKey } from "./query-keys"; import { useRefreshLibrary } from "./refresh"; -export function useLiveSync(): void { +export function usePushSync(): void { const libraryId = useLibraryId(); const refreshLibrary = useRefreshLibrary(); const { signedIn, currentAccessLevel } = useAccessData(); @@ -26,12 +22,12 @@ export function useLiveSync(): void { const showsJobs = signedIn && hasEditorAccess(currentAccessLevel); const hasConnected = useRef(false); - useEffect(() => connectLiveUpdates(libraryId), [libraryId]); + useEffect(() => connectPushes(libraryId), [libraryId]); useEffect(() => { - const apply = (message: LiveMessage) => { + const apply = (message: PushMessage) => { switch (message.type) { - case LiveMessageType.JOBS: + case PushType.JOBS: if (showsJobs && message.libraryId === libraryId) { queryClient.setQueryData( jobStatusQueryKey(libraryId), @@ -39,12 +35,12 @@ export function useLiveSync(): void { ); } break; - case LiveMessageType.LIBRARY: + case PushType.LIBRARY: if (message.libraryId === libraryId) { void refreshLibrary(); } break; - case LiveMessageType.THUMBNAIL: + case PushType.THUMBNAIL: // Rows that took a miss; anything waiting on the render hears the push itself. void queryClient.refetchQueries({ predicate: (query) => { @@ -60,14 +56,14 @@ export function useLiveSync(): void { break; } }; - return subscribeLiveMessages(apply); + return subscribePushes(apply); }, [libraryId, refreshLibrary, showsJobs]); // Pushes during the outage are lost, so a reconnect refetches. useEffect( () => - subscribeLiveConnection(() => { - if (!isLiveConnected()) { + subscribePushConnection((connected) => { + if (!connected) { return; } if (hasConnected.current) { diff --git a/src/frontend/routes/app/route.tsx b/src/frontend/routes/app/route.tsx index 64f36f805..2578b71fa 100644 --- a/src/frontend/routes/app/route.tsx +++ b/src/frontend/routes/app/route.tsx @@ -24,7 +24,7 @@ import { AppNavbar } from "../../components/app-navbar"; import { ProgramSelect } from "../../features/library/components/program-select"; import { SectionLoading } from "../../components/app-notice"; import { useMessageListener } from "../../lib/messages"; -import { useLiveSync } from "../../lib/live-sync"; +import { usePushSync } from "../../lib/push-sync"; import { getUiState, updateUiState } from "../../lib/ui-state"; import { showSuccessToast } from "../../lib/notifications"; import { RootAppError } from "../../components/root-error"; @@ -81,7 +81,7 @@ function App() { const { ref: headerRef, height: headerHeight } = useElementSize(); useMessageListener(); - useLiveSync(); + usePushSync(); return ( diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 416de91dd..9a83c944c 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -14,14 +14,14 @@ interface __BaseEnv_Env { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - LIVE_UPDATES: DurableObjectNamespace; + PUSH_HUB: DurableObjectNamespace; LOAD_DOCUMENT_WORKFLOW: Workflow[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/backend/index"); - durableNamespaces: "LiveUpdates"; + durableNamespaces: "PushHub"; } interface CertEnv { KV: KVNamespace; @@ -36,7 +36,7 @@ declare namespace Cloudflare { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - LIVE_UPDATES: DurableObjectNamespace; + PUSH_HUB: DurableObjectNamespace; LOAD_DOCUMENT_WORKFLOW: Workflow[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } @@ -53,7 +53,7 @@ declare namespace Cloudflare { OAUTH_CLIENT_SECRET: string; SESSION_SECRET: string; VITE_ACCESS_LEVEL_OVERRIDE: string; - LIVE_UPDATES: DurableObjectNamespace; + PUSH_HUB: DurableObjectNamespace; LOAD_DOCUMENT_WORKFLOW: Workflow[0]['payload']>; RENDER_THUMBNAIL_WORKFLOW: Workflow[0]['payload']>; } diff --git a/wrangler.jsonc b/wrangler.jsonc index 4cc37c197..7c6441d79 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -67,7 +67,7 @@ // Inherited by every environment. v2 deletes the thumbnail render queue, // replaced by RenderThumbnailWorkflow; the object's stored queue goes with it. "durable_objects": { - "bindings": [{ "name": "LIVE_UPDATES", "class_name": "LiveUpdates" }] + "bindings": [{ "name": "PUSH_HUB", "class_name": "PushHub" }] }, // Daily, clearing thumbnails nothing shows any more; see the scheduled // handler in src/backend/index.ts. Inherited by every environment. @@ -86,6 +86,10 @@ { "tag": "v3", "new_sqlite_classes": ["LiveUpdates"] + }, + { + "tag": "v4", + "renamed_classes": [{ "from": "LiveUpdates", "to": "PushHub" }] } ], /** @@ -147,7 +151,7 @@ ], "durable_objects": { "bindings": [ - { "name": "LIVE_UPDATES", "class_name": "LiveUpdates" } + { "name": "PUSH_HUB", "class_name": "PushHub" } ] }, "vars": { @@ -199,7 +203,7 @@ ], "durable_objects": { "bindings": [ - { "name": "LIVE_UPDATES", "class_name": "LiveUpdates" } + { "name": "PUSH_HUB", "class_name": "PushHub" } ] }, "vars": { From 5c3c19aec61e5f5725a3e0937b5377cca418ace7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:25:27 +0000 Subject: [PATCH 45/88] Tidy load routes and results - The reload body names its flag forceReload, like the load params. - The forced reload checks the owner through requireOwner, which the owner middleware now shares. - A load reports only whether it wrote to its group; nothing read the rest. - The units webhook route is registered under /api like the others. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/features/auth/guards.ts | 12 ++-- src/backend/features/load/routes.ts | 17 ++---- .../features/load/routes.worker.test.ts | 4 +- src/backend/features/load/workflows.ts | 58 +++++++------------ src/backend/features/webhooks/routes.ts | 4 +- src/backend/features/webhooks/transient.ts | 5 +- src/frontend/features/library/queries.ts | 2 +- 7 files changed, 43 insertions(+), 59 deletions(-) diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index 303194dde..08028719c 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -76,10 +76,7 @@ export const requireAdminMiddleware = requireLibraryAccess( ); /** The owner's access is the same everywhere, so any library answers. */ -export const requireOwnerMiddleware: MiddlewareHandler = async ( - c, - next -) => { +export async function requireOwner(c: AppContext): Promise { await requireSignIn(c); const level = await c.var.getAccessLevel( c.req.param("libraryId") ? getLibraryParam(c) : DEFAULT_LIBRARY @@ -87,5 +84,12 @@ export const requireOwnerMiddleware: MiddlewareHandler = async ( if (level !== AccessLevel.OWNER) { throw forbiddenError("Only the owner can use this functionality"); } +} + +export const requireOwnerMiddleware: MiddlewareHandler = async ( + c, + next +) => { + await requireOwner(c); await next(); }; diff --git a/src/backend/features/load/routes.ts b/src/backend/features/load/routes.ts index 6a385470f..1c4fad208 100644 --- a/src/backend/features/load/routes.ts +++ b/src/backend/features/load/routes.ts @@ -1,13 +1,11 @@ import { eq } from "drizzle-orm"; import * as z from "zod"; import { getApp } from "../../lib/context"; -import { forbiddenError } from "../../lib/api-error"; import { getDb } from "../../db/client"; import { groups, libraries } from "../../db/schema"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { validate } from "../../lib/validate"; -import { AccessLevel } from "../auth/access-level"; -import { requireAdminMiddleware } from "../auth/guards"; +import { requireAdminMiddleware, requireOwner } from "../auth/guards"; import { getSessionId } from "../auth/session"; import { approveHeldLoads, requestLoads } from "./jobs"; import type { @@ -21,7 +19,7 @@ export const loadRoutes = getApp(); const reloadBody = z.object({ /** Reloads documents whose version has not changed, too. */ - force: z.boolean() + forceReload: z.boolean() }); /** @@ -35,12 +33,9 @@ loadRoutes.post( validate("json", reloadBody), async (c) => { const libraryId = getLibraryParam(c); - const { force } = c.req.valid("json"); - if ( - force && - (await c.var.getAccessLevel(libraryId)) !== AccessLevel.OWNER - ) { - throw forbiddenError("Only the owner can reload all documents"); + const { forceReload } = c.req.valid("json"); + if (forceReload) { + await requireOwner(c); } const rows = await getDb(c.env.DB) .select({ groupId: groups.id, libraryId: groups.libraryId }) @@ -53,7 +48,7 @@ loadRoutes.post( rows.map((row) => ({ ...row, sessionId, - forceReload: force, + forceReload, origin })) ); diff --git a/src/backend/features/load/routes.worker.test.ts b/src/backend/features/load/routes.worker.test.ts index fb29aadc5..0b8f246ef 100644 --- a/src/backend/features/load/routes.worker.test.ts +++ b/src/backend/features/load/routes.worker.test.ts @@ -14,8 +14,8 @@ import * as Jobs from "./jobs"; const db = getDb(env.DB); const PATH = `/api/reload/library/${LibraryId.FRC_DESIGN_LIB}`; -function reload(accessLevel: AccessLevel, force: boolean) { - const init = jsonRequest("POST", { force }); +function reload(accessLevel: AccessLevel, forceReload: boolean) { + const init = jsonRequest("POST", { forceReload }); return createTestApp({ accessLevel }).request( PATH, { diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index 6b0c85ac6..ff70c655c 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -45,16 +45,6 @@ import { loadGroup } from "./load-group"; import { ONSHAPE_STEP_RETRIES } from "./steps"; import { ensureWebhook } from "../webhooks/registration"; -/** What a load did with its group. */ -export type LoadResult = - | { status: "skipped" | "failed" | "gone" } - | { - status: "loaded"; - loadedElements: number; - deletedElements: number; - failedElements: number; - }; - /** Loads one group's document when its version moved, or on a forced reload. One per group at a time; see `jobs.ts`. */ export class LoadDocumentWorkflow extends WorkflowEntrypoint< AppBindings, @@ -63,7 +53,7 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< async run( event: WorkflowEvent, step: WorkflowStep - ): Promise { + ): Promise { const params = event.payload; const ctx = createLoadContext( this.env, @@ -71,15 +61,12 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< params.sessionId, step ); - // Anything but a skip wrote to the group: a failure flags it. - let result: LoadResult = { status: "failed" }; + // A throw past loadDocument's own handling may have written too. + let changed = true; try { - result = await loadDocument(ctx, params); - return result; + changed = await loadDocument(ctx, params); } finally { // Always, so whatever queued behind this load starts. - const changed = - result.status === "loaded" || result.status === "failed"; await step.do("finish", () => finishLoad(this.env, params, changed) ); @@ -87,10 +74,11 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< } } +/** Whether it wrote to the group, which a failure does by flagging it. */ async function loadDocument( ctx: LoadContext, params: LoadDocumentParams -): Promise { +): Promise { const { groupId, libraryId, forceReload } = params; const stored = await ctx.step.do("read-group", () => getDb(ctx.env.DB) @@ -106,10 +94,10 @@ async function loadDocument( ); // Deleted since the load was asked for. if (!stored) { - return { status: "gone" }; + return false; } - let result: LoadResult; + let changed = true; try { const target = await resolveGroupTarget(ctx, { libraryId, @@ -122,25 +110,22 @@ async function loadDocument( !forceReload && !hasFailedLoad(stored.buildIssues) ) { - result = { status: "skipped" }; + changed = false; } else { if (isNewVersion && params.awaitApproval && !forceReload) { await waitForApproval(ctx, params); } - result = { - status: "loaded", - ...(await loadGroup( - ctx, - target, - forceReload, - stored.thumbnailWorkspaceId - ? { - workspaceId: stored.thumbnailWorkspaceId, - versionId: stored.versionId - } - : undefined - )) - }; + await loadGroup( + ctx, + target, + forceReload, + stored.thumbnailWorkspaceId + ? { + workspaceId: stored.thumbnailWorkspaceId, + versionId: stored.versionId + } + : undefined + ); } } catch (error) { // The row records only that it failed, so this is the only record of why. @@ -148,7 +133,6 @@ async function loadDocument( await ctx.step.do("flag-failed", () => flagFailedLoads(ctx.env, [groupId]) ); - result = { status: "failed" }; } // After the load, so an unreadable document gets no webhook. Not fatal: the @@ -172,7 +156,7 @@ async function loadDocument( error ); } - return result; + return changed; } /** Loads anyway once nobody has approved it for `APPROVAL_TIMEOUT`. */ diff --git a/src/backend/features/webhooks/routes.ts b/src/backend/features/webhooks/routes.ts index 8e305ae39..9c92670b9 100644 --- a/src/backend/features/webhooks/routes.ts +++ b/src/backend/features/webhooks/routes.ts @@ -14,7 +14,7 @@ import { RECEIVE_PATH, WebhookEvent } from "./registration"; -import { readUnitsDelivery, UNITS_RECEIVE_PATH } from "./transient"; +import { readUnitsDelivery, UNITS_WEBHOOK_ROUTE } from "./transient"; import { forgetUnitInfo } from "../configurations/units"; export const webhookRoutes = getApp(); @@ -52,7 +52,7 @@ webhookRoutes.post(RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { }); /** POST /api/webhooks/units?documentId=&workspaceId= */ -webhookRoutes.post(UNITS_RECEIVE_PATH.replace(/^\/api/, ""), async (c) => { +webhookRoutes.post(UNITS_WEBHOOK_ROUTE, async (c) => { const workspace = readUnitsDelivery(c.req.query()); if (!workspace) { throw forbiddenError("Unrecognized webhook"); diff --git a/src/backend/features/webhooks/transient.ts b/src/backend/features/webhooks/transient.ts index 1d736b749..36831b006 100644 --- a/src/backend/features/webhooks/transient.ts +++ b/src/backend/features/webhooks/transient.ts @@ -8,7 +8,8 @@ import type { OnshapeApi } from "../../lib/onshape/client"; import { createWebhook } from "../../lib/onshape/endpoints/webhooks"; import type { InstancePath } from "../../lib/onshape/path"; -export const UNITS_RECEIVE_PATH = "/api/webhooks/units"; +/** Under `/api`. */ +export const UNITS_WEBHOOK_ROUTE = "/webhooks/units"; const UPDATE_WORKSPACE_UNITS = "onshape.model.lifecycle.updateworkspaceunits"; @@ -18,7 +19,7 @@ export async function watchWorkspaceUnits( workspace: InstancePath, origin: string ): Promise { - const url = new URL(UNITS_RECEIVE_PATH, origin); + const url = new URL("/api" + UNITS_WEBHOOK_ROUTE, origin); url.searchParams.set("documentId", workspace.documentId); url.searchParams.set("workspaceId", workspace.instanceId); await createWebhook(onshapeApi, { diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 3091846bd..82cf93a37 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -183,7 +183,7 @@ export function useReloadMutation(all: boolean) { mutationKey: ["reload", libraryId], mutationFn: (): Promise => apiPost("/reload" + toLibraryPath(libraryId), { - body: { force: all } + body: { forceReload: all } }), onError: getAppErrorHandler("Failed to reload documents!"), onSuccess: (data) => { From 44f48e69ecca788bddd7c9ffdc3a9d8caec65d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:48:07 +0000 Subject: [PATCH 46/88] Fold this branch's migrations into one The D1 migrations after 0003 become a single 0004, generated from the schema, and the Durable Object migrations after v1 a single v2. The load job's rerun flag is named rerun_force_reload, like the reload it reruns. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- ...ooks_jobs.sql => 0004_library_loading.sql} | 17 +- drizzle/0004_thumbnail_workspace.sql | 1 - drizzle/0005_excluded_parameters.sql | 1 - drizzle/0007_admin_team_column.sql | 14 - drizzle/0008_version_approval.sql | 2 - drizzle/meta/0004_snapshot.json | 163 +- drizzle/meta/0005_snapshot.json | 1195 -------------- drizzle/meta/0006_snapshot.json | 1370 ----------------- drizzle/meta/0007_snapshot.json | 1332 ---------------- drizzle/meta/0008_snapshot.json | 1348 ---------------- drizzle/meta/_journal.json | 32 +- src/backend/db/schema.ts | 2 +- src/backend/features/load/jobs.ts | 6 +- src/backend/features/load/jobs.worker.test.ts | 5 +- wrangler.jsonc | 23 +- 15 files changed, 185 insertions(+), 5326 deletions(-) rename drizzle/{0006_admin_teams_webhooks_jobs.sql => 0004_library_loading.sql} (56%) delete mode 100644 drizzle/0004_thumbnail_workspace.sql delete mode 100644 drizzle/0005_excluded_parameters.sql delete mode 100644 drizzle/0007_admin_team_column.sql delete mode 100644 drizzle/0008_version_approval.sql delete mode 100644 drizzle/meta/0005_snapshot.json delete mode 100644 drizzle/meta/0006_snapshot.json delete mode 100644 drizzle/meta/0007_snapshot.json delete mode 100644 drizzle/meta/0008_snapshot.json diff --git a/drizzle/0006_admin_teams_webhooks_jobs.sql b/drizzle/0004_library_loading.sql similarity index 56% rename from drizzle/0006_admin_teams_webhooks_jobs.sql rename to drizzle/0004_library_loading.sql index a966da856..c69b0785d 100644 --- a/drizzle/0006_admin_teams_webhooks_jobs.sql +++ b/drizzle/0004_library_loading.sql @@ -1,18 +1,11 @@ -CREATE TABLE `admin_team_members` ( - `library_id` text NOT NULL, - `user_id` text NOT NULL, - `is_team_admin` integer NOT NULL, - PRIMARY KEY(`library_id`, `user_id`), - FOREIGN KEY (`library_id`) REFERENCES `libraries`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint CREATE TABLE `load_jobs` ( `group_id` text PRIMARY KEY NOT NULL, `library_id` text NOT NULL, `instance_id` text, `started_at` integer NOT NULL, `rerun` integer DEFAULT false NOT NULL, - `rerun_force` integer DEFAULT false NOT NULL, + `rerun_force_reload` integer DEFAULT false NOT NULL, + `awaiting_approval` integer DEFAULT false NOT NULL, FOREIGN KEY (`group_id`) REFERENCES `groups`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`library_id`) REFERENCES `libraries`(`id`) ON UPDATE no action ON DELETE no action ); @@ -26,4 +19,8 @@ CREATE TABLE `onshape_webhooks` ( ); --> statement-breakpoint CREATE UNIQUE INDEX `onshape_webhooks_token_unique` ON `onshape_webhooks` (`token`);--> statement-breakpoint -ALTER TABLE `libraries` ADD `admin_team_id` text; \ No newline at end of file +ALTER TABLE `groups` ADD `thumbnail_workspace_id` text;--> statement-breakpoint +ALTER TABLE `insertables` ADD `excluded_parameter_ids` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +ALTER TABLE `libraries` ADD `admin_team_id` text;--> statement-breakpoint +ALTER TABLE `libraries` ADD `admin_team` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +ALTER TABLE `libraries` ADD `approve_versions` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/0004_thumbnail_workspace.sql b/drizzle/0004_thumbnail_workspace.sql deleted file mode 100644 index de79a1416..000000000 --- a/drizzle/0004_thumbnail_workspace.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `groups` ADD `thumbnail_workspace_id` text; \ No newline at end of file diff --git a/drizzle/0005_excluded_parameters.sql b/drizzle/0005_excluded_parameters.sql deleted file mode 100644 index 7e4a571b6..000000000 --- a/drizzle/0005_excluded_parameters.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `insertables` ADD `excluded_parameter_ids` text DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/drizzle/0007_admin_team_column.sql b/drizzle/0007_admin_team_column.sql deleted file mode 100644 index d6431cbc7..000000000 --- a/drizzle/0007_admin_team_column.sql +++ /dev/null @@ -1,14 +0,0 @@ -ALTER TABLE `libraries` ADD `admin_team` text DEFAULT '[]' NOT NULL;--> statement-breakpoint -UPDATE `libraries` SET `admin_team` = ( - SELECT json_group_array(json_object( - 'userId', `user_id`, - 'isTeamAdmin', json(CASE WHEN `is_team_admin` THEN 'true' ELSE 'false' END) - )) - FROM `admin_team_members` - WHERE `admin_team_members`.`library_id` = `libraries`.`id` -) -WHERE EXISTS ( - SELECT 1 FROM `admin_team_members` - WHERE `admin_team_members`.`library_id` = `libraries`.`id` -);--> statement-breakpoint -DROP TABLE `admin_team_members`; diff --git a/drizzle/0008_version_approval.sql b/drizzle/0008_version_approval.sql deleted file mode 100644 index b63b30894..000000000 --- a/drizzle/0008_version_approval.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE `libraries` ADD `approve_versions` integer DEFAULT false NOT NULL;--> statement-breakpoint -ALTER TABLE `load_jobs` ADD `awaiting_approval` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json index 9d34ce3cc..baee82559 100644 --- a/drizzle/meta/0004_snapshot.json +++ b/drizzle/meta/0004_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "eccc3c71-d808-4e4c-9ece-58a9ad2b81ff", + "id": "69111642-5d7d-4bf2-9c1b-12aa7afd32be", "prevId": "d8945a24-5643-4675-bf1f-b32cff1dbea2", "tables": { "configurations": { @@ -352,6 +352,14 @@ "autoincrement": false, "default": false }, + "excluded_parameter_ids": { + "name": "excluded_parameter_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, "version_id": { "name": "version_id", "type": "text", @@ -468,6 +476,29 @@ "notNull": true, "autoincrement": false, "default": 0 + }, + "admin_team_id": { + "name": "admin_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_team": { + "name": "admin_team", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "approve_versions": { + "name": "approve_versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false } }, "indexes": {}, @@ -476,6 +507,136 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "load_jobs": { + "name": "load_jobs", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rerun": { + "name": "rerun", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rerun_force_reload": { + "name": "rerun_force_reload", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awaiting_approval": { + "name": "awaiting_approval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "load_jobs_group_id_groups_id_fk": { + "name": "load_jobs_group_id_groups_id_fk", + "tableFrom": "load_jobs", + "tableTo": "groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "load_jobs_library_id_libraries_id_fk": { + "name": "load_jobs_library_id_libraries_id_fk", + "tableFrom": "load_jobs", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onshape_webhooks": { + "name": "onshape_webhooks", + "columns": { + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "onshape_webhooks_token_unique": { + "name": "onshape_webhooks_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onshape_webhooks_subject_subject_id_pk": { + "columns": ["subject", "subject_id"], + "name": "onshape_webhooks_subject_subject_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "users": { "name": "users", "columns": { diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json deleted file mode 100644 index d592881d4..000000000 --- a/drizzle/meta/0005_snapshot.json +++ /dev/null @@ -1,1195 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "04f9ddbb-bdbb-437a-bbc7-4933163255ee", - "prevId": "eccc3c71-d808-4e4c-9ece-58a9ad2b81ff", - "tables": { - "configurations": { - "name": "configurations", - "columns": { - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "parameters": { - "name": "parameters", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "records": { - "name": "records", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - } - }, - "indexes": {}, - "foreignKeys": { - "configurations_insertable_id_insertables_id_fk": { - "name": "configurations_insertable_id_insertables_id_fk", - "tableFrom": "configurations", - "tableTo": "insertables", - "columnsFrom": ["insertable_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "favorites": { - "name": "favorites", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "default_selection": { - "name": "default_selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "favorites_user_id_library_id_insertable_id_unique": { - "name": "favorites_user_id_library_id_insertable_id_unique", - "columns": ["user_id", "library_id", "insertable_id"], - "isUnique": true - } - }, - "foreignKeys": { - "favorites_user_id_users_id_fk": { - "name": "favorites_user_id_users_id_fk", - "tableFrom": "favorites", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "favorites_library_id_libraries_id_fk": { - "name": "favorites_library_id_libraries_id_fk", - "tableFrom": "favorites", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "favorites_insertable_id_insertables_id_fk": { - "name": "favorites_insertable_id_insertables_id_fk", - "tableFrom": "favorites", - "tableTo": "insertables", - "columnsFrom": ["insertable_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "groups": { - "name": "groups", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_id": { - "name": "version_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_created_at": { - "name": "version_created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "thumbnail_workspace_id": { - "name": "thumbnail_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_alphabetically": { - "name": "sort_alphabetically", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "small_thumbnail_url": { - "name": "small_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "large_thumbnail_url": { - "name": "large_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "build_issues": { - "name": "build_issues", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "last_loaded_at": { - "name": "last_loaded_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "groups_document_id_library_id_unique": { - "name": "groups_document_id_library_id_unique", - "columns": ["document_id", "library_id"], - "isUnique": true - } - }, - "foreignKeys": { - "groups_library_id_libraries_id_fk": { - "name": "groups_library_id_libraries_id_fk", - "tableFrom": "groups", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "insertables": { - "name": "insertables", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_type": { - "name": "element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "microversion_id": { - "name": "microversion_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_visible": { - "name": "is_visible", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "is_open_composite": { - "name": "is_open_composite", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "supports_fasten": { - "name": "supports_fasten", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "index_configurations": { - "name": "index_configurations", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "excluded_parameter_ids": { - "name": "excluded_parameter_ids", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "version_id": { - "name": "version_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_created_at": { - "name": "version_created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "vendors": { - "name": "vendors", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "small_thumbnail_url": { - "name": "small_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "large_thumbnail_url": { - "name": "large_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fasten_info": { - "name": "fasten_info", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "part_metadata": { - "name": "part_metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "build_issues": { - "name": "build_issues", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "last_loaded_at": { - "name": "last_loaded_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "insertables_group_id_groups_id_fk": { - "name": "insertables_group_id_groups_id_fk", - "tableFrom": "insertables", - "tableTo": "groups", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "insertables_library_id_libraries_id_fk": { - "name": "insertables_library_id_libraries_id_fk", - "tableFrom": "insertables", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "libraries": { - "name": "libraries", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "cache_version": { - "name": "cache_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "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'" - }, - "tab_id": { - "name": "tab_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "users_library_id_libraries_id_fk": { - "name": "users_library_id_libraries_id_fk", - "tableFrom": "users", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_configuration_metrics": { - "name": "daily_configuration_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "parameter_id": { - "name": "parameter_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "daily_configuration_metrics_day_idx": { - "name": "daily_configuration_metrics_day_idx", - "columns": ["library_id", "day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { - "columns": [ - "library_id", - "element_id", - "parameter_id", - "value", - "day" - ], - "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_insertable_metrics": { - "name": "daily_insertable_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "daily_insertable_metrics_day_idx": { - "name": "daily_insertable_metrics_day_idx", - "columns": ["library_id", "day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { - "columns": [ - "library_id", - "element_id", - "day", - "target_element_type" - ], - "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_insertable_users": { - "name": "daily_insertable_users", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_insertable_users_library_id_element_id_day_user_id_pk": { - "columns": ["library_id", "element_id", "day", "user_id"], - "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_metrics": { - "name": "daily_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "favorite_count": { - "name": "favorite_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "fasten_count": { - "name": "fasten_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "quick_insert_count": { - "name": "quick_insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_metrics_day_library_id_type_pk": { - "columns": ["day", "library_id", "type"], - "name": "daily_metrics_day_library_id_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_source_metrics": { - "name": "daily_source_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "quick_insert_count": { - "name": "quick_insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_source_metrics_day_library_id_source_pk": { - "columns": ["day", "library_id", "source"], - "name": "daily_source_metrics_day_library_id_source_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_target_metrics": { - "name": "daily_target_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_target_metrics_day_library_id_target_element_type_pk": { - "columns": ["day", "library_id", "target_element_type"], - "name": "daily_target_metrics_day_library_id_target_element_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_user_activity": { - "name": "daily_user_activity", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "daily_user_activity_day_idx": { - "name": "daily_user_activity_day_idx", - "columns": ["day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_user_activity_day_library_id_user_id_pk": { - "columns": ["day", "library_id", "user_id"], - "name": "daily_user_activity_day_library_id_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "events": { - "name": "events", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema_version": { - "name": "schema_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "instance_type": { - "name": "instance_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "selection": { - "name": "selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_favorite": { - "name": "is_favorite", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_quick_insert": { - "name": "is_quick_insert", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fasten": { - "name": "fasten", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "events_day_idx": { - "name": "events_day_idx", - "columns": ["day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "insertable_stats": { - "name": "insertable_stats", - "columns": { - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insert_count": { - "name": "insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "first_inserted_at": { - "name": "first_inserted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_inserted_at": { - "name": "last_inserted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "insertable_stats_count_idx": { - "name": "insertable_stats_count_idx", - "columns": ["library_id", "insert_count"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "insertable_stats_library_id_element_id_pk": { - "columns": ["library_id", "element_id"], - "name": "insertable_stats_library_id_element_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user_stats": { - "name": "user_stats", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insert_count": { - "name": "insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "open_count": { - "name": "open_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "first_seen_at": { - "name": "first_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "user_stats_user_id_library_id_pk": { - "columns": ["user_id", "library_id"], - "name": "user_stats_user_id_library_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json deleted file mode 100644 index 15f86d88f..000000000 --- a/drizzle/meta/0006_snapshot.json +++ /dev/null @@ -1,1370 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "cac6acf8-d0f8-448b-a93f-5ae1ec06ac95", - "prevId": "04f9ddbb-bdbb-437a-bbc7-4933163255ee", - "tables": { - "admin_team_members": { - "name": "admin_team_members", - "columns": { - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_team_admin": { - "name": "is_team_admin", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "admin_team_members_library_id_libraries_id_fk": { - "name": "admin_team_members_library_id_libraries_id_fk", - "tableFrom": "admin_team_members", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "admin_team_members_library_id_user_id_pk": { - "columns": ["library_id", "user_id"], - "name": "admin_team_members_library_id_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "configurations": { - "name": "configurations", - "columns": { - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "parameters": { - "name": "parameters", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "records": { - "name": "records", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - } - }, - "indexes": {}, - "foreignKeys": { - "configurations_insertable_id_insertables_id_fk": { - "name": "configurations_insertable_id_insertables_id_fk", - "tableFrom": "configurations", - "tableTo": "insertables", - "columnsFrom": ["insertable_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "favorites": { - "name": "favorites", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "default_selection": { - "name": "default_selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "favorites_user_id_library_id_insertable_id_unique": { - "name": "favorites_user_id_library_id_insertable_id_unique", - "columns": ["user_id", "library_id", "insertable_id"], - "isUnique": true - } - }, - "foreignKeys": { - "favorites_user_id_users_id_fk": { - "name": "favorites_user_id_users_id_fk", - "tableFrom": "favorites", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "favorites_library_id_libraries_id_fk": { - "name": "favorites_library_id_libraries_id_fk", - "tableFrom": "favorites", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "favorites_insertable_id_insertables_id_fk": { - "name": "favorites_insertable_id_insertables_id_fk", - "tableFrom": "favorites", - "tableTo": "insertables", - "columnsFrom": ["insertable_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "groups": { - "name": "groups", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_id": { - "name": "version_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_created_at": { - "name": "version_created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "thumbnail_workspace_id": { - "name": "thumbnail_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_alphabetically": { - "name": "sort_alphabetically", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "small_thumbnail_url": { - "name": "small_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "large_thumbnail_url": { - "name": "large_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "build_issues": { - "name": "build_issues", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "last_loaded_at": { - "name": "last_loaded_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "groups_document_id_library_id_unique": { - "name": "groups_document_id_library_id_unique", - "columns": ["document_id", "library_id"], - "isUnique": true - } - }, - "foreignKeys": { - "groups_library_id_libraries_id_fk": { - "name": "groups_library_id_libraries_id_fk", - "tableFrom": "groups", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "insertables": { - "name": "insertables", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_type": { - "name": "element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "microversion_id": { - "name": "microversion_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_visible": { - "name": "is_visible", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "is_open_composite": { - "name": "is_open_composite", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "supports_fasten": { - "name": "supports_fasten", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "index_configurations": { - "name": "index_configurations", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "excluded_parameter_ids": { - "name": "excluded_parameter_ids", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "version_id": { - "name": "version_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_created_at": { - "name": "version_created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "vendors": { - "name": "vendors", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "small_thumbnail_url": { - "name": "small_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "large_thumbnail_url": { - "name": "large_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fasten_info": { - "name": "fasten_info", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "part_metadata": { - "name": "part_metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "build_issues": { - "name": "build_issues", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "last_loaded_at": { - "name": "last_loaded_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "insertables_group_id_groups_id_fk": { - "name": "insertables_group_id_groups_id_fk", - "tableFrom": "insertables", - "tableTo": "groups", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "insertables_library_id_libraries_id_fk": { - "name": "insertables_library_id_libraries_id_fk", - "tableFrom": "insertables", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "libraries": { - "name": "libraries", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "cache_version": { - "name": "cache_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "admin_team_id": { - "name": "admin_team_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "load_jobs": { - "name": "load_jobs", - "columns": { - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "started_at": { - "name": "started_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rerun": { - "name": "rerun", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "rerun_force": { - "name": "rerun_force", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - } - }, - "indexes": {}, - "foreignKeys": { - "load_jobs_group_id_groups_id_fk": { - "name": "load_jobs_group_id_groups_id_fk", - "tableFrom": "load_jobs", - "tableTo": "groups", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "load_jobs_library_id_libraries_id_fk": { - "name": "load_jobs_library_id_libraries_id_fk", - "tableFrom": "load_jobs", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "onshape_webhooks": { - "name": "onshape_webhooks", - "columns": { - "subject": { - "name": "subject", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "subject_id": { - "name": "subject_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "webhook_id": { - "name": "webhook_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "onshape_webhooks_token_unique": { - "name": "onshape_webhooks_token_unique", - "columns": ["token"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "onshape_webhooks_subject_subject_id_pk": { - "columns": ["subject", "subject_id"], - "name": "onshape_webhooks_subject_subject_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "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'" - }, - "tab_id": { - "name": "tab_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "users_library_id_libraries_id_fk": { - "name": "users_library_id_libraries_id_fk", - "tableFrom": "users", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_configuration_metrics": { - "name": "daily_configuration_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "parameter_id": { - "name": "parameter_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "daily_configuration_metrics_day_idx": { - "name": "daily_configuration_metrics_day_idx", - "columns": ["library_id", "day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { - "columns": [ - "library_id", - "element_id", - "parameter_id", - "value", - "day" - ], - "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_insertable_metrics": { - "name": "daily_insertable_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "daily_insertable_metrics_day_idx": { - "name": "daily_insertable_metrics_day_idx", - "columns": ["library_id", "day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { - "columns": [ - "library_id", - "element_id", - "day", - "target_element_type" - ], - "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_insertable_users": { - "name": "daily_insertable_users", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_insertable_users_library_id_element_id_day_user_id_pk": { - "columns": ["library_id", "element_id", "day", "user_id"], - "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_metrics": { - "name": "daily_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "favorite_count": { - "name": "favorite_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "fasten_count": { - "name": "fasten_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "quick_insert_count": { - "name": "quick_insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_metrics_day_library_id_type_pk": { - "columns": ["day", "library_id", "type"], - "name": "daily_metrics_day_library_id_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_source_metrics": { - "name": "daily_source_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "quick_insert_count": { - "name": "quick_insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_source_metrics_day_library_id_source_pk": { - "columns": ["day", "library_id", "source"], - "name": "daily_source_metrics_day_library_id_source_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_target_metrics": { - "name": "daily_target_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_target_metrics_day_library_id_target_element_type_pk": { - "columns": ["day", "library_id", "target_element_type"], - "name": "daily_target_metrics_day_library_id_target_element_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_user_activity": { - "name": "daily_user_activity", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "daily_user_activity_day_idx": { - "name": "daily_user_activity_day_idx", - "columns": ["day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_user_activity_day_library_id_user_id_pk": { - "columns": ["day", "library_id", "user_id"], - "name": "daily_user_activity_day_library_id_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "events": { - "name": "events", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema_version": { - "name": "schema_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "instance_type": { - "name": "instance_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "selection": { - "name": "selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_favorite": { - "name": "is_favorite", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_quick_insert": { - "name": "is_quick_insert", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fasten": { - "name": "fasten", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "events_day_idx": { - "name": "events_day_idx", - "columns": ["day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "insertable_stats": { - "name": "insertable_stats", - "columns": { - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insert_count": { - "name": "insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "first_inserted_at": { - "name": "first_inserted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_inserted_at": { - "name": "last_inserted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "insertable_stats_count_idx": { - "name": "insertable_stats_count_idx", - "columns": ["library_id", "insert_count"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "insertable_stats_library_id_element_id_pk": { - "columns": ["library_id", "element_id"], - "name": "insertable_stats_library_id_element_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user_stats": { - "name": "user_stats", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insert_count": { - "name": "insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "open_count": { - "name": "open_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "first_seen_at": { - "name": "first_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "user_stats_user_id_library_id_pk": { - "columns": ["user_id", "library_id"], - "name": "user_stats_user_id_library_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json deleted file mode 100644 index 292c1d2d9..000000000 --- a/drizzle/meta/0007_snapshot.json +++ /dev/null @@ -1,1332 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "7bfdd317-a1be-4641-b2fe-989132108c63", - "prevId": "cac6acf8-d0f8-448b-a93f-5ae1ec06ac95", - "tables": { - "configurations": { - "name": "configurations", - "columns": { - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "parameters": { - "name": "parameters", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "records": { - "name": "records", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - } - }, - "indexes": {}, - "foreignKeys": { - "configurations_insertable_id_insertables_id_fk": { - "name": "configurations_insertable_id_insertables_id_fk", - "tableFrom": "configurations", - "tableTo": "insertables", - "columnsFrom": ["insertable_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "favorites": { - "name": "favorites", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "default_selection": { - "name": "default_selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "favorites_user_id_library_id_insertable_id_unique": { - "name": "favorites_user_id_library_id_insertable_id_unique", - "columns": ["user_id", "library_id", "insertable_id"], - "isUnique": true - } - }, - "foreignKeys": { - "favorites_user_id_users_id_fk": { - "name": "favorites_user_id_users_id_fk", - "tableFrom": "favorites", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "favorites_library_id_libraries_id_fk": { - "name": "favorites_library_id_libraries_id_fk", - "tableFrom": "favorites", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "favorites_insertable_id_insertables_id_fk": { - "name": "favorites_insertable_id_insertables_id_fk", - "tableFrom": "favorites", - "tableTo": "insertables", - "columnsFrom": ["insertable_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "groups": { - "name": "groups", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_id": { - "name": "version_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_created_at": { - "name": "version_created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "thumbnail_workspace_id": { - "name": "thumbnail_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_alphabetically": { - "name": "sort_alphabetically", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "small_thumbnail_url": { - "name": "small_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "large_thumbnail_url": { - "name": "large_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "build_issues": { - "name": "build_issues", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "last_loaded_at": { - "name": "last_loaded_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "groups_document_id_library_id_unique": { - "name": "groups_document_id_library_id_unique", - "columns": ["document_id", "library_id"], - "isUnique": true - } - }, - "foreignKeys": { - "groups_library_id_libraries_id_fk": { - "name": "groups_library_id_libraries_id_fk", - "tableFrom": "groups", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "insertables": { - "name": "insertables", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_type": { - "name": "element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "microversion_id": { - "name": "microversion_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_visible": { - "name": "is_visible", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "is_open_composite": { - "name": "is_open_composite", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "supports_fasten": { - "name": "supports_fasten", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "index_configurations": { - "name": "index_configurations", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "excluded_parameter_ids": { - "name": "excluded_parameter_ids", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "version_id": { - "name": "version_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_created_at": { - "name": "version_created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "vendors": { - "name": "vendors", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "small_thumbnail_url": { - "name": "small_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "large_thumbnail_url": { - "name": "large_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fasten_info": { - "name": "fasten_info", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "part_metadata": { - "name": "part_metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "build_issues": { - "name": "build_issues", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "last_loaded_at": { - "name": "last_loaded_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "insertables_group_id_groups_id_fk": { - "name": "insertables_group_id_groups_id_fk", - "tableFrom": "insertables", - "tableTo": "groups", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "insertables_library_id_libraries_id_fk": { - "name": "insertables_library_id_libraries_id_fk", - "tableFrom": "insertables", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "libraries": { - "name": "libraries", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "cache_version": { - "name": "cache_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "admin_team_id": { - "name": "admin_team_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "admin_team": { - "name": "admin_team", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "load_jobs": { - "name": "load_jobs", - "columns": { - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "started_at": { - "name": "started_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rerun": { - "name": "rerun", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "rerun_force": { - "name": "rerun_force", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - } - }, - "indexes": {}, - "foreignKeys": { - "load_jobs_group_id_groups_id_fk": { - "name": "load_jobs_group_id_groups_id_fk", - "tableFrom": "load_jobs", - "tableTo": "groups", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "load_jobs_library_id_libraries_id_fk": { - "name": "load_jobs_library_id_libraries_id_fk", - "tableFrom": "load_jobs", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "onshape_webhooks": { - "name": "onshape_webhooks", - "columns": { - "subject": { - "name": "subject", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "subject_id": { - "name": "subject_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "webhook_id": { - "name": "webhook_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "onshape_webhooks_token_unique": { - "name": "onshape_webhooks_token_unique", - "columns": ["token"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "onshape_webhooks_subject_subject_id_pk": { - "columns": ["subject", "subject_id"], - "name": "onshape_webhooks_subject_subject_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "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'" - }, - "tab_id": { - "name": "tab_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "users_library_id_libraries_id_fk": { - "name": "users_library_id_libraries_id_fk", - "tableFrom": "users", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_configuration_metrics": { - "name": "daily_configuration_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "parameter_id": { - "name": "parameter_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "daily_configuration_metrics_day_idx": { - "name": "daily_configuration_metrics_day_idx", - "columns": ["library_id", "day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { - "columns": [ - "library_id", - "element_id", - "parameter_id", - "value", - "day" - ], - "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_insertable_metrics": { - "name": "daily_insertable_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "daily_insertable_metrics_day_idx": { - "name": "daily_insertable_metrics_day_idx", - "columns": ["library_id", "day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { - "columns": [ - "library_id", - "element_id", - "day", - "target_element_type" - ], - "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_insertable_users": { - "name": "daily_insertable_users", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_insertable_users_library_id_element_id_day_user_id_pk": { - "columns": ["library_id", "element_id", "day", "user_id"], - "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_metrics": { - "name": "daily_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "favorite_count": { - "name": "favorite_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "fasten_count": { - "name": "fasten_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "quick_insert_count": { - "name": "quick_insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_metrics_day_library_id_type_pk": { - "columns": ["day", "library_id", "type"], - "name": "daily_metrics_day_library_id_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_source_metrics": { - "name": "daily_source_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "quick_insert_count": { - "name": "quick_insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_source_metrics_day_library_id_source_pk": { - "columns": ["day", "library_id", "source"], - "name": "daily_source_metrics_day_library_id_source_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_target_metrics": { - "name": "daily_target_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_target_metrics_day_library_id_target_element_type_pk": { - "columns": ["day", "library_id", "target_element_type"], - "name": "daily_target_metrics_day_library_id_target_element_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_user_activity": { - "name": "daily_user_activity", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "daily_user_activity_day_idx": { - "name": "daily_user_activity_day_idx", - "columns": ["day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_user_activity_day_library_id_user_id_pk": { - "columns": ["day", "library_id", "user_id"], - "name": "daily_user_activity_day_library_id_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "events": { - "name": "events", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema_version": { - "name": "schema_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "instance_type": { - "name": "instance_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "selection": { - "name": "selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_favorite": { - "name": "is_favorite", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_quick_insert": { - "name": "is_quick_insert", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fasten": { - "name": "fasten", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "events_day_idx": { - "name": "events_day_idx", - "columns": ["day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "insertable_stats": { - "name": "insertable_stats", - "columns": { - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insert_count": { - "name": "insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "first_inserted_at": { - "name": "first_inserted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_inserted_at": { - "name": "last_inserted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "insertable_stats_count_idx": { - "name": "insertable_stats_count_idx", - "columns": ["library_id", "insert_count"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "insertable_stats_library_id_element_id_pk": { - "columns": ["library_id", "element_id"], - "name": "insertable_stats_library_id_element_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user_stats": { - "name": "user_stats", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insert_count": { - "name": "insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "open_count": { - "name": "open_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "first_seen_at": { - "name": "first_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "user_stats_user_id_library_id_pk": { - "columns": ["user_id", "library_id"], - "name": "user_stats_user_id_library_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json deleted file mode 100644 index 79196bbe9..000000000 --- a/drizzle/meta/0008_snapshot.json +++ /dev/null @@ -1,1348 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "b14d2440-111d-40ba-b503-0e8b71409337", - "prevId": "7bfdd317-a1be-4641-b2fe-989132108c63", - "tables": { - "configurations": { - "name": "configurations", - "columns": { - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "parameters": { - "name": "parameters", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "records": { - "name": "records", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - } - }, - "indexes": {}, - "foreignKeys": { - "configurations_insertable_id_insertables_id_fk": { - "name": "configurations_insertable_id_insertables_id_fk", - "tableFrom": "configurations", - "tableTo": "insertables", - "columnsFrom": ["insertable_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "favorites": { - "name": "favorites", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "default_selection": { - "name": "default_selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "favorites_user_id_library_id_insertable_id_unique": { - "name": "favorites_user_id_library_id_insertable_id_unique", - "columns": ["user_id", "library_id", "insertable_id"], - "isUnique": true - } - }, - "foreignKeys": { - "favorites_user_id_users_id_fk": { - "name": "favorites_user_id_users_id_fk", - "tableFrom": "favorites", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "favorites_library_id_libraries_id_fk": { - "name": "favorites_library_id_libraries_id_fk", - "tableFrom": "favorites", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "favorites_insertable_id_insertables_id_fk": { - "name": "favorites_insertable_id_insertables_id_fk", - "tableFrom": "favorites", - "tableTo": "insertables", - "columnsFrom": ["insertable_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "groups": { - "name": "groups", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_id": { - "name": "version_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_created_at": { - "name": "version_created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "thumbnail_workspace_id": { - "name": "thumbnail_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_alphabetically": { - "name": "sort_alphabetically", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "small_thumbnail_url": { - "name": "small_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "large_thumbnail_url": { - "name": "large_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "build_issues": { - "name": "build_issues", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "last_loaded_at": { - "name": "last_loaded_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "groups_document_id_library_id_unique": { - "name": "groups_document_id_library_id_unique", - "columns": ["document_id", "library_id"], - "isUnique": true - } - }, - "foreignKeys": { - "groups_library_id_libraries_id_fk": { - "name": "groups_library_id_libraries_id_fk", - "tableFrom": "groups", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "insertables": { - "name": "insertables", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_type": { - "name": "element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "microversion_id": { - "name": "microversion_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_visible": { - "name": "is_visible", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "is_open_composite": { - "name": "is_open_composite", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "supports_fasten": { - "name": "supports_fasten", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "index_configurations": { - "name": "index_configurations", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "excluded_parameter_ids": { - "name": "excluded_parameter_ids", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "version_id": { - "name": "version_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version_created_at": { - "name": "version_created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "vendors": { - "name": "vendors", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "small_thumbnail_url": { - "name": "small_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "large_thumbnail_url": { - "name": "large_thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fasten_info": { - "name": "fasten_info", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "part_metadata": { - "name": "part_metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "build_issues": { - "name": "build_issues", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "last_loaded_at": { - "name": "last_loaded_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "insertables_group_id_groups_id_fk": { - "name": "insertables_group_id_groups_id_fk", - "tableFrom": "insertables", - "tableTo": "groups", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "insertables_library_id_libraries_id_fk": { - "name": "insertables_library_id_libraries_id_fk", - "tableFrom": "insertables", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "libraries": { - "name": "libraries", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "cache_version": { - "name": "cache_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "admin_team_id": { - "name": "admin_team_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "admin_team": { - "name": "admin_team", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "approve_versions": { - "name": "approve_versions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "load_jobs": { - "name": "load_jobs", - "columns": { - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "started_at": { - "name": "started_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rerun": { - "name": "rerun", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "rerun_force": { - "name": "rerun_force", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "awaiting_approval": { - "name": "awaiting_approval", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - } - }, - "indexes": {}, - "foreignKeys": { - "load_jobs_group_id_groups_id_fk": { - "name": "load_jobs_group_id_groups_id_fk", - "tableFrom": "load_jobs", - "tableTo": "groups", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "load_jobs_library_id_libraries_id_fk": { - "name": "load_jobs_library_id_libraries_id_fk", - "tableFrom": "load_jobs", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "onshape_webhooks": { - "name": "onshape_webhooks", - "columns": { - "subject": { - "name": "subject", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "subject_id": { - "name": "subject_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "webhook_id": { - "name": "webhook_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "onshape_webhooks_token_unique": { - "name": "onshape_webhooks_token_unique", - "columns": ["token"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "onshape_webhooks_subject_subject_id_pk": { - "columns": ["subject", "subject_id"], - "name": "onshape_webhooks_subject_subject_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "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'" - }, - "tab_id": { - "name": "tab_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "users_library_id_libraries_id_fk": { - "name": "users_library_id_libraries_id_fk", - "tableFrom": "users", - "tableTo": "libraries", - "columnsFrom": ["library_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_configuration_metrics": { - "name": "daily_configuration_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "parameter_id": { - "name": "parameter_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "daily_configuration_metrics_day_idx": { - "name": "daily_configuration_metrics_day_idx", - "columns": ["library_id", "day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk": { - "columns": [ - "library_id", - "element_id", - "parameter_id", - "value", - "day" - ], - "name": "daily_configuration_metrics_library_id_element_id_parameter_id_value_day_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_insertable_metrics": { - "name": "daily_insertable_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "daily_insertable_metrics_day_idx": { - "name": "daily_insertable_metrics_day_idx", - "columns": ["library_id", "day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk": { - "columns": [ - "library_id", - "element_id", - "day", - "target_element_type" - ], - "name": "daily_insertable_metrics_library_id_element_id_day_target_element_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_insertable_users": { - "name": "daily_insertable_users", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_insertable_users_library_id_element_id_day_user_id_pk": { - "columns": ["library_id", "element_id", "day", "user_id"], - "name": "daily_insertable_users_library_id_element_id_day_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_metrics": { - "name": "daily_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "favorite_count": { - "name": "favorite_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "fasten_count": { - "name": "fasten_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "quick_insert_count": { - "name": "quick_insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_metrics_day_library_id_type_pk": { - "columns": ["day", "library_id", "type"], - "name": "daily_metrics_day_library_id_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_source_metrics": { - "name": "daily_source_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "quick_insert_count": { - "name": "quick_insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_source_metrics_day_library_id_source_pk": { - "columns": ["day", "library_id", "source"], - "name": "daily_source_metrics_day_library_id_source_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_target_metrics": { - "name": "daily_target_metrics", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_target_metrics_day_library_id_target_element_type_pk": { - "columns": ["day", "library_id", "target_element_type"], - "name": "daily_target_metrics_day_library_id_target_element_type_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "daily_user_activity": { - "name": "daily_user_activity", - "columns": { - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "daily_user_activity_day_idx": { - "name": "daily_user_activity_day_idx", - "columns": ["day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "daily_user_activity_day_library_id_user_id_pk": { - "columns": ["day", "library_id", "user_id"], - "name": "daily_user_activity_day_library_id_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "events": { - "name": "events", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "day": { - "name": "day", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema_version": { - "name": "schema_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "document_id": { - "name": "document_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "instance_type": { - "name": "instance_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "insertable_id": { - "name": "insertable_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "target_element_type": { - "name": "target_element_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "selection": { - "name": "selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_favorite": { - "name": "is_favorite", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_quick_insert": { - "name": "is_quick_insert", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fasten": { - "name": "fasten", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "events_day_idx": { - "name": "events_day_idx", - "columns": ["day"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "insertable_stats": { - "name": "insertable_stats", - "columns": { - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "element_id": { - "name": "element_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insert_count": { - "name": "insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "first_inserted_at": { - "name": "first_inserted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_inserted_at": { - "name": "last_inserted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "insertable_stats_count_idx": { - "name": "insertable_stats_count_idx", - "columns": ["library_id", "insert_count"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "insertable_stats_library_id_element_id_pk": { - "columns": ["library_id", "element_id"], - "name": "insertable_stats_library_id_element_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user_stats": { - "name": "user_stats", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "library_id": { - "name": "library_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "insert_count": { - "name": "insert_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "open_count": { - "name": "open_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "first_seen_at": { - "name": "first_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "user_stats_user_id_library_id_pk": { - "columns": ["user_id", "library_id"], - "name": "user_stats_user_id_library_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 487435e28..570ac2274 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -33,36 +33,8 @@ { "idx": 4, "version": "6", - "when": 1790186214251, - "tag": "0004_thumbnail_workspace", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1790196152689, - "tag": "0005_excluded_parameters", - "breakpoints": true - }, - { - "idx": 6, - "version": "6", - "when": 1790215505598, - "tag": "0006_admin_teams_webhooks_jobs", - "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1790270424041, - "tag": "0007_admin_team_column", - "breakpoints": true - }, - { - "idx": 8, - "version": "6", - "when": 1790305992938, - "tag": "0008_version_approval", + "when": 1790354705475, + "tag": "0004_library_loading", "breakpoints": true } ] diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index bcd5b6b86..994e6b69c 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -240,7 +240,7 @@ export const loadJobs = sqliteTable("load_jobs", { startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), rerun: integer("rerun", { mode: "boolean" }).notNull().default(false), // Whether the rerun reloads unchanged insertables too. - rerunForce: integer("rerun_force", { mode: "boolean" }) + rerunForceReload: integer("rerun_force_reload", { mode: "boolean" }) .notNull() .default(false), // Its load is waiting for an admin to approve the version. diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index 4741560a9..7aca8180c 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -129,7 +129,7 @@ export async function requestLoads( .set({ rerun: true, // Once asked for, a forced reload is not downgraded. - ...(request.forceReload ? { rerunForce: true } : {}) + ...(request.forceReload ? { rerunForceReload: true } : {}) }) .where(eq(loadJobs.groupId, request.groupId)) ); @@ -210,13 +210,13 @@ export async function finishLoad( instanceId, startedAt: new Date(), rerun: false, - rerunForce: false, + rerunForceReload: false, awaitingApproval: false }) .where(eq(loadJobs.groupId, params.groupId)); await env.LOAD_DOCUMENT_WORKFLOW.create({ id: instanceId, - params: { ...params, forceReload: job.rerunForce } + params: { ...params, forceReload: job.rerunForceReload } }); outcome = "rerun"; } else { diff --git a/src/backend/features/load/jobs.worker.test.ts b/src/backend/features/load/jobs.worker.test.ts index 41ae8708d..ae6d0d97c 100644 --- a/src/backend/features/load/jobs.worker.test.ts +++ b/src/backend/features/load/jobs.worker.test.ts @@ -86,7 +86,10 @@ describe("document loads", () => { await requestLoads(env, [params("a", false)]); expect(create).toHaveBeenCalledOnce(); - expect(await job("a")).toMatchObject({ rerun: true, rerunForce: true }); + expect(await job("a")).toMatchObject({ + rerun: true, + rerunForceReload: true + }); }); it("replaces the row of a load that crashed, and flags its group", async () => { diff --git a/wrangler.jsonc b/wrangler.jsonc index 7c6441d79..d52c873f6 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -64,8 +64,8 @@ "class_name": "RenderThumbnailWorkflow" } ], - // Inherited by every environment. v2 deletes the thumbnail render queue, - // replaced by RenderThumbnailWorkflow; the object's stored queue goes with it. + // Inherited by every environment. v2 swaps the thumbnail render queue, + // replaced by RenderThumbnailWorkflow, for the push hub. "durable_objects": { "bindings": [{ "name": "PUSH_HUB", "class_name": "PushHub" }] }, @@ -81,15 +81,8 @@ }, { "tag": "v2", - "deleted_classes": ["ThumbnailRenderer"] - }, - { - "tag": "v3", - "new_sqlite_classes": ["LiveUpdates"] - }, - { - "tag": "v4", - "renamed_classes": [{ "from": "LiveUpdates", "to": "PushHub" }] + "deleted_classes": ["ThumbnailRenderer"], + "new_sqlite_classes": ["PushHub"] } ], /** @@ -150,9 +143,7 @@ } ], "durable_objects": { - "bindings": [ - { "name": "PUSH_HUB", "class_name": "PushHub" } - ] + "bindings": [{ "name": "PUSH_HUB", "class_name": "PushHub" }] }, "vars": { "OWNER_USER_ID": "5eace32713a966103efd2aa0", @@ -202,9 +193,7 @@ } ], "durable_objects": { - "bindings": [ - { "name": "PUSH_HUB", "class_name": "PushHub" } - ] + "bindings": [{ "name": "PUSH_HUB", "class_name": "PushHub" }] }, "vars": { "OWNER_USER_ID": "5eace32713a966103efd2aa0", From 0ee8f47cc8a6bc8d79eccf0843cbbc5baf30ce61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:58:16 +0000 Subject: [PATCH 47/88] Replace a group's running load instead of queueing behind it A load asked for while one runs terminates it and takes its row, since the new load reads the latest version itself. The row keeps whether the running load is forced, so a plain load replacing a forced one stays forced; the rerun flags go. A replaced load that finishes anyway leaves the row alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 4 +- drizzle/0004_library_loading.sql | 3 +- drizzle/meta/0004_snapshot.json | 14 +- drizzle/meta/_journal.json | 2 +- src/backend/db/schema.ts | 5 +- src/backend/features/load/jobs.ts | 140 +++++++++--------- src/backend/features/load/jobs.worker.test.ts | 78 +++++----- src/backend/features/load/workflows.ts | 2 +- 8 files changed, 123 insertions(+), 125 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4dd570870..ce0b68ebe 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -91,7 +91,7 @@ Cloudflare Workflows let you run a long-running background job that survives bey Loading a group means walking the document structure, downloading metadata for every part and assembly, probing each indexed configuration, generating thumbnails, and writing it all to D1 — far too long for a single HTTP request. The request kicks the workflow off and returns immediately. -Every load is one document: adding a document, a new version of one (see Webhooks below), and a library admin's reload of the library's outdated documents (or the owner's reload of all of them), which starts one per group. `features/load/jobs.ts` keeps at most one load per group running, in the `load_jobs` table: a load asked for while one runs is marked on that row, and the running load starts it as it finishes. Each load that wrote to its group rebuilds its library's search index and bumps its version, so every load stands alone and a reload simply starts them all at once. +Every load is one document: adding a document, a new version of one (see Webhooks below), and a library admin's reload of the library's outdated documents (or the owner's reload of all of them), which starts one per group. `features/load/jobs.ts` keeps at most one load per group running, in the `load_jobs` table: a load asked for while one runs terminates it and takes its row, since the new load reads the latest version itself. A plain load replacing a forced one stays forced (`load_jobs.force_reload`). A replaced load that finishes anyway publishes what it wrote but leaves the row to its replacement. Each load that wrote to its group rebuilds its library's search index and bumps its version, so every load stands alone and a reload simply starts them all at once. A load calls Onshape as whoever asked for it, while their session works. A webhook's load has nobody, and a requester's session can expire mid-load, so the load then finds a session itself: the owner's, or else a team admin's of the library (`getOnshapeApiFromContext`). Only the owner's and team admins' latest sessions are kept by user id, in KV under `admin-session:`, written as their access is checked (`features/auth/admin-sessions.ts`). @@ -101,7 +101,7 @@ Onshape pushes one thing, registered with `isTransient: false` and recorded in t - **A new version of a library document.** Registered by the document's load; removed with the last group loaded from it. Reloads that document's groups. -A library admin can switch on **Approve new versions** in the settings menu (`libraries.approve_versions`). A webhook's load in that library then holds a new version: it marks its `load_jobs` row `awaiting_approval` and waits on the workflow event `approve-version` for up to two days, then loads anyway. The group's row shows an **Awaiting approval** badge, and **Approve** beside "Held versions" sends that event to every held load, and so does switching approval off. An admin's reload approves the groups it names. +A library admin can switch on **Approve new versions** in the settings menu (`libraries.approve_versions`). A webhook's load in that library then holds a new version: it marks its `load_jobs` row `awaiting_approval` and waits on the workflow event `approve-version` for up to two days, then loads anyway. The group's row shows an **Awaiting approval** badge, and **Approve** beside "Held versions" sends that event to every held load, and so does switching approval off. An admin's reload replaces a held load with one that doesn't wait, and a newer version's webhook replaces it with one that holds again. Onshape's team webhooks need a company id, which a personal account lacks, so an admin team's membership is pulled again only when the owner sets the team or an admin presses **Refresh** beside "Admin team members" in the settings menu. diff --git a/drizzle/0004_library_loading.sql b/drizzle/0004_library_loading.sql index c69b0785d..b176a576d 100644 --- a/drizzle/0004_library_loading.sql +++ b/drizzle/0004_library_loading.sql @@ -3,8 +3,7 @@ CREATE TABLE `load_jobs` ( `library_id` text NOT NULL, `instance_id` text, `started_at` integer NOT NULL, - `rerun` integer DEFAULT false NOT NULL, - `rerun_force_reload` integer DEFAULT false NOT NULL, + `force_reload` integer DEFAULT false NOT NULL, `awaiting_approval` integer DEFAULT false NOT NULL, FOREIGN KEY (`group_id`) REFERENCES `groups`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`library_id`) REFERENCES `libraries`(`id`) ON UPDATE no action ON DELETE no action diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json index baee82559..70a4a356d 100644 --- a/drizzle/meta/0004_snapshot.json +++ b/drizzle/meta/0004_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "69111642-5d7d-4bf2-9c1b-12aa7afd32be", + "id": "6e982faa-641c-47ec-9d82-683289f15f2f", "prevId": "d8945a24-5643-4675-bf1f-b32cff1dbea2", "tables": { "configurations": { @@ -538,16 +538,8 @@ "notNull": true, "autoincrement": false }, - "rerun": { - "name": "rerun", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "rerun_force_reload": { - "name": "rerun_force_reload", + "force_reload": { + "name": "force_reload", "type": "integer", "primaryKey": false, "notNull": true, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 570ac2274..a6c03c3bf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -33,7 +33,7 @@ { "idx": 4, "version": "6", - "when": 1790354705475, + "when": 1790355366486, "tag": "0004_library_loading", "breakpoints": true } diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 994e6b69c..7285ac706 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -238,9 +238,8 @@ export const loadJobs = sqliteTable("load_jobs", { // Null for the moment between claiming the row and the instance existing. instanceId: text("instance_id"), startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), - rerun: integer("rerun", { mode: "boolean" }).notNull().default(false), - // Whether the rerun reloads unchanged insertables too. - rerunForceReload: integer("rerun_force_reload", { mode: "boolean" }) + // A plain load replacing this one keeps it forced. + forceReload: integer("force_reload", { mode: "boolean" }) .notNull() .default(false), // Its load is waiting for an admin to approve the version. diff --git a/src/backend/features/load/jobs.ts b/src/backend/features/load/jobs.ts index 7aca8180c..b3412355b 100644 --- a/src/backend/features/load/jobs.ts +++ b/src/backend/features/load/jobs.ts @@ -1,7 +1,7 @@ /** * One load per group at a time: two writing the same rows would interleave. - * A load requested meanwhile is marked on the running row and started when it - * finishes. In D1 since concurrent KV writes lose updates. + * A load requested meanwhile replaces the running one. In D1 since concurrent + * KV writes lose updates. */ import { and, eq, inArray } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; @@ -91,16 +91,16 @@ async function clearDead( return jobs.filter((_, i) => alive[i]); } -/** Starts a load of each group, or marks it to run again after its current one. */ +/** + * Starts a load of each group. One already running is terminated and replaced, + * since the new load reads the latest version itself. + */ export async function requestLoads( env: AppBindings, requests: LoadDocumentParams[] ): Promise { const db = getDb(env.DB); - const byGroup = new Map( - requests.map((request) => [request.groupId, request]) - ); - const groupIds = [...byGroup.keys()]; + const groupIds = requests.map((request) => request.groupId); const existing: LoadJob[] = []; for (const chunk of chunkForInArray(groupIds)) { @@ -111,43 +111,49 @@ export async function requestLoads( .where(inArray(loadJobs.groupId, chunk))) ); } - const live = await clearDead(env, existing); - const running = new Set(live.map((job) => job.groupId)); + const running = new Map( + (await clearDead(env, existing)).map((job) => [job.groupId, job]) + ); + await terminateLoads(env, [...running.values()]); - // Asking for a load outright approves the version one is holding. - const overridden = live.filter( - (job) => - job.awaitingApproval && !byGroup.get(job.groupId)?.awaitApproval + const starts = requests.map((request) => ({ + id: crypto.randomUUID(), + params: { + ...request, + // A forced reload isn't undone by a plain load replacing it. + forceReload: + request.forceReload || + (running.get(request.groupId)?.forceReload ?? false) + } + })); + const startedAt = new Date(); + const replacing = starts.filter((start) => + running.has(start.params.groupId) ); - await releaseHeldLoads(env, overridden); + const fresh = starts.filter((start) => !running.has(start.params.groupId)); - // Running already: its load starts this one as it finishes. - const queued = requests.filter((request) => running.has(request.groupId)); - const writes: BatchItem<"sqlite">[] = queued.map((request) => + const writes: BatchItem<"sqlite">[] = replacing.map((start) => db .update(loadJobs) .set({ - rerun: true, - // Once asked for, a forced reload is not downgraded. - ...(request.forceReload ? { rerunForceReload: true } : {}) + instanceId: start.id, + startedAt, + forceReload: start.params.forceReload, + awaitingApproval: false }) - .where(eq(loadJobs.groupId, request.groupId)) + .where(eq(loadJobs.groupId, start.params.groupId)) ); - - const toStart = requests - .filter((request) => !running.has(request.groupId)) - .map((params) => ({ id: crypto.randomUUID(), params })); - const startedAt = new Date(); - for (let i = 0; i < toStart.length; i += ROWS_PER_INSERT) { + for (let i = 0; i < fresh.length; i += ROWS_PER_INSERT) { writes.push( db .insert(loadJobs) .values( - toStart.slice(i, i + ROWS_PER_INSERT).map((start) => ({ + fresh.slice(i, i + ROWS_PER_INSERT).map((start) => ({ groupId: start.params.groupId, libraryId: start.params.libraryId, instanceId: start.id, - startedAt + startedAt, + forceReload: start.params.forceReload })) ) .onConflictDoNothing() @@ -160,9 +166,9 @@ export async function requestLoads( } // Each load stands alone, so they all start at once. - const batches: (typeof toStart)[] = []; - for (let i = 0; i < toStart.length; i += CREATE_BATCH) { - batches.push(toStart.slice(i, i + CREATE_BATCH)); + const batches: (typeof starts)[] = []; + for (let i = 0; i < starts.length; i += CREATE_BATCH) { + batches.push(starts.slice(i, i + CREATE_BATCH)); } await Promise.all( batches.map((batch) => env.LOAD_DOCUMENT_WORKFLOW.createBatch(batch)) @@ -178,15 +184,39 @@ export async function requestLoads( } } -/** What a finished load does next: nothing, or its group's queued load. */ -type FinishOutcome = "done" | "rerun"; +/** Stops loads about to be replaced. One that has just finished can't be, which is fine. */ +async function terminateLoads( + env: AppBindings, + jobs: LoadJob[] +): Promise { + await Promise.all( + jobs.map(async (job) => { + if (!job.instanceId) return; + try { + const instance = await env.LOAD_DOCUMENT_WORKFLOW.get( + job.instanceId + ); + await instance.terminate(); + } catch (error) { + console.warn( + `Failed to stop the load of ${job.groupId}`, + error + ); + } + }) + ); +} -/** Whether it failed or not. Publishes what it wrote, then starts the queued load or releases the group. */ +/** + * Whether it failed or not. Publishes what it wrote, then releases the group, + * unless a newer load has replaced this one. + */ export async function finishLoad( env: AppBindings, params: LoadDocumentParams, + instanceId: string, changed: boolean -): Promise { +): Promise { const db = getDb(env.DB); // Before the bump, which makes /search-db immutable for a year. if (changed) { @@ -194,41 +224,19 @@ export async function finishLoad( await bumpLibraryVersion(db, params.libraryId); await pushLibraryChanged(env, params.libraryId); } - - const job = await db - .select() - .from(loadJobs) - .where(eq(loadJobs.groupId, params.groupId)) - .get(); - - let outcome: FinishOutcome = "done"; - if (job?.rerun) { - const instanceId = crypto.randomUUID(); - await db - .update(loadJobs) - .set({ - instanceId, - startedAt: new Date(), - rerun: false, - rerunForceReload: false, - awaitingApproval: false - }) - .where(eq(loadJobs.groupId, params.groupId)); - await env.LOAD_DOCUMENT_WORKFLOW.create({ - id: instanceId, - params: { ...params, forceReload: job.rerunForceReload } - }); - outcome = "rerun"; - } else { - await db.delete(loadJobs).where(eq(loadJobs.groupId, params.groupId)); - } - + await db + .delete(loadJobs) + .where( + and( + eq(loadJobs.groupId, params.groupId), + eq(loadJobs.instanceId, instanceId) + ) + ); await pushJobStatus( env, params.libraryId, await runningStatus(env, params.libraryId) ); - return outcome; } /** The groups loading, from the rows alone, trusting each to be live. */ diff --git a/src/backend/features/load/jobs.worker.test.ts b/src/backend/features/load/jobs.worker.test.ts index ae6d0d97c..2290da519 100644 --- a/src/backend/features/load/jobs.worker.test.ts +++ b/src/backend/features/load/jobs.worker.test.ts @@ -28,14 +28,17 @@ function params(groupId: string, forceReload = false): LoadDocumentParams { }; } -/** Every instance reports `status`, as far as the jobs can tell. Returns their `sendEvent`. */ +/** Every instance reports `status`, as far as the jobs can tell. Returns their controls. */ function instancesAre(status: InstanceStatus["status"]) { - const sendEvent = vi.fn().mockResolvedValue(undefined); - vi.spyOn(env.LOAD_DOCUMENT_WORKFLOW, "get").mockResolvedValue({ + const instance = { status: () => Promise.resolve({ status }), - sendEvent - } as never); - return sendEvent; + sendEvent: vi.fn().mockResolvedValue(undefined), + terminate: vi.fn().mockResolvedValue(undefined) + }; + vi.spyOn(env.LOAD_DOCUMENT_WORKFLOW, "get").mockResolvedValue( + instance as never + ); + return instance; } const job = (groupId: string) => @@ -75,21 +78,33 @@ describe("document loads", () => { }); // Two loads writing one group's rows at once would interleave. - it("queues a load behind the group's running one, keeping it forced", async () => { + it("replaces the group's running load, which it stops", async () => { const create = vi .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") .mockResolvedValue([]); await requestLoads(env, [params("a")]); - instancesAre("running"); + const { terminate } = instancesAre("running"); + + await requestLoads(env, [params("a")]); + + expect(terminate).toHaveBeenCalledOnce(); + const replacement = create.mock.calls[1][0][0]; + expect((await job("a"))?.instanceId).toBe(replacement.id); + }); + it("keeps a replaced load's force", async () => { + const create = vi + .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "createBatch") + .mockResolvedValue([]); await requestLoads(env, [params("a", true)]); + instancesAre("running"); + await requestLoads(env, [params("a", false)]); - expect(create).toHaveBeenCalledOnce(); - expect(await job("a")).toMatchObject({ - rerun: true, - rerunForceReload: true + expect(create.mock.calls[1][0][0].params).toMatchObject({ + forceReload: true }); + expect(await job("a")).toMatchObject({ forceReload: true }); }); it("replaces the row of a load that crashed, and flags its group", async () => { @@ -133,7 +148,7 @@ describe("document loads", () => { }); it("lets every held load through", async () => { - const sendEvent = instancesAre("waiting"); + const { sendEvent } = instancesAre("waiting"); expect(await approveHeldLoads(env, TEST_LIBRARY_ID)).toBe(1); @@ -147,22 +162,11 @@ describe("document loads", () => { }); }); - it("approves a held load someone asks for outright", async () => { - const sendEvent = instancesAre("waiting"); + it("replaces a held load with one someone asked for outright", async () => { + instancesAre("waiting"); await requestLoads(env, [params("a")]); - expect(sendEvent).toHaveBeenCalledOnce(); expect(await job("a")).toMatchObject({ awaitingApproval: false }); }); - - it("keeps holding for another version's webhook", async () => { - const sendEvent = instancesAre("waiting"); - await requestLoads(env, [held]); - expect(sendEvent).not.toHaveBeenCalled(); - expect(await job("a")).toMatchObject({ - awaitingApproval: true, - rerun: true - }); - }); }); describe("finishing", () => { @@ -173,21 +177,15 @@ describe("document loads", () => { ).mockResolvedValue([]); }); - it("starts the load queued behind it", async () => { + it("leaves the group to the load that replaced it", async () => { await requestLoads(env, [params("a")]); + const replaced = (await job("a"))?.instanceId ?? ""; instancesAre("running"); - await requestLoads(env, [params("a", true)]); - const create = vi - .spyOn(env.LOAD_DOCUMENT_WORKFLOW, "create") - .mockResolvedValue({ id: "next" } as never); + await requestLoads(env, [params("a")]); - expect(await finishLoad(env, params("a"), true)).toBe("rerun"); + await finishLoad(env, params("a"), replaced, false); - expect(create.mock.calls[0][0]?.params).toMatchObject({ - groupId: "a", - forceReload: true - }); - expect(await job("a")).toMatchObject({ rerun: false }); + expect((await job("a"))?.instanceId).not.toBe(replaced); }); // Each load publishes what it wrote, standing alone. @@ -196,11 +194,13 @@ describe("document loads", () => { .spyOn(LibraryDb, "rebuildSearchDb") .mockResolvedValue(""); await requestLoads(env, [params("a"), params("b")]); + const instanceOf = async (groupId: string) => + (await job(groupId))?.instanceId ?? ""; - await finishLoad(env, params("a"), true); + await finishLoad(env, params("a"), await instanceOf("a"), true); expect(rebuild).toHaveBeenCalledOnce(); - await finishLoad(env, params("b"), false); + await finishLoad(env, params("b"), await instanceOf("b"), false); expect(rebuild).toHaveBeenCalledOnce(); expect(await getJobStatus(env, TEST_LIBRARY_ID)).toEqual({ loadingGroupIds: [], diff --git a/src/backend/features/load/workflows.ts b/src/backend/features/load/workflows.ts index ff70c655c..950fafa32 100644 --- a/src/backend/features/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -68,7 +68,7 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< } finally { // Always, so whatever queued behind this load starts. await step.do("finish", () => - finishLoad(this.env, params, changed) + finishLoad(this.env, params, event.instanceId, changed) ); } } From 2d13af4a99a2076ec0ec97dd3cc61555cd8036c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:25:23 +0000 Subject: [PATCH 48/88] Total a library's headline tiles over the selected range The library summary counted its totals over all time, so its tiles never moved with the picker. The overview has no picker and stays lifetime. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- src/backend/features/analytics/contract.ts | 4 ++-- src/backend/features/analytics/metric-queries.ts | 2 +- src/backend/features/analytics/routes.ts | 2 +- .../features/analytics/routes.worker.test.ts | 15 +++++++++++++++ .../{lifetime-tiles.tsx => headline-tiles.tsx} | 10 ++++------ src/frontend/routes/dashboard/index.tsx | 4 ++-- .../routes/dashboard/library/$libraryId/index.tsx | 4 ++-- 7 files changed, 27 insertions(+), 14 deletions(-) rename src/frontend/features/dashboard/{lifetime-tiles.tsx => headline-tiles.tsx} (85%) diff --git a/src/backend/features/analytics/contract.ts b/src/backend/features/analytics/contract.ts index f94679590..4be92f73d 100644 --- a/src/backend/features/analytics/contract.ts +++ b/src/backend/features/analytics/contract.ts @@ -108,9 +108,9 @@ export interface GrowthOut { } export interface LibrarySummaryOut { - /** Lifetime, for the headline cards. */ + /** Over the requested range, for the headline cards. */ totals: AnalyticsTotals; - /** Scoped to the requested range, for the chart and the sparklines. */ + /** Also over the range, for the chart and the sparklines. */ metricSeries: DailyMetricPoint[]; growth: GrowthOut; from: string; diff --git a/src/backend/features/analytics/metric-queries.ts b/src/backend/features/analytics/metric-queries.ts index ded08a4b5..5c7043383 100644 --- a/src/backend/features/analytics/metric-queries.ts +++ b/src/backend/features/analytics/metric-queries.ts @@ -35,7 +35,7 @@ import { type DayRange } from "./day"; import { eachDay } from "./range"; import { getHealthCounts } from "./health"; -/** Lifetime totals, optionally scoped to one library and to a window. */ +/** Lifetime totals, or scoped to one library or a window. */ export async function getTotals( db: Db, libraryId?: LibraryId, diff --git a/src/backend/features/analytics/routes.ts b/src/backend/features/analytics/routes.ts index c868cf730..aa2e3add9 100644 --- a/src/backend/features/analytics/routes.ts +++ b/src/backend/features/analytics/routes.ts @@ -99,7 +99,7 @@ analyticsRoutes.get( const range = clampRange(requested, trackingSince); const [totals, metricSeries, growth] = await Promise.all([ - getTotals(db, libraryId), + getTotals(db, libraryId, range), getMetricSeries(db, range, libraryId), getGrowth(db, toReportingDay(Date.now()), trackingSince, libraryId) ]); diff --git a/src/backend/features/analytics/routes.worker.test.ts b/src/backend/features/analytics/routes.worker.test.ts index 421eb7eec..c0936f8bb 100644 --- a/src/backend/features/analytics/routes.worker.test.ts +++ b/src/backend/features/analytics/routes.worker.test.ts @@ -37,6 +37,7 @@ import { import { ElementType } from "../../lib/onshape/element-type"; import { type AnalyticsOverviewOut, + type LibrarySummaryOut, type UnusedOptionOut, type InsertableReportOut, type LibraryHealthCounts, @@ -468,6 +469,20 @@ describe("analytics routes", () => { }); }); + describe("GET /analytics/summary/library/:libraryId", () => { + it("totals over the requested range", async () => { + await seedMetric("2026-01-01", 1); + await seedMetric("2026-06-01", 2); + + const res = await anonymousGet( + `/api/analytics/summary/library/${TEST_LIBRARY_ID}?from=2026-05-01&to=2026-07-01` + ); + const body: LibrarySummaryOut = await res.json(); + + expect(body.totals.inserts).toBe(2); + }); + }); + describe("GET /analytics/parts/library/:libraryId", () => { function partsUrl(query = `?${ALL_TIME}`) { return `/api/analytics/parts/library/${TEST_LIBRARY_ID}${query}`; diff --git a/src/frontend/features/dashboard/lifetime-tiles.tsx b/src/frontend/features/dashboard/headline-tiles.tsx similarity index 85% rename from src/frontend/features/dashboard/lifetime-tiles.tsx rename to src/frontend/features/dashboard/headline-tiles.tsx index 5f89a849f..726883991 100644 --- a/src/frontend/features/dashboard/lifetime-tiles.tsx +++ b/src/frontend/features/dashboard/headline-tiles.tsx @@ -10,7 +10,8 @@ import { perUnit } from "./derived"; import { toSparkSeries } from "./series"; import { StatTile } from "./stat-tiles"; -interface LifetimeTilesProps { +interface HeadlineTilesProps { + /** Over the selected window, or lifetime where there is no picker. */ totals: AnalyticsTotals; growth: GrowthOut; /** Daily points over the selected window, for the sparklines. */ @@ -19,12 +20,12 @@ interface LifetimeTilesProps { withOpens?: boolean; } -export function LifetimeTiles({ +export function HeadlineTiles({ totals, growth, series, withOpens = false -}: LifetimeTilesProps): ReactNode { +}: HeadlineTilesProps): ReactNode { const { season } = growth; const perUser = totals.uniqueUsers === 0 ? 0 : totals.inserts / totals.uniqueUsers; @@ -44,9 +45,6 @@ export function LifetimeTiles({ change={season.activeUsers} spark={spark.activeUsers} /> - {/* Lifetime uses over everyone who ever used it, against a season's - uses over the people active in that season — the same - value-and-delta split every tile in this row has. */}
- - Date: Fri, 25 Sep 2026 19:31:20 +0000 Subject: [PATCH 49/88] Delete stale thumbnails per document instead of on a cron A load, once it has saved its group, deletes the thumbnails of its document's elements that no row names any more, and deleting a group does the same for its elements. Each lists only those elements' prefixes, so the daily whole-bucket scan and its cron go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 14 +- src/backend/features/library/groups/routes.ts | 29 ++- .../library/groups/routes.worker.test.ts | 24 ++ src/backend/features/load/load-group.ts | 19 ++ src/backend/features/thumbnails/reconcile.ts | 188 +++++++-------- .../thumbnails/reconcile.worker.test.ts | 220 ++++++------------ src/backend/index.ts | 12 +- wrangler.jsonc | 5 - 8 files changed, 220 insertions(+), 291 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index ce0b68ebe..bb6718a05 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -45,7 +45,7 @@ R2 is Cloudflare's blob storage, optimized for unstructured data like images and | Prefix | What it holds | Lifetime | | --------------- | ------------------------------------------------------------------------- | ------------------------------------------------- | -| `thumbnails/` | Rendered thumbnails, by element and configuration | Kept until reconciled; see below | +| `thumbnails/` | Rendered thumbnails, by element and configuration | Deleted by the load that orphans them; see below | | `search-index/` | Each library's serialized MiniSearch index, under a version for its shape | Rewritten on every index rebuild; built on a miss | Onshape can generate preview thumbnails for parts and assemblies, but fetching them from Onshape on every page load would be slow and eat into API rate limits — a single render can require polling and take minutes. Instead, every thumbnail we ever fetch from Onshape lands in R2 and is served from there afterwards. @@ -61,14 +61,12 @@ thumbnails/config/{elementId}/{microversionId}/{configKey}/{size} `{configKey}` is the url-encoded `ConfigurationKey` — the canonical configuration with hidden and default-valued parameters dropped and quantities in meters and radians — so two equivalent selections resolve to one cached image. Encoding it keeps its `;` and `=` inside a single path segment. Including `{microversionId}` makes every object immutable, so an updated document lands on new keys rather than overwriting in place. -Nothing expires on a timer: there is no R2 lifecycle rule, and renders are meant to last. What that costs is orphans — a tab edited into a new microversion leaves its old pair behind, and a deleted group or tab leaves everything it had. A **daily cron** (the `scheduled` handler in `src/backend/index.ts`) collects them, in `features/thumbnails/reconcile.ts`: +Nothing expires on a timer: there is no R2 lifecycle rule, and renders are meant to last. What that costs is orphans — a tab edited into a new microversion leaves its old pair behind, and a deleted group or tab leaves everything it had. So each load, once it has saved its group, deletes the stale thumbnails of its document's elements, and deleting a group does the same for its elements (`deleteStaleThumbnails` in `features/thumbnails/reconcile.ts`): -- The live set is every `(elementId, microversionId)` still named by an insertable row, plus the ones a group's two stored thumbnail urls point at — a group's document thumbnail is often not one of its own insertables, and those urls are the only record of which element it is. -- It spans **every library**, because a thumbnail key names no library. A set built from one library would read every other library's thumbnails as orphaned. -- Both prefixes are reconciled the same way: a configuration render is addressed by the same element and microversion, so it lives and dies with the element's default. -- An object younger than 24 hours is kept whatever the live set says. A group load stores thumbnails as it goes and commits its rows at the end, and a configuration render is started by a user opening the insert menu rather than by any job — so something in flight is indistinguishable from something orphaned, and only age tells them apart. -- An empty live set deletes nothing: a library really can have no elements, but so can a read that failed. -- A run scans at most 50 pages of 1,000. A bucket larger than that is finished by the next run. +- It lists each element's two prefixes, so it never scans the bucket. The elements are the document's tabs plus the group's stored insertables, which covers a removed tab. +- What stays is every `(elementId, microversionId)` an insertable row still names, in **any library**, since another library can load the same document, plus what the document's groups' two thumbnail urls point at: a group's document thumbnail is often not one of its insertables, and those urls are the only record of which element it is. +- Both prefixes are cleaned the same way: a configuration render is addressed by the same element and microversion, so it lives and dies with the element's default. +- An object younger than an hour is kept. A load stores thumbnails before the rows naming them, and a load replacing another can be storing while the one it replaced cleans up. Thumbnails are served via `/api/thumbnail/:size/:elementId?v={microversionId}&configurationKey=&insertableId=`: diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index e0a9c1cd9..253b59736 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -21,6 +21,9 @@ import { handledError } from "../../../lib/api-error"; import { getJobStatus, requestLoads } from "../../load/jobs"; import { createShellGroup } from "../../load/workflows"; import { removeWebhook } from "../../webhooks/registration"; +import { deleteStaleThumbnails } from "../../thumbnails/reconcile"; +import { parseThumbnailUrl } from "../../thumbnails/keys"; +import { runInBackground } from "../../../lib/background"; import { z } from "zod"; import { validate } from "../../../lib/validate"; @@ -233,14 +236,36 @@ groupRoutes.delete( const db = getDb(c.env.DB); + // Read first: the cascade takes the rows naming them. + const elements = await db + .select({ elementId: insertables.elementId }) + .from(insertables) + .where(eq(insertables.groupId, groupId)); + // Cascade deletes insertables → favorites, and configurations automatically const [deleted] = await db .delete(groups) .where(and(eq(groups.id, groupId), eq(groups.libraryId, libraryId))) - .returning({ documentId: groups.documentId }); + .returning({ + documentId: groups.documentId, + smallThumbnailUrl: groups.smallThumbnailUrl + }); - // The document's webhook goes with the last group loaded from it. if (deleted) { + const thumbnailElement = deleted.smallThumbnailUrl + ? parseThumbnailUrl(deleted.smallThumbnailUrl)?.elementId + : undefined; + await runInBackground(c, "delete the group's thumbnails", () => + deleteStaleThumbnails(c.env.BLOB, db, { + documentId: deleted.documentId, + elementIds: [ + ...elements.map((row) => row.elementId), + ...(thumbnailElement ? [thumbnailElement] : []) + ] + }).then(() => undefined) + ); + + // The document's webhook goes with the last group loaded from it. const stillUsed = await db .select({ id: groups.id }) .from(groups) diff --git a/src/backend/features/library/groups/routes.worker.test.ts b/src/backend/features/library/groups/routes.worker.test.ts index b3889d8e1..4fb1a2428 100644 --- a/src/backend/features/library/groups/routes.worker.test.ts +++ b/src/backend/features/library/groups/routes.worker.test.ts @@ -19,6 +19,7 @@ import type { JobStatus } from "../../load/contract"; import { searchIndexKey } from "../db"; import { SEARCH_OPTIONS, type SearchDocument } from "../../search/contract"; import * as DocumentsEndpoint from "../../../lib/onshape/endpoints/documents"; +import * as Reconcile from "../../thumbnails/reconcile"; import * as Jobs from "../../load/jobs"; const db = getDb(env.DB); @@ -175,6 +176,29 @@ describe("group admin routes", () => { expect(await db.select().from(groups).all()).toHaveLength(0); expect(await db.select().from(insertables).all()).toHaveLength(0); }); + + it("DELETE /group cleans up the thumbnails of the elements it had", async () => { + await seedTestData(db); + const elementIds = ( + await db + .select({ elementId: insertables.elementId }) + .from(insertables) + ).map((row) => row.elementId); + const clean = vi + .spyOn(Reconcile, "deleteStaleThumbnails") + .mockResolvedValue(0); + + await createTestApp().request( + `/api/group/library/${TEST_LIBRARY_ID}?groupId=${TEST_GROUP_ID}`, + jsonRequest("DELETE"), + env + ); + + expect(clean.mock.calls[0][2].elementIds).toEqual( + expect.arrayContaining(elementIds) + ); + clean.mockRestore(); + }); }); describe("GET /job-status", () => { diff --git a/src/backend/features/load/load-group.ts b/src/backend/features/load/load-group.ts index 60243de98..b3f5858bd 100644 --- a/src/backend/features/load/load-group.ts +++ b/src/backend/features/load/load-group.ts @@ -33,6 +33,7 @@ import { syncThumbnailWorkspace } from "../thumbnails/workspace"; import { ONSHAPE_STEP_RETRIES, uploadThumbnailsStep } from "./steps"; +import { deleteStaleThumbnails } from "../thumbnails/reconcile"; interface GroupLoadResult { loadedElements: number; @@ -125,6 +126,24 @@ export async function loadGroup( }) ); + // After the save, so the rows name what this load stored. + await ctx.step + .do(`delete-stale-thumbnails-${groupId}`, () => + deleteStaleThumbnails(ctx.env.BLOB, getDb(ctx.env.DB), { + documentId: versionPath.documentId, + elementIds: [ + ...contents.elements.map((element) => element.id), + ...storedInsertables.map((stored) => stored.elementId) + ] + }) + ) + .catch((error: unknown) => { + console.error( + `Failed to delete stale thumbnails of ${groupId}`, + error + ); + }); + // Only once the row names the kept workspace. A leftover is clutter, not // breakage, so this is never fatal. if (failedInsertableIds.length === 0) { diff --git a/src/backend/features/thumbnails/reconcile.ts b/src/backend/features/thumbnails/reconcile.ts index 842300085..344045715 100644 --- a/src/backend/features/thumbnails/reconcile.ts +++ b/src/backend/features/thumbnails/reconcile.ts @@ -1,140 +1,108 @@ /** - * Deletes stored thumbnails nothing points at. Nothing else expires them, so an - * edited or removed element would leave its thumbnails behind for good. + * Deletes a document's stored thumbnails nothing points at any more. Nothing + * else expires them, so an edited or removed element would leave its + * thumbnails behind for good. */ -import { isNotNull, or } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; +import { chunkForInArray } from "../../db/chunk"; import { type Db } from "../../db/client"; import { groups, insertables } from "../../db/schema"; import { THUMBNAIL_PREFIX, parseThumbnailKey, parseThumbnailUrl, - subjectKey, - type ThumbnailSubject + subjectKey } from "./keys"; /** R2 returns at most this many per call, and takes at most this many to delete. */ const R2_BATCH = 1000; -/** Bounds one run; a bigger bucket is finished by later runs. */ -const MAX_PAGES = 50; +/** + * A load stores thumbnails before the rows naming them, and a load replacing + * another can be storing them while the one it replaced cleans up. + */ +export const STALE_THUMBNAIL_GRACE_MS = 60 * 60 * 1000; + +interface StaleThumbnailsScope { + documentId: string; + /** Every element of the document the thumbnails could belong to, past and present. */ + elementIds: string[]; +} /** - * Renders are stored before the row naming them is written, so anything newer - * than a load could take is left alone. + * Across every library, since another library can load the same document. + * Idempotent. */ -const MIN_AGE_MS = 24 * 60 * 60 * 1000; +export async function deleteStaleThumbnails( + bucket: R2Bucket, + db: Db, + scope: StaleThumbnailsScope, + graceMs = STALE_THUMBNAIL_GRACE_MS, + now = Date.now() +): Promise { + const elementIds = [...new Set(scope.elementIds)]; + const live = await liveSubjects(db, scope.documentId, elementIds); -interface ReconcileResult { - /** Objects under the thumbnail prefix this run looked at. */ - scanned: number; - deleted: number; - /** Keys left alone because nothing recognized them. */ - unrecognized: number; - /** Kept because they are too new to tell apart from a render in flight. */ - tooRecent: number; - /** True when the scan hit its page budget, so more may remain. */ - truncated: boolean; - /** True when nothing was deleted because the live set could not be trusted. */ - skipped: boolean; + let deleted = 0; + for (const elementId of elementIds) { + for (const kind of ["default", "config"]) { + let cursor: string | undefined; + do { + const listed = await bucket.list({ + prefix: `${THUMBNAIL_PREFIX}${kind}/${elementId}/`, + limit: R2_BATCH, + cursor + }); + const stale = listed.objects + .filter((object) => { + const subject = parseThumbnailKey(object.key); + return ( + subject && + !live.has(subjectKey(subject)) && + now - object.uploaded.getTime() >= graceMs + ); + }) + .map((object) => object.key); + if (stale.length > 0) { + await bucket.delete(stale); + deleted += stale.length; + } + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); + } + } + return deleted; } -/** Across every library, since a thumbnail key names none. */ -async function liveSubjects(db: Db): Promise> { - const [insertableRows, groupRows] = await Promise.all([ - db +async function liveSubjects( + db: Db, + documentId: string, + elementIds: string[] +): Promise> { + const live = new Set(); + for (const chunk of chunkForInArray(elementIds)) { + const rows = await db .select({ elementId: insertables.elementId, microversionId: insertables.microversionId }) - .from(insertables), - // A group's thumbnail element is often not one of its insertables. - db - .select({ - smallThumbnailUrl: groups.smallThumbnailUrl, - largeThumbnailUrl: groups.largeThumbnailUrl - }) - .from(groups) - .where( - or( - isNotNull(groups.smallThumbnailUrl), - isNotNull(groups.largeThumbnailUrl) - ) - ) - ]); - - const live = new Set(insertableRows.map(subjectKey)); + .from(insertables) + .where(inArray(insertables.elementId, chunk)); + rows.forEach((row) => live.add(subjectKey(row))); + } + // A group's thumbnail element is often not one of its insertables. + const groupRows = await db + .select({ + smallThumbnailUrl: groups.smallThumbnailUrl, + largeThumbnailUrl: groups.largeThumbnailUrl + }) + .from(groups) + .where(eq(groups.documentId, documentId)); for (const row of groupRows) { for (const url of [row.smallThumbnailUrl, row.largeThumbnailUrl]) { const subject = url ? parseThumbnailUrl(url) : undefined; - if (subject) { - live.add(subjectKey(subject)); - } + if (subject) live.add(subjectKey(subject)); } } return live; } - -/** Whether a key's subject is one the library still shows. */ -function isLive(live: Set, subject: ThumbnailSubject): boolean { - return live.has(subjectKey(subject)); -} - -/** - * Idempotent. An empty live set deletes nothing: it could equally be a failed - * read. - */ -export async function reconcileThumbnails( - bucket: R2Bucket, - db: Db, - now: number = Date.now() -): Promise { - const live = await liveSubjects(db); - const result: ReconcileResult = { - scanned: 0, - deleted: 0, - unrecognized: 0, - tooRecent: 0, - truncated: false, - skipped: live.size === 0 - }; - if (result.skipped) { - return result; - } - - let cursor: string | undefined; - for (let page = 0; page < MAX_PAGES; page++) { - const listed = await bucket.list({ - prefix: THUMBNAIL_PREFIX, - limit: R2_BATCH, - cursor - }); - result.scanned += listed.objects.length; - - const orphaned: string[] = []; - for (const object of listed.objects) { - const subject = parseThumbnailKey(object.key); - if (!subject) { - result.unrecognized += 1; - } else if (isLive(live, subject)) { - continue; - } else if (now - object.uploaded.getTime() < MIN_AGE_MS) { - result.tooRecent += 1; - } else { - orphaned.push(object.key); - } - } - if (orphaned.length > 0) { - await bucket.delete(orphaned); - result.deleted += orphaned.length; - } - - if (!listed.truncated) { - return result; - } - cursor = listed.cursor; - } - - result.truncated = true; - return result; -} diff --git a/src/backend/features/thumbnails/reconcile.worker.test.ts b/src/backend/features/thumbnails/reconcile.worker.test.ts index e2e3fcca5..78a6ece4f 100644 --- a/src/backend/features/thumbnails/reconcile.worker.test.ts +++ b/src/backend/features/thumbnails/reconcile.worker.test.ts @@ -10,19 +10,19 @@ import { import { ThumbnailSize } from "./contract"; import { thumbnailKey, thumbnailUrl } from "./keys"; import { DEFAULT_CONFIGURATION_KEY } from "../configurations/contract"; -import { reconcileThumbnails } from "./reconcile"; +import { deleteStaleThumbnails } from "./reconcile"; import { LibraryId } from "../library/library-id"; -const LIVE_ELEMENT = "element-1"; +const ELEMENT = "element-1"; const LIVE_MICROVERSION = "mv-live"; const OLD_MICROVERSION = "mv-old"; +const DOCUMENT = "doc-g1"; -/** A day and a half on, so what these tests store is past the grace period. */ -const LATER = Date.now() + 36 * 60 * 60 * 1000; +/** Past the grace period, so what these tests store counts as settled. */ +const LATER = Date.now() + 2 * 60 * 60 * 1000; let db: Db; -/** Writes a byte at each key, which is all reconciliation reads. */ async function store(...keys: string[]): Promise { await Promise.all(keys.map((key) => env.BLOB.put(key, "x"))); } @@ -32,13 +32,21 @@ async function storedKeys(): Promise { return listed.objects.map((object) => object.key).sort(); } -/** Both sizes for one subject, as the store always writes them in a pair. */ function defaultKeys(elementId: string, microversionId: string): string[] { return Object.values(ThumbnailSize).map((size) => thumbnailKey(elementId, microversionId, size) ); } +const clean = (elementIds: string[], now = LATER) => + deleteStaleThumbnails( + env.BLOB, + db, + { documentId: DOCUMENT, elementIds }, + undefined, + now + ); + beforeEach(async () => { db = getDb(env.DB); await resetDb(db); @@ -46,184 +54,86 @@ beforeEach(async () => { await Promise.all(listed.objects.map((o) => env.BLOB.delete(o.key))); }); -describe("reconcileThumbnails", () => { - it("keeps what an insertable still names and deletes the rest", async () => { +describe("deleteStaleThumbnails", () => { + it("deletes an element's old microversion, renders included", async () => { await seedPartStudio(db, { - elementId: LIVE_ELEMENT, + elementId: ELEMENT, microversionId: LIVE_MICROVERSION }); + const render = (microversionId: string) => + thumbnailKey( + ELEMENT, + microversionId, + ThumbnailSize.LARGE, + "size=l" + ); await store( - ...defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION), - ...defaultKeys(LIVE_ELEMENT, OLD_MICROVERSION), - ...defaultKeys("element-gone", LIVE_MICROVERSION) + ...defaultKeys(ELEMENT, LIVE_MICROVERSION), + ...defaultKeys(ELEMENT, OLD_MICROVERSION), + render(LIVE_MICROVERSION), + render(OLD_MICROVERSION) ); - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.deleted).toBe(4); + expect(await clean([ELEMENT])).toBe(3); expect(await storedKeys()).toEqual( - defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION).sort() + [ + ...defaultKeys(ELEMENT, LIVE_MICROVERSION), + render(LIVE_MICROVERSION) + ].sort() ); }); - it("deletes a configuration render whose microversion moved on", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - const liveConfig = thumbnailKey( - LIVE_ELEMENT, - LIVE_MICROVERSION, - ThumbnailSize.LARGE, - "size=l" - ); - const staleConfig = thumbnailKey( - LIVE_ELEMENT, - OLD_MICROVERSION, - ThumbnailSize.LARGE, - "size=l" - ); - await store(liveConfig, staleConfig); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); + it("deletes everything of an element nothing names any more", async () => { + await store(...defaultKeys("element-removed", OLD_MICROVERSION)); + expect(await clean(["element-removed"])).toBe(2); + expect(await storedKeys()).toEqual([]); + }); - expect(result.deleted).toBe(1); - expect(await storedKeys()).toEqual([liveConfig]); + it("leaves elements outside the document alone", async () => { + await store(...defaultKeys("elsewhere", OLD_MICROVERSION)); + expect(await clean([ELEMENT])).toBe(0); + expect(await storedKeys()).toHaveLength(2); }); // The row's urls are the only record of which element it is. - it("keeps the document thumbnail a group's urls still point at", async () => { + it("keeps the document thumbnail a group's urls point at", async () => { const subject = { - elementId: "doc-thumbnail-element", + elementId: "thumbnail-tab", microversionId: "mv-doc" }; - await seedGroup(db, "g1", undefined, { - smallThumbnailUrl: thumbnailUrl({ - ...subject, - size: ThumbnailSize.SMALL, - configurationKey: DEFAULT_CONFIGURATION_KEY - }), - largeThumbnailUrl: thumbnailUrl({ + const url = (size: ThumbnailSize) => + thumbnailUrl({ ...subject, - size: ThumbnailSize.LARGE, + size, configurationKey: DEFAULT_CONFIGURATION_KEY - }) - }); - await seedInsertable(db, { - groupId: "g1", - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - const keys = defaultKeys(subject.elementId, subject.microversionId); - await store(...keys); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.deleted).toBe(0); - expect(await storedKeys()).toEqual(keys.sort()); - }); - - it("leaves a key it does not recognize alone", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await store("thumbnails/something-else", "thumbnails/config/only-two"); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.unrecognized).toBe(2); - expect(result.deleted).toBe(0); - expect(await storedKeys()).toHaveLength(2); - }); - - it("touches nothing outside the thumbnail prefix", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await env.BLOB.put("search-index/frcDesignLib.json", "{}"); - - await reconcileThumbnails(env.BLOB, db, LATER); - - expect(await env.BLOB.get("search-index/frcDesignLib.json")).not.toBe( - null - ); - }); - - it("deletes nothing when the live set is empty", async () => { - await store(...defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION)); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); - - expect(result.skipped).toBe(true); - expect(result.deleted).toBe(0); - expect(await storedKeys()).toHaveLength(2); - }); - - it("is idempotent, so a retried step re-deletes nothing", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await store(...defaultKeys(LIVE_ELEMENT, OLD_MICROVERSION)); - - expect((await reconcileThumbnails(env.BLOB, db, LATER)).deleted).toBe( - 2 - ); - const second = await reconcileThumbnails(env.BLOB, db, LATER); - expect(second.deleted).toBe(0); - expect(second.scanned).toBe(0); - }); - - // Renders are stored before their rows are written, so a new one could be in flight. - it("keeps an orphan too new to tell apart from a render in flight", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); - await store(...defaultKeys("element-being-added", "mv-new")); - - // Reconciled now, so what was just stored is inside the grace period. - const result = await reconcileThumbnails(env.BLOB, db); - - expect(result.tooRecent).toBe(2); - expect(result.deleted).toBe(0); - expect(await storedKeys()).toHaveLength(2); - }); - - it("collects that same orphan once it is old enough", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION + }); + await seedGroup(db, "g1", undefined, { + smallThumbnailUrl: url(ThumbnailSize.SMALL), + largeThumbnailUrl: url(ThumbnailSize.LARGE) }); - await store(...defaultKeys("element-being-added", "mv-new")); - - const result = await reconcileThumbnails(env.BLOB, db, LATER); + await store(...defaultKeys(subject.elementId, subject.microversionId)); - expect(result.tooRecent).toBe(0); - expect(result.deleted).toBe(2); + expect(await clean([subject.elementId])).toBe(0); }); - it("keeps a thumbnail belonging to another library", async () => { - await seedPartStudio(db, { - elementId: LIVE_ELEMENT, - microversionId: LIVE_MICROVERSION - }); + // Another library can load the same document. + it("keeps what another library's insertable still names", async () => { await seedGroup(db, "g-ftc", LibraryId.FTC_DESIGN_LIB); await seedInsertable(db, { - id: "other-library-insertable", + id: "ftc-insertable", groupId: "g-ftc", libraryId: LibraryId.FTC_DESIGN_LIB, - elementId: "element-ftc", - microversionId: "mv-ftc" + elementId: ELEMENT, + microversionId: LIVE_MICROVERSION }); - const keys = defaultKeys("element-ftc", "mv-ftc"); - await store(...keys, ...defaultKeys(LIVE_ELEMENT, LIVE_MICROVERSION)); + await store(...defaultKeys(ELEMENT, LIVE_MICROVERSION)); - const result = await reconcileThumbnails(env.BLOB, db, LATER); + expect(await clean([ELEMENT])).toBe(0); + }); - expect(result.deleted).toBe(0); - expect(await storedKeys()).toHaveLength(4); + it("keeps what was stored too recently to be settled", async () => { + await store(...defaultKeys(ELEMENT, OLD_MICROVERSION)); + expect(await clean([ELEMENT], Date.now())).toBe(0); + expect(await storedKeys()).toHaveLength(2); }); }); diff --git a/src/backend/index.ts b/src/backend/index.ts index eb6c6b93a..a53fa1c89 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -5,19 +5,9 @@ export { PushHub } from "./features/push/push-hub"; import { createApp } from "./app"; import { productionAuth } from "./features/auth/request-auth"; import type { AppBindings } from "./lib/context"; -import { getDb } from "./db/client"; -import { reconcileThumbnails } from "./features/thumbnails/reconcile"; const app = createApp(productionAuth); export default { - fetch: app.fetch, - /** The daily cron in wrangler.jsonc. */ - scheduled(_controller, env, ctx) { - ctx.waitUntil( - reconcileThumbnails(env.BLOB, getDb(env.DB)).then((result) => { - console.log("Reconciled thumbnails", result); - }) - ); - } + fetch: app.fetch } satisfies ExportedHandler; diff --git a/wrangler.jsonc b/wrangler.jsonc index d52c873f6..fb171890d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -69,11 +69,6 @@ "durable_objects": { "bindings": [{ "name": "PUSH_HUB", "class_name": "PushHub" }] }, - // Daily, clearing thumbnails nothing shows any more; see the scheduled - // handler in src/backend/index.ts. Inherited by every environment. - "triggers": { - "crons": ["0 9 * * *"] - }, "migrations": [ { "tag": "v1", From 995e420e2f93275e7de82a233f192aacd7e67332 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 23:51:06 +0000 Subject: [PATCH 50/88] Keep ui-state in one Zustand store, local only, and drop synced settings - ui-state is one Zustand store, read through selectors, with each field parsed on its own so a stale value only resets itself. Stored fields go to localStorage and this tab's to sessionStorage, under the same keys as before. - Theme is dark or light, dark by default, toggled beside the settings gear; Onshape's color scheme is no longer followed. - The tab, group and theme stay in the browser: the settings route and the users columns behind it go. /init hands the launch to /, which resumes the tab and records the open through POST /api/app-open. - The insert menu's config param and stored state are its selection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDekeqMAagMXf7o9zF2SxJ --- docs/REFERENCE.md | 24 +- drizzle/0004_library_loading.sql | 5 +- drizzle/meta/0004_snapshot.json | 24 +- drizzle/meta/_journal.json | 2 +- package-lock.json | 32 +- package.json | 3 +- src/__test_utils__/seed.ts | 9 +- src/backend/app.ts | 5 +- src/backend/db/schema.ts | 9 +- .../features/auth/guards.worker.test.ts | 9 +- src/backend/features/entry/routes.ts | 97 ++----- .../features/entry/routes.worker.test.ts | 236 +++------------ .../features/favorites/routes.worker.test.ts | 2 +- src/backend/features/settings/app-tab.test.ts | 31 -- src/backend/features/settings/routes.ts | 44 --- .../features/settings/routes.worker.test.ts | 79 ----- src/backend/features/settings/settings.ts | 21 -- src/frontend/components/app-navbar.tsx | 35 ++- src/frontend/features/auth/access-level.tsx | 5 +- .../features/dashboard/dashboard-navbar.tsx | 7 +- .../favorites/components/favorites-list.tsx | 4 +- .../insert/components/insert-menu.tsx | 24 +- .../features/insert/open-insert-menu.tsx | 7 +- .../features/insert/restore-insert-menu.ts | 21 +- .../library/components/program-select.tsx | 10 +- .../settings/components/settings-menu.tsx | 20 +- .../settings/components/vendor-filters.tsx | 9 +- src/frontend/features/settings/settings.ts | 28 -- src/frontend/lib/app-params.ts | 24 +- src/frontend/lib/app-tab.test.ts | 19 ++ .../settings => frontend/lib}/app-tab.ts | 12 +- src/frontend/lib/onshape-launch.ts | 7 - src/frontend/lib/onshape-params.ts | 32 +- src/frontend/lib/tabs.ts | 14 +- src/frontend/lib/ui-state.test.ts | 76 +---- src/frontend/lib/ui-state.ts | 273 ++++++------------ src/frontend/main.tsx | 3 - src/frontend/routes/__root.tsx | 13 +- .../library/$libraryId/groups/$groupId.tsx | 8 +- .../routes/app/library/$libraryId/index.tsx | 21 +- .../routes/app/library/$libraryId/route.tsx | 6 +- src/frontend/routes/app/route.tsx | 29 +- src/frontend/routes/index.tsx | 13 +- 43 files changed, 398 insertions(+), 954 deletions(-) delete mode 100644 src/backend/features/settings/app-tab.test.ts delete mode 100644 src/backend/features/settings/routes.ts delete mode 100644 src/backend/features/settings/routes.worker.test.ts delete mode 100644 src/backend/features/settings/settings.ts delete mode 100644 src/frontend/features/settings/settings.ts create mode 100644 src/frontend/lib/app-tab.test.ts rename src/{backend/features/settings => frontend/lib}/app-tab.ts (65%) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index bb6718a05..6398981f7 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -115,7 +115,7 @@ The asset binding is configured with `single-page-application` mode, which means ## How Users Get Into the App -The server decides where a caller lands before the app loads. The client has one redirect of its own: `/` resumes the last tab and group from `localStorage`. +The server only gates on sign-in. Where a caller lands is the client's: `/` resumes the last tab and group from `localStorage`, so a new browser starts on the welcome. ### From Onshape (`/init`) @@ -123,9 +123,9 @@ Onshape opens the panel at `/init?documentId=…&instanceType=…&elementId=…& 1. A version or microversion is sent to `/version-error`: there is nothing to insert into. 2. If Onshape won't take the caller's session, `/init` sends them through sign-in and back to itself, marked with `signInAttempted` so it never bounces twice. -3. Otherwise it redirects into the app: to the tab and group the caller's row last recorded, with Onshape's parameters kept and their saved `theme` and `tabId` added. +3. Otherwise it redirects to `/` with Onshape's parameters kept. `/` counts a launch from Onshape as an open of the library it resumes, through `POST /api/app-open/library/:libraryId`. -The `/app` route reads all of that off the url once per page load, into `ui-state` (`routes/app/route.tsx`). From then on the store is what the app reads, and in-app navigation keeps only the app's own parameters (`q`, `part`, `config`, `favorite`) in the url. +The `/app` route reads Onshape's parameters off the url once per page load, into `ui-state` (`routes/app/route.tsx`). From then on the store is what the app reads, and in-app navigation keeps only the app's own parameters (`q`, `part`, `config`, `favorite`) in the url. ### Signing in @@ -135,13 +135,13 @@ The `/app` route reads all of that off the url once per page load, into `ui-stat ## Storage at a Glance -| Store | What it holds | Lifetime | Who reads/writes it | -| ------------------ | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| **D1** | Library data, groups, parts (insertables), configurations, user preferences, favorites | Permanent (until explicitly changed) | Backend Worker on every API request | -| **KV** | OAuth session state (during login) and auth tokens (after login) | Login state: 10 minutes. Tokens: 30 days. | Backend Worker in `src/backend/features/auth/session.ts` | -| **R2** | Thumbnail images and per-library search indexes | Defaults and indexes permanent; configuration thumbnails ~90 days | Backend Worker in `src/backend/features/thumbnails/` and `src/backend/features/library/db.ts` | -| **localStorage** | UI state: open/closed panels, active search query, vendor filters, last-opened group | Persists across browser sessions | Frontend only, via `src/frontend/lib/ui-state.ts` | -| **sessionStorage** | Not used | — | — | +| Store | What it holds | Lifetime | Who reads/writes it | +| ------------------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **D1** | Library data, groups, parts (insertables), configurations, users, favorites | Permanent (until explicitly changed) | Backend Worker on every API request | +| **KV** | OAuth session state (during login) and auth tokens (after login) | Login state: 10 minutes. Tokens: 30 days. | Backend Worker in `src/backend/features/auth/session.ts` | +| **R2** | Thumbnail images and per-library search indexes | Defaults and indexes permanent; configuration thumbnails ~90 days | Backend Worker in `src/backend/features/thumbnails/` and `src/backend/features/library/db.ts` | +| **localStorage** | UI state: theme, last tab and group, open/closed panels, search query, vendor filters | Persists across browser sessions | Frontend only, via `src/frontend/lib/ui-state.ts` | +| **sessionStorage** | Not used | — | — | ## Codebase Map @@ -161,7 +161,7 @@ owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. - `lib/onshape/` — everything that talks to Onshape's REST API: `client.ts` (the client class), `api-path.ts`, `path.ts` (`ElementPath`/`InstancePath` and their serializers), `endpoints/` (per-category wrappers), `objects/` (feature and query builders) - `features/` — one directory per feature, each holding its own `routes.ts` plus whatever it owns: - `auth/` — split by role: `session.ts` stores the session cookie and its KV records, `onshape-oauth.ts` runs the handshake, `caller.ts` resolves who is calling (and exports `productionCaller`, the wiring `createApp` binds), `guards.ts` holds both gates, and `routes.ts` serves the OAuth redirects plus `/access-data` - - `entry/` — `/init`, where Onshape lands: gates on auth, then resumes the caller in the library and theme they last used + - `entry/` — `/init`, where Onshape lands: gates on auth, then hands the launch to the app - `settings/` — the caller's stored preferences and the `Settings` model - `library/` — the library response (`db.ts`), its DTOs, and the groups and insertables endpoints - `load/` — everything that turns Onshape into what we store: the `parse-*` modules (document contents, configurations, configuration records, vendors, fasten info), the per-group and per-insertable loaders, the Workflows that drive them, their retry policies, and the job tracker @@ -176,7 +176,7 @@ owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. - `main.tsx` — React root; wraps the app in `QueryClientProvider` and `MantineProvider` - `routes/` — file-based TanStack Router routes -- `lib/` — cross-cutting helpers: `api-client.ts` (fetch wrappers), `query-keys.ts` (every query key in one place), `query-client.ts`, `ui-state.ts` (localStorage state), `refresh.ts`, `notifications.tsx` +- `lib/` — cross-cutting helpers: `api-client.ts` (fetch wrappers), `query-keys.ts` (every query key in one place), `query-client.ts`, `ui-state.ts` (the Zustand store, kept in localStorage and sessionStorage), `refresh.ts`, `notifications.tsx` - `components/` — UI used by more than one feature, plus the app shell (`app-navbar.tsx`, `alerts.tsx`, `root-error.tsx`) - `features/` — `library/`, `favorites/`, `insert/`, `search/`, `settings/`, `thumbnails/`, `build-status/`, `auth/`, each with a `queries.ts` and a `components/` directory diff --git a/drizzle/0004_library_loading.sql b/drizzle/0004_library_loading.sql index b176a576d..0a4185253 100644 --- a/drizzle/0004_library_loading.sql +++ b/drizzle/0004_library_loading.sql @@ -22,4 +22,7 @@ ALTER TABLE `groups` ADD `thumbnail_workspace_id` text;--> statement-breakpoint ALTER TABLE `insertables` ADD `excluded_parameter_ids` text DEFAULT '[]' NOT NULL;--> statement-breakpoint ALTER TABLE `libraries` ADD `admin_team_id` text;--> statement-breakpoint ALTER TABLE `libraries` ADD `admin_team` text DEFAULT '[]' NOT NULL;--> statement-breakpoint -ALTER TABLE `libraries` ADD `approve_versions` integer DEFAULT false NOT NULL; \ No newline at end of file +ALTER TABLE `libraries` ADD `approve_versions` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `theme`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `tab_id`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `group_id`; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json index 70a4a356d..7cf368a63 100644 --- a/drizzle/meta/0004_snapshot.json +++ b/drizzle/meta/0004_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "6e982faa-641c-47ec-9d82-683289f15f2f", + "id": "d12f3484-8212-4351-baf2-4f6d425b9bd6", "prevId": "d8945a24-5643-4675-bf1f-b32cff1dbea2", "tables": { "configurations": { @@ -639,14 +639,6 @@ "notNull": true, "autoincrement": false }, - "theme": { - "name": "theme", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'system'" - }, "library_id": { "name": "library_id", "type": "text", @@ -654,20 +646,6 @@ "notNull": true, "autoincrement": false, "default": "'frc-design-lib'" - }, - "tab_id": { - "name": "tab_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false } }, "indexes": {}, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a6c03c3bf..fbdb81de6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -33,7 +33,7 @@ { "idx": 4, "version": "6", - "when": 1790355366486, + "when": 1790379686803, "tag": "0004_library_loading", "breakpoints": true } diff --git a/package-lock.json b/package-lock.json index c9b8fe405..938733aac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "react-dom": "^19.2.7", "recharts": "^3.10.1", "typescript-parsec": "^0.3.4", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.15" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.45.1", @@ -9669,6 +9670,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 3e49c2f33..5dc59593d 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "react-dom": "^19.2.7", "recharts": "^3.10.1", "typescript-parsec": "^0.3.4", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.15" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.45.1", diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 4feec3821..ad95634ed 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -1,4 +1,3 @@ -import { type AppTab, isLibraryTab } from "@backend/features/settings/app-tab"; import { type Db } from "@backend/db/client"; import { configurations, @@ -103,14 +102,10 @@ export async function seedLibrary( /** Also seeds the default library its dead `library_id` column falls back to. */ export async function seedUser( db: Db, - id: string = TEST_USER_ID, - tabId: AppTab = DEFAULT_LIBRARY + id: string = TEST_USER_ID ): Promise { await seedLibrary(db, DEFAULT_LIBRARY); - if (isLibraryTab(tabId)) { - await seedLibrary(db, tabId); - } - await db.insert(users).values({ id, tabId }).onConflictDoNothing(); + await db.insert(users).values({ id }).onConflictDoNothing(); return id; } diff --git a/src/backend/app.ts b/src/backend/app.ts index 53edeaec3..91e98634c 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -2,13 +2,12 @@ import { analyticsRoutes } from "./features/analytics/routes"; import { accessRoutes, authRoutes } from "./features/auth/routes"; import { buildStatusRoutes } from "./features/build-checker/routes"; import { configurationRoutes } from "./features/configurations/routes"; -import { entryRoutes } from "./features/entry/routes"; +import { appOpenRoutes, entryRoutes } from "./features/entry/routes"; import { favoriteRoutes } from "./features/favorites/routes"; import { groupRoutes } from "./features/library/groups/routes"; import { insertLocationRoutes } from "./features/insert-location/routes"; import { insertableRoutes } from "./features/library/insertables/routes"; import { libraryRoutes } from "./features/library/routes"; -import { settingsRoutes } from "./features/settings/routes"; import { thumbnailRoutes } from "./features/thumbnails/routes"; import { webhookRoutes } from "./features/webhooks/routes"; import { pushRoutes } from "./features/push/routes"; @@ -21,7 +20,7 @@ import { errorHandler } from "./lib/errors"; const apiRoutes = [ accessRoutes, - settingsRoutes, + appOpenRoutes, libraryRoutes, groupRoutes, insertableRoutes, diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 7285ac706..5fcc907f3 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -9,8 +9,6 @@ import { import { ElementType } from "../lib/onshape/element-type"; import { FastenInfo } from "../features/library/insertables/fasten"; import { DEFAULT_LIBRARY, LibraryId } from "../features/library/library-id"; -import { AppTab } from "../features/settings/app-tab"; -import { DEFAULT_THEME, Theme } from "../features/settings/settings"; import type { AdminTeamMember } from "../features/admin-team/contract"; import { Vendor } from "../features/library/vendors"; import { @@ -170,16 +168,11 @@ export const configurations = sqliteTable("configurations", { export const users = sqliteTable("users", { id: text("id").primaryKey(), - theme: text("theme").$type().notNull().default(DEFAULT_THEME), // Unused, but SQLite can't drop a column in a foreign key, and D1 won't // rebuild the table while favorites reference it. libraryId: libraryId() .default(DEFAULT_LIBRARY) - .references(() => libraries.id), - // Null until one is picked. No foreign key: not every tab is a library. - tabId: text("tab_id").$type(), - // Null for the tab itself. A stale id resolves to that, so it isn't cleaned. - groupId: text("group_id") + .references(() => libraries.id) }); export const favorites = sqliteTable( diff --git a/src/backend/features/auth/guards.worker.test.ts b/src/backend/features/auth/guards.worker.test.ts index ffb22eab9..2743760c1 100644 --- a/src/backend/features/auth/guards.worker.test.ts +++ b/src/backend/features/auth/guards.worker.test.ts @@ -2,7 +2,6 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; import { AccessLevel } from "./access-level"; import { LibraryId } from "../library/library-id"; -import { Theme } from "../settings/settings"; import { createTestApp, jsonRequest, @@ -30,12 +29,12 @@ describe("requireSignInMiddleware", () => { ); expect(favorites.status).toBe(401); - const userData = await app.request( - "/api/settings", - jsonRequest("POST", { theme: Theme.DARK }), + const appOpen = await app.request( + "/api/app-open/library/" + LibraryId.FRC_DESIGN_LIB, + jsonRequest("POST"), env ); - expect(userData.status).toBe(401); + expect(appOpen.status).toBe(401); }); it("allows sign-in-only routes when signed in", async () => { diff --git a/src/backend/features/entry/routes.ts b/src/backend/features/entry/routes.ts index d47651353..741db869c 100644 --- a/src/backend/features/entry/routes.ts +++ b/src/backend/features/entry/routes.ts @@ -1,19 +1,10 @@ -/** Where Onshape lands: gates on auth, then resumes the last tab and theme. */ -import { and, eq } from "drizzle-orm"; -import { getDb, type Db } from "../../db/client"; -import { groups, users } from "../../db/schema"; +/** Where Onshape lands: gates on auth, then hands the launch to the app. */ import { cacheMiddleware } from "../../lib/cache"; import { getApp, type AppContext } from "../../lib/context"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { isSignedIn } from "../auth/request-auth"; +import { requireSignInMiddleware } from "../auth/guards"; import { getSessionCompanyId, PERSONAL_COMPANY_ID } from "../auth/session"; -import { DEFAULT_THEME } from "../settings/settings"; -import { DEFAULT_LIBRARY } from "../library/library-id"; -import { - type AppTab, - getTabPath, - isLibraryTab, - toAppTab -} from "../settings/app-tab"; import { trackAppOpen, trackInBackground } from "../analytics/tracking"; /** Marks the `/init` a sign-in returns to; see {@link needsSignIn}. */ @@ -45,65 +36,9 @@ function getSignInUrl(c: AppContext): string { return `/auth/sign-in?${query.toString()}`; } -interface AppEntry { - url: string; - /** Absent when nobody is signed in, so there is no one to look up. */ - userId?: string; - /** Where the caller lands: the default until they have chosen. */ - tabId: AppTab; -} - -/** Where the caller left off, as their row records it. */ -function getUserEntry(db: Db, userId: string) { - // A deleted or stale group joins to null, landing in the tab itself. - return db - .select({ - tabId: users.tabId, - theme: users.theme, - groupId: groups.id - }) - .from(users) - .leftJoin( - groups, - and(eq(groups.id, users.groupId), eq(groups.libraryId, users.tabId)) - ) - .where(eq(users.id, userId)) - .get(); -} - -/** Also returns the user id, so `/init` records the open without another lookup. */ -async function getAppEntry(c: AppContext): Promise { - const userId = (await isSignedIn(c)) ? await c.var.getUserId() : undefined; - const user = userId - ? await getUserEntry(getDb(c.env.DB), userId) - : undefined; - - const search = new URL(c.req.url).searchParams; - // Ours, and spent: the app is being opened, however that turned out. - search.delete(SIGN_IN_ATTEMPTED); - const systemTheme = search.get("theme"); - if (systemTheme !== null) { - search.set("systemTheme", systemTheme); - } - search.set("theme", user?.theme ?? DEFAULT_THEME); - - // Validated: the frontend 404s an unknown id. - const chosenTab = user?.tabId - ? toAppTab(user.tabId, DEFAULT_LIBRARY) - : undefined; - // Without a tab the welcome asks for one. - if (chosenTab) { - search.set("tabId", chosenTab); - } - const tabId = chosenTab ?? DEFAULT_LIBRARY; - const path = getTabPath(tabId); - const groupPath = user?.groupId ? `${path}/groups/${user.groupId}` : path; - return { url: `${groupPath}?${search.toString()}`, userId, tabId }; -} - export const entryRoutes = getApp(); -/** GET /init */ +/** GET /init: the app resumes the caller's last tab itself, from the browser's storage. */ entryRoutes.get("/init", cacheMiddleware(), async (c) => { // A version can't be changed, so there is nothing to insert into. const instanceType = c.req.query("instanceType"); @@ -113,12 +48,24 @@ entryRoutes.get("/init", cacheMiddleware(), async (c) => { if (await needsSignIn(c)) { return c.redirect(getSignInUrl(c)); } - const { url, userId, tabId } = await getAppEntry(c); - // Only library opens are logged, since the log is per library. - if (userId && isLibraryTab(tabId)) { + const search = new URL(c.req.url).searchParams; + // Ours, and spent: the app is being opened, however that turned out. + search.delete(SIGN_IN_ATTEMPTED); + return c.redirect(`/?${search.toString()}`); +}); + +export const appOpenRoutes = getApp(); + +/** POST /api/app-open/library/:libraryId: sent by the app on a launch from Onshape. */ +appOpenRoutes.post( + "/app-open" + libraryRoute(), + requireSignInMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + const userId = await c.var.getUserId(); await trackInBackground(c, () => - trackAppOpen(c, { libraryId: tabId, userId }) + trackAppOpen(c, { libraryId, userId }) ); + return c.json({}); } - return c.redirect(url); -}); +); diff --git a/src/backend/features/entry/routes.worker.test.ts b/src/backend/features/entry/routes.worker.test.ts index b909a9e96..ad8ac433a 100644 --- a/src/backend/features/entry/routes.worker.test.ts +++ b/src/backend/features/entry/routes.worker.test.ts @@ -1,21 +1,13 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { eq } from "drizzle-orm"; -import { users } from "../../db/schema"; import { events } from "../analytics/schema"; import { EVENT_SCHEMA_VERSION, EventType } from "../analytics/usage"; -import { DEFAULT_LIBRARY, LibraryId } from "../library/library-id"; -import { type AppTab, UtilityTab } from "../settings/app-tab"; -import { Theme } from "../settings/settings"; +import { LibraryId } from "../library/library-id"; import { - TEST_GROUP_ID, TEST_USER_ID, createTestApp, jsonRequest, - TEST_LIBRARY_ID, resetDb, - seedGroup, - seedLibrary, seedUser } from "../../../__test_utils__"; import { getDb } from "../../db/client"; @@ -27,9 +19,8 @@ describe("GET /init", () => { await resetDb(db); }); - it("sends a user to the library they last used", async () => { - await seedUser(db, TEST_USER_ID, LibraryId.MKCAD); - + // The app resumes the caller's tab from the browser's own storage. + it("hands Onshape's launch to the app", async () => { const res = await createTestApp().request( "/init?documentId=doc&workspaceId=ws", jsonRequest("GET"), @@ -38,177 +29,11 @@ describe("GET /init", () => { expect(res.status).toBe(302); const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.pathname).toBe(`/app/library/${LibraryId.MKCAD}`); - // The Onshape params have to survive the redirect. + expect(location.pathname).toBe("/"); expect(location.searchParams.get("documentId")).toBe("doc"); expect(location.searchParams.get("workspaceId")).toBe("ws"); }); - // A utility tab resumes the same way, and has no library to count under. - it("resumes in a utility tab, and logs no library open", async () => { - await seedUser(db, TEST_USER_ID, UtilityTab.VERSION_MANAGER); - - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - // A utility is a page of its own, not one of the libraries. - expect(location.pathname).toBe(`/app/${UtilityTab.VERSION_MANAGER}`); - expect(await db.select().from(events).get()).toBeUndefined(); - }); - - // The frontend 404s an unknown tab id. - it("sends a user whose stored tab is unknown to the default", async () => { - await seedLibrary(db); - await db - .insert(users) - .values({ id: TEST_USER_ID, tabId: "old-frc-lib" as AppTab }) - .onConflictDoNothing(); - - const res = await createTestApp().request( - "/init?documentId=doc", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.pathname).toBe(`/app/library/${DEFAULT_LIBRARY}`); - expect(location.searchParams.get("documentId")).toBe("doc"); - }); - - it("sends a user with no row to the default library", async () => { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - - expect(res.status).toBe(302); - expect(res.headers.get("Location")).toContain( - `/app/library/${LibraryId.FRC_DESIGN_LIB}` - ); - }); - - it("seeds the saved theme and forwards Onshape's color scheme", async () => { - await seedUser(db); - await db - .update(users) - .set({ theme: Theme.DARK }) - .where(eq(users.id, TEST_USER_ID)); - - const res = await createTestApp().request( - "/init?theme=light", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - // Onshape's scheme becomes systemTheme; theme carries the user's choice. - expect(location.searchParams.get("systemTheme")).toBe("light"); - expect(location.searchParams.get("theme")).toBe(Theme.DARK); - }); - - it("seeds the default theme for a user with no row", async () => { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - - const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.searchParams.get("theme")).toBe(Theme.SYSTEM); - }); - - it("seeds the tab a row names, and none for a user who has not chosen", async () => { - const seededTab = async () => { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - const location = new URL(res.headers.get("Location")!, "http://x"); - return [ - location.pathname, - location.searchParams.get("tabId") - ] as const; - }; - - expect(await seededTab()).toEqual([ - `/app/library/${DEFAULT_LIBRARY}`, - null - ]); - - await seedLibrary(db); - await db.insert(users).values({ id: TEST_USER_ID }); - expect(await seededTab()).toEqual([ - `/app/library/${DEFAULT_LIBRARY}`, - null - ]); - - await db - .update(users) - .set({ tabId: LibraryId.FTC_DESIGN_LIB }) - .where(eq(users.id, TEST_USER_ID)); - expect(await seededTab()).toEqual([ - `/app/library/${LibraryId.FTC_DESIGN_LIB}`, - LibraryId.FTC_DESIGN_LIB - ]); - }); - - /** The tab and group a user left off in, as their row records them. */ - async function seedResume(tabId: AppTab, groupId: string | null) { - await seedUser(db, TEST_USER_ID, tabId); - await db - .update(users) - .set({ groupId }) - .where(eq(users.id, TEST_USER_ID)); - } - - async function entryPath(): Promise { - const res = await createTestApp().request( - "/init", - jsonRequest("GET"), - env - ); - return new URL(res.headers.get("Location")!, "http://x").pathname; - } - - it("resumes in the group they last opened", async () => { - await seedGroup(db); - await seedResume(TEST_LIBRARY_ID, TEST_GROUP_ID); - - expect(await entryPath()).toBe( - `/app/library/${TEST_LIBRARY_ID}/groups/${TEST_GROUP_ID}` - ); - }); - - it("falls back to the library when the group has been deleted", async () => { - await seedResume(TEST_LIBRARY_ID, "deleted-group"); - - expect(await entryPath()).toBe(`/app/library/${TEST_LIBRARY_ID}`); - }); - - // Whichever library they switched to, they have not opened a group in it. - it("ignores a group belonging to another library", async () => { - await seedGroup(db); - await seedResume(LibraryId.MKCAD, TEST_GROUP_ID); - - expect(await entryPath()).toBe(`/app/library/${LibraryId.MKCAD}`); - }); - - it("versions the open it records", async () => { - await createTestApp().request("/init", jsonRequest("GET"), env); - - const event = await db.select().from(events).get(); - expect(event).toMatchObject({ - type: EventType.APP_OPEN, - schemaVersion: EVENT_SCHEMA_VERSION - }); - }); - /** Where the gate sends a caller Onshape will not take. */ async function signInRedirect(path: string): Promise { const res = await createTestApp({ @@ -251,7 +76,7 @@ describe("GET /init", () => { expect(res.status).toBe(302); const entry = new URL(res.headers.get("Location")!, "http://x"); - expect(entry.pathname).toBe(`/app/library/${LibraryId.FRC_DESIGN_LIB}`); + expect(entry.pathname).toBe("/"); // Spent, so it never reaches the app or a later sign-in. expect(entry.searchParams.has("signInAttempted")).toBe(false); }); @@ -275,19 +100,7 @@ describe("GET /init", () => { ); const location = new URL(res.headers.get("Location")!, "http://x"); - expect(location.pathname).toBe( - `/app/library/${LibraryId.FRC_DESIGN_LIB}` - ); - }); - - // Nobody is signed in, so there is no row to read and no open to record. - it("records no open for a caller it opens signed out", async () => { - await createTestApp({ - isAuthenticated: false, - signedIn: false - }).request("/init?signInAttempted=1", jsonRequest("GET"), env); - - expect(await db.select().from(events).get()).toBeUndefined(); + expect(location.pathname).toBe("/"); }); it.each(["v", "m"])( @@ -311,3 +124,40 @@ describe("GET /init", () => { expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); }); + +describe("POST /app-open", () => { + beforeEach(async () => { + await resetDb(db); + await seedUser(db); + }); + + it("records a versioned open in the library", async () => { + const res = await createTestApp().request( + `/api/app-open/library/${LibraryId.FTC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + + expect(res.status).toBe(200); + expect(await db.select().from(events).get()).toMatchObject({ + type: EventType.APP_OPEN, + libraryId: LibraryId.FTC_DESIGN_LIB, + userId: TEST_USER_ID, + schemaVersion: EVENT_SCHEMA_VERSION + }); + }); + + it("records nothing for a caller who isn't signed in", async () => { + const res = await createTestApp({ + isAuthenticated: false, + signedIn: false + }).request( + `/api/app-open/library/${LibraryId.FTC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + + expect(res.status).not.toBe(200); + expect(await db.select().from(events).get()).toBeUndefined(); + }); +}); diff --git a/src/backend/features/favorites/routes.worker.test.ts b/src/backend/features/favorites/routes.worker.test.ts index dc29dc60b..d3c61a0b7 100644 --- a/src/backend/features/favorites/routes.worker.test.ts +++ b/src/backend/features/favorites/routes.worker.test.ts @@ -55,7 +55,7 @@ interface FavoritesBody { async function fillFavorites(howMany: number) { const db = getDb(env.DB); await seedGroup(db); - await seedUser(db, "test-user", TEST_LIBRARY_ID); + await seedUser(db, "test-user"); const rows = Array.from({ length: howMany }, (_, i) => ({ id: `filler-${i}`, diff --git a/src/backend/features/settings/app-tab.test.ts b/src/backend/features/settings/app-tab.test.ts deleted file mode 100644 index eee736cf7..000000000 --- a/src/backend/features/settings/app-tab.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { LibraryId } from "../library/library-id"; -import { getTabPath, isLibraryTab, toAppTab, UtilityTab } from "./app-tab"; - -describe("app tabs", () => { - it("gives each kind of tab its own path", () => { - expect(getTabPath(LibraryId.FTC_DESIGN_LIB)).toBe( - "/app/library/ftc-design-lib" - ); - expect(getTabPath(UtilityTab.VERSION_MANAGER)).toBe( - "/app/version-manager" - ); - }); - - it("tells a library from a utility", () => { - expect(isLibraryTab(LibraryId.MKCAD)).toBe(true); - expect(isLibraryTab(UtilityTab.VERSION_MANAGER)).toBe(false); - }); - - it("falls back for a tab the app no longer knows", () => { - expect(toAppTab("old-frc-lib", LibraryId.FRC_DESIGN_LIB)).toBe( - LibraryId.FRC_DESIGN_LIB - ); - expect(toAppTab(undefined, LibraryId.FRC_DESIGN_LIB)).toBe( - LibraryId.FRC_DESIGN_LIB - ); - expect(toAppTab(UtilityTab.VERSION_MANAGER, LibraryId.MKCAD)).toBe( - UtilityTab.VERSION_MANAGER - ); - }); -}); diff --git a/src/backend/features/settings/routes.ts b/src/backend/features/settings/routes.ts deleted file mode 100644 index 63aab8e04..000000000 --- a/src/backend/features/settings/routes.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { eq } from "drizzle-orm"; -import { getApp } from "../../lib/context"; -import { getDb } from "../../db/client"; -import { users } from "../../db/schema"; -import { z } from "zod"; -import { validate } from "../../lib/validate"; -import { requireSignInMiddleware } from "../auth/guards"; -import { DEFAULT_LIBRARY, LibraryId } from "../library/library-id"; -import { ensureLibrary } from "../library/db"; -import { UtilityTab } from "./app-tab"; -import { Theme } from "./settings"; - -export const settingsRoutes = getApp(); - -const settingsBody = z.object({ - theme: z.enum(Theme).optional(), - tabId: z.union([z.enum(LibraryId), z.enum(UtilityTab)]).optional(), - // Null on leaving a group: the caller resumes in the tab itself. - groupId: z.string().nullable().optional() -}); - -/** POST /api/settings — update the caller's stored settings */ -settingsRoutes.post( - "/settings", - requireSignInMiddleware, - validate("json", settingsBody), - async (c) => { - const userId = await c.var.getUserId(); - const body = c.req.valid("json"); - - const db = getDb(c.env.DB); - - // The dead `library_id` column still references the default library. - await ensureLibrary(db, DEFAULT_LIBRARY); - // No tab: a caller who has not chosen one has nothing to record. - await db.insert(users).values({ id: userId }).onConflictDoNothing(); - - if (Object.keys(body).length > 0) { - await db.update(users).set(body).where(eq(users.id, userId)); - } - - return c.json({ success: true }); - } -); diff --git a/src/backend/features/settings/routes.worker.test.ts b/src/backend/features/settings/routes.worker.test.ts deleted file mode 100644 index 16e720cc6..000000000 --- a/src/backend/features/settings/routes.worker.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { eq } from "drizzle-orm"; -import { env } from "cloudflare:workers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { users } from "../../db/schema"; -import { LibraryId } from "../library/library-id"; - -import { Theme } from "./settings"; -import { - TEST_USER_ID, - createTestApp, - jsonRequest, - resetDb -} from "../../../__test_utils__"; -import { getDb } from "../../db/client"; - -const db = getDb(env.DB); - -describe("settings routes", () => { - beforeEach(async () => { - await resetDb(db); - }); - - it("POST /settings updates the caller's settings", async () => { - const app = createTestApp(); - - const res = await app.request( - "/api/settings", - jsonRequest("POST", { theme: Theme.DARK }), - env - ); - expect(res.status).toBe(200); - - const row = await db - .select() - .from(users) - .where(eq(users.id, TEST_USER_ID)) - .get(); - expect(row?.theme).toBe(Theme.DARK); - }); - - it("POST /settings records the tab the caller chose", async () => { - const app = createTestApp(); - - const res = await app.request( - "/api/settings", - jsonRequest("POST", { tabId: LibraryId.FTC_DESIGN_LIB }), - env - ); - expect(res.status).toBe(200); - - const row = await db - .select() - .from(users) - .where(eq(users.id, TEST_USER_ID)) - .get(); - expect(row?.tabId).toBe(LibraryId.FTC_DESIGN_LIB); - }); - - it("POST /settings records and clears the open group", async () => { - const app = createTestApp(); - const post = (body: unknown) => - app.request("/api/settings", jsonRequest("POST", body), env); - const storedGroupId = async () => - ( - await db - .select() - .from(users) - .where(eq(users.id, TEST_USER_ID)) - .get() - )?.groupId; - - await post({ groupId: "group-1" }); - expect(await storedGroupId()).toBe("group-1"); - - // Null is leaving the group, which is not the same as saying nothing. - await post({ groupId: null }); - expect(await storedGroupId()).toBeNull(); - }); -}); diff --git a/src/backend/features/settings/settings.ts b/src/backend/features/settings/settings.ts deleted file mode 100644 index 6b18c3690..000000000 --- a/src/backend/features/settings/settings.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AppTab } from "./app-tab"; - -export enum Theme { - SYSTEM = "system", - LIGHT = "light", - DARK = "dark" -} - -/** User settings, which the entry redirect reads and seeds the app with. */ -export interface Settings { - theme: Theme; - /** Null until picked, which the welcome asks for. */ - tabId: AppTab | null; - /** The group they last opened in that tab; null for the tab itself. */ - groupId: string | null; -} - -/** Absent leaves a setting as it is; null clears the tab or group. */ -export type SettingsUpdate = Partial; - -export const DEFAULT_THEME = Theme.SYSTEM; diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index ffc3ac3d5..d87f2d00e 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -10,7 +10,12 @@ import { TextInput, Tooltip } from "@mantine/core"; -import { GearIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { + GearIcon, + MagnifyingGlassIcon, + MoonIcon, + SunIcon +} from "@phosphor-icons/react"; import { IconSize, NAVBAR_DIVIDER_COLOR, @@ -29,7 +34,7 @@ import { useDebouncedCallback } from "@mantine/hooks"; import { AppBrand } from "./app-brand"; import { openSettingsMenu } from "../features/settings/open-settings-menu"; import { VendorMenu } from "../features/settings/components/vendor-filters"; -import { getUiState, updateUiState } from "../lib/ui-state"; +import { getUiState, Theme, updateUiState, useUiState } from "../lib/ui-state"; import { getLibraryName, useLibraryId } from "../lib/library"; import { APP_TABS, getTabName, useNavigateToTab } from "../lib/tabs"; import { @@ -38,7 +43,7 @@ import { } from "../features/auth/access-level"; import { startSignIn } from "../features/auth/sign-in"; import { LibraryId } from "@backend/features/library/library-id"; -import { type AppTab } from "@backend/features/settings/app-tab"; +import { type AppTab } from "../lib/app-tab"; import { queryClient } from "../lib/query-client"; import { getLibraryVersionQuery, @@ -81,6 +86,7 @@ export function AppNavbar(): ReactNode { + @@ -144,7 +150,7 @@ function AppTabs(): ReactNode { return; } const tabId = value as AppTab; - // Only decides where `/init` lands next time; the url is the source of truth. + // Only decides where `/` resumes next time; the url is the source of truth. updateUiState({ tabId }); navigateToTab(tabId); }} @@ -177,6 +183,27 @@ function AppTabs(): ReactNode { ); } +export function ThemeToggle(): ReactNode { + const theme = useUiState((state) => state.theme); + const isDark = theme === Theme.DARK; + return ( + + updateUiState({ theme: isDark ? Theme.LIGHT : Theme.DARK }) + } + > + {isDark ? ( + + ) : ( + + )} + + ); +} + export function SettingsButton() { return ( state.accessLevel); return useMemo(() => { const desired = chosenLevel ?? DEFAULT_ACCESS_LEVEL; let currentAccessLevel = desired; diff --git a/src/frontend/features/dashboard/dashboard-navbar.tsx b/src/frontend/features/dashboard/dashboard-navbar.tsx index e0f1aa6c6..c0cadd074 100644 --- a/src/frontend/features/dashboard/dashboard-navbar.tsx +++ b/src/frontend/features/dashboard/dashboard-navbar.tsx @@ -21,7 +21,11 @@ import { type ReactNode } from "react"; import { LibraryId } from "@backend/features/library/library-id"; import { getLibraryName } from "../../lib/library"; import { IconSize, NAVBAR_ROW_HEIGHT } from "../../lib/style-constants"; -import { NavbarRow, SettingsButton } from "../../components/app-navbar"; +import { + NavbarRow, + SettingsButton, + ThemeToggle +} from "../../components/app-navbar"; import { RangeControl } from "./range-control"; import { DASHBOARDS, @@ -46,6 +50,7 @@ export function DashboardNavbar(): ReactNode { + diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index 50a221237..ba8e1f67b 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -13,7 +13,7 @@ import { type FavoritesData } from "@backend/features/favorites/contract"; import type { Insertables } from "@backend/features/library/contract"; -import { useGetUiState } from "../../../lib/ui-state"; +import { useUiState } from "../../../lib/ui-state"; import { SectionNotice, SectionLoading, @@ -35,7 +35,7 @@ import { useVendorFilters } from "../../settings/components/vendor-filters"; /** Unlike DocumentList, this list can be searched directly. */ export function FavoritesList(): ReactNode { - const { searchQuery } = useGetUiState(); + const searchQuery = useUiState((state) => state.searchQuery); const vendorFilters = useVendorFilters(); const { signedIn, isPending } = useAccessData(); diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index a3c1bf453..3dae6b5bf 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -35,9 +35,8 @@ import { type PartialSelection, Selection } from "@backend/features/configurations/contract"; -import { encodeConfiguration } from "@backend/features/configurations/utils"; import { useFavorite } from "../../favorites/queries"; -import { useGetUiState, updateUiState } from "../../../lib/ui-state"; +import { updateUiState, useUiState } from "../../../lib/ui-state"; import { RequireSignIn } from "../../auth/access-level"; import { useTargetElementType } from "../insert-hooks"; import { InsertSource } from "@backend/features/analytics/usage"; @@ -95,10 +94,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { // So a relaunch reopens the configuration on screen. useEffect(() => { if (report) { - updateUiState({ - openConfiguration: - encodeConfiguration(report.overrides) || undefined - }); + updateUiState({ openSelection: report.selection }); onSelectionChange?.(report.selection); } }, [report, onSelectionChange]); @@ -254,7 +250,7 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { isFavorite, source }); - const uiState = useGetUiState(); + const fasten = useUiState((state) => state.fasten); const isLoadingConfiguration = useIsFetchingConfiguration( insertable.id, @@ -265,18 +261,12 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { insertable.supportsFasten && targetElementType === ElementType.ASSEMBLY; const handleClick = useCallback(() => { - insertMutation.mutate(canFasten && uiState.fasten); + insertMutation.mutate(canFasten && fasten); if (canShowQuickInsertTip) { showQuickInsertTip(); } onInsert(); - }, [ - insertMutation, - onInsert, - canFasten, - uiState.fasten, - canShowQuickInsertTip - ]); + }, [insertMutation, onInsert, canFasten, fasten, canShowQuickInsertTip]); if (!targetElementType) { return null; @@ -288,8 +278,8 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { {canFasten && ( updateUiState({ fasten: !uiState.fasten })} + checked={fasten} + onChange={() => updateUiState({ fasten: !fasten })} /> )}