Skip to content

Onshape API types + load workflow rewrite + part-number search - #34

Open
AlexKempen wants to merge 47 commits into
certfrom
claude/part-number-search-indexing-731tsl
Open

Onshape API types + load workflow rewrite + part-number search#34
AlexKempen wants to merge 47 commits into
certfrom
claude/part-number-search-indexing-731tsl

Conversation

@AlexKempen

@AlexKempen AlexKempen commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

This combines the claude/onshape-api-types-i44z11 branch and the part-number
search work into one PR, since the second was built on the first. cert has
been merged in, and the two unreleased migrations are folded into one.

1. Typed Onshape API and hand-authored types

  • @hey-api/openapi-ts generates a reference of Onshape's OpenAPI spec into
    onshape-api-reference/ (committed, lint-ignored, regenerated with
    npm run gen:onshape-types). It is a reference only — nothing imports it.
  • The types the app actually uses are hand-authored in
    onshape-api/onshape-types.ts, because the generated ones are far too loose
    to be useful (almost everything optional). The reference exists so those
    hand-authored types can be checked against what Onshape documents.
  • Every endpoint wrapper in onshape-api/endpoints/ now has a typed return.
    Endpoints stay thin shells around the HTTP call — parsing and business logic
    live in parse/.
  • Configuration parsing was reworked to return parameters directly, and the
    configuration enums were split into shared/configuration-enums.ts.

2. Load workflow rewrite

parse/load-document.ts (621 lines, one monolithic workflow) is replaced by
src/backend/load/:

File Responsibility
workflows.ts LoadLibraryWorkflow and AddGroupWorkflow entry points
load-group.ts Group traversal, insertable selection, removal detection
load-insertable.ts Per-insertable load and the single save
load-part-numbers.ts Part-number indexing (§3)
load-utils.ts LoadContext, shared step helpers, retry policy

Other changes in this half:

  • Splitting one workflow into two means adding a group no longer walks the whole
    library.
  • A group's current version is resolved in its own workflow step
    (getLatestVersionId) and threaded into loadGroup, rather than being
    derived from the document info.
  • versionName / versionCreatedAt are dropped, and instanceId is renamed to
    versionId on groups and insertables.
  • Fixed the reload button not force-reloading documents whose microversion
    hadn't changed.
  • hasConfiguration is gone; group placement moved into the workflow.

3. Part-number search

Insertables get a per-insertable Search part numbers toggle. When enabled,
the load enumerates the insertable's configuration combinations, asks Onshape
for the part number of each, and stores a part number → parameter values map so
a search hit can launch the configuration that produces it.

  • What varies: only enum and boolean parameters. Quantity and string
    parameters ride on their Onshape defaults, since they're unbounded.
  • Deduping: the map is keyed by part number, first-wins. Many
    configurations resolve to the same part (parameters that don't affect
    geometry), and keying this way collapses them so search never surfaces
    duplicates of one part.
  • Storage: the map lives on the configurations row next to parameters;
    a non-configurable insertable stores its single number in
    insertables.default_part_number.
  • Cap: 512 combinations. Past that the insertable records a
    TOO_MANY_CONFIGURATIONS build issue and isn't indexed.
  • Batching: 20 configurations per Workflows step, so a rate-limited retry
    re-fetches only that batch rather than up to 512 calls.
  • Rate limits: the Onshape client turns a 429 into an
    OnshapeRateLimitError carrying Retry-After; steps retry with that delay,
    and the request path maps it to a 429 with retryAfterSeconds in the JSON
    body.
  • Cost control: indexing runs inside loadInsertable, so the existing
    microversion check gates it — an unchanged insertable is skipped on reload
    instead of re-issuing up to 512 calls.
  • The toggle is transactional: enabling computes the part numbers first and
    only then writes the flag alongside them, so a failure leaves the flag off
    and warns the user rather than persisting a half-built index.

Search-side, buildSearchDb indexes the part numbers as a field and carries the
configuration map, so a matched number resolves to the configuration to open.

4. Naming

The word "configuration" meant three different things, so the types were
disambiguated:

Before After
Configuration (parameterId → value) ParameterValues
InsertableConfiguration (the stored row) Configuration
ParameterObj and variants ConfigurationParameter, EnumParameter, …

The parameter types took the names of the components that render them, so those
gained an Input suffix (EnumInput, QuantityInput, …).

5. Migrations

0003_rename_version_id_drop_version_fields and 0004_add_part_number_search
are both unreleased, so they're folded into a single
0003_version_id_rename_and_part_number_search.

Verification

npm run tsc, npm run lint, and npm test (161 tests, 21 files) all pass.
All four migrations were applied to a fresh local D1 via npm run dev and the
resulting columns diffed against the Drizzle schema.

Not yet exercised manually against live Onshape — enabling the toggle on a real
configurable insertable and confirming a reload skips unchanged insertables is
still worth doing before merge.

Noted, not fixed here

  • deploy:cert can't apply migrations (pre-existing on cert). da2e21c
    fixed npm run dev to pass the DB binding, but deploy:cert still passes
    db, which matches neither the binding nor the cert database's name
    (frc-design-app-cert-db). Wrangler then can't resolve migrations_dir and
    fails with "No migrations present at ./migrations", so the migration in this
    PR wouldn't apply on a cert deploy. deploy:production is fine —
    db-production is that database's actual name.
  • isVisible's schema default diverges from the column default. 675b55e
    changed schema.ts to .default(false), but migration 0000 created
    is_visible with DEFAULT true and nothing reconciles them. No runtime
    effect today, since saveInsertable always writes the value explicitly, but
    drizzle-kit generate will want a migration for it.

Open question

loadGroup throws when an insertable fails to load, failing the group. A build
issue on that insertable might be better — it would let the rest of the group
finish. Left as a throw for now.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF

claude and others added 30 commits June 30, 2026 03:17
Replace `any` in the configuration endpoints and parser with generated
Onshape API types, using @hey-api/openapi-ts's parser layer to shape the
upstream spec rather than hand-editing the output.

- Add @hey-api/openapi-ts dev dependency and a `gen:onshape-types` script.
- openapi-ts.config.ts filters to just the configuration operations and
  patches the schema in place: narrows each `btType` to a literal, marks
  the fields the parser reads as required, trims unused branches, and
  rewrites allOf/discriminator polymorphism into explicit `oneOf` unions
  so the concrete subtypes survive orphan pruning.
- Commit the generated output under src/backend/onshape-api/generated and
  re-export friendly aliases from onshape-api/types/configuration.ts.
- Type getConfiguration/setConfiguration/decodeConfiguration and
  parseOnshapeConfiguration with the generated types; narrow on the
  `btType` discriminant in the parser.
- Ignore the generated dir in eslint and allow linting openapi-ts.config.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
Address review feedback on the configuration types work.

- Rewrite openapi-ts.config.ts to be table-driven: a single `patch.input`
  with compact UNIONS/SCHEMAS tables and three small helpers, instead of a
  per-schema callback map. Adding an endpoint is now mostly adding rows.
- Pin the top-level configuration response `btType` to its constant literal.
- Keep `isCosmetic` required on the parameter schema, surface it on
  ParameterBase, and populate it in the parser (for upcoming use).
- Rebuild the parser tests around a trimmed real configuration response
  (covering all parameter kinds, nested logical conditions, and both
  enum-option visibility shapes) instead of synthetic per-type fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
Pivot the Onshape typing approach: the @hey-api codegen now produces a committed
*reference* slice only (nothing imports it), while the types we actually use are
hand-authored under `onshape-api/types/*`. Hand-authoring lets the raw response types
reuse our own enums (ConfigurationParameterType, Unit, QuantityType, OnshapeElementType,
...), carry comments, and form clean discriminated unions.

- Rewrite types/configuration.ts as hand-authored types keyed on the shared enums; this
  removes the `as Unit` / `as QuantityType` / `as LogicalOp` casts in parse-configuration.
- Add hand-authored types/{versions,documents,assemblies,part-studios}.ts covering the
  fields we read. `OnshapeElementType` moves to types/documents.ts (re-exported from the
  documents endpoint for existing importers).
- Type the endpoints used by the load-document workflow and insert-and-fasten route:
  getVersions/getLatestVersion, getDocument/getContents, getAssembly,
  addElementToAssembly, addAssemblyFeature, getFeatures, addPartStudioFeature.
- Type the consumers: load-document folder-tree helpers (now exported), the fasten
  parsers, and the document-thumbnail upload; drop the now-redundant casts.
- Extend openapi-ts.config.ts to also emit versions/contents into the reference, adding a
  `keep` allowlist that prunes unread fields (and their dependency trees) before filtering.
- Add pure-helper unit tests: getValidElements / getOrderedElementIds (load-document) and
  getFastenQuery (insert-and-fasten), and update the configuration fixtures to enum
  discriminants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
Split the LoadDocumentWorkflow into a thin orchestrator plus a tested
load-document-steps module, and make the per-element data flow explicit.

- run() now delegates each step.do to an extracted function (fetchVersion,
  fetchContents, fetchExistingInsertables, matchElements, selectReloads,
  loadElementFasten, loadElementConfiguration, saveDocument), keeping the
  Workflow step names and the batch upsert/delete semantics.
- Replace the unclear ValidElement + five elementId-keyed maps in save-to-db
  with an explicit pipeline: DocumentElement -> MatchedElement -> LoadedElement.
  A new early match-elements step resolves each element's insertable id (reused
  or a fresh UUID) and DB-matched fields up front, so saveDocument reads ids
  straight off each record — no re-query, no id juggling.
- Gate fasten re-parsing on the element's supportsFasten flag (the user's enable
  intent) instead of fastenInfo IS NOT NULL.
- matchElements takes an injected id generator so it's pure/testable; the
  memoized step supplies crypto.randomUUID for idempotent replays.
- Add load-document-steps.test.ts: pure tests (getValidElements,
  getOrderedElementIds, matchElements, selectReloads, buildInsertable/GroupValues),
  mocked-Onshape tests (fetchVersion, fetchContents, loadElement*), and real-D1
  tests (fetchExistingInsertables, saveDocument covering id reuse, preserved
  user columns, and stale deletes). Remove the superseded load-document.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
Lift the group-level logic that was floating inside LoadDocumentWorkflow
into its callers, so the workflow becomes a pure "sync this document, at
this version, into this group" job.

- LoadDocumentParams drops documentId/libraryId/selectedGroupId, keeping
  only { groupId, sessionId, version, forceReload? }. Since groupId already
  identifies a row carrying documentId/libraryId, the workflow looks them
  up itself via a new load-group step instead of having every caller
  re-thread them through.
- run() no longer fetches the version, resolves/creates the group id, or
  decides whether a reload is needed — all three move to routes/groups.ts.
- Add createOrderedGroup to library-data.ts: creates a group row and places
  it in the library's sort order (after selectedGroupId, or at the end),
  extracted from the workflow's old updateGroupOrder.
- /reload-groups now fetches each group's latest version itself and only
  triggers the workflow for groups whose version changed (or all when
  forceReload), skipping a group whose document can't be reached instead
  of failing the whole batch.
- /group (add) fetches the version, generates the groupId, and calls
  createOrderedGroup before triggering the workflow, so the new group
  appears (and is ordered) immediately rather than only once the sync
  finishes.
- Add library-data.test.ts (createOrderedGroup: append vs insert-after)
  and routes/groups.test.ts coverage for the new reload-decision and
  create+order+trigger behavior, spying on the LOAD_DOCUMENT_WORKFLOW
  binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
Nothing imports the generated Onshape API reference anymore (confirmed by
grep) — the types actually used are hand-authored in
src/backend/onshape-api/types/*. So the config no longer needs to produce
precisely-typed, compileable output: drop the SCHEMAS map (btType pinning,
required-field marking, keep-allowlists) and DROP_DISCRIMINATORS entirely.

- UNIONS now auto-derives each union's members from the base schema's own
  discriminator.mapping instead of a hand-typed member list, so new Onshape
  subtypes show up without config changes. Discriminator subtypes are still
  invisible unless expanded this way (hey-api doesn't turn allOf+discriminator
  into a union on its own) — an early attempt to just replace the base schema
  in place broke with a real TS2456 circular-reference error (confirmed via
  tsc), since a subtype's allOf still extends the base it was replacing. Fixed
  by keeping each union as a new sibling schema and only repointing the
  handful of properties that reference the base polymorphically.
- Add a small OMIT map to keep the reference from ballooning on two newly
  included operations (GET /documents/{did}, GET /assemblies/.../e/{eid}):
  BTDocumentInfo's thumbnail/owner/workspace/user chain (and the same fields
  re-declared on its allOf bases) pulled in ~40 unrelated schemas; trimmed
  the same thumbnail/user-chain pattern from BTVersionInfo and
  BTDocumentElementInfo for consistency.
- Explicitly did not add getFeatures/addAssemblyFeature/addPartStudioFeature
  to operations.include — their response sits on Onshape's universal
  FeatureScript feature-type discriminator (~359 schemas if expanded), not
  reference material; assemblies.ts/part-studios.ts's feature-shaped fields
  stay purely hand-authored.
- Regenerated output verified idempotent (two consecutive runs identical)
  and typechecks clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
Merge the 5 files under onshape-api/types/ into one
onshape-api/onshape-types.ts (with a // === <domain> === banner per endpoint,
matching the section-banner style already used internally), and rename +
gitignore the generated reference dump so the pairing and its "reference
only" status are both obvious from a file listing:

  src/backend/onshape-api/
    onshape-types.ts        # hand-authored, committed
    onshape-types.gen/      # generated by `npm run gen:onshape-types`, gitignored

No type names collided across the 5 source files (all already domain-prefixed),
so this is a straight concatenation. Update all 12 import statements across 10
consumer files (parse/* and onshape-api/endpoints/*) to the new path — two of
them (insert-and-fasten.ts, endpoints/part-studios.ts) had two separate
type-file imports that collapse into one. Update openapi-ts.config.ts's
`output` path and header comment, .gitignore, and eslint.config.js's
globalIgnores entry to match. The old committed, zero-importer generated/
directory is removed from git; the new onshape-types.gen/ never gets tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
…ce outside src/

- OnshapePartStudioFeature now carries name/suppressed (matching Onshape's
  BTMFeature-134), and parseFastenInfoFromPartStudio skips suppressed
  features when picking a mate connector, matching assembly-side behavior.
- openapi-ts.config.ts derives union schema names mechanically from their
  discriminator base instead of hand-picking names that mirrored our
  hand-typed types.
- The generated reference dump now lives at the repo root in
  onshape-api-reference/ and is committed rather than gitignored, but stays
  outside every tsconfig's scope so it never surfaces in editor
  autocomplete/auto-import.
…history

getDocument already returns recentVersion.{id,name} on the raw Onshape
response; we were ignoring it and instead calling getLatestVersion, which
downloads a document's entire version list just to take the last entry.

- OnshapeDocumentInfo now exposes recentVersion (un-omitted in the codegen
  config, since it's a small self-contained BTBaseInfo, unlike the
  thumbnail/owner chains that stay stripped).
- load-document-steps.ts replaces fetchVersion (list + take last) with
  fetchVersionInfo(api, documentId, versionId), a single targeted
  getVersion call for a known version id.
- groups.ts's POST /group route reuses the doc it already fetched for the
  name, instead of a second full version-list fetch.
- groups.ts's POST /reload-groups loop now does a cheap getDocument-based
  staleness check per group, only paying for the extra getVersion call on
  groups that actually need reloading.
…Features in the reference

- addElementToAssembly no longer branches on a useTransform option: the
  fire-and-forget instances endpoint was only ever taken when the caller
  discarded the result anyway, so every insert now goes through
  transformedinstances (with IDENTITY_TRANSFORM) and gets a real,
  consistently-shaped Onshape response.
- Added getPartStudioFeatures to the codegen reference's operations.include,
  without adding its BTMFeature-134 base to UNION_BASES (which would pull in
  the ~350-schema FeatureScript discriminator fan-out). This surfaces the
  recursive subFeatures list and the plain mateConnectorFeature boolean
  needed for upcoming mate-connector sub-feature parsing, at the cost of
  only the feature list's own small schema tree.
parse/ mixed the load-document Workflow (orchestrator + steps) with
unrelated, reusable parsing helpers (insert-and-fasten, parse-configuration,
parse-vendors, build-checks) that aren't workflow-specific — insert-and-fasten
is called directly from routes/insertables.ts, for instance. This made it
unclear where a future workflow would live, and made the load-document /
load-document-steps split feel like an arbitrary same-folder naming
convention rather than a real boundary.

load-document.ts and load-document-steps.ts now live in their own
src/backend/load-document-workflow/ directory (steps.ts, dropping the
redundant folder-scoped prefix), sitting as a sibling to routes/,
onshape-api/, and parse/. A future workflow (e.g. thumbnail loading) follows
the same <name>-workflow/ shape. parse/ now holds only the reusable parsing
helpers it was always mostly made of.
Splits per-element loading out of the group workflow into its own class,
LoadInsertable (load-insertable.ts): it owns everything about loading and
persisting one insertable independently of the rest of the group -
configuration, a discrete vendor-parsing step, thumbnails, insert-and-fasten,
and finally its own D1 write. Each insertable now writes its own row rather
than all elements landing in one batch at the end, so one element's
exhausted retries no longer block the others or the group-level finalize
(group row upsert, stale-insertable cleanup, search rebuild) - a stale
microversionId is simply left in place, which the existing
getInsertablesToReload check already treats as needing another reload.

LoadDocumentWorkflow is renamed LoadGroupWorkflow (load-group-workflow/
load-group.ts), trimmed down to group-level orchestration now that the
per-element logic has moved out. Its version-fetch step also simplifies to
a single getDocument call, since the DB no longer needs the version's
name/createdAt (see below) - just recentVersion.id.

Also renames the groups/insertables schema's instanceId column to
versionId, and drops insertables.versionName/versionCreatedAt (unused
outside the backend, never displayed). conflictUpdateSet and the
thumbnail-upload retry helper move to shared homes (db.ts,
routes/thumbnails.ts) now that both the group and insertable loads need
them.
…kflows

conflictUpdateSet relied on Drizzle's onConflictDoUpdate to paper over
whether a row already existed, but that made it easy to accidentally
overwrite user-owned fields (isVisible, isOpenComposite, sortOrder) unless
they were defensively excluded every time. Both workflows already know
whether they're creating or updating, so they now branch explicitly:

- LoadGroupParams is a discriminated union on isNew (true carries the
  sortOrder to create the group at; false doesn't need one, since an
  existing group's position is left alone). documentId/libraryId are now
  passed in directly instead of being looked up via a load-group step,
  which is gone.
- LoadInsertableData gains isNew and hasConfiguration (the latter from a
  small extra query alongside the existing supportsFasten/microversionId
  lookup, since a not-new insertable can still be getting configured for
  the first time). LoadInsertable.save() inserts or updates each table
  based on those flags instead of upserting.

This also lets POST /group drop its synchronous group-row insert:
placeNewGroup (renamed from createOrderedGroup) now only computes the new
group's sort position and renumbers its siblings, returning a plain
number. The actual row is created by the workflow's own save-group step,
same place it already writes on reload - the route just passes
documentId/libraryId/sortOrder/isNew:true through to the workflow.
hasConfiguration required an extra precomputed query threaded through
ExistingInsertable/MatchedElement/LoadInsertableData just so
LoadInsertable.save() could pick insert vs update for the configurations
row. That's cheap to determine right where it's needed instead: save()
now just checks for an existing configurations row at the point of write.

Group sort placement (placeNewGroup) moves from POST /group into the
workflow's own save-group step, alongside the actual group insert. Doing
this in the route meant sibling groups got renumbered to make room for a
group that didn't exist yet for however long the async workflow took (or
forever, if it failed). LoadGroupParams's isNew branch now carries
selectedGroupId instead of a precomputed sortOrder, and saveGroup computes
the position itself immediately before inserting - safe to retry as a
unit, since the new group is never among the siblings placeNewGroup
queries and renumbers.
The "Reload all documents" button sent a `reloadAll` query param, but
the backend's zValidator schema only reads `forceReload` — so the
field silently defaulted to false regardless of which button was
clicked, and unchanged groups (whose Onshape microversion hasn't
moved) were always skipped. This is why stale buildIssues (e.g.
NO_VENDORS) computed by older logic never got recomputed.

Also swap z.coerce.boolean() for z.stringbool(): Boolean("false") is
true in JS, so the old schema would have coerced an explicit
forceReload=false into true once the param name was fixed.

Add /reload-groups route test coverage (previously untested) and wire
src/shared/**/*.test.ts into the vitest "node" project, which was
silently excluded from both projects' include globs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
fetchContents now calls getDocument once and derives versionId from it,
instead of a separate fetch-version step that fetched the document a
second time just for its recentVersion.

matchElements drops the bulk fetchExistingInsertables + in-memory Map
match in favor of one select per element, scoped by groupId, libraryId,
and elementId (the fields insertables are actually keyed on). A miss
returns a MatchedInsertable with isNew: true and default fields; a hit
returns the selected row's fields. Renamed MatchedElement ->
MatchedInsertable to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
matchElements no longer needs libraryId in its select — groupId alone
already uniquely scopes a group's insertables (unique index on
elementId+groupId). It also drops insertableId/supportsFasten from its
result entirely, since LoadInsertable now resolves those itself.

LoadInsertableData shrinks to just what's needed to load an element
from Onshape and know where to write it (no more insertableId/isNew/
supportsFasten passed through from the match phase). LoadInsertable
adds its own resolve-insertable step: a select on groupId+elementId
that finds the existing row (or assigns a fresh id for a new one),
memoized like every other per-element step so a generated id stays
stable across workflow replays.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ncv6XJzFCzAvtJt8LFMq3
Let editors flag an insertable for part-number search. When enabled, loading
(or toggling) the insertable enumerates its enum/boolean configuration
combinations, reads the Onshape part number Onshape reports for each, and
stores a deduped part number -> configuration map. Part numbers become
searchable, and selecting a part-number hit opens the insert menu
pre-configured to the configuration that produces that part number.

- Schema: insertables.searchPartNumbers (toggle) + insertables.defaultPartNumber
  (non-configurable parts) + configurations.partNumbers (the map); migration 0004.
- Enumeration: shared enumerateConfigurations (enum + boolean only, quantity and
  string held at defaults), capped with a TOO_MANY_CONFIGURATIONS build issue.
- Fetch/compute: getPartNumber endpoint + computePartNumbers with first-wins
  dedup keyed by part number.
- Wired into the load workflow and a new toggle route that rebuilds the index.
- Search index carries part numbers and the launch configuration; doSearch
  attaches the matched configuration to the hit.
- Admin toggle menu item and build-status row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
claude and others added 17 commits July 26, 2026 23:22
Handle Onshape's rate limiting (429 + Retry-After; X-Rate-Limit-Remaining on
every response) so the heavy part-number fan-out no longer crashes into the
limit or fails a reload.

- Client (onshape-api.ts): throw OnshapeRateLimitError (carrying Retry-After
  seconds) on 429; expose lastRateLimitRemaining from X-Rate-Limit-Remaining.
- Workflow: index part numbers in a post-pass that packs per-insertable jobs
  into parallel waves within the live remaining budget (job cost is known from
  enumerateConfigurations), seeded by a probe and updated from reported
  remaining. Each part-numbers-<id> step is durable with a Retry-After-aware
  retry (ONSHAPE_STEP_RETRIES) and a non-fatal backstop that flags
  PART_NUMBER_INDEX_INCOMPLETE, so a reload always completes even if slow.
- computePartNumbers now reports the lowest observed remaining; add the pure
  nextWaveSize scheduler helper.
- saveInsertable clears part-number fields; writePartNumbers persists the map,
  default part number, and part-number build issues in the post-pass.
- Request path: central app.onError maps OnshapeRateLimitError to HTTP 429 with
  Retry-After; the inline toggle catches it and enables best-effort (next
  reload fills part numbers in).

Assumes one reload workflow at a time and per-user limits (no cross-request
coordination).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
Split the part-number extraction rules out of the Onshape endpoint wrapper so
they are pure and directly testable, matching the existing parse/ convention
(parse-configuration, parse-vendors).

- New parse/parse-part-number.ts: normalizePartNumber, parsePartStudioPartNumber,
  parseAssemblyPartNumber, with parse-part-number.test.ts covering them.
- endpoints/parts.ts is now fetch-only (getParts, getAssemblyDefinition are
  exported); getPartNumber composes fetch + parse.
- Reuse the canonical OnshapeAssemblyDefinition from onshape-types.ts instead of
  the local duplicate that shadowed it, adding rootAssembly.partNumber; add an
  OnshapePart type for the parts endpoint.

Part numbers are normalized (trimmed, blank treated as unset) since they become
search terms and PartNumberMap keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
Restructure the config's comments without touching any code: shrink the 38-line
header to what it needs to say (what this generates, that nothing imports it,
how to run it), and move each explanation next to the constant it explains —
the discriminator/union rationale onto UNION_BASES, the trimming rationale and
the allOf re-declaration gotcha onto OMIT. Add a one-line comment on the union
synthesis loop for symmetry with the repointing block below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
placeNewGroup: it spliced the new id into the sibling array, filtered it back
out to renumber, then used indexOf to recover where it landed. Compute the
insertion index directly instead and renumber around it — siblings before the
slot keep their index, those at or past it shift up by one. Behavior is
unchanged (the existing tests pin it). This leaves the groupId parameter unused,
so drop it: the function opens a slot, it doesn't need to know what fills it.
Trim the doc comment to match.

Part-number scheduling (loadPartNumbers, runPartNumberJob, selectPartNumberJobs,
PartNumberJob) moves out of load-group.ts — ~140 lines, 30% of that file — and
into load-part-numbers.ts, which already owned computePartNumbers and
nextWaveSize. All part-number loading now lives in one module and load-group.ts
is back to group orchestration. The moved code is otherwise unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
Naming, per review:
- tabs -> insertableTabs; fetchDocumentTabs -> fetchInsertableTabs;
  parseContents -> parseInsertableTabs; step contents-* -> insertable-tabs-*
- toLoad -> insertablesToLoad; selectElementsToLoad -> selectInsertablesToLoad;
  step select-elements-* -> select-insertables-*
- staleIds -> removedInsertableIds; findOrphanedRows -> findRemovedInsertables

Structure:
- Drop resolveGroupFields and InsertableGroupFields. libraryId is the only
  thing it fetched that callers didn't already have, so loadGroup takes it as a
  parameter; both workflows already have it.
- Build the version path from the document (OnshapeDocumentInfo gains the `id`
  the API already returns) rather than from a stored documentId.
- InsertableElement -> InsertableToLoad, carrying an explicit path plus
  libraryId/groupId, so loadInsertable and saveInsertable no longer take a
  separate group argument and no longer rebuild the path.
- selectInsertablesToLoad branches explicitly on a stored row vs. a new tab
  instead of threading `??` defaults through one shared object literal.
- Upload the document thumbnail after the insertables finish, awaited inline,
  instead of kicking it off early and awaiting it later.
- Drop the rate-limit mention from the part-number comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
Part numbers were indexed by a group-level post-pass that re-queried every
searchPartNumbers-enabled insertable regardless of whether it was reloaded, so
an unchanged insertable burned up to 512 Onshape calls on every reload. That
waste is largely why the rate-limit wave scheduler existed.

Move the work into loadInsertable, so the existing reload decision (microversion
comparison in selectInsertablesToLoad) applies for free:

- load-part-numbers.ts: decompose into batchConfigurations + mergePartNumbers
  (pure) and fetchPartNumbers (I/O); loadPartNumbers now takes an insertable and
  runs one durable step per 20 configurations, each with ONSHAPE_STEP_RETRIES so
  a 429 is waited out per batch instead of re-fetching all 512.
  computePartNumbers stays as the unbatched path for the toggle route.
  Deletes the wave scheduler: runPartNumberJob, selectPartNumberJobs,
  PartNumberJob, nextWaveSize, PART_NUMBER_RATE_LIMIT_RESERVE, minRemaining.
- load-insertable.ts: part numbers are computed before the save and written by
  it, so saveInsertable regains its partNumbers argument and ReloadedFields its
  defaultPartNumber. Deletes writePartNumbers/PartNumberWrite and the
  read-modify-write of buildIssues they needed.
- The toggle route is now transactional: compute first, then write the flag with
  the data. No try/catch, so a failure leaves the flag off and reaches the client
  via app.onError (429 + Retry-After for rate limits) where the existing error
  toast warns the user.
- Deletes PART_NUMBER_INDEX_INCOMPLETE (nothing degrades any more) and the
  now-unread lastRateLimitRemaining. 429/Retry-After handling stays.

Adds the first tests for toggle-part-number-search, plus batchConfigurations and
mergePartNumbers coverage and a test pinning the empty-columns invariant that
getPartNumberMap relies on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
- create-app: return retryAfterSeconds in the 429 JSON body instead of a header,
  so the client reads one shape.
- endpoints/parts.ts is now a plain API wrapper again: getPartNumber, which
  composed a fetch with the parse layer, moves to load-part-numbers.ts.
- Part-number API returns the build issues it raises, with withPartNumberIssues
  to merge them; the load and the toggle route now record issues identically,
  and the route's inline let/if collapses to one call plus an indexPartNumbers
  helper.
- defaultPartNumber is now always populated, configurable or not, so a capped
  insertable still gets one. saveInsertable takes a single
  InsertableConfiguration ({ parameters, partNumbers }) rather than two args.
- getOnshapeApiFromLoadContext -> getOnshapeApi; drop the local closure in
  load-part-numbers.
- load-group: build the save batch as a conditional array instead of an
  early-return; rewrite tab ordering to walk the folder tree and pick up tabs
  (appending any the tree omits) rather than sort by a lookup with an Infinity
  fallback.
- workflows: AddGroupParams moves above its class, forceReload folds into the
  destructuring default, createShell -> createShellGroup, and a comment records
  why these carry a sessionId (workflow params must be JSON-serializable, so an
  OnshapeApi with its refresh callback cannot be passed).
- Tests spy on the endpoint wrapper instead of hand-rolling a fake client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
The word "configuration" meant three different things across the codebase.
Disambiguate:

- `Configuration` (a map of parameterId -> value) -> `ParameterValues`
- `InsertableConfiguration` (the stored `configurations` row) -> `Configuration`
- `ParameterObj` and its variants -> `ConfigurationParameter`, `EnumParameter`,
  `QuantityParameter`, `BooleanParameter`, `StringParameter`
- `ParameterBase` -> `ConfigurationParameterBase`

The parameter types took the names of the frontend components rendering them,
so those get an `Input` suffix: `ParameterInput`, `EnumInput`, `BooleanInput`,
`StringInput`, `QuantityInput`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
Resolves two conflicts and the fallout of the configuration-type rename:

- `create-app.ts`: keep both new imports (`HTTPException` from this branch,
  `getSessionCompanyId` from cert).
- `worker-configuration.d.ts`: drop `ONSHAPE_API_VERSION` /
  `ONSHAPE_API_BASE_PATH`, which cert hardcoded as constants in `getBaseUrl()`.
- `onshape-path.ts` and `insertable-card.tsx`: cert's new `configuration` fields
  hold parameter values, so they become `ParameterValues` under the rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
`0003_rename_version_id_drop_version_fields` (load-workflow rewrite) and
`0004_add_part_number_search` are both unreleased, so fold them into a single
`0003_version_id_rename_and_part_number_search`. Verified by applying all four
migrations to a fresh local D1 and diffing the resulting columns against the
Drizzle schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
`isVisible` became a required field on `StoredInsertable` and
`InsertableToLoad` in 675b55e, but the two fixture helpers build their base
object literal before spreading `Partial<...>` overrides, so neither supplied
it and tsc failed.

Both default to `true`, matching the existing `is_visible` column default. That
also keeps "preserves user-owned fields when reloading an existing row"
meaningful: the row is inserted visible, the test hides it, and the reload has
to leave it hidden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
…ng-731tsl' into claude/part-number-search-indexing-731tsl
**Part numbers.** `load/load-part-numbers.ts` is folded into
`parse/parse-part-number.ts`, which now owns both loaders outright:
`parsePartNumbers` for request handlers and `loadPartNumbers` for the workflow.
They read the same four lines against different plumbing, sharing private
`planPartNumberBatches` / `probePartNumber` / `fetchPartNumberBatch` /
`toPartNumberResult`, so the injected `fetchDefault`/`fetchBatch` callbacks and
the six internal exports (including `PART_NUMBER_BATCH_SIZE`) are gone. I/O in
`parse/` matches the existing `parseFastenInfo`.

Fixed along the way: `enumerateConfigurations([])` returns a single empty
configuration, not none, so an insertable with nothing to vary was fetching its
defaults twice and storing a map entry keyed to an empty configuration.

**New MULTIPLE_PARTS build issue.** `parsePartStudioParts` reports whether the
studio resolved to more than one part, OR'd across the default probe and every
batch, so "ever has more than one part" means "at any configuration". Picking
the first part carrying a number is only correct when there is one part.

**Part-number issues are recomputed, not accumulated.** `clearBuildIssue` is
variadic, and the toggle route clears `PART_NUMBER_ISSUE_TYPES` before adding
the fresh ones. Previously `TOO_MANY_CONFIGURATIONS` stuck to a row forever:
neither disabling the toggle nor re-indexing after trimming configurations could
remove it.

**Load types.** `GroupIdentity`, `InsertableToLoad`, and `ParseData` collapse
into `GroupTarget` and `InsertableTarget`, both passed in rather than assembled
from loose arguments — `loadGroup` drops from 6 parameters to 4, and its separate
`versionId` goes away since `versionPath.instanceId` is the same value.
`ReloadedFields` becomes `ParsedInsertable`, holding only what the load computed;
`saveInsertable` owns the mapping to the write shape and takes 3 parameters.

The user-owned flags leave `StoredInsertable` and `InsertableTarget` entirely:
`loadInsertable` reads the two it needs in a step, and the save writes all three
explicitly on insert, where a new insertable always wants `false`.

**Schema.** `is_visible` goes back to `.default(true)` to match the DDL
migration 0000 created. New insertables still start hidden — `saveInsertable`
writes that explicitly, which is where the decision belongs. Verified against a
fresh local D1 that every column default now agrees with `schema.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
Folded into the unreleased 0003 migration rather than a new one, and schema.ts
goes back to .default(false).

SQLite has no ALTER COLUMN, so this is a table recreate — but the obvious
drizzle-style recreate silently destroys data on D1. `configurations` and
`favorites` both reference `insertables(id)` ON DELETE CASCADE, and there is no
way to switch that off: D1 runs migration statements in a transaction, where
`PRAGMA foreign_keys=OFF` is silently ignored (it reads back as 1), and
`defer_foreign_keys` only postpones violation reporting — cascade actions still
fire. Verified both, and both wiped every configuration and every favorite.

So the migration copies all three tables, drops the children before the parent
so nothing references `insertables` when it goes, and renames
`__new_insertables` last, letting SQLite rewrite the children's foreign keys
back onto `insertables`.

Verified by seeding pre-0003 data (a visible and a hidden insertable, a
configuration, a favorite), then applying 0003: visibility, sort order, build
issues, and both child rows survive; the default is false for new rows; the
cascade and both unique indexes still work; no __new_ tables are left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XT52xco8DfQnh1iMGff1gF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants