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 consumerlistFactorssrc/routes/select/+page.server.ts (default /select)