Skip to content

Factor ordering (source of truth) ​

How the global factor order is set, where it is read, and how it seeds new tracks without ever mutating existing ones. There is one ordering source — factors.display_order — read by the guided flow and used as the starting arrangement for a new track; a track then owns its order independently.

Introduced by #470 (part of the admin-console-usability capability, #466). Shares the reorder control (ReorderableList) and the shared denseOrderPlan helper with category ordering (#471). Interacts with per-track factor order (#144).

The single source ​

factors.display_order (an integer column, default 0) is the one place the global factor rank lives:

StepWhereBehaviour
Read (flow)listFactors (src/lib/server/queries.ts)Active factors ordered by display_order then name; retired excluded. This drives the default /select guided flow.
Read (admin)listFactorsAdminSame order, retired included — the admin list + the Display-order control.
Order sourceglobalFactorOrder (src/lib/server/admin/factors.ts)Active factor ids in display_order then name — the id-only canonical arrangement new tracks seed from.

Setting the order ​

The Display order card on /admin/factors (the shared ReorderableList) rearranges the active factor set with ↑/↓ controls. On save, the reorder action calls reorderFactors, which assigns a dense 1..N display_order to the active set via the shared denseOrderPlan (phantom-safe, omission-safe, duplicate-collapsing — the same plan category ordering uses). The reorder is audited once (set-level, entityId = factor-order).

Because the default /select reads listFactors, the guided flow follows the new order immediately with no further wiring (AC-04).

Seeding new tracks (and staying decoupled) ​

A track stores its own ask-list as an ordered JSON snapshot (tracks.factor_refs_json), so global order and per-track order are two different things:

  • New track (seed). The new-track form (/admin/tracks/new) initializes its factor ask-list from globalFactorOrder — a new track starts from the admin's canonical arrangement (AC-02). The admin can then trim or reorder it (the #144 per-track override); on a validation-error re-render the submitted picks win, so an empty-by-choice ask-list is preserved rather than re-seeded.
  • Existing tracks (untouched). reorderFactors writes onlyfactors.display_order. It never reads or writes any track's factor_refs_json, so reordering the global list can never silently reorder an existing track (AC-03). Each published/draft track keeps the order it was saved with.

This is the confirmed product model (2026-07-04): the global order seeds new tracks only; existing tracks keep their own order.

Option ordering within a factor (#1867) ​

A second, independent order lives one level down: the sequence of options inside a single factor (Residential before Commercial, and so on). The column — factor_options.display_order — has existed since the schema's first cut and has always been what listFactors and getFactorAdmin sort by, but until #1867 nothing ever wrote it. Every option sat at 0, so buyers saw an effective insertion order no operator could change. No migration was needed to fix that — only the missing write path.

StepWhereBehaviour
Read (flow)listFactors (src/lib/server/queries.ts)Options ordered by display_order — the order the guided flow offers choices.
Read (admin)getFactorAdminSame order — the Options tab and every option picker.
WritereorderFactorOptions (src/lib/server/admin/factors.ts)Dense 1..N via the shared denseOrderPlan, scoped to one factor.

The Options tab on /admin/factors/[id] arranges them with the same shared ReorderableList — in controlled mode, because each option row carries its own forms (Remove, the inline editors, the media surface) and those cannot nest inside an owned reorder <form>. Saving posts reorderOptions, which requires edit_data, audits once at set level (entityId = <factorId>:option-order), and fails loud on a bad submission per the two-tier degradation policy.

Scoping matters here in a way it does not for factors. Option ids are globally unique, so the candidate set is read with factorId in the WHERE clause; an id belonging to another factor is simply not in existingIds and denseOrderPlan drops it. One factor's reorder can never renumber another's options — covered by factor-option-order.spec.ts.

Since existing options are all at 0, the first save is what densifies them — the same "explicit once arranged" property the factor and category orders rely on.

Option identity (#1868) ​

An option carries two strings, and only one of them is safe to reference:

FieldEditable?What may key on it
idNo — set at creationEverything. Media links, illustrations, display rules, flow sessions, and (since #1868) track pre-seeded answers.
valueYes, from the Options tabThe flow-facing token only. Nothing durable should reference it.

tracks.preseeded_answers_json used to store the value, and the runtime resolver dropped anything that no longer matched — so renaming an option's value silently stopped every track pre-seeding it from constraining the flow, changing which questions a buyer was asked with no error surfaced. Migration 0095 backfilled that column to option ids; the write path canonicalises to the id, so storage can never drift back.

Read paths still accept both spellings. That is deliberate, not leftover: a pre-seed the backfill could not resolve is left in place (reported by npm run verify:option-refs, not deleted), and narrowing the lookup to id-only would make such a row invisible to the delete guard in factor-option-refs — the exact silent-orphan failure the guard exists to prevent. Tolerance on read, canonical on write.

This is the same label-as-identity fix #1844 applied to attribute_enum_values and tier_ladder, one vocabulary over — but cheaper, because factor_options.id already existed and needed no new surrogate.

Relationship to category ordering (#471) ​

Factor ordering and category ordering share the one reorder control (ReorderableList) and the one ordering plan (denseOrderPlan in src/lib/server/admin/ordering.ts) — they must not fork into two reorder behaviours. See category ordering.

Files ​

ConcernFile
Ordering source columnfactors.display_order (src/lib/server/db/schema.ts)
Order source + reordersrc/lib/server/admin/factors.ts (globalFactorOrder, reorderFactors)
Shared reorder plansrc/lib/server/admin/ordering.ts (denseOrderPlan)
Reorder actionsrc/routes/admin/factors/+page.server.ts (reorder)
Display-order UIsrc/routes/admin/factors/+page.svelte + src/lib/admin/ui/ReorderableList.svelte
Option order columnfactor_options.display_order (#1867)
Option reorder writesrc/lib/server/admin/factors.ts (reorderFactorOptions)
Option reorder actionsrc/routes/admin/factors/[id]/+page.server.ts (reorderOptions)
Option-order UIsrc/routes/admin/factors/[id]/+page.svelte, Options tab
New-track seedsrc/routes/admin/tracks/new/+page.svelte (initialFactorRefs)
Flow consumerlistFactors → src/routes/select/+page.server.ts (default /select)