Record a tier delete as an act, inside one transaction on the runner
A plan-wide change to the catalog left no record of itself, so nothing could say which change moved an organization or offer to run it again (#187). Migration 00027 adds that record: core.acts, one row per act with its cause, actor, note and request id, and core.plan_act_details, the act's plan-specific detail. Both tables are enclosed: only core.record_plan_act writes them, under the exclusive rendezvous for a catalog cause, and core_writer can only read them. core_reader reads every column but the note, which scripts/db-reader-role.sql lists as personal. runPlanAct is the one shape the plan acts will share: begin, find the pools, lock them in ascending id, read again under the locks, act and commit, with every failure named by its step and a refusal returned as it is. The tier delete is the first act on it. Inside its exclusive transaction it counts the tier's live holders (active and suspended), reads the ladder's tiers and the org types defaulting to it, applies the last-tier guard, records the act and deletes and renumbers; before this, the count and the guard's read ran outside the transaction, and a failed read of the org types let the delete through. A delete of a product that is no longer a tier of the ladder, a repeated confirm, writes nothing. The handler only parses, calls and renders: - A lock wait or a deadlock at any step answers "Another change collided with this one; try again." (#216). - Every other failure answers "Failed to remove tier. Reference: {request id}." and logs the step through the request's logger. - A tier held only by suspended positions is a live holder: the tier page routes it to Preview removal, and the plain delete refuses it. The plan-act golden harness builds the act's queriers through the new PlanActs seam, so a scenario can fail a statement inside the act's own transaction, and every golden gains the act and detail sections. Most gain them empty. Behaviour changes in delete-begin-fails (the log line names the step and carries the request id; the answer carries the reference), delete-bottom-tier, delete-top-tier and tier-change-during-conferral/a-tier-delete (the act row), delete-held-tier and delete-last-tier-of-a-default (the reads now run inside the transaction; the answers are unchanged) and contention (the delete answers the collision sentence; later placeholders renumber because the delete's log line now carries a request id). New: delete-org-types-unread and delete-suspended-holder. The design's failure table for the tier delete now gives the reference answer for a failed holder count and a failed tier read, as the approved copy for the delete states, and a row for the repeated delete. The full suite passes.
This commit is contained in:
+10
-2
@@ -180,8 +180,16 @@ organization's enactment run in transactions from `BeginMaterializing`.
|
||||
(`core.align_conferral_shape`), the floor is restored when the disposition is
|
||||
migrate, and the pool materializes.
|
||||
- **Tier delete without holders** (`entitlements.DeleteTier`): the exclusive
|
||||
rendezvous only, with no pool lock, around the tier delete and the
|
||||
renumbering.
|
||||
rendezvous, then the reads that decide the delete, all inside the
|
||||
transaction: the count of the tier's live holders (positions that are
|
||||
`active` or `suspended`), the ladder's tiers and the org types
|
||||
(`ListOrgTypes`), from which `LastTierRefusal` decides. A failed read refuses
|
||||
the delete. Then the names of the ladder and the product, the act row and
|
||||
its detail row (`core.record_plan_act`), the tier delete and the
|
||||
renumbering. It takes no pool lock: a tier with no live holder has no pool
|
||||
to reconcile, and the guard holds because the exclusive rendezvous keeps any
|
||||
conferral from placing a position on the tier between the count and the
|
||||
delete. The delete runs through `runPlanAct`.
|
||||
- **Default change** (`entitlements.ChangeOrgTypeDefault` and `EnactDefault`):
|
||||
the default is saved outside any lock beyond the row update. Then each
|
||||
organization is its own transaction: the rendezvous, the organization's
|
||||
|
||||
@@ -92,6 +92,13 @@ Removing a tier with **no live holders** is a plain confirm-modal delete (the mo
|
||||
|
||||
The commit is **atomic**: tier deletion, renumbering, and every holder reconciliation run in one transaction — a failure rolls back everything, tier included, so no provision is ever left mismatched with its product's shape.
|
||||
|
||||
The plain delete of a tier with no live holders runs the same way, as a removal with no holders, and decides inside the one transaction that deletes:
|
||||
|
||||
- **Live holders.** A holder is live when its position is `active` or `suspended`, the population a removal reconciles. The delete counts them in its own transaction and refuses a tier that has any, so a tier held only by a past-due subscription's suspended position is not deleted under it; the tier page offers Preview removal for such a tier, and the delete a stale page sends is refused with the live-holder sentence. The Active column on the tier page still counts `active` positions only.
|
||||
- **The last tier of a default ladder.** The delete reads the ladder's tiers and the org types that default to it, and refuses to delete the only tier of a ladder some org type defaults to (new signups would land on a ladder with no tier). A read of the org types that fails refuses the delete too; the delete never proceeds for want of an answer.
|
||||
- **The record.** The delete writes one act (cause `tier_removed`, no disposition) in the same transaction, naming the ladder and the product as they were named at that moment, and then deletes and renumbers. A failure at any step rolls the act back with the rest. A lock wait or a deadlock at any step answers "Another change collided with this one; try again."; every other failure answers "Failed to remove tier. Reference: {request id}.", and the log line carries the same request id and the step that failed.
|
||||
- **A repeated delete.** A delete of a product that is no tier of the ladder, such as the second of two deletes of one tier, writes no act, deletes nothing and answers as a successful delete does.
|
||||
|
||||
### Deleting a Ladder
|
||||
|
||||
A ladder can be deleted only when it has **no active attachments** across all its tiers. Deleting a ladder also removes all tier memberships.
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package db_test
|
||||
|
||||
// Migration 00027 (openspec change plan-act-obligations, task 4.6): the act
|
||||
// record, its constraints and its enclosure. The test stack's login role is a
|
||||
// cluster superuser, which no revoke binds, so the privilege tests act as a
|
||||
// role built inside the transaction with the login role's memberships and none
|
||||
// of its superuser bit, as the enclosure tests of the delivery tables do. The
|
||||
// constraint tests write the rows directly, as the owner would: the
|
||||
// constraints are the subject, and the function that writes the rows would
|
||||
// refuse some of these shapes before the constraint saw them.
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
)
|
||||
|
||||
const (
|
||||
actsVersion = 27
|
||||
actsPrevious = 26
|
||||
|
||||
// notInPrerequisiteState is the SQLSTATE of core.assert_rendezvous's
|
||||
// refusal, materialization_rendezvous_missing.
|
||||
notInPrerequisiteState = "55000"
|
||||
|
||||
exclusiveRendezvous = `SELECT pg_advisory_xact_lock(hashtextextended('entitlement_materialization', 0))`
|
||||
sharedRendezvous = `SELECT pg_advisory_xact_lock_shared(hashtextextended('entitlement_materialization', 0))`
|
||||
|
||||
// recordTierRemoved calls core.record_plan_act for a tier removal by a
|
||||
// person: $1 the person, $2 the ladder, $3 the product.
|
||||
recordTierRemoved = `SELECT core.record_plan_act('tier_removed', 'person', $1, NULL, NULL, 'req-1', NOW(),
|
||||
$2, 'Main', $3, 'Basic', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`
|
||||
)
|
||||
|
||||
// actFixture is the rows the act tests share, written in the caller's
|
||||
// transaction.
|
||||
type actFixture struct {
|
||||
person, ladder, product string
|
||||
}
|
||||
|
||||
func newActFixture(t *testing.T, q *dbtest.Q) actFixture {
|
||||
t.Helper()
|
||||
return actFixture{person: fxPerson(t, q.Tx()), ladder: uuid.NewString(), product: uuid.NewString()}
|
||||
}
|
||||
|
||||
func TestActRecordIsEnclosed(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
tx := q.Tx()
|
||||
app := applicationRole(t, tx)
|
||||
f := newActFixture(t, q)
|
||||
q.Exec(exclusiveRendezvous)
|
||||
|
||||
asRole(t, tx, app, func() {
|
||||
for _, table := range []string{"core.acts", "core.plan_act_details"} {
|
||||
q.Refuse(dbtest.SQLStatePrivilege, "", `INSERT INTO `+table+` DEFAULT VALUES`)
|
||||
q.Refuse(dbtest.SQLStatePrivilege, "", `UPDATE `+table+` SET act_id = act_id WHERE FALSE`)
|
||||
q.Refuse(dbtest.SQLStatePrivilege, "", `DELETE FROM `+table+` WHERE FALSE`)
|
||||
q.Refuse(dbtest.SQLStatePrivilege, "", `TRUNCATE `+table)
|
||||
}
|
||||
|
||||
// The function is the write path, and the writer reads what it wrote,
|
||||
// the note included.
|
||||
actID := q.String(recordTierRemoved, f.person, f.ladder, f.product)
|
||||
var cause, requestID string
|
||||
q.Scan([]any{&cause, &requestID}, `SELECT cause, request_id FROM core.acts WHERE act_id = $1`, actID)
|
||||
if cause != "tier_removed" || requestID != "req-1" {
|
||||
t.Errorf("the act row is cause %q, request %q, want tier_removed and req-1", cause, requestID)
|
||||
}
|
||||
var name string
|
||||
q.Scan([]any{&name}, `SELECT product_name FROM core.plan_act_details WHERE act_id = $1`, actID)
|
||||
if name != "Basic" {
|
||||
t.Errorf("the detail row names the product %q, want Basic", name)
|
||||
}
|
||||
q.Exec(`SELECT note FROM core.acts`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordPlanActIsCalledOnlyByTheWriter(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
tx := q.Tx()
|
||||
f := newActFixture(t, q)
|
||||
q.Exec(exclusiveRendezvous)
|
||||
|
||||
bare := dbtest.Name("acts_bare_")
|
||||
q.Exec("CREATE ROLE " + bare + " NOLOGIN")
|
||||
q.Exec("GRANT USAGE ON SCHEMA core TO " + bare)
|
||||
for _, role := range []string{bare, "core_reader"} {
|
||||
asRole(t, tx, role, func() {
|
||||
q.Refuse(dbtest.SQLStatePrivilege, "", recordTierRemoved, f.person, f.ladder, f.product)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A catalog cause asserts the exclusive rendezvous and a Retry asserts it in
|
||||
// either mode, the way the enclosure spec reads.
|
||||
func TestRecordPlanActAssertsTheRendezvous(t *testing.T) {
|
||||
retry := `SELECT core.record_plan_act('plan_act_retried', 'person', $1, NULL, NULL, NULL, NOW(),
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $2)`
|
||||
t.Run("no rendezvous refuses every cause", func(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
q.Refuse(notInPrerequisiteState, "", recordTierRemoved, f.person, f.ladder, f.product)
|
||||
q.Refuse(notInPrerequisiteState, "", retry, f.person, uuid.NewString())
|
||||
})
|
||||
t.Run("a catalog cause refuses under the shared rendezvous", func(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
q.Exec(sharedRendezvous)
|
||||
q.Refuse(notInPrerequisiteState, "", recordTierRemoved, f.person, f.ladder, f.product)
|
||||
})
|
||||
t.Run("a Retry is recorded under the shared rendezvous", func(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
retried := insertAct(q, "tier_added")
|
||||
q.Exec(`INSERT INTO core.plan_act_details (act_id, cause, plan_ladder_id, plan_ladder_name, product_id, product_name)
|
||||
VALUES ($1, 'tier_added', $2, 'Main', $3, 'Basic')`, retried, f.ladder, f.product)
|
||||
q.Exec(sharedRendezvous)
|
||||
actID := q.String(retry, f.person, retried)
|
||||
if got := q.String(`SELECT retried_act_id::text FROM core.plan_act_details WHERE act_id = $1`, actID); got != retried {
|
||||
t.Errorf("the Retry names the act %s, want %s", got, retried)
|
||||
}
|
||||
})
|
||||
t.Run("the exclusive rendezvous records every cause", func(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
q.Exec(exclusiveRendezvous)
|
||||
record := func(cause string, args ...any) {
|
||||
t.Helper()
|
||||
q.String(`SELECT core.record_plan_act($1, 'person', $2, NULL, NULL, NULL, NOW(),
|
||||
$3, $4, $5, $6, NULL, NULL, $7, $8, NULL, NULL, $9, NULL)`,
|
||||
append([]any{cause, f.person}, args...)...)
|
||||
}
|
||||
record("tier_added", f.ladder, "Main", f.product, "Basic", nil, nil, nil)
|
||||
record("tier_removed", f.ladder, "Main", f.product, "Basic", nil, nil, "keep")
|
||||
record("org_type_default_changed", f.ladder, "Main", f.product, "Basic", "personal", "Personal", "migrate")
|
||||
if n := q.Int(`SELECT count(*) FROM core.acts WHERE actor_person_id = $1`, f.person); n != 3 {
|
||||
t.Errorf("%d acts recorded, want 3", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The function reads the ladder's rank-0 product itself, so a caller cannot
|
||||
// misstate which product a reorder made rank 0.
|
||||
func TestRecordPlanActChecksAReordersRankZero(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
ladder := q.ID(`INSERT INTO core.plan_ladders (name) VALUES ($1) RETURNING plan_ladder_id`, dbtest.Name("acts ladder "))
|
||||
first := q.ID(`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`, dbtest.Name("acts first "))
|
||||
second := q.ID(`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`, dbtest.Name("acts second "))
|
||||
q.Exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, 0), ($1, $3, 1)`, ladder, first, second)
|
||||
q.Exec(exclusiveRendezvous)
|
||||
|
||||
const reorder = `SELECT core.record_plan_act('tiers_reordered', 'person', $1, NULL, NULL, NULL, NOW(),
|
||||
$2, 'Main', $3, 'New', $4, 'Old', NULL, NULL, NULL, NULL, NULL, NULL)`
|
||||
q.Refuse(dbtest.SQLStateInvalidParameter, "", reorder, f.person, ladder, second, first)
|
||||
q.Scan([]any{new(string)}, reorder, f.person, ladder, first, second)
|
||||
// A reorder that left rank 0 alone names no product and is recorded.
|
||||
q.Scan([]any{new(string)}, `SELECT core.record_plan_act('tiers_reordered', 'person', $1, NULL, NULL, NULL, NOW(),
|
||||
$2, 'Main', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`, f.person, ladder)
|
||||
}
|
||||
|
||||
// The request id is for correlating the act with log lines: stored when it
|
||||
// fits the column, NULL otherwise, and the act is recorded either way.
|
||||
func TestRecordPlanActStoresARequestIDOnlyWhenItFits(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
q.Exec(exclusiveRendezvous)
|
||||
|
||||
for _, tc := range []struct{ name, id, want string }{
|
||||
{"short", "req-short", "req-short"},
|
||||
{"at the limit", strings.Repeat("a", 100), strings.Repeat("a", 100)},
|
||||
{"one over", strings.Repeat("a", 101), ""},
|
||||
{"none", "", ""},
|
||||
} {
|
||||
var id any = tc.id
|
||||
if tc.id == "" {
|
||||
id = nil
|
||||
}
|
||||
actID := q.String(`SELECT core.record_plan_act('tier_added', 'person', $1, NULL, NULL, $2, NOW(),
|
||||
$3, 'Main', $4, 'Basic', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`, f.person, id, f.ladder, f.product)
|
||||
if got := q.String(`SELECT coalesce(request_id, '') FROM core.acts WHERE act_id = $1`, actID); got != tc.want {
|
||||
t.Errorf("%s: stored request id %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The function writes the real core tables through its pinned search_path,
|
||||
// never a table of the caller's that shadows them.
|
||||
func TestRecordPlanActIgnoresACallersTemporaryTables(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
tx := q.Tx()
|
||||
app := applicationRole(t, tx)
|
||||
f := newActFixture(t, q)
|
||||
q.Exec(exclusiveRendezvous)
|
||||
|
||||
asRole(t, tx, app, func() {
|
||||
q.Exec(`CREATE TEMPORARY TABLE acts (LIKE core.acts INCLUDING DEFAULTS) ON COMMIT DROP`)
|
||||
q.Exec(`CREATE TEMPORARY TABLE plan_act_details (LIKE core.plan_act_details INCLUDING DEFAULTS) ON COMMIT DROP`)
|
||||
q.Exec(`GRANT ALL ON pg_temp.acts, pg_temp.plan_act_details TO PUBLIC`)
|
||||
q.Exec(`SET LOCAL search_path = pg_temp, core`)
|
||||
actID := q.String(recordTierRemoved, f.person, f.ladder, f.product)
|
||||
|
||||
var shadowActs, shadowDetails, realActs, realDetails int
|
||||
q.Scan([]any{&shadowActs}, `SELECT COUNT(*) FROM pg_temp.acts`)
|
||||
q.Scan([]any{&shadowDetails}, `SELECT COUNT(*) FROM pg_temp.plan_act_details`)
|
||||
q.Scan([]any{&realActs}, `SELECT COUNT(*) FROM core.acts WHERE act_id = $1`, actID)
|
||||
q.Scan([]any{&realDetails}, `SELECT COUNT(*) FROM core.plan_act_details WHERE act_id = $1`, actID)
|
||||
if shadowActs != 0 || shadowDetails != 0 {
|
||||
t.Errorf("the caller's temporary tables captured %d acts and %d details, want none", shadowActs, shadowDetails)
|
||||
}
|
||||
if realActs != 1 || realDetails != 1 {
|
||||
t.Errorf("core holds %d acts and %d details of the call, want 1 and 1", realActs, realDetails)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// core_reader, which every integration's writer role holds, reads every
|
||||
// column of the act record but the note.
|
||||
func TestCoreReaderCannotReadAnActsNote(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
tx := q.Tx()
|
||||
f := newActFixture(t, q)
|
||||
q.Exec(exclusiveRendezvous)
|
||||
q.String(recordTierRemoved, f.person, f.ladder, f.product)
|
||||
|
||||
columns := q.Strings(`SELECT a.attname FROM pg_attribute a
|
||||
WHERE a.attrelid = 'core.acts'::regclass AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`)
|
||||
var readable []string
|
||||
for _, column := range columns {
|
||||
if column != "note" {
|
||||
readable = append(readable, column)
|
||||
}
|
||||
}
|
||||
if len(readable) != len(columns)-1 || !slices.Contains(columns, "note") {
|
||||
t.Fatalf("core.acts has columns %v, want a note among them", columns)
|
||||
}
|
||||
for _, role := range []string{"core_reader", "fedwiki_writer", "discourse_writer"} {
|
||||
asRole(t, tx, role, func() {
|
||||
q.Refuse(dbtest.SQLStatePrivilege, "", `SELECT note FROM core.acts`)
|
||||
q.Refuse(dbtest.SQLStatePrivilege, "", `SELECT * FROM core.acts`)
|
||||
q.Exec(`SELECT ` + strings.Join(readable, ", ") + ` FROM core.acts`)
|
||||
q.Exec(`SELECT * FROM core.plan_act_details`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// insertAct writes a system act row directly, as the owner would, and returns
|
||||
// its id.
|
||||
func insertAct(q *dbtest.Q, cause string) string {
|
||||
return q.ID(`INSERT INTO core.acts (cause, actor_type, effective_at) VALUES ($1, 'system', NOW()) RETURNING act_id`, cause)
|
||||
}
|
||||
|
||||
func TestActConstraints(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
|
||||
const insert = `INSERT INTO core.acts (cause, actor_type, actor_person_id, actor_service_account_id, note, effective_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())`
|
||||
q.Refuse(dbtest.SQLStateCheck, "chk_acts_cause", insert, "tier_painted", "system", nil, nil, nil)
|
||||
// Checks are evaluated in name order, so the coherence check names an
|
||||
// unknown actor type before chk_acts_actor_type does.
|
||||
q.Refuse(dbtest.SQLStateCheck, "act_actor_coherent", insert, "tier_added", "robot", nil, nil, nil)
|
||||
q.Refuse(dbtest.SQLStateCheck, "act_actor_coherent", insert, "tier_added", "system", f.person, nil, nil)
|
||||
q.Refuse(dbtest.SQLStateCheck, "act_actor_coherent", insert, "tier_added", "person", nil, nil, nil)
|
||||
q.Refuse(dbtest.SQLStateCheck, "act_actor_coherent", insert, "tier_added", "person", f.person, uuid.NewString(), nil)
|
||||
q.Refuse(dbtest.SQLStateCheck, "act_actor_coherent", insert, "tier_added", "service_account", f.person, nil, nil)
|
||||
q.Refuse(dbtest.SQLStateCheck, "act_note_by_a_person", insert, "tier_added", "system", nil, nil, "a note")
|
||||
q.Refuse(dbtest.SQLStateForeignKey, "fk_acts_actor_person_id", insert, "tier_added", "person", uuid.NewString(), nil, nil)
|
||||
// A person's act may carry a note, and a system act none.
|
||||
q.Exec(insert, "tier_added", "person", f.person, nil, "a note")
|
||||
q.Exec(insert, "tier_added", "system", nil, nil, nil)
|
||||
}
|
||||
|
||||
func TestPlanActDetailConstraints(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
|
||||
const insert = `INSERT INTO core.plan_act_details (act_id, cause, plan_ladder_id, plan_ladder_name, product_id, product_name,
|
||||
previous_product_id, previous_product_name, org_type, org_type_name, previous_plan_ladder_id, previous_plan_ladder_name,
|
||||
disposition, retried_act_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`
|
||||
ladder, product := f.ladder, f.product
|
||||
added := insertAct(q, "tier_added")
|
||||
|
||||
// A detail row belongs to an act of its own kind.
|
||||
q.Refuse(dbtest.SQLStateForeignKey, "plan_act_detail_is_its_act", insert,
|
||||
added, "tier_removed", ladder, "Main", product, "Basic", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
q.Refuse(dbtest.SQLStateCheck, "chk_plan_act_details_cause", insert,
|
||||
added, "tier_painted", ladder, "Main", product, "Basic", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
q.Refuse(dbtest.SQLStateForeignKey, "plan_act_detail_is_its_act", insert,
|
||||
uuid.NewString(), "tier_added", ladder, "Main", product, "Basic", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// An id is stored with its name or not at all.
|
||||
q.Refuse(dbtest.SQLStateCheck, "plan_act_detail_names_what_it_ids", insert,
|
||||
added, "tier_added", ladder, nil, product, "Basic", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
q.Refuse(dbtest.SQLStateCheck, "plan_act_detail_names_what_it_ids", insert,
|
||||
added, "tier_added", ladder, "Main", nil, "Basic", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
q.Refuse(dbtest.SQLStateCheck, "chk_plan_act_details_disposition", insert,
|
||||
added, "tier_added", ladder, "Main", product, "Basic", nil, nil, nil, nil, nil, nil, "discard", nil)
|
||||
|
||||
// Each cause has its shape.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cause string
|
||||
args [12]any // ladder, ladder name, product, product name, previous product, its name, org type, its name, previous ladder, its name, disposition, retried act
|
||||
}{
|
||||
{"a tier add without its product", "tier_added", [12]any{ladder, "Main"}},
|
||||
{"a tier add with a disposition", "tier_added", [12]any{ladder, "Main", product, "Basic", nil, nil, nil, nil, nil, nil, "keep"}},
|
||||
{"a tier add of an org type", "tier_added", [12]any{ladder, "Main", product, "Basic", nil, nil, "personal", "Personal"}},
|
||||
{"a tier removal without its ladder", "tier_removed", [12]any{nil, nil, product, "Basic"}},
|
||||
{"a tier removal that grandfathers", "tier_removed", [12]any{ladder, "Main", product, "Basic", nil, nil, nil, nil, nil, nil, "grandfather"}},
|
||||
{"a reorder with a previous product only", "tiers_reordered", [12]any{ladder, "Main", nil, nil, product, "Basic"}},
|
||||
{"a reorder that keeps", "tiers_reordered", [12]any{ladder, "Main", nil, nil, nil, nil, nil, nil, nil, nil, "keep"}},
|
||||
{"a reorder of an org type", "tiers_reordered", [12]any{ladder, "Main", nil, nil, nil, nil, "personal", "Personal"}},
|
||||
{"a default change without its org type", "org_type_default_changed", [12]any{ladder, "Main", product, "Basic"}},
|
||||
{"a default change to a ladder without its rank 0", "org_type_default_changed", [12]any{ladder, "Main", nil, nil, nil, nil, "personal", "Personal"}},
|
||||
{"a default change that keeps", "org_type_default_changed", [12]any{nil, nil, nil, nil, nil, nil, "personal", "Personal", nil, nil, "keep"}},
|
||||
{"a Retry without its act", "plan_act_retried", [12]any{}},
|
||||
{"a Retry that names a ladder", "plan_act_retried", [12]any{ladder, "Main", nil, nil, nil, nil, nil, nil, nil, nil, nil, added}},
|
||||
} {
|
||||
act := insertAct(q, tc.cause)
|
||||
args := append([]any{act, tc.cause}, tc.args[:]...)
|
||||
if err := q.Attempt(insert, args...); err == nil {
|
||||
t.Errorf("%s: the shape was accepted", tc.name)
|
||||
} else {
|
||||
dbtest.WantPgError(t, err, dbtest.SQLStateCheck, "plan_act_detail_shape")
|
||||
}
|
||||
}
|
||||
|
||||
// And the shapes it accepts: a tier removal with no disposition, a
|
||||
// reorder that left rank 0 alone, a default change to None, a Retry.
|
||||
for _, tc := range []struct {
|
||||
cause string
|
||||
args [12]any
|
||||
}{
|
||||
{"tier_removed", [12]any{ladder, "Main", product, "Basic"}},
|
||||
{"tier_removed", [12]any{ladder, "Main", product, "Basic", nil, nil, nil, nil, nil, nil, "migrate"}},
|
||||
{"tiers_reordered", [12]any{ladder, "Main"}},
|
||||
{"tiers_reordered", [12]any{ladder, "Main", product, "Basic", product, "Plus", nil, nil, nil, nil, "grandfather"}},
|
||||
{"org_type_default_changed", [12]any{nil, nil, nil, nil, nil, nil, "personal", "Personal", ladder, "Main", "migrate"}},
|
||||
{"plan_act_retried", [12]any{nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, added}},
|
||||
} {
|
||||
act := insertAct(q, tc.cause)
|
||||
if tc.cause == "plan_act_retried" {
|
||||
q.Exec(insert, added, "tier_added", ladder, "Main", product, "Basic", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
}
|
||||
q.Exec(insert, append([]any{act, tc.cause}, tc.args[:]...)...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActsMigrationUpDownUp(t *testing.T) {
|
||||
requireMigrationUpDownUp(t, actsPrevious, actsVersion)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
-- +goose Up
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The act record (openspec change plan-act-obligations, design D5, D8).
|
||||
-- An action that writes several rows records itself once, in core.acts: who
|
||||
-- acted, when, why from a closed list, and an optional note. What a plan act
|
||||
-- did goes in core.plan_act_details, keyed by the act and tied to it by a
|
||||
-- composite foreign key on (act_id, cause), so a detail row belongs only to
|
||||
-- an act of its own kind. Later kinds add their own causes by replacing the
|
||||
-- constraint, and their own detail tables.
|
||||
--
|
||||
-- Both tables are enclosed: core_writer reads them and holds no write on
|
||||
-- either. core.record_plan_act, owned by core_owner, is the only writer.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE core.acts (
|
||||
act_id UUID NOT NULL DEFAULT uuidv7(),
|
||||
cause VARCHAR(50) NOT NULL,
|
||||
actor_type VARCHAR(20) NOT NULL,
|
||||
actor_person_id UUID,
|
||||
actor_service_account_id UUID,
|
||||
note TEXT,
|
||||
request_id VARCHAR(100),
|
||||
effective_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT pk_acts PRIMARY KEY (act_id),
|
||||
CONSTRAINT uq_acts_act_id_cause UNIQUE (act_id, cause),
|
||||
CONSTRAINT fk_acts_actor_person_id FOREIGN KEY (actor_person_id) REFERENCES core.persons (person_id),
|
||||
CONSTRAINT chk_acts_cause CHECK (cause IN (
|
||||
'tier_added', 'tier_removed', 'tiers_reordered', 'org_type_default_changed', 'plan_act_retried')),
|
||||
CONSTRAINT chk_acts_actor_type CHECK (actor_type IN ('person', 'service_account', 'system')),
|
||||
CONSTRAINT act_actor_coherent CHECK (
|
||||
(actor_type = 'person' AND actor_person_id IS NOT NULL AND actor_service_account_id IS NULL)
|
||||
OR (actor_type = 'service_account' AND actor_service_account_id IS NOT NULL AND actor_person_id IS NULL)
|
||||
OR (actor_type = 'system' AND actor_person_id IS NULL AND actor_service_account_id IS NULL)),
|
||||
CONSTRAINT act_note_by_a_person CHECK (note IS NULL OR actor_type = 'person')
|
||||
);
|
||||
COMMENT ON TABLE core.acts IS
|
||||
'One row per act: who acted, when, and why, from a closed list of causes. Written once by core.record_plan_act; nothing updates or deletes a row.';
|
||||
COMMENT ON COLUMN core.acts.note IS
|
||||
'A person''s own words. The one column of the act record withheld from core_reader and console_reader.';
|
||||
COMMENT ON COLUMN core.acts.request_id IS
|
||||
'The request that made the act, for correlating it with log lines; NULL when the request id is longer than the column. Never a key.';
|
||||
|
||||
CREATE TABLE core.plan_act_details (
|
||||
act_id UUID NOT NULL,
|
||||
cause VARCHAR(50) NOT NULL,
|
||||
plan_ladder_id UUID,
|
||||
plan_ladder_name VARCHAR(255),
|
||||
product_id UUID,
|
||||
product_name VARCHAR(255),
|
||||
previous_product_id UUID,
|
||||
previous_product_name VARCHAR(255),
|
||||
org_type VARCHAR(20),
|
||||
org_type_name VARCHAR(255),
|
||||
previous_plan_ladder_id UUID,
|
||||
previous_plan_ladder_name VARCHAR(255),
|
||||
disposition VARCHAR(20),
|
||||
retried_act_id UUID,
|
||||
CONSTRAINT pk_plan_act_details PRIMARY KEY (act_id),
|
||||
CONSTRAINT plan_act_detail_is_its_act FOREIGN KEY (act_id, cause) REFERENCES core.acts (act_id, cause),
|
||||
CONSTRAINT fk_plan_act_details_retried_act_id FOREIGN KEY (retried_act_id) REFERENCES core.plan_act_details (act_id),
|
||||
CONSTRAINT chk_plan_act_details_cause CHECK (cause IN (
|
||||
'tier_added', 'tier_removed', 'tiers_reordered', 'org_type_default_changed', 'plan_act_retried')),
|
||||
CONSTRAINT chk_plan_act_details_disposition CHECK (disposition IS NULL OR disposition IN ('grandfather', 'migrate', 'keep')),
|
||||
CONSTRAINT plan_act_detail_names_what_it_ids CHECK (
|
||||
(plan_ladder_id IS NULL) = (plan_ladder_name IS NULL)
|
||||
AND (product_id IS NULL) = (product_name IS NULL)
|
||||
AND (previous_product_id IS NULL) = (previous_product_name IS NULL)
|
||||
AND (org_type IS NULL) = (org_type_name IS NULL)
|
||||
AND (previous_plan_ladder_id IS NULL) = (previous_plan_ladder_name IS NULL)),
|
||||
CONSTRAINT plan_act_detail_shape CHECK (
|
||||
(cause = 'tier_added' AND plan_ladder_id IS NOT NULL AND product_id IS NOT NULL
|
||||
AND org_type IS NULL AND disposition IS NULL AND retried_act_id IS NULL)
|
||||
OR (cause = 'tier_removed' AND plan_ladder_id IS NOT NULL AND product_id IS NOT NULL
|
||||
AND org_type IS NULL AND (disposition IS NULL OR disposition IN ('keep', 'migrate')) AND retried_act_id IS NULL)
|
||||
OR (cause = 'tiers_reordered' AND plan_ladder_id IS NOT NULL AND org_type IS NULL
|
||||
AND (product_id IS NULL) = (previous_product_id IS NULL)
|
||||
AND (disposition IS NULL OR disposition IN ('grandfather', 'migrate')) AND retried_act_id IS NULL)
|
||||
OR (cause = 'org_type_default_changed' AND org_type IS NOT NULL
|
||||
AND (plan_ladder_id IS NULL) = (product_id IS NULL)
|
||||
AND (disposition IS NULL OR disposition IN ('grandfather', 'migrate')) AND retried_act_id IS NULL)
|
||||
OR (cause = 'plan_act_retried' AND retried_act_id IS NOT NULL AND plan_ladder_id IS NULL
|
||||
AND product_id IS NULL AND org_type IS NULL AND disposition IS NULL))
|
||||
);
|
||||
COMMENT ON TABLE core.plan_act_details IS
|
||||
'What a plan act did, by id and by the name each had when the act committed. The ids carry no foreign key, so the record outlives a deleted catalog row. Written once by core.record_plan_act; nothing updates or deletes a row.';
|
||||
CREATE INDEX idx_plan_act_details_org_type ON core.plan_act_details (org_type, act_id DESC) WHERE org_type IS NOT NULL;
|
||||
CREATE INDEX idx_plan_act_details_plan_ladder_id ON core.plan_act_details (plan_ladder_id, act_id DESC) WHERE plan_ladder_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- core.record_plan_act: the only writer of the two tables. It inserts the act
|
||||
-- and its detail and returns the act's id. The four catalog causes assert the
|
||||
-- exclusive materialization rendezvous, because they change what the tiers and
|
||||
-- defaults mean; a Retry asserts it in either mode. A reorder that names a new
|
||||
-- rank-0 product must name the product the ladder now holds at rank 0: the
|
||||
-- caller renumbered first, and this read keeps it from misstating which
|
||||
-- product the reorder made rank 0. The previous product is the caller's word,
|
||||
-- as only the caller saw the ranks before the renumbering.
|
||||
--
|
||||
-- A request id longer than the column is not stored: it is for correlating
|
||||
-- the act with log lines, and the client chose it.
|
||||
--
|
||||
-- The table names are unqualified on purpose: the pinned search_path, core
|
||||
-- first and pg_temp last, is what keeps a caller's temporary table from
|
||||
-- shadowing them.
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION core.record_plan_act(
|
||||
p_cause VARCHAR,
|
||||
p_actor_type VARCHAR,
|
||||
p_actor_person_id UUID,
|
||||
p_actor_service_account_id UUID,
|
||||
p_note TEXT,
|
||||
p_request_id VARCHAR,
|
||||
p_effective_at TIMESTAMPTZ,
|
||||
p_plan_ladder_id UUID,
|
||||
p_plan_ladder_name VARCHAR,
|
||||
p_product_id UUID,
|
||||
p_product_name VARCHAR,
|
||||
p_previous_product_id UUID,
|
||||
p_previous_product_name VARCHAR,
|
||||
p_org_type VARCHAR,
|
||||
p_org_type_name VARCHAR,
|
||||
p_previous_plan_ladder_id UUID,
|
||||
p_previous_plan_ladder_name VARCHAR,
|
||||
p_disposition VARCHAR,
|
||||
p_retried_act_id UUID
|
||||
) RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = core, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_act_id UUID;
|
||||
v_rank_zero UUID;
|
||||
BEGIN
|
||||
IF p_cause = 'plan_act_retried' THEN
|
||||
PERFORM core.assert_rendezvous(FALSE);
|
||||
ELSE
|
||||
PERFORM core.assert_rendezvous(TRUE);
|
||||
END IF;
|
||||
|
||||
IF p_cause = 'tiers_reordered' AND p_product_id IS NOT NULL THEN
|
||||
SELECT t.product_id INTO v_rank_zero
|
||||
FROM plan_ladder_tiers t
|
||||
WHERE t.plan_ladder_id = p_plan_ladder_id AND t.rank = 0;
|
||||
IF v_rank_zero IS DISTINCT FROM p_product_id THEN
|
||||
RAISE EXCEPTION 'plan_act_rank_zero_mismatch'
|
||||
USING ERRCODE = '22023',
|
||||
DETAIL = 'the reorder names a new rank-0 product that is not the ladder''s rank-0 product';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO acts (cause, actor_type, actor_person_id, actor_service_account_id, note, request_id, effective_at)
|
||||
VALUES (p_cause, p_actor_type, p_actor_person_id, p_actor_service_account_id, p_note,
|
||||
CASE WHEN char_length(p_request_id) <= 100 THEN p_request_id END, p_effective_at)
|
||||
RETURNING act_id INTO v_act_id;
|
||||
|
||||
INSERT INTO plan_act_details (
|
||||
act_id, cause, plan_ladder_id, plan_ladder_name, product_id, product_name,
|
||||
previous_product_id, previous_product_name, org_type, org_type_name,
|
||||
previous_plan_ladder_id, previous_plan_ladder_name, disposition, retried_act_id
|
||||
) VALUES (
|
||||
v_act_id, p_cause, p_plan_ladder_id, p_plan_ladder_name, p_product_id, p_product_name,
|
||||
p_previous_product_id, p_previous_product_name, p_org_type, p_org_type_name,
|
||||
p_previous_plan_ladder_id, p_previous_plan_ladder_name, p_disposition, p_retried_act_id
|
||||
);
|
||||
RETURN v_act_id;
|
||||
END;
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- The enclosure (the pattern 00018 set): the function runs as core_owner,
|
||||
-- core_writer keeps SELECT on both tables and loses every write, and EXECUTE
|
||||
-- is granted to core_writer alone. 00001's GRANT ALL ON ALL TABLES covered
|
||||
-- only the tables that existed then. core_reader reads every column of
|
||||
-- core.acts but the note, the one column that holds a person's own words;
|
||||
-- every integration's writer role holds core_reader.
|
||||
GRANT ALL ON core.acts, core.plan_act_details TO core_owner;
|
||||
GRANT SELECT ON core.acts, core.plan_act_details TO core_writer;
|
||||
GRANT SELECT (act_id, cause, actor_type, actor_person_id, actor_service_account_id, request_id, effective_at, created_at)
|
||||
ON core.acts TO core_reader;
|
||||
GRANT SELECT ON core.plan_act_details TO core_reader;
|
||||
|
||||
ALTER FUNCTION core.record_plan_act(VARCHAR, VARCHAR, UUID, UUID, TEXT, VARCHAR, TIMESTAMPTZ, UUID, VARCHAR, UUID, VARCHAR, UUID, VARCHAR, VARCHAR, VARCHAR, UUID, VARCHAR, VARCHAR, UUID) OWNER TO core_owner;
|
||||
REVOKE EXECUTE ON FUNCTION core.record_plan_act(VARCHAR, VARCHAR, UUID, UUID, TEXT, VARCHAR, TIMESTAMPTZ, UUID, VARCHAR, UUID, VARCHAR, UUID, VARCHAR, VARCHAR, VARCHAR, UUID, VARCHAR, VARCHAR, UUID) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION core.record_plan_act(VARCHAR, VARCHAR, UUID, UUID, TEXT, VARCHAR, TIMESTAMPTZ, UUID, VARCHAR, UUID, VARCHAR, UUID, VARCHAR, VARCHAR, VARCHAR, UUID, VARCHAR, VARCHAR, UUID) TO core_writer;
|
||||
|
||||
-- +goose Down
|
||||
DROP FUNCTION core.record_plan_act(VARCHAR, VARCHAR, UUID, UUID, TEXT, VARCHAR, TIMESTAMPTZ, UUID, VARCHAR, UUID, VARCHAR, UUID, VARCHAR, VARCHAR, VARCHAR, UUID, VARCHAR, VARCHAR, UUID);
|
||||
DROP TABLE core.plan_act_details;
|
||||
DROP TABLE core.acts;
|
||||
@@ -153,25 +153,34 @@ func updatedAts(q *dbtest.Q, f positionFixture) map[string]time.Time {
|
||||
}
|
||||
|
||||
func TestOutgoingDefaultRuleMigrationUpDownUp(t *testing.T) {
|
||||
requireMigrationUpDownUp(t, outgoingRulePrevious, outgoingRuleVersion)
|
||||
}
|
||||
|
||||
// requireMigrationUpDownUp applies migration version of the core stream to a
|
||||
// scratch database that holds the stream up to previous, then reverses it, then
|
||||
// applies it again, and requires Down to restore the schema Up started from
|
||||
// and the second Up to rebuild the first one's.
|
||||
func requireMigrationUpDownUp(t *testing.T, previous, version int64) {
|
||||
t.Helper()
|
||||
scratch := dbtest.Connect(t, dbtest.Scratch(t))
|
||||
ctx := t.Context()
|
||||
core := db.BaseSources()[0]
|
||||
|
||||
dbtest.UpTo(t, scratch, core, outgoingRulePrevious)
|
||||
dbtest.UpTo(t, scratch, core, previous)
|
||||
before := schemaSnapshot(t, ctx, scratch)
|
||||
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true)
|
||||
dbtest.ApplyVersion(t, scratch, core, version, true)
|
||||
afterUp := schemaSnapshot(t, ctx, scratch)
|
||||
if slices.Equal(before, afterUp) {
|
||||
t.Fatalf("applying %d changed nothing in the snapshot", outgoingRuleVersion)
|
||||
t.Fatalf("applying %d changed nothing in the snapshot", version)
|
||||
}
|
||||
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, false)
|
||||
dbtest.ApplyVersion(t, scratch, core, version, false)
|
||||
if afterDown := schemaSnapshot(t, ctx, scratch); !slices.Equal(before, afterDown) {
|
||||
t.Errorf("the schema after Down differs from the schema before Up:\n%s", snapshotDiff(before, afterDown))
|
||||
}
|
||||
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true)
|
||||
dbtest.ApplyVersion(t, scratch, core, version, true)
|
||||
if again := schemaSnapshot(t, ctx, scratch); !slices.Equal(afterUp, again) {
|
||||
t.Errorf("the schema after a second Up differs from the first:\n%s", snapshotDiff(afterUp, again))
|
||||
}
|
||||
|
||||
@@ -87,11 +87,11 @@
|
||||
(chrome-conventions "A verb-less row leaves its
|
||||
Actions cell empty"; design D23 keeps tooltips for
|
||||
disabled controls only). */}}
|
||||
{{ else if .HasActiveAttachments }}
|
||||
{{ else if gt .LiveHolders 0 }}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm ms-1"
|
||||
hx-post="{{ routeURL "/partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview" .PlanLadderID .ProductID }}"
|
||||
hx-target="#operator-body" hx-swap="innerHTML"
|
||||
title="{{ .ActiveAttachmentCount }} pool(s) hold this tier. Removing shows what happens to each before anything changes.">
|
||||
title="{{ .LiveHolders }} pool(s) hold this tier. Removing shows what happens to each before anything changes.">
|
||||
Preview removal
|
||||
</button>
|
||||
{{ else }}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package entitlements
|
||||
|
||||
// ActCause is why an act was recorded: the closed list core.acts.cause
|
||||
// holds. The constants mirror chk_acts_cause and chk_plan_act_details_cause,
|
||||
// and a test compares them with the constraints' definitions. A later act
|
||||
// kind adds its causes in its own migration, replacing both constraints.
|
||||
// The list lives here until the first act outside this package adopts the
|
||||
// record.
|
||||
type ActCause string
|
||||
|
||||
// The causes of the plan acts. A tier delete and a tier removal are both
|
||||
// ActTierRemoved; the detail's disposition is NULL when no organization held
|
||||
// the tier.
|
||||
const (
|
||||
ActTierAdded ActCause = "tier_added"
|
||||
ActTierRemoved ActCause = "tier_removed"
|
||||
ActTiersReordered ActCause = "tiers_reordered"
|
||||
ActOrgTypeDefaultChanged ActCause = "org_type_default_changed"
|
||||
ActPlanActRetried ActCause = "plan_act_retried"
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package entitlements_test
|
||||
|
||||
// The Go causes mirror the two constraints that list them (openspec change
|
||||
// plan-act-obligations, task 4.2, design D26): a cause added on one side only
|
||||
// fails here. The list lands whole, so four of the causes have no caller
|
||||
// until the tasks that add their acts.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
)
|
||||
|
||||
// The causes, as the Go constants name them.
|
||||
var actCauses = []entitlements.ActCause{
|
||||
entitlements.ActTierAdded,
|
||||
entitlements.ActTierRemoved,
|
||||
entitlements.ActTiersReordered,
|
||||
entitlements.ActOrgTypeDefaultChanged,
|
||||
entitlements.ActPlanActRetried,
|
||||
}
|
||||
|
||||
var quoted = regexp.MustCompile(`'([^']*)'`)
|
||||
|
||||
// TestActCausesMirrorTheConstraints reads the definition of chk_acts_cause and
|
||||
// of chk_plan_act_details_cause from a migrated database and requires each to
|
||||
// list exactly the Go causes.
|
||||
func TestActCausesMirrorTheConstraints(t *testing.T) {
|
||||
q := dbtest.On(t, dbtest.Open(t))
|
||||
var want []string
|
||||
for _, cause := range actCauses {
|
||||
want = append(want, string(cause))
|
||||
}
|
||||
slices.Sort(want)
|
||||
for _, c := range []struct{ table, constraint string }{
|
||||
{"acts", "chk_acts_cause"},
|
||||
{"plan_act_details", "chk_plan_act_details_cause"},
|
||||
} {
|
||||
def := q.String(`SELECT pg_get_constraintdef(con.oid) FROM pg_constraint con
|
||||
WHERE con.conrelid = ('core.' || $1)::regclass AND con.conname = $2`, c.table, c.constraint)
|
||||
var got []string
|
||||
for _, m := range quoted.FindAllStringSubmatch(def, -1) {
|
||||
got = append(got, m[1])
|
||||
}
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s lists %v, the Go causes are %v\n%s", c.constraint, got, want, def)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -21,7 +23,7 @@ import (
|
||||
// Phase 1 parks every tier at a unique negative rank (disjoint from any
|
||||
// non-negative existing value); phase 2 assigns the final contiguous ranks.
|
||||
// Shared by the reorder, the tier delete and the tier removal.
|
||||
func RenumberTiers(ctx context.Context, qtx *billing.Queries, ladderID string, ordered []string) error {
|
||||
func RenumberTiers(ctx context.Context, qtx billing.Querier, ladderID string, ordered []string) error {
|
||||
for i, pid := range ordered {
|
||||
if _, err := qtx.UpdateTierRank(ctx, billing.UpdateTierRankParams{
|
||||
PlanLadderID: ladderID, ProductID: pid, Rank: int32(-(i + 1)),
|
||||
@@ -49,6 +51,10 @@ const (
|
||||
StepCreateTier PlanActStep = "create the tier"
|
||||
StepReadProvisions PlanActStep = "read the product's live provisions"
|
||||
StepReadHolders PlanActStep = "read the tier's holders"
|
||||
StepReadTiers PlanActStep = "read the ladder's tiers"
|
||||
StepReadOrgTypes PlanActStep = "read the organization types"
|
||||
StepReadNames PlanActStep = "read the ladder's and the product's names"
|
||||
StepRecordAct PlanActStep = "record the act"
|
||||
StepLockPools PlanActStep = "lock the holders' pools"
|
||||
StepRereadHolders PlanActStep = "read the tier's holders under the pool locks"
|
||||
StepDeleteTier PlanActStep = "delete the tier"
|
||||
@@ -87,6 +93,25 @@ func (e *DispositionRequiredError) Error() string {
|
||||
return fmt.Sprintf("%d default-sourced holder(s) need a disposition", e.Holders)
|
||||
}
|
||||
|
||||
// LiveHoldersError is a tier delete refused because Holders positions, active
|
||||
// or suspended, are on the tier. The delete wrote nothing; a held tier is
|
||||
// removed through the removal.
|
||||
type LiveHoldersError struct {
|
||||
Holders int64
|
||||
}
|
||||
|
||||
func (e *LiveHoldersError) Error() string {
|
||||
return fmt.Sprintf("%d live holder(s) on the tier", e.Holders)
|
||||
}
|
||||
|
||||
// LastTierError is a tier delete refused by LastTierRefusal. Message is the
|
||||
// sentence the operator reads. The delete wrote nothing.
|
||||
type LastTierError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *LastTierError) Error() string { return e.Message }
|
||||
|
||||
// operatorActor is the operator acting with the given reason, as every
|
||||
// transition and conferral of a plan-wide act records it.
|
||||
func operatorActor(operatorPersonID, reason string) Actor {
|
||||
@@ -231,7 +256,7 @@ func lockTierHolders(ctx context.Context, qtx *Queries, ladderID, productID stri
|
||||
// deleteTier deletes the tier and renumbers the ladder's remaining tiers to
|
||||
// 0..N-1 inside the caller's transaction, so ranks never gap and a ladder
|
||||
// with tiers always has a rank 0.
|
||||
func deleteTier(ctx context.Context, bq *billing.Queries, ladderID, productID string) error {
|
||||
func deleteTier(ctx context.Context, bq billing.Querier, ladderID, productID string) error {
|
||||
if err := bq.DeleteTier(ctx, billing.DeleteTierParams{PlanLadderID: ladderID, ProductID: productID}); err != nil {
|
||||
return &PlanActError{Step: StepDeleteTier, Err: err}
|
||||
}
|
||||
@@ -345,24 +370,90 @@ func AddTier(ctx context.Context, b db.Beginner, in AddTierInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTier deletes a tier and renumbers the ladder's remaining tiers to
|
||||
// 0..N-1 in one transaction, so ranks never gap and the ladder always has a
|
||||
// rank-0 provisioning default (deleting the base tier promotes the next
|
||||
// tier). It is for a tier no organization holds; RemoveTier removes a held
|
||||
// one.
|
||||
func DeleteTier(ctx context.Context, b db.Beginner, ladderID, productID string) error {
|
||||
tx, err := BeginRuleChange(ctx, b)
|
||||
// DeleteTierInput is the delete of ProductID's tier from LadderID by the
|
||||
// operator. RequestID is the request's identifier, which the act stores for
|
||||
// correlating it with log lines; Acts says where the act builds its queriers.
|
||||
type DeleteTierInput struct {
|
||||
LadderID string
|
||||
ProductID string
|
||||
OperatorPersonID string
|
||||
RequestID string
|
||||
Acts PlanActConfig
|
||||
}
|
||||
|
||||
// DeleteTier deletes a tier no organization holds and renumbers the ladder's
|
||||
// remaining tiers to 0..N-1 in one transaction, so ranks never gap and the
|
||||
// ladder always has a rank-0 provisioning default (deleting the base tier
|
||||
// promotes the next tier). It is a removal with no holders: the same act
|
||||
// record (cause ActTierRemoved, no disposition), with the guards read inside
|
||||
// the exclusive transaction that deletes. It counts the tier's live holders,
|
||||
// positions that are active or suspended (the population a removal
|
||||
// reconciles), and refuses with a *LiveHoldersError when there is one;
|
||||
// RemoveTier removes a held tier. It reads the ladder's tiers and the
|
||||
// organization types that default to it and refuses with a *LastTierError
|
||||
// under LastTierRefusal; a failed read refuses too, so the guard never lets a
|
||||
// delete through for want of an answer. It takes no pool lock: a tier with no
|
||||
// live holder has no pool to reconcile.
|
||||
func DeleteTier(ctx context.Context, b db.Beginner, in DeleteTierInput) error {
|
||||
return runPlanAct(ctx, b, planAct{
|
||||
Kind: RuleChange,
|
||||
Queriers: in.Acts,
|
||||
Act: func(ctx context.Context, q planActQueriers) error {
|
||||
return deleteTierAct(ctx, q, in)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// deleteTierAct is DeleteTier's body, inside its transaction: the guards, the
|
||||
// names, the act and the delete.
|
||||
func deleteTierAct(ctx context.Context, q planActQueriers, in DeleteTierInput) error {
|
||||
holders, err := q.Ent.CountLiveAttachmentsByTier(ctx, CountLiveAttachmentsByTierParams{
|
||||
PlanLadderID: in.LadderID,
|
||||
ProductID: in.ProductID,
|
||||
})
|
||||
if err != nil {
|
||||
return &PlanActError{Step: StepBegin, Err: err}
|
||||
return &PlanActError{Step: StepReadHolders, Err: err}
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := deleteTier(ctx, billing.New(tx), ladderID, productID); err != nil {
|
||||
return err
|
||||
if holders > 0 {
|
||||
return &LiveHoldersError{Holders: holders}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return &PlanActError{Step: StepCommit, Err: err}
|
||||
tiers, err := q.Billing.ListTiersByLadder(ctx, in.LadderID)
|
||||
if err != nil {
|
||||
return &PlanActError{Step: StepReadTiers, Err: err}
|
||||
}
|
||||
return nil
|
||||
// A product that is no tier of the ladder was removed already, by an
|
||||
// earlier delete this one waited behind: nothing to record or delete.
|
||||
if !slices.ContainsFunc(tiers, func(t billing.PlanLadderTier) bool { return t.ProductID == in.ProductID }) {
|
||||
return nil
|
||||
}
|
||||
orgTypes, err := q.Org.ListOrgTypes(ctx)
|
||||
if err != nil {
|
||||
return &PlanActError{Step: StepReadOrgTypes, Err: err}
|
||||
}
|
||||
if refusal := LastTierRefusal(tiers, in.ProductID, orgTypesDefaultingTo(orgTypes, in.LadderID)); refusal != "" {
|
||||
return &LastTierError{Message: refusal}
|
||||
}
|
||||
ladder, err := q.Billing.GetPlanLadderByID(ctx, in.LadderID)
|
||||
if err != nil {
|
||||
return &PlanActError{Step: StepReadNames, Err: err}
|
||||
}
|
||||
product, err := q.Billing.GetProductByID(ctx, in.ProductID)
|
||||
if err != nil {
|
||||
return &PlanActError{Step: StepReadNames, Err: err}
|
||||
}
|
||||
if _, err := writePlanAct(ctx, q.Ent, planActRecord{
|
||||
Cause: ActTierRemoved,
|
||||
ActorPersonID: in.OperatorPersonID,
|
||||
RequestID: in.RequestID,
|
||||
EffectiveAt: time.Now(),
|
||||
PlanLadderID: in.LadderID,
|
||||
PlanLadderName: ladder.Name,
|
||||
ProductID: in.ProductID,
|
||||
ProductName: product.Name,
|
||||
}); err != nil {
|
||||
return &PlanActError{Step: StepRecordAct, Err: err}
|
||||
}
|
||||
return deleteTier(ctx, q.Billing, in.LadderID, in.ProductID)
|
||||
}
|
||||
|
||||
// LadderNames reads a ladder, for the name a reorder's reason records.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package entitlements
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
||||
)
|
||||
|
||||
// PlanActConfig is where a plan act builds its queriers: the constructors of
|
||||
// the entitlements, billing and organization queriers, each called with the
|
||||
// act's own transaction. The zero value means the generated ones. A test
|
||||
// passes constructors that wrap them and refuse a named statement, so a
|
||||
// scenario can make a read or a write fail inside the act's own transaction;
|
||||
// a handler's querier never reaches the act.
|
||||
type PlanActConfig struct {
|
||||
Entitlements func(DBTX) Querier
|
||||
Billing func(billing.DBTX) billing.Querier
|
||||
Organization func(organization.DBTX) organization.Querier
|
||||
}
|
||||
|
||||
// planActQueriers are the queriers of one plan act, all on its transaction.
|
||||
type planActQueriers struct {
|
||||
Ent Querier
|
||||
Billing billing.Querier
|
||||
Org organization.Querier
|
||||
}
|
||||
|
||||
// bind builds the act's queriers on tx.
|
||||
func (c PlanActConfig) bind(tx *sql.Tx) planActQueriers {
|
||||
q := planActQueriers{Ent: New(tx), Billing: billing.New(tx), Org: organization.New(tx)}
|
||||
if c.Entitlements != nil {
|
||||
q.Ent = c.Entitlements(tx)
|
||||
}
|
||||
if c.Billing != nil {
|
||||
q.Billing = c.Billing(tx)
|
||||
}
|
||||
if c.Organization != nil {
|
||||
q.Org = c.Organization(tx)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// planAct is one plan act's transaction, for runPlanAct.
|
||||
type planAct struct {
|
||||
// Kind opens the transaction: RuleChange for the acts that change a
|
||||
// ladder's tiers.
|
||||
Kind db.Kind
|
||||
// Queriers builds the act's queriers.
|
||||
Queriers PlanActConfig
|
||||
// Find reads what names the pools the act locks, and returns their ids.
|
||||
// Nil for an act that takes no pool row.
|
||||
Find func(ctx context.Context, q planActQueriers) ([]string, error)
|
||||
// Reread reads again, under the pool locks, what Find named. Nil when
|
||||
// Find is.
|
||||
Reread func(ctx context.Context, q planActQueriers) error
|
||||
// Act writes the act. It runs under the pool locks.
|
||||
Act func(ctx context.Context, q planActQueriers) error
|
||||
}
|
||||
|
||||
// runPlanAct runs one plan act: it opens the kind's transaction, calls Find
|
||||
// to name the pools, locks them in ascending order through the same function
|
||||
// LockPools runs (each once), calls Reread under the locks, then Act, and
|
||||
// commits. A failure of the runner's own steps comes back as a *PlanActError
|
||||
// naming the step; Find, Reread and Act return a *PlanActError for theirs, or
|
||||
// the type of the refusal they raise, and the runner returns what they return
|
||||
// as it is. The transaction is rolled back on any error, so a failed act
|
||||
// wrote nothing.
|
||||
func runPlanAct(ctx context.Context, b db.Beginner, a planAct) error {
|
||||
tx, err := db.Begin(ctx, b, a.Kind)
|
||||
if err != nil {
|
||||
return &PlanActError{Step: StepBegin, Err: err}
|
||||
}
|
||||
defer tx.Rollback()
|
||||
q := a.Queriers.bind(tx)
|
||||
|
||||
if a.Find != nil {
|
||||
pools, err := a.Find(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := lockPools(ctx, q.Ent, pools); err != nil {
|
||||
return &PlanActError{Step: StepLockPools, Err: err}
|
||||
}
|
||||
if a.Reread != nil {
|
||||
if err := a.Reread(ctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.Act(ctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return &PlanActError{Step: StepCommit, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// planActRecord is the act row and the detail row a plan act records. An
|
||||
// empty string stands for NULL.
|
||||
type planActRecord struct {
|
||||
Cause ActCause
|
||||
ActorPersonID string
|
||||
Note string
|
||||
RequestID string
|
||||
EffectiveAt time.Time
|
||||
PlanLadderID string
|
||||
PlanLadderName string
|
||||
ProductID string
|
||||
ProductName string
|
||||
PreviousProductID string
|
||||
PreviousProductName string
|
||||
OrgType string
|
||||
OrgTypeName string
|
||||
PreviousPlanLadderID string
|
||||
PreviousPlanLadderName string
|
||||
Disposition string
|
||||
RetriedActID string
|
||||
}
|
||||
|
||||
// writePlanAct writes the act through core.record_plan_act and returns its
|
||||
// id. The caller's transaction must hold the exclusive rendezvous (the
|
||||
// shared one for a Retry), which the function asserts. The actor is a person:
|
||||
// every plan act today is an operator's.
|
||||
func writePlanAct(ctx context.Context, q Querier, r planActRecord) (string, error) {
|
||||
var bad error
|
||||
id := func(what, s string) uuid.NullUUID {
|
||||
parsed, err := nullUUID(s)
|
||||
if err != nil && bad == nil {
|
||||
bad = fmt.Errorf("%s id: %w", what, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
params := RecordPlanActParams{
|
||||
Cause: string(r.Cause),
|
||||
ActorType: "person",
|
||||
ActorPersonID: id("actor person", r.ActorPersonID),
|
||||
Note: nullString(r.Note),
|
||||
RequestID: nullString(r.RequestID),
|
||||
EffectiveAt: r.EffectiveAt,
|
||||
PlanLadderID: id("ladder", r.PlanLadderID),
|
||||
PlanLadderName: nullString(r.PlanLadderName),
|
||||
ProductID: id("product", r.ProductID),
|
||||
ProductName: nullString(r.ProductName),
|
||||
PreviousProductID: id("previous product", r.PreviousProductID),
|
||||
PreviousProductName: nullString(r.PreviousProductName),
|
||||
OrgType: nullString(r.OrgType),
|
||||
OrgTypeName: nullString(r.OrgTypeName),
|
||||
PreviousPlanLadderID: id("previous ladder", r.PreviousPlanLadderID),
|
||||
PreviousPlanLadderName: nullString(r.PreviousPlanLadderName),
|
||||
Disposition: nullString(r.Disposition),
|
||||
RetriedActID: id("retried act", r.RetriedActID),
|
||||
}
|
||||
if bad != nil {
|
||||
return "", bad
|
||||
}
|
||||
return q.RecordPlanAct(ctx, params)
|
||||
}
|
||||
|
||||
func nullString(s string) sql.NullString {
|
||||
return sql.NullString{String: s, Valid: s != ""}
|
||||
}
|
||||
|
||||
// LastTierRefusal is the guard against emptying a ladder an organization
|
||||
// type defaults to. It returns the sentence that refuses the deletion of
|
||||
// productID's tier when that tier is the ladder's only one and
|
||||
// defaultingTypes, the display names of the organization types whose default
|
||||
// ladder it is, is not empty; otherwise it returns "". It reads no row, so the
|
||||
// plain delete and the removal apply the one rule: new signups land on the
|
||||
// ladder's rank-0 tier, and a ladder with no tier leaves them without a
|
||||
// starting plan. Emptying a ladder no type defaults to stays legal.
|
||||
func LastTierRefusal(tiers []billing.PlanLadderTier, productID string, defaultingTypes []string) string {
|
||||
if len(tiers) != 1 || tiers[0].ProductID != productID || len(defaultingTypes) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "This ladder's only remaining tier; " + strings.Join(defaultingTypes, ", ") +
|
||||
" defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused."
|
||||
}
|
||||
|
||||
// orgTypesDefaultingTo returns the display names of the organization types
|
||||
// whose default plan ladder is ladderID, in the order types gives them.
|
||||
func orgTypesDefaultingTo(types []organization.OrgType, ladderID string) []string {
|
||||
id, err := uuid.Parse(ladderID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, t := range types {
|
||||
if t.DefaultPlanLadderID.Valid && t.DefaultPlanLadderID.UUID == id {
|
||||
names = append(names, t.DisplayName)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package entitlements
|
||||
|
||||
// The plan-act runner (openspec change plan-act-obligations, task 4.3): the
|
||||
// order of its steps, the pool locks it takes, and the errors it returns. The
|
||||
// pool locks are recorded by a wrapper the act's queriers are built through,
|
||||
// so the test needs the database only to open the transaction.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
)
|
||||
|
||||
// lockRecorder records the pool rows the runner locks, in order, and fails the
|
||||
// lock of failOn when it is set.
|
||||
type lockRecorder struct {
|
||||
Querier
|
||||
events *[]string
|
||||
failOn string
|
||||
}
|
||||
|
||||
func (r lockRecorder) LockResourcePool(_ context.Context, poolID string) (string, error) {
|
||||
*r.events = append(*r.events, "lock "+poolID)
|
||||
if poolID == r.failOn {
|
||||
return "", errors.New("lock failed")
|
||||
}
|
||||
return poolID, nil
|
||||
}
|
||||
|
||||
// recordedAct is an act whose steps append their name to events, with the
|
||||
// pools Find names and the error Act returns.
|
||||
func recordedAct(events *[]string, pools []string, failOn string, actErr error) planAct {
|
||||
return planAct{
|
||||
Kind: RuleChange,
|
||||
Queriers: PlanActConfig{Entitlements: func(d DBTX) Querier {
|
||||
return lockRecorder{Querier: New(d), events: events, failOn: failOn}
|
||||
}},
|
||||
Find: func(context.Context, planActQueriers) ([]string, error) {
|
||||
*events = append(*events, "find")
|
||||
return pools, nil
|
||||
},
|
||||
Reread: func(context.Context, planActQueriers) error {
|
||||
*events = append(*events, "reread")
|
||||
return nil
|
||||
},
|
||||
Act: func(context.Context, planActQueriers) error {
|
||||
*events = append(*events, "act")
|
||||
return actErr
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The runner finds the pools, locks each once in ascending order, reads again
|
||||
// under the locks, and acts, in that order, whatever order Find names them in.
|
||||
func TestRunPlanActLocksThePoolsInAscendingOrderBeforeItActs(t *testing.T) {
|
||||
var events []string
|
||||
act := recordedAct(&events, []string{"c", "a", "b", "a"}, "", nil)
|
||||
if err := runPlanAct(t.Context(), dbtest.Open(t), act); err != nil {
|
||||
t.Fatalf("runPlanAct: %v", err)
|
||||
}
|
||||
want := []string{"find", "lock a", "lock b", "lock c", "reread", "act"}
|
||||
if !slices.Equal(events, want) {
|
||||
t.Errorf("the steps ran as %v, want %v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed pool lock comes back as the lock step's error and stops the act.
|
||||
func TestRunPlanActNamesAFailedPoolLock(t *testing.T) {
|
||||
var events []string
|
||||
err := runPlanAct(t.Context(), dbtest.Open(t), recordedAct(&events, []string{"a", "b"}, "b", nil))
|
||||
var failed *PlanActError
|
||||
if !errors.As(err, &failed) || failed.Step != StepLockPools {
|
||||
t.Fatalf("error = %v, want a *PlanActError at %q", err, StepLockPools)
|
||||
}
|
||||
if want := []string{"find", "lock a", "lock b"}; !slices.Equal(events, want) {
|
||||
t.Errorf("the steps ran as %v, want %v: nothing runs after a failed lock", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
// execAct is an act that runs statements on its own transaction, then returns
|
||||
// result. The transaction reaches the act through the entitlements
|
||||
// constructor, which receives it.
|
||||
func execAct(result error, statements ...string) planAct {
|
||||
var tx DBTX
|
||||
return planAct{
|
||||
Kind: RuleChange,
|
||||
Queriers: PlanActConfig{Entitlements: func(d DBTX) Querier {
|
||||
tx = d
|
||||
return New(d)
|
||||
}},
|
||||
Act: func(ctx context.Context, _ planActQueriers) error {
|
||||
for _, s := range statements {
|
||||
if _, err := tx.ExecContext(ctx, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// An act's own refusal comes back as it is, not wrapped as a failure, and the
|
||||
// transaction does not commit: the table the act created is not there.
|
||||
func TestRunPlanActReturnsAnActsRefusalAsItIs(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
refusal := &LiveHoldersError{Holders: 2}
|
||||
err := runPlanAct(t.Context(), database, execAct(refusal, `CREATE TABLE plan_act_refused_probe (id int)`))
|
||||
var failed *PlanActError
|
||||
if !errors.Is(err, refusal) || errors.As(err, &failed) {
|
||||
t.Errorf("error = %v, want the act's own refusal and no *PlanActError", err)
|
||||
}
|
||||
var probe sql.NullString
|
||||
if err := database.QueryRowContext(t.Context(), `SELECT to_regclass('plan_act_refused_probe')::text`).Scan(&probe); err != nil {
|
||||
t.Fatalf("look for the table: %v", err)
|
||||
}
|
||||
if probe.Valid {
|
||||
t.Errorf("table %s exists: the refused act committed", probe.String)
|
||||
}
|
||||
}
|
||||
|
||||
// A failure at the commit comes back as the commit step's error. The act
|
||||
// breaks a deferred constraint, which the database checks only at the commit.
|
||||
func TestRunPlanActNamesAFailedCommit(t *testing.T) {
|
||||
err := runPlanAct(t.Context(), dbtest.Open(t), execAct(nil,
|
||||
`CREATE TEMP TABLE deferred_probe (id int, UNIQUE (id) DEFERRABLE INITIALLY DEFERRED)`,
|
||||
`INSERT INTO deferred_probe VALUES (1), (1)`))
|
||||
var failed *PlanActError
|
||||
if !errors.As(err, &failed) || failed.Step != StepCommit {
|
||||
t.Fatalf("error = %v, want a *PlanActError at %q", err, StepCommit)
|
||||
}
|
||||
}
|
||||
|
||||
// LastTierRefusal refuses only the deletion of a ladder's only tier, only
|
||||
// when an org type defaults to the ladder, and names every such type.
|
||||
func TestLastTierRefusal(t *testing.T) {
|
||||
only := []billing.PlanLadderTier{{ProductID: "basic"}}
|
||||
two := []billing.PlanLadderTier{{ProductID: "basic"}, {ProductID: "plus"}}
|
||||
const sentence = "This ladder's only remaining tier; Personal, Team defaults new signups to it. " +
|
||||
"Deleting it would leave those signups without a starting plan, so the removal is refused."
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tiers []billing.PlanLadderTier
|
||||
product string
|
||||
defaults []string
|
||||
want string
|
||||
}{
|
||||
{"the only tier of a default ladder", only, "basic", []string{"Personal", "Team"}, sentence},
|
||||
{"the only tier of a ladder no type defaults to", only, "basic", nil, ""},
|
||||
{"a tier of a ladder with another tier", two, "basic", []string{"Personal"}, ""},
|
||||
{"a product that is not the only tier", only, "plus", []string{"Personal"}, ""},
|
||||
{"a ladder with no tier", nil, "basic", []string{"Personal"}, ""},
|
||||
} {
|
||||
if got := LastTierRefusal(tc.tiers, tc.product, tc.defaults); got != tc.want {
|
||||
t.Errorf("%s: LastTierRefusal = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A failed begin comes back as the begin step's error before any step runs.
|
||||
func TestRunPlanActNamesAFailedBegin(t *testing.T) {
|
||||
var events []string
|
||||
err := runPlanAct(t.Context(), dbtest.Closed(t), recordedAct(&events, []string{"a"}, "", nil))
|
||||
var failed *PlanActError
|
||||
if !errors.As(err, &failed) || failed.Step != StepBegin {
|
||||
t.Fatalf("error = %v, want a *PlanActError at %q", err, StepBegin)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("steps ran after a failed begin: %v", events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: plan_acts.sql
|
||||
|
||||
package entitlements
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const recordPlanAct = `-- name: RecordPlanAct :one
|
||||
SELECT core.record_plan_act(
|
||||
$1::varchar,
|
||||
$2::varchar,
|
||||
$3::uuid,
|
||||
$4::uuid,
|
||||
$5::text,
|
||||
$6::varchar,
|
||||
$7::timestamptz,
|
||||
$8::uuid,
|
||||
$9::varchar,
|
||||
$10::uuid,
|
||||
$11::varchar,
|
||||
$12::uuid,
|
||||
$13::varchar,
|
||||
$14::varchar,
|
||||
$15::varchar,
|
||||
$16::uuid,
|
||||
$17::varchar,
|
||||
$18::varchar,
|
||||
$19::uuid
|
||||
)::uuid AS act_id
|
||||
`
|
||||
|
||||
type RecordPlanActParams struct {
|
||||
Cause string `json:"cause"`
|
||||
ActorType string `json:"actor_type"`
|
||||
ActorPersonID uuid.NullUUID `json:"actor_person_id"`
|
||||
ActorServiceAccountID uuid.NullUUID `json:"actor_service_account_id"`
|
||||
Note sql.NullString `json:"note"`
|
||||
RequestID sql.NullString `json:"request_id"`
|
||||
EffectiveAt time.Time `json:"effective_at"`
|
||||
PlanLadderID uuid.NullUUID `json:"plan_ladder_id"`
|
||||
PlanLadderName sql.NullString `json:"plan_ladder_name"`
|
||||
ProductID uuid.NullUUID `json:"product_id"`
|
||||
ProductName sql.NullString `json:"product_name"`
|
||||
PreviousProductID uuid.NullUUID `json:"previous_product_id"`
|
||||
PreviousProductName sql.NullString `json:"previous_product_name"`
|
||||
OrgType sql.NullString `json:"org_type"`
|
||||
OrgTypeName sql.NullString `json:"org_type_name"`
|
||||
PreviousPlanLadderID uuid.NullUUID `json:"previous_plan_ladder_id"`
|
||||
PreviousPlanLadderName sql.NullString `json:"previous_plan_ladder_name"`
|
||||
Disposition sql.NullString `json:"disposition"`
|
||||
RetriedActID uuid.NullUUID `json:"retried_act_id"`
|
||||
}
|
||||
|
||||
// The only writer of core.acts and core.plan_act_details: one act and its
|
||||
// detail row, returning the act's id. The calling transaction must hold the
|
||||
// materialization rendezvous (exclusive for every cause but a Retry), which
|
||||
// the function asserts. A request id longer than the column is not stored.
|
||||
func (q *Queries) RecordPlanAct(ctx context.Context, arg RecordPlanActParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, recordPlanAct,
|
||||
arg.Cause,
|
||||
arg.ActorType,
|
||||
arg.ActorPersonID,
|
||||
arg.ActorServiceAccountID,
|
||||
arg.Note,
|
||||
arg.RequestID,
|
||||
arg.EffectiveAt,
|
||||
arg.PlanLadderID,
|
||||
arg.PlanLadderName,
|
||||
arg.ProductID,
|
||||
arg.ProductName,
|
||||
arg.PreviousProductID,
|
||||
arg.PreviousProductName,
|
||||
arg.OrgType,
|
||||
arg.OrgTypeName,
|
||||
arg.PreviousPlanLadderID,
|
||||
arg.PreviousPlanLadderName,
|
||||
arg.Disposition,
|
||||
arg.RetriedActID,
|
||||
)
|
||||
var act_id string
|
||||
err := row.Scan(&act_id)
|
||||
return act_id, err
|
||||
}
|
||||
@@ -29,23 +29,6 @@ func (q *Queries) CountActiveAttachmentsByLadder(ctx context.Context, planLadder
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countActiveAttachmentsByTier = `-- name: CountActiveAttachmentsByTier :one
|
||||
SELECT COUNT(*) FROM core.pool_provision_ladders
|
||||
WHERE plan_ladder_id = $1 AND product_id = $2 AND status = 'active'
|
||||
`
|
||||
|
||||
type CountActiveAttachmentsByTierParams struct {
|
||||
PlanLadderID string `json:"plan_ladder_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountActiveAttachmentsByTier(ctx context.Context, arg CountActiveAttachmentsByTierParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countActiveAttachmentsByTier, arg.PlanLadderID, arg.ProductID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countActiveAttachmentsPerLadder = `-- name: CountActiveAttachmentsPerLadder :many
|
||||
SELECT plan_ladder_id, COUNT(*) AS active_count
|
||||
FROM core.pool_provision_ladders
|
||||
@@ -86,21 +69,27 @@ func (q *Queries) CountActiveAttachmentsPerLadder(ctx context.Context, planLadde
|
||||
}
|
||||
|
||||
const countActiveAttachmentsPerTier = `-- name: CountActiveAttachmentsPerTier :many
|
||||
SELECT product_id, COUNT(*) AS active_count
|
||||
SELECT product_id,
|
||||
COUNT(*) FILTER (WHERE status = 'active') AS active_count,
|
||||
COUNT(*) AS live_count
|
||||
FROM core.pool_provision_ladders
|
||||
WHERE plan_ladder_id = $1 AND status = 'active'
|
||||
WHERE plan_ladder_id = $1 AND status IN ('active', 'suspended')
|
||||
GROUP BY product_id
|
||||
`
|
||||
|
||||
type CountActiveAttachmentsPerTierRow struct {
|
||||
ProductID string `json:"product_id"`
|
||||
ActiveCount int64 `json:"active_count"`
|
||||
LiveCount int64 `json:"live_count"`
|
||||
}
|
||||
|
||||
// CountActiveAttachmentsByTier for every tier of one ladder in one read
|
||||
// (#10), keyed by the tier's product: the ladder page and the tier reorder
|
||||
// preview count each tier's active attachments at once. A tier with no
|
||||
// active attachment is absent from the result, and its reader takes it as 0.
|
||||
// The tier's active and live positions for every tier of one ladder in one
|
||||
// read (#10), keyed by the tier's product: the ladder page and the tier
|
||||
// reorder preview count each tier's attachments at once. The active count is
|
||||
// the column the page shows; the live count (active or suspended, as
|
||||
// CountLiveAttachmentsByTier counts) decides between the confirmation modal
|
||||
// and Preview removal. A tier with no live position is absent from the
|
||||
// result, and its reader takes both as 0.
|
||||
func (q *Queries) CountActiveAttachmentsPerTier(ctx context.Context, planLadderID string) ([]CountActiveAttachmentsPerTierRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, countActiveAttachmentsPerTier, planLadderID)
|
||||
if err != nil {
|
||||
@@ -110,7 +99,7 @@ func (q *Queries) CountActiveAttachmentsPerTier(ctx context.Context, planLadderI
|
||||
items := []CountActiveAttachmentsPerTierRow{}
|
||||
for rows.Next() {
|
||||
var i CountActiveAttachmentsPerTierRow
|
||||
if err := rows.Scan(&i.ProductID, &i.ActiveCount); err != nil {
|
||||
if err := rows.Scan(&i.ProductID, &i.ActiveCount, &i.LiveCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -124,6 +113,25 @@ func (q *Queries) CountActiveAttachmentsPerTier(ctx context.Context, planLadderI
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const countLiveAttachmentsByTier = `-- name: CountLiveAttachmentsByTier :one
|
||||
SELECT COUNT(*) FROM core.pool_provision_ladders
|
||||
WHERE plan_ladder_id = $1 AND product_id = $2 AND status IN ('active', 'suspended')
|
||||
`
|
||||
|
||||
type CountLiveAttachmentsByTierParams struct {
|
||||
PlanLadderID string `json:"plan_ladder_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// The tier's live holders, positions that are active or suspended: the
|
||||
// population a removal reconciles, and the count a plain delete refuses on.
|
||||
func (q *Queries) CountLiveAttachmentsByTier(ctx context.Context, arg CountLiveAttachmentsByTierParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countLiveAttachmentsByTier, arg.PlanLadderID, arg.ProductID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getActiveAttachmentsByPool = `-- name: GetActiveAttachmentsByPool :many
|
||||
SELECT provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, created_at, updated_at, product_id, provision_ladder_id, org_type, default_sourced FROM core.pool_provision_ladders
|
||||
WHERE pool_id = $1 AND status = 'active'
|
||||
|
||||
@@ -36,21 +36,26 @@ type Querier interface {
|
||||
// asserts: open it with BeginRuleChange.
|
||||
CommitRuleChange(ctx context.Context, arg CommitRuleChangeParams) (CommitRuleChangeRow, error)
|
||||
CountActiveAttachmentsByLadder(ctx context.Context, planLadderID string) (int64, error)
|
||||
CountActiveAttachmentsByTier(ctx context.Context, arg CountActiveAttachmentsByTierParams) (int64, error)
|
||||
// CountActiveAttachmentsByLadder for several ladders in one read (#10): the
|
||||
// operator's plan-ladders page counts every listed ladder's active
|
||||
// attachments at once. A ladder with no active attachment is absent from the
|
||||
// result, and its reader takes it as 0.
|
||||
CountActiveAttachmentsPerLadder(ctx context.Context, planLadderIds []string) ([]CountActiveAttachmentsPerLadderRow, error)
|
||||
// CountActiveAttachmentsByTier for every tier of one ladder in one read
|
||||
// (#10), keyed by the tier's product: the ladder page and the tier reorder
|
||||
// preview count each tier's active attachments at once. A tier with no
|
||||
// active attachment is absent from the result, and its reader takes it as 0.
|
||||
// The tier's active and live positions for every tier of one ladder in one
|
||||
// read (#10), keyed by the tier's product: the ladder page and the tier
|
||||
// reorder preview count each tier's attachments at once. The active count is
|
||||
// the column the page shows; the live count (active or suspended, as
|
||||
// CountLiveAttachmentsByTier counts) decides between the confirmation modal
|
||||
// and Preview removal. A tier with no live position is absent from the
|
||||
// result, and its reader takes both as 0.
|
||||
CountActiveAttachmentsPerTier(ctx context.Context, planLadderID string) ([]CountActiveAttachmentsPerTierRow, error)
|
||||
CountEntitlementSetChangeEffectsByOrg(ctx context.Context, orgID string) (int64, error)
|
||||
CountEntitlementSetChanges(ctx context.Context, setID string) (int64, error)
|
||||
// The same count for a page of sets in one query rather than one per row.
|
||||
CountFailedObligationsBySets(ctx context.Context, setIds []string) ([]CountFailedObligationsBySetsRow, error)
|
||||
// The tier's live holders, positions that are active or suspended: the
|
||||
// population a removal reconciles, and the count a plain delete refuses on.
|
||||
CountLiveAttachmentsByTier(ctx context.Context, arg CountLiveAttachmentsByTierParams) (int64, error)
|
||||
// The drain's own reads, beside the ledger queries in
|
||||
// entitlement_set_changes.sql. Neither writes: one counts what a change still
|
||||
// owes, the other names the pools whose last attempt left an error so the
|
||||
@@ -548,6 +553,11 @@ type Querier interface {
|
||||
// a real deletion stays with the decrement and drift paths.
|
||||
// Creates no usage row; a missing counter remains the materializer's to create.
|
||||
RaiseNumericUsageTo(ctx context.Context, arg RaiseNumericUsageToParams) (sql.Result, error)
|
||||
// The only writer of core.acts and core.plan_act_details: one act and its
|
||||
// detail row, returning the act's id. The calling transaction must hold the
|
||||
// materialization rendezvous (exclusive for every cause but a Retry), which
|
||||
// the function asserts. A request id longer than the column is not stored.
|
||||
RecordPlanAct(ctx context.Context, arg RecordPlanActParams) (string, error)
|
||||
// The Retry control: failed rows go back on the work list with their attempt
|
||||
// count reset, so the drain gives each a fresh run of recomputeMaxAttempts.
|
||||
RequeueFailedObligations(ctx context.Context, setID string) error
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- name: RecordPlanAct :one
|
||||
-- The only writer of core.acts and core.plan_act_details: one act and its
|
||||
-- detail row, returning the act's id. The calling transaction must hold the
|
||||
-- materialization rendezvous (exclusive for every cause but a Retry), which
|
||||
-- the function asserts. A request id longer than the column is not stored.
|
||||
SELECT core.record_plan_act(
|
||||
sqlc.arg(cause)::varchar,
|
||||
sqlc.arg(actor_type)::varchar,
|
||||
sqlc.narg(actor_person_id)::uuid,
|
||||
sqlc.narg(actor_service_account_id)::uuid,
|
||||
sqlc.narg(note)::text,
|
||||
sqlc.narg(request_id)::varchar,
|
||||
sqlc.arg(effective_at)::timestamptz,
|
||||
sqlc.narg(plan_ladder_id)::uuid,
|
||||
sqlc.narg(plan_ladder_name)::varchar,
|
||||
sqlc.narg(product_id)::uuid,
|
||||
sqlc.narg(product_name)::varchar,
|
||||
sqlc.narg(previous_product_id)::uuid,
|
||||
sqlc.narg(previous_product_name)::varchar,
|
||||
sqlc.narg(org_type)::varchar,
|
||||
sqlc.narg(org_type_name)::varchar,
|
||||
sqlc.narg(previous_plan_ladder_id)::uuid,
|
||||
sqlc.narg(previous_plan_ladder_name)::varchar,
|
||||
sqlc.narg(disposition)::varchar,
|
||||
sqlc.narg(retried_act_id)::uuid
|
||||
)::uuid AS act_id;
|
||||
@@ -131,18 +131,25 @@ ORDER BY max(l.activated_at) DESC, l.provision_id;
|
||||
-- idx_pool_provision_ladders_live_default per type.
|
||||
SELECT core.outgoing_default_exists(sqlc.arg(org_types)::varchar[], sqlc.narg(target)::uuid)::boolean AS outgoing_exists;
|
||||
|
||||
-- name: CountActiveAttachmentsByTier :one
|
||||
-- name: CountLiveAttachmentsByTier :one
|
||||
-- The tier's live holders, positions that are active or suspended: the
|
||||
-- population a removal reconciles, and the count a plain delete refuses on.
|
||||
SELECT COUNT(*) FROM core.pool_provision_ladders
|
||||
WHERE plan_ladder_id = $1 AND product_id = $2 AND status = 'active';
|
||||
WHERE plan_ladder_id = $1 AND product_id = $2 AND status IN ('active', 'suspended');
|
||||
|
||||
-- name: CountActiveAttachmentsPerTier :many
|
||||
-- CountActiveAttachmentsByTier for every tier of one ladder in one read
|
||||
-- (#10), keyed by the tier's product: the ladder page and the tier reorder
|
||||
-- preview count each tier's active attachments at once. A tier with no
|
||||
-- active attachment is absent from the result, and its reader takes it as 0.
|
||||
SELECT product_id, COUNT(*) AS active_count
|
||||
-- The tier's active and live positions for every tier of one ladder in one
|
||||
-- read (#10), keyed by the tier's product: the ladder page and the tier
|
||||
-- reorder preview count each tier's attachments at once. The active count is
|
||||
-- the column the page shows; the live count (active or suspended, as
|
||||
-- CountLiveAttachmentsByTier counts) decides between the confirmation modal
|
||||
-- and Preview removal. A tier with no live position is absent from the
|
||||
-- result, and its reader takes both as 0.
|
||||
SELECT product_id,
|
||||
COUNT(*) FILTER (WHERE status = 'active') AS active_count,
|
||||
COUNT(*) AS live_count
|
||||
FROM core.pool_provision_ladders
|
||||
WHERE plan_ladder_id = $1 AND status = 'active'
|
||||
WHERE plan_ladder_id = $1 AND status IN ('active', 'suspended')
|
||||
GROUP BY product_id;
|
||||
|
||||
-- name: ListLadderPositionsByPool :many
|
||||
|
||||
@@ -82,6 +82,17 @@ func rendezvous(lockFn string) func(context.Context, *sql.Tx) error {
|
||||
// order by pool_id. The driver's error comes back as it is; a missing pool is
|
||||
// sql.ErrNoRows.
|
||||
func (q *Queries) LockPools(ctx context.Context, poolIDs ...string) ([]string, error) {
|
||||
return lockPools(ctx, q, poolIDs)
|
||||
}
|
||||
|
||||
// poolLocker is the one query LockPools runs for each pool.
|
||||
type poolLocker interface {
|
||||
LockResourcePool(ctx context.Context, poolID string) (string, error)
|
||||
}
|
||||
|
||||
// lockPools is LockPools over any querier, so a plan act that holds its
|
||||
// queriers by interface takes its locks the same way.
|
||||
func lockPools(ctx context.Context, q poolLocker, poolIDs []string) ([]string, error) {
|
||||
ids := slices.Clone(poolIDs)
|
||||
slices.Sort(ids)
|
||||
ids = slices.Compact(ids)
|
||||
|
||||
@@ -11,6 +11,15 @@ read core.accounts.next_invoice_number
|
||||
read core.accounts.org_id
|
||||
read core.accounts.status
|
||||
read core.accounts.updated_at
|
||||
read core.acts.act_id
|
||||
read core.acts.actor_person_id
|
||||
read core.acts.actor_service_account_id
|
||||
read core.acts.actor_type
|
||||
read core.acts.cause
|
||||
read core.acts.created_at
|
||||
read core.acts.effective_at
|
||||
withhold core.acts.note
|
||||
read core.acts.request_id
|
||||
read core.boolean_entitlements.created_at
|
||||
read core.boolean_entitlements.granted
|
||||
read core.boolean_entitlements.pool_id
|
||||
@@ -302,6 +311,20 @@ read core.persons.primary_email_verified
|
||||
read core.persons.status
|
||||
read core.persons.updated_at
|
||||
read core.persons.user_id
|
||||
read core.plan_act_details.act_id
|
||||
read core.plan_act_details.cause
|
||||
read core.plan_act_details.disposition
|
||||
read core.plan_act_details.org_type
|
||||
read core.plan_act_details.org_type_name
|
||||
read core.plan_act_details.plan_ladder_id
|
||||
read core.plan_act_details.plan_ladder_name
|
||||
read core.plan_act_details.previous_plan_ladder_id
|
||||
read core.plan_act_details.previous_plan_ladder_name
|
||||
read core.plan_act_details.previous_product_id
|
||||
read core.plan_act_details.previous_product_name
|
||||
read core.plan_act_details.product_id
|
||||
read core.plan_act_details.product_name
|
||||
read core.plan_act_details.retried_act_id
|
||||
read core.plan_ladder_tiers.created_at
|
||||
read core.plan_ladder_tiers.plan_ladder_id
|
||||
read core.plan_ladder_tiers.product_id
|
||||
|
||||
@@ -45,6 +45,13 @@ type otcHarness struct {
|
||||
}
|
||||
|
||||
func newOtcHarness(t *testing.T, database *sql.DB, personID string) *otcHarness {
|
||||
t.Helper()
|
||||
return newOtcHarnessActs(t, database, personID, entitlements.PlanActConfig{})
|
||||
}
|
||||
|
||||
// newOtcHarnessActs is newOtcHarness with the queriers the plan acts build
|
||||
// on their own transactions given by acts.
|
||||
func newOtcHarnessActs(t *testing.T, database *sql.DB, personID string, acts entitlements.PlanActConfig) *otcHarness {
|
||||
t.Helper()
|
||||
sm := scs.New()
|
||||
authCfg := &auth.Config{SessionManager: sm}
|
||||
@@ -57,6 +64,7 @@ func newOtcHarness(t *testing.T, database *sql.DB, personID string) *otcHarness
|
||||
OrgQ: organization.New(database),
|
||||
Logger: slog.New(slog.NewTextHandler(logs, nil)),
|
||||
AuthConfig: authCfg,
|
||||
PlanActs: acts,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
||||
|
||||
@@ -101,6 +101,7 @@ type OperatorPartialsHandler struct {
|
||||
Logger *slog.Logger
|
||||
AuthConfig *auth.Config
|
||||
Templates *SafeTemplates
|
||||
PlanActs entitlements.PlanActConfig
|
||||
StripeDashboardURL string
|
||||
// StripeMode is the mode derived from the Stripe API key at boot
|
||||
// ("test", "live", or "" when no key is configured): the provider
|
||||
@@ -136,6 +137,10 @@ type OperatorPartialsConfig struct {
|
||||
AuthConfig *auth.Config
|
||||
StripeDashboardURL string
|
||||
StripeMode string
|
||||
// PlanActs says where the plan acts build their queriers. The zero value
|
||||
// means the generated ones; a test passes wrappers that refuse a named
|
||||
// statement inside an act's own transaction.
|
||||
PlanActs entitlements.PlanActConfig
|
||||
// StripeKeyFingerprint is the digest of the API key this process runs
|
||||
// under (see Config.StripeKeyFingerprint): the Environment check
|
||||
// section compares it with the fingerprint the recorded check ran
|
||||
@@ -163,6 +168,7 @@ func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHa
|
||||
OrgQ: cfg.OrgQ,
|
||||
Logger: cfg.Logger,
|
||||
AuthConfig: cfg.AuthConfig,
|
||||
PlanActs: cfg.PlanActs,
|
||||
Templates: NewSafeTemplates(tmpl, cfg.Logger),
|
||||
IntegrationConfigs: cfg.IntegrationConfigs,
|
||||
StripeDashboardURL: cfg.StripeDashboardURL,
|
||||
|
||||
@@ -14,14 +14,23 @@ package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
|
||||
)
|
||||
|
||||
// The only tier of a ladder some org type defaults to cannot be deleted: the
|
||||
@@ -75,3 +84,200 @@ func TestDeletePlanLadderTier_AllowsLastTierOfNonDefaultLadder(t *testing.T) {
|
||||
t.Errorf("tier still exists after deleting a non-default ladder's last tier; body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// orgTypesUnread is the organization querier whose ListOrgTypes fails, built
|
||||
// on the act's own transaction.
|
||||
type orgTypesUnread struct{ organization.Querier }
|
||||
|
||||
func (orgTypesUnread) ListOrgTypes(context.Context) ([]organization.OrgType, error) {
|
||||
return nil, errors.New("org types unavailable")
|
||||
}
|
||||
|
||||
// deleteTierInRequest sends the plain delete through the request id and
|
||||
// logging middleware, as the server does, with requestID as the request's
|
||||
// identifier. The request's logger writes where the harness's does.
|
||||
func (h *otcHarness) deleteTierInRequest(requestID, ladderID, productID string) (int, string) {
|
||||
h.t.Helper()
|
||||
handler := middleware.RequestID()(middleware.Logging()(http.HandlerFunc(h.handler.DeletePlanLadderTier)))
|
||||
ctx := logging.WithContext(h.ctx, slog.New(slog.NewTextHandler(h.logs, nil)))
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodDelete, "/", nil)
|
||||
req.Header.Set(middleware.RequestIDHeader, requestID)
|
||||
req.SetPathValue("ladderID", ladderID)
|
||||
req.SetPathValue("productID", productID)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
// The delete reads the org types its last-tier guard needs inside its own
|
||||
// transaction, and a failed read refuses: the ladder here is no org type's
|
||||
// default, so a guard that took the failed read for "no type defaults to it"
|
||||
// would delete the tier. The answer carries the request's identifier, and the
|
||||
// log line carries the same identifier and names the step.
|
||||
func TestDeletePlanLadderTier_RefusesWhenTheActCannotReadTheOrgTypes(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
f := newOtcFixture(t, database)
|
||||
h := newOtcHarnessActs(t, database, f.operatorID, entitlements.PlanActConfig{
|
||||
Organization: func(d organization.DBTX) organization.Querier { return orgTypesUnread{organization.New(d)} },
|
||||
})
|
||||
|
||||
code, body := h.deleteTierInRequest("ref-4242", f.ladderB, f.prodB)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "Failed to remove tier. Reference: ref-4242.") {
|
||||
t.Errorf("the answer lacks the failure sentence with the request id; body:\n%s", body)
|
||||
}
|
||||
if !trTierExists(t, database, f.ladderB, f.prodB) {
|
||||
t.Errorf("the tier was deleted although the org types could not be read")
|
||||
}
|
||||
logged := h.logs.String()
|
||||
for _, want := range []string{"tier delete failed", "request_id=ref-4242", `step="read the organization types"`} {
|
||||
if !strings.Contains(logged, want) {
|
||||
t.Errorf("the log lacks %s:\n%s", want, logged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// holdersUnread is the entitlements querier whose holder count fails, and
|
||||
// tiersUnread the billing querier whose tier list fails, each built on the
|
||||
// act's own transaction.
|
||||
type holdersUnread struct{ entitlements.Querier }
|
||||
|
||||
func (holdersUnread) CountLiveAttachmentsByTier(context.Context, entitlements.CountLiveAttachmentsByTierParams) (int64, error) {
|
||||
return 0, errors.New("holders unavailable")
|
||||
}
|
||||
|
||||
type tiersUnread struct{ billing.Querier }
|
||||
|
||||
func (tiersUnread) ListTiersByLadder(context.Context, string) ([]billing.PlanLadderTier, error) {
|
||||
return nil, errors.New("tiers unavailable")
|
||||
}
|
||||
|
||||
// A failed read of the tier's holders or of the ladder's tiers answers with
|
||||
// the failure sentence and the request's identifier, like every failure of
|
||||
// the delete that is not a refusal, and logs the step that failed.
|
||||
func TestDeletePlanLadderTier_AnswersAFailedReadWithTheRequestReference(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
acts entitlements.PlanActConfig
|
||||
step string
|
||||
}{
|
||||
{"holders", entitlements.PlanActConfig{
|
||||
Entitlements: func(d entitlements.DBTX) entitlements.Querier { return holdersUnread{entitlements.New(d)} },
|
||||
}, `step="read the tier's holders"`},
|
||||
{"tiers", entitlements.PlanActConfig{
|
||||
Billing: func(d billing.DBTX) billing.Querier { return tiersUnread{billing.New(d)} },
|
||||
}, `step="read the ladder's tiers"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
f := newOtcFixture(t, database)
|
||||
h := newOtcHarnessActs(t, database, f.operatorID, tc.acts)
|
||||
|
||||
code, body := h.deleteTierInRequest("ref-7", f.ladderB, f.prodB)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "Failed to remove tier. Reference: ref-7.") {
|
||||
t.Errorf("the answer lacks the failure sentence with the request id; body:\n%s", body)
|
||||
}
|
||||
if !trTierExists(t, database, f.ladderB, f.prodB) {
|
||||
t.Errorf("the tier was deleted although a read failed")
|
||||
}
|
||||
logged := h.logs.String()
|
||||
for _, want := range []string{"tier delete failed", "request_id=ref-7", tc.step} {
|
||||
if !strings.Contains(logged, want) {
|
||||
t.Errorf("the log lacks %s:\n%s", want, logged)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A second delete of a tier the first removed, as a confirm sent twice or a
|
||||
// second operator's delete makes it, answers as a delete does and records no
|
||||
// act, deletes nothing and leaves the ladder as the first delete left it.
|
||||
func TestDeletePlanLadderTier_ASecondDeleteOfOneTierRecordsOneAct(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
w := world.New(t, database)
|
||||
operator := w.Person("Operator")
|
||||
basic, plus, pro := w.Product("Basic"), w.Product("Plus"), w.Product("Pro")
|
||||
ladder := w.Ladder("Main", basic, plus, pro)
|
||||
h := newOtcHarness(t, database, operator.ID)
|
||||
const countActs = `SELECT count(*) FROM core.plan_act_details WHERE plan_ladder_id = $1`
|
||||
const ranks = `SELECT string_agg(product_id::text || ':' || rank, ',' ORDER BY rank) FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1`
|
||||
|
||||
var afterFirst string
|
||||
for i := 1; i <= 2; i++ {
|
||||
code, _, trigger := h.reqLadderProduct(http.MethodDelete, h.handler.DeletePlanLadderTier, ladder.ID, plus.ID, nil)
|
||||
if code != http.StatusOK || !strings.Contains(trigger, "Tier removed") {
|
||||
t.Fatalf("delete %d: status=%d toast=%q", i, code, trigger)
|
||||
}
|
||||
var got string
|
||||
if err := database.QueryRowContext(context.Background(), ranks, ladder.ID).Scan(&got); err != nil {
|
||||
t.Fatalf("read the tiers: %v", err)
|
||||
}
|
||||
if i == 1 {
|
||||
afterFirst = got
|
||||
} else if got != afterFirst {
|
||||
t.Errorf("the tiers after the second delete are %s, want %s as the first left them", got, afterFirst)
|
||||
}
|
||||
if n := otcScalar(t, database, countActs, ladder.ID); n != 1 {
|
||||
t.Errorf("after delete %d the ladder has %d recorded acts, want 1", i, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A tier held only by a suspended position is a tier with a live holder: the
|
||||
// plain delete refuses it, the tier page offers Preview removal for it and
|
||||
// not the confirmation modal, and nothing changes.
|
||||
func TestDeletePlanLadderTier_RefusesATierHeldOnlyBySuspendedPositions(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
w := world.New(t, database)
|
||||
operator := w.Person("Operator")
|
||||
basic, plus := w.Product("Basic"), w.Product("Plus")
|
||||
w.Price(plus, 200)
|
||||
ladder := w.Ladder("Main", basic, plus)
|
||||
w.Suspend(w.Subscribe(w.Org("Holder", w.OrgType("susp"), operator), world.SubOpts{}, plus))
|
||||
|
||||
var active, suspended int
|
||||
for status, n := range map[string]*int{"active": &active, "suspended": &suspended} {
|
||||
if err := database.QueryRowContext(context.Background(), `SELECT count(*) FROM core.pool_provision_ladders
|
||||
WHERE plan_ladder_id = $1 AND product_id = $2 AND status = $3`, ladder.ID, plus.ID, status).Scan(n); err != nil {
|
||||
t.Fatalf("count the %s positions: %v", status, err)
|
||||
}
|
||||
}
|
||||
if active != 0 || suspended != 1 {
|
||||
t.Fatalf("the tier has %d active and %d suspended positions, want 0 and 1", active, suspended)
|
||||
}
|
||||
|
||||
h := newOtcHarness(t, database, operator.ID)
|
||||
code, body, _ := h.reqLadderProduct(http.MethodDelete, h.handler.DeletePlanLadderTier, ladder.ID, plus.ID, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "This tier has 1 live holder(s)") {
|
||||
t.Errorf("the refusal does not name the live holder; body:\n%s", body)
|
||||
}
|
||||
if !trTierExists(t, database, ladder.ID, plus.ID) {
|
||||
t.Errorf("a tier held by a suspended position was deleted")
|
||||
}
|
||||
|
||||
// The page it answers with routes the held tier to Preview removal and
|
||||
// the free one to the confirmation modal.
|
||||
rows := map[string][]string{}
|
||||
for _, row := range htmltest.Parse(t, body).All("tbody tr") {
|
||||
name := strings.TrimSuffix(row.One(t, "th a").Text(), " "+w.Sfx)
|
||||
rows[name] = row.Texts("button")
|
||||
if (name == "Plus") != (row.Count(`button[data-bs-target="#confirmActionModal"]`) == 0) {
|
||||
t.Errorf("%s: the row's modal trigger is wrong:\n%s", name, row.HTML())
|
||||
}
|
||||
}
|
||||
if got := rows["Plus"]; len(got) != 1 || got[0] != "Preview removal" {
|
||||
t.Errorf("the suspended-only tier offers %q, want Preview removal", got)
|
||||
}
|
||||
if got := rows["Basic"]; len(got) != 1 || got[0] != "Remove" {
|
||||
t.Errorf("the free tier offers %q, want Remove", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ package server
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
||||
)
|
||||
|
||||
// TestPlanLadderTiersFullLadderAlert covers the misleading "no products to
|
||||
@@ -35,7 +37,7 @@ func TestPlanLadderTiersRemoveConsequenceCopy(t *testing.T) {
|
||||
body := Render(t, OperatorSet(t), "operator_plan_ladder_tiers.html", PlanLadderTiersData{
|
||||
Ladder: PlanLadderViewModel{PlanLadderID: "l-1", Name: "Hosting"},
|
||||
Tiers: []PlanLadderTierViewModel{
|
||||
{PlanLadderID: "l-1", ProductID: "p-occupied", ProductName: "Pro", Rank: 0, HasActiveAttachments: true, ActiveAttachmentCount: 3},
|
||||
{PlanLadderID: "l-1", ProductID: "p-occupied", ProductName: "Pro", Rank: 0, HasActiveAttachments: true, ActiveAttachmentCount: 3, LiveHolders: 3},
|
||||
{PlanLadderID: "l-1", ProductID: "p-empty", ProductName: "Basic", Rank: 1, HasActiveAttachments: false},
|
||||
},
|
||||
})
|
||||
@@ -58,6 +60,38 @@ func TestPlanLadderTiersRemoveConsequenceCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A tier held only by suspended positions has no active position and one live
|
||||
// one: its row offers Preview removal, whose tooltip counts the live holders,
|
||||
// and no confirmation modal, while the active column still reads 0.
|
||||
func TestPlanLadderTiersRoutesALiveTierToPreviewRemoval(t *testing.T) {
|
||||
body := Render(t, OperatorSet(t), "operator_plan_ladder_tiers.html", PlanLadderTiersData{
|
||||
Ladder: PlanLadderViewModel{PlanLadderID: "l-1", Name: "Hosting"},
|
||||
Tiers: []PlanLadderTierViewModel{
|
||||
{PlanLadderID: "l-1", ProductID: "p-suspended", ProductName: "Pro", Rank: 0, LiveHolders: 2},
|
||||
{PlanLadderID: "l-1", ProductID: "p-free", ProductName: "Basic", Rank: 1},
|
||||
},
|
||||
})
|
||||
|
||||
rows := htmltest.Parse(t, body).All("tbody tr")
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("the table has %d rows, want 2", len(rows))
|
||||
}
|
||||
held, free := rows[0], rows[1]
|
||||
if got := held.Texts("button"); len(got) != 1 || got[0] != "Preview removal" {
|
||||
t.Errorf("the suspended-only tier offers %q, want Preview removal", got)
|
||||
}
|
||||
if title, _ := held.One(t, "button").Attr("title"); !strings.HasPrefix(title, "2 pool(s) hold this tier.") {
|
||||
t.Errorf("the Preview removal tooltip is %q, want it to count the 2 live holders", title)
|
||||
}
|
||||
held.None(t, `button[data-bs-target="#confirmActionModal"]`)
|
||||
if got := held.Texts("td")[1]; got != "0" {
|
||||
t.Errorf("the active column reads %q, want 0", got)
|
||||
}
|
||||
if got := free.Texts("button"); len(got) != 1 || got[0] != "Remove" {
|
||||
t.Errorf("the free tier offers %q, want Remove", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanLadderTiersRankAscending covers design D8 "Rank ascending on both
|
||||
// surfaces" (round 2 corrects round 1's rank-0-last convention): the tiers
|
||||
// table renders the given slice as-is (no reordering), so rank 0 is the
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
||||
)
|
||||
|
||||
@@ -62,10 +64,13 @@ type PlanLadderTierViewModel struct {
|
||||
ProductName string
|
||||
Rank int32
|
||||
HasActiveAttachments bool
|
||||
// ActiveAttachmentCount is the live-holder count backing
|
||||
// HasActiveAttachments, rendered inline beside the Remove control so its
|
||||
// consequence is stated before any confirmation step.
|
||||
// ActiveAttachmentCount is the active-position count backing
|
||||
// HasActiveAttachments, the column beside the Remove control.
|
||||
ActiveAttachmentCount int64
|
||||
// LiveHolders counts the tier's live positions, active or suspended: the
|
||||
// removal's population. A tier with any is removed through Preview
|
||||
// removal, and the plain delete refuses it.
|
||||
LiveHolders int64
|
||||
}
|
||||
|
||||
// PlanLaddersData holds data for the plan ladders list partial. It carries
|
||||
@@ -551,7 +556,7 @@ func (h *OperatorPartialsHandler) buildPendingTierReorder(ctx context.Context, l
|
||||
OldRankZeroName: names[prevTop],
|
||||
NewRankZeroName: names[final[0]],
|
||||
}
|
||||
counts := h.activeAttachmentsPerTier(ctx, ladderID)
|
||||
counts := h.attachmentsPerTier(ctx, ladderID)
|
||||
pending.Order = make([]PlanLadderTierViewModel, len(final))
|
||||
for i, pid := range final {
|
||||
count := counts[pid]
|
||||
@@ -560,8 +565,9 @@ func (h *OperatorPartialsHandler) buildPendingTierReorder(ctx context.Context, l
|
||||
ProductID: pid,
|
||||
ProductName: names[pid],
|
||||
Rank: int32(i),
|
||||
HasActiveAttachments: count > 0,
|
||||
ActiveAttachmentCount: count,
|
||||
HasActiveAttachments: count.active > 0,
|
||||
ActiveAttachmentCount: count.active,
|
||||
LiveHolders: count.live,
|
||||
}
|
||||
}
|
||||
// design D8: display order is rank ascending (rank 0 first), the same
|
||||
@@ -804,58 +810,31 @@ func (h *OperatorPartialsHandler) tierReorderFailure(enacted *entitlements.Enact
|
||||
}
|
||||
|
||||
// DeletePlanLadderTier handles DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}
|
||||
//
|
||||
// It parses the request, runs entitlements.DeleteTier and renders. The delete
|
||||
// counts the tier's live holders, reads the ladder's tiers and the org types
|
||||
// that default to it, and refuses a last-tier delete inside its own
|
||||
// transaction. A tier with live holders is removed through the preview and
|
||||
// commit flow (CommitPlanLadderTierRemoval); this DELETE is only reachable
|
||||
// with holders from a stale page, which it re-renders with the current state.
|
||||
func (h *OperatorPartialsHandler) DeletePlanLadderTier(w http.ResponseWriter, r *http.Request) {
|
||||
ladderID := r.PathValue("ladderID")
|
||||
productID := r.PathValue("productID")
|
||||
session := h.AuthConfig.GetUserSession(r.Context())
|
||||
if session == nil {
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// A tier with live holders is removed via the preview → commit flow
|
||||
// (CommitPlanLadderTierRemoval), never this plain delete — this DELETE is
|
||||
// only reachable with holders from a stale page, so re-render with the
|
||||
// current state (whose Remove button now posts the preview).
|
||||
count, err := h.EntitlementsQ.CountActiveAttachmentsByTier(r.Context(), entitlements.CountActiveAttachmentsByTierParams{
|
||||
PlanLadderID: ladderID,
|
||||
ProductID: productID,
|
||||
err := entitlements.DeleteTier(r.Context(), h.Database, entitlements.DeleteTierInput{
|
||||
LadderID: ladderID,
|
||||
ProductID: productID,
|
||||
OperatorPersonID: session.PersonID,
|
||||
RequestID: middleware.GetRequestID(r.Context()),
|
||||
Acts: h.PlanActs,
|
||||
})
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to count active tier attachments", slog.Any("error", err))
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to check active positions")
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "This tier has "+strconv.FormatInt(count, 10)+" live holder(s); Remove now previews the reconciled removal before committing.")
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse deleting a live default ladder's only remaining tier: new
|
||||
// signups land on this ladder's rank-0 tier, so emptying it would leave
|
||||
// every org type that defaults to it without a starting plan. Emptying a
|
||||
// ladder no org type defaults to stays legal — the 2026-07-03
|
||||
// remediation explicitly rejected a blanket last-tier guard.
|
||||
tiers, err := h.BillingQ.ListTiersByLadder(r.Context(), ladderID)
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to list tiers for delete guard", slog.Any("error", err))
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to check the ladder's tiers")
|
||||
return
|
||||
}
|
||||
if len(tiers) == 1 && tiers[0].ProductID == productID {
|
||||
if refs := h.orgTypesDefaultingToLadder(r.Context(), ladderID); len(refs) > 0 {
|
||||
names := make([]string, len(refs))
|
||||
for i, ref := range refs {
|
||||
names[i] = ref.DisplayName
|
||||
}
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "",
|
||||
"This ladder's only remaining tier; "+strings.Join(names, ", ")+" defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Delete + renumber the remaining tiers to contiguous 0..N-1 in one
|
||||
// transaction (entitlements.DeleteTier), so ranks never gap and the ladder
|
||||
// always has a rank-0 provisioning default (deleting the base tier
|
||||
// promotes the next tier — the confirm dialog says so).
|
||||
if err := entitlements.DeleteTier(r.Context(), h.Database, ladderID, productID); err != nil {
|
||||
h.logTierDeleteFailure(err)
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.")
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", h.tierDeleteRefusal(r, ladderID, productID, err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -863,26 +842,38 @@ func (h *OperatorPartialsHandler) DeletePlanLadderTier(w http.ResponseWriter, r
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "Tier removed; remaining tiers renumbered.", "")
|
||||
}
|
||||
|
||||
// logTierDeleteFailure logs a tier delete that did not commit as the step
|
||||
// that failed is logged.
|
||||
func (h *OperatorPartialsHandler) logTierDeleteFailure(err error) {
|
||||
// tierDeleteRefusal is the sentence the operator reads for a tier delete that
|
||||
// did not commit. A refusal of the delete's guards reads as its own sentence.
|
||||
// Any other failure is logged with the step that failed, through the
|
||||
// request's logger so the line carries the request id, and a lock wait or a
|
||||
// deadlock reads as the contention refusal; the rest read the failure
|
||||
// sentence with that request id, so an operator who cannot read the logs can
|
||||
// hand it to the person who can.
|
||||
func (h *OperatorPartialsHandler) tierDeleteRefusal(r *http.Request, ladderID, productID string, err error) string {
|
||||
var holders *entitlements.LiveHoldersError
|
||||
if errors.As(err, &holders) {
|
||||
return "This tier has " + strconv.FormatInt(holders.Holders, 10) + " live holder(s); Remove now previews the reconciled removal before committing."
|
||||
}
|
||||
var lastTier *entitlements.LastTierError
|
||||
if errors.As(err, &lastTier) {
|
||||
return lastTier.Message
|
||||
}
|
||||
|
||||
step := entitlements.PlanActStep("")
|
||||
var failed *entitlements.PlanActError
|
||||
if !errors.As(err, &failed) {
|
||||
return
|
||||
if errors.As(err, &failed) {
|
||||
step = failed.Step
|
||||
}
|
||||
cause := failed.Err
|
||||
switch failed.Step {
|
||||
case entitlements.StepBegin:
|
||||
h.Logger.Error("failed to begin tier delete tx", slog.Any("error", cause))
|
||||
case entitlements.StepDeleteTier:
|
||||
h.Logger.Error("failed to delete tier", slog.Any("error", cause))
|
||||
case entitlements.StepListTiers:
|
||||
h.Logger.Error("failed to list remaining tiers", slog.Any("error", cause))
|
||||
case entitlements.StepRenumber:
|
||||
h.Logger.Error("failed to renumber tiers after delete", slog.Any("error", cause))
|
||||
case entitlements.StepCommit:
|
||||
h.Logger.Error("failed to commit tier delete tx", slog.Any("error", cause))
|
||||
logging.FromContext(r.Context()).Error("tier delete failed",
|
||||
slog.String("step", string(step)),
|
||||
slog.String("ladder_id", ladderID),
|
||||
slog.String("product_id", productID),
|
||||
slog.Any("error", err))
|
||||
switch {
|
||||
case db.IsLockContention(err):
|
||||
return entitlements.MessageInProgress
|
||||
}
|
||||
return "Failed to remove tier. Reference: " + middleware.GetRequestID(r.Context()) + "."
|
||||
}
|
||||
|
||||
// buildTierRemovalPreview classifies the live holders of (ladder, product)
|
||||
@@ -1242,15 +1233,21 @@ func (h *OperatorPartialsHandler) loadPlanLaddersPageData(r *http.Request, errMs
|
||||
return data
|
||||
}
|
||||
|
||||
// activeAttachmentsPerTier counts the active attachments on each tier of one
|
||||
// ladder in one read, keyed by the tier's product. A tier with none is absent
|
||||
// and reads 0, as is every tier when the read fails; the error is discarded,
|
||||
// as the per-tier count discarded it.
|
||||
func (h *OperatorPartialsHandler) activeAttachmentsPerTier(ctx context.Context, ladderID string) map[string]int64 {
|
||||
// tierAttachments is one tier's positions on its ladder: the active ones and
|
||||
// the live ones (active or suspended).
|
||||
type tierAttachments struct {
|
||||
active, live int64
|
||||
}
|
||||
|
||||
// attachmentsPerTier counts the active and the live attachments on each tier
|
||||
// of one ladder in one read, keyed by the tier's product. A tier with none is
|
||||
// absent and reads 0, as is every tier when the read fails; the error is
|
||||
// discarded, as the per-tier count discarded it.
|
||||
func (h *OperatorPartialsHandler) attachmentsPerTier(ctx context.Context, ladderID string) map[string]tierAttachments {
|
||||
rows, _ := h.EntitlementsQ.CountActiveAttachmentsPerTier(ctx, ladderID)
|
||||
counts := make(map[string]int64, len(rows))
|
||||
counts := make(map[string]tierAttachments, len(rows))
|
||||
for _, c := range rows {
|
||||
counts[c.ProductID] = c.ActiveCount
|
||||
counts[c.ProductID] = tierAttachments{active: c.ActiveCount, live: c.LiveCount}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
@@ -1482,7 +1479,7 @@ func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladde
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to list tiers", slog.Any("error", err))
|
||||
} else {
|
||||
counts := h.activeAttachmentsPerTier(r.Context(), ladderID)
|
||||
counts := h.attachmentsPerTier(r.Context(), ladderID)
|
||||
data.Tiers = make([]PlanLadderTierViewModel, len(tiers))
|
||||
for i, t := range tiers {
|
||||
count := counts[t.ProductID]
|
||||
@@ -1491,8 +1488,9 @@ func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladde
|
||||
ProductID: t.ProductID,
|
||||
ProductName: t.ProductName,
|
||||
Rank: t.Rank,
|
||||
HasActiveAttachments: count > 0,
|
||||
ActiveAttachmentCount: count,
|
||||
HasActiveAttachments: count.active > 0,
|
||||
ActiveAttachmentCount: count.active,
|
||||
LiveHolders: count.live,
|
||||
}
|
||||
}
|
||||
// design D8: the tiers table renders ranks ascending (rank 0 first),
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -35,6 +36,8 @@ import (
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/authtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
@@ -58,12 +61,16 @@ type actFixture struct {
|
||||
closed bool
|
||||
// withReads adds the request's statements, reads included, to the file.
|
||||
withReads bool
|
||||
// refuse names the statements the handler's queriers refuse.
|
||||
// refuse names the statements the handler's queriers and the act's own
|
||||
// refuse.
|
||||
refuse actRefusals
|
||||
// namedActs are the acts the normalizer already names.
|
||||
namedActs map[string]bool
|
||||
}
|
||||
|
||||
// actRefusals are the statements a scenario's queriers refuse with
|
||||
// errReadFailed. A refused read returns what the database returned with the
|
||||
// errReadFailed, in the handler and, through the act's own constructors
|
||||
// (entitlements.PlanActConfig), inside the act's transaction. A refused read returns what the database returned with the
|
||||
// error, so code that used a result it was told failed would show it; a
|
||||
// refused write writes nothing.
|
||||
type actRefusals struct {
|
||||
@@ -71,6 +78,9 @@ type actRefusals struct {
|
||||
saveDefault bool // UpdateOrgTypeDefaultPlanLadder
|
||||
listOrgs bool // ListOrganizationsByType
|
||||
orgTypes bool // ListOrgTypes
|
||||
// actOrgTypes refuses ListOrgTypes in the act's organization querier
|
||||
// only, so the page the handler re-renders reads it.
|
||||
actOrgTypes bool
|
||||
}
|
||||
|
||||
// actBillingQ is the billing querier with the refusals applied.
|
||||
@@ -228,6 +238,8 @@ type actRows struct {
|
||||
Positions []string `json:"positions"`
|
||||
Transitions []string `json:"transitions"`
|
||||
Entitlements []string `json:"entitlements"`
|
||||
Acts []string `json:"acts"`
|
||||
ActDetails []string `json:"act_details"`
|
||||
}
|
||||
|
||||
// act sends one request to the handler method act names, signed in as the
|
||||
@@ -251,6 +263,18 @@ type actCall struct {
|
||||
path []authtest.RequestOpt
|
||||
stmts *dbtest.Statements
|
||||
logs *bytes.Buffer
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// inRequest wraps h as the server wraps a handler: the request id middleware
|
||||
// gives the request its identifier, and the request's logger carries it, so
|
||||
// a line the handler logs through logging.FromContext names the request. The
|
||||
// identifier is a fresh UUID, which the file numbers.
|
||||
func (c *actCall) inRequest(h http.HandlerFunc) http.HandlerFunc {
|
||||
return middleware.RequestID()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scoped := c.logger.With(slog.String("request_id", middleware.GetRequestID(r.Context())))
|
||||
h(w, r.WithContext(logging.WithContext(r.Context(), scoped)))
|
||||
})).ServeHTTP
|
||||
}
|
||||
|
||||
// prepare builds the handler a request runs on: its database is a counted
|
||||
@@ -259,22 +283,35 @@ func (f *actFixture) prepare(act func(*OperatorPartialsHandler) http.HandlerFunc
|
||||
f.t.Helper()
|
||||
d, stmts := dbtest.Counted(f.t)
|
||||
c := &actCall{f: f, form: form, path: path, stmts: stmts, logs: &bytes.Buffer{}}
|
||||
c.logger = slog.New(slog.NewTextHandler(c.logs, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}))
|
||||
cfg := OperatorPartialsConfig{
|
||||
Database: d,
|
||||
Logger: slog.New(slog.NewTextHandler(c.logs, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})),
|
||||
Database: d,
|
||||
Logger: c.logger,
|
||||
AuthConfig: &auth.Config{SessionManager: authtest.NewSessionManager()},
|
||||
BillingQ: actBillingQ{Querier: billing.New(d), refuse: f.refuse},
|
||||
EntitlementsQ: entitlements.New(d),
|
||||
OrgQ: actOrgQ{Querier: organization.New(d), refuse: f.refuse},
|
||||
IdentityQ: identity.New(d),
|
||||
StripeMode: "test",
|
||||
// The act builds its own queriers on its transaction. They refuse the
|
||||
// statements the scenario names, as the handler's do.
|
||||
PlanActs: entitlements.PlanActConfig{
|
||||
Billing: func(d billing.DBTX) billing.Querier {
|
||||
return actBillingQ{Querier: billing.New(d), refuse: f.refuse}
|
||||
},
|
||||
Organization: func(d organization.DBTX) organization.Querier {
|
||||
refuse := f.refuse
|
||||
refuse.orgTypes = refuse.orgTypes || refuse.actOrgTypes
|
||||
return actOrgQ{Querier: organization.New(d), refuse: refuse}
|
||||
},
|
||||
},
|
||||
}
|
||||
if f.closed {
|
||||
closed := dbtest.Open(f.t)
|
||||
@@ -287,7 +324,7 @@ func (f *actFixture) prepare(act func(*OperatorPartialsHandler) http.HandlerFunc
|
||||
if err != nil {
|
||||
f.t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
||||
}
|
||||
c.h = act(h)
|
||||
c.h = c.inRequest(act(h))
|
||||
signIn := f.operator.SignIn()
|
||||
signIn.Roles = []string{OperatorRole}
|
||||
c.session = authtest.SignIn(f.t, cfg.AuthConfig.SessionManager, signIn)
|
||||
@@ -375,6 +412,7 @@ func isActRead(name string) bool {
|
||||
func (f *actFixture) rows() actRows {
|
||||
f.t.Helper()
|
||||
q := dbtest.On(f.t, f.w.DB)
|
||||
acts := f.actIDs()
|
||||
return actRows{
|
||||
Tiers: textLines(q.Rows(`SELECT l.plan_ladder_id::text AS ladder, t.rank, t.product_id::text AS product
|
||||
FROM core.plan_ladder_tiers t JOIN core.plan_ladders l USING (plan_ladder_id)
|
||||
@@ -397,9 +435,43 @@ func (f *actFixture) rows() actRows {
|
||||
FROM core.pool_provision_transitions WHERE pool_id = ANY($1) ORDER BY transition_id`, pq.Array(f.poolIDs))),
|
||||
Entitlements: textLines(q.Rows(`SELECT pool_id::text AS pool, resource_key, resource_limit::text AS resource_limit
|
||||
FROM core.numeric_entitlements WHERE pool_id = ANY($1) ORDER BY pool_id, resource_key`, pq.Array(f.poolIDs))),
|
||||
Acts: textLines(q.Rows(`SELECT a.act_id::text AS act, a.cause, a.actor_type,
|
||||
coalesce(a.actor_person_id::text, 'none') AS actor, coalesce(a.note, 'none') AS note,
|
||||
coalesce(a.request_id, 'none') AS request_id
|
||||
FROM core.acts a WHERE a.act_id = ANY($1) ORDER BY a.act_id`, pq.Array(acts))),
|
||||
ActDetails: textLines(q.Rows(`SELECT act_id::text AS act, cause,
|
||||
coalesce(plan_ladder_id::text, 'none') AS ladder, coalesce(plan_ladder_name, 'none') AS ladder_name,
|
||||
coalesce(product_id::text, 'none') AS product, coalesce(product_name, 'none') AS product_name,
|
||||
coalesce(previous_product_id::text, 'none') AS previous_product,
|
||||
coalesce(previous_product_name, 'none') AS previous_product_name,
|
||||
coalesce(org_type, 'none') AS org_type, coalesce(org_type_name, 'none') AS org_type_name,
|
||||
coalesce(previous_plan_ladder_id::text, 'none') AS previous_ladder,
|
||||
coalesce(previous_plan_ladder_name, 'none') AS previous_ladder_name,
|
||||
coalesce(disposition, 'none') AS disposition, coalesce(retried_act_id::text, 'none') AS retried
|
||||
FROM core.plan_act_details WHERE act_id = ANY($1) ORDER BY act_id`, pq.Array(acts))),
|
||||
}
|
||||
}
|
||||
|
||||
// actIDs are the acts that touched the fixture's ladders and org types, oldest
|
||||
// first (an id is a UUIDv7). The normalizer names them act 1, 2 and so on, so
|
||||
// a file reads the same on every run.
|
||||
func (f *actFixture) actIDs() []string {
|
||||
f.t.Helper()
|
||||
ids := dbtest.On(f.t, f.w.DB).Strings(`SELECT act_id::text FROM core.plan_act_details
|
||||
WHERE plan_ladder_id = ANY($1) OR org_type = ANY($2) ORDER BY act_id`,
|
||||
pq.Array(f.ladderIDs()), pq.Array(f.orgTypes))
|
||||
if f.namedActs == nil {
|
||||
f.namedActs = map[string]bool{}
|
||||
}
|
||||
for i, id := range ids {
|
||||
if !f.namedActs[id] {
|
||||
f.norm.Name("act", strconv.Itoa(i+1), id)
|
||||
f.namedActs[id] = true
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ladderIDs are the ids of the fixture's ladders.
|
||||
func (f *actFixture) ladderIDs() []string {
|
||||
ids := make([]string, len(f.ladders))
|
||||
@@ -586,6 +658,20 @@ var planActScenarios = []struct {
|
||||
f.floored("Holder", f.orgType("T", &main))
|
||||
return f.act(actDeleteTier, nil, actOnTier(main, basic)...)
|
||||
}},
|
||||
{"delete-suspended-holder", func(f *actFixture) actGolden {
|
||||
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
|
||||
f.w.Price(plus, 200)
|
||||
main := f.ladder("Main", basic, plus)
|
||||
f.w.Suspend(f.w.Subscribe(f.org("Holder", f.orgType("T", &main)), world.SubOpts{}, plus))
|
||||
return f.act(actDeleteTier, nil, actOnTier(main, plus)...)
|
||||
}},
|
||||
{"delete-org-types-unread", func(f *actFixture) actGolden {
|
||||
solo := f.product("Solo", 1)
|
||||
main := f.ladder("Main", solo)
|
||||
f.orgType("T", &main)
|
||||
f.refuse.actOrgTypes = true
|
||||
return f.act(actDeleteTier, nil, actOnTier(main, solo)...)
|
||||
}},
|
||||
{"delete-begin-fails", func(f *actFixture) actGolden {
|
||||
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
|
||||
main := f.ladder("Main", basic, plus)
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Granted> | plan_golden_seats | 7"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 8"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -23,12 +23,12 @@
|
||||
"delete": {
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: Failed to remove tier; see server logs.",
|
||||
"[role=\"alert\"]: Failed to remove tier; see server logs."
|
||||
"Hx-Trigger showErrorToast: Another change collided with this one; try again.",
|
||||
"[role=\"alert\"]: Another change collided with this one; try again."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"failed to begin tier delete tx\" error=\"take materialization rendezvous: ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)\""
|
||||
"level=ERROR msg=\"tier delete failed\" request_id=<uuid:1> step=begin ladder_id=<ladder:Spare> product_id=<product:Top> error=\"begin: take materialization rendezvous: ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)\""
|
||||
]
|
||||
},
|
||||
"remove": {
|
||||
@@ -69,23 +69,29 @@
|
||||
],
|
||||
"grants": [
|
||||
"grant_id | org | product | grant_reason | granted_by | extends | description | status",
|
||||
"<uuid:1> | <org:Holder> | <product:Basic> | default | none | none | | active"
|
||||
"<uuid:2> | <org:Holder> | <product:Basic> | default | none | none | | active"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
"<uuid:2> | <pool:Holder> | <uuid:1> | active"
|
||||
"<uuid:3> | <pool:Holder> | <uuid:2> | active"
|
||||
],
|
||||
"positions": [
|
||||
"provision | ladder | product | status | ended",
|
||||
"<uuid:2> | <ladder:Main> | <product:Basic> | active | false"
|
||||
"<uuid:3> | <ladder:Main> | <product:Basic> | active | false"
|
||||
],
|
||||
"transitions": [
|
||||
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
|
||||
"<pool:Holder> | <uuid:2> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
"<pool:Holder> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,5 +32,11 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@
|
||||
"<pool:Holder> | plan_golden_seats | 1",
|
||||
"<pool:Planless> | plan_golden_seats | 5",
|
||||
"<pool:Granted> | plan_golden_seats | 7"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 5"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 0"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: Failed to remove tier; see server logs.",
|
||||
"[role=\"alert\"]: Failed to remove tier; see server logs."
|
||||
"Hx-Trigger showErrorToast: Failed to remove tier. Reference: <uuid:1>.",
|
||||
"[role=\"alert\"]: Failed to remove tier. Reference: <uuid:1>."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"failed to begin tier delete tx\" error=\"sql: database is closed\""
|
||||
"level=ERROR msg=\"tier delete failed\" request_id=<uuid:1> step=begin ladder_id=<ladder:Main> product_id=<product:Plus> error=\"begin: sql: database is closed\""
|
||||
],
|
||||
"statements": [],
|
||||
"rows": {
|
||||
@@ -32,6 +32,12 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -39,6 +40,14 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | tier_removed | person | <person:Operator> | none | <uuid:1>"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
|
||||
"<act:1> | tier_removed | <ladder:Main> | Main | <product:Pro> | Pro | none | none | none | none | none | none | none | none"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -6,7 +6,12 @@
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [],
|
||||
"statements": [],
|
||||
"statements": [
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"rollback"
|
||||
],
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
@@ -36,6 +41,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [],
|
||||
"statements": [],
|
||||
"statements": [
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"rollback"
|
||||
],
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
@@ -30,6 +35,12 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: Failed to remove tier. Reference: <uuid:1>.",
|
||||
"[role=\"alert\"]: Failed to remove tier. Reference: <uuid:1>."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"tier delete failed\" request_id=<uuid:1> step=\"read the organization types\" ladder_id=<ladder:Main> product_id=<product:Solo> error=\"read the organization types: read failed\""
|
||||
],
|
||||
"statements": [
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"rollback"
|
||||
],
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Main> | 0 | <product:Solo>"
|
||||
],
|
||||
"defaults": [
|
||||
"org_type | default_ladder",
|
||||
"<orgtype:T> | <ladder:Main>"
|
||||
],
|
||||
"grants": [
|
||||
"grant_id | org | product | grant_reason | granted_by | extends | description | status"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status"
|
||||
],
|
||||
"positions": [
|
||||
"provision | ladder | product | status | ended"
|
||||
],
|
||||
"transitions": [
|
||||
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason"
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: This tier has 1 live holder(s); Remove now previews the reconciled removal before committing.",
|
||||
"[role=\"alert\"]: This tier has 1 live holder(s); Remove now previews the reconciled removal before committing."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [],
|
||||
"statements": [
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"rollback"
|
||||
],
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Main> | 0 | <product:Basic>",
|
||||
"<ladder:Main> | 1 | <product:Plus>"
|
||||
],
|
||||
"defaults": [
|
||||
"org_type | default_ladder",
|
||||
"<orgtype:T> | <ladder:Main>"
|
||||
],
|
||||
"grants": [
|
||||
"grant_id | org | product | grant_reason | granted_by | extends | description | status"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
"<uuid:1> | <pool:Holder> | none | suspended"
|
||||
],
|
||||
"positions": [
|
||||
"provision | ladder | product | status | ended",
|
||||
"<uuid:1> | <ladder:Main> | <product:Plus> | suspended | false"
|
||||
],
|
||||
"transitions": [
|
||||
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
|
||||
"<pool:Holder> | <uuid:1> | <ladder:Main> | - | 1 | initiate | webhook | none | test fixture"
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 0"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -39,6 +40,14 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | tier_removed | person | <person:Operator> | none | <uuid:1>"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
|
||||
"<act:1> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | none | none"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -50,6 +50,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -50,6 +50,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -49,6 +49,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 5"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -54,6 +54,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 2"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 11"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 2"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Granted> | plan_golden_seats | 2"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:First> | plan_golden_seats | 1",
|
||||
"<pool:Second> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,12 @@
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 2",
|
||||
"<pool:Granted> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
"<pool:AtNew> | plan_golden_seats | 2",
|
||||
"<pool:Holder> | plan_golden_seats | 1",
|
||||
"<pool:Planless> | plan_golden_seats | 2"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 2"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 2"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,12 @@
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:First> | plan_golden_seats | 1",
|
||||
"<pool:Second> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -50,6 +50,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -52,6 +52,12 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -49,6 +49,14 @@
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | tier_removed | person | <person:Operator> | none | <uuid:5>"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
|
||||
"<act:1> | tier_removed | <ladder:Main> | Main | <product:Plus> | Plus | none | none | none | none | none | none | none | none"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -56,6 +56,12 @@
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:Holder> | plan_golden_seats | 1",
|
||||
"<pool:Granted> | plan_golden_seats | 2"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func (w *World) ReorderTiers(l Ladder, tiers ...Product) {
|
||||
func (w *World) RemoveTier(l Ladder, p Product) {
|
||||
w.TB.Helper()
|
||||
w.inMaterializingTx("remove "+p.Name+" from "+l.Name, func(tx *sql.Tx) error {
|
||||
held, err := entitlements.New(tx).CountActiveAttachmentsByTier(w.Ctx, entitlements.CountActiveAttachmentsByTierParams{
|
||||
held, err := entitlements.New(tx).CountLiveAttachmentsByTier(w.Ctx, entitlements.CountLiveAttachmentsByTierParams{
|
||||
PlanLadderID: l.ID, ProductID: p.ID,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -633,9 +633,10 @@ Each table lists what is committed, what the operator sees and how the act is ru
|
||||
|---|---|---|---|
|
||||
| Begin: lock wait past 5 s, or deadlock | nothing | the contention refusal | resubmit |
|
||||
| Begin: other error | nothing | "Failed to remove tier. Reference: {request id}."; logged | resubmit |
|
||||
| Holder count read fails | nothing | "Failed to check active positions" (unchanged); logged | resubmit |
|
||||
| Holder count read fails | nothing | "Failed to remove tier. Reference: {request id}."; logged | resubmit |
|
||||
| Live holders (a stale page) | nothing | the existing "This tier has N live holder(s); ..." | use Preview removal |
|
||||
| Tier list read fails | nothing | "Failed to check the ladder's tiers" (unchanged); logged | resubmit |
|
||||
| Tier list read fails | nothing | "Failed to remove tier. Reference: {request id}."; logged | resubmit |
|
||||
| The product is no longer a tier of the ladder (a repeated delete) | nothing: no act, no renumber | the existing success answer | n/a |
|
||||
| Org types read fails | nothing (the guard fails closed) | "Failed to remove tier. Reference: {request id}."; logged | resubmit |
|
||||
| Last tier of a ladder an org type defaults to | nothing | the existing last-tier refusal | add another tier first |
|
||||
| Act, delete, renumber or commit: lock wait or deadlock | nothing | the contention refusal | resubmit |
|
||||
|
||||
@@ -39,14 +39,14 @@ Each numbered section below is one commit, and its items are that commit's parts
|
||||
|
||||
## 4. The act record and the tier delete on the runner
|
||||
|
||||
- [ ] 4.1 Add `internal/db/migrations/00027_acts.sql`: `core.acts` and `core.plan_act_details` with every constraint and the two detail indexes, a comment on each table (never updated), `core.record_plan_act` for every cause (the reached types are added by 00028), the grants of design.md "Schema" including `core_reader`'s column grants on `core.acts` without `note`, and a Down section. Add `'^core\.acts\.note$'` to `scripts/db-reader-role.sql`'s `personal` list, and list the two tables in `console-reader-columns.txt` with `core.acts.note` as `withhold` and every other column as `read`.
|
||||
- [ ] 4.2 Add the five `ActCause` constants to `internal/entitlements` and a test that compares them with `pg_get_constraintdef` of `chk_acts_cause` and `chk_plan_act_details_cause`. The list lands whole here because its mirror test needs every cause; four of them have no caller until tasks 5 to 7.
|
||||
- [ ] 4.3 Add `runPlanAct` (`internal/entitlements/plan_act_runner.go`): open the given kind, `find` the pools, lock them through `LockPools`, `reread` under the locks, `act`, commit; every failure comes back as a `*PlanActError` naming its step. Its input carries the constructors of the entitlements, billing and organization queriers (design D17); production passes the generated ones. Add the `RecordPlanAct` query. Add `LastTierRefusal`, the last-tier guard as a function that reads no row.
|
||||
- [ ] 4.4 Rewrite `DeleteTier` on the runner: inside the exclusive transaction, count the live holders (`active` and `suspended` positions, the removal's population), read the tiers and the org types defaulting to the ladder (a failed read refuses), apply `LastTierRefusal`, record the act (`tier_removed`, no disposition, names read inside), delete and renumber. `DeletePlanLadderTier` only parses, calls and renders: its reads before the transaction go; a lock wait or deadlock at any step answers "Another change collided with this one; try again." (#216); every failure is logged with its step. Ride-along (copy 51): its failure sentence "Failed to remove tier; see server logs." (`internal/server/operator_plan_ladders.go:821`) becomes "Failed to remove tier. Reference: {request id}." and the failure is logged through `logging.FromContext(r.Context())`. Ride-along: `CountActiveAttachmentsByTier` becomes `CountLiveAttachmentsByTier` (status `active` or `suspended`), and `CountActiveAttachmentsPerTier` also returns each tier's live count, which the tier page uses to choose between the confirmation modal and Preview removal (the column of active positions stays as it is); a tier held only by suspended positions is routed to Preview removal and refused by the plain delete.
|
||||
- [ ] 4.5 The test seam first: `OperatorPartialsConfig` gains `PlanActs`, an `entitlements.PlanActConfig` carrying the queriers' constructors the runner builds the act's queriers from (design D17), whose zero value means the generated ones; task 6.2 adds the request's limits to it. `TestPlanActsGolden`'s harness (`actFixture.prepare`) builds it with refusing wrappers, so a scenario can make a named statement fail inside the act's own transaction; a handler's querier no longer reaches the act. The fixture dump gains the act and detail sections, with act ids named by the normalizer and times dropped (design D25).
|
||||
- [ ] 4.6 Tests: role tests for per-schema-roles "Act record enclosure" on the two tables (`core_writer` cannot insert, update or delete; `PUBLIC` cannot call `core.record_plan_act`; a temporary table named `acts` is not written; `core_reader` cannot read `core.acts.note` and can read every other column; a catalog cause refuses under the shared rendezvous); constraint tests (an unknown cause, an incoherent actor, a note on a system act, a detail row of another cause, a detail shape that breaks its cause's rule); `TestDeletePlanLadderTier_*` for the guards that moved inside, including the guard refusing when the act's organization querier fails `ListOrgTypes` and the delete refusing a tier held only by a suspended position; a test of the tier page's routing for a tier held only by suspended positions.
|
||||
- [ ] 4.7 Goldens: every file under `internal/server/testdata/plan_acts/` gains the act and detail sections, empty except where an act now records. Behaviour changes on purpose in `delete-begin-fails.json` (the log line names the step and carries `request_id`; the toast and the alert read "Failed to remove tier. Reference: {request id}."), `delete-bottom-tier.json` and `delete-top-tier.json` (the act row), `delete-held-tier.json` (the holder count read inside), `delete-last-tier-of-a-default.json` (the guard read inside), `tier-change-during-conferral/a-tier-delete.json` (the act row) and `contention.json` (the delete answers the new refusal). New: `delete-org-types-unread.json` (refused; the tier remains) and `delete-suspended-holder.json` (refused: a live holder). The commit lists the files whose behaviour changed and says the rest gain only the empty sections.
|
||||
- [ ] 4.8 Docs: the tier delete's reads inside its transaction, in the "Tier add, tier removal, default change and reorder" section of `docs/database-locks.md`, and the delete's guards in `docs/plan-management.md`.
|
||||
- [x] 4.1 Add `internal/db/migrations/00027_acts.sql`: `core.acts` and `core.plan_act_details` with every constraint and the two detail indexes, a comment on each table (never updated), `core.record_plan_act` for every cause (the reached types are added by 00028), the grants of design.md "Schema" including `core_reader`'s column grants on `core.acts` without `note`, and a Down section. Add `'^core\.acts\.note$'` to `scripts/db-reader-role.sql`'s `personal` list, and list the two tables in `console-reader-columns.txt` with `core.acts.note` as `withhold` and every other column as `read`.
|
||||
- [x] 4.2 Add the five `ActCause` constants to `internal/entitlements` and a test that compares them with `pg_get_constraintdef` of `chk_acts_cause` and `chk_plan_act_details_cause`. The list lands whole here because its mirror test needs every cause; four of them have no caller until tasks 5 to 7.
|
||||
- [x] 4.3 Add `runPlanAct` (`internal/entitlements/plan_act_runner.go`): open the given kind, `find` the pools, lock them through `LockPools`, `reread` under the locks, `act`, commit; every failure comes back as a `*PlanActError` naming its step. Its input carries the constructors of the entitlements, billing and organization queriers (design D17); production passes the generated ones. Add the `RecordPlanAct` query. Add `LastTierRefusal`, the last-tier guard as a function that reads no row.
|
||||
- [x] 4.4 Rewrite `DeleteTier` on the runner: inside the exclusive transaction, count the live holders (`active` and `suspended` positions, the removal's population), read the tiers and the org types defaulting to the ladder (a failed read refuses), apply `LastTierRefusal`, record the act (`tier_removed`, no disposition, names read inside), delete and renumber. `DeletePlanLadderTier` only parses, calls and renders: its reads before the transaction go; a lock wait or deadlock at any step answers "Another change collided with this one; try again." (#216); every failure is logged with its step. Ride-along (copy 51): its failure sentence "Failed to remove tier; see server logs." (`internal/server/operator_plan_ladders.go:821`) becomes "Failed to remove tier. Reference: {request id}." and the failure is logged through `logging.FromContext(r.Context())`. Ride-along: `CountActiveAttachmentsByTier` becomes `CountLiveAttachmentsByTier` (status `active` or `suspended`), and `CountActiveAttachmentsPerTier` also returns each tier's live count, which the tier page uses to choose between the confirmation modal and Preview removal (the column of active positions stays as it is); a tier held only by suspended positions is routed to Preview removal and refused by the plain delete.
|
||||
- [x] 4.5 The test seam first: `OperatorPartialsConfig` gains `PlanActs`, an `entitlements.PlanActConfig` carrying the queriers' constructors the runner builds the act's queriers from (design D17), whose zero value means the generated ones; task 6.2 adds the request's limits to it. `TestPlanActsGolden`'s harness (`actFixture.prepare`) builds it with refusing wrappers, so a scenario can make a named statement fail inside the act's own transaction; a handler's querier no longer reaches the act. The fixture dump gains the act and detail sections, with act ids named by the normalizer and times dropped (design D25).
|
||||
- [x] 4.6 Tests: role tests for per-schema-roles "Act record enclosure" on the two tables (`core_writer` cannot insert, update or delete; `PUBLIC` cannot call `core.record_plan_act`; a temporary table named `acts` is not written; `core_reader` cannot read `core.acts.note` and can read every other column; a catalog cause refuses under the shared rendezvous); constraint tests (an unknown cause, an incoherent actor, a note on a system act, a detail row of another cause, a detail shape that breaks its cause's rule); `TestDeletePlanLadderTier_*` for the guards that moved inside, including the guard refusing when the act's organization querier fails `ListOrgTypes` and the delete refusing a tier held only by a suspended position; a test of the tier page's routing for a tier held only by suspended positions.
|
||||
- [x] 4.7 Goldens: every file under `internal/server/testdata/plan_acts/` gains the act and detail sections, empty except where an act now records. Behaviour changes on purpose in `delete-begin-fails.json` (the log line names the step and carries `request_id`; the toast and the alert read "Failed to remove tier. Reference: {request id}."), `delete-bottom-tier.json` and `delete-top-tier.json` (the act row), `delete-held-tier.json` (the holder count read inside), `delete-last-tier-of-a-default.json` (the guard read inside), `tier-change-during-conferral/a-tier-delete.json` (the act row) and `contention.json` (the delete answers the new refusal). New: `delete-org-types-unread.json` (refused; the tier remains) and `delete-suspended-holder.json` (refused: a live holder). The commit lists the files whose behaviour changed and says the rest gain only the empty sections.
|
||||
- [x] 4.8 Docs: the tier delete's reads inside its transaction, in the "Tier add, tier removal, default change and reorder" section of `docs/database-locks.md`, and the delete's guards in `docs/plan-management.md`.
|
||||
|
||||
## 5. The tier add on the runner
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ DECLARE
|
||||
'^core\.pool_provision_transitions\.reason$', -- copies an issued grant's description
|
||||
'^core\.(instance_settings|integration_config_overrides)\.updated_by$', -- the signed-in operator's email
|
||||
'^core\.entitlement_set_changes\.note$',
|
||||
'^core\.acts\.note$', -- an act's note is its person's own words
|
||||
'^domains\.name_rules\.note$',
|
||||
'^discourse\.user_links\.discourse_username$'
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user