Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
34 changes: 11 additions & 23 deletions .changeset/accordion-titles-carry-values.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Accordion labels state the values they hide, so the collapsed screen is readable
(admin-UX INC-15). A Block Kit console cannot draw cards, and a group whose label is a
bare noun — `Identity`, `Service connection` — makes the operator open it just to find
bare noun — `Identity`, `Checkout & holds` — makes the operator open it just to find
out whether it holds anything. The labels now answer that, which is the cheapest
density win the surface allows.

Expand All @@ -13,24 +13,19 @@ density win the surface allows.
renders as its natural-key slug rather than `name (id)`: the pair would consume the
whole 60-character label budget on its own, leaving no room for the weight the group
also exists to show.
- **Settings.** `Checkout & holds — 15 min hold · low stock at 5` and `Service
connection — token set · service token not set`, and each group now renders closed.
The screen used to open `Store`, the one cosmetic field on it, pushing the two groups
that hold operational and connection state below an expanded form. This is the
- **Settings.** `Checkout & holds — 15 min hold · low stock at 5`, and each group now
renders closed. The screen used to open `Store`, the one cosmetic field on it, pushing
the group that holds operational state below an expanded form. This is the
render-time kind of closing: no `block_id` changes to force a group shut, so no
unsubmitted operator input is ever discarded.
- **A token's label states a FACT about the credential, never any part of it.** "Token
set" is derived from a boolean the render already had; neither token value is in
scope where the labels are built, and the whole-response no-echo pins cover the
labels along with everything else. Both tokens stay write-only and never render back.
- **An absent value is named, not implied.** `Identity — no SKU`, `Classification &
shipping — no tax class · no weight`, `Store — no display name`, and — when the
secondary `GET /settings` fails — `Checkout & holds — not loaded` rather than a label
settings read fails — `Checkout & holds — not loaded` rather than a label
reading `0 min hold · low stock at 0`.
- **A collapsed label reads as persisted state, so it only ever states persisted state.**
On a REJECTED operational save the form keeps the attempted value for correction, and
the label keeps stating what the service actually holds — a group reading
`99999 min hold` after the service refused 99999 would be reporting a value nothing
the label keeps stating what is actually persisted — a group reading
`99999 min hold` after the save was refused would be reporting a value nothing
stored.
- **An over-budget label loses a value, not the tail.** Right-truncation would delete the
last segment outright and leave a label that looks complete, so the truncation costs
Expand All @@ -46,16 +41,9 @@ density win the surface allows.
renders no edit forms at all) still states its kind. Nothing replaced the Title row
with a Title input: `product_commerce.title` is a CMS-owned single-writer cache
(ADR-0013) and `ProductEditWire` has no `title` member, so one would not compile.
- **A blank token submit stops claiming it saved something.** The token fields render
empty on every mount and a blank submit deliberately keeps the stored token, so the
receipt now says `Nothing entered — admin token unchanged` instead of `Admin token
saved` above a group labelled `token not set`.

A Settings render also stops re-reading kv for what it already has: seven sequential
`ctx.kv` gets become five, of which the last three run concurrently. Two were re-reads
of tokens the handler had fetched at the top of the request, and both booleans the
labels need are derivable from the tokens already in hand. A token save updates what its
own re-render is computed from, so a first-ever save reports the token it just persisted
as set rather than as missing.
A Settings render also stops re-reading kv for what it already has, collapsing the
sequential `ctx.kv` gets the handler had already made at the top of the request and
running what remains concurrently.

No service, wire, or schema change.
No wire or schema change.
2 changes: 1 addition & 1 deletion .changeset/admin-failed-load-clears.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ manual page reload. It now answers a failure in one of three ways:
- **stale** (a first page failed under rows) — the rows, the count and
`Load more` are cleared in state; the filter bar and the filter summary stay,
because the operator's typed filters are input rather than answer. The card
carries the service's own words plus a sentence saying the rows went and why,
carries the failure's own words plus a sentence saying the rows went and why,
and focus moves to Retry, which was inside a row that no longer exists;
- **partial** (a page behind a successful one failed) — every accumulated row
and the count stand, and the card renders where `Load more` was, titled for
Expand Down
22 changes: 7 additions & 15 deletions .changeset/admin-orders-console.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
---
"@otta-sh/domain": minor
"@otta-sh/store-postgres": minor
"@otta-sh/service": minor
"@otta-sh/plugin": minor
---

Expand All @@ -19,22 +17,16 @@ Add a WooCommerce-style admin Orders console — VIEW + STATUS-TRANSITION only
suite pin the spec (empty, single/multi state, date boundary, search,
pagination no-overlap/no-gap, identical-`created_at` tie-break, limit
boundary).
- `@otta-sh/store-postgres`: implements `listOrders` as a single
`orders → order_totals` SELECT with a grouped keyset predicate, dialect-identical
on better-sqlite3 and Postgres. Adds forward-only migration `0009` (a
`orders(created_at, id)` index for the keyset order).
- `@otta-sh/service`: adds the internal-token-guarded `GET /admin/orders` (filters +
an OPAQUE base64url keyset cursor that embeds the active filter so it survives
paging; a malformed/tampered cursor fails CLOSED to 400 and the decoded limit is
re-clamped) and `GET /admin/orders/:id` (full order + `allowedTransitions` from
the domain state machine; 404 when absent). `serializeOrder` gains `createdAt` +
`customerId` additively.
- `@otta-sh/plugin`: adds the Orders admin page (list with a status/date/search
filter form, keyset "Load more", open-order → detail with line items, totals,
and legal transition buttons — destructive cancel/refund guarded by a confirm
dialog). A new `AdminOrdersClient` reaches the service only via `ctx.http` +
`allowedHosts` with the write-only kv admin token; the plugin defines its own
local wire types and never imports `@otta-sh/domain` (now enforced by the
dialog). Paging rides an OPAQUE base64url keyset cursor that embeds the active
filter so it survives a "Load more"; a malformed or tampered cursor fails
CLOSED and the decoded limit is re-clamped. The detail read carries the full
order plus `allowedTransitions` derived from the domain state machine, and an
order summary now carries `createdAt` + `customerId`. The console reads
through a plugin-owned admin orders client; the plugin defines its own local
wire types and never imports `@otta-sh/domain` (now enforced by the
dependency-cruiser sandbox-clean rule). The staging trusted descriptor
registers the new page.

Expand Down
17 changes: 8 additions & 9 deletions .changeset/admin-orders-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
Re-lay the admin Orders console onto the design spec's §11 — the REFERENCE screen
the other six pattern-match on. One flat full-width stack becomes a collapsed
filter panel over the data (list) and five blocks plus four task-named panels
(detail). Presentation only: no port, wire-format or money-handling change, and
the service is untouched.
(detail). Presentation only: no port change and no money-handling change.

**The list (§11.1).** `header` + one 101-char `context` + a **collapsed** 4-field
`filterPanel` accordion + the table + the drill-in picker — nothing else above the
Expand Down Expand Up @@ -65,9 +64,9 @@ instead of silently bouncing the operator to the list (DA-3b).
**Status moves are one `actions` block with per-state ids derived from
`ORDER_STATES`** (DA-6) — the old one-block-per-button split existed only because
every button shared the literal id `orders:transition` and they collided as React
keys. `customActions` is derived from the same constant and a service-offered state
outside it renders **no button**, because `admin-route.ts` falls through an
unregistered id to `{blocks: []}` — a blank console.
keys. `customActions` is derived from the same constant and an offered state outside it
renders **no button**, because `admin-route.ts` falls through an unregistered id
to `{blocks: []}` — a blank console.

Also: `formatTotal`'s catch branch renders `—` instead of raw minor units (a wrong
number dressed as a formatted total, M-1) and the totals block says so when it
Expand Down Expand Up @@ -102,7 +101,7 @@ unreadable payload rather than as licence to skip the comparison.

Copy and layout follow-ups in the same pass: the DA-3a refusal restores its causal
clause (*"someone else refunded this order since you started"*); the fail-closed
banner stops claiming the service is unreachable when a console bug lands on the
banner stops blaming an unreachable back end when a console bug lands on the
same path (E-7/X-42); both destructive group labels carry their consequence (D-6a);
`Remaining` becomes `Remaining refundable` and a total that disagrees with its
capture is reconciled in one line (M-11/M-11a), with the degenerate `$0.00 of $0.00`
Expand All @@ -128,6 +127,6 @@ clauses on each of the four refusal paths; a **positive** watermark assertion (t
deliberate identical refunds derive **different** idempotency keys, so both apply —
the property the whole no-nonce design rests on, and the one nothing asserted); a
`shipped`-order assertion that `Mark refunded` really is offered, against a fixture
whose `allowedTransitions` is the domain state machine copied verbatim; and a
service-side assertion that `GET /admin/orders/:id` on a shipped order returns
`["delivered", "refunded"]`, which is the wire shape the watermark exists for.
whose `allowedTransitions` is the domain state machine copied verbatim; and an
assertion that reading a shipped order offers exactly
`["delivered", "refunded"]`, which is the shape the watermark exists for.
2 changes: 1 addition & 1 deletion .changeset/admin-price-save-guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ but it now has four states:
storefront immediately, and `Discard` appears beside `Save`;
- **in flight** — only the button that was clicked reads `Saving…`, and it stays
that way until the re-read that follows the write lands, so no save button is
ever re-armed against a watermark the service has already superseded;
ever re-armed against a watermark the store has already superseded;
- **saved** — a receipt renders inside the section, under the button, naming the
two amounts and saying that orders already placed keep the price they were
charged. It persists; nothing dismisses it.
Expand Down
24 changes: 6 additions & 18 deletions .changeset/admin-products-console-list.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
---
"@otta-sh/domain": minor
"@otta-sh/store-postgres": minor
"@otta-sh/service": minor
"@otta-sh/plugin": minor
---

Expand Down Expand Up @@ -29,23 +27,13 @@ VIEW-ONLY product list + read-only detail (admin-UX Increment 2, "product enumer
`InMemoryInventoryStore` fakes and the contract suites pin both specs (empty,
filters, pagination no-overlap/no-gap, identical-`created_at` tie-break, limit
boundary, tombstone exclusion).
- `@otta-sh/store-postgres`: implements `listProducts` as a single
`product_commerce` SELECT (no join) with a keyset predicate dialect-identical on
better-sqlite3 and Postgres; the substring title search escapes SQL LIKE
metacharacters (`%`, `_`, `\`) so a literal search (e.g. "50% off") never
misfires as a wildcard. Implements `InventoryStore.getOnHand` as a bare
single-row `SELECT on_hand`.
- `@otta-sh/service`: adds the internal-token-guarded `GET /admin/products` (filters
+ an OPAQUE base64url keyset cursor embedding the active filter, mirroring
`GET /admin/orders`'s cursor discipline — a malformed/tampered cursor fails
CLOSED to 400 and the decoded limit is re-clamped) and
`GET /admin/products/:id` (the full product detail plus the single-sku `onHand`
read; 404 for an unknown OR soft-deleted product — there is no admin surface for
browsing/restoring a tombstone yet).
- `@otta-sh/plugin`: adds the Products admin page (list with an active/kind/search
filter form, keyset "Load more", columns title/SKU/price/status/kind — stock
deliberately OMITTED from the list; open-product → read-only detail showing the
full product fields incl. stock). A new `AdminProductsClient` reaches the
service only via `ctx.http` + `allowedHosts` with the write-only kv admin token;
the plugin defines its own local wire types and never imports `@otta-sh/domain`
full product fields incl. stock, via the single-sku `onHand` read). The list
cursor is OPAQUE and embeds the active filter, mirroring the Orders console's
cursor discipline — a malformed or tampered cursor fails CLOSED and the decoded
limit is re-clamped. An unknown OR soft-deleted product reads as not-found;
there is no admin surface for browsing or restoring a tombstone yet. The plugin
defines its own local wire types and never imports `@otta-sh/domain`
(sandbox-clean). The staging trusted descriptor registers the new page.
56 changes: 20 additions & 36 deletions .changeset/admin-products-onhand-projection.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
---
"@otta-sh/domain": minor
"@otta-sh/service": minor
"@otta-sh/plugin": minor
"@otta-sh/store-postgres": patch
---

Carry stock on the admin Products list projection, and the product title on the
low-stock report (admin-UX INC-03). The Pricing & inventory screen already
fetched a row per product but had to send the operator to the detail leaf to
learn whether anything was in stock; the low-stock report listed bare SKUs.

`ProductSummary` (and the `GET /admin/products` wire) gains `onHand: number |
null`, and `LowStockRow` (and `GET /reports/low-stock`) gains `title: string |
null`. Both are REQUIRED fields on exported interfaces, hence `minor` for the
packages that export them; `store-postgres` changes adapter behaviour only and
stays `patch` — the same split as `title-single-writer`.
`ProductSummary` gains `onHand: number | null` and `LowStockRow` gains
`title: string | null`. Both are REQUIRED fields on exported interfaces, hence
`minor` for the packages that export them.

**`null` is not `0`.** `onHand: null` means there is no `inventory` record for
the sku — "unknown" — while `0` means a known sku that is out of stock. Nothing
Expand All @@ -26,34 +22,22 @@ two; the divergence is now documented on both sides of the port boundary.
its own field on the row — substituting it would make "named SKU-42"
indistinguishable from "name unknown".

**Shape, chosen from measurements, not estimates.** Postgres 16, 5,000 products
/ 3,997 inventory rows (~20% deliberately carrying no inventory record).
**Shape, chosen from measurements, not estimates.** Carrying stock on the list
projection itself was measured against the alternative of leaving each caller to
issue a per-row `getOnHand`: the N+1 cost several times the single joined read at
a 5,000-product catalog, in parallel and worse in sequence, on loopback and
before any real network. The projection is also unconditional rather than gated
on a "low stock only" filter — the gated variant measured *slower*, because it
must walk far more rows to fill a page.

*Products list, page size 25* — a single unconditional `LEFT JOIN` costs p50
0.43 → 0.58 ms and p95 0.61 → 0.91 ms, where an N+1 of per-row `getOnHand` reads
cost 2.60 ms p50 in parallel and 6.36 ms sequential: 6x and 15x the baseline, on
loopback, before any real network. The join is therefore unconditional rather
than gated on a "low stock only" filter — the gated variant measured *slower*
(1.15 ms), because it must walk ~9x the rows to fill a page. **No index and no
migration**: the join's inner side is already `inventory`'s primary key, and a
covering index cut buffers 28% without moving wall-clock at all.
The low-stock report's title half is the more expensive one, disclosed as such:
its cost is linear in CATALOG size rather than in the number of low-stock rows.
At a 5,000-product catalog that is comfortably inside the report's budget. Named
follow-up if low-stock latency ever matters: **bound the low-stock report** — it
currently returns every row at or below the threshold, unpaginated.

*Low-stock report* — the title join is the more expensive half, disclosed as
such: p50 2.915 → 4.858 ms (+67%), p95 6.89 → 7.48 ms. The planner picks a Hash
Right Join whose build side is a **Seq Scan over `product_commerce`**, so this
query's cost is linear in CATALOG size, not in the number of low-stock rows. At
5,000 products that is 121 shared buffers and ~4.0 ms of execution, comfortably
inside the report's budget. The partial unique index
`product_commerce_live_sku_unique` remains available to the planner and should
flip it to a nested-loop index lookup once the catalog grows enough for the seq
scan to lose. No index was added, per the user's ruling on §5.1. Named follow-up
if low-stock latency ever matters: **bound the low-stock report** — it currently
returns every row at or below the threshold, unpaginated — before reaching for
an index.

`lowStock`'s title join carries `AND product_commerce.deleted_at IS NULL` on its
ON clause. That predicate is load-bearing, not defensive: sku uniqueness on
`product_commerce` is a PARTIAL unique index over live rows, so a soft-deleted
product may legally hold a sku a live row also holds — without the predicate
such a sku would emit a DUPLICATE low-stock row and could be titled by the dead
product. Pinned by a contract case and an HTTP case on both dialects.
A soft-deleted product must not title a low-stock row or emit a second one.
Sku uniqueness is scoped to LIVE products, so a deleted product may legally hold
a sku a live product also holds; the report excludes deleted products from the
title lookup for that reason, and the exclusion is pinned by its own contract
case rather than left to the adapter.
4 changes: 2 additions & 2 deletions .changeset/admin-products-stock-column.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ bare SKUs, and the SKU→title mapping lived in the operator's head.
reading aid, identity travels in the option's value, and nothing parses a label
back into fields.

No service, wire, or schema change: this is the console rendering `onHand`, which the
No port, wire, or schema change: this is the console rendering `onHand`, which the
admin products list projection already carries.

Three consequences worth carrying forward, none of them blocking here:

- The filter panel is now AT `MAX_FILTER_FIELDS` (4). The next filter added to this
screen makes `filterPanel` throw, so the increments that revisit filters have to
cut a field or raise the cap deliberately.
- Each list and detail render now makes one extra, uncached `GET /settings`. It is
- Each list and detail render now makes one extra, uncached settings read. It is
deliberate and cheap: it runs in parallel with the reads beside it, so it costs no
added latency, and it cannot fail either screen.
- The back button drops every filter on this screen, the low-stock toggle included.
Expand Down
Loading
Loading