State a supersession pair once, in core.supersession_pairs
A conferral that supersedes a ladder's incumbent writes two transitions
at one instant by one actor: the incumbent's end and the successor's
typed row. Migration 00033 adds core.supersession_pairs, which joins
those two rows of core.plan_transitions on pool, ladder, instant, actor
type and actor id, with a from-side on the typed row, and names both
provisions. A restoration (from_rank NULL) is not a pair. The view does
not group: a transaction that confers twice on one ladder at one
instant yields every end and typed combination. core.grant_replacements
is re-expressed over the view, its rows unchanged; the Down restores
00023's body before dropping the view. The console reader may select
the view (its column list gains five lines), and
docs/models/plan-ladders-transitions.md lists it.
test/pin-timestamps.sql now walks base tables only. It updated every
column information_schema.columns names, views included, and the demo
snapshot's rebuild failed on the new view ("cannot update view
supersession_pairs", the first suite run of this section stopped at the
app database reset). No existing view carries a pinned column, so the
snapshot's pinned values do not change.
Tests (internal/entitlements): the pair names both provisions; an
expiry's restoration is not a pair; a whole-bundle end pairs only the
contested ladder; a chain of acts pairs each end at its own instant; an
expiry's end is not paired with a subscription's conferral. The
extension filter's ep.grant_id clause has no break: only a grant
extension succeeding a subscription would need it, and no test builds
that case.
Breaks (scripts/mutation-check, command records on 00033, 8 of 8
CAUGHT):
- view without from_rank
- view without same ladder
- grant_replacements without extension filter
- no SELECT grant on the view
- view selects reason
- view swaps the two provisions
- view without same instant
- view without same actor type
The suite on reset databases passes, 4627 tests, 15 skipped. make lint
passes; openspec validate --strict passes.
This commit is contained in:
@@ -32,7 +32,8 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
|
||||
| `core.sync_pool_provision_ladders()` | The trigger that propagates a provision's status down to its episode rows — except rows already ended, which stay ended | `00001_init.sql`; `00004_doc41_schema_and_data.sql` |
|
||||
| The five conferral functions | `confer`, `end_conferral`, `sync_source_status`, `align_conferral_shape`, `update_conferral_bounds` — verbs glossed in Purpose; the only legal writers of the three position tables | `00005_doc41_conferral_functions.sql`; `00023_grant_resumption.sql` (replaces `confer`, so its retired-product test follows a grant's extensions) |
|
||||
| `core.product_conferral_shapes` | The view conferral reads for a product's ladder positions and availability facts (introduced in the Product Catalog card) | `00004_doc41_schema_and_data.sql` |
|
||||
| `core.grant_replacements` | A view of which source replaced a grant: it pairs a grant provision's `end` transition with the typed transition of the grant or subscription that took the rung in the same pool, on the same ladder, at the same `effective_at`, by the same actor, leaving out extensions and a grant's own re-conferral | `00023_grant_resumption.sql` |
|
||||
| `core.supersession_pairs` | The one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason` | `00033_supersession_pairs.sql` |
|
||||
| `core.grant_replacements` | A view of which source replaced a grant: it reads its pairs from `core.supersession_pairs` and keeps those whose ended provision is a grant's and whose successor is the grant or subscription that took the rung, leaving out extensions and a grant's own re-conferral | `00023_grant_resumption.sql`; `00033_supersession_pairs.sql` |
|
||||
| `core.transition_change_kind` | The kind of change one transition records, which the Tier changes Change badge and the recent-activity line name: `resumption` for a resumption's start, `extension` for an extension's transfer or a legacy `extend` row, otherwise the row's type. A machine reason counts only from the writer that writes it, because Issue grant copies the operator's note into the reason | `00025_superseded_transitions.sql` |
|
||||
| `core.superseded_ends` | A view of the `end` rows a typed row of the same act states: an `end` and a typed row with a from side in the same pool, at the same `effective_at`, by the same actor, with the same reason, on any ladder, since supersession ends every rung of the incumbent. The Tier changes list leaves out every one; the recent-activity feed leaves out only those an extension's transfer states (`by_extension`). A grant that a plan act creates already points at its act in `core.grants.act_id` (the [Acts card](acts.md)), but a transition row has no such column, so the view still matches the rows by pool, instant, actor and reason. When the writers of transitions record their act (#179), the view reads that pointer and only its body changes. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
|
||||
| `core.extension_handoffs` | A view of the transitions that record an extension, each with the grant it handed the position to. The recent-activity feed leaves those grants' rows out: the transfer, which reads "Extended", is the extension's one row. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- One statement of a supersession pair (openspec change
|
||||
-- displaced-subscription-waits). core.confer writes the superseded
|
||||
-- provision's 'end' row and the successor's typed row on the same ladder, in
|
||||
-- the same pool, at the same effective_at, with the same actor. The view
|
||||
-- pairs those two rows, whatever the two provisions' sources (a grant, a
|
||||
-- subscription or the default). core.grant_replacements, which 00023 wrote
|
||||
-- as that join for grants alone, now reads its pairs from the view with its
|
||||
-- rows unchanged. If transitions come to point at the act that wrote them
|
||||
-- (#179), only this view's body changes.
|
||||
--
|
||||
-- The view reads no pool_provision_transitions.reason, which the reader role
|
||||
-- withholds: scripts/db-reader-role.sql withholds whole any view that reads
|
||||
-- a withheld column, so the reader role reads this one.
|
||||
--
|
||||
-- from_rank IS NOT NULL keeps out the start a restoration or a resumption
|
||||
-- writes after an end in the same act, which has no from side. The view does
|
||||
-- not group: a transaction that confers twice on one ladder at one instant
|
||||
-- pairs each end row with each typed row, the middle provision's own among
|
||||
-- them, and core.grant_replacements' grouping collapses them.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE VIEW core.supersession_pairs AS
|
||||
SELECT
|
||||
e.pool_id,
|
||||
e.plan_ladder_id,
|
||||
e.effective_at,
|
||||
e.provision_id AS ended_provision_id,
|
||||
n.provision_id AS successor_provision_id
|
||||
FROM core.pool_provision_transitions e
|
||||
JOIN core.pool_provision_transitions n
|
||||
ON n.pool_id = e.pool_id
|
||||
AND n.plan_ladder_id = e.plan_ladder_id
|
||||
AND n.effective_at = e.effective_at
|
||||
AND n.actor_type = e.actor_type
|
||||
AND n.actor_id IS NOT DISTINCT FROM e.actor_id
|
||||
WHERE e.transition_type = 'end'
|
||||
AND n.transition_type IN ('initiate', 'upgrade', 'downgrade', 'transfer')
|
||||
AND n.from_rank IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE VIEW core.grant_replacements AS
|
||||
SELECT
|
||||
ep.grant_id,
|
||||
np.grant_id AS replacing_grant_id,
|
||||
np.subscription_id AS replacing_subscription_id,
|
||||
sp.pool_id,
|
||||
max(sp.effective_at) AS replaced_at
|
||||
FROM core.supersession_pairs sp
|
||||
JOIN core.pool_provisions ep ON ep.provision_id = sp.ended_provision_id
|
||||
JOIN core.pool_provisions np ON np.provision_id = sp.successor_provision_id
|
||||
LEFT JOIN core.grants ng ON ng.grant_id = np.grant_id
|
||||
WHERE ep.grant_id IS NOT NULL
|
||||
AND (np.grant_id IS NOT NULL OR np.subscription_id IS NOT NULL)
|
||||
AND np.grant_id IS DISTINCT FROM ep.grant_id
|
||||
AND ng.extends_grant_id IS DISTINCT FROM ep.grant_id
|
||||
GROUP BY ep.grant_id, np.grant_id, np.subscription_id, sp.pool_id;
|
||||
|
||||
GRANT SELECT ON core.supersession_pairs TO core_reader, core_writer, core_owner;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
CREATE OR REPLACE VIEW core.grant_replacements AS
|
||||
SELECT
|
||||
ep.grant_id,
|
||||
np.grant_id AS replacing_grant_id,
|
||||
np.subscription_id AS replacing_subscription_id,
|
||||
e.pool_id,
|
||||
max(e.effective_at) AS replaced_at
|
||||
FROM core.pool_provision_transitions e
|
||||
JOIN core.pool_provisions ep ON ep.provision_id = e.provision_id
|
||||
JOIN core.pool_provision_transitions n
|
||||
ON n.pool_id = e.pool_id
|
||||
AND n.plan_ladder_id = e.plan_ladder_id
|
||||
AND n.effective_at = e.effective_at
|
||||
AND n.actor_type = e.actor_type
|
||||
AND n.actor_id IS NOT DISTINCT FROM e.actor_id
|
||||
JOIN core.pool_provisions np ON np.provision_id = n.provision_id
|
||||
LEFT JOIN core.grants ng ON ng.grant_id = np.grant_id
|
||||
WHERE e.transition_type = 'end'
|
||||
AND ep.grant_id IS NOT NULL
|
||||
AND n.transition_type IN ('initiate', 'upgrade', 'downgrade', 'transfer')
|
||||
AND n.from_rank IS NOT NULL
|
||||
AND (np.grant_id IS NOT NULL OR np.subscription_id IS NOT NULL)
|
||||
AND np.grant_id IS DISTINCT FROM ep.grant_id
|
||||
AND ng.extends_grant_id IS DISTINCT FROM ep.grant_id
|
||||
GROUP BY ep.grant_id, np.grant_id, np.subscription_id, e.pool_id;
|
||||
|
||||
DROP VIEW core.supersession_pairs;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package entitlements_test
|
||||
|
||||
// core.supersession_pairs (openspec change displaced-subscription-waits,
|
||||
// task 3.4): the one statement of a supersession. Each test runs in the
|
||||
// world's rolled-back transaction; the world is described in
|
||||
// grant_resumption_fixture_test.go.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// supersessionPair is one row of core.supersession_pairs.
|
||||
type supersessionPair struct {
|
||||
ladder, ended, successor string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// pairs reads the view's rows in the world's pool, ordered by ladder, ended
|
||||
// provision and successor.
|
||||
func (w *resumeWorld) pairs() []supersessionPair {
|
||||
w.t.Helper()
|
||||
rows, err := w.tx.QueryContext(w.ctx, `
|
||||
SELECT plan_ladder_id, ended_provision_id, successor_provision_id, effective_at
|
||||
FROM core.supersession_pairs
|
||||
WHERE pool_id = $1
|
||||
ORDER BY plan_ladder_id, ended_provision_id, successor_provision_id`, w.org.pool.PoolID)
|
||||
if err != nil {
|
||||
w.t.Fatalf("read supersession_pairs: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []supersessionPair
|
||||
for rows.Next() {
|
||||
var r supersessionPair
|
||||
if err := rows.Scan(&r.ladder, &r.ended, &r.successor, &r.at); err != nil {
|
||||
w.t.Fatalf("scan supersession_pairs: %v", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
w.t.Fatalf("read supersession_pairs: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSupersessionPairs_NameBothProvisions(t *testing.T) {
|
||||
p := newPlusProWorld(t)
|
||||
sub := p.subscription()
|
||||
subProvision := p.conferSub(sub, p.plus)
|
||||
g := p.grant(p.pro, grantOpts{})
|
||||
grantProvision := p.confer(g)
|
||||
at := p.clock
|
||||
|
||||
got := p.pairs()
|
||||
if len(got) != 1 || got[0].ladder != p.wiki || got[0].ended != subProvision ||
|
||||
got[0].successor != grantProvision || !got[0].at.Equal(at) {
|
||||
t.Fatalf("pairs = %+v, want one on Wiki from the subscription's provision to the grant's", got)
|
||||
}
|
||||
if n := p.count("SELECT count(*) FROM core.grant_replacements WHERE pool_id = $1", p.org.pool.PoolID); n != 0 {
|
||||
t.Errorf("grant_replacements has %d rows in the pool, want none: the ended provision is a subscription's", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersessionPairs_ARestorationIsNotAPair(t *testing.T) {
|
||||
p := newPlusProWorld(t)
|
||||
g := p.grant(p.plus, grantOpts{validUntil: p.now.Add(-time.Hour)})
|
||||
p.confer(g)
|
||||
p.expire(g)
|
||||
if got := p.holdsLadder(p.wiki); got != p.free {
|
||||
t.Fatalf("precondition: Wiki holds %q after the expiry, want Free from the floor", got)
|
||||
}
|
||||
|
||||
if got := p.pairs(); len(got) != 0 {
|
||||
t.Errorf("the floor's restoration is paired with the expired grant's end: %+v", got)
|
||||
}
|
||||
if got := p.replacements(g.GrantID); len(got) != 0 {
|
||||
t.Errorf("replacements = %+v, want none: the expiry's restoration is not a replacement", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder(t *testing.T) {
|
||||
p := newPlusProWorld(t)
|
||||
docsFree := p.product("Docs Free")
|
||||
docs := p.ladder("Docs", docsFree, p.plus)
|
||||
sub := p.subscription()
|
||||
p.conferSub(sub, p.plus)
|
||||
if got := p.holdsLadder(docs); got != p.plus {
|
||||
t.Fatalf("precondition: Docs holds %q, want the subscription's Plus", got)
|
||||
}
|
||||
p.confer(p.grant(p.pro, grantOpts{}))
|
||||
if got := p.holdsLadder(docs); got != "" {
|
||||
t.Fatalf("precondition: Docs holds %q after the grant, want nothing: the whole-bundle end vacated it", got)
|
||||
}
|
||||
|
||||
got := p.pairs()
|
||||
if len(got) != 1 || got[0].ladder != p.wiki {
|
||||
t.Errorf("pairs = %+v, want one on Wiki", got)
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.ladder == docs {
|
||||
t.Errorf("a pair on Docs, where the grant wrote no rung: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersessionPairs_AChainOfActsPairsEachEndAtItsOwnInstant(t *testing.T) {
|
||||
p := newPlusProWorld(t)
|
||||
plusProv := p.confer(p.grant(p.plus, grantOpts{}))
|
||||
proProv := p.confer(p.grant(p.pro, grantOpts{}))
|
||||
maxProv := p.confer(p.grant(p.maxTier, grantOpts{}))
|
||||
|
||||
got := p.pairs()
|
||||
want := map[[2]string]bool{{plusProv, proProv}: true, {proProv, maxProv}: true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("pairs = %+v, want two: an end row is paired with a typed row written at another instant", got)
|
||||
}
|
||||
for _, r := range got {
|
||||
if !want[[2]string{r.ended, r.successor}] {
|
||||
t.Errorf("an end row is paired with a typed row written at another instant: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersessionPairs_AnExpiryEndIsNotPairedWithTheSubscriptionsConferral(t *testing.T) {
|
||||
p := newPlusProWorld(t)
|
||||
g := p.grant(p.plus, grantOpts{validUntil: p.now.Add(-time.Hour)})
|
||||
grantProv := p.confer(g)
|
||||
p.expire(g)
|
||||
if got := p.holdsLadder(p.wiki); got != p.free {
|
||||
t.Fatalf("precondition: Wiki holds %q after the expiry, want Free from the floor", got)
|
||||
}
|
||||
p.conferSubAt(p.subscription(), p.pro, p.now)
|
||||
|
||||
for _, r := range p.pairs() {
|
||||
if r.ended == grantProv {
|
||||
t.Errorf("the expiry's end is paired with the subscription's conferral: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,11 @@ withhold core.superseded_ends.by_extension
|
||||
withhold core.superseded_ends.pool_id
|
||||
withhold core.superseded_ends.superseded_by
|
||||
withhold core.superseded_ends.transition_id
|
||||
read core.supersession_pairs.effective_at
|
||||
read core.supersession_pairs.ended_provision_id
|
||||
read core.supersession_pairs.plan_ladder_id
|
||||
read core.supersession_pairs.pool_id
|
||||
read core.supersession_pairs.successor_provision_id
|
||||
read core.target_mappings.connection_id
|
||||
read core.target_mappings.created_at
|
||||
read core.target_mappings.display_name
|
||||
|
||||
@@ -53,7 +53,7 @@ None.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code:** `internal/fulfillment/reconcile.go` (the fixed order, the displacement skip, the choice of tiers, two log lines, the pure rule exported for the page), `internal/entitlements/queries` (`ListSubscriptionDisplacements`, `ListAccountSubscriptionDisplacements`, `ListConferralShapes`), `internal/billing/queries` (`ListCurrentSubscriptionItemsByBillingAccountID`), `internal/server/operator_enrollment.go` and `operator_enrollment.html` (the Billing card line), the trace harness `internal/fulfillment/trace_db_test.go`.
|
||||
- **Code:** `internal/fulfillment/reconcile.go` (the fixed order, the displacement skip, the choice of tiers, two log lines, the pure rule exported for the page), `internal/entitlements/queries` (`ListSubscriptionDisplacements`, `ListAccountSubscriptionDisplacements`, `ListConferralShapes`), `internal/billing/queries` (`ListCurrentSubscriptionItemsByBillingAccountID`), `internal/server/operator_enrollment.go` and `operator_enrollment.html` (the Billing card line), the trace harness `internal/fulfillment/trace_db_test.go`, `test/pin-timestamps.sql` (base tables only, because the new view exposes `effective_at` and cannot be updated).
|
||||
- **Schema:** two core migrations, `00033_supersession_pairs.sql` (a view, and `core.grant_replacements` re-expressed over it) and `00034_subscription_displacements.sql` (a function). No column, no data, no grant change beyond the new objects' own.
|
||||
- **Stripe calls:** none added, none removed.
|
||||
- **Routes:** none.
|
||||
|
||||
@@ -31,20 +31,27 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
|
||||
|
||||
## 3. One statement of a supersession pair
|
||||
|
||||
- [ ] 3.1 `internal/db/migrations/00033_supersession_pairs.sql` (design D1, Migration plan): a header comment in the style of `00023` and `00025` saying what the view pairs, that `core.grant_replacements` now reads it with its rows unchanged, that it reads no `reason` so the reader role reads it, and that #179 would change only its body; `CREATE VIEW core.supersession_pairs` and `CREATE OR REPLACE VIEW core.grant_replacements` as the design writes them; `GRANT SELECT ON core.supersession_pairs TO core_reader, core_writer, core_owner;`. Down: `CREATE OR REPLACE VIEW core.grant_replacements` with `00023`'s body, then `DROP VIEW core.supersession_pairs;`. Every multi-statement section is wrapped in `StatementBegin` and `StatementEnd` (`docs/database-management.md`).
|
||||
- [ ] 3.2 `internal/migrate/testdata/console-reader-columns.txt`: the view's five columns, `effective_at`, `ended_provision_id`, `plan_ladder_id`, `pool_id`, `successor_provision_id`, each marked `read`, written by `go test ./internal/migrate/ -run TestConsoleReaderColumns -count=1 -update` and checked in the diff to be those five lines only.
|
||||
- [ ] 3.3 `docs/models/plan-ladders-transitions.md`, "Where this lives": a row for `core.supersession_pairs` (the one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason`; `00033_supersession_pairs.sql`), and the `core.grant_replacements` row says that it reads its pairs from that view (`00023_grant_resumption.sql`; `00033_supersession_pairs.sql`).
|
||||
- [ ] 3.4 Tests, `internal/entitlements/supersession_pairs_test.go` (package `entitlements_test`, over `newPlusProWorld`, in the world's rolled-back transaction):
|
||||
- [x] 3.1 `internal/db/migrations/00033_supersession_pairs.sql` (design D1, Migration plan): a header comment in the style of `00023` and `00025` saying what the view pairs, that `core.grant_replacements` now reads it with its rows unchanged, that it reads no `reason` so the reader role reads it, and that #179 would change only its body; `CREATE VIEW core.supersession_pairs` and `CREATE OR REPLACE VIEW core.grant_replacements` as the design writes them; `GRANT SELECT ON core.supersession_pairs TO core_reader, core_writer, core_owner;`. Down: `CREATE OR REPLACE VIEW core.grant_replacements` with `00023`'s body, then `DROP VIEW core.supersession_pairs;`. Every multi-statement section is wrapped in `StatementBegin` and `StatementEnd` (`docs/database-management.md`).
|
||||
- [x] 3.2 `internal/migrate/testdata/console-reader-columns.txt`: the view's five columns, `effective_at`, `ended_provision_id`, `plan_ladder_id`, `pool_id`, `successor_provision_id`, each marked `read`, written by `go test ./internal/migrate/ -run TestConsoleReaderColumns -count=1 -update` and checked in the diff to be those five lines only.
|
||||
- [x] 3.3 `docs/models/plan-ladders-transitions.md`, "Where this lives": a row for `core.supersession_pairs` (the one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason`; `00033_supersession_pairs.sql`), and the `core.grant_replacements` row says that it reads its pairs from that view (`00023_grant_resumption.sql`; `00033_supersession_pairs.sql`).
|
||||
- [x] 3.4 Tests, `internal/entitlements/supersession_pairs_test.go` (package `entitlements_test`, over `newPlusProWorld`, in the world's rolled-back transaction):
|
||||
- `TestSupersessionPairs_NameBothProvisions`: a subscription confers Plus (`conferSub`), an operator's grant of Pro is conferred over it; the view has exactly one row in the pool, on Wiki, with the subscription's provision ended, the grant's provision as successor and the conferral's instant. A failure reads `pairs = %+v, want one on Wiki from the subscription's provision to the grant's`.
|
||||
- `TestSupersessionPairs_ARestorationIsNotAPair`: a grant of Plus whose `valid_until` has passed (`grantOpts{validUntil: p.now.Add(-time.Hour)}`), conferred and expired (`p.expire(g)`), so the floor restores Free in the same transaction by the same actor (`system`, no id, at `now()`, the one path where `from_rank` alone decides); no row names the default's provision as successor. Its precondition is `p.holdsLadder(p.wiki) == p.free`. A revocation cannot test this, because its `end` row is written by the operator and its floor row by the system, so the two never pair whether or not the view reads `from_rank`. A failure reads `the floor's restoration is paired with the expired grant's end`.
|
||||
- `TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder`: a product that is a tier of Wiki and of a second ladder, Docs, conferred from a subscription, then a grant of Pro (Wiki only) over it; the view has a row on Wiki and none on Docs. A failure reads `a pair on Docs, where the grant wrote no rung`.
|
||||
- `TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder`: a product that is a tier of Wiki and of a second ladder, Docs, conferred from a subscription, then a grant of Pro (Wiki only) over it; the view has a row on Wiki and none on Docs. Its preconditions are that Docs holds Plus before the grant and nothing after it. A failure reads `a pair on Docs, where the grant wrote no rung`.
|
||||
- `TestSupersessionPairs_AChainOfActsPairsEachEndAtItsOwnInstant`: an operator's grants of Plus, Pro over it and Max over Pro, so one actor writes every row; the view has exactly the pairs Plus to Pro and Pro to Max. A failure reads `an end row is paired with a typed row written at another instant`.
|
||||
- `TestSupersessionPairs_AnExpiryEndIsNotPairedWithTheSubscriptionsConferral`: a grant of Plus expired at `now()` (system, the floor restores Free), then a subscription confers Pro over the restored Free at `now()` (webhook); no pair names the expired grant's provision. Its precondition is `p.holdsLadder(p.wiki) == p.free`. A failure reads `the expiry's end is paired with the subscription's conferral`.
|
||||
- `NameBothProvisions` also reads `core.grant_replacements` and finds no row in the pool, and `ARestorationIsNotAPair` finds no replacement of the expired grant. No break separates the instant from the pair in `NameBothProvisions`: the join makes it equal on both rows. No break of `WHERE ep.grant_id IS NOT NULL` is caught either: with a subscription's ended provision, `ng.extends_grant_id IS DISTINCT FROM ep.grant_id` compares NULL with NULL and already refuses the row, so the clause matters only to an extension grant conferred over a subscription's provision, which no test builds.
|
||||
- The six `TestGrantReplacements_*` tests, the `TestResumableGrants_*` tests and the `TestGrantWaits_*` tests in `internal/entitlements/grant_waits_test.go` pass unedited, which is the proof that `core.grant_replacements` keeps its rows; `TestConsoleReaderColumns` and `TestRuntimeRolePrivileges` pass with the view.
|
||||
- [ ] 3.5 Breaks (command records, the migration file `internal/db/migrations/00033_supersession_pairs.sql`):
|
||||
- [x] 3.5 Breaks (command records, the migration file `internal/db/migrations/00033_supersession_pairs.sql`):
|
||||
- the view without `AND n.from_rank IS NOT NULL`: caught by `TestSupersessionPairs_ARestorationIsNotAPair` (`./internal/entitlements`), `want_new` `the floor's restoration is paired with the expired grant's end`;
|
||||
- the view without `AND n.plan_ladder_id = e.plan_ladder_id`: caught by `TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder`, `want_new` `a pair on Docs, where the grant wrote no rung`;
|
||||
- `core.grant_replacements` without `AND ng.extends_grant_id IS DISTINCT FROM ep.grant_id`: caught by `TestGrantReplacements_AnExtensionIsNotAReplacement`, `want_new` `want none for an extension`;
|
||||
- the `GRANT SELECT ON core.supersession_pairs` line removed: caught by `TestRuntimeRolePrivileges` (`./internal/db`), `want_new` `console_runtime cannot SELECT from core.supersession_pairs`;
|
||||
- the view naming the provisions the other way round (`n.provision_id AS ended_provision_id`, `e.provision_id AS successor_provision_id`): caught by `TestSupersessionPairs_NameBothProvisions`, `want_new` `want one on Wiki from the subscription's provision to the grant's`;
|
||||
- the view without `AND n.effective_at = e.effective_at`: caught by `TestSupersessionPairs_AChainOfActsPairsEachEndAtItsOwnInstant`, `want_new` `an end row is paired with a typed row written at another instant`;
|
||||
- the view without `AND n.actor_type = e.actor_type`: caught by `TestSupersessionPairs_AnExpiryEndIsNotPairedWithTheSubscriptionsConferral`, `want_new` `the expiry's end is paired with the subscription's conferral`;
|
||||
- the view selecting `e.reason` as a sixth column, which makes the reader script withhold it whole: caught by `TestConsoleReaderColumns` (`./internal/migrate`), `want_new` `core.supersession_pairs.pool_id is marked read, but console_reader does not read it`.
|
||||
- [x] 3.6 `test/pin-timestamps.sql` walks base tables only (`information_schema.tables.table_type = 'BASE TABLE'`), with a sentence in its header saying why: `core.supersession_pairs` exposes `effective_at`, and the demo snapshot's pinning tried to update the view, which joins `core.pool_provision_transitions` to itself and so cannot be updated. No existing view carries one of the pinned columns, so the snapshot's pinned values do not change.
|
||||
|
||||
## 4. The reconcile leaves a ladder alone while another plan holds it
|
||||
|
||||
|
||||
+11
-6
@@ -16,7 +16,9 @@
|
||||
-- per column would break the order between columns, putting a payment
|
||||
-- before its invoice's issue.
|
||||
-- Runs with session_replication_role = replica so updated_at triggers stay
|
||||
-- quiet. A table without a primary key gets the base instant alone.
|
||||
-- quiet. A table without a primary key gets the base instant alone. Views
|
||||
-- are skipped: their rows are their tables' rows, already pinned, and a view
|
||||
-- over several tables cannot be updated.
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
@@ -24,11 +26,14 @@ DECLARE
|
||||
rowexpr text;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT table_schema, table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'goose')
|
||||
AND data_type LIKE 'timestamp%'
|
||||
AND column_name IN ('created_at', 'updated_at', 'joined_at', 'effective_at', 'activated_at', 'valid_from', 'last_login_at', 'observed_at')
|
||||
SELECT c.table_schema, c.table_name, c.column_name
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
||||
WHERE t.table_type = 'BASE TABLE'
|
||||
AND c.table_schema NOT IN ('pg_catalog', 'information_schema', 'goose')
|
||||
AND c.data_type LIKE 'timestamp%'
|
||||
AND c.column_name IN ('created_at', 'updated_at', 'joined_at', 'effective_at', 'activated_at', 'valid_from', 'last_login_at', 'observed_at')
|
||||
LOOP
|
||||
-- The primary key as one comparable text value, once unqualified (for
|
||||
-- the ranking subquery) and once qualified (for the row it updates).
|
||||
|
||||
Reference in New Issue
Block a user