Add integration delivery core: provider connections, desired-state

records, outbox, lifecycle requests, relay, push, read-back, and FedWiki
lifecycle workflows

- Add core.provider_connections table and unique provider connection per
  provider
- Add core.target_mappings table linking connections to resource keys
  and targets
- Add core.desired_state_records table storing desired entitlements per
  connection and recipient with generation and content hash
- Add core.observed_projections table for application-reported state per
  connection and recipient
- Add core.lifecycle_requests table recording lifecycle requests by
  instance and generation
- Add core.person_cut_offs table and setter function to record persons
  cut off everywhere
- Add core.desired_state_outbox table with triggers on entitlement,
  membership, organization, connection, mapping, and cut-off tables to
  write change notices
- Enforce constraints on recipients, generations, and statuses across
  tables
- Implement desired_state_content SQL function computing record content
  per recipient rules
- Implement relay workflow to claim outbox rows, expand to records,
  recompute records, and dispatch pushes
- Implement push workflow to push records, handle transport answers, and
  record push results
- Implement read-back workflow to page through application state, update
  projections, and raise records as needed
- Add lifecycle request writers with idempotent recording, finishing,
  and acknowledgment
- Add FedWiki integration lifecycle workflows for create, set status,
  swap, delete, and reconcile
- Add FedWiki web handlers and forms for lifecycle requests, including
  create form with instance and generation
- Add tests for schema, triggers, relay, push, read-back, lifecycle
  requests, FedWiki workflows, and operator pages
- Add documentation updates for provider integration, entitlements,
  identity, organizations, lifecycle, and FedWiki integration
- Add benchmarks for delivery and push workflows
- Ensure schema-per-provider for in-tree integrations; connector state
  lives in generic core tables keyed by connection
- Secure functions and triggers with SECURITY DEFINER and restrict
  core_writer role privileges accordingly
- Handle Temporal client integration for workflow execution and
  scheduling
- Provide a recording transport for integration tests
- Close Issues 32 and 33 by implementing provider connections and
  desired-state records with lifecycle requests and relay mechanisms
This commit is contained in:
2026-10-06 03:46:15 -05:00
parent d807446ef4
commit 23d5bad024
178 changed files with 39853 additions and 1299 deletions
+29
View File
@@ -6,6 +6,7 @@ package cmd
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
@@ -84,3 +85,31 @@ func ensureBootDataState(ctx context.Context, database *sql.DB, logger *slog.Log
}
return nil
}
// bindIntegrationConnections runs after ensureBootDataState has registered
// providers (provider-connections, design D28): it looks up, once, the
// connection boot registration ensured for every integration whose manifest
// owns resource keys, fails when one is missing, and hands the identifier to
// an integration that caches it (integrations.ConnectionBinder) before its
// routes and workflows are registered. A missing row means registration and
// the database disagree, which no request should discover later.
func bindIntegrationConnections(ctx context.Context, database *sql.DB, integs []integrations.Integration) error {
q := integration.New(database)
for _, integ := range integs {
manifest := integ.Provider().ProviderManifest()
if len(manifest.ResourceKeys) == 0 {
continue
}
conn, err := q.GetConnectionByProvider(ctx, manifest.Key)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("provider %q owns resource keys but has no connection", manifest.Key)
}
if err != nil {
return fmt.Errorf("look up the connection of provider %q: %w", manifest.Key, err)
}
if binder, ok := integ.(integrations.ConnectionBinder); ok {
binder.BindConnection(conn.ConnectionID)
}
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package cmd
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
)
// bindTestIntegration is a made-up integration owning one key, which caches
// the connection identifier it is handed.
type bindTestIntegration struct {
manifest integration.Manifest
bound *string
}
func (b bindTestIntegration) Key() string { return b.manifest.Key }
func (b bindTestIntegration) MigrationSource() db.MigrationSource { return db.MigrationSource{} }
func (b bindTestIntegration) Provider() integration.ProviderSource {
return manifestOf(b.manifest)
}
func (b bindTestIntegration) BindConnection(id string) { *b.bound = id }
type manifestOf integration.Manifest
func (m manifestOf) ProviderManifest() integration.Manifest { return integration.Manifest(m) }
// TestBindIntegrationConnections covers task 4.5's wiring: after
// registration, a key-owning integration that caches its connection gets the
// identifier registration ensured, and boot fails naming the provider when
// that provider's connection row is missing.
func TestBindIntegrationConnections(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := db.RunMigrations(database, migrate.Sources()); err != nil {
t.Fatalf("run migrations: %v", err)
}
ctx := context.Background()
provider := fmt.Sprintf("bindt%d", time.Now().UnixNano())
key := provider + "_seats"
// The connection's identifier, kept for the cleanup: the test deletes the
// connection row itself, and the outbox row its insert wrote must not
// outlive the test as a pending row naming no connection.
var connectionID string
t.Cleanup(func() {
if _, err := database.ExecContext(ctx,
`DELETE FROM core.desired_state_outbox WHERE subject_kind = 'connection' AND subject_id::text = $1`,
connectionID); err != nil {
t.Errorf("cleanup: %v", err)
}
for _, q := range []string{
`DELETE FROM core.provider_connections WHERE provider = $1`,
`DELETE FROM core.resource_keys WHERE provider = $1 OR resource_key = $1 || '_seats'`,
`DELETE FROM core.providers WHERE provider = $1`,
} {
if _, err := database.ExecContext(ctx, q, provider); err != nil {
t.Errorf("cleanup: %v", err)
}
}
})
if _, err := database.ExecContext(ctx,
`INSERT INTO core.resource_keys (resource_key, display_name, unit, kind) VALUES ($1, 'Bind Key', 'unit', 'boolean')`,
key); err != nil {
t.Fatal(err)
}
var bound string
integ := bindTestIntegration{
manifest: integration.Manifest{
Key: provider,
Kind: integration.KindProvisioning,
DisplayName: "Bind Test",
ResourceKeys: []string{key},
Recipients: map[string]integration.RecipientKind{key: integration.RecipientPerson},
},
bound: &bound,
}
if err := integration.RegisterProviders(ctx, database, []integration.ProviderSource{integ.Provider()}); err != nil {
t.Fatalf("RegisterProviders: %v", err)
}
if err := database.QueryRowContext(ctx,
`SELECT connection_id FROM core.provider_connections WHERE provider = $1`, provider).Scan(&connectionID); err != nil {
t.Fatal(err)
}
if err := bindIntegrationConnections(ctx, database, []integrations.Integration{integ}); err != nil {
t.Fatalf("bindIntegrationConnections: %v", err)
}
if bound != connectionID {
t.Errorf("the integration cached %q, want its connection %q", bound, connectionID)
}
// The connection row removed after registration: boot fails, naming the
// provider.
if _, err := database.ExecContext(ctx, `DELETE FROM core.provider_connections WHERE provider = $1`, provider); err != nil {
t.Fatal(err)
}
err = bindIntegrationConnections(ctx, database, []integrations.Integration{integ})
if err == nil || !strings.Contains(err.Error(), provider) {
t.Fatalf("error = %v, want one naming provider %s", err, provider)
}
}
+37
View File
@@ -30,6 +30,7 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
wfBilling "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/desiredstate"
wfDomains "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/domains"
wfEnt "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/entitlements"
wfMaintenance "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/maintenance"
@@ -178,6 +179,13 @@ var startCmd = &cobra.Command{
logger.Error("boot data state", slog.Any("error", err))
os.Exit(1)
}
// Each key-owning provider's connection, ensured by registration
// above, is looked up once and cached by the integrations that act
// on it, before their routes and workflows are registered below.
if err := bindIntegrationConnections(ctx, database, integs); err != nil {
logger.Error("bind integration connections", slog.Any("error", err))
os.Exit(1)
}
// Collect HTTP route mounts, Temporal workflow/activity
// registrations, and UI (template/static) mounts from installed
@@ -285,6 +293,9 @@ var startCmd = &cobra.Command{
workerCfg := workflows.DefaultWorkerConfig(database, logger)
workerCfg.WorkflowProviders = workflowProviders
workerCfg.DomainsConnectTarget = domainsConnectTarget
// No transport serves a connection until integration-contract-wire
// adds one: records are computed and stored, nothing is pushed.
workerCfg.Transports = integration.NoTransports{}
worker, err := workflows.NewWorker(temporalClient, workerCfg)
if err != nil {
@@ -326,6 +337,27 @@ var startCmd = &cobra.Command{
logger.Info("started entitlement recompute poller workflow")
}
// Start the desired-state relay, which turns the triggers' outbox
// rows into records and pushes them where a transport serves the
// connection. USE_EXISTING, so a second console process joins the
// running relay.
if err := desiredstate.StartRelay(ctx, temporalClient, workerCfg.TaskQueue); err != nil {
logger.Error("failed to start the desired-state relay", slog.Any("error", err))
} else {
logger.Info("started the desired-state relay")
}
// Read-back schedules exist only for a connection a transport
// serves; a leftover one of any other connection is paused.
if err := desiredstate.EnsureReadBackSchedules(ctx, database, workerCfg.Transports,
desiredstate.TemporalReadBackSchedules{
Client: temporalClient,
Logger: logger,
PauseLeftover: workflows.PauseLeftoverSchedule,
ResumeDormant: workflows.ResumeDormantSchedule,
}, desiredstate.ReadBackInterval, logger); err != nil {
logger.Error("failed to set up desired-state read-back schedules", slog.Any("error", err))
}
// The Stripe environment check (stripe-environment-stamp D3).
// A Stripe id resolves in the environment that made it and in
// no other, so a key that moved leaves every mapping pointing
@@ -404,6 +436,11 @@ var startCmd = &cobra.Command{
}
} else {
logger.Warn("Temporal not configured - integration workflows and scheduled jobs will be unavailable")
// The desired-state triggers keep writing rows; without Temporal
// no relay runs, so boot reports the backlog it leaves.
if err := desiredstate.LogBacklog(ctx, database, logger); err != nil {
logger.Error("failed to read the desired-state outbox backlog", slog.Any("error", err))
}
}
// Create server config. stripeDashboardURL is Stripe-specific plumbing
+44 -3
View File
@@ -244,6 +244,9 @@ func (providerSource) ProviderManifest() integration.Manifest {
Operations: []integration.Operation{integration.OpCreate, integration.OpSetStatus, integration.OpDelete, integration.OpList, integration.OpDescribe},
States: []integration.State{integration.StateActive, integration.StateReadonly, integration.StateArchived}, // required iff OpSetStatus
ResourceKeys: []string{"fedwiki_sites"}, // each MUST be "<key>_…"
Recipients: map[string]integration.RecipientKind{ // one entry per owned key
"fedwiki_sites": integration.RecipientOrganization,
},
}
}
```
@@ -258,6 +261,23 @@ state, and declaring states without `OpSetStatus` is rejected; every
`ResourceKeys` entry must be `"<key>_"`-prefixed; duplicate provider keys
across providers are rejected.
**Declaring each key's recipient** — `Recipients` maps every owned key to
the party the console delivers it to through a connection:
`integration.RecipientOrganization` when the application acts for an
organization (FedWiki counts sites per organization's pool), or
`integration.RecipientPerson` when it acts for each person whose
organizations hold the key (Discourse puts each such person in a forum
group). Registration fails boot, naming the provider and the key, when an
owned key has no entry, when an entry names a key the provider does not
own, or when a value is neither constant. A person-recipient key must be
boolean: a person's record takes the union of what their organizations
hold, and a union of two numeric limits has no single meaning, so
`core.resource_keys` refuses the stamp and boot fails. Registration stamps
the recipient onto the key row with the owner, and inserts one approved
connection for your provider the first time it owns a key. A later deploy
that changes a key's owner or recipient re-approves that connection, so the
core relay recomputes its records.
**Registering it at boot** requires no hand-maintained list: `cmd/start.go`
collects `integ.Provider()` from every entry in `integrations.All()` and
calls `integration.RegisterProviders` once, after migrations have run (so the
@@ -486,8 +506,23 @@ it is the only place an integration is type-asserted against all of them.
## 5. Dispatch transport: capability, not transport
The manifest describes lifecycle **verbs**; it does not mandate how a
mutating verb (`create`/`set_status`/`delete`) is actually dispatched.
**Desired state is not yours to dispatch.** What the console wants an
application to hold for each recipient, the entitlements your owned keys
carry, is delivered by core: database triggers write a row into
`core.desired_state_outbox` in the same transaction as every change to a
delivered value, and a core relay (`internal/workflows/desiredstate/`)
recomputes the affected desired-state records and sends each one through
its connection's transport. Do not write a drainer for those changes in
your integration's tree, and do not insert into
`core.desired_state_outbox`; your writer role cannot. No transport exists
yet, so the relay keeps your connection's records current and sends
nothing, and FedWiki and Discourse still deliver through their own
workflows. The [Providers and Integrations card](models/provider-integration.md)
describes the records, the triggers and the relay.
The rest of this section is about lifecycle **verbs**. The manifest
describes them; it does not mandate how a mutating verb
(`create`/`set_status`/`delete`) is actually dispatched.
FedWiki dispatches directly through its own Temporal workflows. Stripe
dispatches through the transactional outbox (`core.outbox`), whose drainer
is itself a Temporal workflow (`PollIntegrationOutbox`, registered via the
@@ -524,7 +559,13 @@ Use this decision rule when designing a new integration's mutating verbs:
orchestrates external calls and database writes as a single unit inside
Temporal itself (FedWiki's create/set-status/delete/sync workflows call
FarmManager and write `fedwiki.sites` in the same workflow) — **dispatch
directly** via your own `RegisterWorkflows`-registered workflows.
directly** via your own `RegisterWorkflows`-registered workflows. When a
person starts the operation, record it first as a lifecycle request
through `internal/integration`'s writers (`RecordLifecycleRequest` in the
same transaction as your own rows, then start the workflow under the
request's workflow id, and finish the request from the workflow), as
FedWiki does: the handler then answers when the workflow starts, and a
repeated submission is answered from the request's record.
Forcing every integration onto one shape buys uniformity at the cost of the
worse fit for whichever shape doesn't match: pure-Temporal dispatch for
+7
View File
@@ -42,6 +42,13 @@ Base URL: `http://admin.localtest.me` (local dev)
| `DELETE` | `/plugin/farmmanager/sites/:domain` | Soft-delete (deactivate) a site |
| `DELETE` | `/plugin/farmmanager/sites/:domain?hard=true` | Permanently delete a site |
The console requires `wiki-plugin-farmmanager` 0.4.1 or later. When a
create fails, the console reads `GET /plugin/farmmanager/sites/:domain` and
compares the owner the farm records for the domain with the member who
asked, before it releases the name or the member's site slot. A farm whose
plugin lacks that route answers 404 for every domain, so the console
releases the name even when the farm went on to create the site.
`PATCH` sets a site's status. The API has no rename and no owner
reassignment, so changing a site's domain or its farm-side owner means
deleting the site and creating another one, which loses the site's page
+1 -1
View File
@@ -17,7 +17,7 @@ The catalog was motivated by a 2026-07 inventory of the console's domain models,
- [Plan Ladders and Transitions](plan-ladders-transitions.md) — ranked tiers, who occupies which rung, the append-only audit, and the conferral functions as sole writers.
- [Entitlements](entitlements.md) — resource keys, sets and rules, grants as decrees, provisions as delivery, and the materializer.
- [Resource Pools](resource-pools.md) — the per-organization container: workspace assignments, the two resolution directions, and pool-grain usage.
- [Providers and Integrations](provider-integration.md) — the registry, boot-time manifests, resource-key ownership, and the shared substrate.
- [Providers and Integrations](provider-integration.md) — the registry and its connections, boot-time manifests, resource-key ownership, desired-state delivery, and the shared substrate.
- [Domains Registry](domains-registry.md) — claims as subtree ownership, placements as the only thing that serves, and the anti-abuse ledger.
- [Identity, Organizations, and Workspaces](identity-organization-workspace.md) — users vs persons, the containment ladder, org types as policy, and first-login provisioning.
+2
View File
@@ -39,6 +39,7 @@ Database objects sit in the `core` schema; baseline DDL in `internal/db/migratio
| `core.resource_pools`, `core.pool_assignments` | The per-organization container entitlements materialize onto, and its links to workspaces | `00001_init.sql` |
| `core.numeric_entitlements`, `core.numeric_entitlement_contributions`, `core.numeric_entitlement_usage` | The materialized numeric limits, the per-provision contribution ledger explaining each limit, and the usage counters consumers increment | `00001_init.sql` |
| `core.boolean_entitlements` | Materialized on/off capabilities: `granted` true while at least one active provision carries the rule; rows lapse to false but are never deleted, so an absent row always means never-conferred | `00007_boolean_entitlements.sql` |
| Desired-state triggers on the two materialized tables | Write a notice into `core.desired_state_outbox` when a delivered value changes (invariant 17) | `00024_integration_delivery_core.sql` |
| The five conferral functions | The sole writers of the provision tables (verbs glossed in the ladders card), raising named error codes the conferral boundary translates | `00005_doc41_conferral_functions.sql`; `00023_grant_resumption.sql` replaces `core.confer` |
| Grant-lineage guards | A CHECK rejects self-extension; two triggers reject cross-organization lineage and freeze `extends_grant_id` after insert | `00001_init.sql` |
| Materializer | `MaterializePoolEntitlements`: full in-transaction re-evaluation of a pool's active provisions into limits, contributions, and boolean grants | `internal/entitlements/materialize.go` |
@@ -70,6 +71,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
14. **[db]** Every rule carries a reduction policy, NOT NULL and CHECK-constrained to `block`, `defer`, `clamp` or `force_reduce`, defaulting to `defer`. It is meaningful for limit rules and inert for boolean ones.
15. **[db-code]** Rule changes reach existing pools: `core.commit_rule_change` is the only path that writes a rule, and it recomputes every pool carrying the set, synchronously up to the cap and through obligations beyond it, filing one act row and its effects as it goes.
16. **[db]** `grants.resumes_after_supersession` is NOT NULL and defaults to false, and a CHECK (`chk_grants_default_never_resumes`) keeps it false on every `default` grant, since the floor restores the default by minting a fresh grant. It and the note (`description`) are the grant columns an operator changes in place apart from revocation: the Manage action writes those two columns and nothing else when the submitted Valid until is unchanged, and when Valid until changes it writes a successor grant carrying the submitted mark and note instead. When the mark is read, and what a resumed grant does, is the ladders card's invariant 18.
17. **[db-code]** A change to a stored entitlement's delivered values writes a desired-state outbox row in the same transaction. Row-level triggers on `core.boolean_entitlements` and `core.numeric_entitlements` write one row naming the pool and the key when a boolean's `granted` flips, when a numeric `resource_limit` changes, or when either row's pool or key moves; a lapsed boolean and a limit that is not positive (zero, or the -1 the data model names unlimited, whose delivery is forge issue #164) count as not held, so inserting or deleting one writes nothing. An idempotent materialization rewrites values that are already stored, which changes no delivered value, so it writes no row; a dry run writes no entitlement row and therefore no notice; and a rule change that refuses a pool is refused whole, so no row names any pool it materialized. The triggers read no other table, so the materializer's lock order gains no step. What the rows feed, desired-state records per connection and recipient, is the [Providers and Integrations card](provider-integration.md)'s subject.
## Dimensions
@@ -16,6 +16,8 @@ Containment runs person → **organization** → **workspaces**. The organizatio
Two role systems exist in parallel, and only one of them does anything. The database roles — memberships, role assignments, seeded permission arrays — are written at signup and read by nothing for authorization. The operative gates are the **IdP session roles**: member surfaces are scoped by the session's identity trio (person, organization, workspace), and every operator route requires the IdP-issued `operator-member` role. Separately from all member tenancy, boot ensures a singleton **System tenant** — a synthetic organization with a non-loginable owner — that holds ownerless resources like operator-root domain claims.
A person can also be **cut off everywhere**: the recorded fact that the person's desired-state records at every connection carry no entitlements, whatever their organizations hold. A cut-off is a row in `core.person_cut_offs`, opened and closed only through one database function, which no console page calls yet, and it is separate from `persons.status`, which describes the person's business identity. A cut-off changes only the person's own records; the records of the organizations they belong to are unaffected. (Desired-state records, connections and the rest of the delivery vocabulary are defined in the [Providers and Integrations card](provider-integration.md).)
Vocabulary reused: resource pool and pool assignment ([Resource Pools](resource-pools.md)), billing account ([Payments and Billing](payments-billing.md)), conferral and grant ([Product Catalog](product-catalog.md) and [Entitlements](entitlements.md)), plan ladder and rank-0 tier ([Plan Ladders and Transitions](plan-ladders-transitions.md)), operator-root claims ([Domains Registry](domains-registry.md)).
## Where this lives
@@ -26,9 +28,11 @@ All tables sit in the `core` schema (`internal/db/migrations/00001_init.sql`; se
|---|---|---|
| `core.users` | The IdP cache: unique OIDC subject, last-login tracking | `00001_init.sql` |
| `core.persons` | The stable anchor everything references; linked one-to-one to users | `00001_init.sql` |
| `core.organizations` | Organizations: a display name that need not be unique, a nullable unique `key` (the row's declarative address; NULL on every organization the console creates for a member), a required owner person, a type, and a free-text status column born `active` that nothing evaluates (the operator list renders it as plain text, never a badge, so the unread column implies no lifecycle it doesn't have; the unchecked-status gap is forge issue #54) | `00001_init.sql`; `00012_entity_keys.sql` |
| `core.organizations` | Organizations: a display name that need not be unique, a nullable unique `key` (the row's declarative address; NULL on every organization the console creates for a member), a required owner person, a type, and a free-text status column born `active` that no console path changes after creation. Core reads the status when it computes desired-state records, as Discourse's desired-member query already does: an organization whose status is not `active` delivers nothing through any connection. The operator list still renders the status as plain text, never a badge, because a badge would present a lifecycle the console does not run (the unchecked-status gap is forge issue #54) | `00001_init.sql`; `00012_entity_keys.sql` |
| `core.org_types` | Policy per type: display fields, `is_active`, `is_reserved`, and the default plan ladder that drives signup grants | `00001_init.sql`; seeds in `00003` |
| `core.org_members` | Baseline belonging: one row per (organization, person) with exactly one role, born active — no invited or pending state exists | `00001_init.sql` |
| `core.org_members` | Baseline belonging: one row per (organization, person) with exactly one role, born active — no invited or pending state exists. Core reads the membership's status when it computes a person's desired-state records: only an active membership in an active organization delivers | `00001_init.sql` |
| Desired-state triggers on `core.organizations` and `core.org_members` | Write a notice into `core.desired_state_outbox` when a membership's status, organization or person changes, or an organization's status, owner or type changes (invariant 17) | `00024_integration_delivery_core.sql` |
| `core.person_cut_offs`, `core.set_person_cut_off` | The cut-off history (one open row per person who is cut off everywhere) and the one function that opens or closes a row; no production code calls it yet | `00024_integration_delivery_core.sql` |
| `core.roles`, `core.role_assignments` | Role definitions (with permission arrays nothing reads) and scoped refinements (only the owner self-assignment is ever written). `role_name` is a key: unique among system roles and unique per organization among custom roles, both ignoring case | `00001_init.sql`; `00003`; `00014_provider_column_roles_uniqueness.sql` |
| `core.workspaces` | Working surfaces, name-unique per organization among live rows, carrying a nullable `key` unique within the organization, with a free-text status born `active` (read by the switch guard, invariant 16, and by the name index's predicate) | `00001_init.sql`; `00012_entity_keys.sql` |
| First-login provisioning | `AutoProvision`: the single-transaction signup chain | `internal/provisioning/provisioning.go` |
@@ -62,6 +66,8 @@ An invariant is a rule the model guarantees everywhere; code that would break on
14. **[app]** Operator access is gated solely by the IdP role `operator-member`, captured into the session at login — database roles play no part, and role changes take effect only on re-login.
15. **[app]** Role extraction unions all four claim locations an IdP may use, deduplicated, with per-client roles scoped to this app's client so another client's roles never leak in. A same-named role in any location grants.
16. **[app]** Member workspace operations are session-scoped: switching verifies the target belongs to the session's organization and carries status `active` before the handler rewrites the session; creation runs the same transactional routine signup uses, so a workspace cannot be created without its primary pool assignment — a failure rolls the whole creation back and the member sees an error, never a false success. (The switch guard is the only reader of workspace status, an interim check; what a suspended or archived workspace means is forge issue #140.)
17. **[db-code]** A change that can alter what a person's or an organization's desired-state records carry writes a desired-state outbox row in the same transaction, whatever code path, seed or hand-run SQL makes it. Triggers on `core.org_members` fire when an active membership is inserted or deleted, or when a membership's `status`, `org_id` or `person_id` changes; triggers on `core.organizations` fire when an organization's `status`, `owner_person_id`, `org_type` or `org_id` changes. When a membership moves to another person or an organization changes owner, the trigger also writes a row for the previous person, because nothing would otherwise link that person to the organization they left. Inserting an organization, renaming it and changing a membership's role write nothing.
18. **[db]** A person has at most one open cut-off, enforced by a partial unique index, and the application's role cannot insert, update or delete cut-off rows: `core.set_person_cut_off` is the only write path, it stamps who acted, and it closes a row rather than deleting it. Cutting off a person already cut off, or lifting a cut-off already lifted, changes nothing.
## Dimensions
+58 -15
View File
@@ -1,36 +1,65 @@
---
title: "Providers and Integrations"
audience: [developer]
summary: "How external services plug in: the provider registry, boot-time manifests, resource-key ownership, the shared webhook and outbox substrate, and runtime-managed configuration."
summary: "How external services plug in: the provider registry and its connections, boot-time manifests, resource-key ownership and recipients, desired-state delivery through triggers and a core relay, lifecycle requests, the shared webhook and outbox substrate, and runtime-managed configuration."
---
# Providers and Integrations
## Purpose
The console is a hub for external services, and this model is the socket they plug into. Two words carry the whole design and must not be swapped: a **provider** is the domain entity — a row in the registry recording an external service's kind, its declared lifecycle capabilities, and the resource keys it owns; an **integration** is the code unit — the tree under `internal/integrations/<key>/` that implements and registers exactly one provider. ("Extension" is a retired synonym.) Three providers exist today: FedWiki (wiki hosting, the full per-instance lifecycle), Discourse (forums, delivering converged group membership — a managed forum group whose member set the console recomputes and pushes), and Stripe (payments — its webhook and outbox workers already live in its integration tree, while checkout, fulfillment, and catalog sync remain core-side pending the tracked payments-provider seam). One wording rule for this card: "provider kind" always means the registry's four-way category, and "key kind" always means a resource key's boolean/numeric shape — the bare word "kind" is never used alone.
The console is a hub for external services, and this model is the socket they plug into. Two words carry the whole design and must not be swapped: a **provider** is the domain entity — a row in the registry recording one type of application and the code that serves it, its declared lifecycle capabilities, and the resource keys it owns; an **integration** is the code unit — the tree under `internal/integrations/<key>/` that implements and registers exactly one provider. ("Extension" is a retired synonym.) Beneath each provider, the registry records **connections**: one row per registered link between the console and one running application of the provider's type. The one-to-one pairing of provider and integration holds for in-tree integrations, the code compiled into the console; an outside connector, which arrives through a connection, has no integration tree. Three providers exist today: FedWiki (wiki hosting, the full per-instance lifecycle), Discourse (forums, delivering converged group membership — a managed forum group whose member set the console recomputes and pushes), and Stripe (payments — its webhook and outbox workers already live in its integration tree, while checkout, fulfillment, and catalog sync remain core-side pending the tracked payments-provider seam). One wording rule for this card: "provider kind" always means the registry's four-way category, and "key kind" always means a resource key's boolean/numeric shape — the bare word "kind" is never used alone.
The registry splits every provider's rows into two halves with different owners. **Capability rows** — the kind, the declared lifecycle verbs and states, the owned resource keys, the operator-surface path — are stamped from a typed **manifest** in code at every boot, idempotently. **Operational rows** — the provider's status, anything an operator edits — are database-canonical, and a boot re-registration never touches them. Everything registry-driven follows: the operator sidebar lists providers from the registry, the settings pages render from declared **config specs** (with operator overrides stored in one table and secrets never stored at all), and adding an integration is one code tree plus one line in a compile-time list — no core schema change.
Two shared substrate tables serve all providers: `core.webhook_events` stores verified inbound events, and `core.outbox` carries outbound actions that must commit atomically with a domain write. Dispatch transport is a capability choice, not a mandate: Stripe enqueues through the outbox because its actions must ride the caller's transaction; FedWiki and Discourse dispatch directly through Temporal because their workflows own the database writes end-to-end.
Two shared substrate tables serve all providers: `core.webhook_events` stores verified inbound events, and `core.outbox` carries outbound actions that must commit atomically with a domain write. For lifecycle commands, dispatch transport is a capability choice, not a mandate: Stripe enqueues through the outbox because its actions must ride the caller's transaction; FedWiki and Discourse dispatch directly through Temporal because their workflows own the database writes end-to-end.
Desired state is delivered differently, and always the same way. The console stores, per connection and recipient, the whole set of entitlements it wants that application to carry out. Database triggers write a row into a second outbox, `core.desired_state_outbox`, in the same transaction as every change to a value such a record carries, and a core relay turns those rows into recomputed records. The relay sends a record through its connection's transport when one exists. No transport exists yet, so FedWiki's and Discourse's connections hold records that the relay keeps current and nothing sends; both integrations still deliver through their own workflows and schedules.
The delivery vocabulary, used the same way in code, specs and the other cards:
- **Application**: one running system members use, such as one wiki farm or one forum.
- **Connector**: the code that carries out the console's desired state in an application; for an in-tree integration, the integration's own code.
- **Connection**: one registered link between the console and one running application, beneath the provider that records the application's type.
- **Recipient**: whoever a resource key is delivered to through a connection, a person or an organization, declared per key. This is not a grant's recipient, which can also be a billing account.
- **Desired-state record**: the whole set of entitlements the console wants one application to carry out for one recipient, stored per connection and recipient.
- **Generation**: the number on a record or a lifecycle request that rises with each change and orders them. A generation is never a count, because the console floors it by the clock in microseconds so that a number issued after a database restore never repeats one issued before it.
- **Desired-state outbox**: `core.desired_state_outbox`, whose rows say that something a record carries may have changed. It is not `core.outbox`, which carries actions for one provider.
- **Observed projection**: what an application reports holding for one recipient, with the generation it applied.
- **Read-back**: the console asking a connection what its application holds, page by page.
- **Lag**: a record whose generation the application has not applied, measured from when that generation was raised.
- **Lifecycle request**: one request a person made for one instance (create, change status, delete), recorded under the console's identifier for the instance and a generation.
- **Target mapping**: an operator's row that delivers one resource key through one group or role inside one application.
- **Cut off everywhere**: the recorded fact that a person's records at every connection carry no entitlements.
Vocabulary reused from the [Entitlements card](entitlements.md): resource key, key kind (boolean/numeric), boolean and numeric entitlements, usage counters. From [Payments and Billing](payments-billing.md): webhook event store, outbox, dead-letter. **Temporal** is the workflow engine the console runs background jobs on; **goose** is the SQL migration tool, and each migration stream keeps its own goose version-ledger table with its own 00001-up numbering.
## Where this lives
Registry and substrate tables sit in the `core` schema (`internal/db/migrations/00001_init.sql`); each integration owns its own schema and migration stream.
Registry, delivery and substrate tables sit in the `core` schema (`internal/db/migrations/00001_init.sql`; the delivery tables in `00024_integration_delivery_core.sql`); each in-tree integration owns its own schema and migration stream.
| Piece | What it is | Where |
|---|---|---|
| `core.providers` | The registry: `provider` (the key, and the primary key; named `slug` until `00014` renamed it to upstream's ratified name. Providers are a vocabulary, so the primary key is the key and no second `key` column is added; see `docs/identifiers.md`), provider kind, display name, the operator-surface path (the URL under which the provider's operator pages mount), and the operator-editable `status` with its `suspended_at`/`retired_at` timestamps | `00001_init.sql`; `00010_schema_hardening.sql`; `00014_provider_column_roles_uniqueness.sql` |
| `core.provider_operations` | One row per declared lifecycle verb per provider; the verb set is closed to five: create, set_status, delete, list, describe | `00001_init.sql` |
| `core.provider_states` | One row per declared lifecycle state per provider — deliberately per-provider vocabulary, format-checked but not a closed list | `00001_init.sql` |
| `core.resource_keys` (`provider`, `kind`) | Ownership and shape on the flat key namespace: `provider` names the owning provider or is empty for platform keys; the key kind is boolean or numeric | `00001_init.sql`; `00008_resource_key_kinds.sql` |
| `core.resource_keys` (`provider`, `kind`, `recipient`) | Ownership, shape and recipient on the flat key namespace: `provider` names the owning provider or is empty for platform keys; the key kind is boolean or numeric; `recipient` says whether a provider-owned key is delivered to a person or to an organization, and is empty on a platform key | `00001_init.sql`; `00008_resource_key_kinds.sql`; `00024_integration_delivery_core.sql` |
| `core.provider_connections` | One row per registered link to one running application, beneath its provider: a UUID identity, a display name, the operator-owned `status` (active, suspended or retired) and a separate `approval_state` (pending or approved) with the time of the last approval. A unique index allows one connection per provider | `00024_integration_delivery_core.sql` |
| `core.target_mappings` | One row per (connection, target): the resource key it delivers and the application's own identifier for the group or role (`target_ref`). Composite foreign keys allow only a key owned by the connection's provider. Nothing writes it yet | `00024_integration_delivery_core.sql` |
| `core.desired_state_records` | One row per (connection, recipient): the canonical JSON content, its stored hash, the generation and when it was raised, and the console's own push bookkeeping. A recipient who never held anything has no row, and a row is never deleted | `00024_integration_delivery_core.sql` |
| `core.observed_projections` | One row per (connection, recipient) for what the application last reported: the applied generation and hash, the observed entitlements, the last push answer, and a marker for a reported generation above the console's | `00024_integration_delivery_core.sql` |
| `core.desired_state_outbox` | Change notices: each row names a subject (a pool, an organization, a person or a connection) and optionally a key, and carries a status, an attempt count, a claim lease and a paging cursor. No foreign keys | `00024_integration_delivery_core.sql` |
| `core.person_cut_offs` | One open row per person who is cut off everywhere, closed rather than deleted when the cut-off is lifted, with who acted and when. Written only through `core.set_person_cut_off`, which no production code calls yet | `00024_integration_delivery_core.sql` |
| `core.lifecycle_requests` | One row per request a person made for one instance, addressed by (instance, generation): the operation, the workspace, identifier-only parameters, the outcome (`accepted`, `succeeded`, `declined` or `failed`) with a reason code, the workflow id, and when the member dismissed the outcome. A partial unique index allows one `accepted` request per instance | `00024_integration_delivery_core.sql` |
| Delivery functions | `core.desired_state_content` computes a record's content from the materialized entitlements under the recipient rules; `core.next_generation` and `core.generation_ceiling` raise and bound generations; `core.guard_desired_state_generation` refuses a falling generation or a content change without a rise; `core.requeue_desired_state` gives one failed recipient a retry row; one trigger function per trigger table writes notices through `core.insert_desired_state_notice` | `00024_integration_delivery_core.sql` |
| The desired-state relay | `ProcessDesiredStateOutbox` claims notices, expands them into the records they can affect, and recomputes each record once under a per-address advisory lock, within one record budget per tick across every row it claimed; the relay workflow runs it every tick, push workflows send one record at a time and end, leaving the record as lag, after two consecutive stale or conflict answers, and the read-back workflow and its per-connection schedule are registered and started only for a connection a transport serves, each page ending the run once the connection is no longer approved and active | `internal/workflows/desiredstate/` |
| The transport interface | `Transport` (`Apply` one record, `List` one page of what an application holds) and the lookup the relay asks for a connection's transport, which answers none for every connection today | `internal/integration/transport.go` |
| Lifecycle request writers | The only writers of `core.lifecycle_requests`: minting an instance identifier, recording a request and answering a repeat from its record, deleting an accepted request whose workflow start was refused, finishing, listing open and unacknowledged requests, and recording a dismissal | `internal/integration/lifecycle.go` |
| Discourse convergence | The set-shaped delivery in action: a sweep recomputes each managed forum group's desired member set from boolean entitlements and pushes adds/removes, refreshing an observed projection | `internal/integrations/discourse/workflows/`; `discourse.observed_group_members` |
| `core.webhook_events`, `core.outbox` | The shared inbound and outbound substrate (described in the Payments and Billing card); both deliberately carry no foreign keys, staying provider-generic | `00001_init.sql` |
| `core.integration_config_overrides` | Operator-set values for non-secret config keys, overlaid at boot over environment and defaults | `00009_integration_config_overrides.sql` |
| The provider contract | The manifest types every integration fills in: kind, operations, states, resource keys, operator-surface path | `internal/integration/contract.go` |
| Boot registration | Validates every manifest, upserts registry rows, reconciles verb and state sets, and stamps key ownership — one idempotent transaction | `internal/integration/registration.go` |
| Boot registration | Validates every manifest, upserts registry rows, reconciles verb and state sets, stamps key ownership and each owned key's recipient, and inserts one approved connection for every provider that owns a key — one idempotent transaction. When a deploy changes which provider owns a key or which recipient a key declares, registration re-approves that provider's existing connection, so the relay recomputes its records | `internal/integration/registration.go` |
| `integration.Enqueue` | The only supported write into `core.outbox`; takes the caller's transaction so the domain write and the queued action commit together | `internal/integration/outbox.go` |
| The integration interface and registry | The mandatory capability (key, manifest, migration source) and the compile-time list of integrations | `internal/integrations/integration.go`; `internal/integrations/registry.go` |
| Optional capability hooks | Routes, Temporal workflows, config specs, UI assets, and member-dashboard cards — each an optional interface the composition root discovers by type assertion | `cmd/start.go`; `docs/building-an-integration.md` |
@@ -38,7 +67,7 @@ Registry and substrate tables sit in the `core` schema (`internal/db/migrations/
| Operator settings surface | One generic handler renders `/operator/integrations/<key>/settings` for every integration from its declared config spec | `internal/server/operator_integration_settings.go` |
| Registry-driven operator surfaces | The sidebar's Integrations group lists provisioning-kind providers from the registry; the Integrations landing page deliberately lists *every* provider kind, so Stripe gets a row and a reachable settings link too | `internal/server/operator_pages.go` |
| Status presentation (ux-honest-surfaces) | The Integrations list and the landing surface's System region each show two distinct, honestly-labeled facts per provider — the operator-managed **registry status** (labeled so it cannot read as a health check; never changed by any connectivity probe) and a **configuration-readiness signal** (`configured`/missing-key names) derived from the *same* required-key resolution the settings page performs. Neither surface claims "connected" or "read live". The System region's outbox report also names, in its caption, which integrations' queued work it actually covers (today: Stripe's only) — an empty or draining outbox is never evidence that an uncovered integration is healthy | `internal/config.RequiredKeysUnresolved`; `internal/server.configurationReadiness` (shared by `operator_pages.go` and `operator_overview.go`) |
| The three manifests | FedWiki (provisioning, all five verbs, states, owns `fedwiki_sites`), Discourse (provisioning, read verbs only, owns `discourse_posting`), Stripe (payment, no verbs, no keys) | `internal/integrations/*/store/provider.go` |
| The three manifests | FedWiki (provisioning, all five verbs, states, owns `fedwiki_sites`, delivered to the organization), Discourse (provisioning, read verbs only, owns `discourse_posting`, delivered to the person), Stripe (payment, no verbs, no keys, and therefore no connection) | `internal/integrations/*/store/provider.go` |
| Per-integration schemas and migrations | Each integration's own schema, roles, and goose ledger with native numbering | `internal/integrations/*/store/migrations/` |
| Migration ordering | Core first, then core-module streams (domains), then integrations in interchangeable order | `internal/migrate/sources.go` |
| Authoring guide | The normative how-to for building an integration, including the dispatch decision rule and conformance notes | `docs/building-an-integration.md` |
@@ -54,26 +83,40 @@ An invariant is a rule the model guarantees everywhere; code that would break on
4. **[db]** A key's `provider` must name a registered provider or be empty — a foreign key enforces it. Boot validation additionally rejects (app-side) a provider key that would nest inside a platform key's first underscore-separated word: a provider named `external` would be refused because the platform key `external_domain_claims` starts with that word.
5. **[app]** Boot re-registration refreshes capability rows in one transaction and never clobbers the operational `status` column — operators own it.
6. **[app]** The operator *sidebar* shows only provisioning-kind providers; the Integrations *landing page* deliberately lists every provider kind, and any integration declaring config keys gets a reachable settings page regardless of kind — Stripe included.
7. **[app]** `integration.Enqueue` is the only path that *inserts* into `core.outbox`, and it rides the caller's transaction — a domain write and its queued action commit or roll back together. (The drainer and the operator retry surface update existing rows' statuses; they never insert.)
7. **[app]** for `core.outbox`, **[db]** for `core.desired_state_outbox` — each outbox has a fixed set of inserters. `integration.Enqueue` is the only path that *inserts* into `core.outbox`, and it rides the caller's transaction — a domain write and its queued action commit or roll back together. (The drainer and the operator retry surface update existing rows' statuses; they never insert.) Only Go code upholds that half. `core.desired_state_outbox` is held by the database instead: the application's role has no INSERT on it, so its only inserters are the row-level triggers, which run with their owner's rights, and `core.requeue_desired_state`, which writes a retry row for one person or organization whose recompute failed. The application's role may update only the relay's bookkeeping columns, so it cannot change which subject a row names. Each trigger fires only when a value a record carries changes, in the same transaction as the change, and a rewrite of an unchanged value writes no row:
- `core.boolean_entitlements`: `granted`, `pool_id`, `resource_key`; an insert or delete fires only for a granted row.
- `core.numeric_entitlements`: `resource_limit`, `pool_id`, `resource_key`; an insert or delete fires only for a limit above zero.
- `core.org_members`: `status`, `org_id`, `person_id`; an insert or delete fires only for an active membership.
- `core.organizations`: `status`, `owner_person_id`, `org_type`, `org_id`, on update only.
- `core.provider_connections`: `status`, `approval_state`, `approved_at`, `provider`, `connection_id`; an insert fires only for an approved connection.
- `core.target_mappings`: `connection_id`, `resource_key`, `target_ref`; every insert and delete fires.
- `core.person_cut_offs`: whether the row is open (`lifted_at` set or not) and `person_id`; an insert or delete fires only for an open row.
When an identifying column moves, such as a membership's person or an organization's owner, the trigger writes a row for the old side as well as the new one, because the relay recomputes from current state and could not otherwise find the record that has to shrink.
8. **[app]** Inbound webhook dedup is handler-level: Postgres requires a partitioned table's unique constraints to include the partition column, so the uniqueness necessarily includes the received-at timestamp and cannot reject a redelivery on its own — every receiver therefore inserts with an existence check on (provider, event id). Adding the "obvious" two-column unique index is exactly the fix the partitioning forbids.
9. **[db]** The substrate tables carry no foreign keys into any module table; entity references are text or payload fields, keeping both tables provider-generic.
9. **[db]** The substrate tables, `core.webhook_events`, `core.outbox` and `core.desired_state_outbox`, carry no foreign keys into any module table; entity references are text, payload or identifier fields, keeping the tables provider-generic. A desired-state notice names a subject in one of four tables, which one foreign key cannot express, and a foreign-key check would make a trigger wait on a pool row that a settlement holds locked. The other delivery tables (connections, target mappings, records, projections, cut-offs and lifecycle requests) do carry foreign keys, because they are registry and state tables, not substrate.
10. **[app]** Secret config keys are never stored: the settings page rejects writes to them and the overlay refuses to apply them; secrets live in the environment only.
11. **[app]** Config resolves override → environment → declared default, once, at single-threaded boot. An unrecognized stored override is skipped; an invalid one aborts startup; changes take effect on restart only.
12. **[app]** Schema references point from integration schemas into core, never the reverse and never sideways between integrations — which is exactly why integration migration order is interchangeable. Nothing mechanical blocks a violation; review must.
12. **[app]** Each in-tree integration owns one schema, its migrations and its roles. Schema references point from integration schemas into core, never the reverse and never sideways between integrations — which is exactly why integration migration order is interchangeable. A connector reached through a connection has no schema, no migrations and no roles in the console's database; what the console keeps about it lives in the generic delivery tables, keyed by connection. Nothing mechanical blocks a violation; review must.
13. **[app]** Integration UI is key-namespaced — templates, template names, and static paths all carry the integration key, and the composition root panics at startup on an unprefixed template — and member-dashboard cards are generic shells: core renders a header and a loading body per declaration, naming no integration anywhere; the card body is served by the integration's own routes.
14. **[app]** An integration's `Key()` method and its manifest's `Key` must be the same string, and boot asserts it (`integration.AssertKeyMatch`): a mismatch stops startup with an error naming the integration and both values, instead of registering the provider under one key while mounting its UI and settings under the other. `Key` here is Go contract vocabulary, not a column: the value it carries is written to `providers.provider`. The contract spelled these identifiers "slug" until `entity-keys` task 8.2 renamed them; the grammar `^[a-z0-9]+$` and the no-separator rule are unchanged, because a provider key prefixes resource keys.
15. **[app]** Every operator surface presenting a provider's `core.providers.status` must label it as a registry/lifecycle value, never as connectivity or health — the console performs no provider health checks, so any wording implying one (e.g. "connected") would be a claim the system cannot back. A provider with any `RequiredGroup`-tagged config key unresolved must be visibly marked not-configured wherever its status renders, derived from the one shared resolution (`config.RequiredKeysUnresolved`) — never a second, independently-drifting readiness check.
15. **[app]** Every operator surface presenting a provider's `core.providers.status` must label it as a registry/lifecycle value, never as connectivity or health — the console performs no provider health checks, so any wording implying one (e.g. "connected") would be a claim the system cannot back. A provider with any `RequiredGroup`-tagged config key unresolved must be visibly marked not-configured wherever its status renders, derived from the one shared resolution (`config.RequiredKeysUnresolved`) — never a second, independently-drifting readiness check. The same rule covers a connection's `status` and `approval_state`: both are registry values an operator or boot registration sets, and neither says the application is reachable. Lag, when a surface shows it, is a fact computed from recorded generations, not the result of a probe.
16. **[db]** A key's `recipient` is a person, an organization or empty; a platform key carries none; and a key delivered to a person must be boolean, because a person's record takes the union of what their organizations hold and a union of numeric limits has no single meaning. Boot registration additionally refuses (app-side) a manifest that leaves an owned key without a recipient or names a key the provider does not own.
17. **[db]** One connection exists per provider, retired connections included, so boot registration never creates a second connection beside one an operator retired. A target mapping can name only a key its connection's provider owns.
18. **[db-code]** A record's generation never falls, and its content never changes without its generation rising: a trigger on `core.desired_state_records` refuses either update. Every raise goes through `core.next_generation`, which takes the largest of the current generation plus one, a reported generation plus one, and the clock in microseconds, and refuses a reported generation more than one day ahead of the clock.
## Dimensions
- **Provider vs integration** — the registry row vs the code tree. One-to-one, and the words are not interchangeable.
- **Provider vs integration** — the registry row vs the code tree. A provider records one type of application and the code that serves it, with connections beneath it, one per running application. The pairing of one provider with one integration holds for in-tree integrations only, and the words are never interchangeable.
- **Provider kind** — payment / provisioning / notification / tax; the sidebar shows only the provisioning subset, while the Integrations landing page and settings surface span all kinds (invariant 6).
- **Capability rows vs operational rows** — boot-stamped from code and reconciled every boot, vs database-canonical and operator-owned. The line runs through the middle of `core.providers`.
- **Mandatory surface vs optional hooks** — every integration has a key, a manifest, and migrations; routes, workflows, config, UI, and dashboard cards are opt-in interfaces discovered at composition time.
- **Dispatch transport** — outbox handoff when dispatch must be atomic with a domain commit; direct Temporal when the workflow owns its own writes. A capability decision per integration, not a doctrine.
- **Inbound vs outbound substrate** — `webhook_events` (verified events in; each Stripe row is one Temporal workflow, retried on Temporal's schedule to dead-letter) vs `outbox` (actions out; a relay polls pending rows and each becomes its own Temporal workflow, retried the same way to dead-letter).
- **Dispatch transport** — desired state is always delivered through the desired-state outbox and the core relay, whatever the provider. Lifecycle commands keep a per-provider transport: outbox handoff when dispatch must be atomic with a domain commit, direct Temporal when the workflow owns its own writes. That second choice is a capability decision per integration, not a doctrine.
- **Inbound vs outbound substrate** — `webhook_events` (verified events in; each Stripe row is one Temporal workflow, retried on Temporal's schedule to dead-letter) vs `outbox` (actions out; a relay polls pending rows and each becomes its own Temporal workflow, retried the same way to dead-letter) vs `desired_state_outbox` (notices that a subject changed, written by triggers; the core relay merges the notices it claims and expands them into the records they affect, and recomputes each record once). A notice is dead-lettered after five failed attempts: for a wide notice, failures of its expansion query or of requeueing a failed recipient; for a recipient_only notice, failures of its own recompute. A push that fails is never dead-lettered, because the gap between a record's generation and the applied generation already records it as lag.
- **Key ownership × shape × metering** — ownership (the `provider` column) and metering (whether usage of the key is measured, via the usage counters in the entitlements model) are independent: a platform-owned key may be metered. Shape is not fully independent — as built, only numeric keys have usage machinery, so a boolean key cannot be metered.
- **The three roles of a key** — the bare stored id (`fedwiki_sites`), the grouping attribute (the `provider` column), and the display name shown to humans. A dotted `provider.key` form does not exist in storage and must never be parsed out of a key string.
- **Config key classes** — secret vs overridable; boot-required vs runtime-managed; enum-closed vs free. Orthogonal, all declared in the spec.
- **Per-instance lifecycle vs converged-set delivery** — FedWiki's resource is instance-shaped (create / set_status / delete per site); Discourse's is set-shaped (a member-set converged by reconciliation, manifest declaring only the read verbs). The verb set models the first natively and merely permits the second — the registry cannot yet say "this provider reconciles by design".
- **Per-instance lifecycle vs converged-set delivery** — FedWiki's resource is instance-shaped (create / set_status / delete per site, each a lifecycle request a person makes); Discourse's is set-shaped (a member set converged by reconciliation, manifest declaring only the read verbs). The registry expresses both. Instance-shaped work is recorded as lifecycle requests, addressed by an instance identifier the console chooses and a generation. Set-shaped delivery is a person-recipient key delivered through the connection's target mappings: a group's member set is the set of person records that hold the key, so it needs no verb of its own.
- **Schema per provider vs generic delivery tables** — an in-tree integration keeps its own state in its own schema; what the console keeps per connection, for any provider, lives in the core delivery tables keyed by connection (invariant 12).
- **Desired vs observed** — a desired-state record is what the console wants an application to hold; an observed projection is what the application last reported holding. They have different writers and sit in different tables, and a record is delivered when the projection's applied generation has reached the record's generation and, when the application reported a content hash, that hash equals the record's.
- **Registry status vs configuration readiness** — the operator-managed `core.providers.status` (a stored lifecycle value, changed only by an operator) vs whether the declared required config actually resolves (a read-time computation, `config.RequiredKeysUnresolved`). Independent facts, both shown, neither one a health check (invariant 15).
+33
View File
@@ -159,6 +159,39 @@ operators' notes or workspace descriptions; re-run it after every deploy
that applies a migration
([database-management.md](database-management.md#the-console_reader-role)).
**Roll an image back only when no lifecycle request is open.** Before you
deploy an older image, check that this query returns 0:
```sql
SELECT count(*) FROM core.lifecycle_requests WHERE status = 'accepted';
```
A member's create, status change or permanent delete runs as a workflow of
a type the newer image registers. An older image may not register that
type, so a run it did not finish would wait for a worker that never comes,
and the member's row would stay Pending.
**Retry a failed desired-state delivery by hand.** The relay stops
retrying a row of `core.desired_state_outbox` after five failed attempts,
marks it `failed` and logs its subject and error. No page lists these rows
yet. Connected with the application's database role, list them:
```sql
SELECT id, subject_kind, subject_id, resource_key, attempts, last_error, created_at
FROM core.desired_state_outbox
WHERE status = 'failed'
ORDER BY created_at;
```
Once the cause is fixed, put a row back in the queue. The relay claims it
on its next tick, within five seconds:
```sql
UPDATE core.desired_state_outbox
SET status = 'pending', attempts = 0, claimed_until = NULL, last_error = NULL
WHERE id = '<row id>' AND status = 'failed';
```
## 6. Integrations
Configure whichever of these your deployment actually offers. Each is
@@ -0,0 +1,230 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// core.desired_state_content under each recipient rule (openspec change
// integration-delivery-core, task 2.4), computed as core_reader, the role
// every integration writer inherits. One transaction, rolled back.
import (
"context"
"database/sql"
"slices"
"strings"
"testing"
)
// contentAs computes a record's content as core_reader and returns jsonb's
// text form. An empty person or organization passes NULL.
func contentAs(t *testing.T, ctx context.Context, tx *sql.Tx, connectionID, personID, orgID string) string {
t.Helper()
var content string
asRole(t, ctx, tx, "core_reader", func() {
mustScan(t, ctx, tx, &content,
`SELECT core.desired_state_content($1, NULLIF($2, '')::uuid, NULLIF($3, '')::uuid)::text`,
connectionID, personID, orgID)
})
return content
}
// canonical returns jsonb's text form of s, the form contentAs returns.
func canonical(t *testing.T, ctx context.Context, tx *sql.Tx, s string) string {
t.Helper()
var out string
mustScan(t, ctx, tx, &out, `SELECT $1::jsonb::text`, s)
return out
}
func TestDesiredStateContentRecipientRules(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
personKey := fxKey(t, ctx, tx, provider, "boolean", "person")
orgKey := fxKey(t, ctx, tx, provider, "numeric", "organization")
bareKey := fxKey(t, ctx, tx, provider, "boolean", "")
connectionID := fxConnection(t, ctx, tx, provider, true)
// Another provider's person key, which this connection never carries.
otherProvider := fxProvider(t, ctx, tx)
otherKey := fxKey(t, ctx, tx, otherProvider, "boolean", "person")
empty := canonical(t, ctx, tx, `{"entitlements": []}`)
holdsPersonKey := canonical(t, ctx, tx, `{"entitlements": [{"key": "`+personKey+`", "granted": true}]}`)
holdsLimit := func(n string) string {
return canonical(t, ctx, tx, `{"entitlements": [{"key": "`+orgKey+`", "limit": `+n+`}]}`)
}
check := func(what, got, want string) {
t.Helper()
if got != want {
t.Errorf("%s: content = %s, want %s", what, got, want)
}
}
// An active organization whose default pool grants the person key, holds
// a limit of 3 on the organization key, and grants a key with no
// declared recipient and another provider's key.
owner := fxPerson(t, ctx, tx)
org := fxOrg(t, ctx, tx, owner)
pool := fxPool(t, ctx, tx, org, "default")
fxBoolean(t, ctx, tx, pool, personKey, true)
fxLimit(t, ctx, tx, pool, orgKey, 3)
fxBoolean(t, ctx, tx, pool, bareKey, true)
fxBoolean(t, ctx, tx, pool, otherKey, true)
// A second, shared pool with a higher limit: an organization's record
// reads its default pool only.
fxLimit(t, ctx, tx, fxPool(t, ctx, tx, org, "shared"), orgKey, 7)
check("an owner without a membership row", contentAs(t, ctx, tx, connectionID, owner, ""), holdsPersonKey)
check("the organization, from its default pool", contentAs(t, ctx, tx, connectionID, "", org), holdsLimit("3"))
active := fxPerson(t, ctx, tx)
fxMember(t, ctx, tx, org, active, "active")
check("an active member", contentAs(t, ctx, tx, connectionID, active, ""), holdsPersonKey)
removed := fxPerson(t, ctx, tx)
fxMember(t, ctx, tx, org, removed, "removed")
check("an inactive membership", contentAs(t, ctx, tx, connectionID, removed, ""), empty)
// Any one of two organizations granting the boolean is enough.
both := fxPerson(t, ctx, tx)
fxMember(t, ctx, tx, org, both, "active")
quiet := fxOrg(t, ctx, tx, fxPerson(t, ctx, tx))
fxPool(t, ctx, tx, quiet, "default")
fxMember(t, ctx, tx, quiet, both, "active")
check("a member of two organizations, one granting", contentAs(t, ctx, tx, connectionID, both, ""), holdsPersonKey)
// A person's record reads every pool of the organizations they belong
// to, a shared pool included.
sharedOwner := fxPerson(t, ctx, tx)
sharedOrg := fxOrg(t, ctx, tx, sharedOwner)
fxPool(t, ctx, tx, sharedOrg, "default")
fxBoolean(t, ctx, tx, fxPool(t, ctx, tx, sharedOrg, "shared"), personKey, true)
check("a person key held in a shared pool", contentAs(t, ctx, tx, connectionID, sharedOwner, ""), holdsPersonKey)
// An organization that is not active contributes nothing.
suspendedOwner := fxPerson(t, ctx, tx)
suspendedMember := fxPerson(t, ctx, tx)
suspended := fxOrg(t, ctx, tx, suspendedOwner)
suspendedPool := fxPool(t, ctx, tx, suspended, "default")
fxBoolean(t, ctx, tx, suspendedPool, personKey, true)
fxLimit(t, ctx, tx, suspendedPool, orgKey, 3)
fxMember(t, ctx, tx, suspended, suspendedMember, "active")
mustExec(t, ctx, tx, `UPDATE core.organizations SET status = 'suspended' WHERE org_id = $1`, suspended)
check("the owner of an organization that is not active", contentAs(t, ctx, tx, connectionID, suspendedOwner, ""), empty)
check("a member of an organization that is not active", contentAs(t, ctx, tx, connectionID, suspendedMember, ""), empty)
check("an organization that is not active", contentAs(t, ctx, tx, connectionID, "", suspended), empty)
// A lapsed boolean and a zero limit read as absent, identical to a
// recipient that never held either key.
lapsedOwner := fxPerson(t, ctx, tx)
lapsed := fxOrg(t, ctx, tx, lapsedOwner)
lapsedPool := fxPool(t, ctx, tx, lapsed, "default")
fxBoolean(t, ctx, tx, lapsedPool, personKey, false)
fxLimit(t, ctx, tx, lapsedPool, orgKey, 0)
never := fxOrg(t, ctx, tx, fxPerson(t, ctx, tx))
fxPool(t, ctx, tx, never, "default")
check("a lapsed boolean", contentAs(t, ctx, tx, connectionID, lapsedOwner, ""), empty)
check("a zeroed limit", contentAs(t, ctx, tx, connectionID, "", lapsed), contentAs(t, ctx, tx, connectionID, "", never))
// The System organization and its synthetic owner are not recipients,
// even when the organization's pool holds the keys.
systemOrg, systemOwner := fxSystemOrg(t, ctx, tx)
var systemPool string
if err := tx.QueryRowContext(ctx,
`SELECT pool_id FROM core.resource_pools WHERE org_id = $1 AND pool_type = 'default'`, systemOrg,
).Scan(&systemPool); err == sql.ErrNoRows {
systemPool = fxPool(t, ctx, tx, systemOrg, "default")
} else if err != nil {
t.Fatal(err)
}
fxBoolean(t, ctx, tx, systemPool, personKey, true)
fxLimit(t, ctx, tx, systemPool, orgKey, 3)
check("the System organization", contentAs(t, ctx, tx, connectionID, "", systemOrg), empty)
check("the System organization's owner", contentAs(t, ctx, tx, connectionID, systemOwner, ""), empty)
systemMember := fxPerson(t, ctx, tx)
fxMember(t, ctx, tx, systemOrg, systemMember, "active")
check("an active member of the System organization", contentAs(t, ctx, tx, connectionID, systemMember, ""), empty)
// A cut-off person's content is empty; their organization's is not.
mustScan(t, ctx, tx, new(bool), `SELECT core.set_person_cut_off($1, TRUE, NULL)`, owner)
check("a cut-off owner", contentAs(t, ctx, tx, connectionID, owner, ""), empty)
check("a cut-off owner's organization", contentAs(t, ctx, tx, connectionID, "", org), holdsLimit("3"))
check("a member beside a cut-off owner", contentAs(t, ctx, tx, connectionID, active, ""), holdsPersonKey)
// The key with no declared recipient and the other provider's key reach
// no record at this connection; the person keys reach no organization
// and the organization key no person (checked by the exact contents
// above). The other provider has no connection, so nothing carries its
// key.
for _, got := range []string{
contentAs(t, ctx, tx, connectionID, active, ""),
contentAs(t, ctx, tx, connectionID, "", org),
} {
for _, key := range []string{bareKey, otherKey} {
if strings.Contains(got, key) {
t.Errorf("content %s carries %s", got, key)
}
}
}
}
func TestDesiredStateContentIsSortedAndCarriesTargets(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
first := fxKey(t, ctx, tx, provider, "boolean", "person")
second := fxKey(t, ctx, tx, provider, "boolean", "person")
orgKey := fxKey(t, ctx, tx, provider, "numeric", "organization")
connectionID := fxConnection(t, ctx, tx, provider, true)
person := fxPerson(t, ctx, tx)
org := fxOrg(t, ctx, tx, person)
pool := fxPool(t, ctx, tx, org, "default")
// Granted in reverse key order.
fxBoolean(t, ctx, tx, pool, second, true)
fxBoolean(t, ctx, tx, pool, first, true)
fxLimit(t, ctx, tx, pool, orgKey, 3)
// Targets in byte order: "C" (0x43) before "b" (0x62), which a
// locale-aware sort would reverse.
for _, target := range []string{"42", "b", "17", "C"} {
fxMapping(t, ctx, tx, connectionID, provider, first, target)
}
keys := []string{first, second}
slices.Sort(keys)
if keys[0] != first {
t.Fatalf("fixture keys %s and %s were minted out of order", first, second)
}
// Keys in byte order: "_" (0x5F) before "a" (0x61), so stem_x_b comes
// before stem_xa, which a locale-aware sort, ignoring the underscore at
// first, would reverse.
stem := deliveryName(provider + "_sort")
underscored, plain := stem+"_x_b", stem+"_xa"
for _, key := range []string{plain, underscored} {
mustExec(t, ctx, tx,
`INSERT INTO core.resource_keys (resource_key, display_name, unit, provider, kind, recipient)
VALUES ($1, 'Delivery Key', 'unit', $2, 'boolean', 'person')`, key, provider)
fxBoolean(t, ctx, tx, pool, key, true)
}
if second >= stem {
t.Fatalf("fixture key %s sorts after %s", second, stem)
}
want := canonical(t, ctx, tx, `{"entitlements": [
{"key": "`+first+`", "granted": true, "targets": ["17", "42", "C", "b"]},
{"key": "`+second+`", "granted": true},
{"key": "`+underscored+`", "granted": true},
{"key": "`+plain+`", "granted": true}]}`)
if got := contentAs(t, ctx, tx, connectionID, person, ""); got != want {
t.Errorf("person content = %s, want %s", got, want)
}
wantOrg := canonical(t, ctx, tx, `{"entitlements": [{"key": "`+orgKey+`", "limit": 3}]}`)
if got := contentAs(t, ctx, tx, connectionID, "", org); got != wantOrg {
t.Errorf("organization content = %s, want %s", got, wantOrg)
}
}
@@ -0,0 +1,403 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// The delivery enclosures (openspec change integration-delivery-core, tasks
// 2.10 and 2.11). The test stack's login role is a cluster superuser, which
// no revoke binds, so these tests act as a role built inside the transaction
// with the login role's memberships and none of its superuser bit: the
// application's privileges as a deployment grants them.
import (
"testing"
"github.com/google/uuid"
)
// --- 2.10 ------------------------------------------------------------------
func TestDesiredStateOutboxEnclosure(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
app := applicationRole(t, ctx, tx)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
person := fxPerson(t, ctx, tx)
org := fxOrg(t, ctx, tx, person)
pool := fxPool(t, ctx, tx, org, "default")
connectionID := fxConnection(t, ctx, tx, provider, true)
takeNotices(t, ctx, tx)
asRole(t, ctx, tx, app, func() {
mustRefuse(t, ctx, tx, sqlstatePrivilege, "",
`INSERT INTO core.desired_state_outbox (subject_kind, subject_id) VALUES ('pool', $1)`, pool)
// A trigger inserts for a role that holds no INSERT on the outbox and
// no EXECUTE on the trigger function.
fxBoolean(t, ctx, tx, pool, key, true)
var written int
mustScan(t, ctx, tx, &written, `SELECT COUNT(*) FROM core.desired_state_outbox WHERE subject_id = $1`, pool)
if written != 1 {
t.Fatalf("granting as the application role wrote %d rows, want 1", written)
}
mustRefuse(t, ctx, tx, sqlstatePrivilege, "",
`SELECT core.insert_desired_state_notice('pool', $1, NULL)`, pool)
mustRefuse(t, ctx, tx, sqlstatePrivilege, "", `SELECT core.desired_state_on_boolean_entitlements()`)
// The claim and completion columns update; what a row names does not.
for _, column := range []string{"subject_kind = 'person'", "subject_id = uuidv7()", "resource_key = 'other'",
"recipient_only = TRUE", "created_at = NOW()", "id = uuidv7()"} {
mustRefuse(t, ctx, tx, sqlstatePrivilege, "",
`UPDATE core.desired_state_outbox SET `+column+` WHERE subject_id = $1`, pool)
}
mustExec(t, ctx, tx,
`UPDATE core.desired_state_outbox SET claimed_until = NOW() + INTERVAL '2 minutes', attempts = attempts + 1,
expansion_cursor = 'person:x', last_error = 'retry'
WHERE subject_id = $1`, pool)
mustExec(t, ctx, tx,
`UPDATE core.desired_state_outbox SET status = 'completed', completed_at = NOW(), claimed_until = NULL
WHERE subject_id = $1`, pool)
mustExec(t, ctx, tx, `DELETE FROM core.desired_state_outbox WHERE subject_id = $1 AND status = 'completed'`, pool)
mustRefuse(t, ctx, tx, sqlstatePrivilege, "", `TRUNCATE core.desired_state_outbox`)
// The requeue function writes one recipient-only row for a person or
// organization that exists, and refuses anything else.
for _, subject := range []struct{ kind, id string }{{"person", person}, {"organization", org}} {
var id string
mustScan(t, ctx, tx, &id, `SELECT core.requeue_desired_state($1, $2, 'recompute failed')`, subject.kind, subject.id)
var kind, lastError, status string
var recipientOnly bool
var attempts int
if err := tx.QueryRowContext(ctx,
`SELECT subject_kind, recipient_only, attempts, last_error, status FROM core.desired_state_outbox WHERE id = $1`, id,
).Scan(&kind, &recipientOnly, &attempts, &lastError, &status); err != nil {
t.Fatal(err)
}
if kind != subject.kind || !recipientOnly || attempts != 1 || lastError != "recompute failed" || status != "pending" {
t.Errorf("requeue for %s wrote kind=%s recipient_only=%v attempts=%d last_error=%q status=%s",
subject.kind, kind, recipientOnly, attempts, lastError, status)
}
}
mustRefuse(t, ctx, tx, sqlstateInvalidParameter, "", `SELECT core.requeue_desired_state('pool', $1, 'x')`, pool)
mustRefuse(t, ctx, tx, sqlstateInvalidParameter, "", `SELECT core.requeue_desired_state('connection', $1, 'x')`, connectionID)
mustRefuse(t, ctx, tx, sqlstateNoDataFound, "", `SELECT core.requeue_desired_state('person', $1, 'x')`, uuid.NewString())
mustRefuse(t, ctx, tx, sqlstateNoDataFound, "", `SELECT core.requeue_desired_state('organization', $1, 'x')`, person)
})
}
func TestPersonCutOffEnclosure(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
app := applicationRole(t, ctx, tx)
person := fxPerson(t, ctx, tx)
actor := fxPerson(t, ctx, tx)
asRole(t, ctx, tx, app, func() {
mustRefuse(t, ctx, tx, sqlstatePrivilege, "",
`INSERT INTO core.person_cut_offs (person_id) VALUES ($1)`, person)
var changed bool
mustScan(t, ctx, tx, &changed, `SELECT core.set_person_cut_off($1, TRUE, $2)`, person, actor)
if !changed {
t.Fatalf("the setter reported no change for a first cut-off")
}
mustRefuse(t, ctx, tx, sqlstatePrivilege, "",
`UPDATE core.person_cut_offs SET lifted_at = NOW() WHERE person_id = $1`, person)
mustRefuse(t, ctx, tx, sqlstatePrivilege, "", `DELETE FROM core.person_cut_offs WHERE person_id = $1`, person)
mustRefuse(t, ctx, tx, sqlstatePrivilege, "", `TRUNCATE core.person_cut_offs`)
})
// PUBLIC (a role with no grants) and core_reader cannot execute the
// enclosed functions.
bare := deliveryName("delivery_bare_")
mustExec(t, ctx, tx, "CREATE ROLE "+bare+" NOLOGIN")
mustExec(t, ctx, tx, "GRANT USAGE ON SCHEMA core TO "+bare)
for _, role := range []string{bare, "core_reader"} {
asRole(t, ctx, tx, role, func() {
mustRefuse(t, ctx, tx, sqlstatePrivilege, "", `SELECT core.set_person_cut_off($1, FALSE, NULL)`, person)
mustRefuse(t, ctx, tx, sqlstatePrivilege, "", `SELECT core.requeue_desired_state('person', $1, 'x')`, person)
})
}
}
func TestEnclosuresIgnoreACallersTemporaryTables(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
app := applicationRole(t, ctx, tx)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
person := fxPerson(t, ctx, tx)
pool := fxPool(t, ctx, tx, fxOrg(t, ctx, tx, person), "default")
asRole(t, ctx, tx, app, func() {
mustExec(t, ctx, tx, `CREATE TEMPORARY TABLE person_cut_offs (LIKE core.person_cut_offs) ON COMMIT DROP`)
mustExec(t, ctx, tx, `CREATE TEMPORARY TABLE desired_state_outbox (LIKE core.desired_state_outbox) ON COMMIT DROP`)
mustExec(t, ctx, tx, `SET LOCAL search_path = pg_temp, core`)
mustScan(t, ctx, tx, new(bool), `SELECT core.set_person_cut_off($1, TRUE, NULL)`, person)
fxBoolean(t, ctx, tx, pool, key, true)
var shadowCutOffs, shadowNotices, realCutOffs, realNotices int
mustScan(t, ctx, tx, &shadowCutOffs, `SELECT COUNT(*) FROM pg_temp.person_cut_offs`)
mustScan(t, ctx, tx, &shadowNotices, `SELECT COUNT(*) FROM pg_temp.desired_state_outbox`)
mustScan(t, ctx, tx, &realCutOffs, `SELECT COUNT(*) FROM core.person_cut_offs WHERE person_id = $1 AND lifted_at IS NULL`, person)
mustScan(t, ctx, tx, &realNotices, `SELECT COUNT(*) FROM core.desired_state_outbox WHERE subject_id IN ($1, $2)`, person, pool)
if shadowCutOffs != 0 || shadowNotices != 0 {
t.Errorf("the caller's temporary tables captured %d cut-offs and %d notices, want none", shadowCutOffs, shadowNotices)
}
if realCutOffs != 1 || realNotices != 2 {
t.Errorf("core holds %d open cut-offs and %d notices, want 1 and 2", realCutOffs, realNotices)
}
})
}
// The requeue function, which core_writer may call, reads persons and
// writes the outbox through its pinned search_path, never a caller's
// temporary tables: a temporary persons table naming a person that does not
// exist does not make the requeue succeed.
func TestRequeueIgnoresACallersTemporaryTables(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
app := applicationRole(t, ctx, tx)
ghost := uuid.NewString()
asRole(t, ctx, tx, app, func() {
mustExec(t, ctx, tx, `CREATE TEMPORARY TABLE persons (person_id uuid) ON COMMIT DROP`)
mustExec(t, ctx, tx, `INSERT INTO pg_temp.persons VALUES ($1)`, ghost)
mustExec(t, ctx, tx, `CREATE TEMPORARY TABLE desired_state_outbox (LIKE core.desired_state_outbox INCLUDING DEFAULTS) ON COMMIT DROP`)
mustExec(t, ctx, tx, `GRANT SELECT ON pg_temp.persons TO PUBLIC`)
mustExec(t, ctx, tx, `GRANT ALL ON pg_temp.desired_state_outbox TO PUBLIC`)
mustExec(t, ctx, tx, `SET LOCAL search_path = pg_temp, core`)
mustRefuse(t, ctx, tx, sqlstateNoDataFound, "", `SELECT core.requeue_desired_state('person', $1, 'x')`, ghost)
})
}
// Every SECURITY DEFINER function in core pins its search_path to core with
// pg_temp last (per-schema-roles), so no caller's object can shadow what it
// reads or writes. The migration round trip cannot catch a wrong value
// written into the migration itself; this does.
func TestCoreSecurityDefinerFunctionsPinTheirSearchPath(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
rows, err := tx.QueryContext(ctx, `
SELECT p.oid::regprocedure::text FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'core' AND p.prosecdef
AND (p.proconfig IS NULL OR NOT ('search_path=core, pg_temp' = ANY(p.proconfig)))`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var fn string
if err := rows.Scan(&fn); err != nil {
t.Fatal(err)
}
t.Errorf("%s runs SECURITY DEFINER without search_path = core, pg_temp", fn)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
}
func TestDeliveryTablePrivileges(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
app := applicationRole(t, ctx, tx)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "numeric", "organization")
connectionID := fxConnection(t, ctx, tx, provider, true)
person := fxPerson(t, ctx, tx)
workspace := fxWorkspace(t, ctx, tx, fxOrg(t, ctx, tx, person))
// core_writer holds every write on the five ordinary delivery tables;
// the outbox and the cut-offs are enclosures, tested above.
for _, table := range []string{"desired_state_records", "lifecycle_requests", "observed_projections",
"provider_connections", "target_mappings"} {
for _, privilege := range []string{"SELECT", "INSERT", "UPDATE", "DELETE"} {
var held bool
mustScan(t, ctx, tx, &held, `SELECT has_table_privilege('core_writer', 'core.' || $1, $2)`, table, privilege)
if !held {
t.Errorf("core_writer lacks %s on core.%s", privilege, table)
}
}
}
// The application role writes the ordinary delivery tables.
asRole(t, ctx, tx, app, func() {
var requestID string
mustScan(t, ctx, tx, &requestID,
`INSERT INTO core.lifecycle_requests (instance_id, generation, connection_id, resource_key, workspace_id, operation, workflow_id)
VALUES (uuidv7(), core.next_generation(NULL, NULL), $1, $2, $3, 'create', 'wf') RETURNING request_id`,
connectionID, key, workspace)
mustExec(t, ctx, tx,
`UPDATE core.lifecycle_requests SET status = 'succeeded', finished_at = NOW() WHERE request_id = $1`, requestID)
mustExec(t, ctx, tx, `DELETE FROM core.lifecycle_requests WHERE request_id = $1`, requestID)
for _, table := range deliveryTables {
mustExec(t, ctx, tx, `SELECT * FROM core.`+table+` LIMIT 1`)
}
})
// The integration writer roles and core_reader read every delivery table
// and write none of them.
for _, role := range []string{"fedwiki_writer", "discourse_writer", "core_reader"} {
asRole(t, ctx, tx, role, func() {
for _, table := range deliveryTables {
mustExec(t, ctx, tx, `SELECT * FROM core.`+table+` LIMIT 1`)
mustRefuse(t, ctx, tx, sqlstatePrivilege, "", `INSERT INTO core.`+table+` DEFAULT VALUES`)
mustRefuse(t, ctx, tx, sqlstatePrivilege, "",
`UPDATE core.`+table+` SET `+deliveryKeys[table]+` = `+deliveryKeys[table]+` WHERE FALSE`)
mustRefuse(t, ctx, tx, sqlstatePrivilege, "", `DELETE FROM core.`+table+` WHERE FALSE`)
}
mustScan(t, ctx, tx, new(string), `SELECT core.desired_state_content($1, $2, NULL)::text`, connectionID, person)
})
}
}
// deliveryKeys names each delivery table's primary key column.
var deliveryKeys = map[string]string{
"desired_state_outbox": "id",
"desired_state_records": "record_id",
"lifecycle_requests": "request_id",
"observed_projections": "projection_id",
"person_cut_offs": "cut_off_id",
"provider_connections": "connection_id",
"target_mappings": "mapping_id",
}
// --- 2.11 the cut-off setter -----------------------------------------------
func TestPersonCutOffSetter(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
app := applicationRole(t, ctx, tx)
person := fxPerson(t, ctx, tx)
actor := fxPerson(t, ctx, tx)
lifter := fxPerson(t, ctx, tx)
var personRowBefore string
mustScan(t, ctx, tx, &personRowBefore,
`SELECT ctid::text || row_to_json(p)::text FROM core.persons p WHERE person_id = $1`, person)
set := func(target string, cutOff bool, by string) bool {
t.Helper()
var changed bool
asRole(t, ctx, tx, app, func() {
mustScan(t, ctx, tx, &changed, `SELECT core.set_person_cut_off($1, $2, $3)`, target, cutOff, by)
})
return changed
}
type cutOffRow struct {
cutOffBy, liftedBy string
lifted bool
}
rowsOf := func() []cutOffRow {
t.Helper()
rows, err := tx.QueryContext(ctx,
`SELECT coalesce(cut_off_by_person_id::text, ''), coalesce(lifted_by_person_id::text, ''), lifted_at IS NOT NULL
FROM core.person_cut_offs WHERE person_id = $1 ORDER BY cut_off_id`, person)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []cutOffRow
for rows.Next() {
var r cutOffRow
if err := rows.Scan(&r.cutOffBy, &r.liftedBy, &r.lifted); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
return out
}
if set(person, false, lifter) {
t.Errorf("lifting a person who is not cut off reported a change")
}
if got := rowsOf(); len(got) != 0 {
t.Errorf("lifting a person who is not cut off wrote %v", got)
}
if !set(person, true, actor) {
t.Errorf("cutting a person off reported no change")
}
if set(person, true, lifter) {
t.Errorf("cutting off twice reported a change")
}
if got := rowsOf(); len(got) != 1 || got[0] != (cutOffRow{cutOffBy: actor}) {
t.Errorf("after cutting off twice the rows are %v, want one open row by the first actor", got)
}
if !set(person, false, lifter) {
t.Errorf("lifting reported no change")
}
if got := rowsOf(); len(got) != 1 || got[0] != (cutOffRow{cutOffBy: actor, liftedBy: lifter, lifted: true}) {
t.Errorf("after lifting the rows are %v, want one closed row", got)
}
if !set(person, true, actor) {
t.Errorf("a second cut-off after a lift reported no change")
}
if got := rowsOf(); len(got) != 2 || !got[0].lifted || got[1].lifted {
t.Errorf("after a second cut-off the rows are %v, want the closed row kept and one open row", got)
}
// The person row is untouched: same tuple, same values.
var personRowAfter string
mustScan(t, ctx, tx, &personRowAfter,
`SELECT ctid::text || row_to_json(p)::text FROM core.persons p WHERE person_id = $1`, person)
if personRowAfter != personRowBefore {
t.Errorf("cutting off rewrote the person row:\nbefore %s\nafter %s", personRowBefore, personRowAfter)
}
// The shape and the one-open rule, written directly as the owner would.
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_person_cut_offs_one_open",
`INSERT INTO core.person_cut_offs (person_id) VALUES ($1)`, person)
other := fxPerson(t, ctx, tx)
mustRefuse(t, ctx, tx, sqlstateCheck, "person_cut_off_lifted_shape",
`INSERT INTO core.person_cut_offs (person_id, cut_off_at, lifted_at) VALUES ($1, NOW(), NOW() - INTERVAL '1 second')`, other)
mustRefuse(t, ctx, tx, sqlstateCheck, "person_cut_off_lifted_shape",
`INSERT INTO core.person_cut_offs (person_id, lifted_by_person_id) VALUES ($1, $2)`, other, lifter)
mustExec(t, ctx, tx, `SAVEPOINT unknown_person`)
_, err := tx.ExecContext(ctx, `SELECT core.set_person_cut_off($1, TRUE, NULL)`, uuid.NewString())
mustExec(t, ctx, tx, `ROLLBACK TO SAVEPOINT unknown_person`)
assertPgError(t, err, sqlstateForeignKey, "fk_person_cut_offs_person_id")
// A NULL person or cut_off is refused, never read as a lift, and the
// open cut-off stays open.
asRole(t, ctx, tx, app, func() {
mustRefuse(t, ctx, tx, sqlstateNullValue, "", `SELECT core.set_person_cut_off($1, NULL, NULL)`, person)
mustRefuse(t, ctx, tx, sqlstateNullValue, "", `SELECT core.set_person_cut_off(NULL, FALSE, NULL)`)
})
var open int
mustScan(t, ctx, tx, &open, `SELECT COUNT(*) FROM core.person_cut_offs WHERE person_id = $1 AND lifted_at IS NULL`, person)
if open != 1 {
t.Errorf("after a NULL cut_off the person has %d open cut-offs, want 1", open)
}
// A cut-off committed after the lifting transaction began has a
// cut_off_at later than that transaction's NOW(); the lift still lifts.
late := fxPerson(t, ctx, tx)
mustExec(t, ctx, tx,
`INSERT INTO core.person_cut_offs (person_id, cut_off_at) VALUES ($1, NOW() + INTERVAL '1 second')`, late)
if !set(late, false, lifter) {
t.Errorf("lifting a cut-off stamped after the transaction began reported no change")
}
var liftedAfter bool
mustScan(t, ctx, tx, &liftedAfter,
`SELECT lifted_at >= cut_off_at FROM core.person_cut_offs WHERE person_id = $1`, late)
if !liftedAfter {
t.Errorf("the lift is stamped before its cut-off")
}
// persons.status cuts nobody off: a person whose status is not active
// still holds a key their active organization grants.
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
connectionID := fxConnection(t, ctx, tx, provider, true)
holder := fxPerson(t, ctx, tx)
fxBoolean(t, ctx, tx, fxPool(t, ctx, tx, fxOrg(t, ctx, tx, holder), "default"), key, true)
mustExec(t, ctx, tx, `UPDATE core.persons SET status = 'inactive' WHERE person_id = $1`, holder)
want := canonical(t, ctx, tx, `{"entitlements": [{"key": "`+key+`", "granted": true}]}`)
if got := contentAs(t, ctx, tx, connectionID, holder, ""); got != want {
t.Errorf("a person with status inactive has content %s, want %s", got, want)
}
}
@@ -0,0 +1,357 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// Fixtures for the database tests of migration 00024 (openspec change
// integration-delivery-core, section 2). Every fixture is written inside the
// caller's transaction, which the test rolls back, except where a test needs
// the clock to move between transactions and cleans up after itself. The
// test login is a cluster superuser; a test that needs an application role's
// privileges drops to it with SET LOCAL ROLE. Providers and keys are made up
// per test, so no fixture depends on which integrations have registered.
import (
"context"
"database/sql"
"fmt"
"os"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
)
// deliveryTestDB opens TEST_DATABASE_URL migrated with every stream, because
// the enclosure tests read the integration writer roles their streams create.
func deliveryTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := db.RunMigrations(database, migrate.Sources()); err != nil {
t.Fatalf("migrations: %v", err)
}
return database
}
// deliveryTx begins a transaction the test rolls back when it ends.
func deliveryTx(t *testing.T, database *sql.DB) (context.Context, *sql.Tx) {
t.Helper()
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
t.Cleanup(func() { _ = tx.Rollback() })
return ctx, tx
}
var deliverySeq atomic.Int64
// deliveryName returns a lowercase alphanumeric name unique across runs, which
// the provider key's hygiene check accepts.
func deliveryName(prefix string) string {
return fmt.Sprintf("%s%d%d", prefix, time.Now().UnixNano(), deliverySeq.Add(1))
}
type execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
func mustExec(t *testing.T, ctx context.Context, q execer, query string, args ...any) {
t.Helper()
if _, err := q.ExecContext(ctx, query, args...); err != nil {
t.Fatalf("%s: %v", strings.Join(strings.Fields(query), " "), err)
}
}
func mustScan(t *testing.T, ctx context.Context, q execer, dest any, query string, args ...any) {
t.Helper()
if err := q.QueryRowContext(ctx, query, args...).Scan(dest); err != nil {
t.Fatalf("%s: %v", strings.Join(strings.Fields(query), " "), err)
}
}
// attempt runs one statement inside a savepoint and returns its error,
// rolling back to the savepoint when it fails so the transaction goes on.
func attempt(t *testing.T, ctx context.Context, tx *sql.Tx, query string, args ...any) error {
t.Helper()
mustExec(t, ctx, tx, "SAVEPOINT delivery_attempt")
_, err := tx.ExecContext(ctx, query, args...)
if err != nil {
mustExec(t, ctx, tx, "ROLLBACK TO SAVEPOINT delivery_attempt")
return err
}
mustExec(t, ctx, tx, "RELEASE SAVEPOINT delivery_attempt")
return nil
}
// mustRefuse requires a statement to fail with the SQLSTATE and, when given,
// the constraint name.
func mustRefuse(t *testing.T, ctx context.Context, tx *sql.Tx, code, constraint, query string, args ...any) {
t.Helper()
assertPgError(t, attempt(t, ctx, tx, query, args...), code, constraint)
}
const (
sqlstateCheck = "23514"
sqlstateForeignKey = "23503"
sqlstateUnique = "23505"
sqlstatePrivilege = "42501"
sqlstateOutOfRange = "22003"
sqlstateInvalidParameter = "22023"
sqlstateNoDataFound = "P0002"
sqlstateNullValue = "22004"
)
// --- identity and organizations --------------------------------------------
func fxPerson(t *testing.T, ctx context.Context, tx *sql.Tx) string {
t.Helper()
var userID, personID string
mustScan(t, ctx, tx, &userID, `INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`,
deliveryName("delivery-subject-"))
mustScan(t, ctx, tx, &personID,
`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1, 'Delivery Person', $2) RETURNING person_id`,
userID, deliveryName("delivery-")+"@example.test")
return personID
}
func fxOrg(t *testing.T, ctx context.Context, tx *sql.Tx, ownerPersonID string) string {
t.Helper()
var orgID string
mustScan(t, ctx, tx, &orgID,
`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ('Delivery Org', 'personal', $1) RETURNING org_id`,
ownerPersonID)
return orgID
}
func fxPool(t *testing.T, ctx context.Context, tx *sql.Tx, orgID, poolType string) string {
t.Helper()
var poolID string
mustScan(t, ctx, tx, &poolID,
`INSERT INTO core.resource_pools (org_id, name, pool_type) VALUES ($1, $2, $3) RETURNING pool_id`,
orgID, deliveryName("pool-"), poolType)
return poolID
}
func fxWorkspace(t *testing.T, ctx context.Context, tx *sql.Tx, orgID string) string {
t.Helper()
var workspaceID string
mustScan(t, ctx, tx, &workspaceID,
`INSERT INTO core.workspaces (org_id, name) VALUES ($1, $2) RETURNING workspace_id`,
orgID, deliveryName("workspace-"))
return workspaceID
}
func fxRole(t *testing.T, ctx context.Context, tx *sql.Tx, name string) string {
t.Helper()
var roleID string
mustScan(t, ctx, tx, &roleID, `SELECT role_id FROM core.roles WHERE role_name = $1 AND org_id IS NULL`, name)
return roleID
}
func fxMember(t *testing.T, ctx context.Context, tx *sql.Tx, orgID, personID, status string) string {
t.Helper()
var memberID string
mustScan(t, ctx, tx, &memberID,
`INSERT INTO core.org_members (org_id, person_id, role_id, status) VALUES ($1, $2, $3, $4) RETURNING org_member_id`,
orgID, personID, fxRole(t, ctx, tx, "member"), status)
return memberID
}
// fxSystemOrg returns the System organization and its synthetic owner,
// creating both inside the transaction when the database has none yet.
func fxSystemOrg(t *testing.T, ctx context.Context, tx *sql.Tx) (orgID, ownerID string) {
t.Helper()
err := tx.QueryRowContext(ctx,
`SELECT org_id, owner_person_id FROM core.organizations WHERE org_type = 'system'`).Scan(&orgID, &ownerID)
if err == nil {
return orgID, ownerID
}
if err != sql.ErrNoRows {
t.Fatalf("look up the System organization: %v", err)
}
mustExec(t, ctx, tx, `INSERT INTO core.org_types (org_type, display_name, is_reserved)
VALUES ('system', 'System', TRUE) ON CONFLICT DO NOTHING`)
ownerID = fxPerson(t, ctx, tx)
mustScan(t, ctx, tx, &orgID,
`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ('System', 'system', $1) RETURNING org_id`,
ownerID)
return orgID, ownerID
}
// --- the registry ----------------------------------------------------------
func fxProvider(t *testing.T, ctx context.Context, tx *sql.Tx) string {
t.Helper()
provider := deliveryName("dsprov")
mustExec(t, ctx, tx,
`INSERT INTO core.providers (provider, provider_kind, display_name) VALUES ($1, 'provisioning', 'Delivery Provider')`,
provider)
return provider
}
// fxKey inserts a resource key. An empty provider makes a platform key; an
// empty recipient leaves it NULL.
func fxKey(t *testing.T, ctx context.Context, tx *sql.Tx, provider, kind, recipient string) string {
t.Helper()
prefix := provider
if prefix == "" {
prefix = "dsplatform"
}
key := deliveryName(prefix + "_" + kind + "_")
mustExec(t, ctx, tx,
`INSERT INTO core.resource_keys (resource_key, display_name, unit, provider, kind, recipient)
VALUES ($1, 'Delivery Key', 'unit', NULLIF($2, ''), $3, NULLIF($4, ''))`,
key, provider, kind, recipient)
return key
}
func fxConnection(t *testing.T, ctx context.Context, tx *sql.Tx, provider string, approved bool) string {
t.Helper()
var connectionID string
if approved {
mustScan(t, ctx, tx, &connectionID,
`INSERT INTO core.provider_connections (provider, display_name, approval_state, approved_at)
VALUES ($1, 'Delivery Connection', 'approved', NOW()) RETURNING connection_id`, provider)
} else {
mustScan(t, ctx, tx, &connectionID,
`INSERT INTO core.provider_connections (provider, display_name) VALUES ($1, 'Delivery Connection') RETURNING connection_id`,
provider)
}
return connectionID
}
func fxMapping(t *testing.T, ctx context.Context, tx *sql.Tx, connectionID, provider, key, target string) string {
t.Helper()
var mappingID string
mustScan(t, ctx, tx, &mappingID,
`INSERT INTO core.target_mappings (connection_id, provider, resource_key, target_ref, display_name)
VALUES ($1, $2, $3, $4, 'Delivery Group') RETURNING mapping_id`,
connectionID, provider, key, target)
return mappingID
}
// --- entitlements ----------------------------------------------------------
func fxBoolean(t *testing.T, ctx context.Context, tx *sql.Tx, poolID, key string, granted bool) {
t.Helper()
mustExec(t, ctx, tx,
`INSERT INTO core.boolean_entitlements (pool_id, resource_key, granted) VALUES ($1, $2, $3)`,
poolID, key, granted)
}
func fxLimit(t *testing.T, ctx context.Context, tx *sql.Tx, poolID, key string, limit int64) string {
t.Helper()
var entitlementID string
mustScan(t, ctx, tx, &entitlementID,
`INSERT INTO core.numeric_entitlements (pool_id, resource_key, entitlement_type, resource_limit)
VALUES ($1, $2, 'limit', $3) RETURNING entitlement_id`,
poolID, key, limit)
return entitlementID
}
// --- the desired-state outbox ----------------------------------------------
// notice is one outbox row as (subject_kind, subject_id, resource_key).
type notice struct{ kind, subject, key string }
func (n notice) String() string { return n.kind + "/" + n.subject + "/" + n.key }
// takeNotices removes and returns every outbox row the test's transaction
// wrote, sorted, so each check starts from none. created_at defaults to
// NOW(), which is fixed for one transaction, so the filter finds every row
// this transaction wrote, whatever subject it names, and no row another
// transaction wrote.
func takeNotices(t *testing.T, ctx context.Context, tx *sql.Tx) []notice {
t.Helper()
rows, err := tx.QueryContext(ctx,
`DELETE FROM core.desired_state_outbox WHERE created_at = now()
RETURNING subject_kind, subject_id::text, coalesce(resource_key, '')`)
if err != nil {
t.Fatalf("read notices: %v", err)
}
defer rows.Close()
var got []notice
for rows.Next() {
var n notice
if err := rows.Scan(&n.kind, &n.subject, &n.key); err != nil {
t.Fatal(err)
}
got = append(got, n)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
slices.SortFunc(got, func(a, b notice) int { return strings.Compare(a.String(), b.String()) })
return got
}
// expectNotices requires the outbox rows the test's transaction wrote since
// the last check to be exactly want, in any order, and removes them.
func expectNotices(t *testing.T, ctx context.Context, tx *sql.Tx, what string, want ...notice) {
t.Helper()
got := takeNotices(t, ctx, tx)
slices.SortFunc(want, func(a, b notice) int { return strings.Compare(a.String(), b.String()) })
if !slices.Equal(got, want) {
t.Errorf("%s wrote %v, want %v", what, got, want)
}
}
// --- roles -----------------------------------------------------------------
// asRole runs fn with the transaction's role set to role, then resets it.
func asRole(t *testing.T, ctx context.Context, tx *sql.Tx, role string, fn func()) {
t.Helper()
mustExec(t, ctx, tx, "SET LOCAL ROLE "+role)
defer mustExec(t, ctx, tx, "RESET ROLE")
fn()
}
// applicationRole creates, inside the transaction, a role holding every role
// the login role is a member of, without the login role's superuser bit,
// and returns its name: the privileges the application's login role has in
// a deployment, where it is not a superuser. CREATE ROLE rolls back with the
// transaction.
func applicationRole(t *testing.T, ctx context.Context, tx *sql.Tx) string {
t.Helper()
name := deliveryName("delivery_app_")
mustExec(t, ctx, tx, "CREATE ROLE "+name+" NOLOGIN")
rows, err := tx.QueryContext(ctx,
`SELECT m.roleid::regrole::text FROM pg_auth_members m WHERE m.member = current_user::regrole`)
if err != nil {
t.Fatalf("list the login role's memberships: %v", err)
}
var roles []string
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
t.Fatal(err)
}
roles = append(roles, r)
}
rows.Close()
if !slices.Contains(roles, "core_writer") {
t.Fatalf("the login role is not a member of core_writer (memberships %v)", roles)
}
for _, r := range roles {
mustExec(t, ctx, tx, "GRANT "+r+" TO "+name)
}
return name
}
@@ -0,0 +1,367 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// Migration 00024 (openspec change integration-delivery-core, tasks 1.14 and
// 2.15). The test creates a scratch database in the test cluster, migrates
// core to 00023, snapshots the schema from the catalogs, applies 00024, rolls
// it back, and requires the snapshot after Down to equal the one before Up.
// It then applies 00024 again, requires the same schema as the first Up,
// migrates every other stream on top, and checks where the seven delivery
// tables live. A scratch database keeps Down away from the shared test
// database the other packages use.
import (
"context"
"database/sql"
"fmt"
"io/fs"
"net/url"
"os"
"slices"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
goosedb "github.com/pressly/goose/v3/database"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
)
const (
deliveryMigrationVersion = 24
deliveryPreviousVersion = 23
)
// deliveryTables are the seven tables 00024 creates.
var deliveryTables = []string{
"desired_state_outbox",
"desired_state_records",
"lifecycle_requests",
"observed_projections",
"person_cut_offs",
"provider_connections",
"target_mappings",
}
// createScratchDatabase creates an empty database in the cluster
// TEST_DATABASE_URL points at, drops it when the test ends, and returns its
// DSN. The test stack's login role may create databases; a cluster where it
// cannot skips the test.
func createScratchDatabase(t *testing.T) string {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { admin.Close() })
name := fmt.Sprintf("mc_scratch_delivery_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE DATABASE " + name); err != nil {
t.Skipf("cannot create a scratch database: %v", err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP DATABASE IF EXISTS " + name + " WITH (FORCE)"); err != nil {
t.Logf("drop scratch database %s: %v", name, err)
}
})
u, err := url.Parse(dsn)
if err != nil {
t.Fatalf("parse TEST_DATABASE_URL: %v", err)
}
u.Path = "/" + name
return u.String()
}
// coreProvider returns a goose provider over the core stream on its own
// ledger table, without goose's process-wide state.
func coreProvider(t *testing.T, database *sql.DB) *goose.Provider {
t.Helper()
src := db.BaseSources()[0]
fsys, err := fs.Sub(src.Migrations, src.Dir)
if err != nil {
t.Fatalf("core migrations: %v", err)
}
store, err := goosedb.NewStore(goosedb.DialectPostgres, "goose_db_version_"+src.Name)
if err != nil {
t.Fatalf("goose store: %v", err)
}
provider, err := goose.NewProvider("", database, fsys,
goose.WithStore(store), goose.WithDisableGlobalRegistry(true))
if err != nil {
t.Fatalf("goose provider: %v", err)
}
return provider
}
func TestIntegrationDeliveryMigrationUpDownUp(t *testing.T) {
scratch, err := sql.Open("pgx", createScratchDatabase(t))
if err != nil {
t.Fatalf("open scratch database: %v", err)
}
t.Cleanup(func() { scratch.Close() })
ctx := context.Background()
provider := coreProvider(t, scratch)
if _, err := provider.UpTo(ctx, deliveryPreviousVersion); err != nil {
t.Fatalf("migrate core to %d: %v", deliveryPreviousVersion, err)
}
before := schemaSnapshot(t, ctx, scratch)
if _, err := provider.ApplyVersion(ctx, deliveryMigrationVersion, true); err != nil {
t.Fatalf("apply %d up: %v", deliveryMigrationVersion, err)
}
afterUp := schemaSnapshot(t, ctx, scratch)
if slices.Equal(before, afterUp) {
t.Fatalf("applying %d changed nothing in the snapshot", deliveryMigrationVersion)
}
if _, err := provider.ApplyVersion(ctx, deliveryMigrationVersion, false); err != nil {
t.Fatalf("apply %d down: %v", deliveryMigrationVersion, err)
}
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))
}
if _, err := provider.ApplyVersion(ctx, deliveryMigrationVersion, true); err != nil {
t.Fatalf("apply %d up again: %v", deliveryMigrationVersion, err)
}
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))
}
// Task 2.15: on an empty database after migrating every stream.
if err := db.RunMigrations(scratch, migrate.Sources()); err != nil {
t.Fatalf("migrate every stream: %v", err)
}
assertDeliverySchemaPlacement(t, ctx, scratch)
}
// assertDeliverySchemaPlacement checks task 2.15: the seven tables exist in
// core and in no other schema; the four tables that hold a connection's
// state reference core.provider_connections and nothing in an integration
// schema; no integration table references a delivery table; and the set of
// schemas is the one the streams created before this change.
func assertDeliverySchemaPlacement(t *testing.T, ctx context.Context, database *sql.DB) {
t.Helper()
for _, table := range deliveryTables {
var schemas []string
rows, err := database.QueryContext(ctx,
`SELECT table_schema FROM information_schema.tables WHERE table_name = $1 ORDER BY 1`, table)
if err != nil {
t.Fatalf("look up %s: %v", table, err)
}
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatal(err)
}
schemas = append(schemas, s)
}
rows.Close()
if !slices.Equal(schemas, []string{"core"}) {
t.Errorf("%s exists in schemas %v, want only core", table, schemas)
}
}
for _, table := range []string{"target_mappings", "desired_state_records", "observed_projections", "lifecycle_requests"} {
var toConnections int
var foreign []string
rows, err := database.QueryContext(ctx, `
SELECT rn.nspname || '.' || rc.relname
FROM pg_constraint c
JOIN pg_class rc ON rc.oid = c.confrelid
JOIN pg_namespace rn ON rn.oid = rc.relnamespace
WHERE c.contype = 'f' AND c.conrelid = ('core.' || $1)::regclass`, table)
if err != nil {
t.Fatalf("foreign keys of %s: %v", table, err)
}
for rows.Next() {
var target string
if err := rows.Scan(&target); err != nil {
t.Fatal(err)
}
if target == "core.provider_connections" {
toConnections++
}
if !strings.HasPrefix(target, "core.") {
foreign = append(foreign, target)
}
}
rows.Close()
if toConnections == 0 {
t.Errorf("core.%s holds no foreign key to core.provider_connections", table)
}
if len(foreign) > 0 {
t.Errorf("core.%s references tables outside core: %v", table, foreign)
}
}
var intoDelivery []string
rows, err := database.QueryContext(ctx, `
SELECT n.nspname || '.' || r.relname || ' -> ' || rc.relname
FROM pg_constraint c
JOIN pg_class r ON r.oid = c.conrelid
JOIN pg_namespace n ON n.oid = r.relnamespace
JOIN pg_class rc ON rc.oid = c.confrelid
JOIN pg_namespace rn ON rn.oid = rc.relnamespace
WHERE c.contype = 'f' AND n.nspname <> 'core' AND rn.nspname = 'core'
AND rc.relname = ANY ($1)`, deliveryTables)
if err != nil {
t.Fatalf("references into the delivery tables: %v", err)
}
for rows.Next() {
var ref string
if err := rows.Scan(&ref); err != nil {
t.Fatal(err)
}
intoDelivery = append(intoDelivery, ref)
}
rows.Close()
if len(intoDelivery) > 0 {
t.Errorf("tables outside core reference the delivery tables: %v", intoDelivery)
}
var schemas []string
rows, err = database.QueryContext(ctx, `
SELECT nspname FROM pg_namespace
WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'
ORDER BY nspname`)
if err != nil {
t.Fatalf("list schemas: %v", err)
}
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatal(err)
}
schemas = append(schemas, s)
}
rows.Close()
want := []string{"core", "discourse", "domains", "fedwiki", "public", "stripe"}
if !slices.Equal(schemas, want) {
t.Errorf("schemas = %v, want %v: a connection gets no schema of its own", schemas, want)
}
}
// schemaSnapshot lists, one line per object, everything a migration can
// change in the database's own schemas: relations and their privileges,
// columns with their types, defaults, generation and column privileges
// (not their attnum, which a dropped and re-added column does not keep),
// constraints, indexes, triggers, functions with their definitions, owners,
// security and privileges, and comments. The lines are sorted, so two
// snapshots compare with slices.Equal.
func schemaSnapshot(t *testing.T, ctx context.Context, database *sql.DB) []string {
t.Helper()
const userSchemas = `n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema'`
queries := []string{
`SELECT 'schema ' || n.nspname || ' owner=' || pg_get_userbyid(n.nspowner) || ' acl=' || coalesce(n.nspacl::text, '')
FROM pg_namespace n WHERE ` + userSchemas,
`SELECT 'relation ' || n.nspname || '.' || c.relname || ' kind=' || c.relkind::text || ' owner=' || pg_get_userbyid(c.relowner)
|| ' acl=' || coalesce(c.relacl::text, '')
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE ` + userSchemas + ` AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')`,
`SELECT 'column ' || n.nspname || '.' || c.relname || '.' || a.attname
|| ' ' || format_type(a.atttypid, a.atttypmod) || ' notnull=' || a.attnotnull
|| ' default=' || coalesce(pg_get_expr(d.adbin, d.adrelid), '')
|| ' generated=' || a.attgenerated::text || ' identity=' || a.attidentity::text
|| ' acl=' || coalesce(a.attacl::text, '')
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE ` + userSchemas + ` AND c.relkind IN ('r', 'p', 'v', 'm', 'f') AND a.attnum > 0 AND NOT a.attisdropped`,
`SELECT 'constraint ' || n.nspname || '.' || c.relname || '.' || con.conname || ' ' || pg_get_constraintdef(con.oid)
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE ` + userSchemas,
`SELECT 'index ' || n.nspname || '.' || i.relname || ' ' || pg_get_indexdef(i.oid)
FROM pg_index x
JOIN pg_class i ON i.oid = x.indexrelid
JOIN pg_namespace n ON n.oid = i.relnamespace
WHERE ` + userSchemas,
`SELECT 'trigger ' || pg_get_triggerdef(tg.oid) || ' enabled=' || tg.tgenabled::text
FROM pg_trigger tg
JOIN pg_class c ON c.oid = tg.tgrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE ` + userSchemas + ` AND NOT tg.tgisinternal`,
`SELECT 'function ' || p.oid::regprocedure::text || ' owner=' || pg_get_userbyid(p.proowner)
|| ' secdef=' || p.prosecdef || ' acl=' || coalesce(p.proacl::text, '') || E'\n'
|| CASE WHEN p.prokind IN ('f', 'p', 'w') THEN pg_get_functiondef(p.oid) ELSE '' END
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE ` + userSchemas,
`SELECT 'comment ' || d.classoid::regclass::text || ' ' || d.objoid || ' ' || d.objsubid || ' ' || d.description
FROM pg_description d
JOIN pg_class c ON d.classoid = 'pg_class'::regclass AND c.oid = d.objoid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE ` + userSchemas,
`SELECT 'extension ' || e.extname || ' ' || e.extversion FROM pg_extension e`,
}
var lines []string
for _, q := range queries {
rows, err := database.QueryContext(ctx, q)
if err != nil {
t.Fatalf("snapshot query %q: %v", q, err)
}
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
rows.Close()
t.Fatal(err)
}
lines = append(lines, line)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
rows.Close()
}
// A comment is keyed by the object's OID, which a re-created table does
// not keep; name the relation instead.
for i, line := range lines {
if strings.HasPrefix(line, "comment ") {
lines[i] = commentByName(t, ctx, database, line)
}
}
slices.Sort(lines)
return lines
}
func commentByName(t *testing.T, ctx context.Context, database *sql.DB, line string) string {
t.Helper()
parts := strings.SplitN(line, " ", 5)
if len(parts) < 5 {
return line
}
var name string
if err := database.QueryRowContext(ctx, `SELECT $1::oid::regclass::text`, parts[2]).Scan(&name); err != nil {
t.Fatalf("name the commented relation %s: %v", parts[2], err)
}
return "comment " + name + " " + parts[3] + " " + parts[4]
}
// snapshotDiff names the lines only one of two snapshots holds.
func snapshotDiff(want, got []string) string {
var b strings.Builder
for _, line := range want {
if !slices.Contains(got, line) {
fmt.Fprintf(&b, " missing: %s\n", line)
}
}
for _, line := range got {
if !slices.Contains(want, line) {
fmt.Fprintf(&b, " extra: %s\n", line)
}
}
return b.String()
}
@@ -0,0 +1,678 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// Constraint tests for the tables migration 00024 adds (openspec change
// integration-delivery-core, tasks 2.1, 2.2, 2.3, 2.5, 2.6, 2.12, 2.13 and
// 2.14). Each test runs in one transaction it rolls back, except the
// simulated restore, which needs the clock to move between transactions.
import (
"context"
"database/sql"
"encoding/json"
"strings"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// --- 2.1 connections -------------------------------------------------------
func TestProviderConnectionConstraints(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
t.Run("the database chooses the identifier", func(t *testing.T) {
var connectionID string
var version int
mustScan(t, ctx, tx, &connectionID,
`INSERT INTO core.provider_connections (provider, display_name, updated_at)
VALUES ($1, 'Delivery Connection', '2000-01-01T00:00:00Z') RETURNING connection_id`, provider)
mustScan(t, ctx, tx, &version, `SELECT uuid_extract_version($1::uuid)`, connectionID)
if version != 7 {
t.Errorf("connection_id %s is UUID version %d, want 7", connectionID, version)
}
var keyColumns int
mustScan(t, ctx, tx, &keyColumns,
`SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'core' AND table_name = 'provider_connections' AND column_name IN ('key', 'slug')`)
if keyColumns != 0 {
t.Errorf("core.provider_connections has %d key columns, want none", keyColumns)
}
// An update stamps updated_at; the fixture row was inserted with an
// old one, because NOW() does not move inside one transaction.
mustExec(t, ctx, tx, `UPDATE core.provider_connections SET display_name = 'Renamed' WHERE connection_id = $1`, connectionID)
var stamped bool
mustScan(t, ctx, tx, &stamped,
`SELECT updated_at > '2000-01-01T00:00:00Z' FROM core.provider_connections WHERE connection_id = $1`, connectionID)
if !stamped {
t.Errorf("an update left updated_at as it was")
}
// A second connection for one provider is refused, and so is one
// beside a retired connection.
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_provider_connections_one_per_provider",
`INSERT INTO core.provider_connections (provider, display_name) VALUES ($1, 'Second')`, provider)
mustExec(t, ctx, tx,
`UPDATE core.provider_connections SET status = 'retired', retired_at = NOW() WHERE connection_id = $1`, connectionID)
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_provider_connections_one_per_provider",
`INSERT INTO core.provider_connections (provider, display_name) VALUES ($1, 'Beside a retired one')`, provider)
})
t.Run("refusals", func(t *testing.T) {
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_provider_connections_provider",
`INSERT INTO core.provider_connections (provider, display_name) VALUES ($1, 'Unknown')`, deliveryName("dsmissing"))
other := fxProvider(t, ctx, tx)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_provider_connections_status",
`INSERT INTO core.provider_connections (provider, display_name, status) VALUES ($1, 'Paused', 'paused')`, other)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_provider_connections_approval_state",
`INSERT INTO core.provider_connections (provider, display_name, approval_state, approved_at)
VALUES ($1, 'Declined', 'declined', NOW())`, other)
mustRefuse(t, ctx, tx, sqlstateCheck, "connection_approved_shape",
`INSERT INTO core.provider_connections (provider, display_name, approval_state) VALUES ($1, 'No time', 'approved')`, other)
mustRefuse(t, ctx, tx, sqlstateCheck, "connection_approved_shape",
`INSERT INTO core.provider_connections (provider, display_name, approved_at) VALUES ($1, 'Pending with a time', NOW())`, other)
})
t.Run("a suspended connection stays approved", func(t *testing.T) {
connectionID := fxConnection(t, ctx, tx, fxProvider(t, ctx, tx), true)
var approvedBefore time.Time
mustScan(t, ctx, tx, &approvedBefore, `SELECT approved_at FROM core.provider_connections WHERE connection_id = $1`, connectionID)
mustExec(t, ctx, tx,
`UPDATE core.provider_connections SET status = 'suspended', suspended_at = NOW() WHERE connection_id = $1`, connectionID)
var state string
var approvedAfter time.Time
if err := tx.QueryRowContext(ctx,
`SELECT approval_state, approved_at FROM core.provider_connections WHERE connection_id = $1`, connectionID,
).Scan(&state, &approvedAfter); err != nil {
t.Fatal(err)
}
if state != "approved" || !approvedAfter.Equal(approvedBefore) {
t.Errorf("after suspending, approval is %s at %v, want approved at %v", state, approvedAfter, approvedBefore)
}
})
}
// --- 2.2 target mappings ---------------------------------------------------
func TestTargetMappingConstraints(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
ownProvider := fxProvider(t, ctx, tx)
ownKey := fxKey(t, ctx, tx, ownProvider, "boolean", "person")
ownConnection := fxConnection(t, ctx, tx, ownProvider, true)
otherProvider := fxProvider(t, ctx, tx)
otherKey := fxKey(t, ctx, tx, otherProvider, "boolean", "person")
otherConnection := fxConnection(t, ctx, tx, otherProvider, true)
platformKey := fxKey(t, ctx, tx, "", "boolean", "")
insert := `INSERT INTO core.target_mappings (connection_id, provider, resource_key, target_ref, display_name)
VALUES ($1, $2, $3, $4, 'Delivery Group')`
if mappingID := fxMapping(t, ctx, tx, ownConnection, ownProvider, ownKey, "17"); mappingID == "" {
t.Fatalf("a mapping to an owned key got no mapping_id")
}
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_target_mappings_resource_key",
insert, ownConnection, ownProvider, otherKey, "20")
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_target_mappings_resource_key",
insert, ownConnection, ownProvider, platformKey, "21")
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_target_mappings_connection",
insert, ownConnection, otherProvider, otherKey, "22")
_ = otherConnection
// One target takes one mapping; one key maps to several targets.
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_target_mappings_connection_target",
insert, ownConnection, ownProvider, ownKey, "17")
fxMapping(t, ctx, tx, ownConnection, ownProvider, ownKey, "42")
var mapped int
mustScan(t, ctx, tx, &mapped, `SELECT COUNT(*) FROM core.target_mappings WHERE resource_key = $1`, ownKey)
if mapped != 2 {
t.Errorf("the key has %d mappings, want 2", mapped)
}
// Registration cannot move a mapped key to another provider.
mustExec(t, ctx, tx, "SAVEPOINT move_key")
_, err := integration.New(tx).SetResourceKeyProvider(ctx, integration.SetResourceKeyProviderParams{
ResourceKey: ownKey,
Provider: sql.NullString{String: otherProvider, Valid: true},
OverLimitBehavior: "deny_new",
})
mustExec(t, ctx, tx, "ROLLBACK TO SAVEPOINT move_key")
assertPgError(t, err, sqlstateForeignKey, "fk_target_mappings_resource_key")
var owner string
mustScan(t, ctx, tx, &owner, `SELECT provider FROM core.resource_keys WHERE resource_key = $1`, ownKey)
if owner != ownProvider {
t.Errorf("the mapped key belongs to %s, want %s", owner, ownProvider)
}
}
// --- 2.3 references to the connection and the recipient --------------------
func TestConnectionStateForeignKeysAndRecipientArc(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
connectionID := fxConnection(t, ctx, tx, provider, true)
person := fxPerson(t, ctx, tx)
org := fxOrg(t, ctx, tx, person)
workspace := fxWorkspace(t, ctx, tx, org)
unknown := uuid.NewString()
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_target_mappings_connection",
`INSERT INTO core.target_mappings (connection_id, provider, resource_key, target_ref, display_name)
VALUES ($1, $2, $3, '17', 'Delivery Group')`, unknown, provider, key)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_desired_state_records_connection_id",
`INSERT INTO core.desired_state_records (connection_id, person_id, content, generation)
VALUES ($1, $2, '{"entitlements": []}', 1)`, unknown, person)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_observed_projections_connection_id",
`INSERT INTO core.observed_projections (connection_id, person_id, observed_at) VALUES ($1, $2, NOW())`, unknown, person)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_lifecycle_requests_connection_id",
`INSERT INTO core.lifecycle_requests (instance_id, generation, connection_id, resource_key, workspace_id, operation, workflow_id)
VALUES (uuidv7(), 1, $1, $2, $3, 'create', 'wf')`, unknown, key, workspace)
record := `INSERT INTO core.desired_state_records (connection_id, person_id, org_id, content, generation)
VALUES ($1, $2, $3, $4, 1)`
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_desired_state_records_recipient",
record, connectionID, nil, nil, `{"entitlements": []}`)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_desired_state_records_recipient",
record, connectionID, person, org, `{"entitlements": []}`)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_desired_state_records_person_id",
record, connectionID, unknown, nil, `{"entitlements": []}`)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_desired_state_records_org_id",
record, connectionID, nil, unknown, `{"entitlements": []}`)
for _, content := range []string{`[]`, `{}`, `{"entitlements": {}}`, `"entitlements"`} {
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_desired_state_records_content",
record, connectionID, person, nil, content)
}
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_desired_state_records_generation",
`INSERT INTO core.desired_state_records (connection_id, person_id, content, generation)
VALUES ($1, $2, '{"entitlements": []}', 0)`, connectionID, person)
// One record per connection and recipient.
mustExec(t, ctx, tx, record, connectionID, person, nil, `{"entitlements": []}`)
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_desired_state_records_connection_person",
record, connectionID, person, nil, `{"entitlements": []}`)
mustExec(t, ctx, tx, record, connectionID, nil, org, `{"entitlements": []}`)
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_desired_state_records_connection_org",
record, connectionID, nil, org, `{"entitlements": []}`)
}
// --- 2.5 the content hash --------------------------------------------------
func TestDesiredStateContentHash(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
connectionID := fxConnection(t, ctx, tx, provider, true)
owner := fxPerson(t, ctx, tx)
member := fxPerson(t, ctx, tx)
org := fxOrg(t, ctx, tx, owner)
fxMember(t, ctx, tx, org, member, "active")
fxBoolean(t, ctx, tx, fxPool(t, ctx, tx, org, "default"), key, true)
insertRecord := func(person, content string) []byte {
t.Helper()
var hash []byte
mustScan(t, ctx, tx, &hash,
`INSERT INTO core.desired_state_records (connection_id, person_id, content, generation)
VALUES ($1, $2, $3, core.next_generation(NULL, NULL)) RETURNING content_hash`,
connectionID, person, content)
return hash
}
// Two contents written with their object keys in another order.
first := insertRecord(owner, `{"entitlements": [], "b": 1, "a": 2}`)
second := insertRecord(member, `{"a": 2, "b": 1, "entitlements": []}`)
if string(first) != string(second) || len(first) != 32 {
t.Errorf("contents differing only in key order hash to %x and %x, want one SHA-256", first, second)
}
// A target holding a quote, a backslash and a newline.
awkward := "group \"a\\b\"\nline two"
fxMapping(t, ctx, tx, connectionID, provider, key, awkward)
ownerTwo := fxPerson(t, ctx, tx)
memberTwo := fxPerson(t, ctx, tx)
orgTwo := fxOrg(t, ctx, tx, ownerTwo)
fxMember(t, ctx, tx, orgTwo, memberTwo, "active")
fxBoolean(t, ctx, tx, fxPool(t, ctx, tx, orgTwo, "default"), key, true)
var content string
mustScan(t, ctx, tx, &content, `SELECT core.desired_state_content($1, $2, NULL)::text`, connectionID, ownerTwo)
hashOwner := insertRecord(ownerTwo, content)
var contentMember string
mustScan(t, ctx, tx, &contentMember, `SELECT core.desired_state_content($1, $2, NULL)::text`, connectionID, memberTwo)
hashMember := insertRecord(memberTwo, contentMember)
if string(hashOwner) != string(hashMember) {
t.Errorf("equal contents carrying an awkward target hash to %x and %x", hashOwner, hashMember)
}
var stored string
mustScan(t, ctx, tx, &stored,
`SELECT content::text FROM core.desired_state_records WHERE connection_id = $1 AND person_id = $2`, connectionID, ownerTwo)
var decoded struct {
Entitlements []struct {
Key string `json:"key"`
Granted bool `json:"granted"`
Targets []string `json:"targets"`
} `json:"entitlements"`
}
if err := json.Unmarshal([]byte(stored), &decoded); err != nil {
t.Fatalf("decode stored content %s: %v", stored, err)
}
if len(decoded.Entitlements) != 1 || len(decoded.Entitlements[0].Targets) != 1 ||
decoded.Entitlements[0].Targets[0] != awkward {
t.Errorf("stored content %s does not return the target %q exactly", stored, awkward)
}
var same bool
mustScan(t, ctx, tx, &same,
`SELECT content_hash = sha256(jsonb_send(core.desired_state_content($1, $2, NULL)))
FROM core.desired_state_records WHERE connection_id = $1 AND person_id = $2`, connectionID, ownerTwo)
if !same {
t.Errorf("recomputing the same content gives another hash")
}
}
// --- 2.6 generations -------------------------------------------------------
func TestGenerationFunctions(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
var clock, ceiling, fromNothing, fromFive int64
if err := tx.QueryRowContext(ctx, `
SELECT (EXTRACT(EPOCH FROM now()) * 1000000)::BIGINT,
core.generation_ceiling(),
core.next_generation(NULL, NULL),
core.next_generation(5, NULL)`).Scan(&clock, &ceiling, &fromNothing, &fromFive); err != nil {
t.Fatal(err)
}
if fromNothing != clock || fromFive != clock {
t.Errorf("next_generation(NULL, NULL) = %d and next_generation(5, NULL) = %d, want the clock %d",
fromNothing, fromFive, clock)
}
if ceiling != clock+86_400_000_000 {
t.Errorf("generation_ceiling() = %d, want the clock %d plus one day", ceiling, clock)
}
current := clock + 10
reported := clock + 5_000_000
var raised int64
mustScan(t, ctx, tx, &raised, `SELECT core.next_generation($1, $2)`, current, reported)
if raised <= current || raised <= reported {
t.Errorf("next_generation(%d, %d) = %d, want above both", current, reported, raised)
}
mustScan(t, ctx, tx, &raised, `SELECT core.next_generation($1, $2)`, current, ceiling)
if raised <= ceiling {
t.Errorf("next_generation at the ceiling = %d, want above it", raised)
}
mustRefuse(t, ctx, tx, sqlstateOutOfRange, "", `SELECT core.next_generation($1, $2)`, current, ceiling+1)
mustRefuse(t, ctx, tx, sqlstateOutOfRange, "", `SELECT core.next_generation($1, 9223372036854775807)`, current)
// The guard trigger.
provider := fxProvider(t, ctx, tx)
connectionID := fxConnection(t, ctx, tx, provider, true)
person := fxPerson(t, ctx, tx)
var recordID string
var generation int64
if err := tx.QueryRowContext(ctx,
`INSERT INTO core.desired_state_records (connection_id, person_id, content, generation)
VALUES ($1, $2, '{"entitlements": []}', core.next_generation(NULL, NULL)) RETURNING record_id, generation`,
connectionID, person).Scan(&recordID, &generation); err != nil {
t.Fatal(err)
}
mustRefuse(t, ctx, tx, sqlstateCheck, "guard_desired_state_generation",
`UPDATE core.desired_state_records SET generation = generation - 1 WHERE record_id = $1`, recordID)
mustRefuse(t, ctx, tx, sqlstateCheck, "guard_desired_state_generation",
`UPDATE core.desired_state_records SET content = '{"entitlements": [{"key": "k", "granted": true}]}'
WHERE record_id = $1`, recordID)
mustExec(t, ctx, tx,
`UPDATE core.desired_state_records
SET content = '{"entitlements": [{"key": "k", "granted": true}]}', generation = core.next_generation(generation, NULL)
WHERE record_id = $1`, recordID)
mustExec(t, ctx, tx,
`UPDATE core.desired_state_records SET generation = core.next_generation(generation, $2) WHERE record_id = $1`,
recordID, reported)
var after int64
mustScan(t, ctx, tx, &after, `SELECT generation FROM core.desired_state_records WHERE record_id = $1`, recordID)
if after <= reported {
t.Errorf("a floor raise left generation %d, want above %d", after, reported)
}
}
// A restore simulated by writing a record back to an older generation and
// content, then changing it, gives a generation above every generation
// issued before the restore. Each step is its own transaction, because the
// clock floor is the transaction's NOW(). The write-back runs as core_owner
// with the guard trigger disabled for that one statement; disabling a
// trigger needs the table's owner, the migration role.
func TestGenerationsSurviveASimulatedRestore(t *testing.T) {
database := deliveryTestDB(t)
ctx := context.Background()
setup, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
provider := fxProvider(t, ctx, setup)
connectionID := fxConnection(t, ctx, setup, provider, true)
person := fxPerson(t, ctx, setup)
var recordID string
var g1 int64
if err := setup.QueryRowContext(ctx,
`INSERT INTO core.desired_state_records (connection_id, person_id, content, generation)
VALUES ($1, $2, '{"entitlements": [{"key": "one", "granted": true}]}', core.next_generation(NULL, NULL))
RETURNING record_id, generation`, connectionID, person).Scan(&recordID, &g1); err != nil {
t.Fatal(err)
}
if err := setup.Commit(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
for _, q := range []string{
`DELETE FROM core.desired_state_records WHERE record_id = '` + recordID + `'`,
`DELETE FROM core.provider_connections WHERE connection_id = '` + connectionID + `'`,
`DELETE FROM core.desired_state_outbox WHERE subject_id = '` + connectionID + `'`,
`DELETE FROM core.providers WHERE provider = '` + provider + `'`,
`DELETE FROM core.users WHERE user_id = (SELECT user_id FROM core.persons WHERE person_id = '` + person + `')`,
} {
if _, err := database.ExecContext(ctx, q); err != nil {
t.Logf("cleanup %q: %v", q, err)
}
}
})
step := func(query string) int64 {
t.Helper()
time.Sleep(5 * time.Millisecond)
var generation int64
mustScan(t, ctx, database, &generation, query, recordID)
return generation
}
g2 := step(`UPDATE core.desired_state_records
SET content = '{"entitlements": [{"key": "two", "granted": true}]}',
generation = core.next_generation(generation, NULL)
WHERE record_id = $1 RETURNING generation`)
issuedBefore := max(g1, g2)
restore, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
mustExec(t, ctx, restore, `ALTER TABLE core.desired_state_records DISABLE TRIGGER trigger_desired_state_records_guard_generation`)
mustExec(t, ctx, restore, `SET LOCAL ROLE core_owner`)
mustExec(t, ctx, restore,
`UPDATE core.desired_state_records SET content = '{"entitlements": [{"key": "one", "granted": true}]}', generation = $2
WHERE record_id = $1`, recordID, g1)
mustExec(t, ctx, restore, `RESET ROLE`)
mustExec(t, ctx, restore, `ALTER TABLE core.desired_state_records ENABLE TRIGGER trigger_desired_state_records_guard_generation`)
if err := restore.Commit(); err != nil {
t.Fatal(err)
}
g3 := step(`UPDATE core.desired_state_records
SET content = '{"entitlements": [{"key": "three", "granted": true}]}',
generation = core.next_generation(generation, NULL)
WHERE record_id = $1 RETURNING generation`)
if g3 <= issuedBefore {
t.Errorf("after the simulated restore the record was raised to %d, want above %d, every generation issued before it", g3, issuedBefore)
}
}
// --- 2.12 lifecycle requests -----------------------------------------------
func TestLifecycleRequestConstraints(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "numeric", "organization")
connectionID := fxConnection(t, ctx, tx, provider, true)
person := fxPerson(t, ctx, tx)
workspace := fxWorkspace(t, ctx, tx, fxOrg(t, ctx, tx, person))
instance := uuid.NewString()
insert := `INSERT INTO core.lifecycle_requests
(instance_id, generation, connection_id, resource_key, workspace_id, operation, workflow_id,
requested_by_person_id, status, finished_at, reason_code, parameters)
VALUES ($1, $2, $3, $4, $5, $6, 'wf', $7, $8, $9, $10, $11)`
request := func(instance string, generation int64, operation, status string, finished bool, reason string) []any {
var finishedAt any
if finished {
finishedAt = time.Now()
}
var reasonCode any
if reason != "" {
reasonCode = reason
}
return []any{instance, generation, connectionID, key, workspace, operation, person, status, finishedAt, reasonCode, `{}`}
}
mustExec(t, ctx, tx, insert, request(instance, 10, "create", "accepted", false, "")...)
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_lifecycle_requests_one_open",
insert, request(instance, 11, "set_status", "accepted", false, "")...)
mustExec(t, ctx, tx,
`UPDATE core.lifecycle_requests SET status = 'succeeded', finished_at = NOW() WHERE instance_id = $1 AND generation = 10`,
instance)
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_lifecycle_requests_instance_generation",
insert, request(instance, 10, "create", "accepted", false, "")...)
mustExec(t, ctx, tx, insert, request(instance, 11, "set_status", "accepted", false, "")...)
other := uuid.NewString()
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_lifecycle_requests_operation",
insert, request(other, 1, "list", "accepted", false, "")...)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_lifecycle_requests_status",
insert, request(other, 1, "create", "pending", true, "")...)
mustRefuse(t, ctx, tx, sqlstateCheck, "lifecycle_request_finished_shape",
insert, request(other, 1, "create", "succeeded", false, "")...)
mustRefuse(t, ctx, tx, sqlstateCheck, "lifecycle_request_finished_shape",
insert, request(other, 1, "create", "accepted", true, "")...)
mustRefuse(t, ctx, tx, sqlstateCheck, "lifecycle_request_reason_shape",
insert, request(other, 1, "create", "accepted", false, "site_limit_reached")...)
mustRefuse(t, ctx, tx, sqlstateCheck, "lifecycle_request_reason_shape",
insert, request(other, 1, "create", "succeeded", true, "site_limit_reached")...)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_lifecycle_requests_generation",
insert, request(other, 0, "create", "accepted", false, "")...)
bad := request(other, 1, "create", "accepted", false, "")
bad[len(bad)-1] = `[]`
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_lifecycle_requests_parameters", insert, bad...)
mustExec(t, ctx, tx, insert, request(other, 1, "create", "declined", true, "site_limit_reached")...)
mustExec(t, ctx, tx, insert, request(uuid.NewString(), 1, "delete", "failed", true, "retries_exhausted")...)
}
// --- 2.13 the outbox -------------------------------------------------------
func TestDesiredStateOutboxConstraints(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
pool := fxPool(t, ctx, tx, fxOrg(t, ctx, tx, fxPerson(t, ctx, tx)), "default")
fxBoolean(t, ctx, tx, pool, key, true)
var version int
mustScan(t, ctx, tx, &version,
`SELECT uuid_extract_version(id) FROM core.desired_state_outbox WHERE subject_id = $1`, pool)
if version != 7 {
t.Errorf("a trigger-written row's id is UUID version %d, want 7", version)
}
var sequenced int
mustScan(t, ctx, tx, &sequenced, `
SELECT COUNT(*) FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = 'core.desired_state_outbox'::regclass AND a.attnum > 0 AND NOT a.attisdropped
AND (a.attidentity <> '' OR pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval%')`)
if sequenced != 0 {
t.Errorf("core.desired_state_outbox has %d identity or serial columns, want none", sequenced)
}
insert := `INSERT INTO core.desired_state_outbox (subject_kind, subject_id, recipient_only, status, completed_at)
VALUES ($1, uuidv7(), $2, $3, $4)`
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_desired_state_outbox_subject_kind", insert, "workspace", false, "pending", nil)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_desired_state_outbox_status", insert, "pool", false, "done", nil)
mustRefuse(t, ctx, tx, sqlstateCheck, "desired_state_outbox_completed_shape", insert, "pool", false, "completed", nil)
mustRefuse(t, ctx, tx, sqlstateCheck, "desired_state_outbox_completed_shape", insert, "pool", false, "pending", time.Now())
mustRefuse(t, ctx, tx, sqlstateCheck, "desired_state_outbox_completed_shape", insert, "pool", false, "failed", time.Now())
mustRefuse(t, ctx, tx, sqlstateCheck, "desired_state_outbox_recipient_only_shape", insert, "pool", true, "pending", nil)
mustRefuse(t, ctx, tx, sqlstateCheck, "desired_state_outbox_recipient_only_shape", insert, "connection", true, "pending", nil)
mustExec(t, ctx, tx, insert, "person", true, "pending", nil)
mustExec(t, ctx, tx, insert, "organization", true, "completed", time.Now())
}
// --- 2.14 observed projections ---------------------------------------------
func TestObservedProjectionConstraints(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
connectionID := fxConnection(t, ctx, tx, provider, true)
person := fxPerson(t, ctx, tx)
org := fxOrg(t, ctx, tx, person)
insert := `INSERT INTO core.observed_projections
(connection_id, person_id, org_id, result, ahead_generation, ahead_at, observed, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`
row := func(person, org, result any, aheadGeneration, aheadAt any, observed string) []any {
return []any{connectionID, person, org, result, aheadGeneration, aheadAt, observed}
}
empty := `{"entitlements": []}`
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_observed_projections_recipient", insert, row(nil, nil, nil, nil, nil, empty)...)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_observed_projections_recipient", insert, row(person, org, nil, nil, nil, empty)...)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_observed_projections_result", insert, row(person, nil, "stale", nil, nil, empty)...)
mustRefuse(t, ctx, tx, sqlstateCheck, "observed_projection_ahead_shape", insert, row(person, nil, nil, 5, nil, empty)...)
mustRefuse(t, ctx, tx, sqlstateCheck, "observed_projection_ahead_shape", insert, row(person, nil, nil, nil, time.Now(), empty)...)
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_observed_projections_observed", insert, row(person, nil, nil, nil, nil, `[]`)...)
// A recipient with no record at the connection: the projection has no
// foreign key to the record.
mustExec(t, ctx, tx, insert, row(person, nil, "unlinked", 5, time.Now(), empty)...)
mustExec(t, ctx, tx, insert, row(nil, org, "applied", nil, nil, empty)...)
var records int
mustScan(t, ctx, tx, &records, `SELECT COUNT(*) FROM core.desired_state_records WHERE connection_id = $1`, connectionID)
if records != 0 {
t.Fatalf("the connection has %d records, want none", records)
}
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_observed_projections_connection_person", insert, row(person, nil, nil, nil, nil, empty)...)
mustRefuse(t, ctx, tx, sqlstateUnique, "uq_observed_projections_connection_org", insert, row(nil, org, nil, nil, nil, empty)...)
}
// --- facts the specs and tasks name that no behaviour above exercises ------
func TestDeliverySchemaFacts(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "numeric", "organization")
connectionID := fxConnection(t, ctx, tx, provider, true)
person := fxPerson(t, ctx, tx)
workspace := fxWorkspace(t, ctx, tx, fxOrg(t, ctx, tx, person))
unknown := uuid.NewString()
t.Run("foreign keys", func(t *testing.T) {
request := `INSERT INTO core.lifecycle_requests
(instance_id, generation, connection_id, resource_key, workspace_id, operation, workflow_id, requested_by_person_id)
VALUES (uuidv7(), 1, $1, $2, $3, 'create', 'wf', $4)`
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_lifecycle_requests_resource_key",
request, connectionID, deliveryName("dsmissing_"), workspace, person)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_lifecycle_requests_workspace_id",
request, connectionID, key, unknown, person)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_lifecycle_requests_requested_by_person_id",
request, connectionID, key, workspace, unknown)
projection := `INSERT INTO core.observed_projections (connection_id, person_id, org_id, observed_at) VALUES ($1, $2, $3, NOW())`
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_observed_projections_person_id", projection, connectionID, unknown, nil)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_observed_projections_org_id", projection, connectionID, nil, unknown)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_person_cut_offs_cut_off_by_person_id",
`INSERT INTO core.person_cut_offs (person_id, cut_off_by_person_id) VALUES ($1, $2)`, person, unknown)
mustRefuse(t, ctx, tx, sqlstateForeignKey, "fk_person_cut_offs_lifted_by_person_id",
`INSERT INTO core.person_cut_offs (person_id, lifted_at, lifted_by_person_id) VALUES ($1, NOW(), $2)`, person, unknown)
// The outbox names a subject in one of four tables and references none.
var outboxKeys int
mustScan(t, ctx, tx, &outboxKeys,
`SELECT COUNT(*) FROM pg_constraint WHERE contype = 'f' AND conrelid = 'core.desired_state_outbox'::regclass`)
if outboxKeys != 0 {
t.Errorf("core.desired_state_outbox has %d foreign keys, want none", outboxKeys)
}
})
t.Run("the database never chooses an instance", func(t *testing.T) {
var hasDefault bool
mustScan(t, ctx, tx, &hasDefault,
`SELECT column_default IS NOT NULL FROM information_schema.columns
WHERE table_schema = 'core' AND table_name = 'lifecycle_requests' AND column_name = 'instance_id'`)
if hasDefault {
t.Errorf("core.lifecycle_requests.instance_id has a default; the console chooses every instance identifier")
}
})
t.Run("a mapping update stamps updated_at", func(t *testing.T) {
mappingKey := fxKey(t, ctx, tx, provider, "boolean", "person")
var mappingID string
mustScan(t, ctx, tx, &mappingID,
`INSERT INTO core.target_mappings (connection_id, provider, resource_key, target_ref, display_name, updated_at)
VALUES ($1, $2, $3, '17', 'Delivery Group', '2000-01-01T00:00:00Z') RETURNING mapping_id`,
connectionID, provider, mappingKey)
mustExec(t, ctx, tx, `UPDATE core.target_mappings SET display_name = 'Renamed' WHERE mapping_id = $1`, mappingID)
var stamped bool
mustScan(t, ctx, tx, &stamped,
`SELECT updated_at > '2000-01-01T00:00:00Z' FROM core.target_mappings WHERE mapping_id = $1`, mappingID)
if !stamped {
t.Errorf("a mapping update left updated_at as it was")
}
})
t.Run("indexes", func(t *testing.T) {
for name, want := range map[string]string{
"idx_desired_state_outbox_pending": "CREATE INDEX idx_desired_state_outbox_pending ON core.desired_state_outbox " +
"USING btree (id) WHERE ((status)::text = 'pending'::text)",
"idx_target_mappings_connection_key": "CREATE INDEX idx_target_mappings_connection_key ON core.target_mappings " +
"USING btree (connection_id, resource_key)",
"idx_desired_state_records_sweep": "CREATE INDEX idx_desired_state_records_sweep ON core.desired_state_records " +
"USING btree (connection_id, last_dispatched_at)",
"idx_lifecycle_requests_workspace_shown": "CREATE INDEX idx_lifecycle_requests_workspace_shown ON core.lifecycle_requests " +
"USING btree (workspace_id) WHERE (((status)::text <> 'succeeded'::text) AND (acknowledged_at IS NULL))",
} {
var got string
if err := tx.QueryRowContext(ctx,
`SELECT indexdef FROM pg_indexes WHERE schemaname = 'core' AND indexname = $1`, name).Scan(&got); err != nil {
t.Errorf("index %s: %v", name, err)
continue
}
if got != want {
t.Errorf("index %s is\n %s\nwant\n %s", name, got, want)
}
}
})
t.Run("the personal-data rule on parameters", func(t *testing.T) {
var comment sql.NullString
mustScan(t, ctx, tx, &comment,
`SELECT col_description('core.lifecycle_requests'::regclass,
(SELECT attnum FROM pg_attribute WHERE attrelid = 'core.lifecycle_requests'::regclass AND attname = 'parameters'))`)
if !comment.Valid || !strings.Contains(comment.String, "Identifiers and public DNS names only") {
t.Errorf("core.lifecycle_requests.parameters carries comment %v, want the personal-data rule", comment)
}
})
t.Run("recipient checks", func(t *testing.T) {
insert := `INSERT INTO core.resource_keys (resource_key, display_name, unit, kind, provider, recipient)
VALUES ($1, 'Delivery Key', 'unit', $2, NULLIF($3, ''), $4)`
mustRefuse(t, ctx, tx, sqlstateCheck, "person_recipient_keys_boolean",
insert, deliveryName(provider+"_numeric_person_"), "numeric", provider, "person")
mustRefuse(t, ctx, tx, sqlstateCheck, "platform_keys_no_recipient",
insert, deliveryName("dsplatform_recipient_"), "boolean", "", "person")
mustRefuse(t, ctx, tx, sqlstateCheck, "chk_resource_keys_recipient",
insert, deliveryName(provider+"_workspace_"), "boolean", provider, "workspace")
})
}
@@ -0,0 +1,511 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// The desired-state outbox triggers (openspec change integration-delivery-
// core, tasks 2.7, 2.8 and the plain-SQL half of 2.9). Each check makes one
// change and requires exactly the rows written as (subject_kind, subject_id,
// resource_key). The materialization and first-login halves of 2.9 live in
// internal/entitlements and internal/provisioning, beside the code they
// drive.
import (
"context"
"database/sql"
"regexp"
"slices"
"strings"
"testing"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// --- 2.7 one test per trigger table ----------------------------------------
func TestBooleanEntitlementTrigger(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
otherKey := fxKey(t, ctx, tx, provider, "boolean", "person")
platformKey := fxKey(t, ctx, tx, "", "boolean", "")
org := fxOrg(t, ctx, tx, fxPerson(t, ctx, tx))
pool := fxPool(t, ctx, tx, org, "default")
otherPool := fxPool(t, ctx, tx, org, "shared")
q := entitlements.New(tx)
fxBoolean(t, ctx, tx, pool, key, true)
expectNotices(t, ctx, tx, "granting a boolean", notice{"pool", pool, key})
if _, err := q.UpsertBooleanEntitlementGranted(ctx, entitlements.UpsertBooleanEntitlementGrantedParams{
PoolID: pool, ResourceKey: key,
}); err != nil {
t.Fatal(err)
}
expectNotices(t, ctx, tx, "re-granting through UpsertBooleanEntitlementGranted")
fxBoolean(t, ctx, tx, pool, otherKey, false)
expectNotices(t, ctx, tx, "inserting a lapsed boolean")
mustExec(t, ctx, tx, `UPDATE core.boolean_entitlements SET granted = TRUE WHERE pool_id = $1 AND resource_key = $2`, pool, otherKey)
expectNotices(t, ctx, tx, "granting a lapsed boolean", notice{"pool", pool, otherKey})
mustExec(t, ctx, tx, `UPDATE core.boolean_entitlements SET updated_at = NOW() - INTERVAL '1 day' WHERE pool_id = $1`, pool)
expectNotices(t, ctx, tx, "an undelivered column")
mustExec(t, ctx, tx, `UPDATE core.boolean_entitlements SET pool_id = $3 WHERE pool_id = $1 AND resource_key = $2`, pool, key, otherPool)
expectNotices(t, ctx, tx, "moving a row to another pool", notice{"pool", otherPool, key}, notice{"pool", pool, key})
movedKey := fxKey(t, ctx, tx, provider, "boolean", "person")
mustExec(t, ctx, tx, `UPDATE core.boolean_entitlements SET resource_key = $3 WHERE pool_id = $1 AND resource_key = $2`, otherPool, key, movedKey)
expectNotices(t, ctx, tx, "moving a row to another key", notice{"pool", otherPool, movedKey}, notice{"pool", otherPool, key})
mustExec(t, ctx, tx, `UPDATE core.boolean_entitlements SET resource_key = $3 WHERE pool_id = $1 AND resource_key = $2`, otherPool, movedKey, key)
expectNotices(t, ctx, tx, "moving the row back", notice{"pool", otherPool, key}, notice{"pool", otherPool, movedKey})
mustExec(t, ctx, tx, `UPDATE core.boolean_entitlements SET granted = FALSE WHERE pool_id = $1 AND resource_key = $2`, otherPool, key)
expectNotices(t, ctx, tx, "lapsing a boolean", notice{"pool", otherPool, key})
mustExec(t, ctx, tx, `DELETE FROM core.boolean_entitlements WHERE pool_id = $1 AND resource_key = $2`, otherPool, key)
expectNotices(t, ctx, tx, "deleting a lapsed row")
mustExec(t, ctx, tx, `DELETE FROM core.boolean_entitlements WHERE pool_id = $1 AND resource_key = $2`, pool, otherKey)
expectNotices(t, ctx, tx, "deleting a granted row", notice{"pool", pool, otherKey})
fxBoolean(t, ctx, tx, pool, platformKey, true)
expectNotices(t, ctx, tx, "granting a platform key", notice{"pool", pool, platformKey})
}
func TestNumericEntitlementTrigger(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "numeric", "organization")
otherKey := fxKey(t, ctx, tx, provider, "numeric", "organization")
pool := fxPool(t, ctx, tx, fxOrg(t, ctx, tx, fxPerson(t, ctx, tx)), "default")
q := entitlements.New(tx)
entitlementID := fxLimit(t, ctx, tx, pool, key, 3)
expectNotices(t, ctx, tx, "inserting a limit", notice{"pool", pool, key})
fxLimit(t, ctx, tx, pool, otherKey, 0)
expectNotices(t, ctx, tx, "inserting a zero limit")
if _, err := q.UpdateNumericEntitlementLimit(ctx, entitlements.UpdateNumericEntitlementLimitParams{
EntitlementID: entitlementID, ResourceLimit: 3,
}); err != nil {
t.Fatal(err)
}
expectNotices(t, ctx, tx, "rewriting the same limit through UpdateNumericEntitlementLimit")
if _, err := q.UpdateNumericEntitlementLimit(ctx, entitlements.UpdateNumericEntitlementLimitParams{
EntitlementID: entitlementID, ResourceLimit: 5,
}); err != nil {
t.Fatal(err)
}
expectNotices(t, ctx, tx, "changing a limit", notice{"pool", pool, key})
// A limit that falls to zero is how a revoked limit reaches a record.
if _, err := q.UpdateNumericEntitlementLimit(ctx, entitlements.UpdateNumericEntitlementLimitParams{
EntitlementID: entitlementID, ResourceLimit: 0,
}); err != nil {
t.Fatal(err)
}
expectNotices(t, ctx, tx, "a limit falling to zero", notice{"pool", pool, key})
if _, err := q.UpdateNumericEntitlementLimit(ctx, entitlements.UpdateNumericEntitlementLimitParams{
EntitlementID: entitlementID, ResourceLimit: 5,
}); err != nil {
t.Fatal(err)
}
expectNotices(t, ctx, tx, "raising a zero limit", notice{"pool", pool, key})
otherPool := fxPool(t, ctx, tx, fxOrg(t, ctx, tx, fxPerson(t, ctx, tx)), "default")
mustExec(t, ctx, tx, `UPDATE core.numeric_entitlements SET pool_id = $2 WHERE entitlement_id = $1`, entitlementID, otherPool)
expectNotices(t, ctx, tx, "moving a row to another pool", notice{"pool", otherPool, key}, notice{"pool", pool, key})
mustExec(t, ctx, tx, `UPDATE core.numeric_entitlements SET pool_id = $2 WHERE entitlement_id = $1`, entitlementID, pool)
expectNotices(t, ctx, tx, "moving the row back", notice{"pool", pool, key}, notice{"pool", otherPool, key})
mustExec(t, ctx, tx, `UPDATE core.numeric_entitlements SET entitlement_type = 'quota' WHERE entitlement_id = $1`, entitlementID)
expectNotices(t, ctx, tx, "changing entitlement_type")
thirdKey := fxKey(t, ctx, tx, provider, "numeric", "organization")
mustExec(t, ctx, tx, `UPDATE core.numeric_entitlements SET resource_key = $2 WHERE entitlement_id = $1`, entitlementID, thirdKey)
expectNotices(t, ctx, tx, "moving a row to another key", notice{"pool", pool, thirdKey}, notice{"pool", pool, key})
mustExec(t, ctx, tx, `DELETE FROM core.numeric_entitlements WHERE pool_id = $1 AND resource_key = $2`, pool, otherKey)
expectNotices(t, ctx, tx, "deleting a zero limit")
mustExec(t, ctx, tx, `DELETE FROM core.numeric_entitlements WHERE entitlement_id = $1`, entitlementID)
expectNotices(t, ctx, tx, "deleting a held limit", notice{"pool", pool, thirdKey})
}
func TestMembershipTrigger(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
org := fxOrg(t, ctx, tx, fxPerson(t, ctx, tx))
person := fxPerson(t, ctx, tx)
other := fxPerson(t, ctx, tx)
invited := fxPerson(t, ctx, tx)
memberID := fxMember(t, ctx, tx, org, person, "active")
expectNotices(t, ctx, tx, "a new active membership", notice{"person", person, ""})
fxMember(t, ctx, tx, org, invited, "invited")
expectNotices(t, ctx, tx, "a membership inserted as invited")
mustExec(t, ctx, tx, `UPDATE core.org_members SET role_id = $2 WHERE org_member_id = $1`, memberID, fxRole(t, ctx, tx, "admin"))
expectNotices(t, ctx, tx, "a role change")
mustExec(t, ctx, tx, `UPDATE core.org_members SET status = status, org_id = org_id, person_id = person_id WHERE org_member_id = $1`, memberID)
expectNotices(t, ctx, tx, "rewriting the values a membership holds")
mustExec(t, ctx, tx, `UPDATE core.org_members SET person_id = $2 WHERE org_member_id = $1`, memberID, other)
expectNotices(t, ctx, tx, "a membership moved to another person",
notice{"person", other, ""}, notice{"person", person, ""})
mustExec(t, ctx, tx, `UPDATE core.org_members SET status = 'removed', removed_at = NOW() WHERE org_member_id = $1`, memberID)
expectNotices(t, ctx, tx, "a membership leaving active", notice{"person", other, ""})
mustExec(t, ctx, tx, `DELETE FROM core.org_members WHERE org_member_id = $1`, memberID)
expectNotices(t, ctx, tx, "deleting an inactive membership")
again := fxMember(t, ctx, tx, org, person, "active")
takeNotices(t, ctx, tx)
mustExec(t, ctx, tx, `DELETE FROM core.org_members WHERE org_member_id = $1`, again)
expectNotices(t, ctx, tx, "deleting an active membership", notice{"person", person, ""})
}
func TestOrganizationTrigger(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
owner := fxPerson(t, ctx, tx)
newOwner := fxPerson(t, ctx, tx)
org := fxOrg(t, ctx, tx, owner)
expectNotices(t, ctx, tx, "creating an organization")
mustExec(t, ctx, tx, `UPDATE core.organizations SET name = 'Renamed' WHERE org_id = $1`, org)
expectNotices(t, ctx, tx, "a rename")
mustExec(t, ctx, tx, `UPDATE core.organizations SET status = 'suspended' WHERE org_id = $1`, org)
expectNotices(t, ctx, tx, "a status change", notice{"organization", org, ""})
mustExec(t, ctx, tx, `UPDATE core.organizations SET owner_person_id = $2 WHERE org_id = $1`, org, newOwner)
expectNotices(t, ctx, tx, "an ownership change",
notice{"organization", org, ""}, notice{"person", owner, ""}, notice{"person", newOwner, ""})
mustExec(t, ctx, tx,
`UPDATE core.organizations SET status = status, owner_person_id = owner_person_id, org_type = org_type, org_id = org_id
WHERE org_id = $1`, org)
expectNotices(t, ctx, tx, "rewriting the values an organization holds")
// An organization with no dependents moves to a new identifier.
lone := fxOrg(t, ctx, tx, owner)
moved := uuid.NewString()
mustExec(t, ctx, tx, `UPDATE core.organizations SET org_id = $2 WHERE org_id = $1`, lone, moved)
expectNotices(t, ctx, tx, "moving an organization to another identifier",
notice{"organization", lone, ""}, notice{"organization", moved, ""})
}
func TestConnectionTrigger(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
pending := fxConnection(t, ctx, tx, fxProvider(t, ctx, tx), false)
expectNotices(t, ctx, tx, "a pending connection")
mustExec(t, ctx, tx,
`UPDATE core.provider_connections SET approval_state = 'approved', approved_at = NOW() WHERE connection_id = $1`, pending)
expectNotices(t, ctx, tx, "approving a pending connection", notice{"connection", pending, ""})
approved := fxConnection(t, ctx, tx, fxProvider(t, ctx, tx), true)
expectNotices(t, ctx, tx, "an approved insert", notice{"connection", approved, ""})
mustExec(t, ctx, tx, `UPDATE core.provider_connections SET display_name = 'Renamed' WHERE connection_id = $1`, approved)
expectNotices(t, ctx, tx, "a rename")
mustExec(t, ctx, tx,
`UPDATE core.provider_connections SET status = 'suspended', suspended_at = NOW() WHERE connection_id = $1`, approved)
expectNotices(t, ctx, tx, "suspending", notice{"connection", approved, ""})
mustExec(t, ctx, tx,
`UPDATE core.provider_connections SET approved_at = approved_at + INTERVAL '1 second' WHERE connection_id = $1`, approved)
expectNotices(t, ctx, tx, "a re-approval", notice{"connection", approved, ""})
moved := uuid.NewString()
mustExec(t, ctx, tx, `UPDATE core.provider_connections SET connection_id = $2 WHERE connection_id = $1`, approved, moved)
expectNotices(t, ctx, tx, "moving a connection to another identifier",
notice{"connection", approved, ""}, notice{"connection", moved, ""})
}
func TestTargetMappingTrigger(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
otherKey := fxKey(t, ctx, tx, provider, "boolean", "person")
connectionID := fxConnection(t, ctx, tx, provider, true)
takeNotices(t, ctx, tx)
mappingID := fxMapping(t, ctx, tx, connectionID, provider, key, "17")
expectNotices(t, ctx, tx, "adding a mapping", notice{"connection", connectionID, key})
mustExec(t, ctx, tx, `UPDATE core.target_mappings SET target_ref = '42' WHERE mapping_id = $1`, mappingID)
expectNotices(t, ctx, tx, "retargeting a mapping", notice{"connection", connectionID, key})
mustExec(t, ctx, tx, `UPDATE core.target_mappings SET display_name = 'Renamed' WHERE mapping_id = $1`, mappingID)
expectNotices(t, ctx, tx, "renaming a mapping")
mustExec(t, ctx, tx, `UPDATE core.target_mappings SET resource_key = $2 WHERE mapping_id = $1`, mappingID, otherKey)
expectNotices(t, ctx, tx, "moving a mapping to another key",
notice{"connection", connectionID, otherKey}, notice{"connection", connectionID, key})
mustExec(t, ctx, tx, `DELETE FROM core.target_mappings WHERE mapping_id = $1`, mappingID)
expectNotices(t, ctx, tx, "removing a mapping", notice{"connection", connectionID, otherKey})
}
func TestCutOffTrigger(t *testing.T) {
database := deliveryTestDB(t)
ctx, tx := deliveryTx(t, database)
person := fxPerson(t, ctx, tx)
actor := fxPerson(t, ctx, tx)
setCutOff := func(cutOff bool) bool {
t.Helper()
var changed bool
mustScan(t, ctx, tx, &changed, `SELECT core.set_person_cut_off($1, $2, $3)`, person, cutOff, actor)
return changed
}
setCutOff(true)
expectNotices(t, ctx, tx, "cutting a person off", notice{"person", person, ""})
setCutOff(true)
expectNotices(t, ctx, tx, "repeating a cut-off")
setCutOff(false)
expectNotices(t, ctx, tx, "lifting a cut-off", notice{"person", person, ""})
setCutOff(false)
expectNotices(t, ctx, tx, "lifting again")
// Deleting an open row writes one; deleting a closed one writes none.
setCutOff(true)
takeNotices(t, ctx, tx)
mustExec(t, ctx, tx, `DELETE FROM core.person_cut_offs WHERE person_id = $1 AND lifted_at IS NOT NULL`, person)
expectNotices(t, ctx, tx, "deleting a closed cut-off")
mustExec(t, ctx, tx, `DELETE FROM core.person_cut_offs WHERE person_id = $1`, person)
expectNotices(t, ctx, tx, "deleting an open cut-off", notice{"person", person, ""})
// An open cut-off moved to another person names both.
setCutOff(true)
takeNotices(t, ctx, tx)
mustExec(t, ctx, tx, `UPDATE core.person_cut_offs SET person_id = $2 WHERE person_id = $1`, person, actor)
expectNotices(t, ctx, tx, "moving a cut-off to another person", notice{"person", person, ""}, notice{"person", actor, ""})
}
// --- 2.8 every column the content reads is watched -------------------------
// contentAliasTables maps each alias in core.desired_state_content to the
// table it names; the function gives each table one alias throughout.
var (
fromJoin = regexp.MustCompile(`(?i)\b(?:from|join)\s+(?:core\.)?([a-z_][a-z0-9_]*)\s+(?:as\s+)?([a-z_][a-z0-9_]*)`)
qualified = regexp.MustCompile(`\b([a-z_][a-z0-9_]*)\.([a-z_][a-z0-9_]*)\b`)
oldNew = regexp.MustCompile(`(?i)\b(?:old|new)\.([a-z_][a-z0-9_]*)`)
sqlWords = []string{"on", "where", "join", "left", "right", "inner", "outer", "cross", "full", "natural",
"group", "order", "limit", "union", "using", "lateral", "as", "and", "or"}
)
// triggerTables are the seven tables whose changes write outbox rows.
var triggerTables = []string{
"boolean_entitlements", "numeric_entitlements", "org_members", "organizations",
"provider_connections", "target_mappings", "person_cut_offs",
}
// untriggeredReads are tables the content reads that carry no outbox
// trigger, each for a stated reason. resource_pools: a pool is only ever
// created with its organization, and no code moves one (design.md Risks).
// resource_keys: a key's provider and recipient change only at boot
// registration, which re-approves the connection, and the connection's
// trigger writes the row (design D2).
var untriggeredReads = []string{"resource_pools", "resource_keys"}
// The guard fails when core.desired_state_content reads a column of a
// trigger table that the table's UPDATE trigger does not watch, or reads a
// table that has neither a trigger nor a stated reason to have none.
func TestDesiredStateTriggersWatchEveryColumnContentReads(t *testing.T) {
database := deliveryTestDB(t)
ctx := context.Background()
var source string
mustScan(t, ctx, database, &source,
`SELECT prosrc FROM pg_proc WHERE oid = 'core.desired_state_content(uuid, uuid, uuid)'::regprocedure`)
source = strings.ToLower(source)
aliases := map[string]string{}
for _, m := range fromJoin.FindAllStringSubmatch(source, -1) {
table, alias := m[1], m[2]
if slices.Contains(sqlWords, alias) {
t.Errorf("core.desired_state_content reads %s without an alias; give each table one alias", table)
continue
}
if prev, ok := aliases[alias]; ok && prev != table {
t.Errorf("alias %s names both %s and %s; give each table its own alias", alias, prev, table)
}
aliases[alias] = table
}
reads := map[string][]string{}
for _, m := range qualified.FindAllStringSubmatch(source, -1) {
table, ok := aliases[m[1]]
if !ok {
continue
}
if !slices.Contains(reads[table], m[2]) {
reads[table] = append(reads[table], m[2])
}
}
var coreTables []string
rows, err := database.QueryContext(ctx, `SELECT tablename FROM pg_tables WHERE schemaname = 'core'`)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
coreTables = append(coreTables, name)
}
rows.Close()
for table := range reads {
if !slices.Contains(coreTables, table) {
continue // a CTE
}
if !slices.Contains(triggerTables, table) && !slices.Contains(untriggeredReads, table) {
t.Errorf("core.desired_state_content reads core.%s, which has no outbox trigger and no stated reason for none", table)
}
}
for _, table := range triggerTables {
read := reads[table]
if len(read) == 0 {
t.Errorf("core.desired_state_content reads nothing from core.%s; the trigger list and the content disagree", table)
continue
}
watched := updateTriggerColumns(t, ctx, database, table)
for _, column := range read {
if !slices.Contains(watched, column) {
t.Errorf("core.desired_state_content reads %s.%s, which the UPDATE trigger's WHEN does not name (it names %v)",
table, column, watched)
}
}
}
}
// updateTriggerColumns returns the columns the WHEN condition of a table's
// row-level AFTER UPDATE outbox trigger names, after checking the trigger
// calls a SECURITY DEFINER function owned by core_owner.
func updateTriggerColumns(t *testing.T, ctx context.Context, database *sql.DB, table string) []string {
t.Helper()
rows, err := database.QueryContext(ctx, `
SELECT pg_get_triggerdef(tg.oid), p.prosecdef, pg_get_userbyid(p.proowner)
FROM pg_trigger tg
JOIN pg_proc p ON p.oid = tg.tgfoid
WHERE tg.tgrelid = ('core.' || $1)::regclass AND NOT tg.tgisinternal
AND tg.tgname LIKE '%desired_state%'
AND (tg.tgtype & 16) <> 0 -- UPDATE
AND (tg.tgtype & 1) <> 0 -- FOR EACH ROW
AND (tg.tgtype & 2) = 0 -- AFTER`, table)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var columns []string
found := 0
for rows.Next() {
var def, owner string
var secdef bool
if err := rows.Scan(&def, &secdef, &owner); err != nil {
t.Fatal(err)
}
found++
if !secdef || owner != "core_owner" {
t.Errorf("core.%s's trigger function is secdef=%v owned by %s, want SECURITY DEFINER owned by core_owner", table, secdef, owner)
}
_, when, ok := strings.Cut(def, " WHEN ")
if !ok {
t.Errorf("core.%s's UPDATE trigger has no WHEN condition: %s", table, def)
continue
}
for _, m := range oldNew.FindAllStringSubmatch(when, -1) {
if !slices.Contains(columns, m[1]) {
columns = append(columns, m[1])
}
}
}
if found != 1 {
t.Errorf("core.%s has %d AFTER UPDATE outbox triggers, want 1", table, found)
}
return columns
}
// --- 2.9 the same transaction, on any write path ---------------------------
func TestDesiredStateNoticesShareTheChangesTransaction(t *testing.T) {
database := deliveryTestDB(t)
t.Run("a rolled-back change leaves no row", func(t *testing.T) {
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "boolean", "person")
pool := fxPool(t, ctx, tx, fxOrg(t, ctx, tx, fxPerson(t, ctx, tx)), "default")
// Inside a savepoint first, as a rule change settles each pool.
mustExec(t, ctx, tx, "SAVEPOINT settle")
fxBoolean(t, ctx, tx, pool, key, true)
var inside int
mustScan(t, ctx, tx, &inside, `SELECT COUNT(*) FROM core.desired_state_outbox WHERE subject_id = $1`, pool)
mustExec(t, ctx, tx, "ROLLBACK TO SAVEPOINT settle")
var after int
mustScan(t, ctx, tx, &after, `SELECT COUNT(*) FROM core.desired_state_outbox WHERE subject_id = $1`, pool)
if inside != 1 || after != 0 {
t.Errorf("the change wrote %d rows and left %d after its savepoint rolled back, want 1 and 0", inside, after)
}
fxBoolean(t, ctx, tx, pool, key, true)
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
var left int
mustScan(t, ctx, database, &left, `SELECT COUNT(*) FROM core.desired_state_outbox WHERE subject_id = $1`, pool)
if left != 0 {
t.Errorf("a rolled-back change left %d rows", left)
}
})
t.Run("plain SQL outside the application writes a row", func(t *testing.T) {
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx)
key := fxKey(t, ctx, tx, provider, "numeric", "organization")
pool := fxPool(t, ctx, tx, fxOrg(t, ctx, tx, fxPerson(t, ctx, tx)), "default")
// One multi-statement string over the simple protocol, as psql sends it.
mustExec(t, ctx, tx, `
INSERT INTO core.numeric_entitlements (pool_id, resource_key, entitlement_type, resource_limit)
VALUES ('`+pool+`', '`+key+`', 'limit', 2);
UPDATE core.numeric_entitlements SET resource_limit = 4 WHERE pool_id = '`+pool+`';`)
expectNotices(t, ctx, tx, "plain SQL", notice{"pool", pool, key}, notice{"pool", pool, key})
})
t.Run("no connection and no recipient yet", func(t *testing.T) {
ctx, tx := deliveryTx(t, database)
provider := fxProvider(t, ctx, tx) // no connection
unstamped := fxKey(t, ctx, tx, provider, "boolean", "")
pool := fxPool(t, ctx, tx, fxOrg(t, ctx, tx, fxPerson(t, ctx, tx)), "default")
fxBoolean(t, ctx, tx, pool, unstamped, true)
expectNotices(t, ctx, tx, "a key with no connection and no recipient", notice{"pool", pool, unstamped})
})
}
@@ -0,0 +1,877 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- Integration delivery core (openspec change integration-delivery-core). A
-- provider records one type of application and owns its resource keys; a
-- connection beneath it records one running application. The console stores
-- what it wants each application to hold for each recipient (a desired-state
-- record, with a generation), what the application last reported (an
-- observed projection), the lifecycle requests a person made, and who is cut
-- off everywhere. Triggers on a closed list of seven tables write an
-- identifier-only row into core.desired_state_outbox in the transaction of
-- every change that alters a record; a core relay recomputes the records.
-- The migration writes no data, and the triggers fire only on later changes.
-- ---------------------------------------------------------------------------
-- Design D3: each owned key declares who it is delivered to. Boot
-- registration stamps the value with the provider, as it stamps the
-- over-limit pair, so no backfill runs here and a key reads NULL until the
-- same boot registers its provider. A NULL recipient is carried by no record.
-- A platform key is never delivered through a connection. A person's record
-- takes the union of what their organizations hold, which is defined for a
-- held-or-not key and undefined for a limit, so a person key is boolean. The
-- composite unique constraint is the target core.target_mappings references;
-- it is trivially unique because resource_key is the primary key.
ALTER TABLE core.resource_keys
ADD COLUMN recipient VARCHAR(20)
CONSTRAINT chk_resource_keys_recipient CHECK (recipient IN ('person', 'organization')),
ADD CONSTRAINT platform_keys_no_recipient CHECK (provider IS NOT NULL OR recipient IS NULL),
ADD CONSTRAINT person_recipient_keys_boolean CHECK (recipient IS DISTINCT FROM 'person' OR kind = 'boolean'),
ADD CONSTRAINT uq_resource_keys_key_provider UNIQUE (resource_key, provider);
-- ---------------------------------------------------------------------------
-- Design D1: a connection is one registered link to one running application,
-- beneath its provider. Its only identity is the database's UUID; it carries
-- no key. Status is the operator's switch and mirrors core.providers;
-- approval is a fact about trust in the connector and its manifest, kept
-- separate so a suspended connection stays approved. The unique index counts
-- retired rows too, so boot can never create a second connection beside one
-- an operator retired; a screen able to show several connections drops it.
-- ---------------------------------------------------------------------------
CREATE TABLE core.provider_connections (
connection_id UUID NOT NULL DEFAULT uuidv7(),
provider TEXT NOT NULL,
display_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
suspended_at TIMESTAMPTZ,
retired_at TIMESTAMPTZ,
approval_state VARCHAR(20) NOT NULL DEFAULT 'pending',
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_provider_connections PRIMARY KEY (connection_id),
CONSTRAINT fk_provider_connections_provider FOREIGN KEY (provider) REFERENCES core.providers(provider),
CONSTRAINT uq_provider_connections_connection_provider UNIQUE (connection_id, provider),
CONSTRAINT chk_provider_connections_status CHECK (status IN ('active', 'suspended', 'retired')),
CONSTRAINT chk_provider_connections_approval_state CHECK (approval_state IN ('pending', 'approved')),
CONSTRAINT connection_approved_shape CHECK ((approval_state = 'approved') = (approved_at IS NOT NULL))
);
CREATE UNIQUE INDEX uq_provider_connections_one_per_provider ON core.provider_connections (provider);
CREATE TRIGGER trigger_provider_connections_updated_at
BEFORE UPDATE ON core.provider_connections
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
COMMENT ON TABLE core.provider_connections IS
'One registered link between the console and one running application, beneath the provider that records the application''s type. One per provider until a screen can show several.';
-- ---------------------------------------------------------------------------
-- Design D4: a target mapping delivers one key through one group or role in
-- one application. Both composite foreign keys share provider, so a mapping
-- can name only a key owned by its connection's provider, and registration
-- cannot move a mapped key to another provider. One mapping per target: a
-- managed group never receives two desired member sets. No code writes this
-- table in this change; Discourse's mappings stay in its own schema.
-- ---------------------------------------------------------------------------
CREATE TABLE core.target_mappings (
mapping_id UUID NOT NULL DEFAULT uuidv7(),
connection_id UUID NOT NULL,
provider TEXT NOT NULL,
resource_key VARCHAR(100) NOT NULL,
target_ref TEXT NOT NULL,
display_name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_target_mappings PRIMARY KEY (mapping_id),
CONSTRAINT fk_target_mappings_connection FOREIGN KEY (connection_id, provider)
REFERENCES core.provider_connections(connection_id, provider),
CONSTRAINT fk_target_mappings_resource_key FOREIGN KEY (resource_key, provider)
REFERENCES core.resource_keys(resource_key, provider),
CONSTRAINT uq_target_mappings_connection_target UNIQUE (connection_id, target_ref)
);
CREATE INDEX idx_target_mappings_connection_key ON core.target_mappings (connection_id, resource_key);
CREATE TRIGGER trigger_target_mappings_updated_at
BEFORE UPDATE ON core.target_mappings
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
COMMENT ON TABLE core.target_mappings IS
'An operator''s row delivering one resource key through one group or role inside the application a connection reaches. target_ref is the application''s own identifier.';
-- ---------------------------------------------------------------------------
-- Design D5, D6, D9: one desired-state record per connection and recipient,
-- the whole set of entitlements the console wants the application to carry
-- out for that person or organization. The recipient is an exclusive arc
-- with foreign keys, as core.grants does. content is built only by
-- core.desired_state_content; content_hash is sha256 over jsonb's binary send
-- form, which is IMMUTABLE and orders object keys canonically (a text-to-
-- bytea cast would parse backslash escapes). Records are never deleted: a
-- recipient who loses everything keeps an empty record under a raised
-- generation, which is how removal reaches the application.
-- ---------------------------------------------------------------------------
CREATE TABLE core.desired_state_records (
record_id UUID NOT NULL DEFAULT uuidv7(),
connection_id UUID NOT NULL,
person_id UUID,
org_id UUID,
content JSONB NOT NULL,
content_hash BYTEA NOT NULL GENERATED ALWAYS AS (sha256(jsonb_send(content))) STORED,
generation BIGINT NOT NULL,
generation_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_dispatched_at TIMESTAMPTZ,
last_push_error TEXT,
last_push_failed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_desired_state_records PRIMARY KEY (record_id),
CONSTRAINT fk_desired_state_records_connection_id FOREIGN KEY (connection_id) REFERENCES core.provider_connections(connection_id),
CONSTRAINT fk_desired_state_records_person_id FOREIGN KEY (person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_desired_state_records_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id),
CONSTRAINT chk_desired_state_records_recipient CHECK (num_nonnulls(person_id, org_id) = 1),
CONSTRAINT uq_desired_state_records_connection_person UNIQUE (connection_id, person_id),
CONSTRAINT uq_desired_state_records_connection_org UNIQUE (connection_id, org_id),
-- "content ? 'entitlements'" keeps the check from passing on NULL: for an
-- object without the member, jsonb_typeof(content -> 'entitlements') is
-- NULL, and a CHECK that evaluates to NULL admits the row.
CONSTRAINT chk_desired_state_records_content CHECK (
jsonb_typeof(content) = 'object' AND content ? 'entitlements'
AND jsonb_typeof(content -> 'entitlements') = 'array'),
CONSTRAINT chk_desired_state_records_generation CHECK (generation > 0)
);
CREATE INDEX idx_desired_state_records_sweep ON core.desired_state_records (connection_id, last_dispatched_at);
CREATE TRIGGER trigger_desired_state_records_updated_at
BEFORE UPDATE ON core.desired_state_records
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
COMMENT ON TABLE core.desired_state_records IS
'What the console wants one application to carry out for one recipient, per connection, with a generation that rises when the content changes. Rows are never deleted.';
-- ---------------------------------------------------------------------------
-- Design D10: what the application reports holding for one recipient, keyed
-- like the record and not by record_id, so a report for a recipient whose
-- record a restored database no longer has can still be stored. Nothing
-- writes it in production until a transport exists.
-- ---------------------------------------------------------------------------
CREATE TABLE core.observed_projections (
projection_id UUID NOT NULL DEFAULT uuidv7(),
connection_id UUID NOT NULL,
person_id UUID,
org_id UUID,
applied_generation BIGINT,
applied_content_hash BYTEA,
observed JSONB NOT NULL DEFAULT '{"entitlements": []}',
result VARCHAR(20),
result_generation BIGINT,
next_action VARCHAR(50),
observed_at TIMESTAMPTZ NOT NULL,
ahead_generation BIGINT,
ahead_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_observed_projections PRIMARY KEY (projection_id),
CONSTRAINT fk_observed_projections_connection_id FOREIGN KEY (connection_id) REFERENCES core.provider_connections(connection_id),
CONSTRAINT fk_observed_projections_person_id FOREIGN KEY (person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_observed_projections_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id),
CONSTRAINT chk_observed_projections_recipient CHECK (num_nonnulls(person_id, org_id) = 1),
CONSTRAINT uq_observed_projections_connection_person UNIQUE (connection_id, person_id),
CONSTRAINT uq_observed_projections_connection_org UNIQUE (connection_id, org_id),
CONSTRAINT chk_observed_projections_result CHECK (result IS NULL OR result IN ('applied', 'unlinked')),
CONSTRAINT chk_observed_projections_observed CHECK (jsonb_typeof(observed) = 'object'),
CONSTRAINT observed_projection_ahead_shape CHECK ((ahead_generation IS NULL) = (ahead_at IS NULL))
);
CREATE TRIGGER trigger_observed_projections_updated_at
BEFORE UPDATE ON core.observed_projections
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
COMMENT ON TABLE core.observed_projections IS
'What an application last reported holding for one recipient at one connection, with the generation it applied. Not an instance list.';
-- ---------------------------------------------------------------------------
-- Design D24: one request a person made for one instance, addressed by the
-- console's instance identifier and a generation. instance_id has no
-- default: the console chooses it before the row exists. One accepted
-- request per instance through the partial unique index.
-- ---------------------------------------------------------------------------
CREATE TABLE core.lifecycle_requests (
request_id UUID NOT NULL DEFAULT uuidv7(),
instance_id UUID NOT NULL,
generation BIGINT NOT NULL,
connection_id UUID NOT NULL,
resource_key VARCHAR(100) NOT NULL,
workspace_id UUID NOT NULL,
operation VARCHAR(20) NOT NULL,
parameters JSONB NOT NULL DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'accepted',
reason_code VARCHAR(50),
workflow_id TEXT NOT NULL,
requested_by_person_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
acknowledged_at TIMESTAMPTZ,
CONSTRAINT pk_lifecycle_requests PRIMARY KEY (request_id),
CONSTRAINT uq_lifecycle_requests_instance_generation UNIQUE (instance_id, generation),
CONSTRAINT fk_lifecycle_requests_connection_id FOREIGN KEY (connection_id) REFERENCES core.provider_connections(connection_id),
CONSTRAINT fk_lifecycle_requests_resource_key FOREIGN KEY (resource_key) REFERENCES core.resource_keys(resource_key),
CONSTRAINT fk_lifecycle_requests_workspace_id FOREIGN KEY (workspace_id) REFERENCES core.workspaces(workspace_id),
CONSTRAINT fk_lifecycle_requests_requested_by_person_id FOREIGN KEY (requested_by_person_id) REFERENCES core.persons(person_id),
CONSTRAINT chk_lifecycle_requests_operation CHECK (operation IN ('create', 'set_status', 'delete')),
CONSTRAINT chk_lifecycle_requests_status CHECK (status IN ('accepted', 'succeeded', 'declined', 'failed')),
-- A posted generation is bounded as a reported one is (D8): at most the
-- clock floor plus one day when the row was written, so the next
-- generation of an instance can never overflow.
CONSTRAINT chk_lifecycle_requests_generation CHECK (
generation > 0 AND generation <= (EXTRACT(EPOCH FROM created_at) * 1000000)::BIGINT + 86400000000),
CONSTRAINT chk_lifecycle_requests_parameters CHECK (jsonb_typeof(parameters) = 'object'),
CONSTRAINT lifecycle_request_finished_shape CHECK ((status = 'accepted') = (finished_at IS NULL)),
CONSTRAINT lifecycle_request_reason_shape CHECK (reason_code IS NULL OR status IN ('declined', 'failed'))
);
CREATE UNIQUE INDEX uq_lifecycle_requests_one_open ON core.lifecycle_requests (instance_id) WHERE status = 'accepted';
CREATE INDEX idx_lifecycle_requests_workspace_shown ON core.lifecycle_requests (workspace_id)
WHERE status <> 'succeeded' AND acknowledged_at IS NULL;
CREATE TRIGGER trigger_lifecycle_requests_updated_at
BEFORE UPDATE ON core.lifecycle_requests
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
COMMENT ON TABLE core.lifecycle_requests IS
'One request a person made for one instance (create, change status, delete), addressed by the console''s instance identifier and a generation.';
COMMENT ON COLUMN core.lifecycle_requests.parameters IS
'Identifiers and public DNS names only. A personal field added later moves this column to the withheld list in scripts/db-reader-role.sql in the same change.';
-- ---------------------------------------------------------------------------
-- Design D23: the recorded fact that a person's records at every connection
-- carry no entitlements. A row opens and closes and is never deleted; the
-- open row is the cut-off, closed rows its history. Written only through
-- core.set_person_cut_off below. No note column: an operator's free text can
-- name people.
-- ---------------------------------------------------------------------------
CREATE TABLE core.person_cut_offs (
cut_off_id UUID NOT NULL DEFAULT uuidv7(),
person_id UUID NOT NULL,
cut_off_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cut_off_by_person_id UUID,
lifted_at TIMESTAMPTZ,
lifted_by_person_id UUID,
CONSTRAINT pk_person_cut_offs PRIMARY KEY (cut_off_id),
CONSTRAINT fk_person_cut_offs_person_id FOREIGN KEY (person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_person_cut_offs_cut_off_by_person_id FOREIGN KEY (cut_off_by_person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_person_cut_offs_lifted_by_person_id FOREIGN KEY (lifted_by_person_id) REFERENCES core.persons(person_id),
-- An open row has no lifter; a closed row is lifted at or after its
-- cut-off. The second branch names lifted_at IS NOT NULL so a lifter
-- without a lift time makes the check false rather than NULL, which a
-- CHECK admits.
CONSTRAINT person_cut_off_lifted_shape CHECK (
(lifted_at IS NULL AND lifted_by_person_id IS NULL)
OR (lifted_at IS NOT NULL AND lifted_at >= cut_off_at))
);
CREATE UNIQUE INDEX uq_person_cut_offs_one_open ON core.person_cut_offs (person_id) WHERE lifted_at IS NULL;
COMMENT ON TABLE core.person_cut_offs IS
'Cut off everywhere: while a person has an open row, their records at every connection carry no entitlements. Written only by core.set_person_cut_off.';
-- ---------------------------------------------------------------------------
-- Design D13: a notice that something a record carries may have changed. It
-- names a subject, never a recipient list or a payload, so every column is
-- an identifier, a key, a status, a count, a time or the console's own error
-- text. No foreign keys: the subject points at one of four tables, and a
-- check on the referenced pool would add a lock edge to materialization. The
-- identifier is a UUIDv7, never a sequence, which restarts after a restore.
-- ---------------------------------------------------------------------------
CREATE TABLE core.desired_state_outbox (
id UUID NOT NULL DEFAULT uuidv7(),
subject_kind VARCHAR(20) NOT NULL,
subject_id UUID NOT NULL,
resource_key VARCHAR(100),
recipient_only BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
claimed_until TIMESTAMPTZ,
expansion_cursor TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
CONSTRAINT pk_desired_state_outbox PRIMARY KEY (id),
CONSTRAINT chk_desired_state_outbox_subject_kind CHECK (subject_kind IN ('pool', 'organization', 'person', 'connection')),
CONSTRAINT chk_desired_state_outbox_status CHECK (status IN ('pending', 'completed', 'failed')),
CONSTRAINT desired_state_outbox_completed_shape CHECK ((status = 'completed') = (completed_at IS NOT NULL)),
CONSTRAINT desired_state_outbox_recipient_only_shape CHECK (
NOT recipient_only OR subject_kind IN ('person', 'organization'))
);
CREATE INDEX idx_desired_state_outbox_pending ON core.desired_state_outbox (id) WHERE status = 'pending';
COMMENT ON TABLE core.desired_state_outbox IS
'Notices that a value a desired-state record carries may have changed, written by triggers in the changing transaction and by core.requeue_desired_state. Not core.outbox.';
-- ---------------------------------------------------------------------------
-- Design D8: generations. Every raise goes through core.next_generation: the
-- greatest of current + 1, reported + 1 and the clock in microseconds, so a
-- generation issued after a restore is above every one issued before it. A
-- reported generation comes from outside code; anything above the clock
-- floor plus one day is a protocol error, refused here as a backstop.
-- Invoker rights: neither reads a table.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.generation_ceiling() RETURNS BIGINT
LANGUAGE sql STABLE AS $$
SELECT (EXTRACT(EPOCH FROM now()) * 1000000)::BIGINT + 86400000000;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.next_generation(p_current BIGINT, p_reported BIGINT) RETURNS BIGINT
LANGUAGE plpgsql STABLE AS $$
BEGIN
IF p_reported > core.generation_ceiling() THEN
RAISE EXCEPTION 'reported generation % is above the ceiling', p_reported
USING ERRCODE = 'numeric_value_out_of_range';
END IF;
RETURN GREATEST(COALESCE(p_current, 0) + 1,
COALESCE(p_reported, 0) + 1,
(EXTRACT(EPOCH FROM now()) * 1000000)::BIGINT);
END;
$$;
-- +goose StatementEnd
-- Design D5: "content differs, generation rises" is a database fact, not a
-- relay convention. The raise names the rule as its constraint.
-- +goose StatementBegin
CREATE FUNCTION core.guard_desired_state_generation() RETURNS TRIGGER
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.generation < OLD.generation THEN
RAISE EXCEPTION 'desired-state record % generation would fall from % to %',
OLD.record_id, OLD.generation, NEW.generation
USING ERRCODE = 'check_violation', CONSTRAINT = 'guard_desired_state_generation';
END IF;
IF NEW.content IS DISTINCT FROM OLD.content AND NEW.generation <= OLD.generation THEN
RAISE EXCEPTION 'desired-state record % content changed without a raised generation', OLD.record_id
USING ERRCODE = 'check_violation', CONSTRAINT = 'guard_desired_state_generation';
END IF;
RETURN NEW;
END;
$$;
-- +goose StatementEnd
CREATE TRIGGER trigger_desired_state_records_guard_generation
BEFORE UPDATE ON core.desired_state_records
FOR EACH ROW
EXECUTE FUNCTION core.guard_desired_state_generation();
-- ---------------------------------------------------------------------------
-- Design D7: one function computes a record's content under the recipient
-- rules. A person's record holds a person-recipient key that any pool of an
-- active organization they own, or belong to through an active membership,
-- holds; an organization's record holds the organization-recipient keys of
-- its default pool. The System organization is excluded in every branch, so
-- it and its synthetic owner get empty content. A cut-off person's content
-- is empty. Entries are sorted by key and targets sorted, in byte order
-- (COLLATE "C"), so the canonical form does not depend on the database's
-- locale. Each table carries one alias throughout, which the trigger guard
-- test reads (TestDesiredStateTriggersWatchEveryColumnContentReads).
-- Invoker rights with a pinned search_path: it reads only what its caller
-- may, and a caller's temporary table cannot shadow a core table.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.desired_state_content(p_connection_id UUID, p_person_id UUID, p_org_id UUID)
RETURNS JSONB
LANGUAGE sql STABLE
SET search_path = core, pg_temp
AS $$
WITH conn AS (
SELECT pc.provider FROM provider_connections pc WHERE pc.connection_id = p_connection_id
),
source_orgs AS (
SELECT o.org_id FROM organizations o
WHERE p_person_id IS NOT NULL AND o.owner_person_id = p_person_id
AND o.status = 'active' AND o.org_type <> 'system'
UNION
SELECT o.org_id FROM org_members om
JOIN organizations o ON o.org_id = om.org_id
WHERE p_person_id IS NOT NULL AND om.person_id = p_person_id AND om.status = 'active'
AND o.status = 'active' AND o.org_type <> 'system'
UNION
SELECT o.org_id FROM organizations o
WHERE p_org_id IS NOT NULL AND o.org_id = p_org_id
AND o.status = 'active' AND o.org_type <> 'system'
),
pools AS (
SELECT rp.pool_id FROM resource_pools rp JOIN source_orgs so ON so.org_id = rp.org_id
WHERE p_person_id IS NOT NULL OR rp.pool_type = 'default'
),
keys AS (
SELECT rk.resource_key, rk.kind FROM resource_keys rk JOIN conn cn ON rk.provider = cn.provider
WHERE rk.recipient = CASE WHEN p_person_id IS NOT NULL THEN 'person' ELSE 'organization' END
),
held AS (
SELECT k.resource_key, NULL::BIGINT AS resource_limit FROM keys k
WHERE k.kind = 'boolean' AND EXISTS (
SELECT 1 FROM boolean_entitlements be JOIN pools pl ON pl.pool_id = be.pool_id
WHERE be.resource_key = k.resource_key AND be.granted)
UNION ALL
SELECT k.resource_key, ne.resource_limit FROM keys k
JOIN numeric_entitlements ne ON ne.resource_key = k.resource_key
JOIN pools pl ON pl.pool_id = ne.pool_id
WHERE k.kind = 'numeric' AND ne.resource_limit > 0
)
SELECT jsonb_build_object('entitlements', COALESCE(jsonb_agg(
jsonb_strip_nulls(jsonb_build_object(
'key', h.resource_key,
'granted', CASE WHEN h.resource_limit IS NULL THEN TRUE END,
'limit', h.resource_limit,
'targets', (SELECT jsonb_agg(tm.target_ref ORDER BY tm.target_ref COLLATE "C")
FROM target_mappings tm
WHERE tm.connection_id = p_connection_id
AND tm.resource_key = h.resource_key)))
ORDER BY h.resource_key COLLATE "C"), '[]'::jsonb))
FROM held h
WHERE NOT EXISTS (SELECT 1 FROM person_cut_offs pco
WHERE pco.person_id = p_person_id AND pco.lifted_at IS NULL);
$$;
-- +goose StatementEnd
-- ---------------------------------------------------------------------------
-- Design D23: the cut-off setter, the only write path to
-- core.person_cut_offs for the application's roles. Returns whether the
-- person's state changed; a call that changes nothing writes nothing, so the
-- cut-off trigger fires only for real changes. The ON CONFLICT target
-- repeats the partial index's predicate so PostgreSQL can infer it. A lift
-- stamps the later of NOW() and the cut-off time: NOW() is the caller's
-- transaction start, and under READ COMMITTED that transaction can see a
-- cut-off committed after it began. A NULL person or cut_off is refused, so
-- a NULL is never read as a lift.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.set_person_cut_off(p_person_id UUID, p_cut_off BOOLEAN, p_actor_person_id UUID)
RETURNS BOOLEAN
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
BEGIN
IF p_person_id IS NULL OR p_cut_off IS NULL THEN
RAISE EXCEPTION 'set_person_cut_off: person and cut_off are required'
USING ERRCODE = 'null_value_not_allowed';
END IF;
IF p_cut_off THEN
INSERT INTO person_cut_offs (person_id, cut_off_by_person_id)
VALUES (p_person_id, p_actor_person_id)
ON CONFLICT (person_id) WHERE lifted_at IS NULL DO NOTHING;
ELSE
UPDATE person_cut_offs SET lifted_at = GREATEST(NOW(), cut_off_at), lifted_by_person_id = p_actor_person_id
WHERE person_id = p_person_id AND lifted_at IS NULL;
END IF;
RETURN FOUND;
END;
$$;
-- +goose StatementEnd
-- ---------------------------------------------------------------------------
-- Design D16: when the relay's recompute of one recipient fails, it gives
-- that recipient a row of its own with one attempt counted, so one bad
-- recipient cannot dead-letter a row that expands to thousands. It writes
-- only a recipient_only row for a person or organization that exists, and
-- raises for any other subject kind or an unknown identifier, so the worst a
-- forged call does is cause one extra recompute. Returns the row's id.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.requeue_desired_state(p_subject_kind TEXT, p_subject_id UUID, p_error TEXT)
RETURNS UUID
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
DECLARE
v_id UUID;
BEGIN
IF p_subject_kind = 'person' THEN
PERFORM 1 FROM persons WHERE person_id = p_subject_id;
ELSIF p_subject_kind = 'organization' THEN
PERFORM 1 FROM organizations WHERE org_id = p_subject_id;
ELSE
RAISE EXCEPTION 'requeue_desired_state: subject kind % is not a recipient', p_subject_kind
USING ERRCODE = 'invalid_parameter_value';
END IF;
IF NOT FOUND THEN
RAISE EXCEPTION 'requeue_desired_state: no % with id %', p_subject_kind, p_subject_id
USING ERRCODE = 'no_data_found';
END IF;
INSERT INTO desired_state_outbox (subject_kind, subject_id, recipient_only, attempts, last_error)
VALUES (p_subject_kind, p_subject_id, TRUE, 1, p_error)
RETURNING id INTO v_id;
RETURN v_id;
END;
$$;
-- +goose StatementEnd
-- ---------------------------------------------------------------------------
-- Design D11, D12: the outbox triggers. Every trigger function runs as
-- core_owner with a pinned search_path, because core_writer holds no INSERT
-- on the outbox and a trigger running with the writer's rights could not
-- insert. The functions read nothing but the changed row's old and new
-- values: not a key's recipient, not connection state. Which changes fire is
-- decided by each trigger's WHEN condition, evaluated before the function is
-- queued, so an unchanged rewrite costs one comparison and no call.
-- PostgreSQL refuses an INSERT trigger's WHEN that reads OLD and a DELETE
-- trigger's WHEN that reads NEW, so each table carries one trigger per event,
-- all calling that table's function. Each UPDATE trigger's WHEN names every
-- column core.desired_state_content reads from its table. When an
-- identifying column moved, the old side is written too, because the relay
-- recomputes from current state and could not otherwise find it.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.insert_desired_state_notice(p_subject_kind TEXT, p_subject_id UUID, p_resource_key TEXT)
RETURNS VOID
LANGUAGE sql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
INSERT INTO desired_state_outbox (subject_kind, subject_id, resource_key)
VALUES (p_subject_kind, p_subject_id, p_resource_key);
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.desired_state_on_boolean_entitlements() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
PERFORM insert_desired_state_notice('pool', NEW.pool_id, NEW.resource_key);
END IF;
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND (OLD.pool_id IS DISTINCT FROM NEW.pool_id
OR OLD.resource_key IS DISTINCT FROM NEW.resource_key)) THEN
PERFORM insert_desired_state_notice('pool', OLD.pool_id, OLD.resource_key);
END IF;
RETURN NULL;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.desired_state_on_numeric_entitlements() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
PERFORM insert_desired_state_notice('pool', NEW.pool_id, NEW.resource_key);
END IF;
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND (OLD.pool_id IS DISTINCT FROM NEW.pool_id
OR OLD.resource_key IS DISTINCT FROM NEW.resource_key)) THEN
PERFORM insert_desired_state_notice('pool', OLD.pool_id, OLD.resource_key);
END IF;
RETURN NULL;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.desired_state_on_org_members() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
PERFORM insert_desired_state_notice('person', NEW.person_id, NULL);
END IF;
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.person_id IS DISTINCT FROM NEW.person_id) THEN
PERFORM insert_desired_state_notice('person', OLD.person_id, NULL);
END IF;
RETURN NULL;
END;
$$;
-- +goose StatementEnd
-- An organization's own record changes with its status; its owner's and
-- members' records are reached by the relay's expansion of the row. On an
-- owner change both owners are written, because nothing links the old owner
-- to the organization once the change commits.
-- +goose StatementBegin
CREATE FUNCTION core.desired_state_on_organizations() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
BEGIN
PERFORM insert_desired_state_notice('organization', NEW.org_id, NULL);
IF OLD.org_id IS DISTINCT FROM NEW.org_id THEN
PERFORM insert_desired_state_notice('organization', OLD.org_id, NULL);
END IF;
IF OLD.owner_person_id IS DISTINCT FROM NEW.owner_person_id THEN
PERFORM insert_desired_state_notice('person', OLD.owner_person_id, NULL);
PERFORM insert_desired_state_notice('person', NEW.owner_person_id, NULL);
END IF;
RETURN NULL;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.desired_state_on_provider_connections() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
BEGIN
PERFORM insert_desired_state_notice('connection', NEW.connection_id, NULL);
IF TG_OP = 'UPDATE' AND OLD.connection_id IS DISTINCT FROM NEW.connection_id THEN
PERFORM insert_desired_state_notice('connection', OLD.connection_id, NULL);
END IF;
RETURN NULL;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.desired_state_on_target_mappings() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
PERFORM insert_desired_state_notice('connection', NEW.connection_id, NEW.resource_key);
END IF;
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND (OLD.connection_id IS DISTINCT FROM NEW.connection_id
OR OLD.resource_key IS DISTINCT FROM NEW.resource_key)) THEN
PERFORM insert_desired_state_notice('connection', OLD.connection_id, OLD.resource_key);
END IF;
RETURN NULL;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.desired_state_on_person_cut_offs() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
PERFORM insert_desired_state_notice('person', NEW.person_id, NULL);
END IF;
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.person_id IS DISTINCT FROM NEW.person_id) THEN
PERFORM insert_desired_state_notice('person', OLD.person_id, NULL);
END IF;
RETURN NULL;
END;
$$;
-- +goose StatementEnd
-- Entitlements: a held value appearing, disappearing or moving. A lapsed
-- boolean and a zero limit are not held, so inserting or deleting one fires
-- nothing.
CREATE TRIGGER trigger_boolean_entitlements_desired_state_insert
AFTER INSERT ON core.boolean_entitlements
FOR EACH ROW WHEN (NEW.granted)
EXECUTE FUNCTION core.desired_state_on_boolean_entitlements();
CREATE TRIGGER trigger_boolean_entitlements_desired_state_update
AFTER UPDATE ON core.boolean_entitlements
FOR EACH ROW WHEN (OLD.granted IS DISTINCT FROM NEW.granted
OR OLD.pool_id IS DISTINCT FROM NEW.pool_id
OR OLD.resource_key IS DISTINCT FROM NEW.resource_key)
EXECUTE FUNCTION core.desired_state_on_boolean_entitlements();
CREATE TRIGGER trigger_boolean_entitlements_desired_state_delete
AFTER DELETE ON core.boolean_entitlements
FOR EACH ROW WHEN (OLD.granted)
EXECUTE FUNCTION core.desired_state_on_boolean_entitlements();
CREATE TRIGGER trigger_numeric_entitlements_desired_state_insert
AFTER INSERT ON core.numeric_entitlements
FOR EACH ROW WHEN (NEW.resource_limit > 0)
EXECUTE FUNCTION core.desired_state_on_numeric_entitlements();
CREATE TRIGGER trigger_numeric_entitlements_desired_state_update
AFTER UPDATE ON core.numeric_entitlements
FOR EACH ROW WHEN (OLD.resource_limit IS DISTINCT FROM NEW.resource_limit
OR OLD.pool_id IS DISTINCT FROM NEW.pool_id
OR OLD.resource_key IS DISTINCT FROM NEW.resource_key)
EXECUTE FUNCTION core.desired_state_on_numeric_entitlements();
CREATE TRIGGER trigger_numeric_entitlements_desired_state_delete
AFTER DELETE ON core.numeric_entitlements
FOR EACH ROW WHEN (OLD.resource_limit > 0)
EXECUTE FUNCTION core.desired_state_on_numeric_entitlements();
-- Memberships: only an active membership delivers anything; role_id never
-- does.
CREATE TRIGGER trigger_org_members_desired_state_insert
AFTER INSERT ON core.org_members
FOR EACH ROW WHEN (NEW.status = 'active')
EXECUTE FUNCTION core.desired_state_on_org_members();
CREATE TRIGGER trigger_org_members_desired_state_update
AFTER UPDATE ON core.org_members
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status
OR OLD.org_id IS DISTINCT FROM NEW.org_id
OR OLD.person_id IS DISTINCT FROM NEW.person_id)
EXECUTE FUNCTION core.desired_state_on_org_members();
CREATE TRIGGER trigger_org_members_desired_state_delete
AFTER DELETE ON core.org_members
FOR EACH ROW WHEN (OLD.status = 'active')
EXECUTE FUNCTION core.desired_state_on_org_members();
-- Organizations: no INSERT trigger, because a new organization has no pool
-- yet and the materialization that gives it entitlements writes its own
-- rows. A rename fires nothing.
CREATE TRIGGER trigger_organizations_desired_state_update
AFTER UPDATE ON core.organizations
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status
OR OLD.owner_person_id IS DISTINCT FROM NEW.owner_person_id
OR OLD.org_type IS DISTINCT FROM NEW.org_type
OR OLD.org_id IS DISTINCT FROM NEW.org_id)
EXECUTE FUNCTION core.desired_state_on_organizations();
-- Connections: approval and status decide whether the relay delivers to a
-- connection; provider decides which keys its records carry. A pending
-- insert fires nothing, and a rename fires nothing.
CREATE TRIGGER trigger_provider_connections_desired_state_insert
AFTER INSERT ON core.provider_connections
FOR EACH ROW WHEN (NEW.approval_state = 'approved')
EXECUTE FUNCTION core.desired_state_on_provider_connections();
CREATE TRIGGER trigger_provider_connections_desired_state_update
AFTER UPDATE ON core.provider_connections
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status
OR OLD.approval_state IS DISTINCT FROM NEW.approval_state
OR OLD.approved_at IS DISTINCT FROM NEW.approved_at
OR OLD.provider IS DISTINCT FROM NEW.provider
OR OLD.connection_id IS DISTINCT FROM NEW.connection_id)
EXECUTE FUNCTION core.desired_state_on_provider_connections();
-- Target mappings: every insert and delete delivers; display_name never
-- does.
CREATE TRIGGER trigger_target_mappings_desired_state_insert
AFTER INSERT ON core.target_mappings
FOR EACH ROW
EXECUTE FUNCTION core.desired_state_on_target_mappings();
CREATE TRIGGER trigger_target_mappings_desired_state_update
AFTER UPDATE ON core.target_mappings
FOR EACH ROW WHEN (OLD.connection_id IS DISTINCT FROM NEW.connection_id
OR OLD.resource_key IS DISTINCT FROM NEW.resource_key
OR OLD.target_ref IS DISTINCT FROM NEW.target_ref)
EXECUTE FUNCTION core.desired_state_on_target_mappings();
CREATE TRIGGER trigger_target_mappings_desired_state_delete
AFTER DELETE ON core.target_mappings
FOR EACH ROW
EXECUTE FUNCTION core.desired_state_on_target_mappings();
-- Cut-offs: an open row appearing, closing or disappearing.
CREATE TRIGGER trigger_person_cut_offs_desired_state_insert
AFTER INSERT ON core.person_cut_offs
FOR EACH ROW WHEN (NEW.lifted_at IS NULL)
EXECUTE FUNCTION core.desired_state_on_person_cut_offs();
CREATE TRIGGER trigger_person_cut_offs_desired_state_update
AFTER UPDATE ON core.person_cut_offs
FOR EACH ROW WHEN ((OLD.lifted_at IS NULL) <> (NEW.lifted_at IS NULL)
OR OLD.person_id IS DISTINCT FROM NEW.person_id)
EXECUTE FUNCTION core.desired_state_on_person_cut_offs();
CREATE TRIGGER trigger_person_cut_offs_desired_state_delete
AFTER DELETE ON core.person_cut_offs
FOR EACH ROW WHEN (OLD.lifted_at IS NULL)
EXECUTE FUNCTION core.desired_state_on_person_cut_offs();
-- ---------------------------------------------------------------------------
-- Design D33: ownership, revokes and grants. 00001's GRANT ALL ON ALL TABLES
-- covered only the tables that existed then, so nothing here needs revoking.
-- The five ordinary tables get the writer posture the per-schema-roles spec
-- requires. person_cut_offs is a full enclosure: its setter is core_writer's
-- only write path. desired_state_outbox is an insert enclosure: no INSERT for
-- core_writer, UPDATE on the claim and completion columns only, so the relay
-- can claim and complete a row but never retarget it, and DELETE for
-- retention. Integration writer roles inherit core_reader, so they read the
-- new tables and write none of them.
-- ---------------------------------------------------------------------------
GRANT ALL ON core.provider_connections, core.target_mappings, core.desired_state_records,
core.observed_projections, core.lifecycle_requests,
core.desired_state_outbox, core.person_cut_offs
TO core_owner;
GRANT SELECT, INSERT, UPDATE, DELETE
ON core.provider_connections, core.target_mappings, core.desired_state_records,
core.observed_projections, core.lifecycle_requests
TO core_writer;
GRANT SELECT, DELETE ON core.desired_state_outbox TO core_writer;
GRANT UPDATE (status, attempts, claimed_until, expansion_cursor, last_error, completed_at)
ON core.desired_state_outbox TO core_writer;
GRANT SELECT ON core.person_cut_offs TO core_writer;
GRANT SELECT ON core.provider_connections, core.target_mappings, core.desired_state_records,
core.observed_projections, core.lifecycle_requests,
core.desired_state_outbox, core.person_cut_offs
TO core_reader;
-- Invoker-rights functions, granted as 00023 granted its two.
GRANT EXECUTE ON FUNCTION core.generation_ceiling() TO core_reader, core_writer, core_owner;
GRANT EXECUTE ON FUNCTION core.next_generation(BIGINT, BIGINT) TO core_reader, core_writer, core_owner;
GRANT EXECUTE ON FUNCTION core.desired_state_content(UUID, UUID, UUID) TO core_reader, core_writer, core_owner;
GRANT EXECUTE ON FUNCTION core.guard_desired_state_generation() TO core_reader, core_writer, core_owner;
-- The enclosure functions (Decision 137 pattern): owned by core_owner, EXECUTE
-- revoked from PUBLIC, which also keeps scripts/db-reader-role.sql from
-- refusing to run. Only the setter and the requeue function are granted, to
-- core_writer; a trigger fires for a role that holds no EXECUTE on its
-- function.
ALTER FUNCTION core.set_person_cut_off(UUID, BOOLEAN, UUID) OWNER TO core_owner;
ALTER FUNCTION core.requeue_desired_state(TEXT, UUID, TEXT) OWNER TO core_owner;
ALTER FUNCTION core.insert_desired_state_notice(TEXT, UUID, TEXT) OWNER TO core_owner;
ALTER FUNCTION core.desired_state_on_boolean_entitlements() OWNER TO core_owner;
ALTER FUNCTION core.desired_state_on_numeric_entitlements() OWNER TO core_owner;
ALTER FUNCTION core.desired_state_on_org_members() OWNER TO core_owner;
ALTER FUNCTION core.desired_state_on_organizations() OWNER TO core_owner;
ALTER FUNCTION core.desired_state_on_provider_connections() OWNER TO core_owner;
ALTER FUNCTION core.desired_state_on_target_mappings() OWNER TO core_owner;
ALTER FUNCTION core.desired_state_on_person_cut_offs() OWNER TO core_owner;
REVOKE EXECUTE ON FUNCTION core.set_person_cut_off(UUID, BOOLEAN, UUID) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.requeue_desired_state(TEXT, UUID, TEXT) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.insert_desired_state_notice(TEXT, UUID, TEXT) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.desired_state_on_boolean_entitlements() FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.desired_state_on_numeric_entitlements() FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.desired_state_on_org_members() FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.desired_state_on_organizations() FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.desired_state_on_provider_connections() FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.desired_state_on_target_mappings() FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.desired_state_on_person_cut_offs() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION core.set_person_cut_off(UUID, BOOLEAN, UUID) TO core_writer;
GRANT EXECUTE ON FUNCTION core.requeue_desired_state(TEXT, UUID, TEXT) TO core_writer;
-- +goose Down
-- The outbox triggers, then the functions, then the tables, then the column
-- on core.resource_keys. The down migration discards connection identifiers,
-- records, requests and cut-off history; nothing outside the database holds
-- a connection identifier yet.
DROP TRIGGER trigger_person_cut_offs_desired_state_delete ON core.person_cut_offs;
DROP TRIGGER trigger_person_cut_offs_desired_state_update ON core.person_cut_offs;
DROP TRIGGER trigger_person_cut_offs_desired_state_insert ON core.person_cut_offs;
DROP TRIGGER trigger_target_mappings_desired_state_delete ON core.target_mappings;
DROP TRIGGER trigger_target_mappings_desired_state_update ON core.target_mappings;
DROP TRIGGER trigger_target_mappings_desired_state_insert ON core.target_mappings;
DROP TRIGGER trigger_provider_connections_desired_state_update ON core.provider_connections;
DROP TRIGGER trigger_provider_connections_desired_state_insert ON core.provider_connections;
DROP TRIGGER trigger_organizations_desired_state_update ON core.organizations;
DROP TRIGGER trigger_org_members_desired_state_delete ON core.org_members;
DROP TRIGGER trigger_org_members_desired_state_update ON core.org_members;
DROP TRIGGER trigger_org_members_desired_state_insert ON core.org_members;
DROP TRIGGER trigger_numeric_entitlements_desired_state_delete ON core.numeric_entitlements;
DROP TRIGGER trigger_numeric_entitlements_desired_state_update ON core.numeric_entitlements;
DROP TRIGGER trigger_numeric_entitlements_desired_state_insert ON core.numeric_entitlements;
DROP TRIGGER trigger_boolean_entitlements_desired_state_delete ON core.boolean_entitlements;
DROP TRIGGER trigger_boolean_entitlements_desired_state_update ON core.boolean_entitlements;
DROP TRIGGER trigger_boolean_entitlements_desired_state_insert ON core.boolean_entitlements;
DROP TRIGGER trigger_desired_state_records_guard_generation ON core.desired_state_records;
DROP FUNCTION core.desired_state_on_person_cut_offs();
DROP FUNCTION core.desired_state_on_target_mappings();
DROP FUNCTION core.desired_state_on_provider_connections();
DROP FUNCTION core.desired_state_on_organizations();
DROP FUNCTION core.desired_state_on_org_members();
DROP FUNCTION core.desired_state_on_numeric_entitlements();
DROP FUNCTION core.desired_state_on_boolean_entitlements();
DROP FUNCTION core.insert_desired_state_notice(TEXT, UUID, TEXT);
DROP FUNCTION core.requeue_desired_state(TEXT, UUID, TEXT);
DROP FUNCTION core.set_person_cut_off(UUID, BOOLEAN, UUID);
DROP FUNCTION core.desired_state_content(UUID, UUID, UUID);
DROP FUNCTION core.guard_desired_state_generation();
DROP FUNCTION core.next_generation(BIGINT, BIGINT);
DROP FUNCTION core.generation_ceiling();
DROP TABLE core.observed_projections;
DROP TABLE core.desired_state_records;
DROP TABLE core.target_mappings;
DROP TABLE core.lifecycle_requests;
DROP TABLE core.desired_state_outbox;
DROP TABLE core.person_cut_offs;
DROP TABLE core.provider_connections;
ALTER TABLE core.resource_keys
DROP CONSTRAINT uq_resource_keys_key_provider,
DROP CONSTRAINT person_recipient_keys_boolean,
DROP CONSTRAINT platform_keys_no_recipient,
DROP CONSTRAINT chk_resource_keys_recipient,
DROP COLUMN recipient;
-2
View File
@@ -35,7 +35,6 @@ internal/embeds/
└── partials/ # HTMX partial templates
├── fedwiki_sites.html
├── fedwiki_create_form.html
├── fedwiki_create_success.html
├── fedwiki_delete_confirm.html
└── fedwiki_delete_success.html
```
@@ -106,7 +105,6 @@ HTML fragments returned by HTMX handlers. These are **not** full pages.
Examples:
- `fedwiki_sites.html` - Sites list display
- `fedwiki_create_form.html` - Site creation form
- `fedwiki_create_success.html` - Creation success message
- `fedwiki_delete_confirm.html` - Delete confirmation dialog
- `fedwiki_delete_success.html` - Deletion success message
+1 -1
View File
@@ -89,7 +89,7 @@ func TestTimedSyncCap(t *testing.T) {
// seedCarryingPools adds n organizations whose default pools carry the
// product, in batches so one transaction never holds hundreds of pool
// materializations.
func seedCarryingPools(t *testing.T, ctx context.Context, database *sql.DB, productID string, n int) {
func seedCarryingPools(t testing.TB, ctx context.Context, database *sql.DB, productID string, n int) {
t.Helper()
const batch = 50
for done := 0; done < n; {
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// Benchmarks for the desired-state outbox (openspec change
// integration-delivery-core, task 16.1). Each benchmark creates a scratch
// database in the test cluster, because the first one disables triggers and
// the second one deletes records, and neither may touch the shared test
// database other packages use. Run them with:
//
// go test -run '^$' -bench 'RuleChangeAtSyncCap|FirstFill' -benchtime 10x ./internal/entitlements/
import (
"context"
"database/sql"
"fmt"
"net/url"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"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/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/desiredstate"
)
// firstFillRecipients is how many entitled organizations the first-fill
// benchmark seeds before it fills one connection's records.
const firstFillRecipients = 1000
// scratchDeliveryDB creates an empty database in the cluster
// TEST_DATABASE_URL points at, migrates every stream into it, and drops it
// when the benchmark ends.
func scratchDeliveryDB(tb testing.TB) *sql.DB {
tb.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
tb.Skip("TEST_DATABASE_URL not set, skipping integration benchmark")
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
tb.Fatalf("open test database: %v", err)
}
tb.Cleanup(func() { admin.Close() })
name := fmt.Sprintf("mc_scratch_bench_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE DATABASE " + name); err != nil {
tb.Skipf("cannot create a scratch database: %v", err)
}
tb.Cleanup(func() {
if _, err := admin.Exec("DROP DATABASE IF EXISTS " + name + " WITH (FORCE)"); err != nil {
tb.Logf("drop scratch database %s: %v", name, err)
}
})
u, err := url.Parse(dsn)
if err != nil {
tb.Fatalf("parse TEST_DATABASE_URL: %v", err)
}
u.Path = "/" + name
scratch, err := sql.Open("pgx", u.String())
if err != nil {
tb.Fatalf("open scratch database: %v", err)
}
// Closed before the drop above, because cleanups run last-in first-out.
tb.Cleanup(func() { scratch.Close() })
if err := db.RunMigrations(scratch, migrate.Sources()); err != nil {
tb.Fatalf("migrate scratch database: %v", err)
}
return scratch
}
// setDesiredStateTriggers enables or disables the eighteen triggers that
// write into core.desired_state_outbox. The generation guard on the records
// table is not one of them and stays enabled.
func setDesiredStateTriggers(tb testing.TB, ctx context.Context, database *sql.DB, enabled bool) {
tb.Helper()
rows, err := database.QueryContext(ctx, `
SELECT c.relname, t.tgname FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'core' AND NOT t.tgisinternal
AND t.tgname ~ '_desired_state_(insert|update|delete)$'`)
if err != nil {
tb.Fatalf("list desired-state triggers: %v", err)
}
type trig struct{ table, name string }
var trigs []trig
for rows.Next() {
var tr trig
if err := rows.Scan(&tr.table, &tr.name); err != nil {
tb.Fatal(err)
}
trigs = append(trigs, tr)
}
rows.Close()
if len(trigs) != 18 {
tb.Fatalf("found %d desired-state triggers, want 18", len(trigs))
}
verb := "DISABLE"
if enabled {
verb = "ENABLE"
}
for _, tr := range trigs {
if _, err := database.ExecContext(ctx,
fmt.Sprintf("ALTER TABLE core.%s %s TRIGGER %s", tr.table, verb, tr.name)); err != nil {
tb.Fatalf("%s trigger %s: %v", verb, tr.name, err)
}
}
}
func takeOutboxCount(tb testing.TB, ctx context.Context, database *sql.DB) int64 {
tb.Helper()
res, err := database.ExecContext(ctx, `DELETE FROM core.desired_state_outbox`)
if err != nil {
tb.Fatalf("clear outbox: %v", err)
}
n, err := res.RowsAffected()
if err != nil {
tb.Fatal(err)
}
return n
}
// BenchmarkRuleChangeAtSyncCap commits a rule change that changes one numeric
// limit on every one of the synchronous cap's pools inside the exclusive
// rendezvous, once with the desired-state triggers enabled and once with them
// disabled. The difference is what the triggers add while the lock is held.
func BenchmarkRuleChangeAtSyncCap(b *testing.B) {
database := scratchDeliveryDB(b)
ctx := context.Background()
pools := int(entitlements.SyncCap())
setupTx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
b.Fatal(err)
}
product := createTestProduct(b, ctx, setupTx, "Sync cap bench", 6, false)
rule := firstActiveRule(b, ctx, entitlements.New(setupTx), product.setID)
if err := setupTx.Commit(); err != nil {
b.Fatalf("commit product: %v", err)
}
seedCarryingPools(b, ctx, database, product.productID, pools)
takeOutboxCount(b, ctx, database)
value := int64(6)
for _, enabled := range []bool{true, false} {
name := "triggers=disabled"
if enabled {
name = "triggers=enabled"
}
b.Run(name, func(b *testing.B) {
setDesiredStateTriggers(b, ctx, database, enabled)
b.Cleanup(func() { setDesiredStateTriggers(b, ctx, database, true) })
var notices int64
b.ResetTimer()
for i := 0; i < b.N; i++ {
value++
result, err := entitlements.RuleChangeCommit(ctx, database, entitlements.RuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: rule.RuleID,
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: value, Valid: true}},
ActorType: entitlements.ActorTypeSystem,
SyncCap: int32(pools),
})
if err != nil {
b.Fatalf("commit: %v", err)
}
if result.SyncPath != "atomic" || result.Recomputed != pools {
b.Fatalf("commit took path %q with %d recomputed, want atomic with %d",
result.SyncPath, result.Recomputed, pools)
}
b.StopTimer()
notices += takeOutboxCount(b, ctx, database)
b.StartTimer()
}
b.ReportMetric(float64(notices)/float64(b.N), "notices/op")
})
}
}
// BenchmarkFirstFill seeds organizations entitled to FedWiki's
// organization-recipient key, then times the relay filling every record of
// FedWiki's connection from the one row its approval writes, as the first
// boot after migrating does. Each iteration deletes the records and
// re-approves the connection with the timer stopped.
func BenchmarkFirstFill(b *testing.B) {
database := scratchDeliveryDB(b)
ctx := context.Background()
if err := integration.RegisterProviders(ctx, database,
[]integration.ProviderSource{fwmod.ProviderSource()}); err != nil {
b.Fatalf("register FedWiki: %v", err)
}
var connectionID string
if err := database.QueryRowContext(ctx,
`SELECT connection_id FROM core.provider_connections WHERE provider = 'fedwiki'`).Scan(&connectionID); err != nil {
b.Fatalf("FedWiki connection: %v", err)
}
setupTx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
b.Fatal(err)
}
product := createTestProduct(b, ctx, setupTx, "First fill bench", 3, false)
if err := setupTx.Commit(); err != nil {
b.Fatalf("commit product: %v", err)
}
seedCarryingPools(b, ctx, database, product.productID, firstFillRecipients)
b.Run(fmt.Sprintf("recipients=%d", firstFillRecipients), func(b *testing.B) {
var created int
b.StopTimer()
for i := 0; i < b.N; i++ {
if _, err := database.ExecContext(ctx,
`DELETE FROM core.desired_state_records WHERE connection_id = $1`, connectionID); err != nil {
b.Fatalf("delete records: %v", err)
}
takeOutboxCount(b, ctx, database)
if _, err := database.ExecContext(ctx,
`UPDATE core.provider_connections SET approved_at = clock_timestamp() WHERE connection_id = $1`,
connectionID); err != nil {
b.Fatalf("re-approve connection: %v", err)
}
b.StartTimer()
for {
res, err := desiredstate.ProcessDesiredStateOutbox(ctx, database, desiredstate.DefaultLimits())
if err != nil {
b.Fatalf("relay: %v", err)
}
created += res.Created
if res.Claimed == 0 {
break
}
}
b.StopTimer()
}
if want := firstFillRecipients * b.N; created != want {
b.Fatalf("created %d records, want %d", created, want)
}
b.ReportMetric(float64(created)/float64(b.N), "records/op")
b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(created), "ns/record")
})
}
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// Materialization and the desired-state outbox (openspec change
// integration-delivery-core, task 2.9). The triggers on the entitlement
// tables write a row only when a delivered value changes, so the rewrites
// every reconcile performs write none, a dry run writes none, and a rule
// change refused partway leaves none.
import (
"context"
"database/sql"
"slices"
"testing"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// takePoolNotices removes and returns the desired-state outbox rows naming
// the pool, as "kind/key", sorted.
func takePoolNotices(t *testing.T, ctx context.Context, tx *sql.Tx, poolID string) []string {
t.Helper()
rows, err := tx.QueryContext(ctx,
`DELETE FROM core.desired_state_outbox WHERE subject_id = $1
RETURNING subject_kind || '/' || coalesce(resource_key, '')`, poolID)
if err != nil {
t.Fatalf("read notices: %v", err)
}
defer rows.Close()
var got []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
t.Fatal(err)
}
got = append(got, n)
}
slices.Sort(got)
return got
}
func countPoolNotices(t *testing.T, ctx context.Context, q interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, poolID string) int {
t.Helper()
var n int
if err := q.QueryRowContext(ctx,
`SELECT COUNT(*) FROM core.desired_state_outbox WHERE subject_id = $1`, poolID).Scan(&n); err != nil {
t.Fatalf("count notices: %v", err)
}
return n
}
// Scenario: a materialization writes one row per changed key; repeating it,
// as every reconcile does, writes none; a dry run writes none; a lapse
// writes one.
func TestMaterializationWritesDesiredStateNoticesOnlyOnChange(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
pool := to.pool.PoolID
flag := "delivery.flag-" + uuid.New().String()[:8]
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.resource_keys (resource_key, display_name, unit, kind) VALUES ($1, 'Delivery Flag', 'flag', 'boolean')`,
flag); err != nil {
t.Fatalf("create key: %v", err)
}
limited := createTestProduct(t, ctx, tx, "Delivery Limit "+uuid.New().String()[:8], 5, false)
flagged := createBoolTestProduct(t, ctx, tx, "Delivery Flag "+uuid.New().String()[:8], flag)
createGrantAndProvision(t, ctx, tx, q, to, limited.productID, 1)
if got, want := takePoolNotices(t, ctx, tx, pool), []string{"pool/fedwiki_sites"}; !slices.Equal(got, want) {
t.Errorf("the first materialization wrote %v, want %v", got, want)
}
flagGrant := createGrantAndProvision(t, ctx, tx, q, to, flagged.productID, 1)
if got, want := takePoolNotices(t, ctx, tx, pool), []string{"pool/" + flag}; !slices.Equal(got, want) {
t.Errorf("materializing a new boolean wrote %v, want %v (the unchanged limit writes nothing)", got, want)
}
for i := 0; i < 2; i++ {
if err := entitlements.MaterializePoolEntitlements(ctx, q, pool); err != nil {
t.Fatalf("materialize again: %v", err)
}
}
if got := takePoolNotices(t, ctx, tx, pool); len(got) != 0 {
t.Errorf("idempotent materializations wrote %v, want nothing", got)
}
rule := firstActiveRule(t, ctx, q, limited.setID)
ruleID := rule.RuleID
if _, err := entitlements.DryRunPoolEntitlements(ctx, q, pool, []entitlements.RuleOverlay{{
RuleID: &ruleID,
SetID: limited.setID,
RuleType: "limit",
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: 9, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
IsActive: true,
}}); err != nil {
t.Fatalf("dry run: %v", err)
}
if got := takePoolNotices(t, ctx, tx, pool); len(got) != 0 {
t.Errorf("a dry run wrote %v, want nothing", got)
}
endGrantConferral(t, ctx, q, flagGrant.GrantID, pool)
if got, want := takePoolNotices(t, ctx, tx, pool), []string{"pool/" + flag}; !slices.Equal(got, want) {
t.Errorf("a lapse wrote %v, want %v", got, want)
}
}
// Scenario: an atomic rule change whose second pool fails is refused, and
// the rows the first pool's settlement wrote roll back with it; the same
// change without the failure writes one row per pool.
func TestRefusedRuleChangeLeavesNoDesiredStateNotice(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
q := entitlements.New(tx)
product := createTestProduct(t, ctx, tx, "Delivery Atomic "+uuid.New().String()[:8], 4, false)
var pools []string
for i := 0; i < 3; i++ {
org := setupTestOrg(t, ctx, tx)
createGrantAndProvision(t, ctx, tx, q, org, product.productID, 1)
pools = append(pools, org.pool.PoolID)
}
rule := firstActiveRule(t, ctx, q, product.setID)
if err := tx.Commit(); err != nil {
t.Fatalf("commit fixture: %v", err)
}
sortPoolIDs(pools)
before := map[string]int{}
for _, pool := range pools {
before[pool] = countPoolNotices(t, ctx, database, pool)
}
_, err = entitlements.RuleChangeCommit(ctx, database, entitlements.RuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: rule.RuleID,
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: 40, Valid: true}},
ActorType: entitlements.ActorTypeSystem,
TestOnlyFailOnPool: 2,
})
if _, ok := entitlements.AsRefusal(err); !ok {
t.Fatalf("commit with an injected failure = %v, want a refusal", err)
}
for _, pool := range pools {
if after := countPoolNotices(t, ctx, database, pool); after != before[pool] {
t.Errorf("pool %s has %d desired-state rows after the refused change, want %d", pool, after, before[pool])
}
}
if _, err := entitlements.RuleChangeCommit(ctx, database, entitlements.RuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: rule.RuleID,
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: 40, Valid: true}},
ActorType: entitlements.ActorTypeSystem,
}); err != nil {
t.Fatalf("commit: %v", err)
}
for _, pool := range pools {
if after := countPoolNotices(t, ctx, database, pool); after != before[pool]+1 {
t.Errorf("pool %s has %d desired-state rows after the change, want %d", pool, after, before[pool]+1)
}
}
}
+5 -5
View File
@@ -168,7 +168,7 @@ type testOrg struct {
pool entitlements.ResourcePool
}
func setupTestOrg(t *testing.T, ctx context.Context, tx *sql.Tx) testOrg {
func setupTestOrg(t testing.TB, ctx context.Context, tx *sql.Tx) testOrg {
t.Helper()
q := entitlements.New(tx)
orgQ := organization.New(tx)
@@ -226,7 +226,7 @@ func setupTestOrg(t *testing.T, ctx context.Context, tx *sql.Tx) testOrg {
// path to core.entitlement_set_rules since migration 00018 revoked the DML,
// and returns the rule id it wrote. The caller's transaction must hold the
// exclusive materialization rendezvous, so it comes from BeginRuleChange.
func addSetRule(t *testing.T, ctx context.Context, q *entitlements.Queries, setID string, rule entitlements.RuleFields) string {
func addSetRule(t testing.TB, ctx context.Context, q *entitlements.Queries, setID string, rule entitlements.RuleFields) string {
t.Helper()
row, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: setID,
@@ -248,7 +248,7 @@ type testProduct struct {
// createTestProduct creates a billing product with an entitlement set containing a "fedwiki_sites" limit rule.
// sitesLimit is the base limit, perUnit controls whether it scales with quantity.
func createTestProduct(t *testing.T, ctx context.Context, tx *sql.Tx, name string, sitesLimit int64, perUnit bool) testProduct {
func createTestProduct(t testing.TB, ctx context.Context, tx *sql.Tx, name string, sitesLimit int64, perUnit bool) testProduct {
t.Helper()
q := entitlements.New(tx)
@@ -287,7 +287,7 @@ func createTestProduct(t *testing.T, ctx context.Context, tx *sql.Tx, name strin
// createGrantAndProvision records a grant for the given product and quantity,
// confers it onto the org's pool via core.confer, then materializes.
func createGrantAndProvision(t *testing.T, ctx context.Context, tx *sql.Tx, q *entitlements.Queries, to testOrg, productID string, quantity int32) entitlements.Grant {
func createGrantAndProvision(t testing.TB, ctx context.Context, tx *sql.Tx, q *entitlements.Queries, to testOrg, productID string, quantity int32) entitlements.Grant {
t.Helper()
grant, err := q.CreateGrant(ctx, entitlements.CreateGrantParams{
@@ -1101,7 +1101,7 @@ func stateFor(t *testing.T, states []entitlements.KeyState, key string) entitlem
return entitlements.KeyState{}
}
func firstActiveRule(t *testing.T, ctx context.Context, q *entitlements.Queries, setID string) entitlements.EntitlementSetRule {
func firstActiveRule(t testing.TB, ctx context.Context, q *entitlements.Queries, setID string) entitlements.EntitlementSetRule {
t.Helper()
rules, err := q.GetActiveRulesBySetID(ctx, setID)
if err != nil || len(rules) == 0 {
+1
View File
@@ -210,6 +210,7 @@ type ResourceKey struct {
Kind string `json:"kind"`
OverLimitBehavior string `json:"over_limit_behavior"`
OverLimitConsequence sql.NullString `json:"over_limit_consequence"`
Recipient sql.NullString `json:"recipient"`
}
type ResourcePool struct {
@@ -55,6 +55,33 @@ func (q *Queries) AtomicIncrementUsage(ctx context.Context, arg AtomicIncrementU
return q.db.ExecContext(ctx, atomicIncrementUsage, arg.WorkspaceID, arg.ResourceKey)
}
const boundNumericUsage = `-- name: BoundNumericUsage :execresult
UPDATE core.numeric_entitlement_usage
SET current_usage = GREATEST($1::bigint, LEAST(current_usage, $2::bigint))
WHERE pool_id = $3 AND resource_key = $4
`
type BoundNumericUsageParams struct {
Floor int64 `json:"floor"`
Ceiling int64 `json:"ceiling"`
PoolID string `json:"pool_id"`
ResourceKey string `json:"resource_key"`
}
// Moves the counter into [floor, ceiling], for boot reconciliation: floor is
// the count of rows the owning integration holds, ceiling adds the open
// requests that may hold a reservation beside them. An overstated counter
// comes down to the ceiling, never below a reservation an open request may
// hold; an understated one comes up to the floor. Creates no usage row.
func (q *Queries) BoundNumericUsage(ctx context.Context, arg BoundNumericUsageParams) (sql.Result, error) {
return q.db.ExecContext(ctx, boundNumericUsage,
arg.Floor,
arg.Ceiling,
arg.PoolID,
arg.ResourceKey,
)
}
const createNumericEntitlementUsage = `-- name: CreateNumericEntitlementUsage :one
INSERT INTO core.numeric_entitlement_usage (entitlement_id, pool_id, resource_key)
VALUES ($1, $2, $3)
@@ -199,6 +226,64 @@ func (q *Queries) ListPoolUsageWithLimitsByPoolID(ctx context.Context, poolID st
return items, nil
}
const listWorkspaceIDsHoldingUsage = `-- name: ListWorkspaceIDsHoldingUsage :many
SELECT DISTINCT pa.workspace_id
FROM core.pool_assignments pa
JOIN core.numeric_entitlement_usage neu ON neu.pool_id = pa.pool_id
WHERE pa.is_primary = TRUE AND pa.status = 'active'
AND neu.resource_key = $1
AND neu.current_usage > 0
`
// Workspaces whose active primary pool counts usage of the key above zero.
// Boot repair bounds these beside the workspaces that own the integration's
// rows, so a counter left holding a slot in a workspace with no row left is
// brought back down too.
func (q *Queries) ListWorkspaceIDsHoldingUsage(ctx context.Context, resourceKey string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listWorkspaceIDsHoldingUsage, resourceKey)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var workspace_id string
if err := rows.Scan(&workspace_id); err != nil {
return nil, err
}
items = append(items, workspace_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockNumericUsage = `-- name: LockNumericUsage :one
SELECT current_usage FROM core.numeric_entitlement_usage
WHERE pool_id = $1 AND resource_key = $2
FOR UPDATE
`
type LockNumericUsageParams struct {
PoolID string `json:"pool_id"`
ResourceKey string `json:"resource_key"`
}
// Locks a pool's usage row for a key and returns its count. A reconciliation
// that lowers the counter takes this lock before it counts what the counter
// should hold, so every increment or decrement either committed before the
// count or waits until the new value is written.
func (q *Queries) LockNumericUsage(ctx context.Context, arg LockNumericUsageParams) (int64, error) {
row := q.db.QueryRowContext(ctx, lockNumericUsage, arg.PoolID, arg.ResourceKey)
var current_usage int64
err := row.Scan(&current_usage)
return current_usage, err
}
const raiseNumericUsageTo = `-- name: RaiseNumericUsageTo :execresult
UPDATE core.numeric_entitlement_usage
SET current_usage = GREATEST(current_usage, $1)
@@ -219,23 +304,3 @@ type RaiseNumericUsageToParams struct {
func (q *Queries) RaiseNumericUsageTo(ctx context.Context, arg RaiseNumericUsageToParams) (sql.Result, error) {
return q.db.ExecContext(ctx, raiseNumericUsageTo, arg.Observed, arg.PoolID, arg.ResourceKey)
}
const setNumericUsage = `-- name: SetNumericUsage :execresult
UPDATE core.numeric_entitlement_usage
SET current_usage = $1
WHERE pool_id = $2 AND resource_key = $3
`
type SetNumericUsageParams struct {
Observed int64 `json:"observed"`
PoolID string `json:"pool_id"`
ResourceKey string `json:"resource_key"`
}
// Exact set, for boot reconciliation only: at boot no create workflow holds an
// unmaterialized reservation, so the owned rows are the whole truth and an
// overstated counter (which refuses creates) can be brought down.
// Creates no usage row.
func (q *Queries) SetNumericUsage(ctx context.Context, arg SetNumericUsageParams) (sql.Result, error) {
return q.db.ExecContext(ctx, setNumericUsage, arg.Observed, arg.PoolID, arg.ResourceKey)
}
@@ -122,16 +122,36 @@ func TestNumericUsageReconcile(t *testing.T) {
}
})
t.Run("exact set at boot brings an overstated counter down", func(t *testing.T) {
t.Run("the boot bound brings an overstated counter down to its ceiling", func(t *testing.T) {
tx, q := begin(t)
_, poolID := usageFixture(t, tx, 64, 20, true)
if _, err := q.SetNumericUsage(ctx, entitlements.SetNumericUsageParams{
PoolID: poolID, ResourceKey: "fedwiki_sites", Observed: 12,
if _, err := q.BoundNumericUsage(ctx, entitlements.BoundNumericUsageParams{
Floor: 12, Ceiling: 14, PoolID: poolID, ResourceKey: "fedwiki_sites",
}); err != nil {
t.Fatalf("set: %v", err)
t.Fatalf("bound: %v", err)
}
if got := read(t, tx, poolID); got != 12 {
t.Errorf("current_usage = %d, want 12", got)
if got := read(t, tx, poolID); got != 14 {
t.Errorf("current_usage = %d, want 14", got)
}
})
t.Run("the boot bound keeps a counter inside it and raises one below it", func(t *testing.T) {
tx, q := begin(t)
_, poolID := usageFixture(t, tx, 64, 13, true)
bound := func(floor, ceiling int64) {
if _, err := q.BoundNumericUsage(ctx, entitlements.BoundNumericUsageParams{
Floor: floor, Ceiling: ceiling, PoolID: poolID, ResourceKey: "fedwiki_sites",
}); err != nil {
t.Fatalf("bound: %v", err)
}
}
bound(12, 14)
if got := read(t, tx, poolID); got != 13 {
t.Errorf("current_usage = %d, want 13 (inside the bound)", got)
}
bound(20, 22)
if got := read(t, tx, poolID); got != 20 {
t.Errorf("current_usage = %d, want 20 (raised to the floor)", got)
}
})
@@ -147,9 +167,9 @@ func TestNumericUsageReconcile(t *testing.T) {
PoolID: poolID, ResourceKey: "fedwiki_sites", Observed: 3,
})
}},
{"set", func() (sql.Result, error) {
return q.SetNumericUsage(ctx, entitlements.SetNumericUsageParams{
PoolID: poolID, ResourceKey: "fedwiki_sites", Observed: 3,
{"bound", func() (sql.Result, error) {
return q.BoundNumericUsage(ctx, entitlements.BoundNumericUsageParams{
Floor: 3, Ceiling: 3, PoolID: poolID, ResourceKey: "fedwiki_sites",
})
}},
} {
+16 -5
View File
@@ -22,6 +22,12 @@ type Querier interface {
AnyActiveLadderAttachment(ctx context.Context) (bool, error)
AtomicDecrementUsage(ctx context.Context, arg AtomicDecrementUsageParams) (sql.Result, error)
AtomicIncrementUsage(ctx context.Context, arg AtomicIncrementUsageParams) (sql.Result, error)
// Moves the counter into [floor, ceiling], for boot reconciliation: floor is
// the count of rows the owning integration holds, ceiling adds the open
// requests that may hold a reservation beside them. An overstated counter
// comes down to the ceiling, never below a reservation an open request may
// hold; an understated one comes up to the floor. Creates no usage row.
BoundNumericUsage(ctx context.Context, arg BoundNumericUsageParams) (sql.Result, error)
// The act rows (core.entitlement_set_changes) and the effect rows
// (core.entitlement_set_change_effects) are append-only: nothing in this file
// updates or deletes one. Both are written by core.commit_rule_change and
@@ -472,6 +478,11 @@ type Querier interface {
// end) lists first.
ListTransitionsByPool(ctx context.Context, poolID string) ([]ListTransitionsByPoolRow, error)
ListTransitionsByProvision(ctx context.Context, provisionID uuid.NullUUID) ([]PoolProvisionTransition, error)
// Workspaces whose active primary pool counts usage of the key above zero.
// Boot repair bounds these beside the workspaces that own the integration's
// rows, so a counter left holding a slot in a workspace with no row left is
// brought back down too.
ListWorkspaceIDsHoldingUsage(ctx context.Context, resourceKey string) ([]string, error)
// The set row taken FOR UPDATE: the guard a rule change holds beside the
// exclusive materialization rendezvous against a writer that changes the set
// row without that lock. A batch takes it once for every act it files
@@ -485,6 +496,11 @@ type Querier interface {
// an expiry is either wholly visible to it or waits for it. No row: the
// grant names no organization, or the organization has no default pool.
LockGrantPool(ctx context.Context, grantID string) (string, error)
// Locks a pool's usage row for a key and returns its count. A reconciliation
// that lowers the counter takes this lock before it counts what the counter
// should hold, so every increment or decrement either committed before the
// count or waits until the new value is written.
LockNumericUsage(ctx context.Context, arg LockNumericUsageParams) (int64, error)
// The pool row taken FOR UPDATE: the lock a rule commit below the cap and
// each drained pool above it hold while they materialize, taken in ascending
// pool_id order (Decision 144).
@@ -532,11 +548,6 @@ type Querier interface {
// core.grant_waits returns its columns under the aliases below because sqlc
// cannot read a function's RETURNS TABLE.
SetGrantMarkAndNote(ctx context.Context, arg SetGrantMarkAndNoteParams) (SetGrantMarkAndNoteRow, error)
// Exact set, for boot reconciliation only: at boot no create workflow holds an
// unmaterialized reservation, so the owned rows are the whole truth and an
// overstated counter (which refuses creates) can be brought down.
// Creates no usage row.
SetNumericUsage(ctx context.Context, arg SetNumericUsageParams) (sql.Result, error)
// Records what one pool's recomputation did and marks the obligation. The
// calling transaction must hold the rendezvous in either mode.
SettleObligation(ctx context.Context, arg SettleObligationParams) (int32, error)
@@ -60,11 +60,34 @@ UPDATE core.numeric_entitlement_usage
SET current_usage = GREATEST(current_usage, sqlc.arg(observed))
WHERE pool_id = sqlc.arg(pool_id) AND resource_key = sqlc.arg(resource_key);
-- name: SetNumericUsage :execresult
-- Exact set, for boot reconciliation only: at boot no create workflow holds an
-- unmaterialized reservation, so the owned rows are the whole truth and an
-- overstated counter (which refuses creates) can be brought down.
-- Creates no usage row.
-- name: LockNumericUsage :one
-- Locks a pool's usage row for a key and returns its count. A reconciliation
-- that lowers the counter takes this lock before it counts what the counter
-- should hold, so every increment or decrement either committed before the
-- count or waits until the new value is written.
SELECT current_usage FROM core.numeric_entitlement_usage
WHERE pool_id = sqlc.arg(pool_id) AND resource_key = sqlc.arg(resource_key)
FOR UPDATE;
-- name: BoundNumericUsage :execresult
-- Moves the counter into [floor, ceiling], for boot reconciliation: floor is
-- the count of rows the owning integration holds, ceiling adds the open
-- requests that may hold a reservation beside them. An overstated counter
-- comes down to the ceiling, never below a reservation an open request may
-- hold; an understated one comes up to the floor. Creates no usage row.
UPDATE core.numeric_entitlement_usage
SET current_usage = sqlc.arg(observed)
SET current_usage = GREATEST(sqlc.arg(floor)::bigint, LEAST(current_usage, sqlc.arg(ceiling)::bigint))
WHERE pool_id = sqlc.arg(pool_id) AND resource_key = sqlc.arg(resource_key);
-- name: ListWorkspaceIDsHoldingUsage :many
-- Workspaces whose active primary pool counts usage of the key above zero.
-- Boot repair bounds these beside the workspaces that own the integration's
-- rows, so a counter left holding a slot in a workspace with no row left is
-- brought back down too.
SELECT DISTINCT pa.workspace_id
FROM core.pool_assignments pa
JOIN core.numeric_entitlement_usage neu ON neu.pool_id = pa.pool_id
WHERE pa.is_primary = TRUE AND pa.status = 'active'
AND neu.resource_key = sqlc.arg(resource_key)
AND neu.current_usage > 0;
+4 -2
View File
@@ -13,7 +13,7 @@ import (
)
const getResourceKey = `-- name: GetResourceKey :one
SELECT resource_key, display_name, description, unit, created_at, provider, kind, over_limit_behavior, over_limit_consequence FROM core.resource_keys
SELECT resource_key, display_name, description, unit, created_at, provider, kind, over_limit_behavior, over_limit_consequence, recipient FROM core.resource_keys
WHERE resource_key = $1
`
@@ -32,6 +32,7 @@ func (q *Queries) GetResourceKey(ctx context.Context, resourceKey string) (Resou
&i.Kind,
&i.OverLimitBehavior,
&i.OverLimitConsequence,
&i.Recipient,
)
return i, err
}
@@ -84,7 +85,7 @@ func (q *Queries) ListResourceKeyLabels(ctx context.Context) ([]ListResourceKeyL
}
const listResourceKeys = `-- name: ListResourceKeys :many
SELECT resource_key, display_name, description, unit, created_at, provider, kind, over_limit_behavior, over_limit_consequence FROM core.resource_keys
SELECT resource_key, display_name, description, unit, created_at, provider, kind, over_limit_behavior, over_limit_consequence, recipient FROM core.resource_keys
ORDER BY resource_key ASC
`
@@ -107,6 +108,7 @@ func (q *Queries) ListResourceKeys(ctx context.Context) ([]ResourceKey, error) {
&i.Kind,
&i.OverLimitBehavior,
&i.OverLimitConsequence,
&i.Recipient,
); err != nil {
return nil, err
}
+133
View File
@@ -0,0 +1,133 @@
// 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: connections.sql
package integration
import (
"context"
)
const ensureInTreeConnection = `-- name: EnsureInTreeConnection :one
INSERT INTO core.provider_connections (provider, display_name, approval_state, approved_at)
VALUES ($1, $2, 'approved', NOW())
ON CONFLICT (provider) DO NOTHING
RETURNING connection_id
`
type EnsureInTreeConnectionParams struct {
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
}
// Boot registration: insert the one approved connection of an in-tree
// provider that owns keys. The database chooses connection_id. An existing
// row, whatever its status, is never touched: its status, approval and
// display name are the operator's, and the unique index on provider makes a
// second boot, or two replicas booting together, insert nothing. No row
// (sql.ErrNoRows) means the connection already existed. The insert of an
// approved row fires the connection trigger, which writes the connection's
// desired-state outbox row (the first fill).
func (q *Queries) EnsureInTreeConnection(ctx context.Context, arg EnsureInTreeConnectionParams) (string, error) {
row := q.db.QueryRowContext(ctx, ensureInTreeConnection, arg.Provider, arg.DisplayName)
var connection_id string
err := row.Scan(&connection_id)
return connection_id, err
}
const getConnectionByProvider = `-- name: GetConnectionByProvider :one
SELECT connection_id, provider, display_name, status, suspended_at, retired_at, approval_state, approved_at, created_at, updated_at FROM core.provider_connections WHERE provider = $1
`
// The one connection beneath a provider (uq_provider_connections_one_per_provider).
func (q *Queries) GetConnectionByProvider(ctx context.Context, provider string) (ProviderConnection, error) {
row := q.db.QueryRowContext(ctx, getConnectionByProvider, provider)
var i ProviderConnection
err := row.Scan(
&i.ConnectionID,
&i.Provider,
&i.DisplayName,
&i.Status,
&i.SuspendedAt,
&i.RetiredAt,
&i.ApprovalState,
&i.ApprovedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listDeliverableConnections = `-- name: ListDeliverableConnections :many
SELECT pc.connection_id,
pc.provider,
pc.status AS connection_status,
p.status AS provider_status
FROM core.provider_connections pc
JOIN core.providers p ON p.provider = pc.provider
WHERE pc.approval_state = 'approved'
AND pc.status <> 'retired'
ORDER BY pc.connection_id
`
type ListDeliverableConnectionsRow struct {
ConnectionID string `json:"connection_id"`
Provider string `json:"provider"`
ConnectionStatus string `json:"connection_status"`
ProviderStatus string `json:"provider_status"`
}
// Connections the relay may deliver to and the read-back may schedule:
// approved and not retired. The connection's and its provider's status are
// returned so the caller decides whether a push or a schedule may run
// (both must be active); a suspended connection still gets its records
// recomputed.
func (q *Queries) ListDeliverableConnections(ctx context.Context) ([]ListDeliverableConnectionsRow, error) {
rows, err := q.db.QueryContext(ctx, listDeliverableConnections)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDeliverableConnectionsRow{}
for rows.Next() {
var i ListDeliverableConnectionsRow
if err := rows.Scan(
&i.ConnectionID,
&i.Provider,
&i.ConnectionStatus,
&i.ProviderStatus,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const reapproveConnection = `-- name: ReapproveConnection :execrows
UPDATE core.provider_connections
SET approved_at = NOW()
WHERE provider = $1 AND approval_state = 'approved'
`
// Boot registration: an in-tree deploy that changed an owned key's provider or
// recipient is the approval of a manifest change. Refreshing approved_at fires
// the connection trigger, so the relay recomputes every record of the
// connection. A pending connection is left pending.
func (q *Queries) ReapproveConnection(ctx context.Context, provider string) (int64, error) {
result, err := q.db.ExecContext(ctx, reapproveConnection, provider)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+21
View File
@@ -68,6 +68,13 @@ type Manifest struct {
// key's provider, because the platform_keys_deny_new CHECK requires a
// provider on any key that is not deny_new.
OverLimit map[string]OverLimit
// Recipients declares, per owned resource key, who the key is delivered
// to through a connection: a person or an organization. Every owned key
// has exactly one entry. A person's record holds a key when any
// organization the person owns or belongs to holds it, a union defined
// only for a held-or-not key, so a person-recipient key is boolean; the
// database refuses a numeric one when registration stamps it.
Recipients map[string]RecipientKind
// OperatorSurfacePath is the operator admin-surface URL for this provider,
// rendered in the operator sidebar's Integration group. Empty for providers
@@ -110,3 +117,17 @@ const (
// OverLimitReclaim takes the excess back.
OverLimitReclaim OverLimitBehavior = "reclaim"
)
// RecipientKind is who an owned resource key is delivered to through a
// connection. It is not a grant's recipient, which can also be a billing
// account.
type RecipientKind string
const (
// RecipientPerson delivers the key to each person whose organizations
// hold it, as membership in a group or role. The key must be boolean.
RecipientPerson RecipientKind = "person"
// RecipientOrganization delivers the key to the organization whose
// default pool holds it.
RecipientOrganization RecipientKind = "organization"
)
@@ -0,0 +1,776 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package integration_test
// Database tests of the queries the relay, the push, the read-back and the
// lifecycle writers build on (integration-delivery-core, section 3). Each
// test runs in one transaction it rolls back, with a made-up provider, so
// nothing it writes outlives it.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
type dq struct {
t *testing.T
ctx context.Context
tx *sql.Tx
q *integration.Queries
}
func newDQ(t *testing.T) *dq {
t.Helper()
database := regDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback() })
// Rows other tests committed stay out of this test's claims.
d := &dq{t: t, ctx: ctx, tx: tx, q: integration.New(tx)}
d.exec(`UPDATE core.desired_state_outbox SET claimed_until = NOW() + INTERVAL '1 day' WHERE status = 'pending'`)
return d
}
func (d *dq) exec(query string, args ...any) {
d.t.Helper()
if _, err := d.tx.ExecContext(d.ctx, query, args...); err != nil {
d.t.Fatalf("%s: %v", strings.Join(strings.Fields(query), " "), err)
}
}
func (d *dq) scan(dest any, query string, args ...any) {
d.t.Helper()
if err := d.tx.QueryRowContext(d.ctx, query, args...).Scan(dest); err != nil {
d.t.Fatalf("%s: %v", strings.Join(strings.Fields(query), " "), err)
}
}
func (d *dq) id(query string, args ...any) string {
d.t.Helper()
var id string
d.scan(&id, query, args...)
return id
}
var dqSeq int
func dqName(prefix string) string {
dqSeq++
return fmt.Sprintf("%s%d%d", prefix, time.Now().UnixNano(), dqSeq)
}
func (d *dq) person() string {
user := d.id(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, dqName("dq-subject-"))
return d.id(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1, 'Query Person', $2) RETURNING person_id`,
user, dqName("dq-")+"@example.test")
}
func (d *dq) org(owner string) string {
return d.id(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ('Query Org', 'personal', $1) RETURNING org_id`, owner)
}
func (d *dq) pool(org string) string {
return d.id(`INSERT INTO core.resource_pools (org_id, name, pool_type) VALUES ($1, $2, 'default') RETURNING pool_id`, org, dqName("pool-"))
}
func (d *dq) member(org, person, status string) {
d.exec(`INSERT INTO core.org_members (org_id, person_id, role_id, status)
VALUES ($1, $2, (SELECT role_id FROM core.roles WHERE role_name = 'member' AND org_id IS NULL), $3)`,
org, person, status)
}
// registry is a made-up provider owning a person-recipient boolean key and an
// organization-recipient numeric key, with one approved connection.
type registry struct {
provider, posting, sites, conn string
}
func (d *dq) registry() registry {
r := registry{provider: dqName("dqprov")}
d.exec(`INSERT INTO core.providers (provider, provider_kind, display_name) VALUES ($1, 'provisioning', 'Query Provider')`, r.provider)
d.exec(`INSERT INTO core.provider_operations (provider, operation) VALUES ($1, 'create'), ($1, 'delete')`, r.provider)
r.posting = r.provider + "_posting"
r.sites = r.provider + "_sites"
d.exec(`INSERT INTO core.resource_keys (resource_key, display_name, unit, provider, kind, recipient)
VALUES ($1, 'Posting', 'flag', $3, 'boolean', 'person'), ($2, 'Sites', 'sites', $3, 'numeric', 'organization')`,
r.posting, r.sites, r.provider)
r.conn = d.id(`INSERT INTO core.provider_connections (provider, display_name, approval_state, approved_at)
VALUES ($1, 'Query Connection', 'approved', NOW()) RETURNING connection_id`, r.provider)
return r
}
type reach struct{ kind, id, conn string }
// mine keeps the rows at the given connections: the shared test database
// also holds the real integrations' connections, which unfiltered rows reach.
func mine(rows []reach, conns ...string) []reach {
var out []reach
for _, r := range rows {
if slices.Contains(conns, r.conn) {
out = append(out, r)
}
}
return out
}
func sortedReach(r []reach) []reach {
slices.SortFunc(r, func(a, b reach) int { return strings.Compare(a.kind+a.id+a.conn, b.kind+b.id+b.conn) })
return r
}
func nullStr(s string) sql.NullString { return sql.NullString{String: s, Valid: s != ""} }
func nullUUID(s string) uuid.NullUUID {
if s == "" {
return uuid.NullUUID{}
}
return uuid.NullUUID{UUID: uuid.MustParse(s), Valid: true}
}
func TestDesiredStateOutboxQueries_DB(t *testing.T) {
d := newDQ(t)
owner := d.person()
org := d.org(owner)
// Three pending rows of our own, written as the requeue function writes
// them, in identifier order.
var ids []string
for range 3 {
id, err := d.q.RequeueDesiredState(d.ctx, integration.RequeueDesiredStateParams{
SubjectKind: "organization", SubjectID: org, LastError: "boom"})
if err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
claimed, err := d.q.ClaimDesiredStateOutbox(d.ctx, integration.ClaimDesiredStateOutboxParams{LeaseSeconds: 120, BatchSize: 2})
if err != nil {
t.Fatal(err)
}
if len(claimed) != 2 || claimed[0].ID != ids[0] || claimed[1].ID != ids[1] || !claimed[0].RecipientOnly || claimed[0].Attempts != 1 {
t.Fatalf("first claim = %+v, want the first two rows", claimed)
}
// Held leases are skipped; the third row is next.
again, err := d.q.ClaimDesiredStateOutbox(d.ctx, integration.ClaimDesiredStateOutboxParams{LeaseSeconds: 120, BatchSize: 10})
if err != nil {
t.Fatal(err)
}
if len(again) != 1 || again[0].ID != ids[2] {
t.Fatalf("second claim = %+v, want only the third row", again)
}
// Release with a cursor clears the lease, so the row is claimed at once.
if n, err := d.q.ReleaseDesiredStateOutboxRow(d.ctx, integration.ReleaseDesiredStateOutboxRowParams{
ExpansionCursor: "person:" + owner, ID: ids[0]}); err != nil || n != 1 {
t.Fatalf("release = %d, %v", n, err)
}
reclaimed, err := d.q.ClaimDesiredStateOutbox(d.ctx, integration.ClaimDesiredStateOutboxParams{LeaseSeconds: 120, BatchSize: 10})
if err != nil || len(reclaimed) != 1 || reclaimed[0].ExpansionCursor.String != "person:"+owner {
t.Fatalf("reclaim = %+v, %v", reclaimed, err)
}
// An expired lease is claimed again.
d.exec(`UPDATE core.desired_state_outbox SET claimed_until = NOW() - INTERVAL '1 second' WHERE id = $1`, ids[1])
if expired, err := d.q.ClaimDesiredStateOutbox(d.ctx, integration.ClaimDesiredStateOutboxParams{LeaseSeconds: 120, BatchSize: 10}); err != nil || len(expired) != 1 || expired[0].ID != ids[1] {
t.Fatalf("expired-lease claim = %+v, %v", expired, err)
}
// Attempts: the row turns failed at the budget and is never claimed.
for want := int32(2); want <= 3; want++ {
row, err := d.q.MarkDesiredStateOutboxAttempt(d.ctx, integration.MarkDesiredStateOutboxAttemptParams{
LastError: "again", MaxAttempts: 3, ID: ids[2]})
if err != nil {
t.Fatal(err)
}
wantStatus := "pending"
if want == 3 {
wantStatus = "failed"
}
if row.Attempts != want || row.Status != wantStatus {
t.Errorf("attempt %d: %+v", want, row)
}
}
d.exec(`UPDATE core.desired_state_outbox SET claimed_until = NULL WHERE id = $1`, ids[2])
// Complete, then retention deletes completed rows past the window only.
if n, err := d.q.CompleteDesiredStateOutboxRows(d.ctx, []string{ids[0], ids[1]}); err != nil || n != 2 {
t.Fatalf("complete = %d, %v", n, err)
}
if rows, _ := d.q.ClaimDesiredStateOutbox(d.ctx, integration.ClaimDesiredStateOutboxParams{LeaseSeconds: 120, BatchSize: 10}); len(rows) != 0 {
t.Errorf("completed or failed rows were claimed: %+v", rows)
}
d.exec(`UPDATE core.desired_state_outbox SET completed_at = NOW() - INTERVAL '8 days' WHERE id = $1`, ids[0])
d.exec(`UPDATE core.desired_state_outbox SET created_at = NOW() - INTERVAL '9 days' WHERE id = $1`, ids[2])
if n, err := d.q.DeleteExpiredDesiredStateOutboxRows(d.ctx, integration.DeleteExpiredDesiredStateOutboxRowsParams{
RetentionSeconds: 7 * 24 * 3600, BatchSize: 100}); err != nil || n < 1 {
t.Fatalf("retention = %d, %v", n, err)
}
var left []string
rows, err := d.tx.QueryContext(d.ctx, `SELECT id::text FROM core.desired_state_outbox WHERE id = ANY($1::uuid[]) ORDER BY id`, ids)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var id string
_ = rows.Scan(&id)
left = append(left, id)
}
rows.Close()
if !slices.Equal(left, []string{ids[1], ids[2]}) {
t.Errorf("after retention %v remain, want the recent completed row and the failed row", left)
}
// Backlog: our rows are not pending; one pending row from now is.
if _, err := d.q.RequeueDesiredState(d.ctx, integration.RequeueDesiredStateParams{
SubjectKind: "person", SubjectID: owner, LastError: "x"}); err != nil {
t.Fatal(err)
}
backlog, err := d.q.DesiredStateOutboxBacklog(d.ctx)
if err != nil || backlog.PendingCount < 1 || backlog.OldestAgeMs < 0 {
t.Errorf("backlog = %+v, %v", backlog, err)
}
// The requeue function refuses a subject it cannot requeue.
d.exec("SAVEPOINT s")
if _, err := d.q.RequeueDesiredState(d.ctx, integration.RequeueDesiredStateParams{
SubjectKind: "pool", SubjectID: org, LastError: "x"}); err == nil {
t.Error("a pool subject was requeued")
}
d.exec("ROLLBACK TO SAVEPOINT s")
}
func TestDesiredStateExpansionQueries_DB(t *testing.T) {
d := newDQ(t)
r := d.registry()
owner, active, inactive := d.person(), d.person(), d.person()
org := d.org(owner)
pool := d.pool(org)
d.member(org, active, "active")
d.member(org, inactive, "inactive")
d.exec(`INSERT INTO core.boolean_entitlements (pool_id, resource_key, granted) VALUES ($1, $2, TRUE)`, pool, r.posting)
d.exec(`INSERT INTO core.numeric_entitlements (pool_id, resource_key, entitlement_type, resource_limit) VALUES ($1, $2, 'limit', 3)`, pool, r.sites)
// A pending and a retired connection of other providers reach nothing.
other := d.registry()
d.exec(`UPDATE core.provider_connections SET status = 'retired', retired_at = NOW() WHERE connection_id = $1`, other.conn)
people := []reach{{"person", owner, r.conn}, {"person", active, r.conn}}
orgOnly := []reach{{"organization", org, r.conn}}
pageAll := func(f func(afterKind sql.NullString, afterID uuid.NullUUID) []reach, size int) []reach {
var all []reach
var ak sql.NullString
var ai uuid.NullUUID
for {
page := f(ak, ai)
all = append(all, page...)
if len(page) < size {
return all
}
last := page[len(page)-1]
ak, ai = nullStr(last.kind), nullUUID(last.id)
}
}
pool1 := func(key string) []reach {
return pageAll(func(ak sql.NullString, ai uuid.NullUUID) []reach {
rows, err := d.q.ExpandDesiredStatePool(d.ctx, integration.ExpandDesiredStatePoolParams{
PoolID: pool, ResourceKey: nullStr(key), AfterKind: ak, AfterID: ai, PageSize: 1})
if err != nil {
t.Fatal(err)
}
var out []reach
for _, x := range rows {
out = append(out, reach{x.RecipientKind, x.RecipientID, x.ConnectionID})
}
return out
}, 1)
}
pool1 = func(inner func(string) []reach) func(string) []reach {
return func(key string) []reach { return mine(inner(key), r.conn, other.conn) }
}(pool1)
if got, want := sortedReach(pool1("")), sortedReach(append(slices.Clone(people), orgOnly...)); !slices.Equal(got, want) {
t.Errorf("pool row, paged one recipient at a time: %v, want %v", got, want)
}
if got, want := sortedReach(pool1(r.posting)), sortedReach(slices.Clone(people)); !slices.Equal(got, want) {
t.Errorf("pool row for the person key: %v, want %v", got, want)
}
if got := pool1(r.sites); !slices.Equal(got, orgOnly) {
t.Errorf("pool row for the organization key: %v, want %v", got, orgOnly)
}
if got := pool1("external_domain_claims"); len(got) != 0 {
t.Errorf("pool row for a platform key reaches %v", got)
}
orgRows, err := d.q.ExpandDesiredStateOrganization(d.ctx, integration.ExpandDesiredStateOrganizationParams{OrgID: org, PageSize: 100})
if err != nil {
t.Fatal(err)
}
var orgReach []reach
for _, x := range orgRows {
orgReach = append(orgReach, reach{x.RecipientKind, x.RecipientID, x.ConnectionID})
}
orgReach = mine(orgReach, r.conn, other.conn)
if want := sortedReach(append(slices.Clone(people), orgOnly...)); !slices.Equal(sortedReach(orgReach), want) {
t.Errorf("organization row: %v, want %v", orgReach, want)
}
for _, tc := range []struct {
kind, id string
want []reach
}{
{"person", active, []reach{{"person", active, r.conn}}},
{"organization", org, orgOnly},
} {
rows, err := d.q.ExpandDesiredStateRecipient(d.ctx, integration.ExpandDesiredStateRecipientParams{
RecipientKind: tc.kind, RecipientID: tc.id})
if err != nil {
t.Fatal(err)
}
var got []reach
for _, x := range rows {
got = append(got, reach{x.RecipientKind, x.RecipientID, x.ConnectionID})
}
got = mine(got, r.conn, other.conn)
if !slices.Equal(got, tc.want) {
t.Errorf("recipient %s row: %v, want %v", tc.kind, got, tc.want)
}
}
// A connection row reaches every would-be recipient and every recipient
// that already has a record; paging resumes after the cursor.
stranger := d.person()
d.exec(`INSERT INTO core.desired_state_records (connection_id, person_id, content, generation)
VALUES ($1, $2, '{"entitlements": []}', 1)`, r.conn, stranger)
conn := func(key string, size int32) []reach {
return pageAll(func(ak sql.NullString, ai uuid.NullUUID) []reach {
rows, err := d.q.ExpandDesiredStateConnection(d.ctx, integration.ExpandDesiredStateConnectionParams{
ConnectionID: r.conn, ResourceKey: nullStr(key), AfterKind: ak, AfterID: ai, PageSize: size})
if err != nil {
t.Fatal(err)
}
var out []reach
for _, x := range rows {
out = append(out, reach{x.RecipientKind, x.RecipientID, x.ConnectionID})
}
return out
}, int(size))
}
want := sortedReach(append(append(slices.Clone(people), orgOnly...), reach{"person", stranger, r.conn}))
if got := conn("", 2); !slices.Equal(sortedReach(got), want) {
t.Errorf("connection row: %v, want %v", got, want)
}
// A named key reaches its holders and the records that carry it, so a
// former holder whose record still carries the key is recomputed too.
former := d.person()
d.exec(`INSERT INTO core.desired_state_records (connection_id, person_id, content, generation)
VALUES ($1, $2, jsonb_build_object('entitlements', jsonb_build_array(
jsonb_build_object('key', $3::text, 'granted', true))), 1)`, r.conn, former, r.posting)
holders := sortedReach(append(slices.Clone(people), reach{"person", former, r.conn}))
if got := conn(r.posting, 100); !slices.Equal(sortedReach(got), holders) {
t.Errorf("connection row for the person key: %v, want %v", got, holders)
}
if got := conn(r.sites, 100); !slices.Equal(got, orgOnly) {
t.Errorf("connection row for the organization key: %v, want %v", got, orgOnly)
}
// A suspended connection is still reached: its records stay current.
d.exec(`UPDATE core.provider_connections SET status = 'suspended', suspended_at = NOW() WHERE connection_id = $1`, r.conn)
if got := conn(r.sites, 100); !slices.Equal(got, orgOnly) {
t.Errorf("a suspended connection's row: %v, want %v", got, orgOnly)
}
if got := pool1(r.sites); !slices.Equal(got, orgOnly) {
t.Errorf("a pool row at a suspended connection: %v, want %v", got, orgOnly)
}
// A pending connection is reached by no row.
d.exec(`UPDATE core.provider_connections SET approval_state = 'pending', approved_at = NULL WHERE connection_id = $1`, r.conn)
if got := conn("", 100); len(got) != 0 {
t.Errorf("a pending connection's row reaches %v", got)
}
if got := pool1(""); len(got) != 0 {
t.Errorf("a pool row reaches a pending connection: %v", got)
}
}
func TestListDeliverableConnections_DB(t *testing.T) {
d := newDQ(t)
active, suspended, pending, retired := d.registry(), d.registry(), d.registry(), d.registry()
d.exec(`UPDATE core.provider_connections SET status = 'suspended', suspended_at = NOW() WHERE connection_id = $1`, suspended.conn)
d.exec(`UPDATE core.providers SET status = 'suspended' WHERE provider = $1`, suspended.provider)
d.exec(`UPDATE core.provider_connections SET approval_state = 'pending', approved_at = NULL WHERE connection_id = $1`, pending.conn)
d.exec(`UPDATE core.provider_connections SET status = 'retired', retired_at = NOW() WHERE connection_id = $1`, retired.conn)
rows, err := d.q.ListDeliverableConnections(d.ctx)
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, row := range rows {
got[row.ConnectionID] = row.Provider + "/" + row.ConnectionStatus + "/" + row.ProviderStatus
}
want := map[string]string{
active.conn: active.provider + "/active/active",
suspended.conn: suspended.provider + "/suspended/suspended",
}
for conn, w := range want {
if got[conn] != w {
t.Errorf("connection %s = %q, want %q", conn, got[conn], w)
}
}
for _, conn := range []string{pending.conn, retired.conn} {
if _, ok := got[conn]; ok {
t.Errorf("a pending or retired connection %s is deliverable", conn)
}
}
}
func TestDesiredStateRecordQueries_DB(t *testing.T) {
d := newDQ(t)
r := d.registry()
owner := d.person()
org := d.org(owner)
pool := d.pool(org)
d.exec(`INSERT INTO core.numeric_entitlements (pool_id, resource_key, entitlement_type, resource_limit) VALUES ($1, $2, 'limit', 3)`, pool, r.sites)
if err := d.q.LockDesiredStateAddress(d.ctx, integration.LockDesiredStateAddressParams{
ConnectionID: r.conn, RecipientKind: "organization", RecipientID: org}); err != nil {
t.Fatal(err)
}
addr := integration.LockDesiredStateRecordParams{ConnectionID: r.conn, OrgID: nullUUID(org)}
if _, err := d.q.LockDesiredStateRecord(d.ctx, addr); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("lock of an address without a record: %v", err)
}
content, err := d.q.DesiredStateContent(d.ctx, integration.DesiredStateContentParams{ConnectionID: r.conn, OrgID: nullUUID(org)})
if err != nil {
t.Fatal(err)
}
decoded, err := integration.DecodeDesiredContent(content.Content)
if err != nil || content.EntitlementCount != 1 || len(decoded.Entitlements) != 1 ||
decoded.Entitlements[0].Key != r.sites || *decoded.Entitlements[0].Limit != 3 {
t.Fatalf("content = %s (%d), decoded %+v, %v", content.Content, content.EntitlementCount, decoded, err)
}
inserted, err := d.q.InsertDesiredStateRecord(d.ctx, integration.InsertDesiredStateRecordParams{
ConnectionID: r.conn, OrgID: nullUUID(org), Content: content.Content})
if err != nil {
t.Fatal(err)
}
if string(inserted.ContentHash) != string(content.ContentHash) || inserted.Generation <= 0 {
t.Errorf("insert = %+v; the computed hash differs from the stored one", inserted)
}
locked, err := d.q.LockDesiredStateRecord(d.ctx, addr)
if err != nil || locked.RecordID != inserted.RecordID {
t.Fatalf("lock = %+v, %v", locked, err)
}
// A raise above a reported floor with unchanged content.
floor := inserted.Generation + 1_000_000
raised, err := d.q.UpdateDesiredStateRecord(d.ctx, integration.UpdateDesiredStateRecordParams{
Content: content.Content, Floor: sql.NullInt64{Int64: floor, Valid: true}, RecordID: inserted.RecordID})
if err != nil || raised.Generation <= floor {
t.Fatalf("raise = %+v, %v", raised, err)
}
// A floor above the ceiling is refused by core.next_generation.
d.exec("SAVEPOINT s")
if _, err := d.q.UpdateDesiredStateRecord(d.ctx, integration.UpdateDesiredStateRecordParams{
Content: content.Content, Floor: sql.NullInt64{Int64: 9223372036854775807, Valid: true}, RecordID: inserted.RecordID}); err == nil {
t.Error("a floor above the ceiling was used")
}
d.exec("ROLLBACK TO SAVEPOINT s")
got, err := d.q.GetDesiredStateRecord(d.ctx, inserted.RecordID)
if err != nil || got.Generation != raised.Generation {
t.Fatalf("get = %+v, %v", got, err)
}
// Push bookkeeping, and which records need a push.
if n, err := d.q.StampDesiredStateRecordsDispatched(d.ctx, []string{inserted.RecordID}); err != nil || n != 1 {
t.Fatalf("stamp = %d, %v", n, err)
}
if n, err := d.q.SetDesiredStatePushFailure(d.ctx, integration.SetDesiredStatePushFailureParams{
LastPushError: "transport: timeout", RecordID: inserted.RecordID}); err != nil || n != 1 {
t.Fatalf("failure = %d, %v", n, err)
}
toPush := func(served []string) []string {
ids, err := d.q.ListDesiredStateRecordsToPush(d.ctx, integration.ListDesiredStateRecordsToPushParams{
RecordIds: []string{inserted.RecordID}, ServedConnectionIds: served})
if err != nil {
t.Fatal(err)
}
return ids
}
if ids := toPush([]string{}); len(ids) != 0 {
t.Errorf("a record without a transport needs a push: %v", ids)
}
if ids := toPush([]string{r.conn}); !slices.Equal(ids, []string{inserted.RecordID}) {
t.Errorf("an undelivered record at a served connection: %v", ids)
}
lagging := func() []string {
ids, err := d.q.ListLaggingDesiredStateRecords(d.ctx, integration.ListLaggingDesiredStateRecordsParams{
ServedConnectionIds: []string{r.conn}, CooldownSeconds: 900, MaxRecords: 100})
if err != nil {
t.Fatal(err)
}
return ids
}
if ids := lagging(); len(ids) != 0 {
t.Errorf("a record dispatched within the cooldown is swept: %v", ids)
}
d.exec(`UPDATE core.desired_state_records SET last_dispatched_at = NOW() - INTERVAL '1 hour',
last_push_failed_at = NOW() - INTERVAL '1 hour' WHERE record_id = $1`, inserted.RecordID)
if ids := lagging(); !slices.Equal(ids, []string{inserted.RecordID}) {
t.Errorf("a lagging record past the cooldown: %v", ids)
}
// An applied answer for the current generation and hash: delivered.
if err := d.q.RecordPushResultForOrganization(d.ctx, integration.RecordPushResultForOrganizationParams{
ConnectionID: r.conn, OrgID: org,
AppliedGeneration: sql.NullInt64{Int64: raised.Generation, Valid: true}, AppliedContentHash: raised.ContentHash,
Result: "applied", ResultGeneration: raised.Generation, ObservedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if n, err := d.q.ClearDesiredStatePushError(d.ctx, inserted.RecordID); err != nil || n != 1 {
t.Fatalf("clear = %d, %v", n, err)
}
if ids := toPush([]string{r.conn}); len(ids) != 0 {
t.Errorf("a delivered record needs a push: %v", ids)
}
if ids := lagging(); len(ids) != 0 {
t.Errorf("a delivered record is swept: %v", ids)
}
// A suspended connection gets no push.
d.exec(`UPDATE core.desired_state_records SET content = '{"entitlements": []}', generation = generation + 1 WHERE record_id = $1`, inserted.RecordID)
d.exec(`UPDATE core.provider_connections SET status = 'suspended', suspended_at = NOW() WHERE connection_id = $1`, r.conn)
if ids := toPush([]string{r.conn}); len(ids) != 0 {
t.Errorf("a suspended connection's record needs a push: %v", ids)
}
}
func TestObservedProjectionQueries_DB(t *testing.T) {
d := newDQ(t)
r := d.registry()
person := d.person()
observed := json.RawMessage(`{"entitlements": []}`)
upsert := func(gen int64) int64 {
n, err := d.q.UpsertObservedProjectionForPerson(d.ctx, integration.UpsertObservedProjectionForPersonParams{
ConnectionID: r.conn, PersonID: person,
AppliedGeneration: sql.NullInt64{Int64: gen, Valid: true}, AppliedContentHash: []byte{byte(gen)},
Observed: observed, ObservedAt: time.Now()})
if err != nil {
t.Fatal(err)
}
return n
}
read := func() integration.ObservedProjection {
p, err := d.q.GetObservedProjection(d.ctx, integration.GetObservedProjectionParams{ConnectionID: r.conn, PersonID: nullUUID(person)})
if err != nil {
t.Fatal(err)
}
return p
}
if upsert(20) != 1 || upsert(30) != 1 {
t.Fatal("upserts did not write")
}
if n := upsert(25); n != 0 {
t.Errorf("an older read-back item wrote %d rows", n)
}
if p := read(); p.AppliedGeneration.Int64 != 30 || p.AppliedContentHash[0] != 30 {
t.Errorf("projection = %+v, want generation 30 kept", p)
}
// An unlinked push answer holding an older generation leaves the applied
// pair as it was and records the result.
if err := d.q.RecordPushResultForPerson(d.ctx, integration.RecordPushResultForPersonParams{
ConnectionID: r.conn, PersonID: person,
AppliedGeneration: sql.NullInt64{Int64: 10, Valid: true},
Result: "unlinked", ResultGeneration: 40, NextAction: nullStr("sign_in"), ObservedAt: time.Now()}); err != nil {
t.Fatal(err)
}
p := read()
if p.AppliedGeneration.Int64 != 30 || p.AppliedContentHash[0] != 30 || p.Result.String != "unlinked" ||
p.ResultGeneration.Int64 != 40 || p.NextAction.String != "sign_in" {
t.Errorf("after unlinked: %+v", p)
}
// An applied answer above raises the pair together.
if err := d.q.RecordPushResultForPerson(d.ctx, integration.RecordPushResultForPersonParams{
ConnectionID: r.conn, PersonID: person,
AppliedGeneration: sql.NullInt64{Int64: 40, Valid: true}, AppliedContentHash: []byte{40},
Result: "applied", ResultGeneration: 40, ObservedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if p := read(); p.AppliedGeneration.Int64 != 40 || p.AppliedContentHash[0] != 40 || p.Result.String != "applied" || p.NextAction.Valid {
t.Errorf("after applied: %+v", p)
}
// The ahead marker: the latest replaces the earlier, on an existing row
// and for a recipient that has none.
for _, h := range []int64{500, 700} {
if err := d.q.MarkObservedProjectionAheadForPerson(d.ctx, integration.MarkObservedProjectionAheadForPersonParams{
ConnectionID: r.conn, PersonID: person, AheadGeneration: h}); err != nil {
t.Fatal(err)
}
}
if p := read(); p.AheadGeneration.Int64 != 700 || !p.AheadAt.Valid || p.AppliedGeneration.Int64 != 40 {
t.Errorf("ahead marker: %+v", p)
}
owner := d.person()
org := d.org(owner)
if err := d.q.MarkObservedProjectionAheadForOrganization(d.ctx, integration.MarkObservedProjectionAheadForOrganizationParams{
ConnectionID: r.conn, OrgID: org, AheadGeneration: 9}); err != nil {
t.Fatal(err)
}
if n, err := d.q.UpsertObservedProjectionForOrganization(d.ctx, integration.UpsertObservedProjectionForOrganizationParams{
ConnectionID: r.conn, OrgID: org, AppliedGeneration: sql.NullInt64{Int64: 5, Valid: true},
Observed: observed, ObservedAt: time.Now()}); err != nil || n != 1 {
t.Fatalf("organization upsert = %d, %v", n, err)
}
if err := d.q.RecordPushResultForOrganization(d.ctx, integration.RecordPushResultForOrganizationParams{
ConnectionID: r.conn, OrgID: org, Result: "applied", ResultGeneration: 6,
AppliedGeneration: sql.NullInt64{Int64: 6, Valid: true}, ObservedAt: time.Now()}); err != nil {
t.Fatal(err)
}
po, err := d.q.GetObservedProjection(d.ctx, integration.GetObservedProjectionParams{ConnectionID: r.conn, OrgID: nullUUID(org)})
if err != nil || po.AheadGeneration.Int64 != 9 || po.AppliedGeneration.Int64 != 6 {
t.Errorf("organization projection = %+v, %v", po, err)
}
}
func TestLifecycleRequestQueries_DB(t *testing.T) {
d := newDQ(t)
r := d.registry()
owner := d.person()
org := d.org(owner)
ws := d.id(`INSERT INTO core.workspaces (org_id, name) VALUES ($1, 'Query WS') RETURNING workspace_id`, org)
otherWS := d.id(`INSERT INTO core.workspaces (org_id, name) VALUES ($1, 'Other WS') RETURNING workspace_id`, org)
instance := uuid.New().String()
var gen int64
d.scan(&gen, `SELECT core.next_generation(NULL, NULL)`)
record := func(g int64, op, key string, params string) (string, error) {
if err := d.q.LockLifecycleInstance(d.ctx, instance); err != nil {
t.Fatal(err)
}
return d.q.InsertLifecycleRequest(d.ctx, integration.InsertLifecycleRequestParams{
InstanceID: instance, Generation: g, ConnectionID: r.conn, ResourceKey: key, WorkspaceID: ws,
Operation: op, Parameters: json.RawMessage(params), WorkflowID: "wf-" + instance,
RequestedByPersonID: nullUUID(owner)})
}
if latest, err := d.q.LatestLifecycleGeneration(d.ctx, instance); err != nil || latest != 0 {
t.Fatalf("latest of a new instance = %d, %v", latest, err)
}
reqID, err := record(gen, "create", r.sites, `{"domain": "one.example.test"}`)
if err != nil || reqID == "" {
t.Fatalf("insert = %q, %v", reqID, err)
}
// A repeat inserts nothing; the address holds the first.
if _, err := record(gen, "create", r.sites, `{"domain": "one.example.test"}`); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("repeat insert: %v", err)
}
held, err := d.q.GetLifecycleRequestByAddress(d.ctx, integration.GetLifecycleRequestByAddressParams{InstanceID: instance, Generation: gen})
if err != nil || held.RequestID != reqID || held.Status != "accepted" {
t.Fatalf("by address = %+v, %v", held, err)
}
// An operation the provider does not declare, or a key it does not own,
// inserts nothing and holds no address.
for _, tc := range []struct{ op, key string }{{"set_status", r.sites}, {"create", "external_domain_claims"}} {
if _, err := record(gen+10, tc.op, tc.key, `{}`); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("%s on %s: %v", tc.op, tc.key, err)
}
if _, err := d.q.GetLifecycleRequestByAddress(d.ctx, integration.GetLifecycleRequestByAddressParams{InstanceID: instance, Generation: gen + 10}); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("%s on %s left a row: %v", tc.op, tc.key, err)
}
}
// A second open request on the instance is refused by the one-open index.
d.exec("SAVEPOINT s")
if _, err := record(gen+1, "delete", r.sites, `{}`); err == nil || !strings.Contains(err.Error(), "uq_lifecycle_requests_one_open") {
t.Errorf("second open request: %v", err)
}
d.exec("ROLLBACK TO SAVEPOINT s")
open := func(workspace string) []integration.LifecycleRequest {
rows, err := d.q.OpenLifecycleRequestsByWorkspace(d.ctx, integration.OpenLifecycleRequestsByWorkspaceParams{WorkspaceID: workspace, ConnectionID: r.conn})
if err != nil {
t.Fatal(err)
}
return rows
}
if rows := open(ws); len(rows) != 1 || rows[0].RequestID != reqID {
t.Errorf("open requests = %+v", rows)
}
if rows := open(otherWS); len(rows) != 0 {
t.Errorf("another workspace sees %+v", rows)
}
d.exec(`UPDATE core.lifecycle_requests SET created_at = NOW() - INTERVAL '5 minutes' WHERE request_id = $1`, reqID)
if rows, err := d.q.ListAcceptedLifecycleRequestsOlderThan(d.ctx, integration.ListAcceptedLifecycleRequestsOlderThanParams{
ConnectionID: r.conn, AgeSeconds: 120}); err != nil || len(rows) != 1 || rows[0].RequestID != reqID {
t.Errorf("older than two minutes = %+v, %v", rows, err)
}
// Finishing: a missing request fails; the outcome is recorded; the same
// outcome again matches; another outcome does not.
finish := func(g int64, status, reason string) error {
_, err := d.q.FinishLifecycleRequest(d.ctx, integration.FinishLifecycleRequestParams{
Status: status, ReasonCode: nullStr(reason), FinishedAt: time.Now(), InstanceID: instance, Generation: g})
return err
}
if err := finish(gen+5, "failed", "retries_exhausted"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("finish of a missing request: %v", err)
}
if err := finish(gen, "declined", "site_limit_reached"); err != nil {
t.Fatal(err)
}
if err := finish(gen, "declined", "site_limit_reached"); err != nil {
t.Errorf("repeating the same outcome: %v", err)
}
if err := finish(gen, "succeeded", ""); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("a different outcome overwrote the first: %v", err)
}
if rows := open(ws); len(rows) != 1 || rows[0].Status != "declined" {
t.Errorf("an unacknowledged declined request is shown: %+v", rows)
}
// Deleting only removes an accepted row.
if n, err := d.q.DeleteUnstartedLifecycleRequest(d.ctx, integration.DeleteUnstartedLifecycleRequestParams{
InstanceID: instance, Generation: gen, WorkspaceID: ws}); err != nil || n != 0 {
t.Errorf("deleted a finished request: %d, %v", n, err)
}
// Acknowledge in another workspace does nothing; in its own it hides it.
if n, _ := d.q.AcknowledgeLifecycleRequest(d.ctx, integration.AcknowledgeLifecycleRequestParams{InstanceID: instance, Generation: gen, WorkspaceID: otherWS}); n != 0 {
t.Error("another workspace acknowledged the request")
}
if n, err := d.q.AcknowledgeLifecycleRequest(d.ctx, integration.AcknowledgeLifecycleRequestParams{InstanceID: instance, Generation: gen, WorkspaceID: ws}); err != nil || n != 1 {
t.Fatalf("acknowledge = %d, %v", n, err)
}
if rows := open(ws); len(rows) != 0 {
t.Errorf("an acknowledged outcome is shown: %+v", rows)
}
// A later request supersedes an unacknowledged outcome, and an accepted
// row can be deleted when its workflow never started.
d.exec(`UPDATE core.lifecycle_requests SET acknowledged_at = NULL WHERE request_id = $1`, reqID)
laterID, err := record(gen+1, "delete", r.sites, `{}`)
if err != nil {
t.Fatal(err)
}
if rows := open(ws); len(rows) != 1 || rows[0].RequestID != laterID {
t.Errorf("a later request does not supersede the outcome: %+v", rows)
}
latest, err := d.q.LatestLifecycleGenerations(d.ctx, []string{instance, uuid.New().String()})
if err != nil || len(latest) != 1 || latest[0].LatestGeneration != gen+1 {
t.Errorf("latest generations = %+v, %v", latest, err)
}
if n, err := d.q.DeleteUnstartedLifecycleRequest(d.ctx, integration.DeleteUnstartedLifecycleRequestParams{
InstanceID: instance, Generation: gen + 1, WorkspaceID: ws}); err != nil || n != 1 {
t.Errorf("delete of the accepted row = %d, %v", n, err)
}
}
File diff suppressed because it is too large Load Diff
+509
View File
@@ -0,0 +1,509 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package integration
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"reflect"
"time"
"github.com/google/uuid"
)
// Lifecycle requests (design D24 to D28). Core owns core.lifecycle_requests,
// so this file holds its only writers. Each takes the caller's DBTX, as
// Enqueue does, so an integration records a request and its own rows in one
// transaction.
// Lifecycle request statuses (chk_lifecycle_requests_status).
const (
LifecycleAccepted = "accepted"
LifecycleSucceeded = "succeeded"
LifecycleDeclined = "declined"
LifecycleFailed = "failed"
)
// Lifecycle request operations (chk_lifecycle_requests_operation): the three
// mutating verbs of the five-verb set.
const (
LifecycleCreate = "create"
LifecycleSetStatus = "set_status"
LifecycleDelete = "delete"
)
// ErrLifecycleRequestNotFound reports that a finish named no request that
// could take that outcome: no row at the address, or a row finished with
// another outcome.
var ErrLifecycleRequestNotFound = errors.New("lifecycle request not found")
// ErrLifecycleOperationRefused reports that the connection's provider does
// not declare the request's operation in core.provider_operations, or does
// not own its resource key. Nothing was recorded.
var ErrLifecycleOperationRefused = errors.New("lifecycle request refused: operation or resource key not the provider's")
// LifecycleRequestSpec is one request a person makes for one instance. Its
// content, which a repeat must match, is Operation, WorkspaceID,
// RequestedByPersonID and Parameters (design D26).
type LifecycleRequestSpec struct {
InstanceID string
Generation int64
ConnectionID string
ResourceKey string
WorkspaceID string
Operation string
// Parameters holds identifiers and public DNS names only (the column
// comment on core.lifecycle_requests.parameters). Nil records {}.
Parameters json.RawMessage
WorkflowID string
RequestedByPersonID string // "" when no person made the request
}
// LifecycleAnswerKind is what recording a request found at its address.
type LifecycleAnswerKind int
const (
// LifecycleRecorded: the address held nothing and the generation was
// above the instance's latest; the request is recorded and its workflow
// should start.
LifecycleRecorded LifecycleAnswerKind = iota + 1
// LifecycleRepeatOpen: the address holds the same request, still
// accepted; answer accepted and start nothing.
LifecycleRepeatOpen
// LifecycleRepeatFinished: the address holds the same request, finished;
// answer its stored outcome.
LifecycleRepeatFinished
// LifecycleConflict: the address holds a different request, or nothing
// at a generation at or below the instance's latest.
LifecycleConflict
// LifecycleBusy: the generation is new, but another request on the
// instance is still accepted (the one-open rule, design D27). Nothing
// is recorded.
LifecycleBusy
// LifecycleNotFound: the address holds a request of another workspace.
// The caller learns nothing of it (design D26).
LifecycleNotFound
)
func (k LifecycleAnswerKind) String() string {
switch k {
case LifecycleRecorded:
return "recorded"
case LifecycleRepeatOpen:
return "repeat-open"
case LifecycleRepeatFinished:
return "repeat-finished"
case LifecycleConflict:
return "conflict"
case LifecycleBusy:
return "busy"
case LifecycleNotFound:
return "not-found"
default:
return fmt.Sprintf("LifecycleAnswerKind(%d)", int(k))
}
}
// LifecycleAnswer is RecordLifecycleRequest's answer. Status, ReasonCode
// and WorkflowID describe the request at the address for a recorded
// request or a repeat, and are empty otherwise.
type LifecycleAnswer struct {
Kind LifecycleAnswerKind
Status string
ReasonCode string
WorkflowID string
}
// Accepted reports whether the answer is "accepted": a new request or an
// identical repeat of one still running.
func (a LifecycleAnswer) Accepted() bool {
return a.Kind == LifecycleRecorded || a.Kind == LifecycleRepeatOpen
}
// NewInstanceID mints a new instance's identifier from the database, the
// only source of UUIDs (design D25).
func NewInstanceID(ctx context.Context, db DBTX) (string, error) {
return New(db).MintInstanceID(ctx)
}
// NewLifecycleGeneration mints the generation for a request on an instance
// that has no request yet: the database clock in microseconds, through
// core.next_generation (design D25).
func NewLifecycleGeneration(ctx context.Context, db DBTX) (int64, error) {
return New(db).MintLifecycleGeneration(ctx)
}
// RecordLifecycleRequest records spec or answers from what holds its address
// (design D26). It serializes per instance through a transaction-scoped
// advisory lock, so db must be a transaction for the answer to hold until the
// caller commits; given a *sql.DB it runs in a transaction of its own.
//
// Under the lock it reads the instance's latest generation. A generation
// above it is new: the request is recorded unless another request on the
// instance is open (LifecycleBusy) or the generation is above
// core.generation_ceiling() (LifecycleConflict). A generation at or below it is a repeat
// when the address holds the same content, and a conflict otherwise, which
// also covers an older tab's generation that was never recorded.
func RecordLifecycleRequest(ctx context.Context, db DBTX, spec LifecycleRequestSpec) (LifecycleAnswer, error) {
if sqlDB, ok := db.(*sql.DB); ok {
tx, err := sqlDB.BeginTx(ctx, nil)
if err != nil {
return LifecycleAnswer{}, err
}
defer func() { _ = tx.Rollback() }()
answer, err := RecordLifecycleRequest(ctx, tx, spec)
if err != nil {
return LifecycleAnswer{}, err
}
return answer, tx.Commit()
}
if spec.InstanceID == "" || spec.Generation <= 0 {
return LifecycleAnswer{}, fmt.Errorf("record lifecycle request: instance %q generation %d", spec.InstanceID, spec.Generation)
}
params := spec.Parameters
if len(params) == 0 {
params = json.RawMessage(`{}`)
}
var person uuid.NullUUID
if spec.RequestedByPersonID != "" {
id, err := uuid.Parse(spec.RequestedByPersonID)
if err != nil {
return LifecycleAnswer{}, fmt.Errorf("record lifecycle request: requesting person: %w", err)
}
person = uuid.NullUUID{UUID: id, Valid: true}
}
q := New(db)
if err := q.LockLifecycleInstance(ctx, spec.InstanceID); err != nil {
return LifecycleAnswer{}, fmt.Errorf("lock lifecycle instance: %w", err)
}
latest, err := q.LatestLifecycleGeneration(ctx, spec.InstanceID)
if err != nil {
return LifecycleAnswer{}, fmt.Errorf("latest lifecycle generation: %w", err)
}
if spec.Generation > latest {
// A posted generation comes from outside code, as a reported one
// does (design D8): above the ceiling it would make the instance's
// next generation overflow, so it is answered as a conflict.
ceiling, err := q.GenerationCeiling(ctx)
if err != nil {
return LifecycleAnswer{}, fmt.Errorf("generation ceiling: %w", err)
}
if spec.Generation > ceiling {
return LifecycleAnswer{Kind: LifecycleConflict}, nil
}
open, err := q.OpenLifecycleGeneration(ctx, spec.InstanceID)
if err != nil {
return LifecycleAnswer{}, fmt.Errorf("open lifecycle request: %w", err)
}
if open != 0 {
return LifecycleAnswer{Kind: LifecycleBusy}, nil
}
_, err = q.InsertLifecycleRequest(ctx, InsertLifecycleRequestParams{
InstanceID: spec.InstanceID,
Generation: spec.Generation,
WorkspaceID: spec.WorkspaceID,
Parameters: params,
WorkflowID: spec.WorkflowID,
RequestedByPersonID: person,
Operation: spec.Operation,
ResourceKey: spec.ResourceKey,
ConnectionID: spec.ConnectionID,
})
if errors.Is(err, sql.ErrNoRows) {
// Under the lock no row can hold an address above the latest
// generation, so nothing inserted means the join refused the
// operation or the key.
return LifecycleAnswer{}, ErrLifecycleOperationRefused
}
if err != nil {
return LifecycleAnswer{}, fmt.Errorf("insert lifecycle request: %w", err)
}
return LifecycleAnswer{Kind: LifecycleRecorded, Status: LifecycleAccepted, WorkflowID: spec.WorkflowID}, nil
}
held, err := q.GetLifecycleRequestByAddress(ctx, GetLifecycleRequestByAddressParams{
InstanceID: spec.InstanceID,
Generation: spec.Generation,
})
if errors.Is(err, sql.ErrNoRows) {
return LifecycleAnswer{Kind: LifecycleConflict}, nil
}
if err != nil {
return LifecycleAnswer{}, fmt.Errorf("read lifecycle request: %w", err)
}
if held.WorkspaceID != spec.WorkspaceID {
return LifecycleAnswer{Kind: LifecycleNotFound}, nil
}
same, err := sameLifecycleContent(held, spec.Operation, person, params)
if err != nil {
return LifecycleAnswer{}, err
}
if !same {
return LifecycleAnswer{Kind: LifecycleConflict}, nil
}
return answerFromRow(held), nil
}
// RecordFirstLifecycleRequest records a request that must be its instance's
// first, such as a create, or answers from what holds its address. It runs
// as RecordLifecycleRequest does, except that a generation above the
// instance's latest is a conflict when the instance already holds a request:
// a create posted again under another generation must not become a second
// create of the same instance.
func RecordFirstLifecycleRequest(ctx context.Context, db DBTX, spec LifecycleRequestSpec) (LifecycleAnswer, error) {
if sqlDB, ok := db.(*sql.DB); ok {
tx, err := sqlDB.BeginTx(ctx, nil)
if err != nil {
return LifecycleAnswer{}, err
}
defer func() { _ = tx.Rollback() }()
answer, err := RecordFirstLifecycleRequest(ctx, tx, spec)
if err != nil {
return LifecycleAnswer{}, err
}
return answer, tx.Commit()
}
if spec.InstanceID == "" || spec.Generation <= 0 {
return LifecycleAnswer{}, fmt.Errorf("record first lifecycle request: instance %q generation %d", spec.InstanceID, spec.Generation)
}
q := New(db)
if err := q.LockLifecycleInstance(ctx, spec.InstanceID); err != nil {
return LifecycleAnswer{}, fmt.Errorf("lock lifecycle instance: %w", err)
}
latest, err := q.LatestLifecycleGeneration(ctx, spec.InstanceID)
if err != nil {
return LifecycleAnswer{}, fmt.Errorf("latest lifecycle generation: %w", err)
}
if latest != 0 && spec.Generation > latest {
return LifecycleAnswer{Kind: LifecycleConflict}, nil
}
// The advisory lock is transaction-scoped and re-entrant, so taking it
// again inside RecordLifecycleRequest is harmless.
return RecordLifecycleRequest(ctx, db, spec)
}
// FirstLifecycleRequest returns an instance's first request in the caller's
// workspace, as a repeat answer, or found false when the instance has no
// request the caller may see. The JSON API finds a create this way, by its
// instance alone (design D25).
func FirstLifecycleRequest(ctx context.Context, db DBTX, workspaceID, instanceID string) (LifecycleAnswer, LifecycleRequest, bool, error) {
held, err := New(db).GetFirstLifecycleRequest(ctx, instanceID)
if errors.Is(err, sql.ErrNoRows) {
return LifecycleAnswer{}, LifecycleRequest{}, false, nil
}
if err != nil {
return LifecycleAnswer{}, LifecycleRequest{}, false, fmt.Errorf("read first lifecycle request: %w", err)
}
if held.WorkspaceID != workspaceID {
return LifecycleAnswer{}, LifecycleRequest{}, false, nil
}
return answerFromRow(held), held, true, nil
}
// LookupLifecycleRequest answers a repeat without recording: the request at
// (instance, generation) in the caller's workspace, as a repeat answer, or
// found false when the address holds nothing the caller may see. A handler
// uses it before resolving a site that a finished delete removed (design
// D31).
func LookupLifecycleRequest(ctx context.Context, db DBTX, workspaceID, instanceID string, generation int64) (LifecycleAnswer, LifecycleRequest, bool, error) {
held, err := New(db).GetLifecycleRequestByAddress(ctx, GetLifecycleRequestByAddressParams{
InstanceID: instanceID,
Generation: generation,
})
if errors.Is(err, sql.ErrNoRows) {
return LifecycleAnswer{}, LifecycleRequest{}, false, nil
}
if err != nil {
return LifecycleAnswer{}, LifecycleRequest{}, false, fmt.Errorf("read lifecycle request: %w", err)
}
if held.WorkspaceID != workspaceID {
return LifecycleAnswer{}, LifecycleRequest{}, false, nil
}
return answerFromRow(held), held, true, nil
}
func answerFromRow(held LifecycleRequest) LifecycleAnswer {
answer := LifecycleAnswer{Status: held.Status, ReasonCode: held.ReasonCode.String, WorkflowID: held.WorkflowID}
if held.Status == LifecycleAccepted {
answer.Kind = LifecycleRepeatOpen
} else {
answer.Kind = LifecycleRepeatFinished
}
return answer
}
// sameLifecycleContent compares a stored request's content with a new one's:
// operation, requesting person and parameters (the workspace is compared by
// the caller). Parameters compare as JSON values, so key order and spacing
// do not matter.
func sameLifecycleContent(held LifecycleRequest, operation string, person uuid.NullUUID, params json.RawMessage) (bool, error) {
if held.Operation != operation || held.RequestedByPersonID != person {
return false, nil
}
var a, b any
if err := json.Unmarshal(held.Parameters, &a); err != nil {
return false, fmt.Errorf("stored lifecycle parameters: %w", err)
}
if err := json.Unmarshal(params, &b); err != nil {
return false, fmt.Errorf("lifecycle parameters: %w", err)
}
return reflect.DeepEqual(a, b), nil
}
// LifecycleOutcome is how a lifecycle workflow ends its request.
type LifecycleOutcome struct {
InstanceID string
Generation int64
Status string // succeeded, declined or failed
ReasonCode string // "" for none; only with declined or failed
FinishedAt time.Time
}
// FinishLifecycleRequest records a request's outcome. It returns
// ErrLifecycleRequestNotFound when no request matches, rather than updating
// nothing (design D28). Finishing again with the same outcome succeeds and
// keeps the first finish time, because the outcome is written by an activity
// retried without an attempt limit.
func FinishLifecycleRequest(ctx context.Context, db DBTX, out LifecycleOutcome) error {
switch out.Status {
case LifecycleSucceeded, LifecycleDeclined, LifecycleFailed:
default:
return fmt.Errorf("finish lifecycle request: status %q is not an outcome", out.Status)
}
if out.FinishedAt.IsZero() {
out.FinishedAt = time.Now()
}
_, err := New(db).FinishLifecycleRequest(ctx, FinishLifecycleRequestParams{
Status: out.Status,
ReasonCode: sql.NullString{String: out.ReasonCode, Valid: out.ReasonCode != ""},
FinishedAt: out.FinishedAt,
InstanceID: out.InstanceID,
Generation: out.Generation,
})
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: instance %s generation %d as %s", ErrLifecycleRequestNotFound, out.InstanceID, out.Generation, out.Status)
}
if err != nil {
return fmt.Errorf("finish lifecycle request: %w", err)
}
return nil
}
// DeleteUnstartedLifecycleRequest deletes the accepted request a handler
// inserted when its workflow start was refused with NotFound, so the
// member's retry is a fresh insert (design D27). It deletes nothing that is
// finished or in another workspace, and reports whether a row went.
func DeleteUnstartedLifecycleRequest(ctx context.Context, db DBTX, workspaceID, instanceID string, generation int64) (bool, error) {
n, err := New(db).DeleteUnstartedLifecycleRequest(ctx, DeleteUnstartedLifecycleRequestParams{
InstanceID: instanceID,
Generation: generation,
WorkspaceID: workspaceID,
})
if err != nil {
return false, fmt.Errorf("delete unstarted lifecycle request: %w", err)
}
return n > 0, nil
}
// LatestGenerations returns the highest generation recorded for each of
// instanceIDs; an instance with no request is absent from the map.
func LatestGenerations(ctx context.Context, db DBTX, instanceIDs []string) (map[string]int64, error) {
out := make(map[string]int64, len(instanceIDs))
if len(instanceIDs) == 0 {
return out, nil
}
rows, err := New(db).LatestLifecycleGenerations(ctx, instanceIDs)
if err != nil {
return nil, fmt.Errorf("latest lifecycle generations: %w", err)
}
for _, r := range rows {
out[r.InstanceID] = r.LatestGeneration
}
return out, nil
}
// NextGenerations returns the generation each instance's next request
// carries, core.next_generation over its latest generation: above every
// generation recorded for it and at least the clock, so an instance with no
// request gets the clock (design D25). One query serves a whole list.
func NextGenerations(ctx context.Context, db DBTX, instanceIDs []string) (map[string]int64, error) {
out := make(map[string]int64, len(instanceIDs))
if len(instanceIDs) == 0 {
return out, nil
}
rows, err := New(db).NextLifecycleGenerations(ctx, instanceIDs)
if err != nil {
return nil, fmt.Errorf("next lifecycle generations: %w", err)
}
for _, r := range rows {
out[r.InstanceID] = r.NextGeneration
}
return out, nil
}
// OpenRequestsByWorkspace returns what a workspace's list shows for one
// connection (design D32): every accepted request, and per instance the
// latest request when it is declined or failed and not acknowledged; a later
// request on the instance supersedes an earlier outcome.
func OpenRequestsByWorkspace(ctx context.Context, db DBTX, workspaceID, connectionID string) ([]LifecycleRequest, error) {
rows, err := New(db).OpenLifecycleRequestsByWorkspace(ctx, OpenLifecycleRequestsByWorkspaceParams{
WorkspaceID: workspaceID,
ConnectionID: connectionID,
})
if err != nil {
return nil, fmt.Errorf("open lifecycle requests: %w", err)
}
return rows, nil
}
// AcknowledgeLifecycleRequest records that a member dismissed a declined or
// failed outcome in their workspace, and reports whether one was dismissed.
func AcknowledgeLifecycleRequest(ctx context.Context, db DBTX, workspaceID, instanceID string, generation int64) (bool, error) {
n, err := New(db).AcknowledgeLifecycleRequest(ctx, AcknowledgeLifecycleRequestParams{
InstanceID: instanceID,
Generation: generation,
WorkspaceID: workspaceID,
})
if err != nil {
return false, fmt.Errorf("acknowledge lifecycle request: %w", err)
}
return n > 0, nil
}
// AcceptedLifecycleRequestsOlderThan lists a connection's accepted requests
// recorded more than age ago, for the boot pass that settles requests whose
// workflow never started or closed without an outcome (design D27).
func AcceptedLifecycleRequestsOlderThan(ctx context.Context, db DBTX, connectionID string, age time.Duration) ([]LifecycleRequest, error) {
rows, err := New(db).ListAcceptedLifecycleRequestsOlderThan(ctx, ListAcceptedLifecycleRequestsOlderThanParams{
ConnectionID: connectionID,
AgeSeconds: int64(age / time.Second),
})
if err != nil {
return nil, fmt.Errorf("accepted lifecycle requests: %w", err)
}
return rows, nil
}
// LifecycleRequesterSubject returns the identity-provider subject of the
// person who made the request at (instance, generation), or "" when the
// request names no person. A workflow reads it inside an activity, so the
// subject never enters a workflow input (design D27).
func LifecycleRequesterSubject(ctx context.Context, db DBTX, instanceID string, generation int64) (string, error) {
subject, err := New(db).GetLifecycleRequesterSubject(ctx, GetLifecycleRequesterSubjectParams{
InstanceID: instanceID,
Generation: generation,
})
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("lifecycle requester subject: %w", err)
}
return subject, nil
}
+596
View File
@@ -0,0 +1,596 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package integration_test
// Database tests of the lifecycle request writers (integration-delivery-core
// task 10.2, design D24 to D28). Most run in one rolled-back transaction with
// a made-up provider; the concurrency test commits its fixture, because it
// needs two sessions, and removes it at cleanup.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"math"
"strings"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// lifecycleFixture is a made-up provider declaring create and delete on its
// organization key, one connection, and a workspace with its owner.
type lifecycleFixture struct {
r registry
owner, ws, ows string
}
func newLifecycleFixture(d *dq) lifecycleFixture {
f := lifecycleFixture{r: d.registry(), owner: d.person()}
org := d.org(f.owner)
f.ws = d.id(`INSERT INTO core.workspaces (org_id, name) VALUES ($1, 'Lifecycle WS') RETURNING workspace_id`, org)
f.ows = d.id(`INSERT INTO core.workspaces (org_id, name) VALUES ($1, 'Other WS') RETURNING workspace_id`, org)
return f
}
func (f lifecycleFixture) spec(instance string, gen int64, op, params string) integration.LifecycleRequestSpec {
return integration.LifecycleRequestSpec{
InstanceID: instance, Generation: gen, ConnectionID: f.r.conn, ResourceKey: f.r.sites,
WorkspaceID: f.ws, Operation: op, Parameters: json.RawMessage(params),
WorkflowID: "wf-" + instance, RequestedByPersonID: f.owner,
}
}
func (d *dq) generation() int64 {
g, err := integration.NewLifecycleGeneration(d.ctx, d.tx)
if err != nil {
d.t.Fatal(err)
}
return g
}
func (d *dq) record(spec integration.LifecycleRequestSpec) integration.LifecycleAnswer {
d.t.Helper()
a, err := integration.RecordLifecycleRequest(d.ctx, d.tx, spec)
if err != nil {
d.t.Fatalf("record %+v: %v", spec, err)
}
return a
}
func (d *dq) requestRows(instance string) int {
var n int
d.scan(&n, `SELECT count(*) FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
return n
}
func (d *dq) finish(instance string, gen int64, status, reason string) error {
return integration.FinishLifecycleRequest(d.ctx, d.tx, integration.LifecycleOutcome{
InstanceID: instance, Generation: gen, Status: status, ReasonCode: reason, FinishedAt: time.Now()})
}
func TestRecordLifecycleRequestAnswers_DB(t *testing.T) {
d := newDQ(t)
f := newLifecycleFixture(d)
instance, err := integration.NewInstanceID(d.ctx, d.tx)
if err != nil {
t.Fatal(err)
}
if _, err := uuid.Parse(instance); err != nil {
t.Fatalf("instance %q is not a UUID: %v", instance, err)
}
gen := d.generation()
create := f.spec(instance, gen, integration.LifecycleCreate, `{"domain": "one.example.test", "custom": false}`)
var outboxBefore int
d.scan(&outboxBefore, `SELECT count(*) FROM core.desired_state_outbox`)
// A new request is accepted and recorded.
if a := d.record(create); a.Kind != integration.LifecycleRecorded || !a.Accepted() || a.WorkflowID != "wf-"+instance {
t.Fatalf("new request = %+v", a)
}
var status string
d.scan(&status, `SELECT status FROM core.lifecycle_requests WHERE instance_id = $1 AND generation = $2`, instance, gen)
if status != "accepted" {
t.Fatalf("stored status = %q", status)
}
// An identical repeat while open is accepted and records nothing; key
// order and spacing in the parameters do not make it different.
repeat := create
repeat.Parameters = json.RawMessage(`{"custom":false,"domain":"one.example.test"}`)
if a := d.record(repeat); a.Kind != integration.LifecycleRepeatOpen || !a.Accepted() {
t.Errorf("repeat while open = %+v", a)
}
if n := d.requestRows(instance); n != 1 {
t.Errorf("rows after a repeat = %d", n)
}
// The same address with other content is a conflict and leaves the
// stored request unchanged: other parameters, another operation, another
// requesting person.
other := create
other.Parameters = json.RawMessage(`{"domain": "two.example.test", "custom": false}`)
otherOp := create
otherOp.Operation = integration.LifecycleDelete
otherPerson := create
otherPerson.RequestedByPersonID = d.person()
for name, s := range map[string]integration.LifecycleRequestSpec{"parameters": other, "operation": otherOp, "person": otherPerson} {
if a := d.record(s); a.Kind != integration.LifecycleConflict || a.Accepted() || a.Status != "" {
t.Errorf("other %s at the address = %+v", name, a)
}
}
var params string
d.scan(&params, `SELECT parameters->>'domain' FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
if params != "one.example.test" {
t.Errorf("stored domain changed to %q", params)
}
// Another workspace's request is not found, by recording or by lookup.
foreign := create
foreign.WorkspaceID = f.ows
if a := d.record(foreign); a.Kind != integration.LifecycleNotFound || a.Status != "" || a.WorkflowID != "" {
t.Errorf("another workspace's address = %+v", a)
}
if _, _, found, err := integration.LookupLifecycleRequest(d.ctx, d.tx, f.ows, instance, gen); err != nil || found {
t.Errorf("lookup from another workspace found it: %v", err)
}
if a, row, found, err := integration.LookupLifecycleRequest(d.ctx, d.tx, f.ws, instance, gen); err != nil || !found ||
a.Kind != integration.LifecycleRepeatOpen || row.Operation != integration.LifecycleCreate {
t.Errorf("lookup in its own workspace = %+v, %+v, %v, %v", a, row, found, err)
}
// A different request on the open instance under a new generation is
// busy, and nothing is recorded.
busy := f.spec(instance, gen+1, integration.LifecycleDelete, `{}`)
if a := d.record(busy); a.Kind != integration.LifecycleBusy || a.Accepted() {
t.Errorf("second request while one is open = %+v", a)
}
if n := d.requestRows(instance); n != 1 {
t.Errorf("rows after a busy answer = %d", n)
}
// Finishing a missing request fails; finishing the request records it;
// an identical repeat then returns the stored outcome and its reason.
if err := d.finish(instance, gen+7, integration.LifecycleFailed, "retries_exhausted"); !errors.Is(err, integration.ErrLifecycleRequestNotFound) {
t.Errorf("finish of a missing request: %v", err)
}
if err := d.finish(instance, gen, integration.LifecycleDeclined, "site_limit_reached"); err != nil {
t.Fatal(err)
}
if err := d.finish(instance, gen, integration.LifecycleDeclined, "site_limit_reached"); err != nil {
t.Errorf("repeating the same finish: %v", err)
}
if err := d.finish(instance, gen, integration.LifecycleSucceeded, ""); !errors.Is(err, integration.ErrLifecycleRequestNotFound) {
t.Errorf("finishing with another outcome: %v", err)
}
if err := d.finish(instance, gen, integration.LifecycleAccepted, ""); err == nil {
t.Error("accepted was taken as an outcome")
}
if a := d.record(create); a.Kind != integration.LifecycleRepeatFinished || a.Accepted() ||
a.Status != integration.LifecycleDeclined || a.ReasonCode != "site_limit_reached" {
t.Errorf("repeat after finishing = %+v", a)
}
if n := d.requestRows(instance); n != 1 {
t.Errorf("rows after a finished repeat = %d", n)
}
// Recording and finishing wrote no desired-state outbox row.
var outboxAfter int
d.scan(&outboxAfter, `SELECT count(*) FROM core.desired_state_outbox`)
if outboxAfter != outboxBefore {
t.Errorf("desired-state outbox rows %d -> %d", outboxBefore, outboxAfter)
}
}
func TestRecordLifecycleRequestOlderTabAndFloors_DB(t *testing.T) {
d := newDQ(t)
f := newLifecycleFixture(d)
instance := uuid.New().String()
// The first generation for an existing instance with no request is
// floored by the clock. core.next_generation reads the transaction's
// clock (now()), so the floor is the transaction's start.
var clock int64
d.scan(&clock, `SELECT (extract(epoch FROM now()) * 1000000)::bigint`)
next, err := integration.NextGenerations(d.ctx, d.tx, []string{instance})
if err != nil {
t.Fatal(err)
}
if next[instance] < clock {
t.Errorf("first generation %d is below the clock %d", next[instance], clock)
}
if g := d.generation(); g < clock {
t.Errorf("minted generation %d is below the clock %d", g, clock)
}
// An older tab rendered G1, a newer tab rendered later G2 > G1; G2 is
// recorded and finishes, then G1 arrives and is a conflict.
g1 := d.generation()
g2 := g1 + 1000
if a := d.record(f.spec(instance, g2, integration.LifecycleDelete, `{}`)); a.Kind != integration.LifecycleRecorded {
t.Fatalf("newer tab = %+v", a)
}
if err := d.finish(instance, g2, integration.LifecycleFailed, "retries_exhausted"); err != nil {
t.Fatal(err)
}
if a := d.record(f.spec(instance, g1, integration.LifecycleDelete, `{}`)); a.Kind != integration.LifecycleConflict {
t.Errorf("older tab = %+v", a)
}
if n := d.requestRows(instance); n != 1 {
t.Errorf("rows after the older tab = %d", n)
}
// Next generations rise above the latest; LatestGenerations reports it
// and omits an instance with no request.
latest, err := integration.LatestGenerations(d.ctx, d.tx, []string{instance, uuid.New().String()})
if err != nil || len(latest) != 1 || latest[instance] != g2 {
t.Errorf("latest = %v, %v", latest, err)
}
next, err = integration.NextGenerations(d.ctx, d.tx, []string{instance})
if err != nil || next[instance] <= g2 {
t.Errorf("next after %d = %v, %v", g2, next, err)
}
// An undeclared operation and a key the provider does not own are
// refused, and nothing is stored.
fresh := uuid.New().String()
undeclared := f.spec(fresh, d.generation(), integration.LifecycleSetStatus, `{}`)
foreignKey := f.spec(fresh, d.generation(), integration.LifecycleCreate, `{}`)
foreignKey.ResourceKey = "external_domain_claims"
for name, s := range map[string]integration.LifecycleRequestSpec{"operation": undeclared, "key": foreignKey} {
d.exec("SAVEPOINT refused")
if _, err := integration.RecordLifecycleRequest(d.ctx, d.tx, s); !errors.Is(err, integration.ErrLifecycleOperationRefused) {
t.Errorf("undeclared %s: %v", name, err)
}
d.exec("ROLLBACK TO SAVEPOINT refused")
}
if n := d.requestRows(fresh); n != 0 {
t.Errorf("a refused request left %d rows", n)
}
}
func TestLifecycleRequestListAndCleanup_DB(t *testing.T) {
d := newDQ(t)
f := newLifecycleFixture(d)
instance := uuid.New().String()
shown := func() []integration.LifecycleRequest {
rows, err := integration.OpenRequestsByWorkspace(d.ctx, d.tx, f.ws, f.r.conn)
if err != nil {
t.Fatal(err)
}
return rows
}
g1 := d.generation()
d.record(f.spec(instance, g1, integration.LifecycleCreate, `{"domain": "list.example.test"}`))
if rows := shown(); len(rows) != 1 || rows[0].Status != "accepted" {
t.Fatalf("open create = %+v", rows)
}
if rows, err := integration.OpenRequestsByWorkspace(d.ctx, d.tx, f.ows, f.r.conn); err != nil || len(rows) != 0 {
t.Errorf("another workspace sees %+v, %v", rows, err)
}
// An unacknowledged failure stays until it is acknowledged.
if err := d.finish(instance, g1, integration.LifecycleFailed, "retries_exhausted"); err != nil {
t.Fatal(err)
}
if rows := shown(); len(rows) != 1 || rows[0].Status != "failed" || rows[0].ReasonCode.String != "retries_exhausted" {
t.Fatalf("failed create = %+v", rows)
}
// DeleteUnstartedLifecycleRequest leaves a finished row alone.
if gone, err := integration.DeleteUnstartedLifecycleRequest(d.ctx, d.tx, f.ws, instance, g1); err != nil || gone {
t.Errorf("deleted a finished request: %v, %v", gone, err)
}
// Acknowledging from another workspace does nothing.
if ok, err := integration.AcknowledgeLifecycleRequest(d.ctx, d.tx, f.ows, instance, g1); err != nil || ok {
t.Errorf("another workspace acknowledged: %v, %v", ok, err)
}
if ok, err := integration.AcknowledgeLifecycleRequest(d.ctx, d.tx, f.ws, instance, g1); err != nil || !ok {
t.Fatalf("acknowledge = %v, %v", ok, err)
}
var acked sql.NullTime
d.scan(&acked, `SELECT acknowledged_at FROM core.lifecycle_requests WHERE instance_id = $1 AND generation = $2`, instance, g1)
if !acked.Valid {
t.Error("acknowledged_at not set")
}
if rows := shown(); len(rows) != 0 {
t.Errorf("an acknowledged outcome is shown: %+v", rows)
}
// A declined outcome is superseded by a later request on the instance.
site := uuid.New().String()
s1 := d.generation()
d.record(f.spec(site, s1, integration.LifecycleDelete, `{}`))
if err := d.finish(site, s1, integration.LifecycleDeclined, "site_changed"); err != nil {
t.Fatal(err)
}
if rows := shown(); len(rows) != 1 || rows[0].InstanceID != site || rows[0].Status != "declined" {
t.Fatalf("declined delete = %+v", rows)
}
nextGen, err := integration.NextGenerations(d.ctx, d.tx, []string{site})
if err != nil {
t.Fatal(err)
}
s2 := nextGen[site]
if a := d.record(f.spec(site, s2, integration.LifecycleDelete, `{}`)); a.Kind != integration.LifecycleRecorded {
t.Fatalf("later request = %+v", a)
}
if rows := shown(); len(rows) != 1 || rows[0].Generation != s2 || rows[0].Status != "accepted" {
t.Errorf("a later request does not supersede the outcome: %+v", rows)
}
// An accepted request whose workflow never started is deleted, and only
// in its own workspace.
if gone, err := integration.DeleteUnstartedLifecycleRequest(d.ctx, d.tx, f.ows, site, s2); err != nil || gone {
t.Errorf("deleted from another workspace: %v, %v", gone, err)
}
if gone, err := integration.DeleteUnstartedLifecycleRequest(d.ctx, d.tx, f.ws, site, s2); err != nil || !gone {
t.Errorf("delete of the unstarted request = %v, %v", gone, err)
}
// The deleted request never happened, so the declined outcome it had
// superseded shows again.
if rows := shown(); len(rows) != 1 || rows[0].Generation != s1 || rows[0].Status != "declined" {
t.Errorf("after the delete the list shows %+v", rows)
}
// The retry after the delete records afresh under the same address.
if a := d.record(f.spec(site, s2, integration.LifecycleDelete, `{}`)); a.Kind != integration.LifecycleRecorded {
t.Errorf("retry after the delete = %+v", a)
}
}
// TestRecordLifecycleRequestConcurrentOlderTab is the race the advisory lock
// closes: an older tab's G1 recording starts while a newer G2 recording
// holds the instance, and G2 is recorded and finished before G1's recording
// completes. Without the lock G1 would read the latest generation before
// G2's insert and be recorded as new.
func TestRecordLifecycleRequestConcurrentOlderTab_DB(t *testing.T) {
database := regDB(t)
ctx := context.Background()
fx := committedLifecycleFixture(t, database)
instance := uuid.New().String()
mint := func() int64 {
g, err := integration.NewLifecycleGeneration(ctx, database)
if err != nil {
t.Fatal(err)
}
return g
}
// Two mints in quick succession can read the same microsecond, so the
// newer tab's generation is set clearly above the older one's.
g1 := mint()
g2 := g1 + 1000
newer, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = newer.Rollback() }()
if a, err := integration.RecordLifecycleRequest(ctx, newer, fx.spec(instance, g2, integration.LifecycleDelete, `{}`)); err != nil || a.Kind != integration.LifecycleRecorded {
t.Fatalf("newer = %+v, %v", a, err)
}
older, err := database.Conn(ctx)
if err != nil {
t.Fatal(err)
}
defer older.Close()
var olderPID int
if err := older.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&olderPID); err != nil {
t.Fatal(err)
}
type result struct {
a integration.LifecycleAnswer
err error
}
done := make(chan result, 1)
go func() {
tx, err := older.BeginTx(ctx, nil)
if err != nil {
done <- result{err: err}
return
}
a, err := integration.RecordLifecycleRequest(ctx, tx, fx.spec(instance, g1, integration.LifecycleDelete, `{}`))
if err == nil {
err = tx.Commit()
} else {
_ = tx.Rollback()
}
done <- result{a, err}
}()
// Wait until the older recording waits on the instance's lock.
deadline := time.Now().Add(10 * time.Second)
for {
var waiting string
_ = database.QueryRowContext(ctx, `SELECT COALESCE(wait_event_type, '') FROM pg_stat_activity WHERE pid = $1`, olderPID).Scan(&waiting)
if waiting == "Lock" {
break
}
if time.Now().After(deadline) {
t.Fatal("the older recording never waited on the instance lock")
}
time.Sleep(20 * time.Millisecond)
}
if err := integration.FinishLifecycleRequest(ctx, newer, integration.LifecycleOutcome{
InstanceID: instance, Generation: g2, Status: integration.LifecycleSucceeded, FinishedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if err := newer.Commit(); err != nil {
t.Fatal(err)
}
r := <-done
if r.err != nil || r.a.Kind != integration.LifecycleConflict {
t.Fatalf("older tab = %+v, %v", r.a, r.err)
}
var rows int
if err := database.QueryRowContext(ctx, `SELECT count(*) FROM core.lifecycle_requests WHERE instance_id = $1`, instance).Scan(&rows); err != nil || rows != 1 {
t.Errorf("rows = %d, %v; want only G2", rows, err)
}
}
// committedLifecycleFixture commits a made-up provider, connection and
// workspace and removes them, with their requests, when the test ends.
func committedLifecycleFixture(t *testing.T, database *sql.DB) lifecycleFixture {
t.Helper()
ctx := context.Background()
provider := regProvider(t, database)
exec := func(q string, args ...any) {
t.Helper()
if _, err := database.ExecContext(ctx, q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
id := func(q string, args ...any) string {
t.Helper()
var v string
if err := database.QueryRowContext(ctx, q, args...).Scan(&v); err != nil {
t.Fatalf("%s: %v", q, err)
}
return v
}
f := lifecycleFixture{r: registry{provider: provider, sites: provider + "_sites"}}
exec(`INSERT INTO core.providers (provider, provider_kind, display_name) VALUES ($1, 'provisioning', 'Lifecycle Provider')`, provider)
exec(`INSERT INTO core.provider_operations (provider, operation) VALUES ($1, 'create'), ($1, 'delete')`, provider)
exec(`INSERT INTO core.resource_keys (resource_key, display_name, unit, provider, kind, recipient)
VALUES ($1, 'Sites', 'sites', $2, 'numeric', 'organization')`, f.r.sites, provider)
f.r.conn = id(`INSERT INTO core.provider_connections (provider, display_name, approval_state, approved_at)
VALUES ($1, 'Lifecycle Connection', 'approved', NOW()) RETURNING connection_id`, provider)
t.Cleanup(func() {
if _, err := database.ExecContext(ctx, `DELETE FROM core.lifecycle_requests WHERE connection_id = $1`, f.r.conn); err != nil {
t.Errorf("cleanup requests: %v", err)
}
if _, err := database.ExecContext(ctx, `DELETE FROM core.provider_operations WHERE provider = $1`, provider); err != nil {
t.Errorf("cleanup operations: %v", err)
}
})
user := id(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, dqName("lc-subject-"))
f.owner = id(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1, 'Lifecycle Person', $2) RETURNING person_id`,
user, dqName("lc-")+"@example.test")
org := id(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ('Lifecycle Org', 'personal', $1) RETURNING org_id`, f.owner)
f.ws = id(`INSERT INTO core.workspaces (org_id, name) VALUES ($1, 'Lifecycle WS') RETURNING workspace_id`, org)
return f
}
// A repeated finish keeps the first finish time, and the outcome is the
// status with its reason: the same status with another reason is not found.
func TestLifecycleFinishOutcomeIsStatusAndReason_DB(t *testing.T) {
d := newDQ(t)
f := newLifecycleFixture(d)
instance := uuid.New().String()
gen := d.generation()
d.record(f.spec(instance, gen, integration.LifecycleCreate, `{"domain": "finish.example.test"}`))
first := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
finish := func(reason string, at time.Time) error {
return integration.FinishLifecycleRequest(d.ctx, d.tx, integration.LifecycleOutcome{
InstanceID: instance, Generation: gen, Status: integration.LifecycleFailed, ReasonCode: reason, FinishedAt: at})
}
if err := finish("not_started", first); err != nil {
t.Fatal(err)
}
if err := finish("not_started", first.Add(time.Hour)); err != nil {
t.Fatalf("a repeated identical finish = %v", err)
}
var finished time.Time
d.scan(&finished, `SELECT finished_at FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
if !finished.Equal(first) {
t.Errorf("finished_at after a repeated finish = %v, want the first %v", finished, first)
}
if err := finish("retries_exhausted", first.Add(2*time.Hour)); !errors.Is(err, integration.ErrLifecycleRequestNotFound) {
t.Errorf("finish with the same status and another reason = %v, want not found", err)
}
var reason string
d.scan(&reason, `SELECT reason_code FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
if reason != "not_started" {
t.Errorf("stored reason = %q, want not_started", reason)
}
}
// Only a declined or failed request can be acknowledged.
func TestLifecycleAcknowledgeOnlyDeclinedOrFailed_DB(t *testing.T) {
d := newDQ(t)
f := newLifecycleFixture(d)
instance := uuid.New().String()
g1 := d.generation()
d.record(f.spec(instance, g1, integration.LifecycleCreate, `{"domain": "ack.example.test"}`))
if ok, err := integration.AcknowledgeLifecycleRequest(d.ctx, d.tx, f.ws, instance, g1); err != nil || ok {
t.Errorf("acknowledging an accepted request = %v, %v; want nothing changed", ok, err)
}
if err := d.finish(instance, g1, integration.LifecycleSucceeded, ""); err != nil {
t.Fatal(err)
}
if ok, err := integration.AcknowledgeLifecycleRequest(d.ctx, d.tx, f.ws, instance, g1); err != nil || ok {
t.Errorf("acknowledging a succeeded request = %v, %v; want nothing changed", ok, err)
}
other := uuid.New().String()
g2 := d.generation()
d.record(f.spec(other, g2, integration.LifecycleCreate, `{"domain": "ack2.example.test"}`))
if err := d.finish(other, g2, integration.LifecycleFailed, "retries_exhausted"); err != nil {
t.Fatal(err)
}
if ok, err := integration.AcknowledgeLifecycleRequest(d.ctx, d.tx, f.ws, other, g2); err != nil || !ok {
t.Errorf("acknowledging a failed request = %v, %v", ok, err)
}
}
// A first request repeated at its own address is a repeat, and the first
// request is found only from its own workspace, whoever asks.
func TestFirstLifecycleRequestRepeatAndWorkspace_DB(t *testing.T) {
d := newDQ(t)
f := newLifecycleFixture(d)
instance := uuid.New().String()
spec := f.spec(instance, d.generation(), integration.LifecycleCreate, `{"domain": "first.example.test"}`)
for i, want := range []integration.LifecycleAnswerKind{integration.LifecycleRecorded, integration.LifecycleRepeatOpen} {
a, err := integration.RecordFirstLifecycleRequest(d.ctx, d.tx, spec)
if err != nil || a.Kind != want {
t.Fatalf("first request, call %d = %v, %v; want %v", i+1, a.Kind, err, want)
}
}
if n := d.requestRows(instance); n != 1 {
t.Errorf("rows after an identical repeat = %d", n)
}
// The same person asking from another workspace of the organization.
if _, _, found, err := integration.FirstLifecycleRequest(d.ctx, d.tx, f.ows, instance); err != nil || found {
t.Errorf("FirstLifecycleRequest from another workspace: found=%v, %v", found, err)
}
if _, _, found, err := integration.FirstLifecycleRequest(d.ctx, d.tx, f.ws, instance); err != nil || !found {
t.Errorf("FirstLifecycleRequest from its own workspace: found=%v, %v", found, err)
}
}
// A posted generation above the ceiling is a conflict and records nothing,
// so the instance's next generation is still computable; the table refuses
// one written past the function.
func TestLifecycleGenerationAboveTheCeilingIsRefused_DB(t *testing.T) {
d := newDQ(t)
f := newLifecycleFixture(d)
instance := uuid.New().String()
if a := d.record(f.spec(instance, math.MaxInt64, integration.LifecycleCreate, `{"domain": "top.example.test"}`)); a.Kind != integration.LifecycleConflict {
t.Errorf("a generation of MaxInt64 = %+v, want a conflict", a)
}
if a, err := integration.RecordFirstLifecycleRequest(d.ctx, d.tx, f.spec(instance, math.MaxInt64, integration.LifecycleCreate, `{}`)); err != nil || a.Kind != integration.LifecycleConflict {
t.Errorf("a first request at MaxInt64 = %+v, %v; want a conflict", a, err)
}
if n := d.requestRows(instance); n != 0 {
t.Fatalf("a generation above the ceiling recorded %d rows", n)
}
if _, err := integration.NextGenerations(d.ctx, d.tx, []string{instance}); err != nil {
t.Errorf("NextGenerations after the refusal = %v", err)
}
d.exec(`SAVEPOINT above_ceiling`)
_, err := d.tx.ExecContext(d.ctx, `INSERT INTO core.lifecycle_requests
(instance_id, generation, connection_id, resource_key, workspace_id, operation, workflow_id)
VALUES ($1, 9223372036854775807, $2, $3, $4, 'create', 'wf')`, instance, f.r.conn, f.r.sites, f.ws)
if err == nil || !strings.Contains(err.Error(), "chk_lifecycle_requests_generation") {
t.Errorf("a direct insert above the ceiling = %v, want chk_lifecycle_requests_generation", err)
}
d.exec(`ROLLBACK TO SAVEPOINT above_ceiling`)
}
@@ -0,0 +1,521 @@
// 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: lifecycle_requests.sql
package integration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
const acknowledgeLifecycleRequest = `-- name: AcknowledgeLifecycleRequest :execrows
UPDATE core.lifecycle_requests
SET acknowledged_at = NOW()
WHERE instance_id = $1::uuid
AND generation = $2::bigint
AND workspace_id = $3::uuid
AND status IN ('declined', 'failed')
AND acknowledged_at IS NULL
`
type AcknowledgeLifecycleRequestParams struct {
InstanceID string `json:"instance_id"`
Generation int64 `json:"generation"`
WorkspaceID string `json:"workspace_id"`
}
// The member dismissed a declined or failed outcome in their workspace.
func (q *Queries) AcknowledgeLifecycleRequest(ctx context.Context, arg AcknowledgeLifecycleRequestParams) (int64, error) {
result, err := q.db.ExecContext(ctx, acknowledgeLifecycleRequest, arg.InstanceID, arg.Generation, arg.WorkspaceID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteUnstartedLifecycleRequest = `-- name: DeleteUnstartedLifecycleRequest :execrows
DELETE FROM core.lifecycle_requests
WHERE instance_id = $1::uuid
AND generation = $2::bigint
AND workspace_id = $3::uuid
AND status = 'accepted'
`
type DeleteUnstartedLifecycleRequestParams struct {
InstanceID string `json:"instance_id"`
Generation int64 `json:"generation"`
WorkspaceID string `json:"workspace_id"`
}
// A handler whose workflow start was refused with NotFound deletes the row it
// inserted, so the member's retry is a fresh insert. Only an accepted row in
// the caller's workspace is deleted.
func (q *Queries) DeleteUnstartedLifecycleRequest(ctx context.Context, arg DeleteUnstartedLifecycleRequestParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteUnstartedLifecycleRequest, arg.InstanceID, arg.Generation, arg.WorkspaceID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const finishLifecycleRequest = `-- name: FinishLifecycleRequest :one
UPDATE core.lifecycle_requests
SET status = $1::text,
reason_code = $2::text,
finished_at = CASE WHEN status = 'accepted' THEN $3::timestamptz ELSE finished_at END
WHERE instance_id = $4::uuid
AND generation = $5::bigint
AND (status = 'accepted'
OR (status = $1::text AND reason_code IS NOT DISTINCT FROM $2::text))
RETURNING request_id
`
type FinishLifecycleRequestParams struct {
Status string `json:"status"`
ReasonCode sql.NullString `json:"reason_code"`
FinishedAt time.Time `json:"finished_at"`
InstanceID string `json:"instance_id"`
Generation int64 `json:"generation"`
}
// Record a request's outcome. Repeating the same outcome (a retried local
// activity whose first attempt committed) matches again and keeps the first
// finish time; a missing request, or one finished with another outcome,
// matches nothing and returns sql.ErrNoRows, which fails the activity.
func (q *Queries) FinishLifecycleRequest(ctx context.Context, arg FinishLifecycleRequestParams) (string, error) {
row := q.db.QueryRowContext(ctx, finishLifecycleRequest,
arg.Status,
arg.ReasonCode,
arg.FinishedAt,
arg.InstanceID,
arg.Generation,
)
var request_id string
err := row.Scan(&request_id)
return request_id, err
}
const getFirstLifecycleRequest = `-- name: GetFirstLifecycleRequest :one
SELECT request_id, instance_id, generation, connection_id, resource_key, workspace_id, operation, parameters, status, reason_code, workflow_id, requested_by_person_id, created_at, updated_at, finished_at, acknowledged_at FROM core.lifecycle_requests
WHERE instance_id = $1::uuid
ORDER BY generation
LIMIT 1
`
// An instance's first request. A create is always its instance's first
// request, so the JSON API finds a create by its instance alone (design D25).
func (q *Queries) GetFirstLifecycleRequest(ctx context.Context, instanceID string) (LifecycleRequest, error) {
row := q.db.QueryRowContext(ctx, getFirstLifecycleRequest, instanceID)
var i LifecycleRequest
err := row.Scan(
&i.RequestID,
&i.InstanceID,
&i.Generation,
&i.ConnectionID,
&i.ResourceKey,
&i.WorkspaceID,
&i.Operation,
&i.Parameters,
&i.Status,
&i.ReasonCode,
&i.WorkflowID,
&i.RequestedByPersonID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FinishedAt,
&i.AcknowledgedAt,
)
return i, err
}
const getLifecycleRequestByAddress = `-- name: GetLifecycleRequestByAddress :one
SELECT request_id, instance_id, generation, connection_id, resource_key, workspace_id, operation, parameters, status, reason_code, workflow_id, requested_by_person_id, created_at, updated_at, finished_at, acknowledged_at FROM core.lifecycle_requests
WHERE instance_id = $1::uuid AND generation = $2::bigint
`
type GetLifecycleRequestByAddressParams struct {
InstanceID string `json:"instance_id"`
Generation int64 `json:"generation"`
}
func (q *Queries) GetLifecycleRequestByAddress(ctx context.Context, arg GetLifecycleRequestByAddressParams) (LifecycleRequest, error) {
row := q.db.QueryRowContext(ctx, getLifecycleRequestByAddress, arg.InstanceID, arg.Generation)
var i LifecycleRequest
err := row.Scan(
&i.RequestID,
&i.InstanceID,
&i.Generation,
&i.ConnectionID,
&i.ResourceKey,
&i.WorkspaceID,
&i.Operation,
&i.Parameters,
&i.Status,
&i.ReasonCode,
&i.WorkflowID,
&i.RequestedByPersonID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FinishedAt,
&i.AcknowledgedAt,
)
return i, err
}
const getLifecycleRequesterSubject = `-- name: GetLifecycleRequesterSubject :one
SELECT u.oidc_subject
FROM core.lifecycle_requests lr
JOIN core.persons p ON p.person_id = lr.requested_by_person_id
JOIN core.users u ON u.user_id = p.user_id
WHERE lr.instance_id = $1::uuid
AND lr.generation = $2::bigint
`
type GetLifecycleRequesterSubjectParams struct {
InstanceID string `json:"instance_id"`
Generation int64 `json:"generation"`
}
// The identity-provider subject of the person who made a request, read when
// a workflow must compare it with an application's owner record without
// carrying it in its input (design D27's create reconcile).
func (q *Queries) GetLifecycleRequesterSubject(ctx context.Context, arg GetLifecycleRequesterSubjectParams) (string, error) {
row := q.db.QueryRowContext(ctx, getLifecycleRequesterSubject, arg.InstanceID, arg.Generation)
var oidc_subject string
err := row.Scan(&oidc_subject)
return oidc_subject, err
}
const insertLifecycleRequest = `-- name: InsertLifecycleRequest :one
INSERT INTO core.lifecycle_requests
(instance_id, generation, connection_id, resource_key, workspace_id,
operation, parameters, workflow_id, requested_by_person_id)
SELECT $1::uuid,
$2::bigint,
pc.connection_id,
rk.resource_key,
$3::uuid,
po.operation,
$4::jsonb,
$5::text,
$6::uuid
FROM core.provider_connections pc
JOIN core.provider_operations po
ON po.provider = pc.provider AND po.operation = $7::text
JOIN core.resource_keys rk
ON rk.provider = pc.provider AND rk.resource_key = $8::text
WHERE pc.connection_id = $9::uuid
ON CONFLICT (instance_id, generation) DO NOTHING
RETURNING request_id
`
type InsertLifecycleRequestParams struct {
InstanceID string `json:"instance_id"`
Generation int64 `json:"generation"`
WorkspaceID string `json:"workspace_id"`
Parameters json.RawMessage `json:"parameters"`
WorkflowID string `json:"workflow_id"`
RequestedByPersonID uuid.NullUUID `json:"requested_by_person_id"`
Operation string `json:"operation"`
ResourceKey string `json:"resource_key"`
ConnectionID string `json:"connection_id"`
}
// Insert a request at its address. The operation must be one the
// connection's provider declares in core.provider_operations, and the
// provider must own the resource key; otherwise nothing is inserted. A held
// address also inserts nothing (ON CONFLICT on the address only), so
// sql.ErrNoRows sends the caller to GetLifecycleRequestByAddress: a row there
// is a repeat or a conflict, no row means the operation or key was refused.
// A second accepted request on the instance under another generation raises
// a unique violation on uq_lifecycle_requests_one_open.
func (q *Queries) InsertLifecycleRequest(ctx context.Context, arg InsertLifecycleRequestParams) (string, error) {
row := q.db.QueryRowContext(ctx, insertLifecycleRequest,
arg.InstanceID,
arg.Generation,
arg.WorkspaceID,
arg.Parameters,
arg.WorkflowID,
arg.RequestedByPersonID,
arg.Operation,
arg.ResourceKey,
arg.ConnectionID,
)
var request_id string
err := row.Scan(&request_id)
return request_id, err
}
const latestLifecycleGeneration = `-- name: LatestLifecycleGeneration :one
SELECT COALESCE(MAX(generation), 0)::bigint AS latest_generation
FROM core.lifecycle_requests
WHERE instance_id = $1::uuid
`
// The highest generation recorded for an instance, 0 when it has none.
func (q *Queries) LatestLifecycleGeneration(ctx context.Context, instanceID string) (int64, error) {
row := q.db.QueryRowContext(ctx, latestLifecycleGeneration, instanceID)
var latest_generation int64
err := row.Scan(&latest_generation)
return latest_generation, err
}
const latestLifecycleGenerations = `-- name: LatestLifecycleGenerations :many
SELECT instance_id, MAX(generation)::bigint AS latest_generation
FROM core.lifecycle_requests
WHERE instance_id = ANY($1::uuid[])
GROUP BY instance_id
`
type LatestLifecycleGenerationsRow struct {
InstanceID string `json:"instance_id"`
LatestGeneration int64 `json:"latest_generation"`
}
// The highest generation recorded for each of a set of instances; an
// instance with no request is absent.
func (q *Queries) LatestLifecycleGenerations(ctx context.Context, instanceIds []string) ([]LatestLifecycleGenerationsRow, error) {
rows, err := q.db.QueryContext(ctx, latestLifecycleGenerations, pq.Array(instanceIds))
if err != nil {
return nil, err
}
defer rows.Close()
items := []LatestLifecycleGenerationsRow{}
for rows.Next() {
var i LatestLifecycleGenerationsRow
if err := rows.Scan(&i.InstanceID, &i.LatestGeneration); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAcceptedLifecycleRequestsOlderThan = `-- name: ListAcceptedLifecycleRequestsOlderThan :many
SELECT request_id, instance_id, generation, connection_id, resource_key, workspace_id, operation, parameters, status, reason_code, workflow_id, requested_by_person_id, created_at, updated_at, finished_at, acknowledged_at FROM core.lifecycle_requests
WHERE connection_id = $1::uuid
AND status = 'accepted'
AND created_at < NOW() - ($2::bigint * INTERVAL '1 second')
ORDER BY created_at, request_id
`
type ListAcceptedLifecycleRequestsOlderThanParams struct {
ConnectionID string `json:"connection_id"`
AgeSeconds int64 `json:"age_seconds"`
}
// The boot pass (design D27): accepted requests at one connection older than
// the given age, whose workflow may never have started or may have closed
// without recording an outcome.
func (q *Queries) ListAcceptedLifecycleRequestsOlderThan(ctx context.Context, arg ListAcceptedLifecycleRequestsOlderThanParams) ([]LifecycleRequest, error) {
rows, err := q.db.QueryContext(ctx, listAcceptedLifecycleRequestsOlderThan, arg.ConnectionID, arg.AgeSeconds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []LifecycleRequest{}
for rows.Next() {
var i LifecycleRequest
if err := rows.Scan(
&i.RequestID,
&i.InstanceID,
&i.Generation,
&i.ConnectionID,
&i.ResourceKey,
&i.WorkspaceID,
&i.Operation,
&i.Parameters,
&i.Status,
&i.ReasonCode,
&i.WorkflowID,
&i.RequestedByPersonID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FinishedAt,
&i.AcknowledgedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockLifecycleInstance = `-- name: LockLifecycleInstance :exec
SELECT pg_advisory_xact_lock(hashtextextended('lifecycle:' || $1::uuid::text, 0))
`
// Lifecycle requests (design D24 to D28). Recording a request runs, in one
// transaction: LockLifecycleInstance, LatestLifecycleGeneration,
// InsertLifecycleRequest, then GetLifecycleRequestByAddress when the insert
// returned nothing, so the latest-generation check and the insert are one
// step per instance.
func (q *Queries) LockLifecycleInstance(ctx context.Context, instanceID string) error {
_, err := q.db.ExecContext(ctx, lockLifecycleInstance, instanceID)
return err
}
const mintInstanceID = `-- name: MintInstanceID :one
SELECT uuidv7()::uuid AS instance_id
`
// A new instance's identifier comes from the database, the only source of
// UUIDs (design D25).
func (q *Queries) MintInstanceID(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, mintInstanceID)
var instance_id string
err := row.Scan(&instance_id)
return instance_id, err
}
const mintLifecycleGeneration = `-- name: MintLifecycleGeneration :one
SELECT core.next_generation(NULL, NULL)::bigint AS generation
`
// A generation for a request on an instance that has no request yet,
// floored by the clock (design D25).
func (q *Queries) MintLifecycleGeneration(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, mintLifecycleGeneration)
var generation int64
err := row.Scan(&generation)
return generation, err
}
const nextLifecycleGenerations = `-- name: NextLifecycleGenerations :many
SELECT ids.instance_id::uuid AS instance_id,
core.next_generation(MAX(lr.generation), NULL)::bigint AS next_generation
FROM unnest($1::uuid[]) AS ids(instance_id)
LEFT JOIN core.lifecycle_requests lr ON lr.instance_id = ids.instance_id
GROUP BY ids.instance_id
`
type NextLifecycleGenerationsRow struct {
InstanceID string `json:"instance_id"`
NextGeneration int64 `json:"next_generation"`
}
// The generation each instance's next request carries: above its latest
// request and at least the clock, one row per instance asked for.
func (q *Queries) NextLifecycleGenerations(ctx context.Context, instanceIds []string) ([]NextLifecycleGenerationsRow, error) {
rows, err := q.db.QueryContext(ctx, nextLifecycleGenerations, pq.Array(instanceIds))
if err != nil {
return nil, err
}
defer rows.Close()
items := []NextLifecycleGenerationsRow{}
for rows.Next() {
var i NextLifecycleGenerationsRow
if err := rows.Scan(&i.InstanceID, &i.NextGeneration); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const openLifecycleGeneration = `-- name: OpenLifecycleGeneration :one
SELECT COALESCE(MAX(generation), 0)::bigint AS open_generation
FROM core.lifecycle_requests
WHERE instance_id = $1::uuid AND status = 'accepted'
`
// The generation of the instance's accepted request, 0 when none is open.
// Read under LockLifecycleInstance, so the one-open rule is answered before
// the insert instead of by a unique violation that would abort the caller's
// transaction.
func (q *Queries) OpenLifecycleGeneration(ctx context.Context, instanceID string) (int64, error) {
row := q.db.QueryRowContext(ctx, openLifecycleGeneration, instanceID)
var open_generation int64
err := row.Scan(&open_generation)
return open_generation, err
}
const openLifecycleRequestsByWorkspace = `-- name: OpenLifecycleRequestsByWorkspace :many
SELECT lr.request_id, lr.instance_id, lr.generation, lr.connection_id, lr.resource_key, lr.workspace_id, lr.operation, lr.parameters, lr.status, lr.reason_code, lr.workflow_id, lr.requested_by_person_id, lr.created_at, lr.updated_at, lr.finished_at, lr.acknowledged_at FROM core.lifecycle_requests lr
WHERE lr.workspace_id = $1::uuid
AND lr.connection_id = $2::uuid
AND (lr.status = 'accepted'
OR (lr.status IN ('declined', 'failed')
AND lr.acknowledged_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM core.lifecycle_requests later
WHERE later.instance_id = lr.instance_id
AND later.generation > lr.generation)))
ORDER BY lr.created_at, lr.request_id
`
type OpenLifecycleRequestsByWorkspaceParams struct {
WorkspaceID string `json:"workspace_id"`
ConnectionID string `json:"connection_id"`
}
// What a workspace's list shows for one connection: every accepted request,
// and per instance the latest request when it is declined or failed and not
// acknowledged (a later request on the instance supersedes it).
func (q *Queries) OpenLifecycleRequestsByWorkspace(ctx context.Context, arg OpenLifecycleRequestsByWorkspaceParams) ([]LifecycleRequest, error) {
rows, err := q.db.QueryContext(ctx, openLifecycleRequestsByWorkspace, arg.WorkspaceID, arg.ConnectionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []LifecycleRequest{}
for rows.Next() {
var i LifecycleRequest
if err := rows.Scan(
&i.RequestID,
&i.InstanceID,
&i.Generation,
&i.ConnectionID,
&i.ResourceKey,
&i.WorkspaceID,
&i.Operation,
&i.Parameters,
&i.Status,
&i.ReasonCode,
&i.WorkflowID,
&i.RequestedByPersonID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FinishedAt,
&i.AcknowledgedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+74
View File
@@ -9,7 +9,10 @@ package integration
import (
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
type ConfigOverride struct {
@@ -19,6 +22,63 @@ type ConfigOverride struct {
UpdatedAt time.Time `json:"updated_at"`
}
// What the console wants one application to carry out for one recipient, per connection, with a generation that rises when the content changes. Rows are never deleted.
type DesiredStateRecord struct {
RecordID string `json:"record_id"`
ConnectionID string `json:"connection_id"`
PersonID uuid.NullUUID `json:"person_id"`
OrgID uuid.NullUUID `json:"org_id"`
Content json.RawMessage `json:"content"`
ContentHash []byte `json:"content_hash"`
Generation int64 `json:"generation"`
GenerationAt time.Time `json:"generation_at"`
LastDispatchedAt sql.NullTime `json:"last_dispatched_at"`
LastPushError sql.NullString `json:"last_push_error"`
LastPushFailedAt sql.NullTime `json:"last_push_failed_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// One request a person made for one instance (create, change status, delete), addressed by the console's instance identifier and a generation.
type LifecycleRequest struct {
RequestID string `json:"request_id"`
InstanceID string `json:"instance_id"`
Generation int64 `json:"generation"`
ConnectionID string `json:"connection_id"`
ResourceKey string `json:"resource_key"`
WorkspaceID string `json:"workspace_id"`
Operation string `json:"operation"`
// Identifiers and public DNS names only. A personal field added later moves this column to the withheld list in scripts/db-reader-role.sql in the same change.
Parameters json.RawMessage `json:"parameters"`
Status string `json:"status"`
ReasonCode sql.NullString `json:"reason_code"`
WorkflowID string `json:"workflow_id"`
RequestedByPersonID uuid.NullUUID `json:"requested_by_person_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FinishedAt sql.NullTime `json:"finished_at"`
AcknowledgedAt sql.NullTime `json:"acknowledged_at"`
}
// What an application last reported holding for one recipient at one connection, with the generation it applied. Not an instance list.
type ObservedProjection struct {
ProjectionID string `json:"projection_id"`
ConnectionID string `json:"connection_id"`
PersonID uuid.NullUUID `json:"person_id"`
OrgID uuid.NullUUID `json:"org_id"`
AppliedGeneration sql.NullInt64 `json:"applied_generation"`
AppliedContentHash []byte `json:"applied_content_hash"`
Observed json.RawMessage `json:"observed"`
Result sql.NullString `json:"result"`
ResultGeneration sql.NullInt64 `json:"result_generation"`
NextAction sql.NullString `json:"next_action"`
ObservedAt time.Time `json:"observed_at"`
AheadGeneration sql.NullInt64 `json:"ahead_generation"`
AheadAt sql.NullTime `json:"ahead_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Provider struct {
Provider string `json:"provider"`
ProviderKind string `json:"provider_kind"`
@@ -30,3 +90,17 @@ type Provider struct {
SuspendedAt sql.NullTime `json:"suspended_at"`
RetiredAt sql.NullTime `json:"retired_at"`
}
// One registered link between the console and one running application, beneath the provider that records the application's type. One per provider until a screen can show several.
type ProviderConnection struct {
ConnectionID string `json:"connection_id"`
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
Status string `json:"status"`
SuspendedAt sql.NullTime `json:"suspended_at"`
RetiredAt sql.NullTime `json:"retired_at"`
ApprovalState string `json:"approval_state"`
ApprovedAt sql.NullTime `json:"approved_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+301
View File
@@ -0,0 +1,301 @@
// 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: observed.sql
package integration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
const getObservedProjection = `-- name: GetObservedProjection :one
SELECT projection_id, connection_id, person_id, org_id, applied_generation, applied_content_hash, observed, result, result_generation, next_action, observed_at, ahead_generation, ahead_at, created_at, updated_at FROM core.observed_projections op
WHERE (op.connection_id = $1::uuid AND op.person_id = $2::uuid)
OR (op.connection_id = $1::uuid AND op.org_id = $3::uuid)
`
type GetObservedProjectionParams struct {
ConnectionID string `json:"connection_id"`
PersonID uuid.NullUUID `json:"person_id"`
OrgID uuid.NullUUID `json:"org_id"`
}
// The projection at one address; exactly one of person_id and org_id is set.
// sql.ErrNoRows when the application has never answered for the recipient.
func (q *Queries) GetObservedProjection(ctx context.Context, arg GetObservedProjectionParams) (ObservedProjection, error) {
row := q.db.QueryRowContext(ctx, getObservedProjection, arg.ConnectionID, arg.PersonID, arg.OrgID)
var i ObservedProjection
err := row.Scan(
&i.ProjectionID,
&i.ConnectionID,
&i.PersonID,
&i.OrgID,
&i.AppliedGeneration,
&i.AppliedContentHash,
&i.Observed,
&i.Result,
&i.ResultGeneration,
&i.NextAction,
&i.ObservedAt,
&i.AheadGeneration,
&i.AheadAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const markObservedProjectionAheadForOrganization = `-- name: MarkObservedProjectionAheadForOrganization :exec
INSERT INTO core.observed_projections
(connection_id, org_id, observed_at, ahead_generation, ahead_at)
VALUES ($1::uuid, $2::uuid, NOW(),
$3::bigint, NOW())
ON CONFLICT (connection_id, org_id) DO UPDATE SET
ahead_generation = excluded.ahead_generation,
ahead_at = excluded.ahead_at
`
type MarkObservedProjectionAheadForOrganizationParams struct {
ConnectionID string `json:"connection_id"`
OrgID string `json:"org_id"`
AheadGeneration int64 `json:"ahead_generation"`
}
// The restore marker for an organization; as the person form.
func (q *Queries) MarkObservedProjectionAheadForOrganization(ctx context.Context, arg MarkObservedProjectionAheadForOrganizationParams) error {
_, err := q.db.ExecContext(ctx, markObservedProjectionAheadForOrganization, arg.ConnectionID, arg.OrgID, arg.AheadGeneration)
return err
}
const markObservedProjectionAheadForPerson = `-- name: MarkObservedProjectionAheadForPerson :exec
INSERT INTO core.observed_projections
(connection_id, person_id, observed_at, ahead_generation, ahead_at)
VALUES ($1::uuid, $2::uuid, NOW(),
$3::bigint, NOW())
ON CONFLICT (connection_id, person_id) DO UPDATE SET
ahead_generation = excluded.ahead_generation,
ahead_at = excluded.ahead_at
`
type MarkObservedProjectionAheadForPersonParams struct {
ConnectionID string `json:"connection_id"`
PersonID string `json:"person_id"`
AheadGeneration int64 `json:"ahead_generation"`
}
// The restore marker (design D19): the application reported a generation
// above the record's, or the record's own generation with another hash. Only
// the latest occurrence is kept.
func (q *Queries) MarkObservedProjectionAheadForPerson(ctx context.Context, arg MarkObservedProjectionAheadForPersonParams) error {
_, err := q.db.ExecContext(ctx, markObservedProjectionAheadForPerson, arg.ConnectionID, arg.PersonID, arg.AheadGeneration)
return err
}
const recordPushResultForOrganization = `-- name: RecordPushResultForOrganization :exec
INSERT INTO core.observed_projections
(connection_id, org_id, applied_generation, applied_content_hash,
result, result_generation, next_action, observed_at)
VALUES ($1::uuid, $2::uuid,
$3::bigint, $4::bytea,
$5::text, $6::bigint,
$7::text, $8::timestamptz)
ON CONFLICT (connection_id, org_id) DO UPDATE SET
result = excluded.result,
result_generation = excluded.result_generation,
next_action = excluded.next_action,
observed_at = excluded.observed_at,
applied_content_hash = CASE
WHEN excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
THEN excluded.applied_content_hash
ELSE observed_projections.applied_content_hash END,
applied_generation = CASE
WHEN excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
THEN excluded.applied_generation
ELSE observed_projections.applied_generation END
`
type RecordPushResultForOrganizationParams struct {
ConnectionID string `json:"connection_id"`
OrgID string `json:"org_id"`
AppliedGeneration sql.NullInt64 `json:"applied_generation"`
AppliedContentHash []byte `json:"applied_content_hash"`
Result string `json:"result"`
ResultGeneration int64 `json:"result_generation"`
NextAction sql.NullString `json:"next_action"`
ObservedAt time.Time `json:"observed_at"`
}
// A push answer for an organization; as the person form.
func (q *Queries) RecordPushResultForOrganization(ctx context.Context, arg RecordPushResultForOrganizationParams) error {
_, err := q.db.ExecContext(ctx, recordPushResultForOrganization,
arg.ConnectionID,
arg.OrgID,
arg.AppliedGeneration,
arg.AppliedContentHash,
arg.Result,
arg.ResultGeneration,
arg.NextAction,
arg.ObservedAt,
)
return err
}
const recordPushResultForPerson = `-- name: RecordPushResultForPerson :exec
INSERT INTO core.observed_projections
(connection_id, person_id, applied_generation, applied_content_hash,
result, result_generation, next_action, observed_at)
VALUES ($1::uuid, $2::uuid,
$3::bigint, $4::bytea,
$5::text, $6::bigint,
$7::text, $8::timestamptz)
ON CONFLICT (connection_id, person_id) DO UPDATE SET
result = excluded.result,
result_generation = excluded.result_generation,
next_action = excluded.next_action,
observed_at = excluded.observed_at,
applied_content_hash = CASE
WHEN excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
THEN excluded.applied_content_hash
ELSE observed_projections.applied_content_hash END,
applied_generation = CASE
WHEN excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
THEN excluded.applied_generation
ELSE observed_projections.applied_generation END
`
type RecordPushResultForPersonParams struct {
ConnectionID string `json:"connection_id"`
PersonID string `json:"person_id"`
AppliedGeneration sql.NullInt64 `json:"applied_generation"`
AppliedContentHash []byte `json:"applied_content_hash"`
Result string `json:"result"`
ResultGeneration int64 `json:"result_generation"`
NextAction sql.NullString `json:"next_action"`
ObservedAt time.Time `json:"observed_at"`
}
// A push answer for a person: the result, the generation it was about and
// the next-action key always replace what was stored; the applied generation
// and its hash move together and only upwards. An applied answer reports the
// pushed generation and hash; an unlinked one reports the generation the
// application still holds and no hash.
func (q *Queries) RecordPushResultForPerson(ctx context.Context, arg RecordPushResultForPersonParams) error {
_, err := q.db.ExecContext(ctx, recordPushResultForPerson,
arg.ConnectionID,
arg.PersonID,
arg.AppliedGeneration,
arg.AppliedContentHash,
arg.Result,
arg.ResultGeneration,
arg.NextAction,
arg.ObservedAt,
)
return err
}
const upsertObservedProjectionForOrganization = `-- name: UpsertObservedProjectionForOrganization :execrows
INSERT INTO core.observed_projections
(connection_id, org_id, applied_generation, applied_content_hash, observed, observed_at)
VALUES ($1::uuid, $2::uuid,
$3::bigint, $4::bytea,
$5::jsonb, $6::timestamptz)
ON CONFLICT (connection_id, org_id) DO UPDATE SET
applied_generation = excluded.applied_generation,
applied_content_hash = excluded.applied_content_hash,
observed = excluded.observed,
observed_at = excluded.observed_at
WHERE excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
`
type UpsertObservedProjectionForOrganizationParams struct {
ConnectionID string `json:"connection_id"`
OrgID string `json:"org_id"`
AppliedGeneration sql.NullInt64 `json:"applied_generation"`
AppliedContentHash []byte `json:"applied_content_hash"`
Observed json.RawMessage `json:"observed"`
ObservedAt time.Time `json:"observed_at"`
}
// A read-back item for an organization; as the person form.
func (q *Queries) UpsertObservedProjectionForOrganization(ctx context.Context, arg UpsertObservedProjectionForOrganizationParams) (int64, error) {
result, err := q.db.ExecContext(ctx, upsertObservedProjectionForOrganization,
arg.ConnectionID,
arg.OrgID,
arg.AppliedGeneration,
arg.AppliedContentHash,
arg.Observed,
arg.ObservedAt,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertObservedProjectionForPerson = `-- name: UpsertObservedProjectionForPerson :execrows
INSERT INTO core.observed_projections
(connection_id, person_id, applied_generation, applied_content_hash, observed, observed_at)
VALUES ($1::uuid, $2::uuid,
$3::bigint, $4::bytea,
$5::jsonb, $6::timestamptz)
ON CONFLICT (connection_id, person_id) DO UPDATE SET
applied_generation = excluded.applied_generation,
applied_content_hash = excluded.applied_content_hash,
observed = excluded.observed,
observed_at = excluded.observed_at
WHERE excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
`
type UpsertObservedProjectionForPersonParams struct {
ConnectionID string `json:"connection_id"`
PersonID string `json:"person_id"`
AppliedGeneration sql.NullInt64 `json:"applied_generation"`
AppliedContentHash []byte `json:"applied_content_hash"`
Observed json.RawMessage `json:"observed"`
ObservedAt time.Time `json:"observed_at"`
}
// Observed projections (design D10, D19, D21). Every statement here runs in a
// transaction that already holds the address's advisory lock and the record
// row, so the projection row is the last lock taken. Each write comes in a
// person and an organization form because an upsert names one unique
// constraint as its conflict target.
//
// A read-back item for a person. The stored applied generation is never
// lowered: a page fetched before a push was applied can arrive after the push
// answer, and then changes nothing.
func (q *Queries) UpsertObservedProjectionForPerson(ctx context.Context, arg UpsertObservedProjectionForPersonParams) (int64, error) {
result, err := q.db.ExecContext(ctx, upsertObservedProjectionForPerson,
arg.ConnectionID,
arg.PersonID,
arg.AppliedGeneration,
arg.AppliedContentHash,
arg.Observed,
arg.ObservedAt,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+19 -12
View File
@@ -152,36 +152,43 @@ func (q *Queries) SetProviderStatus(ctx context.Context, arg SetProviderStatusPa
return err
}
const setResourceKeyProvider = `-- name: SetResourceKeyProvider :execrows
const setResourceKeyProvider = `-- name: SetResourceKeyProvider :one
UPDATE core.resource_keys
SET provider = $2,
over_limit_behavior = $3,
over_limit_consequence = $4
recipient = $3,
over_limit_behavior = $4,
over_limit_consequence = $5
WHERE resource_key = $1
RETURNING (OLD.provider IS DISTINCT FROM NEW.provider
OR OLD.recipient IS DISTINCT FROM NEW.recipient)::boolean AS changed
`
type SetResourceKeyProviderParams struct {
ResourceKey string `json:"resource_key"`
Provider sql.NullString `json:"provider"`
Recipient sql.NullString `json:"recipient"`
OverLimitBehavior string `json:"over_limit_behavior"`
OverLimitConsequence sql.NullString `json:"over_limit_consequence"`
}
// Stamp provider ownership on a resource key (cross-schema write, enabled by the
// Stamp provider ownership, the declared recipient and the over-limit pair on
// a resource key in one write (cross-schema write, enabled by the
// resource_keys.provider FK + this package's core_writer grant on the core
// schema). Returns rows affected so a manifest referencing a non-existent key
// can be detected at registration.
func (q *Queries) SetResourceKeyProvider(ctx context.Context, arg SetResourceKeyProviderParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setResourceKeyProvider,
// schema). Returns whether the stamp changed the key's provider or recipient,
// the two values that decide which records carry the key, so registration can
// re-approve the provider's connection. No row (sql.ErrNoRows) means the
// manifest names a key that does not exist.
func (q *Queries) SetResourceKeyProvider(ctx context.Context, arg SetResourceKeyProviderParams) (bool, error) {
row := q.db.QueryRowContext(ctx, setResourceKeyProvider,
arg.ResourceKey,
arg.Provider,
arg.Recipient,
arg.OverLimitBehavior,
arg.OverLimitConsequence,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
var changed bool
err := row.Scan(&changed)
return changed, err
}
const upsertProvider = `-- name: UpsertProvider :one
+252 -4
View File
@@ -12,8 +12,21 @@ import (
)
type Querier interface {
// The member dismissed a declined or failed outcome in their workspace.
AcknowledgeLifecycleRequest(ctx context.Context, arg AcknowledgeLifecycleRequestParams) (int64, error)
AddProviderOperation(ctx context.Context, arg AddProviderOperationParams) error
AddProviderState(ctx context.Context, arg AddProviderStateParams) error
// The relay's claim (design D16): up to a batch of pending rows in identifier
// order whose lease is absent or expired, leased for lease_seconds and
// committed before any record is recomputed. SKIP LOCKED lets a retried
// activity pass over rows an earlier attempt still holds. RETURNING keeps no
// order of its own, so the claimed rows are returned sorted by identifier.
ClaimDesiredStateOutbox(ctx context.Context, arg ClaimDesiredStateOutboxParams) ([]ClaimDesiredStateOutboxRow, error)
// An applied answer clears the push error; last_push_failed_at keeps the time
// of the last failure.
ClearDesiredStatePushError(ctx context.Context, recordID string) (int64, error)
// Rows whose expansion is finished.
CompleteDesiredStateOutboxRows(ctx context.Context, ids []string) (int64, error)
// Operator overview "System" panel: the health of the integration delivery
// queue in one round trip.
//
@@ -30,14 +43,146 @@ type Querier interface {
// outbox.go); this is a read-only health probe over the same table.
CountOutboxByStatus(ctx context.Context) (CountOutboxByStatusRow, error)
DeleteConfigOverride(ctx context.Context, key string) (int64, error)
// Retention: completed rows older than the window, in a bounded batch.
// Pending and failed rows are never deleted here.
DeleteExpiredDesiredStateOutboxRows(ctx context.Context, arg DeleteExpiredDesiredStateOutboxRowsParams) (int64, error)
// Boot reconciliation: drop operations a provider no longer declares.
DeleteProviderOperationsNotIn(ctx context.Context, arg DeleteProviderOperationsNotInParams) error
// Boot reconciliation: drop states a provider no longer declares.
DeleteProviderStatesNotIn(ctx context.Context, arg DeleteProviderStatesNotInParams) error
// A handler whose workflow start was refused with NotFound deletes the row it
// inserted, so the member's retry is a fresh insert. Only an accepted row in
// the caller's workspace is deleted.
DeleteUnstartedLifecycleRequest(ctx context.Context, arg DeleteUnstartedLifecycleRequestParams) (int64, error)
// A record's content under the recipient rules, its hash computed as the
// stored column computes it, and how many entitlements it carries (0 is the
// empty record).
DesiredStateContent(ctx context.Context, arg DesiredStateContentParams) (DesiredStateContentRow, error)
// Pending rows and the age of the oldest, in milliseconds by the database's
// clock (0 when nothing is pending), for the boot log without Temporal.
DesiredStateOutboxBacklog(ctx context.Context) (DesiredStateOutboxBacklogRow, error)
// Whether a read-back item names a person or organization the console knows.
// An item for an unknown recipient is counted and logged, never stored.
DesiredStateRecipientExists(ctx context.Context, arg DesiredStateRecipientExistsParams) (bool, error)
// Boot registration: insert the one approved connection of an in-tree
// provider that owns keys. The database chooses connection_id. An existing
// row, whatever its status, is never touched: its status, approval and
// display name are the operator's, and the unique index on provider makes a
// second boot, or two replicas booting together, insert nothing. No row
// (sql.ErrNoRows) means the connection already existed. The insert of an
// approved row fires the connection trigger, which writes the connection's
// desired-state outbox row (the first fill).
EnsureInTreeConnection(ctx context.Context, arg EnsureInTreeConnectionParams) (string, error)
// A connection row reaches every recipient at that connection: the active
// organizations whose default pool holds one of the provider's
// organization-recipient keys, the persons who would hold one of its
// person-recipient keys (owners and active members of active organizations
// whose pools hold it), and every recipient that already has a record there,
// so a record that must empty is recomputed too. When the row names a key (a
// target-mapping edit), the existing records are those that carry that key.
ExpandDesiredStateConnection(ctx context.Context, arg ExpandDesiredStateConnectionParams) ([]ExpandDesiredStateConnectionRow, error)
// An organization row reaches the organization's record and its owner's and
// active members' records.
ExpandDesiredStateOrganization(ctx context.Context, arg ExpandDesiredStateOrganizationParams) ([]ExpandDesiredStateOrganizationRow, error)
// ---------------------------------------------------------------------------
// Expansion (design D16). Each query returns the records a row can affect as
// (recipient_kind, recipient_id, connection_id), at connections that are
// approved and not retired and whose provider owns a key with the matching
// declared recipient (and the row's key, when it names one). A key whose
// recipient is NULL reaches nothing. Results are paged by recipient: the
// page holds the first page_size recipients after the cursor
// (after_kind, after_id) in (kind, id) order, with every connection of each,
// so a recipient is never split across pages. A page holding fewer than
// page_size recipients is the last.
// ---------------------------------------------------------------------------
// A pool row reaches the pool's organization for an organization-recipient
// key, and the organization's owner and active members for a person-recipient
// key.
ExpandDesiredStatePool(ctx context.Context, arg ExpandDesiredStatePoolParams) ([]ExpandDesiredStatePoolRow, error)
// One recipient's own records: a person row (recipient_kind 'person'), and a
// recipient_only row for a person or an organization, which for an
// organization does not reach its members. At most one recipient, so the
// result is always a single page.
ExpandDesiredStateRecipient(ctx context.Context, arg ExpandDesiredStateRecipientParams) ([]ExpandDesiredStateRecipientRow, error)
// Record a request's outcome. Repeating the same outcome (a retried local
// activity whose first attempt committed) matches again and keeps the first
// finish time; a missing request, or one finished with another outcome,
// matches nothing and returns sql.ErrNoRows, which fails the activity.
FinishLifecycleRequest(ctx context.Context, arg FinishLifecycleRequestParams) (string, error)
// The bound on a generation an application reports (design D8): the clock
// floor plus one day. A report above it is a protocol error and is never
// used as a floor.
GenerationCeiling(ctx context.Context) (int64, error)
// The one connection beneath a provider (uq_provider_connections_one_per_provider).
GetConnectionByProvider(ctx context.Context, provider string) (ProviderConnection, error)
GetDesiredStateRecord(ctx context.Context, recordID string) (DesiredStateRecord, error)
// An instance's first request. A create is always its instance's first
// request, so the JSON API finds a create by its instance alone (design D25).
GetFirstLifecycleRequest(ctx context.Context, instanceID string) (LifecycleRequest, error)
GetLifecycleRequestByAddress(ctx context.Context, arg GetLifecycleRequestByAddressParams) (LifecycleRequest, error)
// The identity-provider subject of the person who made a request, read when
// a workflow must compare it with an application's owner record without
// carrying it in its input (design D27's create reconcile).
GetLifecycleRequesterSubject(ctx context.Context, arg GetLifecycleRequesterSubjectParams) (string, error)
// The projection at one address; exactly one of person_id and org_id is set.
// sql.ErrNoRows when the application has never answered for the recipient.
GetObservedProjection(ctx context.Context, arg GetObservedProjectionParams) (ObservedProjection, error)
GetProvider(ctx context.Context, provider string) (Provider, error)
// Checked by every read-back page before it calls the application: whether
// the connection may still be read back (approved and active, of an active
// provider, as a scheduled read-back requires), and which recipient kinds
// its provider owns keys for. No row means the connection is gone.
GetReadBackConnection(ctx context.Context, connectionID string) (GetReadBackConnectionRow, error)
// Create the record at an address that has none: when its content is first
// non-empty, or at a reported floor (design D19). Exactly one of person_id
// and org_id is set.
InsertDesiredStateRecord(ctx context.Context, arg InsertDesiredStateRecordParams) (InsertDesiredStateRecordRow, error)
// Insert a request at its address. The operation must be one the
// connection's provider declares in core.provider_operations, and the
// provider must own the resource key; otherwise nothing is inserted. A held
// address also inserts nothing (ON CONFLICT on the address only), so
// sql.ErrNoRows sends the caller to GetLifecycleRequestByAddress: a row there
// is a repeat or a conflict, no row means the operation or key was refused.
// A second accepted request on the instance under another generation raises
// a unique violation on uq_lifecycle_requests_one_open.
InsertLifecycleRequest(ctx context.Context, arg InsertLifecycleRequestParams) (string, error)
// The highest generation recorded for an instance, 0 when it has none.
LatestLifecycleGeneration(ctx context.Context, instanceID string) (int64, error)
// The highest generation recorded for each of a set of instances; an
// instance with no request is absent.
LatestLifecycleGenerations(ctx context.Context, instanceIds []string) ([]LatestLifecycleGenerationsRow, error)
// The boot pass (design D27): accepted requests at one connection older than
// the given age, whose workflow may never have started or may have closed
// without recording an outcome.
ListAcceptedLifecycleRequestsOlderThan(ctx context.Context, arg ListAcceptedLifecycleRequestsOlderThanParams) ([]LifecycleRequest, error)
ListAllProviderOperations(ctx context.Context) ([]ListAllProviderOperationsRow, error)
ListAllProviderStates(ctx context.Context) ([]ListAllProviderStatesRow, error)
ListConfigOverrides(ctx context.Context) ([]ConfigOverride, error)
// Connections the relay may deliver to and the read-back may schedule:
// approved and not retired. The connection's and its provider's status are
// returned so the caller decides whether a push or a schedule may run
// (both must be active); a suspended connection still gets its records
// recomputed.
ListDeliverableConnections(ctx context.Context) ([]ListDeliverableConnectionsRow, error)
// Delivered, waiting and lag for every record of one connection, computed
// from the record and its projection and never stored (design D10). A record
// is delivered when the application applied its generation, and its hash
// when it reported one; it waits for an account when the application
// answered unlinked for its current generation; any other record is lag,
// aged from generation_at, but only at a connection a transport serves
// (served), because a record nothing could carry is not behind.
ListDesiredStateDelivery(ctx context.Context, arg ListDesiredStateDeliveryParams) ([]ListDesiredStateDeliveryRow, error)
// Of the given records, those a push may go out for: at a connection a
// transport serves (served_connection_ids), approved and active, of an active
// provider, and neither delivered (the application applied the record's
// generation, and its hash when it reported one) nor waiting for an account
// (the application answered unlinked for the current generation).
ListDesiredStateRecordsToPush(ctx context.Context, arg ListDesiredStateRecordsToPushParams) ([]string, error)
// The lag sweep (design D18): records at the served connections that need a
// push, excluding those dispatched or failed within the cooldown, oldest and
// never-dispatched first so records rotate past the per-tick cap. The caller
// skips the query when no transport serves any connection.
ListLaggingDesiredStateRecords(ctx context.Context, arg ListLaggingDesiredStateRecordsParams) ([]string, error)
// Platform-owned (unprefixed) resource keys, for the provider-key hygiene
// nesting check.
ListPlatformResourceKeys(ctx context.Context) ([]string, error)
@@ -45,14 +190,117 @@ type Querier interface {
ListProviderStates(ctx context.Context, provider string) ([]string, error)
ListProviders(ctx context.Context) ([]Provider, error)
ListProvidersByKind(ctx context.Context, providerKind string) ([]Provider, error)
// ---------------------------------------------------------------------------
// Recomputing one record (design D9), in one READ COMMITTED transaction, in
// this order: LockDesiredStateAddress, LockDesiredStateRecord, then
// DesiredStateContent in a new statement, then UpdateDesiredStateRecord or
// InsertDesiredStateRecord. Every transaction that writes a record's content
// or generation, or a projection, takes the address lock first.
// ---------------------------------------------------------------------------
// The serialization point for one (connection, recipient) address, including
// one that has no record yet. recipient_kind is 'person' or 'organization'.
LockDesiredStateAddress(ctx context.Context, arg LockDesiredStateAddressParams) error
// The record at one address, locked; exactly one of person_id and org_id is
// set. sql.ErrNoRows when the address has no record.
LockDesiredStateRecord(ctx context.Context, arg LockDesiredStateRecordParams) (DesiredStateRecord, error)
// Lifecycle requests (design D24 to D28). Recording a request runs, in one
// transaction: LockLifecycleInstance, LatestLifecycleGeneration,
// InsertLifecycleRequest, then GetLifecycleRequestByAddress when the insert
// returned nothing, so the latest-generation check and the insert are one
// step per instance.
LockLifecycleInstance(ctx context.Context, instanceID string) error
// A failed attempt on a row: for a wide row, its expansion query failed; for
// a recipient_only row, its recompute failed. The row becomes failed (the
// dead letter) when attempts reaches max_attempts and otherwise stays
// pending. The lease is kept, so the retry waits for it to expire.
MarkDesiredStateOutboxAttempt(ctx context.Context, arg MarkDesiredStateOutboxAttemptParams) (MarkDesiredStateOutboxAttemptRow, error)
// The restore marker for an organization; as the person form.
MarkObservedProjectionAheadForOrganization(ctx context.Context, arg MarkObservedProjectionAheadForOrganizationParams) error
// The restore marker (design D19): the application reported a generation
// above the record's, or the record's own generation with another hash. Only
// the latest occurrence is kept.
MarkObservedProjectionAheadForPerson(ctx context.Context, arg MarkObservedProjectionAheadForPersonParams) error
// A new instance's identifier comes from the database, the only source of
// UUIDs (design D25).
MintInstanceID(ctx context.Context) (string, error)
// A generation for a request on an instance that has no request yet,
// floored by the clock (design D25).
MintLifecycleGeneration(ctx context.Context) (int64, error)
// The generation each instance's next request carries: above its latest
// request and at least the clock, one row per instance asked for.
NextLifecycleGenerations(ctx context.Context, instanceIds []string) ([]NextLifecycleGenerationsRow, error)
// The generation of the instance's accepted request, 0 when none is open.
// Read under LockLifecycleInstance, so the one-open rule is answered before
// the insert instead of by a unique violation that would abort the caller's
// transaction.
OpenLifecycleGeneration(ctx context.Context, instanceID string) (int64, error)
// What a workspace's list shows for one connection: every accepted request,
// and per instance the latest request when it is declined or failed and not
// acknowledged (a later request on the instance supersedes it).
OpenLifecycleRequestsByWorkspace(ctx context.Context, arg OpenLifecycleRequestsByWorkspaceParams) ([]LifecycleRequest, error)
// Boot registration: an in-tree deploy that changed an owned key's provider or
// recipient is the approval of a manifest change. Refreshing approved_at fires
// the connection trigger, so the relay recomputes every record of the
// connection. A pending connection is left pending.
ReapproveConnection(ctx context.Context, provider string) (int64, error)
// A push answer for an organization; as the person form.
RecordPushResultForOrganization(ctx context.Context, arg RecordPushResultForOrganizationParams) error
// A push answer for a person: the result, the generation it was about and
// the next-action key always replace what was stored; the applied generation
// and its hash move together and only upwards. An applied answer reports the
// pushed generation and hash; an unlinked one reports the generation the
// application still holds and no hash.
RecordPushResultForPerson(ctx context.Context, arg RecordPushResultForPersonParams) error
// A row whose expansion is not finished keeps its place: the cursor records
// the last recipient processed ("<kind>:<id>") and the lease is released in
// the same statement, so the next tick claims the row at once.
ReleaseDesiredStateOutboxRow(ctx context.Context, arg ReleaseDesiredStateOutboxRowParams) (int64, error)
// One recipient whose recompute failed inside a wide row gets a
// recipient_only row of its own with one attempt counted (design D16). The
// function refuses any subject but an existing person or organization.
RequeueDesiredState(ctx context.Context, arg RequeueDesiredStateParams) (string, error)
// A spent push budget or a protocol error: the transport's error class and
// the console's own text, never the application's.
SetDesiredStatePushFailure(ctx context.Context, arg SetDesiredStatePushFailureParams) (int64, error)
// Operational mutation (operator-editable), distinct from capability registration.
SetProviderStatus(ctx context.Context, arg SetProviderStatusParams) error
// Stamp provider ownership on a resource key (cross-schema write, enabled by the
// Stamp provider ownership, the declared recipient and the over-limit pair on
// a resource key in one write (cross-schema write, enabled by the
// resource_keys.provider FK + this package's core_writer grant on the core
// schema). Returns rows affected so a manifest referencing a non-existent key
// can be detected at registration.
SetResourceKeyProvider(ctx context.Context, arg SetResourceKeyProviderParams) (int64, error)
// schema). Returns whether the stamp changed the key's provider or recipient,
// the two values that decide which records carry the key, so registration can
// re-approve the provider's connection. No row (sql.ErrNoRows) means the
// manifest names a key that does not exist.
SetResourceKeyProvider(ctx context.Context, arg SetResourceKeyProviderParams) (bool, error)
// ---------------------------------------------------------------------------
// Push bookkeeping (design D18): what the console did, on the record. Each
// statement holds one row lock and waits for nothing else.
// ---------------------------------------------------------------------------
// Locks the records in identifier order before stamping them, whatever plan
// the update would take, so two stamps over overlapping records never wait
// on each other in a cycle (design D14).
StampDesiredStateRecordsDispatched(ctx context.Context, recordIds []string) (int64, error)
// Claimed rows an attempt did not reach before its record budget was spent:
// the lease is released and nothing else changes (cursor, attempts), so the
// next tick claims them at once.
UnclaimDesiredStateOutboxRows(ctx context.Context, ids []string) (int64, error)
// Store new content, or raise above a reported generation (floor), with the
// generation from core.next_generation and generation_at now. The guard
// trigger refuses a falling generation and a content change without a raise.
UpdateDesiredStateRecord(ctx context.Context, arg UpdateDesiredStateRecordParams) (UpdateDesiredStateRecordRow, error)
UpsertConfigOverride(ctx context.Context, arg UpsertConfigOverrideParams) error
// A read-back item for an organization; as the person form.
UpsertObservedProjectionForOrganization(ctx context.Context, arg UpsertObservedProjectionForOrganizationParams) (int64, error)
// Observed projections (design D10, D19, D21). Every statement here runs in a
// transaction that already holds the address's advisory lock and the record
// row, so the projection row is the last lock taken. Each write comes in a
// person and an organization form because an upsert names one unique
// constraint as its conflict target.
//
// A read-back item for a person. The stored applied generation is never
// lowered: a page fetched before a push was applied can arrive after the push
// answer, and then changes nothing.
UpsertObservedProjectionForPerson(ctx context.Context, arg UpsertObservedProjectionForPersonParams) (int64, error)
// Boot-time capability registration. On re-registration, capability fields
// (kind, display_name) are refreshed; operational `status` is preserved.
UpsertProvider(ctx context.Context, arg UpsertProviderParams) (Provider, error)
@@ -0,0 +1,42 @@
-- name: EnsureInTreeConnection :one
-- Boot registration: insert the one approved connection of an in-tree
-- provider that owns keys. The database chooses connection_id. An existing
-- row, whatever its status, is never touched: its status, approval and
-- display name are the operator's, and the unique index on provider makes a
-- second boot, or two replicas booting together, insert nothing. No row
-- (sql.ErrNoRows) means the connection already existed. The insert of an
-- approved row fires the connection trigger, which writes the connection's
-- desired-state outbox row (the first fill).
INSERT INTO core.provider_connections (provider, display_name, approval_state, approved_at)
VALUES ($1, $2, 'approved', NOW())
ON CONFLICT (provider) DO NOTHING
RETURNING connection_id;
-- name: ReapproveConnection :execrows
-- Boot registration: an in-tree deploy that changed an owned key's provider or
-- recipient is the approval of a manifest change. Refreshing approved_at fires
-- the connection trigger, so the relay recomputes every record of the
-- connection. A pending connection is left pending.
UPDATE core.provider_connections
SET approved_at = NOW()
WHERE provider = $1 AND approval_state = 'approved';
-- name: GetConnectionByProvider :one
-- The one connection beneath a provider (uq_provider_connections_one_per_provider).
SELECT * FROM core.provider_connections WHERE provider = $1;
-- name: ListDeliverableConnections :many
-- Connections the relay may deliver to and the read-back may schedule:
-- approved and not retired. The connection's and its provider's status are
-- returned so the caller decides whether a push or a schedule may run
-- (both must be active); a suspended connection still gets its records
-- recomputed.
SELECT pc.connection_id,
pc.provider,
pc.status AS connection_status,
p.status AS provider_status
FROM core.provider_connections pc
JOIN core.providers p ON p.provider = pc.provider
WHERE pc.approval_state = 'approved'
AND pc.status <> 'retired'
ORDER BY pc.connection_id;
@@ -0,0 +1,445 @@
-- name: ClaimDesiredStateOutbox :many
-- The relay's claim (design D16): up to a batch of pending rows in identifier
-- order whose lease is absent or expired, leased for lease_seconds and
-- committed before any record is recomputed. SKIP LOCKED lets a retried
-- activity pass over rows an earlier attempt still holds. RETURNING keeps no
-- order of its own, so the claimed rows are returned sorted by identifier.
WITH claimed AS (
UPDATE core.desired_state_outbox
SET claimed_until = NOW() + (sqlc.arg(lease_seconds)::bigint * INTERVAL '1 second')
WHERE id IN (
SELECT o.id FROM core.desired_state_outbox o
WHERE o.status = 'pending'
AND (o.claimed_until IS NULL OR o.claimed_until < NOW())
ORDER BY o.id
LIMIT sqlc.arg(batch_size)::int
FOR UPDATE SKIP LOCKED)
RETURNING id, subject_kind, subject_id, resource_key, recipient_only, attempts, expansion_cursor
)
SELECT c.id, c.subject_kind, c.subject_id, c.resource_key, c.recipient_only, c.attempts, c.expansion_cursor
FROM claimed c
ORDER BY c.id;
-- name: ReleaseDesiredStateOutboxRow :execrows
-- A row whose expansion is not finished keeps its place: the cursor records
-- the last recipient processed ("<kind>:<id>") and the lease is released in
-- the same statement, so the next tick claims the row at once.
UPDATE core.desired_state_outbox
SET expansion_cursor = sqlc.arg(expansion_cursor)::text,
claimed_until = NULL
WHERE id = sqlc.arg(id)::uuid AND status = 'pending';
-- name: UnclaimDesiredStateOutboxRows :execrows
-- Claimed rows an attempt did not reach before its record budget was spent:
-- the lease is released and nothing else changes (cursor, attempts), so the
-- next tick claims them at once.
UPDATE core.desired_state_outbox
SET claimed_until = NULL
WHERE id = ANY(sqlc.arg(ids)::uuid[]) AND status = 'pending';
-- name: CompleteDesiredStateOutboxRows :execrows
-- Rows whose expansion is finished.
UPDATE core.desired_state_outbox
SET status = 'completed',
completed_at = NOW(),
claimed_until = NULL
WHERE id = ANY(sqlc.arg(ids)::uuid[]) AND status = 'pending';
-- name: MarkDesiredStateOutboxAttempt :one
-- A failed attempt on a row: for a wide row, its expansion query failed; for
-- a recipient_only row, its recompute failed. The row becomes failed (the
-- dead letter) when attempts reaches max_attempts and otherwise stays
-- pending. The lease is kept, so the retry waits for it to expire.
UPDATE core.desired_state_outbox
SET attempts = attempts + 1,
last_error = sqlc.arg(last_error)::text,
status = CASE WHEN attempts + 1 >= sqlc.arg(max_attempts)::int THEN 'failed' ELSE 'pending' END
WHERE id = sqlc.arg(id)::uuid AND status = 'pending'
RETURNING status, attempts;
-- name: RequeueDesiredState :one
-- One recipient whose recompute failed inside a wide row gets a
-- recipient_only row of its own with one attempt counted (design D16). The
-- function refuses any subject but an existing person or organization.
SELECT core.requeue_desired_state(sqlc.arg(subject_kind)::text, sqlc.arg(subject_id)::uuid, sqlc.arg(last_error)::text)::uuid AS id;
-- name: DeleteExpiredDesiredStateOutboxRows :execrows
-- Retention: completed rows older than the window, in a bounded batch.
-- Pending and failed rows are never deleted here.
DELETE FROM core.desired_state_outbox
WHERE id IN (
SELECT o.id FROM core.desired_state_outbox o
WHERE o.status = 'completed'
AND o.completed_at < NOW() - (sqlc.arg(retention_seconds)::bigint * INTERVAL '1 second')
ORDER BY o.id
LIMIT sqlc.arg(batch_size)::int);
-- name: DesiredStateOutboxBacklog :one
-- Pending rows and the age of the oldest, in milliseconds by the database's
-- clock (0 when nothing is pending), for the boot log without Temporal.
SELECT COUNT(*)::bigint AS pending_count,
COALESCE((EXTRACT(EPOCH FROM (NOW() - MIN(created_at))) * 1000)::bigint, 0)::bigint AS oldest_age_ms
FROM core.desired_state_outbox
WHERE status = 'pending';
-- ---------------------------------------------------------------------------
-- Expansion (design D16). Each query returns the records a row can affect as
-- (recipient_kind, recipient_id, connection_id), at connections that are
-- approved and not retired and whose provider owns a key with the matching
-- declared recipient (and the row's key, when it names one). A key whose
-- recipient is NULL reaches nothing. Results are paged by recipient: the
-- page holds the first page_size recipients after the cursor
-- (after_kind, after_id) in (kind, id) order, with every connection of each,
-- so a recipient is never split across pages. A page holding fewer than
-- page_size recipients is the last.
-- ---------------------------------------------------------------------------
-- name: ExpandDesiredStatePool :many
-- A pool row reaches the pool's organization for an organization-recipient
-- key, and the organization's owner and active members for a person-recipient
-- key.
WITH conns AS (
SELECT DISTINCT pc.connection_id, rk.recipient
FROM core.provider_connections pc
JOIN core.resource_keys rk ON rk.provider = pc.provider
WHERE pc.approval_state = 'approved' AND pc.status <> 'retired'
AND rk.recipient IS NOT NULL
AND (sqlc.narg(resource_key)::text IS NULL OR rk.resource_key = sqlc.narg(resource_key)::text)
),
pool_org AS (
SELECT rp.org_id FROM core.resource_pools rp WHERE rp.pool_id = sqlc.arg(pool_id)::uuid
),
reached AS (
SELECT 'organization'::text AS recipient_kind, po.org_id AS recipient_id, c.connection_id
FROM pool_org po CROSS JOIN conns c
WHERE c.recipient = 'organization'
UNION
SELECT 'person'::text, o.owner_person_id, c.connection_id
FROM pool_org po
JOIN core.organizations o ON o.org_id = po.org_id
CROSS JOIN conns c
WHERE c.recipient = 'person'
UNION
SELECT 'person'::text, om.person_id, c.connection_id
FROM pool_org po
JOIN core.org_members om ON om.org_id = po.org_id AND om.status = 'active'
CROSS JOIN conns c
WHERE c.recipient = 'person'
),
page AS (
SELECT DISTINCT r.recipient_kind, r.recipient_id FROM reached r
WHERE sqlc.narg(after_kind)::text IS NULL
OR (r.recipient_kind, r.recipient_id) > (sqlc.narg(after_kind)::text, sqlc.narg(after_id)::uuid)
ORDER BY r.recipient_kind, r.recipient_id
LIMIT sqlc.arg(page_size)::int
)
SELECT r.recipient_kind::text AS recipient_kind, r.recipient_id::uuid AS recipient_id, r.connection_id
FROM reached r
JOIN page p ON p.recipient_kind = r.recipient_kind AND p.recipient_id = r.recipient_id
ORDER BY r.recipient_kind, r.recipient_id, r.connection_id;
-- name: ExpandDesiredStateOrganization :many
-- An organization row reaches the organization's record and its owner's and
-- active members' records.
WITH conns AS (
SELECT DISTINCT pc.connection_id, rk.recipient
FROM core.provider_connections pc
JOIN core.resource_keys rk ON rk.provider = pc.provider
WHERE pc.approval_state = 'approved' AND pc.status <> 'retired'
AND rk.recipient IS NOT NULL
AND (sqlc.narg(resource_key)::text IS NULL OR rk.resource_key = sqlc.narg(resource_key)::text)
),
reached AS (
SELECT 'organization'::text AS recipient_kind, o.org_id AS recipient_id, c.connection_id
FROM core.organizations o CROSS JOIN conns c
WHERE o.org_id = sqlc.arg(org_id)::uuid AND c.recipient = 'organization'
UNION
SELECT 'person'::text, o.owner_person_id, c.connection_id
FROM core.organizations o CROSS JOIN conns c
WHERE o.org_id = sqlc.arg(org_id)::uuid AND c.recipient = 'person'
UNION
SELECT 'person'::text, om.person_id, c.connection_id
FROM core.org_members om CROSS JOIN conns c
WHERE om.org_id = sqlc.arg(org_id)::uuid AND om.status = 'active' AND c.recipient = 'person'
),
page AS (
SELECT DISTINCT r.recipient_kind, r.recipient_id FROM reached r
WHERE sqlc.narg(after_kind)::text IS NULL
OR (r.recipient_kind, r.recipient_id) > (sqlc.narg(after_kind)::text, sqlc.narg(after_id)::uuid)
ORDER BY r.recipient_kind, r.recipient_id
LIMIT sqlc.arg(page_size)::int
)
SELECT r.recipient_kind::text AS recipient_kind, r.recipient_id::uuid AS recipient_id, r.connection_id
FROM reached r
JOIN page p ON p.recipient_kind = r.recipient_kind AND p.recipient_id = r.recipient_id
ORDER BY r.recipient_kind, r.recipient_id, r.connection_id;
-- name: ExpandDesiredStateRecipient :many
-- One recipient's own records: a person row (recipient_kind 'person'), and a
-- recipient_only row for a person or an organization, which for an
-- organization does not reach its members. At most one recipient, so the
-- result is always a single page.
SELECT DISTINCT sqlc.arg(recipient_kind)::text AS recipient_kind,
sqlc.arg(recipient_id)::uuid AS recipient_id,
pc.connection_id
FROM core.provider_connections pc
JOIN core.resource_keys rk ON rk.provider = pc.provider
WHERE pc.approval_state = 'approved' AND pc.status <> 'retired'
AND rk.recipient = sqlc.arg(recipient_kind)::text
AND (sqlc.narg(resource_key)::text IS NULL OR rk.resource_key = sqlc.narg(resource_key)::text)
ORDER BY pc.connection_id;
-- name: ExpandDesiredStateConnection :many
-- A connection row reaches every recipient at that connection: the active
-- organizations whose default pool holds one of the provider's
-- organization-recipient keys, the persons who would hold one of its
-- person-recipient keys (owners and active members of active organizations
-- whose pools hold it), and every recipient that already has a record there,
-- so a record that must empty is recomputed too. When the row names a key (a
-- target-mapping edit), the existing records are those that carry that key.
WITH conn AS (
SELECT pc.connection_id, pc.provider FROM core.provider_connections pc
WHERE pc.connection_id = sqlc.arg(connection_id)::uuid
AND pc.approval_state = 'approved' AND pc.status <> 'retired'
),
keys AS (
SELECT rk.resource_key, rk.kind, rk.recipient
FROM core.resource_keys rk JOIN conn cn ON rk.provider = cn.provider
WHERE rk.recipient IS NOT NULL
AND (sqlc.narg(resource_key)::text IS NULL OR rk.resource_key = sqlc.narg(resource_key)::text)
),
held_pools AS (
SELECT be.pool_id, k.recipient FROM core.boolean_entitlements be
JOIN keys k ON k.resource_key = be.resource_key AND k.kind = 'boolean'
WHERE be.granted
UNION
SELECT ne.pool_id, k.recipient FROM core.numeric_entitlements ne
JOIN keys k ON k.resource_key = ne.resource_key AND k.kind = 'numeric'
WHERE ne.resource_limit > 0
),
held_orgs AS (
SELECT DISTINCT rp.org_id, hp.recipient, rp.pool_type
FROM held_pools hp
JOIN core.resource_pools rp ON rp.pool_id = hp.pool_id
JOIN core.organizations o ON o.org_id = rp.org_id
WHERE o.status = 'active' AND o.org_type <> 'system'
),
reached AS (
SELECT 'organization'::text AS recipient_kind, ho.org_id AS recipient_id
FROM held_orgs ho
WHERE ho.recipient = 'organization' AND ho.pool_type = 'default'
UNION
SELECT 'person'::text, o.owner_person_id
FROM held_orgs ho JOIN core.organizations o ON o.org_id = ho.org_id
WHERE ho.recipient = 'person'
UNION
SELECT 'person'::text, om.person_id
FROM held_orgs ho JOIN core.org_members om ON om.org_id = ho.org_id AND om.status = 'active'
WHERE ho.recipient = 'person'
UNION
SELECT CASE WHEN r.person_id IS NOT NULL THEN 'person' ELSE 'organization' END::text,
COALESCE(r.person_id, r.org_id)
FROM core.desired_state_records r JOIN conn cn ON cn.connection_id = r.connection_id
WHERE sqlc.narg(resource_key)::text IS NULL
OR r.content -> 'entitlements' @> jsonb_build_array(jsonb_build_object('key', sqlc.narg(resource_key)::text))
)
SELECT r.recipient_kind::text AS recipient_kind, r.recipient_id::uuid AS recipient_id, cn.connection_id
FROM reached r CROSS JOIN conn cn
WHERE sqlc.narg(after_kind)::text IS NULL
OR (r.recipient_kind, r.recipient_id) > (sqlc.narg(after_kind)::text, sqlc.narg(after_id)::uuid)
ORDER BY r.recipient_kind, r.recipient_id
LIMIT sqlc.arg(page_size)::int;
-- ---------------------------------------------------------------------------
-- Recomputing one record (design D9), in one READ COMMITTED transaction, in
-- this order: LockDesiredStateAddress, LockDesiredStateRecord, then
-- DesiredStateContent in a new statement, then UpdateDesiredStateRecord or
-- InsertDesiredStateRecord. Every transaction that writes a record's content
-- or generation, or a projection, takes the address lock first.
-- ---------------------------------------------------------------------------
-- name: LockDesiredStateAddress :exec
-- The serialization point for one (connection, recipient) address, including
-- one that has no record yet. recipient_kind is 'person' or 'organization'.
SELECT pg_advisory_xact_lock(hashtextextended(
'desired-state:' || sqlc.arg(connection_id)::uuid::text
|| ':' || sqlc.arg(recipient_kind)::text
|| ':' || sqlc.arg(recipient_id)::uuid::text, 0));
-- name: LockDesiredStateRecord :one
-- The record at one address, locked; exactly one of person_id and org_id is
-- set. sql.ErrNoRows when the address has no record.
SELECT * FROM core.desired_state_records r
WHERE (r.connection_id = sqlc.arg(connection_id)::uuid AND r.person_id = sqlc.narg(person_id)::uuid)
OR (r.connection_id = sqlc.arg(connection_id)::uuid AND r.org_id = sqlc.narg(org_id)::uuid)
FOR UPDATE;
-- name: DesiredStateContent :one
-- A record's content under the recipient rules, its hash computed as the
-- stored column computes it, and how many entitlements it carries (0 is the
-- empty record).
SELECT c.content::jsonb AS content,
sha256(jsonb_send(c.content))::bytea AS content_hash,
jsonb_array_length(c.content -> 'entitlements')::int AS entitlement_count
FROM (SELECT core.desired_state_content(sqlc.arg(connection_id)::uuid,
sqlc.narg(person_id)::uuid,
sqlc.narg(org_id)::uuid) AS content) c;
-- name: UpdateDesiredStateRecord :one
-- Store new content, or raise above a reported generation (floor), with the
-- generation from core.next_generation and generation_at now. The guard
-- trigger refuses a falling generation and a content change without a raise.
UPDATE core.desired_state_records
SET content = sqlc.arg(content)::jsonb,
generation = core.next_generation(generation, sqlc.narg(floor)::bigint),
generation_at = NOW()
WHERE record_id = sqlc.arg(record_id)::uuid
RETURNING record_id, generation, content_hash;
-- name: InsertDesiredStateRecord :one
-- Create the record at an address that has none: when its content is first
-- non-empty, or at a reported floor (design D19). Exactly one of person_id
-- and org_id is set.
INSERT INTO core.desired_state_records (connection_id, person_id, org_id, content, generation)
VALUES (sqlc.arg(connection_id)::uuid,
sqlc.narg(person_id)::uuid,
sqlc.narg(org_id)::uuid,
sqlc.arg(content)::jsonb,
core.next_generation(NULL, sqlc.narg(floor)::bigint))
RETURNING record_id, generation, content_hash;
-- name: GetDesiredStateRecord :one
SELECT * FROM core.desired_state_records WHERE record_id = $1;
-- ---------------------------------------------------------------------------
-- Push bookkeeping (design D18): what the console did, on the record. Each
-- statement holds one row lock and waits for nothing else.
-- ---------------------------------------------------------------------------
-- name: StampDesiredStateRecordsDispatched :execrows
-- Locks the records in identifier order before stamping them, whatever plan
-- the update would take, so two stamps over overlapping records never wait
-- on each other in a cycle (design D14).
UPDATE core.desired_state_records r
SET last_dispatched_at = NOW()
FROM (SELECT record_id FROM core.desired_state_records
WHERE record_id = ANY(sqlc.arg(record_ids)::uuid[])
ORDER BY record_id
FOR UPDATE) locked
WHERE r.record_id = locked.record_id;
-- name: SetDesiredStatePushFailure :execrows
-- A spent push budget or a protocol error: the transport's error class and
-- the console's own text, never the application's.
UPDATE core.desired_state_records
SET last_push_error = sqlc.arg(last_push_error)::text,
last_push_failed_at = NOW()
WHERE record_id = sqlc.arg(record_id)::uuid;
-- name: ClearDesiredStatePushError :execrows
-- An applied answer clears the push error; last_push_failed_at keeps the time
-- of the last failure.
UPDATE core.desired_state_records
SET last_push_error = NULL
WHERE record_id = sqlc.arg(record_id)::uuid AND last_push_error IS NOT NULL;
-- name: ListDesiredStateRecordsToPush :many
-- Of the given records, those a push may go out for: at a connection a
-- transport serves (served_connection_ids), approved and active, of an active
-- provider, and neither delivered (the application applied the record's
-- generation, and its hash when it reported one) nor waiting for an account
-- (the application answered unlinked for the current generation).
SELECT r.record_id
FROM core.desired_state_records r
JOIN core.provider_connections pc ON pc.connection_id = r.connection_id
JOIN core.providers p ON p.provider = pc.provider
LEFT JOIN core.observed_projections op
ON op.connection_id = r.connection_id
AND (op.person_id = r.person_id OR op.org_id = r.org_id)
WHERE r.record_id = ANY(sqlc.arg(record_ids)::uuid[])
AND r.connection_id = ANY(sqlc.arg(served_connection_ids)::uuid[])
AND pc.approval_state = 'approved' AND pc.status = 'active' AND p.status = 'active'
AND NOT COALESCE(op.applied_generation >= r.generation
AND (op.applied_content_hash IS NULL OR op.applied_content_hash = r.content_hash), FALSE)
AND NOT COALESCE(op.result = 'unlinked' AND op.result_generation = r.generation, FALSE)
ORDER BY r.record_id;
-- name: ListLaggingDesiredStateRecords :many
-- The lag sweep (design D18): records at the served connections that need a
-- push, excluding those dispatched or failed within the cooldown, oldest and
-- never-dispatched first so records rotate past the per-tick cap. The caller
-- skips the query when no transport serves any connection.
SELECT r.record_id
FROM core.desired_state_records r
JOIN core.provider_connections pc ON pc.connection_id = r.connection_id
JOIN core.providers p ON p.provider = pc.provider
LEFT JOIN core.observed_projections op
ON op.connection_id = r.connection_id
AND (op.person_id = r.person_id OR op.org_id = r.org_id)
WHERE r.connection_id = ANY(sqlc.arg(served_connection_ids)::uuid[])
AND pc.approval_state = 'approved' AND pc.status = 'active' AND p.status = 'active'
AND (r.last_dispatched_at IS NULL
OR r.last_dispatched_at < NOW() - (sqlc.arg(cooldown_seconds)::bigint * INTERVAL '1 second'))
AND (r.last_push_failed_at IS NULL
OR r.last_push_failed_at < NOW() - (sqlc.arg(cooldown_seconds)::bigint * INTERVAL '1 second'))
AND NOT COALESCE(op.applied_generation >= r.generation
AND (op.applied_content_hash IS NULL OR op.applied_content_hash = r.content_hash), FALSE)
AND NOT COALESCE(op.result = 'unlinked' AND op.result_generation = r.generation, FALSE)
ORDER BY r.last_dispatched_at ASC NULLS FIRST, r.record_id
LIMIT sqlc.arg(max_records)::int;
-- name: GenerationCeiling :one
-- The bound on a generation an application reports (design D8): the clock
-- floor plus one day. A report above it is a protocol error and is never
-- used as a floor.
SELECT core.generation_ceiling()::bigint AS ceiling;
-- name: GetReadBackConnection :one
-- Checked by every read-back page before it calls the application: whether
-- the connection may still be read back (approved and active, of an active
-- provider, as a scheduled read-back requires), and which recipient kinds
-- its provider owns keys for. No row means the connection is gone.
SELECT (pc.approval_state = 'approved' AND pc.status = 'active' AND p.status = 'active')::bool AS readable,
EXISTS (SELECT 1 FROM core.resource_keys rk
WHERE rk.provider = pc.provider AND rk.recipient = 'person')::bool AS person_keys,
EXISTS (SELECT 1 FROM core.resource_keys rk
WHERE rk.provider = pc.provider AND rk.recipient = 'organization')::bool AS organization_keys
FROM core.provider_connections pc
JOIN core.providers p ON p.provider = pc.provider
WHERE pc.connection_id = sqlc.arg(connection_id)::uuid;
-- name: DesiredStateRecipientExists :one
-- Whether a read-back item names a person or organization the console knows.
-- An item for an unknown recipient is counted and logged, never stored.
SELECT CASE sqlc.arg(recipient_kind)::text
WHEN 'person' THEN EXISTS (SELECT 1 FROM core.persons p WHERE p.person_id = sqlc.arg(recipient_id)::uuid)
WHEN 'organization' THEN EXISTS (SELECT 1 FROM core.organizations o WHERE o.org_id = sqlc.arg(recipient_id)::uuid)
ELSE FALSE
END::bool AS known;
-- name: ListDesiredStateDelivery :many
-- Delivered, waiting and lag for every record of one connection, computed
-- from the record and its projection and never stored (design D10). A record
-- is delivered when the application applied its generation, and its hash
-- when it reported one; it waits for an account when the application
-- answered unlinked for its current generation; any other record is lag,
-- aged from generation_at, but only at a connection a transport serves
-- (served), because a record nothing could carry is not behind.
WITH states AS (
SELECT r.record_id, r.generation_at,
COALESCE(op.applied_generation >= r.generation
AND (op.applied_content_hash IS NULL OR op.applied_content_hash = r.content_hash), FALSE) AS delivered,
COALESCE(op.result = 'unlinked' AND op.result_generation = r.generation, FALSE) AS unlinked
FROM core.desired_state_records r
LEFT JOIN core.observed_projections op
ON op.connection_id = r.connection_id
AND (op.person_id = r.person_id OR op.org_id = r.org_id)
WHERE r.connection_id = sqlc.arg(connection_id)::uuid
)
SELECT s.record_id,
s.delivered::bool AS delivered,
(NOT s.delivered AND s.unlinked)::bool AS waiting,
(sqlc.arg(served)::bool AND NOT s.delivered AND NOT s.unlinked)::bool AS lagging,
s.generation_at
FROM states s
ORDER BY s.record_id;
@@ -0,0 +1,163 @@
-- name: LockLifecycleInstance :exec
-- Lifecycle requests (design D24 to D28). Recording a request runs, in one
-- transaction: LockLifecycleInstance, LatestLifecycleGeneration,
-- InsertLifecycleRequest, then GetLifecycleRequestByAddress when the insert
-- returned nothing, so the latest-generation check and the insert are one
-- step per instance.
SELECT pg_advisory_xact_lock(hashtextextended('lifecycle:' || sqlc.arg(instance_id)::uuid::text, 0));
-- name: LatestLifecycleGeneration :one
-- The highest generation recorded for an instance, 0 when it has none.
SELECT COALESCE(MAX(generation), 0)::bigint AS latest_generation
FROM core.lifecycle_requests
WHERE instance_id = sqlc.arg(instance_id)::uuid;
-- name: InsertLifecycleRequest :one
-- Insert a request at its address. The operation must be one the
-- connection's provider declares in core.provider_operations, and the
-- provider must own the resource key; otherwise nothing is inserted. A held
-- address also inserts nothing (ON CONFLICT on the address only), so
-- sql.ErrNoRows sends the caller to GetLifecycleRequestByAddress: a row there
-- is a repeat or a conflict, no row means the operation or key was refused.
-- A second accepted request on the instance under another generation raises
-- a unique violation on uq_lifecycle_requests_one_open.
INSERT INTO core.lifecycle_requests
(instance_id, generation, connection_id, resource_key, workspace_id,
operation, parameters, workflow_id, requested_by_person_id)
SELECT sqlc.arg(instance_id)::uuid,
sqlc.arg(generation)::bigint,
pc.connection_id,
rk.resource_key,
sqlc.arg(workspace_id)::uuid,
po.operation,
sqlc.arg(parameters)::jsonb,
sqlc.arg(workflow_id)::text,
sqlc.narg(requested_by_person_id)::uuid
FROM core.provider_connections pc
JOIN core.provider_operations po
ON po.provider = pc.provider AND po.operation = sqlc.arg(operation)::text
JOIN core.resource_keys rk
ON rk.provider = pc.provider AND rk.resource_key = sqlc.arg(resource_key)::text
WHERE pc.connection_id = sqlc.arg(connection_id)::uuid
ON CONFLICT (instance_id, generation) DO NOTHING
RETURNING request_id;
-- name: GetLifecycleRequestByAddress :one
SELECT * FROM core.lifecycle_requests
WHERE instance_id = sqlc.arg(instance_id)::uuid AND generation = sqlc.arg(generation)::bigint;
-- name: FinishLifecycleRequest :one
-- Record a request's outcome. Repeating the same outcome (a retried local
-- activity whose first attempt committed) matches again and keeps the first
-- finish time; a missing request, or one finished with another outcome,
-- matches nothing and returns sql.ErrNoRows, which fails the activity.
UPDATE core.lifecycle_requests
SET status = sqlc.arg(status)::text,
reason_code = sqlc.narg(reason_code)::text,
finished_at = CASE WHEN status = 'accepted' THEN sqlc.arg(finished_at)::timestamptz ELSE finished_at END
WHERE instance_id = sqlc.arg(instance_id)::uuid
AND generation = sqlc.arg(generation)::bigint
AND (status = 'accepted'
OR (status = sqlc.arg(status)::text AND reason_code IS NOT DISTINCT FROM sqlc.narg(reason_code)::text))
RETURNING request_id;
-- name: DeleteUnstartedLifecycleRequest :execrows
-- A handler whose workflow start was refused with NotFound deletes the row it
-- inserted, so the member's retry is a fresh insert. Only an accepted row in
-- the caller's workspace is deleted.
DELETE FROM core.lifecycle_requests
WHERE instance_id = sqlc.arg(instance_id)::uuid
AND generation = sqlc.arg(generation)::bigint
AND workspace_id = sqlc.arg(workspace_id)::uuid
AND status = 'accepted';
-- name: LatestLifecycleGenerations :many
-- The highest generation recorded for each of a set of instances; an
-- instance with no request is absent.
SELECT instance_id, MAX(generation)::bigint AS latest_generation
FROM core.lifecycle_requests
WHERE instance_id = ANY(sqlc.arg(instance_ids)::uuid[])
GROUP BY instance_id;
-- name: OpenLifecycleRequestsByWorkspace :many
-- What a workspace's list shows for one connection: every accepted request,
-- and per instance the latest request when it is declined or failed and not
-- acknowledged (a later request on the instance supersedes it).
SELECT lr.* FROM core.lifecycle_requests lr
WHERE lr.workspace_id = sqlc.arg(workspace_id)::uuid
AND lr.connection_id = sqlc.arg(connection_id)::uuid
AND (lr.status = 'accepted'
OR (lr.status IN ('declined', 'failed')
AND lr.acknowledged_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM core.lifecycle_requests later
WHERE later.instance_id = lr.instance_id
AND later.generation > lr.generation)))
ORDER BY lr.created_at, lr.request_id;
-- name: AcknowledgeLifecycleRequest :execrows
-- The member dismissed a declined or failed outcome in their workspace.
UPDATE core.lifecycle_requests
SET acknowledged_at = NOW()
WHERE instance_id = sqlc.arg(instance_id)::uuid
AND generation = sqlc.arg(generation)::bigint
AND workspace_id = sqlc.arg(workspace_id)::uuid
AND status IN ('declined', 'failed')
AND acknowledged_at IS NULL;
-- name: ListAcceptedLifecycleRequestsOlderThan :many
-- The boot pass (design D27): accepted requests at one connection older than
-- the given age, whose workflow may never have started or may have closed
-- without recording an outcome.
SELECT * FROM core.lifecycle_requests
WHERE connection_id = sqlc.arg(connection_id)::uuid
AND status = 'accepted'
AND created_at < NOW() - (sqlc.arg(age_seconds)::bigint * INTERVAL '1 second')
ORDER BY created_at, request_id;
-- name: MintInstanceID :one
-- A new instance's identifier comes from the database, the only source of
-- UUIDs (design D25).
SELECT uuidv7()::uuid AS instance_id;
-- name: MintLifecycleGeneration :one
-- A generation for a request on an instance that has no request yet,
-- floored by the clock (design D25).
SELECT core.next_generation(NULL, NULL)::bigint AS generation;
-- name: NextLifecycleGenerations :many
-- The generation each instance's next request carries: above its latest
-- request and at least the clock, one row per instance asked for.
SELECT ids.instance_id::uuid AS instance_id,
core.next_generation(MAX(lr.generation), NULL)::bigint AS next_generation
FROM unnest(sqlc.arg(instance_ids)::uuid[]) AS ids(instance_id)
LEFT JOIN core.lifecycle_requests lr ON lr.instance_id = ids.instance_id
GROUP BY ids.instance_id;
-- name: OpenLifecycleGeneration :one
-- The generation of the instance's accepted request, 0 when none is open.
-- Read under LockLifecycleInstance, so the one-open rule is answered before
-- the insert instead of by a unique violation that would abort the caller's
-- transaction.
SELECT COALESCE(MAX(generation), 0)::bigint AS open_generation
FROM core.lifecycle_requests
WHERE instance_id = sqlc.arg(instance_id)::uuid AND status = 'accepted';
-- name: GetLifecycleRequesterSubject :one
-- The identity-provider subject of the person who made a request, read when
-- a workflow must compare it with an application's owner record without
-- carrying it in its input (design D27's create reconcile).
SELECT u.oidc_subject
FROM core.lifecycle_requests lr
JOIN core.persons p ON p.person_id = lr.requested_by_person_id
JOIN core.users u ON u.user_id = p.user_id
WHERE lr.instance_id = sqlc.arg(instance_id)::uuid
AND lr.generation = sqlc.arg(generation)::bigint;
-- name: GetFirstLifecycleRequest :one
-- An instance's first request. A create is always its instance's first
-- request, so the JSON API finds a create by its instance alone (design D25).
SELECT * FROM core.lifecycle_requests
WHERE instance_id = sqlc.arg(instance_id)::uuid
ORDER BY generation
LIMIT 1;
+126
View File
@@ -0,0 +1,126 @@
-- name: UpsertObservedProjectionForPerson :execrows
-- Observed projections (design D10, D19, D21). Every statement here runs in a
-- transaction that already holds the address's advisory lock and the record
-- row, so the projection row is the last lock taken. Each write comes in a
-- person and an organization form because an upsert names one unique
-- constraint as its conflict target.
--
-- A read-back item for a person. The stored applied generation is never
-- lowered: a page fetched before a push was applied can arrive after the push
-- answer, and then changes nothing.
INSERT INTO core.observed_projections
(connection_id, person_id, applied_generation, applied_content_hash, observed, observed_at)
VALUES (sqlc.arg(connection_id)::uuid, sqlc.arg(person_id)::uuid,
sqlc.narg(applied_generation)::bigint, sqlc.narg(applied_content_hash)::bytea,
sqlc.arg(observed)::jsonb, sqlc.arg(observed_at)::timestamptz)
ON CONFLICT (connection_id, person_id) DO UPDATE SET
applied_generation = excluded.applied_generation,
applied_content_hash = excluded.applied_content_hash,
observed = excluded.observed,
observed_at = excluded.observed_at
WHERE excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation);
-- name: UpsertObservedProjectionForOrganization :execrows
-- A read-back item for an organization; as the person form.
INSERT INTO core.observed_projections
(connection_id, org_id, applied_generation, applied_content_hash, observed, observed_at)
VALUES (sqlc.arg(connection_id)::uuid, sqlc.arg(org_id)::uuid,
sqlc.narg(applied_generation)::bigint, sqlc.narg(applied_content_hash)::bytea,
sqlc.arg(observed)::jsonb, sqlc.arg(observed_at)::timestamptz)
ON CONFLICT (connection_id, org_id) DO UPDATE SET
applied_generation = excluded.applied_generation,
applied_content_hash = excluded.applied_content_hash,
observed = excluded.observed,
observed_at = excluded.observed_at
WHERE excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation);
-- name: RecordPushResultForPerson :exec
-- A push answer for a person: the result, the generation it was about and
-- the next-action key always replace what was stored; the applied generation
-- and its hash move together and only upwards. An applied answer reports the
-- pushed generation and hash; an unlinked one reports the generation the
-- application still holds and no hash.
INSERT INTO core.observed_projections
(connection_id, person_id, applied_generation, applied_content_hash,
result, result_generation, next_action, observed_at)
VALUES (sqlc.arg(connection_id)::uuid, sqlc.arg(person_id)::uuid,
sqlc.narg(applied_generation)::bigint, sqlc.narg(applied_content_hash)::bytea,
sqlc.arg(result)::text, sqlc.arg(result_generation)::bigint,
sqlc.narg(next_action)::text, sqlc.arg(observed_at)::timestamptz)
ON CONFLICT (connection_id, person_id) DO UPDATE SET
result = excluded.result,
result_generation = excluded.result_generation,
next_action = excluded.next_action,
observed_at = excluded.observed_at,
applied_content_hash = CASE
WHEN excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
THEN excluded.applied_content_hash
ELSE observed_projections.applied_content_hash END,
applied_generation = CASE
WHEN excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
THEN excluded.applied_generation
ELSE observed_projections.applied_generation END;
-- name: RecordPushResultForOrganization :exec
-- A push answer for an organization; as the person form.
INSERT INTO core.observed_projections
(connection_id, org_id, applied_generation, applied_content_hash,
result, result_generation, next_action, observed_at)
VALUES (sqlc.arg(connection_id)::uuid, sqlc.arg(org_id)::uuid,
sqlc.narg(applied_generation)::bigint, sqlc.narg(applied_content_hash)::bytea,
sqlc.arg(result)::text, sqlc.arg(result_generation)::bigint,
sqlc.narg(next_action)::text, sqlc.arg(observed_at)::timestamptz)
ON CONFLICT (connection_id, org_id) DO UPDATE SET
result = excluded.result,
result_generation = excluded.result_generation,
next_action = excluded.next_action,
observed_at = excluded.observed_at,
applied_content_hash = CASE
WHEN excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
THEN excluded.applied_content_hash
ELSE observed_projections.applied_content_hash END,
applied_generation = CASE
WHEN excluded.applied_generation IS NOT NULL
AND (observed_projections.applied_generation IS NULL
OR excluded.applied_generation >= observed_projections.applied_generation)
THEN excluded.applied_generation
ELSE observed_projections.applied_generation END;
-- name: MarkObservedProjectionAheadForPerson :exec
-- The restore marker (design D19): the application reported a generation
-- above the record's, or the record's own generation with another hash. Only
-- the latest occurrence is kept.
INSERT INTO core.observed_projections
(connection_id, person_id, observed_at, ahead_generation, ahead_at)
VALUES (sqlc.arg(connection_id)::uuid, sqlc.arg(person_id)::uuid, NOW(),
sqlc.arg(ahead_generation)::bigint, NOW())
ON CONFLICT (connection_id, person_id) DO UPDATE SET
ahead_generation = excluded.ahead_generation,
ahead_at = excluded.ahead_at;
-- name: MarkObservedProjectionAheadForOrganization :exec
-- The restore marker for an organization; as the person form.
INSERT INTO core.observed_projections
(connection_id, org_id, observed_at, ahead_generation, ahead_at)
VALUES (sqlc.arg(connection_id)::uuid, sqlc.arg(org_id)::uuid, NOW(),
sqlc.arg(ahead_generation)::bigint, NOW())
ON CONFLICT (connection_id, org_id) DO UPDATE SET
ahead_generation = excluded.ahead_generation,
ahead_at = excluded.ahead_at;
-- name: GetObservedProjection :one
-- The projection at one address; exactly one of person_id and org_id is set.
-- sql.ErrNoRows when the application has never answered for the recipient.
SELECT * FROM core.observed_projections op
WHERE (op.connection_id = sqlc.arg(connection_id)::uuid AND op.person_id = sqlc.narg(person_id)::uuid)
OR (op.connection_id = sqlc.arg(connection_id)::uuid AND op.org_id = sqlc.narg(org_id)::uuid);
+11 -5
View File
@@ -28,13 +28,19 @@ UPDATE core.providers SET status = $2, updated_at = NOW() WHERE provider = $1;
-- nesting check.
SELECT resource_key FROM core.resource_keys WHERE provider IS NULL ORDER BY resource_key;
-- name: SetResourceKeyProvider :execrows
-- Stamp provider ownership on a resource key (cross-schema write, enabled by the
-- name: SetResourceKeyProvider :one
-- Stamp provider ownership, the declared recipient and the over-limit pair on
-- a resource key in one write (cross-schema write, enabled by the
-- resource_keys.provider FK + this package's core_writer grant on the core
-- schema). Returns rows affected so a manifest referencing a non-existent key
-- can be detected at registration.
-- schema). Returns whether the stamp changed the key's provider or recipient,
-- the two values that decide which records carry the key, so registration can
-- re-approve the provider's connection. No row (sql.ErrNoRows) means the
-- manifest names a key that does not exist.
UPDATE core.resource_keys
SET provider = $2,
recipient = sqlc.narg(recipient),
over_limit_behavior = sqlc.arg(over_limit_behavior),
over_limit_consequence = sqlc.arg(over_limit_consequence)
WHERE resource_key = $1;
WHERE resource_key = $1
RETURNING (OLD.provider IS DISTINCT FROM NEW.provider
OR OLD.recipient IS DISTINCT FROM NEW.recipient)::boolean AS changed;
+94 -20
View File
@@ -6,8 +6,10 @@ package integration
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"slices"
"strings"
)
@@ -84,6 +86,21 @@ func validateManifest(m Manifest) error {
}
owned[key] = true
}
for _, key := range m.ResourceKeys {
if _, ok := m.Recipients[key]; !ok {
return fmt.Errorf("provider %q: owned resource key %q declares no recipient", m.Key, key)
}
}
for key, r := range m.Recipients {
if !owned[key] {
return fmt.Errorf("provider %q: recipient declaration for %q, a key it does not own", m.Key, key)
}
switch r {
case RecipientPerson, RecipientOrganization:
default:
return fmt.Errorf("provider %q: recipient %q for %q is not person or organization", m.Key, r, key)
}
}
for key, ol := range m.OverLimit {
if !owned[key] {
return fmt.Errorf("provider %q: over-limit declaration for %q, a key it does not own", m.Key, key)
@@ -131,11 +148,13 @@ func checkKeyNesting(providerKeys map[string]bool, platformKeys []string) error
// RegisterProviders reconciles the provider registry from code at boot,
// parallel to how migrations are assembled from MigrationSources. It validates
// every manifest, enforces key hygiene against existing platform resource
// keys, then upserts provider + operation rows and stamps
// core.resource_keys.provider for each owned key — all in one
// transaction. It is idempotent: re-running with the same sources only
// keys, then upserts provider + operation rows, stamps
// core.resource_keys.provider and the declared recipient for each owned key,
// and ensures one approved connection for each provider that owns keys — all
// in one transaction. It is idempotent: re-running with the same sources only
// refreshes capability fields and reconciles the declared operation set.
// Operational state (provider status) is never clobbered.
// Operational state (provider status; a connection's status, approval and
// display name) is never clobbered.
//
// NOTE: the contract's "every declared operation resolves to an implemented
// activity" check is only partially realizable here. The manifest is the
@@ -190,6 +209,8 @@ func RegisterProviders(ctx context.Context, db *sql.DB, sources []ProviderSource
defer func() { _ = tx.Rollback() }()
qtx := q.WithTx(tx)
// Providers whose stamp changed an owned key's provider or recipient.
restamped := make(map[string]bool)
for _, m := range manifests {
if _, err := qtx.UpsertProvider(ctx, UpsertProviderParams{
// The registry column is `provider` (migration 00014, upstream's
@@ -239,24 +260,77 @@ func RegisterProviders(ctx context.Context, db *sql.DB, sources []ProviderSource
}); err != nil {
return fmt.Errorf("reconcile states for %q: %w", m.Key, err)
}
}
for _, key := range m.ResourceKeys {
ol := m.OverLimit[key]
if ol.Behavior == "" {
ol.Behavior = OverLimitDenyNew
}
n, err := qtx.SetResourceKeyProvider(ctx, SetResourceKeyProviderParams{
ResourceKey: key,
Provider: sql.NullString{String: m.Key, Valid: true},
OverLimitBehavior: string(ol.Behavior),
OverLimitConsequence: sql.NullString{String: ol.Consequence, Valid: ol.Consequence != ""},
})
if err != nil {
return fmt.Errorf("stamp resource key %q for %q: %w", key, m.Key, err)
}
if n == 0 {
return fmt.Errorf("provider %q declares resource key %q, but no such row exists in core.resource_keys", m.Key, key)
// Stamp every owned key in byte order, across providers. A stamp that
// changes a key's provider takes FOR UPDATE on the key row (provider is
// part of uq_resource_keys_key_provider), which waits on the FOR KEY
// SHARE an open entitlement insert of that key holds; the materializer
// writes a pool's keys in byte order too, so the two wait in one
// direction (design D14).
type stamp struct {
key string
m *Manifest
}
var stamps []stamp
for i := range manifests {
for _, key := range manifests[i].ResourceKeys {
stamps = append(stamps, stamp{key: key, m: &manifests[i]})
}
}
slices.SortFunc(stamps, func(a, b stamp) int { return strings.Compare(a.key, b.key) })
for _, st := range stamps {
key, m := st.key, st.m
ol := m.OverLimit[key]
if ol.Behavior == "" {
ol.Behavior = OverLimitDenyNew
}
changed, err := qtx.SetResourceKeyProvider(ctx, SetResourceKeyProviderParams{
ResourceKey: key,
Provider: sql.NullString{String: m.Key, Valid: true},
Recipient: sql.NullString{String: string(m.Recipients[key]), Valid: true},
OverLimitBehavior: string(ol.Behavior),
OverLimitConsequence: sql.NullString{String: ol.Consequence, Valid: ol.Consequence != ""},
})
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("provider %q declares resource key %q, but no such row exists in core.resource_keys", m.Key, key)
}
if err != nil {
return fmt.Errorf("stamp resource key %q for %q: %w", key, m.Key, err)
}
if changed {
restamped[m.Key] = true
}
}
// Connections (design D2), after every owned key is stamped: each
// provider that owns keys has one approved connection. The deployment
// approved an in-tree integration's code by building and running it, so
// the insert is approved; an existing row is the operator's and is left
// alone. When the connection already existed and a stamp changed which
// records carry the provider's keys, the deploy is the approval of a
// manifest change: refreshing approved_at writes the connection's outbox
// row, so the relay recomputes its records. A provider that stops owning
// keys keeps its connection.
for _, m := range manifests {
if len(m.ResourceKeys) == 0 {
continue
}
_, err := qtx.EnsureInTreeConnection(ctx, EnsureInTreeConnectionParams{
Provider: m.Key,
DisplayName: m.DisplayName,
})
switch {
case err == nil:
// Inserted: the insert is the approval.
case errors.Is(err, sql.ErrNoRows):
if restamped[m.Key] {
if _, err := qtx.ReapproveConnection(ctx, m.Key); err != nil {
return fmt.Errorf("re-approve connection for %q: %w", m.Key, err)
}
}
default:
return fmt.Errorf("ensure connection for %q: %w", m.Key, err)
}
}
@@ -6,8 +6,13 @@ package integration_test
import (
"context"
"database/sql"
"fmt"
"os"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
@@ -177,3 +182,461 @@ func TestRegisterProviders_DB(t *testing.T) {
t.Fatalf("second RegisterProviders (idempotency): %v", err)
}
}
// --- Recipients and connections (integration-delivery-core, tasks 4.x) -----
//
// The tests below register made-up providers whose keys they insert, commit
// and remove again, so the real FedWiki and Discourse connections other
// packages read stay as boot leaves them. The test login is a cluster
// superuser, which the cleanup needs to delete outbox rows.
// regDB opens TEST_DATABASE_URL migrated with every stream.
func regDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := db.RunMigrations(database, migrate.Sources()); err != nil {
t.Fatalf("run migrations: %v", err)
}
return database
}
var regSeq atomic.Int64
// regProvider returns a provider key no other run uses, and removes every
// row registration and the triggers wrote for it when the test ends.
func regProvider(t *testing.T, database *sql.DB) string {
t.Helper()
key := fmt.Sprintf("regt%d%d", time.Now().UnixNano(), regSeq.Add(1))
t.Cleanup(func() {
ctx := context.Background()
for _, q := range []string{
`DELETE FROM core.desired_state_outbox WHERE subject_id IN
(SELECT connection_id FROM core.provider_connections WHERE provider = $1)`,
`DELETE FROM core.provider_connections WHERE provider = $1`,
`DELETE FROM core.resource_keys WHERE provider = $1 OR resource_key LIKE $1 || '\_%'`,
`DELETE FROM core.providers WHERE provider = $1`,
} {
if _, err := database.ExecContext(ctx, q, key); err != nil {
t.Errorf("cleanup %s: %v", key, err)
}
}
})
return key
}
// regKey inserts an unowned resource key "<provider>_<suffix>" of the kind.
func regKey(t *testing.T, database *sql.DB, provider, suffix, kind string) string {
t.Helper()
key := provider + "_" + suffix
if _, err := database.ExecContext(context.Background(),
`INSERT INTO core.resource_keys (resource_key, display_name, unit, kind) VALUES ($1, 'Registration Key', 'unit', $2)`,
key, kind); err != nil {
t.Fatalf("insert key %s: %v", key, err)
}
return key
}
type manifestSource integration.Manifest
func (m manifestSource) ProviderManifest() integration.Manifest { return integration.Manifest(m) }
func regManifest(provider string, recipients map[string]integration.RecipientKind) manifestSource {
keys := make([]string, 0, len(recipients))
for k := range recipients {
keys = append(keys, k)
}
slices.Sort(keys)
return manifestSource{
Key: provider,
Kind: integration.KindProvisioning,
DisplayName: "Registration " + provider,
Operations: []integration.Operation{integration.OpList},
ResourceKeys: keys,
Recipients: recipients,
}
}
func register(t *testing.T, database *sql.DB, sources ...integration.ProviderSource) {
t.Helper()
if err := integration.RegisterProviders(context.Background(), database, sources); err != nil {
t.Fatalf("RegisterProviders: %v", err)
}
}
type connectionState struct {
id, status, approval, displayName string
approvedAt, suspendedAt sql.NullTime
}
// connectionsOf returns every connection of the provider.
func connectionsOf(t *testing.T, database *sql.DB, provider string) []connectionState {
t.Helper()
rows, err := database.QueryContext(context.Background(),
`SELECT connection_id, status, approval_state, display_name, approved_at, suspended_at
FROM core.provider_connections WHERE provider = $1`, provider)
if err != nil {
t.Fatalf("list connections of %s: %v", provider, err)
}
defer rows.Close()
var out []connectionState
for rows.Next() {
var c connectionState
if err := rows.Scan(&c.id, &c.status, &c.approval, &c.displayName, &c.approvedAt, &c.suspendedAt); err != nil {
t.Fatal(err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func onlyConnection(t *testing.T, database *sql.DB, provider string) connectionState {
t.Helper()
conns := connectionsOf(t, database, provider)
if len(conns) != 1 {
t.Fatalf("provider %s has %d connections, want 1", provider, len(conns))
}
return conns[0]
}
// connectionRows counts the desired-state outbox rows naming the connection.
func connectionRows(t *testing.T, database *sql.DB, connectionID string) int {
t.Helper()
var n int
if err := database.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM core.desired_state_outbox WHERE subject_kind = 'connection' AND subject_id = $1`,
connectionID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func recipientOf(t *testing.T, database *sql.DB, key string) sql.NullString {
t.Helper()
var r sql.NullString
if err := database.QueryRowContext(context.Background(),
`SELECT recipient FROM core.resource_keys WHERE resource_key = $1`, key).Scan(&r); err != nil {
t.Fatalf("recipient of %s: %v", key, err)
}
return r
}
func TestRegisterProvidersStampsRecipientsAndEnsuresConnections_DB(t *testing.T) {
database := regDB(t)
ctx := context.Background()
register(t, database, fwmod.ProviderSource(), stripemod.ProviderSource(), dcmod.ProviderSource())
for key, want := range map[string]string{"fedwiki_sites": "organization", "discourse_posting": "person"} {
if got := recipientOf(t, database, key); got.String != want {
t.Errorf("%s recipient = %v, want %s", key, got, want)
}
}
if got := recipientOf(t, database, "external_domain_claims"); got.Valid {
t.Errorf("platform key external_domain_claims carries recipient %q", got.String)
}
for provider, display := range map[string]string{"fedwiki": "FedWiki", "discourse": "Discourse"} {
c := onlyConnection(t, database, provider)
if c.approval != "approved" || !c.approvedAt.Valid || c.status != "active" || c.displayName != display {
t.Errorf("%s connection = %+v, want approved, active, named %q", provider, c, display)
}
}
if conns := connectionsOf(t, database, "stripe"); len(conns) != 0 {
t.Errorf("stripe, which owns no key, has %d connections", len(conns))
}
// The database refuses a recipient on a platform key and an unknown value.
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
for _, tc := range []struct{ key, recipient, constraint string }{
{"external_domain_claims", "person", "platform_keys_no_recipient"},
{"discourse_posting", "workspace", "chk_resource_keys_recipient"},
} {
if _, err := tx.ExecContext(ctx, "SAVEPOINT s"); err != nil {
t.Fatal(err)
}
_, err := tx.ExecContext(ctx, `UPDATE core.resource_keys SET recipient = $2 WHERE resource_key = $1`, tc.key, tc.recipient)
if err == nil || !strings.Contains(err.Error(), tc.constraint) {
t.Errorf("recipient %q on %s: error = %v, want %s", tc.recipient, tc.key, err, tc.constraint)
}
if _, err := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT s"); err != nil {
t.Fatal(err)
}
}
}
func TestRegisterProvidersRefusesBadRecipients_DB(t *testing.T) {
database := regDB(t)
ctx := context.Background()
p := regProvider(t, database)
seats := regKey(t, database, p, "seats", "boolean")
sites := regKey(t, database, p, "sites", "numeric")
cases := []struct {
name string
source manifestSource
want []string // the error names the provider and the key
}{
{
name: "missing recipient",
source: func() manifestSource {
m := regManifest(p, map[string]integration.RecipientKind{seats: integration.RecipientPerson})
m.ResourceKeys = append(m.ResourceKeys, sites)
return m
}(),
want: []string{p, sites, "declares no recipient"},
},
{
name: "recipient for a key it does not own",
source: func() manifestSource {
m := regManifest(p, map[string]integration.RecipientKind{seats: integration.RecipientPerson})
m.Recipients = map[string]integration.RecipientKind{seats: integration.RecipientPerson, sites: integration.RecipientOrganization}
return m
}(),
want: []string{p, sites, "does not own"},
},
{
name: "invalid recipient",
source: regManifest(p, map[string]integration.RecipientKind{seats: "workspace"}),
want: []string{p, seats, "not person or organization"},
},
{
name: "numeric person key",
source: regManifest(p, map[string]integration.RecipientKind{seats: integration.RecipientPerson, sites: integration.RecipientPerson}),
want: []string{p, sites, "person_recipient_keys_boolean"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := integration.RegisterProviders(ctx, database, []integration.ProviderSource{tc.source})
if err == nil {
t.Fatal("registration succeeded, want boot to stop")
}
for _, w := range tc.want {
if !strings.Contains(err.Error(), w) {
t.Errorf("error %q does not contain %q", err, w)
}
}
if conns := connectionsOf(t, database, p); len(conns) != 0 {
t.Errorf("a refused registration left %d connections", len(conns))
}
if got := recipientOf(t, database, sites); got.Valid {
t.Errorf("a refused registration stamped %s with %q", sites, got.String)
}
})
}
}
func TestRegisterProvidersConnectionLifecycle_DB(t *testing.T) {
database := regDB(t)
ctx := context.Background()
owner := regProvider(t, database)
keyless := regProvider(t, database)
seats := regKey(t, database, owner, "seats", "boolean")
first := regManifest(owner, map[string]integration.RecipientKind{seats: integration.RecipientPerson})
none := regManifest(keyless, nil)
// First boot: a connection for the provider that owns keys only, with
// one connection row from its insert.
register(t, database, first, none)
c := onlyConnection(t, database, owner)
if c.approval != "approved" || c.status != "active" || c.displayName != first.DisplayName || !c.approvedAt.Valid {
t.Fatalf("first boot connection = %+v", c)
}
if n := len(connectionsOf(t, database, keyless)); n != 0 {
t.Errorf("the provider owning no key has %d connections", n)
}
if n := connectionRows(t, database, c.id); n != 1 {
t.Errorf("first boot wrote %d connection rows, want 1", n)
}
// A second boot with the same manifest changes nothing and writes no row.
register(t, database, first, none)
if again := onlyConnection(t, database, owner); again != c {
t.Errorf("second boot changed the connection: %+v, was %+v", again, c)
}
if n := connectionRows(t, database, c.id); n != 1 {
t.Errorf("an unchanged boot wrote a row: %d connection rows", n)
}
// A changed recipient re-approves the connection and writes one row.
changed := regManifest(owner, map[string]integration.RecipientKind{seats: integration.RecipientOrganization})
register(t, database, changed, none)
re := onlyConnection(t, database, owner)
if !re.approvedAt.Time.After(c.approvedAt.Time) || re.id != c.id || re.status != c.status || re.approval != "approved" {
t.Errorf("after a changed recipient: %+v, was %+v", re, c)
}
if n := connectionRows(t, database, c.id); n != 2 {
t.Errorf("a changed recipient left %d connection rows, want 2", n)
}
// A newly owned key re-approves it too. (The key is inserted only now:
// an unowned key under the provider's prefix fails the nesting check.)
sites := regKey(t, database, owner, "sites", "numeric")
grown := regManifest(owner, map[string]integration.RecipientKind{
seats: integration.RecipientOrganization, sites: integration.RecipientOrganization})
register(t, database, grown, none)
if g := onlyConnection(t, database, owner); !g.approvedAt.Time.After(re.approvedAt.Time) {
t.Errorf("a newly owned key did not re-approve: %+v", g)
}
if n := connectionRows(t, database, c.id); n != 3 {
t.Errorf("a newly owned key left %d connection rows, want 3", n)
}
// Boot keeps an operator's status and name, and the name it copied at
// insert when the provider's own name changes.
if _, err := database.ExecContext(ctx,
`UPDATE core.provider_connections SET status = 'suspended', suspended_at = NOW(), display_name = 'Renamed'
WHERE connection_id = $1`, c.id); err != nil {
t.Fatal(err)
}
before := onlyConnection(t, database, owner)
renamed := grown
renamed.DisplayName = "Another provider name"
register(t, database, renamed, none)
if after := onlyConnection(t, database, owner); after != before || after.displayName != "Renamed" {
t.Errorf("boot changed an operator's connection: %+v, was %+v", after, before)
}
// A pending connection is not approved by a deploy.
if _, err := database.ExecContext(ctx,
`UPDATE core.provider_connections SET approval_state = 'pending', approved_at = NULL WHERE connection_id = $1`, c.id); err != nil {
t.Fatal(err)
}
flipped := regManifest(owner, map[string]integration.RecipientKind{
seats: integration.RecipientPerson, sites: integration.RecipientOrganization})
register(t, database, flipped, none)
if p := onlyConnection(t, database, owner); p.approval != "pending" || p.approvedAt.Valid {
t.Errorf("a deploy approved a pending connection: %+v", p)
}
// A retired connection is not recreated.
if _, err := database.ExecContext(ctx,
`UPDATE core.provider_connections SET status = 'retired', retired_at = NOW() WHERE connection_id = $1`, c.id); err != nil {
t.Fatal(err)
}
register(t, database, flipped, none)
if r := onlyConnection(t, database, owner); r.id != c.id || r.status != "retired" {
t.Errorf("boot recreated a retired connection: %+v", r)
}
// A provider that stops owning keys keeps its connection.
stopped := regManifest(owner, nil)
register(t, database, stopped, none)
if s := onlyConnection(t, database, owner); s.id != c.id {
t.Errorf("a provider that stopped owning keys lost its connection: %+v", s)
}
}
func TestRegisterProvidersConcurrentBootsLeaveOneConnection_DB(t *testing.T) {
database := regDB(t)
owner := regProvider(t, database)
seats := regKey(t, database, owner, "seats", "boolean")
m := regManifest(owner, map[string]integration.RecipientKind{seats: integration.RecipientPerson})
start := make(chan struct{})
errs := make(chan error, 2)
for range 2 {
go func() {
<-start
errs <- integration.RegisterProviders(context.Background(), database, []integration.ProviderSource{m})
}()
}
close(start)
for range 2 {
if err := <-errs; err != nil {
t.Fatalf("concurrent RegisterProviders: %v", err)
}
}
c := onlyConnection(t, database, owner)
if n := connectionRows(t, database, c.id); n != 1 {
t.Errorf("two concurrent boots wrote %d connection rows, want 1", n)
}
}
// Registration stamps keys in byte order, whatever order a manifest lists
// them in, the order the materializer writes entitlement rows in. A stamp
// that changes a key's provider takes FOR UPDATE on the key row, which waits
// on the FOR KEY SHARE an open entitlement insert of that key holds; a
// common order keeps the two from waiting on each other in a cycle.
func TestRegisterProvidersStampsKeysInByteOrder_DB(t *testing.T) {
database := regDB(t)
ctx := context.Background()
owner := regProvider(t, database)
first := regKey(t, database, owner, "a", "boolean")
second := regKey(t, database, owner, "b", "boolean")
m := regManifest(owner, map[string]integration.RecipientKind{
first: integration.RecipientPerson, second: integration.RecipientPerson,
})
m.ResourceKeys = []string{second, first}
// An open materialization holds the first key.
holder, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = holder.Rollback() }()
if _, err := holder.ExecContext(ctx,
`SELECT 1 FROM core.resource_keys WHERE resource_key = $1 FOR KEY SHARE`, first); err != nil {
t.Fatal(err)
}
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
done <- integration.RegisterProviders(runCtx, database, []integration.ProviderSource{m})
}()
// Wait until registration waits on the first key.
deadline := time.Now().Add(10 * time.Second)
for {
var waiting int
if err := database.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pg_stat_activity
WHERE datname = current_database() AND wait_event_type = 'Lock'
AND query LIKE '%UPDATE core.resource_keys%'`).Scan(&waiting); err != nil {
t.Fatal(err)
}
if waiting > 0 {
break
}
select {
case err := <-done:
t.Fatalf("registration finished (%v) while the first key was held", err)
default:
}
if time.Now().After(deadline) {
t.Fatal("registration never waited on the held key")
}
time.Sleep(20 * time.Millisecond)
}
// Registration has not stamped the second key yet: nothing holds it.
_, lockErr := database.ExecContext(ctx,
`SELECT 1 FROM core.resource_keys WHERE resource_key = $1 FOR KEY SHARE NOWAIT`, second)
if err := holder.Rollback(); err != nil {
t.Fatal(err)
}
if err := <-done; err != nil {
t.Fatalf("RegisterProviders: %v", err)
}
if lockErr != nil {
t.Errorf("registration locked %s before %s, which sorts first: %v", second, first, lockErr)
}
}
+50 -4
View File
@@ -3,13 +3,16 @@
package integration
import "testing"
import (
"strings"
"testing"
)
func TestValidateManifest(t *testing.T) {
tests := []struct {
name string
m Manifest
wantErr bool
wantErr any // true, or the text the error must contain
}{
{
name: "valid provisioning provider",
@@ -19,8 +22,39 @@ func TestValidateManifest(t *testing.T) {
DisplayName: "Example",
Operations: []Operation{OpCreate, OpDelete, OpList},
ResourceKeys: []string{"example_sites"},
Recipients: map[string]RecipientKind{"example_sites": RecipientOrganization},
},
},
{
name: "owned key without a recipient is rejected",
m: Manifest{
Key: "example", Kind: KindProvisioning, DisplayName: "Example",
ResourceKeys: []string{"example_sites", "example_posting"},
Recipients: map[string]RecipientKind{"example_sites": RecipientOrganization},
},
wantErr: "owned resource key \"example_posting\" declares no recipient",
},
{
name: "recipient for a key the provider does not own is rejected",
m: Manifest{
Key: "example", Kind: KindProvisioning, DisplayName: "Example",
ResourceKeys: []string{"example_sites"},
Recipients: map[string]RecipientKind{
"example_sites": RecipientOrganization,
"example_posting": RecipientPerson,
},
},
wantErr: "recipient declaration for \"example_posting\", a key it does not own",
},
{
name: "recipient that is neither person nor organization is rejected",
m: Manifest{
Key: "example", Kind: KindProvisioning, DisplayName: "Example",
ResourceKeys: []string{"example_sites"},
Recipients: map[string]RecipientKind{"example_sites": "workspace"},
},
wantErr: "recipient \"workspace\" for \"example_sites\" is not person or organization",
},
{
name: "valid payment provider with no ops or keys",
m: Manifest{Key: "stripe", Kind: KindPayment, DisplayName: "Stripe"},
@@ -103,8 +137,20 @@ func TestValidateManifest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateManifest(tt.m)
if (err != nil) != tt.wantErr {
t.Fatalf("validateManifest() error = %v, wantErr %v", err, tt.wantErr)
switch want := tt.wantErr.(type) {
case nil:
if err != nil {
t.Fatalf("validateManifest() error = %v, want none", err)
}
case bool:
if (err != nil) != want {
t.Fatalf("validateManifest() error = %v, wantErr %v", err, want)
}
case string:
// The error names the provider and the key.
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), `provider "example"`) {
t.Fatalf("validateManifest() error = %v, want one naming provider \"example\" and containing %q", err, want)
}
}
})
}
+12
View File
@@ -25,6 +25,18 @@ sql:
core_resource_keys: ResourceKey
core_integration_config_override: ConfigOverride
core_integration_config_overrides: ConfigOverride
core_provider_connection: ProviderConnection
core_provider_connections: ProviderConnection
core_desired_state_record: DesiredStateRecord
core_desired_state_records: DesiredStateRecord
core_observed_projection: ObservedProjection
core_observed_projections: ObservedProjection
core_lifecycle_request: LifecycleRequest
core_lifecycle_requests: LifecycleRequest
core_desired_state_outbox: DesiredStateOutbox
core_desired_state_outboxes: DesiredStateOutbox
core_target_mapping: TargetMapping
core_target_mappings: TargetMapping
# Spillover tables pulled in because ../db/migrations/ declares the
# whole "core" schema. Renamed to match the exact pre-consolidation
# type names (from their old owning schema) so query result/model
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package integration
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
)
// Transport carries desired-state records to the application one connection
// reaches, and reads back what that application holds (design D20). The
// relay, the push workflow and the read-back depend on this interface only,
// so they do not change when a wire transport arrives: a transport translates
// the console's identifiers to whatever its protocol carries at its own
// boundary. Lifecycle requests are not part of it.
//
// List is required, not optional: after a restore of the console's database,
// a read-back is the only way the console learns that an application holds a
// newer generation for a record whose sources did not change.
//
// A transport wraps ErrPermanent for an answer retrying cannot change; every
// other error is retried within the push budget.
type Transport interface {
Apply(ctx context.Context, req ApplyRequest) (ApplyResult, error)
List(ctx context.Context, req ListRequest) (ListPage, error)
}
// ErrPermanent marks a transport error that retrying cannot change. A push
// that meets it stops retrying at once and leaves the record as lag.
var ErrPermanent = errors.New("integration: permanent transport error")
// Recipient is whoever a record is delivered to: a person or an
// organization, by the console's own identifier.
type Recipient struct {
Kind RecipientKind
ID uuid.UUID
}
// String renders the recipient as "<kind>:<id>", the form the relay's
// expansion cursor and the address lock use.
func (r Recipient) String() string {
return fmt.Sprintf("%s:%s", r.Kind, r.ID)
}
// DesiredEntitlement is one entry of a record's content: a boolean key held
// (Granted), or a numeric key's limit above zero (Limit), with the
// application's own identifiers of the groups or roles that deliver it
// (Targets), when the connection maps the key. Absence means "not held".
type DesiredEntitlement struct {
Key string `json:"key"`
Granted bool `json:"granted,omitempty"`
Limit *int64 `json:"limit,omitempty"`
Targets []string `json:"targets,omitempty"`
}
// DesiredContent is the canonical form of a record's content, as
// core.desired_state_content builds it.
type DesiredContent struct {
Entitlements []DesiredEntitlement `json:"entitlements"`
}
// DecodeDesiredContent decodes a record's stored content.
func DecodeDesiredContent(raw json.RawMessage) (DesiredContent, error) {
var c DesiredContent
if err := json.Unmarshal(raw, &c); err != nil {
return DesiredContent{}, fmt.Errorf("decode desired-state content: %w", err)
}
if c.Entitlements == nil {
c.Entitlements = []DesiredEntitlement{}
}
return c, nil
}
// ApplyRequest pushes one whole record. ContentHash is the console's hash of
// the content: opaque to the transport and the application, which store it
// and compare it for equality and never compute it.
type ApplyRequest struct {
ConnectionID uuid.UUID
Recipient Recipient
Generation int64
ContentHash []byte
Entitlements []DesiredEntitlement
}
// ApplyOutcome is the application's answer to a push.
type ApplyOutcome string
const (
// OutcomeApplied: the application now holds the pushed generation.
OutcomeApplied ApplyOutcome = "applied"
// OutcomeUnlinked: the recipient has no account in the application yet;
// it keeps the generation it holds and applies the pushed one when the
// account appears.
OutcomeUnlinked ApplyOutcome = "unlinked"
// OutcomeStale: the application holds a generation above the pushed one.
OutcomeStale ApplyOutcome = "stale"
// OutcomeConflict: the application holds the pushed generation with
// other content.
OutcomeConflict ApplyOutcome = "conflict"
)
// ApplyResult is the application's answer. AppliedGeneration is the
// generation the application holds after the call: the pushed one when
// applied, the one it keeps when unlinked, the higher one when stale, the
// pushed one when conflicting. NextAction is a key the console maps to its
// own copy (such as "sign_in"), never text from the application.
type ApplyResult struct {
Outcome ApplyOutcome
AppliedGeneration int64
NextAction string
}
// ListRequest asks for one page of what the application holds at a
// connection, after Cursor ("" for the first page), at most Limit items.
type ListRequest struct {
ConnectionID uuid.UUID
Cursor string
Limit int
}
// ListItem is what the application reports holding for one recipient:
// the generation it applied and that generation's hash (0 and nil when it
// has applied none), and the entitlements it holds, in content's shape.
type ListItem struct {
Recipient Recipient
AppliedGeneration int64
AppliedContentHash []byte
Entitlements []DesiredEntitlement
}
// ListPage is one page of a read-back. An empty NextCursor ends it.
type ListPage struct {
Items []ListItem
NextCursor string
}
// TransportLookup tells the relay, the push and the read-back which
// transport serves a connection. A connection no transport serves gets
// records and no pushes, and shows no lag.
type TransportLookup interface {
TransportFor(connectionID uuid.UUID) (Transport, bool)
}
// NoTransports is the production lookup until a wire transport exists: it
// serves no connection, so records are computed and stored and nothing is
// pushed or read back.
type NoTransports struct{}
// TransportFor reports that no transport serves the connection.
func (NoTransports) TransportFor(uuid.UUID) (Transport, bool) { return nil, false }
@@ -0,0 +1,302 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Package transporttest provides a recording integration.Transport for the
// relay, push and read-back tests: it records every call in order, notices
// calls for one record that overlap, answers Apply from a script and serves
// List pages from a script.
package transporttest
import (
"context"
"errors"
"fmt"
"slices"
"sync"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// Answer is one scripted answer to Apply.
type Answer struct {
// Outcome is the answer's outcome when Err is nil.
Outcome integration.ApplyOutcome
// Generation is the generation the application reports holding. For an
// applied or conflict answer, zero means the pushed generation, which is
// what those answers report. For an unlinked or stale answer it is
// reported as given, so Unlinked(0, ...) is an application that holds no
// generation for the recipient yet.
Generation int64
// NextAction is the key an unlinked answer carries.
NextAction string
// Err, when set, is returned instead of a result.
Err error
// Hold, when set, keeps the call open until the channel is closed or
// the call's context ends, so a test can make two calls overlap.
Hold <-chan struct{}
}
// Applied answers that the application holds the pushed generation.
func Applied() Answer { return Answer{Outcome: integration.OutcomeApplied} }
// Unlinked answers that the recipient has no account yet and the
// application keeps generation held (0 when it holds none).
func Unlinked(held int64, nextAction string) Answer {
return Answer{Outcome: integration.OutcomeUnlinked, Generation: held, NextAction: nextAction}
}
// Stale answers that the application holds generation held, above the
// pushed one.
func Stale(held int64) Answer {
return Answer{Outcome: integration.OutcomeStale, Generation: held}
}
// Conflict answers that the application holds the pushed generation with
// other content.
func Conflict() Answer { return Answer{Outcome: integration.OutcomeConflict} }
// Retryable answers with an error the push retries.
func Retryable(msg string) Answer { return Answer{Err: errors.New(msg)} }
// Permanent answers with an error wrapping integration.ErrPermanent.
func Permanent(msg string) Answer {
return Answer{Err: fmt.Errorf("%s: %w", msg, integration.ErrPermanent)}
}
// Page is one scripted answer to List.
type Page struct {
Page integration.ListPage
Err error
}
// Call is one recorded call. Exactly one of Apply and List is set.
type Call struct {
Seq int
Apply *integration.ApplyRequest
List *integration.ListRequest
}
// RecordKey names one record: a connection and a recipient.
type RecordKey struct {
ConnectionID uuid.UUID
Recipient integration.Recipient
}
// Transport is the recording transport. The zero value is ready: an
// unscripted Apply answers Applied, an unscripted List an empty last page.
type Transport struct {
mu sync.Mutex
calls []Call
applyFor map[RecordKey][]Answer
apply []Answer
pages []Page
inFlight map[RecordKey]int
overlaps map[RecordKey]int
maxFlight map[RecordKey]int
}
var _ integration.Transport = (*Transport)(nil)
// New returns an empty recording transport.
func New() *Transport { return &Transport{} }
// ScriptApply queues answers for Apply calls on any record, in order. An
// answer queued for the call's record by ScriptApplyFor is used first.
func (t *Transport) ScriptApply(answers ...Answer) {
t.mu.Lock()
defer t.mu.Unlock()
t.apply = append(t.apply, answers...)
}
// ScriptApplyFor queues answers for Apply calls on one record, in order.
func (t *Transport) ScriptApplyFor(key RecordKey, answers ...Answer) {
t.mu.Lock()
defer t.mu.Unlock()
if t.applyFor == nil {
t.applyFor = make(map[RecordKey][]Answer)
}
t.applyFor[key] = append(t.applyFor[key], answers...)
}
// ScriptList queues pages for List calls, in order.
func (t *Transport) ScriptList(pages ...Page) {
t.mu.Lock()
defer t.mu.Unlock()
t.pages = append(t.pages, pages...)
}
// Apply records the call and answers from the script.
func (t *Transport) Apply(ctx context.Context, req integration.ApplyRequest) (integration.ApplyResult, error) {
key := RecordKey{ConnectionID: req.ConnectionID, Recipient: req.Recipient}
t.mu.Lock()
recorded := cloneApply(req)
t.calls = append(t.calls, Call{Seq: len(t.calls) + 1, Apply: &recorded})
if t.inFlight == nil {
t.inFlight = make(map[RecordKey]int)
t.overlaps = make(map[RecordKey]int)
t.maxFlight = make(map[RecordKey]int)
}
if t.inFlight[key] > 0 {
t.overlaps[key]++
}
t.inFlight[key]++
t.maxFlight[key] = max(t.maxFlight[key], t.inFlight[key])
answer := t.nextAnswer(key)
t.mu.Unlock()
defer func() {
t.mu.Lock()
t.inFlight[key]--
t.mu.Unlock()
}()
if answer.Hold != nil {
select {
case <-answer.Hold:
case <-ctx.Done():
return integration.ApplyResult{}, ctx.Err()
}
}
if answer.Err != nil {
return integration.ApplyResult{}, answer.Err
}
generation := answer.Generation
if generation == 0 && (answer.Outcome == integration.OutcomeApplied || answer.Outcome == integration.OutcomeConflict) {
generation = req.Generation
}
return integration.ApplyResult{
Outcome: answer.Outcome,
AppliedGeneration: generation,
NextAction: answer.NextAction,
}, nil
}
// nextAnswer pops the record's own script, then the shared one, and falls
// back to Applied. The caller holds t.mu.
func (t *Transport) nextAnswer(key RecordKey) Answer {
if q := t.applyFor[key]; len(q) > 0 {
t.applyFor[key] = q[1:]
return q[0]
}
if len(t.apply) > 0 {
a := t.apply[0]
t.apply = t.apply[1:]
return a
}
return Applied()
}
// List records the call and serves the next scripted page.
func (t *Transport) List(_ context.Context, req integration.ListRequest) (integration.ListPage, error) {
t.mu.Lock()
defer t.mu.Unlock()
recorded := req
t.calls = append(t.calls, Call{Seq: len(t.calls) + 1, List: &recorded})
if len(t.pages) == 0 {
return integration.ListPage{Items: []integration.ListItem{}}, nil
}
p := t.pages[0]
t.pages = t.pages[1:]
if p.Err != nil {
return integration.ListPage{}, p.Err
}
return p.Page, nil
}
// Calls returns every call so far, in the order they were made.
func (t *Transport) Calls() []Call {
t.mu.Lock()
defer t.mu.Unlock()
return slices.Clone(t.calls)
}
// ApplyCalls returns the Apply requests so far, in order.
func (t *Transport) ApplyCalls() []integration.ApplyRequest {
t.mu.Lock()
defer t.mu.Unlock()
var out []integration.ApplyRequest
for _, c := range t.calls {
if c.Apply != nil {
out = append(out, *c.Apply)
}
}
return out
}
// Overlaps returns how many Apply calls for the record began while another
// call for the same record was still open. Pushes for one record must never
// overlap, so a test expects 0.
func (t *Transport) Overlaps(key RecordKey) int {
t.mu.Lock()
defer t.mu.Unlock()
return t.overlaps[key]
}
// TotalOverlaps sums Overlaps over every record.
func (t *Transport) TotalOverlaps() int {
t.mu.Lock()
defer t.mu.Unlock()
n := 0
for _, v := range t.overlaps {
n += v
}
return n
}
// MaxConcurrent returns the most Apply calls for the record open at once.
func (t *Transport) MaxConcurrent(key RecordKey) int {
t.mu.Lock()
defer t.mu.Unlock()
return t.maxFlight[key]
}
func cloneApply(req integration.ApplyRequest) integration.ApplyRequest {
out := req
out.ContentHash = slices.Clone(req.ContentHash)
out.Entitlements = make([]integration.DesiredEntitlement, len(req.Entitlements))
for i, e := range req.Entitlements {
e.Targets = slices.Clone(e.Targets)
if e.Limit != nil {
limit := *e.Limit
e.Limit = &limit
}
out.Entitlements[i] = e
}
return out
}
// Lookup is a TransportLookup serving the connections a test registers.
type Lookup struct {
mu sync.Mutex
served map[uuid.UUID]integration.Transport
}
var _ integration.TransportLookup = (*Lookup)(nil)
// NewLookup returns a lookup serving no connection.
func NewLookup() *Lookup { return &Lookup{served: make(map[uuid.UUID]integration.Transport)} }
// Serve makes transport serve the connection.
func (l *Lookup) Serve(connectionID uuid.UUID, transport integration.Transport) {
l.mu.Lock()
defer l.mu.Unlock()
l.served[connectionID] = transport
}
// Withdraw stops serving the connection.
func (l *Lookup) Withdraw(connectionID uuid.UUID) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.served, connectionID)
}
// TransportFor returns the transport serving the connection, if any.
func (l *Lookup) TransportFor(connectionID uuid.UUID) (integration.Transport, bool) {
l.mu.Lock()
defer l.mu.Unlock()
t, ok := l.served[connectionID]
return t, ok
}
@@ -0,0 +1,240 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package transporttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
func request(conn uuid.UUID, r integration.Recipient, generation int64) integration.ApplyRequest {
limit := int64(3)
return integration.ApplyRequest{
ConnectionID: conn,
Recipient: r,
Generation: generation,
ContentHash: []byte{1, 2, 3},
Entitlements: []integration.DesiredEntitlement{
{Key: "example_posting", Granted: true, Targets: []string{"17"}},
{Key: "example_sites", Limit: &limit},
},
}
}
func TestApplyAnswersFromTheScript(t *testing.T) {
ctx := context.Background()
conn := uuid.New()
person := integration.Recipient{Kind: integration.RecipientPerson, ID: uuid.New()}
org := integration.Recipient{Kind: integration.RecipientOrganization, ID: uuid.New()}
tr := New()
tr.ScriptApply(
Applied(),
Unlinked(40, "sign_in"),
Unlinked(0, "sign_in"),
Stale(900),
Conflict(),
Retryable("connection reset"),
Permanent("refused"),
)
// The organization's own script is used before the shared one.
tr.ScriptApplyFor(RecordKey{ConnectionID: conn, Recipient: org}, Stale(7000))
type want struct {
outcome integration.ApplyOutcome
generation int64
next string
err string
permanent bool
}
cases := []struct {
r integration.Recipient
gen int64
want want
}{
{org, 50, want{outcome: integration.OutcomeStale, generation: 7000}},
{person, 50, want{outcome: integration.OutcomeApplied, generation: 50}},
{person, 51, want{outcome: integration.OutcomeUnlinked, generation: 40, next: "sign_in"}},
// An application holding nothing for the recipient reports 0, not
// the pushed generation, so the push never counts it as applied.
{person, 51, want{outcome: integration.OutcomeUnlinked, generation: 0, next: "sign_in"}},
{person, 52, want{outcome: integration.OutcomeStale, generation: 900}},
{person, 53, want{outcome: integration.OutcomeConflict, generation: 53}},
{person, 54, want{err: "connection reset"}},
{person, 55, want{err: "refused", permanent: true}},
// The script is spent: the default answer is applied.
{person, 56, want{outcome: integration.OutcomeApplied, generation: 56}},
}
for i, c := range cases {
got, err := tr.Apply(ctx, request(conn, c.r, c.gen))
if c.want.err != "" {
if err == nil || err.Error() == "" {
t.Fatalf("call %d: error = %v, want %q", i, err, c.want.err)
}
if errors.Is(err, integration.ErrPermanent) != c.want.permanent {
t.Errorf("call %d: permanent = %v, want %v", i, errors.Is(err, integration.ErrPermanent), c.want.permanent)
}
continue
}
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
if got.Outcome != c.want.outcome || got.AppliedGeneration != c.want.generation || got.NextAction != c.want.next {
t.Errorf("call %d: got %+v, want %+v", i, got, c.want)
}
}
}
func TestCallsAreRecordedInOrderAndCopied(t *testing.T) {
ctx := context.Background()
conn := uuid.New()
person := integration.Recipient{Kind: integration.RecipientPerson, ID: uuid.New()}
tr := New()
req := request(conn, person, 10)
if _, err := tr.Apply(ctx, req); err != nil {
t.Fatal(err)
}
// Changing the caller's request afterwards does not change the record.
req.Entitlements[0].Targets[0] = "changed"
*req.Entitlements[1].Limit = 99
req.ContentHash[0] = 9
tr.ScriptList(Page{Page: integration.ListPage{
Items: []integration.ListItem{{Recipient: person, AppliedGeneration: 10}},
NextCursor: "c1",
}}, Page{Err: errors.New("unavailable")})
page, err := tr.List(ctx, integration.ListRequest{ConnectionID: conn, Limit: 200})
if err != nil || page.NextCursor != "c1" || len(page.Items) != 1 {
t.Fatalf("first page = %+v, %v", page, err)
}
if _, err := tr.List(ctx, integration.ListRequest{ConnectionID: conn, Cursor: "c1", Limit: 200}); err == nil {
t.Fatal("second page: want the scripted error")
}
page, err = tr.List(ctx, integration.ListRequest{ConnectionID: conn, Limit: 200})
if err != nil || page.NextCursor != "" || len(page.Items) != 0 {
t.Fatalf("unscripted page = %+v, %v; want an empty last page", page, err)
}
calls := tr.Calls()
if len(calls) != 4 {
t.Fatalf("recorded %d calls, want 4", len(calls))
}
for i, c := range calls {
if c.Seq != i+1 {
t.Errorf("call %d has Seq %d", i, c.Seq)
}
}
if calls[0].Apply == nil || calls[1].List == nil || calls[2].List.Cursor != "c1" || calls[3].List == nil {
t.Fatalf("calls out of order: %+v", calls)
}
got := calls[0].Apply
if got.Generation != 10 || got.Entitlements[0].Targets[0] != "17" || *got.Entitlements[1].Limit != 3 || got.ContentHash[0] != 1 {
t.Errorf("recorded request changed with the caller's: %+v", got)
}
if applies := tr.ApplyCalls(); len(applies) != 1 || applies[0].Recipient != person {
t.Errorf("ApplyCalls = %+v", applies)
}
}
func TestOverlappingCallsPerRecordAreCounted(t *testing.T) {
ctx := context.Background()
conn := uuid.New()
a := RecordKey{ConnectionID: conn, Recipient: integration.Recipient{Kind: integration.RecipientPerson, ID: uuid.New()}}
b := RecordKey{ConnectionID: conn, Recipient: integration.Recipient{Kind: integration.RecipientPerson, ID: uuid.New()}}
tr := New()
release := make(chan struct{})
tr.ScriptApplyFor(a, Answer{Outcome: integration.OutcomeApplied, Hold: release}, Applied())
tr.ScriptApplyFor(b, Answer{Outcome: integration.OutcomeApplied, Hold: release})
done := make(chan error, 2)
go func() { _, err := tr.Apply(ctx, request(conn, a.Recipient, 1)); done <- err }()
go func() { _, err := tr.Apply(ctx, request(conn, b.Recipient, 1)); done <- err }()
waitFor(t, func() bool { return len(tr.Calls()) == 2 })
// A second call for record a while its first is held overlaps; the held
// call for record b is another record and does not count against a.
if _, err := tr.Apply(ctx, request(conn, a.Recipient, 2)); err != nil {
t.Fatal(err)
}
close(release)
for range 2 {
if err := <-done; err != nil {
t.Fatal(err)
}
}
if got := tr.Overlaps(a); got != 1 {
t.Errorf("Overlaps(a) = %d, want 1", got)
}
if got := tr.Overlaps(b); got != 0 {
t.Errorf("Overlaps(b) = %d, want 0", got)
}
if got := tr.MaxConcurrent(a); got != 2 {
t.Errorf("MaxConcurrent(a) = %d, want 2", got)
}
if got := tr.TotalOverlaps(); got != 1 {
t.Errorf("TotalOverlaps = %d, want 1", got)
}
// Sequential calls never overlap.
seq := New()
for g := int64(1); g <= 3; g++ {
if _, err := seq.Apply(ctx, request(conn, a.Recipient, g)); err != nil {
t.Fatal(err)
}
}
if seq.TotalOverlaps() != 0 || seq.MaxConcurrent(a) != 1 {
t.Errorf("sequential calls: overlaps %d, max %d", seq.TotalOverlaps(), seq.MaxConcurrent(a))
}
}
func TestHeldCallEndsWithItsContext(t *testing.T) {
conn := uuid.New()
r := integration.Recipient{Kind: integration.RecipientOrganization, ID: uuid.New()}
tr := New()
tr.ScriptApply(Answer{Outcome: integration.OutcomeApplied, Hold: make(chan struct{})})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := tr.Apply(ctx, request(conn, r, 1)); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v, want the context's deadline", err)
}
}
func TestLookupServesRegisteredConnections(t *testing.T) {
served, other := uuid.New(), uuid.New()
l := NewLookup()
tr := New()
l.Serve(served, tr)
if got, ok := l.TransportFor(served); !ok || got != tr {
t.Errorf("served connection: %v, %v", got, ok)
}
if _, ok := l.TransportFor(other); ok {
t.Error("an unregistered connection is served")
}
l.Withdraw(served)
if _, ok := l.TransportFor(served); ok {
t.Error("a withdrawn connection is still served")
}
if _, ok := (integration.NoTransports{}).TransportFor(served); ok {
t.Error("the production lookup serves a connection")
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatal("condition not met in time")
}
time.Sleep(time.Millisecond)
}
}
@@ -32,5 +32,10 @@ func (providerSource) ProviderManifest() integration.Manifest {
integration.OpDescribe,
},
ResourceKeys: []string{"discourse_posting"},
// Delivered as forum group membership, to each person whose
// organizations hold it.
Recipients: map[string]integration.RecipientKind{
"discourse_posting": integration.RecipientPerson,
},
}
}
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fedwiki
import (
"context"
"fmt"
"slices"
"testing"
"time"
"github.com/google/uuid"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
)
// TestAcceptedCreateQueries covers the two store queries that leave an open
// create alone (integration-delivery-core task 3.6, design D30): the domains
// of accepted create requests, which the sync skips (ListDomainsWithOpenRequests), and the orphan sweep's
// exclusion of a placement whose resource_ref is an accepted create's
// instance. Everything runs in one transaction that is rolled back.
func TestAcceptedCreateQueries(t *testing.T) {
database := newReconcileDomainsDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
scan := func(q string, args ...any) string {
t.Helper()
var v string
if err := tx.QueryRowContext(ctx, q, args...).Scan(&v); err != nil {
t.Fatalf("%s: %v", q, err)
}
return v
}
exec := func(q string, args ...any) {
t.Helper()
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
uniq := fmt.Sprintf("acq%d", time.Now().UnixNano())
user := scan(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+uniq)
person := scan(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1, 'Tester', $2) RETURNING person_id`,
user, uniq+"@example.test")
org := scan(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ('Org', 'personal', $1) RETURNING org_id`, person)
ws := scan(`INSERT INTO core.workspaces (org_id, name) VALUES ($1, 'WS') RETURNING workspace_id`, org)
exec(`INSERT INTO core.providers (provider, provider_kind, display_name) VALUES ('fedwiki', 'provisioning', 'FedWiki')
ON CONFLICT (provider) DO NOTHING`)
exec(`INSERT INTO core.provider_connections (provider, display_name, approval_state, approved_at)
VALUES ('fedwiki', 'FedWiki', 'approved', NOW()) ON CONFLICT (provider) DO NOTHING`)
conn := scan(`SELECT connection_id FROM core.provider_connections WHERE provider = 'fedwiki'`)
root := uniq + ".example.test"
claim := scan(`INSERT INTO domains.claims (workspace_id, root_fqdn, reversed_labels, kind, status)
VALUES ($1, $2, $2, 'operator_root', 'active') RETURNING claim_id`, ws, root)
place := func(fqdn, ref string) {
exec(`INSERT INTO domains.placements (claim_id, fqdn, reversed_labels, provider, resource_ref)
VALUES ($1, $2, $2, 'fedwiki', $3)`, claim, fqdn, ref)
}
pendingInstance, strandedRef := uuid.New().String(), uuid.New().String()
pendingFQDN, strandedFQDN := "pending."+root, "stranded."+root
place(pendingFQDN, pendingInstance)
place(strandedFQDN, strandedRef)
exec(`INSERT INTO core.lifecycle_requests
(instance_id, generation, connection_id, resource_key, workspace_id, operation, parameters, workflow_id)
VALUES ($1::uuid, core.next_generation(NULL, NULL), $2, 'fedwiki_sites', $3, 'create',
jsonb_build_object('domain', $4::text), 'create-fedwiki-site-' || $1::uuid::text)`,
pendingInstance, conn, ws, pendingFQDN)
q := fwmod.New(tx)
orphans := func() []string {
rows, err := q.ListOrphanedPlacements(ctx)
if err != nil {
t.Fatal(err)
}
var out []string
for _, r := range rows {
if r.Fqdn == pendingFQDN || r.Fqdn == strandedFQDN {
out = append(out, r.Fqdn)
}
}
return out
}
domains, err := q.ListDomainsWithOpenRequests(ctx)
if err != nil {
t.Fatal(err)
}
if !slices.Contains(domains, pendingFQDN) {
t.Errorf("accepted create domains %v lack %s", domains, pendingFQDN)
}
if got := orphans(); !slices.Equal(got, []string{strandedFQDN}) {
t.Errorf("orphans = %v, want only %s: an accepted create's placement is not an orphan", got, strandedFQDN)
}
// Once the create fails, its placement is an orphan like any other.
exec(`UPDATE core.lifecycle_requests SET status = 'failed', finished_at = NOW() WHERE instance_id = $1`, pendingInstance)
domains, err = q.ListDomainsWithOpenRequests(ctx)
if err != nil {
t.Fatal(err)
}
if slices.Contains(domains, pendingFQDN) {
t.Errorf("a failed create's domain %s is still listed", pendingFQDN)
}
if got := orphans(); !slices.Equal(got, []string{pendingFQDN, strandedFQDN}) {
t.Errorf("orphans after the create failed = %v", got)
}
}
+86 -9
View File
@@ -56,6 +56,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"sync/atomic"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
@@ -114,13 +115,44 @@ func staticFS() fs.FS {
// New returns the FedWiki integration adapter.
func New() Adapter {
return Adapter{}
return Adapter{conn: &connectionBinding{}}
}
// Adapter implements the FedWiki integration's capability hooks, delegating
// to store (module) and workflows (Temporal) — see the package doc comment
// for why those live in separate packages within this tree.
type Adapter struct{}
type Adapter struct {
// conn holds FedWiki's connection identifier once boot has bound it
// (integrations.ConnectionBinder). A pointer, so the adapter value the
// integration list carries and every copy of it share one binding.
conn *connectionBinding
}
// connectionBinding is the connection identifier cached at boot.
type connectionBinding struct {
id atomic.Pointer[string]
}
// BindConnection caches FedWiki's connection identifier, looked up once at
// boot after provider registration ensured the row.
func (a Adapter) BindConnection(connectionID string) {
if a.conn == nil {
panic("fedwiki: BindConnection on an adapter not built by New")
}
a.conn.id.Store(&connectionID)
}
// ConnectionID returns the identifier BindConnection cached, or "" before
// boot has bound it.
func (a Adapter) ConnectionID() string {
if a.conn == nil {
return ""
}
if id := a.conn.id.Load(); id != nil {
return *id
}
return ""
}
// Key returns FedWiki's provider-registry key.
func (Adapter) Key() string {
@@ -199,7 +231,7 @@ func (Adapter) ConfigSpec() []config.ConfigKey {
// scheme keys read here. The custom-domain connect target is the opposite
// case — core configuration (domains-connect-target) — so it arrives as
// data on Deps, resolved once by the composition root.
func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
func (a Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
siteQ := fwmod.New(deps.Database)
allowedDomains := viper.GetStringSlice("fedwiki-allowed-domains")
siteScheme := viper.GetString("fedwiki-site-scheme")
@@ -233,6 +265,7 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
CustomDomainTarget: customDomainTarget,
CustomDomainsGate: customDomainsGate,
SupportURL: supportURL,
ConnectionID: a.ConnectionID,
})
apiHandler.RegisterRoutes(mux)
@@ -250,6 +283,7 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
CustomDomainsGate: customDomainsGate,
SupportURL: supportURL,
SwapCooldown: viper.GetDuration("fedwiki-swap-cooldown"),
ConnectionID: a.ConnectionID,
TemplatesFS: templatesFS(),
Include: deps.Include,
})
@@ -350,11 +384,10 @@ func (Adapter) Static() fs.FS {
// database/logger parameters — mirroring RegisterRoutes above. ConfigSpec
// declares the farm-api-url/allowed-domains/admin-token keys read here.
func (Adapter) RegisterWorkflows(w worker.Worker, database *sql.DB, logger *slog.Logger) {
w.RegisterWorkflow(workflows.CreateFedWikiSiteWorkflow)
w.RegisterWorkflow(workflows.DeleteFedWikiSiteWorkflow)
w.RegisterWorkflow(workflows.SetSiteStatusWorkflow)
w.RegisterWorkflow(workflows.SwapActiveSiteWorkflow)
w.RegisterWorkflow(workflows.SyncFedWikiSitesWorkflow)
// The lifecycle types created before lifecycle requests stay registered
// for one release beside their request-driven successors, so runs in
// flight at deploy finish on the code that started them (design D28).
workflows.RegisterWorkflows(w)
// VerifyCustomDomainWorkflow is gone: domain-control verification is a
// registry concern now, run by internal/workflows/domains.VerifyClaimWorkflow
// against `domains.claims` and registered by the core worker (design D4).
@@ -388,7 +421,26 @@ func (Adapter) RegisterWorkflows(w worker.Worker, database *sql.DB, logger *slog
// app-database wipe). cmd/start.go's separate, unconditional, fatal
// systemtenant.Ensure call is untouched and remains the boot-time guarantee that
// the System tenant exists at all, independent of FedWiki.
func (Adapter) Startup(ctx context.Context, c client.Client, taskQueue string, database *sql.DB, logger *slog.Logger) error {
func (a Adapter) Startup(ctx context.Context, c client.Client, taskQueue string, database *sql.DB, logger *slog.Logger) error {
// Settle lifecycle requests whose workflow never recorded an outcome
// (design D27) before the sync check below returns early: the sync is
// off by default, and the requests are the member's, not the sync's.
a.settleLifecycleRequests(ctx, c, taskQueue, database, logger)
// A request committed just before the process died is younger than
// SettleRequestAge at a quick restart, so the boot pass leaves it; a
// second pass once that age has passed settles it instead of the next
// boot. The delay is read here, before the goroutine starts.
again := settleAgainAfter
go func() {
timer := time.NewTimer(again)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
a.settleLifecycleRequests(ctx, c, taskQueue, database, logger)
}
}()
if !viper.GetBool("fedwiki-sync-enabled") {
// Schedules are durable: one ensured by an earlier sync-enabled boot
// would keep firing into this boot's unconfigured sync path forever.
@@ -413,3 +465,28 @@ func (Adapter) Startup(ctx context.Context, c client.Client, taskQueue string, d
}
return nil
}
// settleAgainAfter is when, after boot, the second settle pass runs: past
// SettleRequestAge, so every request committed before the boot is old enough
// to settle. A variable so a test can shorten it.
var settleAgainAfter = workflows.SettleRequestAge + 10*time.Second
// settleLifecycleRequests runs the boot pass over FedWiki's accepted
// lifecycle requests. Non-fatal, as the rest of Startup: a failure is logged
// and the next boot tries again.
func (a Adapter) settleLifecycleRequests(ctx context.Context, c workflows.LifecycleClient, taskQueue string, database *sql.DB, logger *slog.Logger) {
counts, err := workflows.SettleOpenRequests(ctx, c, database, a.ConnectionID(), taskQueue, time.Now(), logger)
if err != nil {
logger.Error("failed to settle FedWiki lifecycle requests", slog.Any("error", err))
return
}
if counts.Seen > 0 {
logger.Info("settled FedWiki lifecycle requests",
slog.Int("seen", counts.Seen),
slog.Int("not_started", counts.NotStarted),
slog.Int("running", counts.Running),
slog.Int("reconciling", counts.Reconciling),
slog.Int("retries_exhausted", counts.Exhausted),
slog.Int("unanswered", counts.Unanswered))
}
}
@@ -40,3 +40,21 @@ func TestDashboardCardsOnlyWhenConfigured(t *testing.T) {
t.Errorf("card partial path = %q, want /partials/fedwiki/sites", cards[0].PartialPath)
}
}
// TestBindConnectionIsSharedByCopies covers task 4.5: the identifier boot
// binds once is what every copy of the adapter value reads, because the
// integration list hands out the adapter by value.
func TestBindConnectionIsSharedByCopies(t *testing.T) {
a := New()
copied := a
if got := a.ConnectionID(); got != "" {
t.Fatalf("unbound ConnectionID = %q, want empty", got)
}
a.BindConnection("0199a000-0000-7000-8000-000000000001")
if got := copied.ConnectionID(); got != "0199a000-0000-7000-8000-000000000001" {
t.Errorf("a copy reads %q", got)
}
if got := (Adapter{}).ConnectionID(); got != "" {
t.Errorf("a zero adapter reads %q, want empty", got)
}
}
@@ -84,33 +84,57 @@ func (Adapter) ReconcileDomains(ctx context.Context, database *sql.DB, logger *s
if err := backfillSitePlacements(ctx, registry, siteQ, systemWorkspaceID, logger); err != nil {
return err
}
return repairSiteUsageCounters(ctx, entitlements.New(database), siteQ, logger)
return repairSiteUsageCounters(ctx, database, siteQ, logger)
}
// repairSiteUsageCounters sets every site-owning workspace's `fedwiki_sites`
// counter to its active row count (design D2). Rows written outside the create
// workflow — an import, farm sync, the adoption above — never passed the gated
// increment, so the counter drifts below the rows it is supposed to reserve
// against, and a member at their limit is offered a create that then fails.
// repairSiteUsageCounters brings the `fedwiki_sites` counter of every
// workspace that owns a site or counts a slot between the active row count
// of the workspaces sharing its pool and that count plus their open requests
// that may hold a slot (design D2; usage.BoundWorkspace, Open Questions 60).
// Rows written outside the create workflow — an import, farm sync, the
// adoption above — never passed the gated increment, so the counter drifts
// below the rows it is supposed to reserve against, and a member at their
// limit is offered a create that then fails.
//
// Exact, not raise-only, because at boot no create workflow holds a
// reservation between its increment and its row insert, so the rows are the
// whole truth; runtime reconciliation stays raise-only for that reason.
// Not exact: a create, activation or delete whose workflow runs across the
// restart holds a slot no row shows, and setting the counter to the rows
// would let the member go past the limit by that many. Runtime
// reconciliation stays raise-only.
//
// A workspace with no site row is visited too when its counter is above
// zero: a first create whose run died after taking its slot, or a workspace
// whose sites were all deleted, leaves a slot no row shows, and only this
// pass gives it back (design Open Questions 50, 53).
//
// A single workspace's failure never aborts the pass: the next boot retries it.
func repairSiteUsageCounters(
ctx context.Context,
entQ entitlements.Querier,
database *sql.DB,
siteQ fwmod.Querier,
logger *slog.Logger,
) error {
workspaceIDs, err := siteQ.ListWorkspaceIDsWithSites(ctx)
withSites, err := siteQ.ListWorkspaceIDsWithSites(ctx)
if err != nil {
return err
}
holding, err := entitlements.New(database).ListWorkspaceIDsHoldingUsage(ctx, siteusage.ResourceKey)
if err != nil {
return err
}
workspaceIDs := withSites
seen := make(map[string]bool, len(withSites))
for _, id := range withSites {
seen[id] = true
}
for _, id := range holding {
if !seen[id] {
seen[id] = true
workspaceIDs = append(workspaceIDs, id)
}
}
repaired := 0
for _, workspaceID := range workspaceIDs {
observed, updated, err := siteusage.SetWorkspaceExact(ctx, entQ, siteQ, workspaceID)
observed, updated, err := siteusage.BoundWorkspace(ctx, database, workspaceID)
if err != nil {
logger.Warn("failed to repair fedwiki usage counter",
slog.String("workspaceID", workspaceID),
@@ -19,6 +19,7 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
@@ -432,3 +433,72 @@ func TestReconcileDomainsClassifiesEveryBranch(t *testing.T) {
t.Errorf("placements after a second boot = %d, want %d", got, placementsBefore)
}
}
// TestReconcileDomainsKeepsARunningCreatesName: boot reconciliation's orphan
// sweep leaves a placement whose resource_ref is the instance of an accepted
// create request, because the create saga reserved it and has not inserted
// its site row yet (design D30, task 11.6). Once the request has an outcome
// the placement is an orphan like any other.
func TestReconcileDomainsKeepsARunningCreatesName(t *testing.T) {
database := newReconcileDomainsDB(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
root, systemWS, memberWS := setupReconcileRoot(t, database, "running")
registry := domains.NewRegistry(database)
if _, err := registry.EnsureOperatorRoot(ctx, systemWS, root); err != nil {
t.Fatalf("ensure operator root: %v", err)
}
if err := integration.RegisterProviders(ctx, database, []integration.ProviderSource{fwmod.ProviderSource()}); err != nil {
t.Fatal(err)
}
conn, err := integration.New(database).GetConnectionByProvider(ctx, fwmod.ModuleName)
if err != nil {
t.Fatal(err)
}
instance, err := integration.NewInstanceID(ctx, database)
if err != nil {
t.Fatal(err)
}
gen, err := integration.NewLifecycleGeneration(ctx, database)
if err != nil {
t.Fatal(err)
}
fqdn := "pending." + root
if a, err := integration.RecordLifecycleRequest(ctx, database, integration.LifecycleRequestSpec{
InstanceID: instance, Generation: gen, ConnectionID: conn.ConnectionID, ResourceKey: "fedwiki_sites",
WorkspaceID: memberWS, Operation: integration.LifecycleCreate, Parameters: []byte(`{"domain": "` + fqdn + `"}`),
WorkflowID: workflows.CreateRequestWorkflowID(instance),
}); err != nil || a.Kind != integration.LifecycleRecorded {
t.Fatalf("record: %+v, %v", a, err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(), `DELETE FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
})
if err := workflows.AllocateSiteDomain(ctx, registry, memberWS, fqdn, instance); err != nil {
t.Fatalf("allocate %s: %v", fqdn, err)
}
if err := (Adapter{}).ReconcileDomains(ctx, database, logger); err != nil {
t.Fatalf("reconcile: %v", err)
}
if _, _, ok := placementAt(t, database, fqdn); !ok {
t.Fatalf("the sweep released %s while its create request is accepted", fqdn)
}
if av, err := registry.CheckAvailability(ctx, fqdn, newMemberWorkspace(t, database, "rival")); err != nil {
t.Fatal(err)
} else if av.Verdict == domains.VerdictAvailable {
t.Errorf("%s is available to another workspace while the create runs", fqdn)
}
if err := integration.FinishLifecycleRequest(ctx, database, integration.LifecycleOutcome{
InstanceID: instance, Generation: gen, Status: integration.LifecycleFailed, ReasonCode: workflows.ReasonRetriesExhausted}); err != nil {
t.Fatal(err)
}
if err := (Adapter{}).ReconcileDomains(ctx, database, logger); err != nil {
t.Fatalf("second reconcile: %v", err)
}
if _, _, ok := placementAt(t, database, fqdn); ok {
t.Errorf("%s survived the sweep after its create failed", fqdn)
}
}
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fedwiki
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/mock"
"go.temporal.io/api/serviceerror"
"go.temporal.io/sdk/mocks"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
)
// TestStartupSettlesRequestsWithTheSyncDisabled: the boot pass runs at the
// top of Startup, before the sync-enabled check returns early (design D27),
// so a request whose workflow never started is settled on a deployment that
// never enables the sync (task 11.5).
func TestStartupSettlesRequestsWithTheSyncDisabled(t *testing.T) {
database := newReconcileDomainsDB(t)
ctx := context.Background()
if err := integration.RegisterProviders(ctx, database, []integration.ProviderSource{fwmod.ProviderSource()}); err != nil {
t.Fatal(err)
}
conn, err := integration.New(database).GetConnectionByProvider(ctx, fwmod.ModuleName)
if err != nil {
t.Fatal(err)
}
ws := newMemberWorkspace(t, database, "settle")
instance, err := integration.NewInstanceID(ctx, database)
if err != nil {
t.Fatal(err)
}
gen, err := integration.NewLifecycleGeneration(ctx, database)
if err != nil {
t.Fatal(err)
}
if a, err := integration.RecordLifecycleRequest(ctx, database, integration.LifecycleRequestSpec{
InstanceID: instance, Generation: gen, ConnectionID: conn.ConnectionID, ResourceKey: "fedwiki_sites",
WorkspaceID: ws, Operation: integration.LifecycleCreate, Parameters: []byte(`{"domain": "settle.example.test"}`),
WorkflowID: workflows.CreateRequestWorkflowID(instance),
}); err != nil || a.Kind != integration.LifecycleRecorded {
t.Fatalf("record: %+v, %v", a, err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(), `DELETE FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
})
if _, err := database.ExecContext(ctx,
`UPDATE core.lifecycle_requests SET created_at = NOW() - INTERVAL '10 minutes' WHERE instance_id = $1`, instance); err != nil {
t.Fatal(err)
}
prev := viper.GetBool("fedwiki-sync-enabled")
viper.Set("fedwiki-sync-enabled", false)
t.Cleanup(func() { viper.Set("fedwiki-sync-enabled", prev) })
c := &mocks.Client{}
c.On("DescribeWorkflowExecution", mock.Anything, mock.Anything, "").Return(nil, serviceerror.NewNotFound("no such workflow"))
schedules := &mocks.ScheduleClient{}
handle := &mocks.ScheduleHandle{}
c.On("ScheduleClient").Return(schedules)
schedules.On("GetHandle", mock.Anything, workflows.SyncScheduleID).Return(handle)
handle.On("Describe", mock.Anything).Return(nil, serviceerror.NewNotFound("no such schedule"))
adapter := New()
adapter.BindConnection(conn.ConnectionID)
if err := adapter.Startup(ctx, c, "queue", database, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatal(err)
}
var status, reason string
if err := database.QueryRowContext(ctx,
`SELECT status, COALESCE(reason_code, '') FROM core.lifecycle_requests WHERE instance_id = $1`, instance).Scan(&status, &reason); err != nil {
t.Fatal(err)
}
if status != "failed" || reason != workflows.ReasonNotStarted {
t.Errorf("request = %s/%s, want failed/%s", status, reason, workflows.ReasonNotStarted)
}
c.AssertNotCalled(t, "ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
// A request younger than SettleRequestAge at boot, as one committed just
// before a quick restart is, is left by the boot pass and settled by the
// second pass once it has aged (design D27, Open Questions 54).
func TestStartupSettlesAYoungRequestOnceItHasAged(t *testing.T) {
database := newReconcileDomainsDB(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := integration.RegisterProviders(ctx, database, []integration.ProviderSource{fwmod.ProviderSource()}); err != nil {
t.Fatal(err)
}
conn, err := integration.New(database).GetConnectionByProvider(ctx, fwmod.ModuleName)
if err != nil {
t.Fatal(err)
}
ws := newMemberWorkspace(t, database, "settle-young")
instance, err := integration.NewInstanceID(ctx, database)
if err != nil {
t.Fatal(err)
}
gen, err := integration.NewLifecycleGeneration(ctx, database)
if err != nil {
t.Fatal(err)
}
if a, err := integration.RecordLifecycleRequest(ctx, database, integration.LifecycleRequestSpec{
InstanceID: instance, Generation: gen, ConnectionID: conn.ConnectionID, ResourceKey: "fedwiki_sites",
WorkspaceID: ws, Operation: integration.LifecycleSetStatus, Parameters: []byte(`{"domain": "young.example.test", "status": "archived"}`),
WorkflowID: "settle-young-" + instance,
}); err != nil || a.Kind != integration.LifecycleRecorded {
t.Fatalf("record: %+v, %v", a, err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(), `DELETE FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
})
// One second short of the age at boot.
if _, err := database.ExecContext(ctx,
`UPDATE core.lifecycle_requests SET created_at = NOW() - make_interval(secs => $2) WHERE instance_id = $1`,
instance, workflows.SettleRequestAge.Seconds()-1); err != nil {
t.Fatal(err)
}
prev, prevDelay := viper.GetBool("fedwiki-sync-enabled"), settleAgainAfter
viper.Set("fedwiki-sync-enabled", false)
settleAgainAfter = 1500 * time.Millisecond
t.Cleanup(func() { viper.Set("fedwiki-sync-enabled", prev); settleAgainAfter = prevDelay })
c := &mocks.Client{}
c.On("DescribeWorkflowExecution", mock.Anything, mock.Anything, "").Return(nil, serviceerror.NewNotFound("no such workflow"))
schedules := &mocks.ScheduleClient{}
handle := &mocks.ScheduleHandle{}
c.On("ScheduleClient").Return(schedules)
schedules.On("GetHandle", mock.Anything, workflows.SyncScheduleID).Return(handle)
handle.On("Describe", mock.Anything).Return(nil, serviceerror.NewNotFound("no such schedule"))
adapter := New()
adapter.BindConnection(conn.ConnectionID)
if err := adapter.Startup(ctx, c, "queue", database, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatal(err)
}
status := func() (string, string) {
var s, r string
if err := database.QueryRowContext(ctx,
`SELECT status, COALESCE(reason_code, '') FROM core.lifecycle_requests WHERE instance_id = $1`, instance).Scan(&s, &r); err != nil {
t.Fatal(err)
}
return s, r
}
if s, _ := status(); s != integration.LifecycleAccepted {
t.Fatalf("the boot pass settled a request younger than the age: %s", s)
}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if s, r := status(); s != integration.LifecycleAccepted {
if s != integration.LifecycleFailed || r != "not_started" {
t.Errorf("settled as %s/%s, want failed/not_started", s, r)
}
return
}
time.Sleep(100 * time.Millisecond)
}
t.Error("the request was still accepted after the second pass was due")
}
@@ -40,6 +40,51 @@
}
});
// An accepted create closes the modal (integration-delivery-core design
// D31): the new Pending row in the refreshed list is the confirmation.
// The server names the event in its HX-Trigger header; htmx dispatches it
// on the request's element, or on document when that element left the
// page, so the listener sits on document. The "hidden.bs.modal" listener
// above resets the body once the modal has hidden.
document.addEventListener("fedwikiCreateAccepted", function () {
var modalEl = document.getElementById("createSiteModal");
if (!modalEl || !window.bootstrap) return;
window.bootstrap.Modal.getOrCreateInstance(modalEl).hide();
});
// While a request is open the list re-renders itself every three seconds
// (the poller in fedwiki_sites.html swaps #fedwiki-sites whole). At mobile
// width each table sits in a sideways-scrolling .table-responsive box, and
// a fresh box starts at its left edge, so a member scrolled over to Status
// and the row actions was sent back every three seconds. Carry each box's
// scroll position across that swap. htmx 4 names the swap's target in
// detail.ctx.target on both events; after the swap that element has left
// the page, so the new boxes are read from the document again.
var savedScroll = null;
var swapsSitesList = function (evt) {
var ctx = evt.detail && evt.detail.ctx;
return !!(ctx && ctx.target && ctx.target.id === "fedwiki-sites");
};
document.addEventListener("htmx:before:swap", function (evt) {
if (!swapsSitesList(evt)) return;
var root = document.getElementById("fedwiki-sites");
if (!root) return;
savedScroll = Array.prototype.map.call(
root.querySelectorAll(".table-responsive"),
function (box) { return box.scrollLeft; }
);
});
document.addEventListener("htmx:after:swap", function (evt) {
if (!swapsSitesList(evt) || !savedScroll) return;
var saved = savedScroll;
savedScroll = null;
var root = document.getElementById("fedwiki-sites");
if (!root) return;
root.querySelectorAll(".table-responsive").forEach(function (box, i) {
if (saved[i]) box.scrollLeft = saved[i];
});
});
// Copy-on-click for DNS record names/values in the custom-domain
// verification view (custom-domain-verification-ux). Delegated so it
// survives the view's 5s HTMX self-swap. Clipboard API requires a secure
@@ -40,6 +40,11 @@ func (providerSource) ProviderManifest() integration.Manifest {
integration.StateArchived,
},
ResourceKeys: []string{"fedwiki_sites"},
// Sites are counted per organization's pool, so the limit is
// delivered to the organization.
Recipients: map[string]integration.RecipientKind{
"fedwiki_sites": integration.RecipientOrganization,
},
OverLimit: map[string]integration.OverLimit{
"fedwiki_sites": {
Behavior: integration.OverLimitPark,
+59 -8
View File
@@ -15,7 +15,23 @@ import (
type Querier interface {
ClearForceReducedByDomain(ctx context.Context, domain string) error
CountActiveSitesByWorkspace(ctx context.Context, workspaceID string) (int64, error)
// The active sites of every workspace whose active primary assignment is the
// pool. Usage is pool-grain (an organization's workspaces draw down one
// `fedwiki_sites` counter against one limit), so this, not one workspace's
// rows, is what the counter, the limit and the member's quota display stand
// for.
CountActiveSitesByPool(ctx context.Context, poolID string) (int64, error)
// The active sites, and the open FedWiki requests that may hold a
// `fedwiki_sites` slot beside them, of every workspace whose active primary
// assignment is the pool, in one snapshot, so a create that commits its site
// row and its outcome between two counts is counted once. Usage is
// pool-grain: sibling workspaces of an organization draw down one counter,
// so it is bounded by all of their rows together. A request may hold a slot
// while it is accepted and is a create, a status change to active other
// than a swap's activating half (a swap moves one site out of active and one
// in, and takes no slot), or a delete whose site row is gone and whose
// outcome, which releases the slot, is not yet recorded.
CountSiteSlotsByPool(ctx context.Context, poolID string) (CountSiteSlotsByPoolRow, error)
CountSitesByWorkspace(ctx context.Context, workspaceID string) (int64, error)
// site_id is supplied by the caller, not defaulted: the creation saga
// pre-generates it so the registry placement can reserve the name
@@ -24,6 +40,12 @@ type Querier interface {
CreateSite(ctx context.Context, arg CreateSiteParams) (Site, error)
DeleteSite(ctx context.Context, siteID string) error
DeleteSiteByDomain(ctx context.Context, domain string) error
// A member's permanent delete removes the row and reads the status it held
// in the same statement, so the slot it releases is the one the row held
// when it was deleted, not the one it held when the request was made: a
// downgrade park that took the site out of active in between has already
// released it.
DeleteSiteReturningStatus(ctx context.Context, domain string) (string, error)
GetSiteByDomain(ctx context.Context, domain string) (GetSiteByDomainRow, error)
// The display queries below derive `is_custom_domain` instead of reading a
// column: whether a site sits on a member-owned domain is a fact about its
@@ -32,11 +54,16 @@ type Querier interface {
// the generated field stays a plain `bool` named IsCustomDomain, keeping
// every render site unchanged. Only the queries that feed member, API, and
// operator display carry it; the reconcile/sweep queries
// (ListActiveSitesByWorkspace, ListForceReducedSitesByWorkspace,
// ListArchivedSitesArchivedBefore) do not render it and stay plain
// (ListActiveSitesByWorkspace, ListActiveSitesByPool,
// ListForceReducedSitesByPool, ListArchivedSitesArchivedBefore) do not
// render it and stay plain
// SELECT * so they keep returning the Site model.
GetSiteByID(ctx context.Context, siteID string) (GetSiteByIDRow, error)
GetSiteSwapPolicy(ctx context.Context, workspaceID string) (time.Time, error)
// The active sites of every workspace whose active primary assignment is the
// pool, most-recently-modified first: the force_reduce keep/park order across
// the workspaces that draw down the pool's one `fedwiki_sites` counter.
ListActiveSitesByPool(ctx context.Context, poolID string) ([]Site, error)
// Active sites, most-recently-modified first — the force_reduce keep/park order.
ListActiveSitesByWorkspace(ctx context.Context, workspaceID string) ([]Site, error)
ListAllSites(ctx context.Context) ([]ListAllSitesRow, error)
@@ -62,15 +89,26 @@ type Querier interface {
ListAllSitesForOperatorPage(ctx context.Context, arg ListAllSitesForOperatorPageParams) ([]ListAllSitesForOperatorPageRow, error)
// Retention purge: archived sites whose archive predates the cutoff.
ListArchivedSitesArchivedBefore(ctx context.Context, archivedAt sql.NullTime) ([]Site, error)
// Sites parked read-only by a downgrade, most-recently-modified first — the
// re-upgrade reactivation order.
ListForceReducedSitesByWorkspace(ctx context.Context, workspaceID string) ([]Site, error)
// Domains a FedWiki lifecycle request still `accepted` acts on: a create's
// resolved domain (parameters.domain), and the domain of every site row whose
// instance has an open request. The sync leaves these sites alone: the create
// workflow owns an open create's row insert, and a status change, swap or
// delete writes the site's status and its slot in its own record step.
ListDomainsWithOpenRequests(ctx context.Context) ([]string, error)
// Sites a downgrade parked that are still read-only, in every workspace
// whose active primary assignment is the pool, most-recently-modified first:
// the re-upgrade reactivation order. A parked site the member archived since
// is in the trash, not in reserve.
ListForceReducedSitesByPool(ctx context.Context, poolID string) ([]Site, error)
// Boot reconciliation's orphan set (domains-registry D8), the mirror image of
// ListSitesWithoutPlacement: every `fedwiki` placement whose resource_ref
// names no `fedwiki.sites` row. A create saga whose compensation never ran and
// a delete interrupted between freeing the row and freeing the name both leave
// one, and until it is released `/domains/ask` keeps authorizing certificates
// for a name nothing serves. Ordered by name for a stable pass.
// for a name nothing serves. A placement whose resource_ref is the instance
// of a create request still `accepted` is not an orphan: the running create
// workflow reserved it and has not inserted the row yet. Ordered by name for
// a stable pass.
ListOrphanedPlacements(ctx context.Context) ([]ListOrphanedPlacementsRow, error)
ListSitesByWorkspace(ctx context.Context, workspaceID string) ([]ListSitesByWorkspaceRow, error)
// Boot reconciliation's backfill set (domains-registry D8): every site row the
@@ -81,11 +119,24 @@ type Querier interface {
ListSitesWithoutPlacement(ctx context.Context) ([]Site, error)
// Workspaces that own at least one site — the units the quota reconcile sweeps.
ListWorkspaceIDsWithSites(ctx context.Context) ([]string, error)
// The site's status, its row locked until the transaction ends. A path that
// releases or takes a `fedwiki_sites` slot for a status change reads the
// status here first, so of two paths that move one site out of active (a
// member's request and the downgrade sweep) only the first to commit
// releases its slot.
LockSiteStatusByDomain(ctx context.Context, domain string) (LockSiteStatusByDomainRow, error)
SetForceReducedByDomain(ctx context.Context, domain string) error
// Project a lifecycle status change locally. archived_at is set on entering
// `archived` (idempotent — preserves the original timestamp) and cleared on
// leaving it, driving the Trash retention purge.
// leaving it, driving the Trash retention purge. force_reduced_at marks a
// site a downgrade left read-only, so it is kept only while the site stays
// `readonly`: a site that becomes active or archived is no longer parked,
// and the re-upgrade sweep must not bring it back.
SetSiteStatusByDomain(ctx context.Context, arg SetSiteStatusByDomainParams) (Site, error)
// Whether a lifecycle request on the site is still accepted. The downgrade
// sweep leaves such a site alone, as the sync leaves an open create's name:
// the member's request decides its status.
SiteHasOpenRequest(ctx context.Context, instanceID string) (bool, error)
UpsertSiteByDomain(ctx context.Context, arg UpsertSiteByDomainParams) (Site, error)
// Sync projection: insert a newly-seen farm site (default workspace), or project
// the observed status/storage/last-modified onto an existing row WITHOUT touching
@@ -14,8 +14,9 @@ RETURNING *;
-- the generated field stays a plain `bool` named IsCustomDomain, keeping
-- every render site unchanged. Only the queries that feed member, API, and
-- operator display carry it; the reconcile/sweep queries
-- (ListActiveSitesByWorkspace, ListForceReducedSitesByWorkspace,
-- ListArchivedSitesArchivedBefore) do not render it and stay plain
-- (ListActiveSitesByWorkspace, ListActiveSitesByPool,
-- ListForceReducedSitesByPool, ListArchivedSitesArchivedBefore) do not
-- render it and stay plain
-- SELECT * so they keep returning the Site model.
-- name: GetSiteByID :one
@@ -71,6 +72,16 @@ WHERE site_id = $1;
DELETE FROM fedwiki.sites
WHERE domain = $1;
-- name: DeleteSiteReturningStatus :one
-- A member's permanent delete removes the row and reads the status it held
-- in the same statement, so the slot it releases is the one the row held
-- when it was deleted, not the one it held when the request was made: a
-- downgrade park that took the site out of active in between has already
-- released it.
DELETE FROM fedwiki.sites
WHERE domain = $1
RETURNING status;
-- name: CountSitesByWorkspace :one
SELECT COUNT(*) AS count FROM fedwiki.sites
WHERE workspace_id = $1;
@@ -99,7 +110,10 @@ RETURNING *;
-- name: SetSiteStatusByDomain :one
-- Project a lifecycle status change locally. archived_at is set on entering
-- `archived` (idempotent — preserves the original timestamp) and cleared on
-- leaving it, driving the Trash retention purge.
-- leaving it, driving the Trash retention purge. force_reduced_at marks a
-- site a downgrade left read-only, so it is kept only while the site stays
-- `readonly`: a site that becomes active or archived is no longer parked,
-- and the re-upgrade sweep must not bring it back.
UPDATE fedwiki.sites
SET status = $2,
archived_at = CASE
@@ -107,10 +121,30 @@ SET status = $2,
WHEN $2 <> 'archived' THEN NULL
ELSE archived_at
END,
force_reduced_at = CASE WHEN $2 = 'readonly' THEN force_reduced_at END,
updated_at = NOW()
WHERE domain = $1
RETURNING *;
-- name: LockSiteStatusByDomain :one
-- The site's status, its row locked until the transaction ends. A path that
-- releases or takes a `fedwiki_sites` slot for a status change reads the
-- status here first, so of two paths that move one site out of active (a
-- member's request and the downgrade sweep) only the first to commit
-- releases its slot.
SELECT status, force_reduced_at FROM fedwiki.sites
WHERE domain = $1
FOR UPDATE;
-- name: SiteHasOpenRequest :one
-- Whether a lifecycle request on the site is still accepted. The downgrade
-- sweep leaves such a site alone, as the sync leaves an open create's name:
-- the member's request decides its status.
SELECT EXISTS (
SELECT 1 FROM core.lifecycle_requests
WHERE instance_id = $1 AND status = 'accepted'
) AS open;
-- name: SetForceReducedByDomain :exec
UPDATE fedwiki.sites
SET force_reduced_at = NOW(), updated_at = NOW()
@@ -127,16 +161,70 @@ SELECT * FROM fedwiki.sites
WHERE workspace_id = $1 AND status = 'active'
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC;
-- name: ListForceReducedSitesByWorkspace :many
-- Sites parked read-only by a downgrade, most-recently-modified first — the
-- re-upgrade reactivation order.
-- name: ListActiveSitesByPool :many
-- The active sites of every workspace whose active primary assignment is the
-- pool, most-recently-modified first: the force_reduce keep/park order across
-- the workspaces that draw down the pool's one `fedwiki_sites` counter.
SELECT * FROM fedwiki.sites
WHERE workspace_id = $1 AND force_reduced_at IS NOT NULL
WHERE status = 'active' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = sqlc.arg(pool_id)::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC;
-- name: CountActiveSitesByWorkspace :one
-- name: ListForceReducedSitesByPool :many
-- Sites a downgrade parked that are still read-only, in every workspace
-- whose active primary assignment is the pool, most-recently-modified first:
-- the re-upgrade reactivation order. A parked site the member archived since
-- is in the trash, not in reserve.
SELECT * FROM fedwiki.sites
WHERE force_reduced_at IS NOT NULL AND status = 'readonly' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = sqlc.arg(pool_id)::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC;
-- name: CountActiveSitesByPool :one
-- The active sites of every workspace whose active primary assignment is the
-- pool. Usage is pool-grain (an organization's workspaces draw down one
-- `fedwiki_sites` counter against one limit), so this, not one workspace's
-- rows, is what the counter, the limit and the member's quota display stand
-- for.
SELECT COUNT(*) AS count FROM fedwiki.sites
WHERE workspace_id = $1 AND status = 'active';
WHERE status = 'active' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = sqlc.arg(pool_id)::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
);
-- name: CountSiteSlotsByPool :one
-- The active sites, and the open FedWiki requests that may hold a
-- `fedwiki_sites` slot beside them, of every workspace whose active primary
-- assignment is the pool, in one snapshot, so a create that commits its site
-- row and its outcome between two counts is counted once. Usage is
-- pool-grain: sibling workspaces of an organization draw down one counter,
-- so it is bounded by all of their rows together. A request may hold a slot
-- while it is accepted and is a create, a status change to active other
-- than a swap's activating half (a swap moves one site out of active and one
-- in, and takes no slot), or a delete whose site row is gone and whose
-- outcome, which releases the slot, is not yet recorded.
WITH pool_workspaces AS (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = sqlc.arg(pool_id)::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
SELECT
(SELECT count(*) FROM fedwiki.sites s
WHERE s.workspace_id IN (SELECT workspace_id FROM pool_workspaces) AND s.status = 'active')::bigint AS active,
(SELECT count(*) FROM core.lifecycle_requests lr
JOIN core.provider_connections pc ON pc.connection_id = lr.connection_id
WHERE pc.provider = 'fedwiki'
AND lr.workspace_id IN (SELECT workspace_id FROM pool_workspaces)
AND lr.status = 'accepted'
AND (lr.operation = 'create'
OR (lr.operation = 'set_status' AND lr.parameters ->> 'status' = 'active'
AND NOT lr.parameters ? 'swap_park_site_id')
OR (lr.operation = 'delete'
AND NOT EXISTS (SELECT 1 FROM fedwiki.sites s WHERE s.site_id = lr.instance_id)))
)::bigint AS open_holders;
-- name: ListSitesWithoutPlacement :many
-- Boot reconciliation's backfill set (domains-registry D8): every site row the
@@ -157,15 +245,46 @@ ORDER BY length(domain) ASC, domain ASC;
-- names no `fedwiki.sites` row. A create saga whose compensation never ran and
-- a delete interrupted between freeing the row and freeing the name both leave
-- one, and until it is released `/domains/ask` keeps authorizing certificates
-- for a name nothing serves. Ordered by name for a stable pass.
-- for a name nothing serves. A placement whose resource_ref is the instance
-- of a create request still `accepted` is not an orphan: the running create
-- workflow reserved it and has not inserted the row yet. Ordered by name for
-- a stable pass.
SELECT p.placement_id, p.fqdn, p.resource_ref
FROM domains.placements p
WHERE p.provider = 'fedwiki'
AND NOT EXISTS (
SELECT 1 FROM fedwiki.sites s WHERE s.site_id::text = p.resource_ref
)
AND NOT EXISTS (
SELECT 1 FROM core.lifecycle_requests lr
JOIN core.provider_connections pc ON pc.connection_id = lr.connection_id
WHERE pc.provider = 'fedwiki'
AND lr.operation = 'create'
AND lr.status = 'accepted'
AND lr.instance_id::text = p.resource_ref
)
ORDER BY p.fqdn ASC;
-- name: ListDomainsWithOpenRequests :many
-- Domains a FedWiki lifecycle request still `accepted` acts on: a create's
-- resolved domain (parameters.domain), and the domain of every site row whose
-- instance has an open request. The sync leaves these sites alone: the create
-- workflow owns an open create's row insert, and a status change, swap or
-- delete writes the site's status and its slot in its own record step.
SELECT (lr.parameters ->> 'domain')::text AS domain
FROM core.lifecycle_requests lr
JOIN core.provider_connections pc ON pc.connection_id = lr.connection_id
WHERE pc.provider = 'fedwiki'
AND lr.operation = 'create'
AND lr.status = 'accepted'
AND lr.parameters ->> 'domain' IS NOT NULL
UNION
SELECT s.domain
FROM fedwiki.sites s
JOIN core.lifecycle_requests lr ON lr.instance_id = s.site_id
WHERE lr.status = 'accepted'
ORDER BY 1;
-- name: ListWorkspaceIDsWithSites :many
-- Workspaces that own at least one site — the units the quota reconcile sweeps.
SELECT DISTINCT workspace_id FROM fedwiki.sites;
+234 -14
View File
@@ -25,18 +25,69 @@ func (q *Queries) ClearForceReducedByDomain(ctx context.Context, domain string)
return err
}
const countActiveSitesByWorkspace = `-- name: CountActiveSitesByWorkspace :one
const countActiveSitesByPool = `-- name: CountActiveSitesByPool :one
SELECT COUNT(*) AS count FROM fedwiki.sites
WHERE workspace_id = $1 AND status = 'active'
WHERE status = 'active' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = $1::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
`
func (q *Queries) CountActiveSitesByWorkspace(ctx context.Context, workspaceID string) (int64, error) {
row := q.db.QueryRowContext(ctx, countActiveSitesByWorkspace, workspaceID)
// The active sites of every workspace whose active primary assignment is the
// pool. Usage is pool-grain (an organization's workspaces draw down one
// `fedwiki_sites` counter against one limit), so this, not one workspace's
// rows, is what the counter, the limit and the member's quota display stand
// for.
func (q *Queries) CountActiveSitesByPool(ctx context.Context, poolID string) (int64, error) {
row := q.db.QueryRowContext(ctx, countActiveSitesByPool, poolID)
var count int64
err := row.Scan(&count)
return count, err
}
const countSiteSlotsByPool = `-- name: CountSiteSlotsByPool :one
WITH pool_workspaces AS (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = $1::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
SELECT
(SELECT count(*) FROM fedwiki.sites s
WHERE s.workspace_id IN (SELECT workspace_id FROM pool_workspaces) AND s.status = 'active')::bigint AS active,
(SELECT count(*) FROM core.lifecycle_requests lr
JOIN core.provider_connections pc ON pc.connection_id = lr.connection_id
WHERE pc.provider = 'fedwiki'
AND lr.workspace_id IN (SELECT workspace_id FROM pool_workspaces)
AND lr.status = 'accepted'
AND (lr.operation = 'create'
OR (lr.operation = 'set_status' AND lr.parameters ->> 'status' = 'active'
AND NOT lr.parameters ? 'swap_park_site_id')
OR (lr.operation = 'delete'
AND NOT EXISTS (SELECT 1 FROM fedwiki.sites s WHERE s.site_id = lr.instance_id)))
)::bigint AS open_holders
`
type CountSiteSlotsByPoolRow struct {
Active int64 `json:"active"`
OpenHolders int64 `json:"open_holders"`
}
// The active sites, and the open FedWiki requests that may hold a
// `fedwiki_sites` slot beside them, of every workspace whose active primary
// assignment is the pool, in one snapshot, so a create that commits its site
// row and its outcome between two counts is counted once. Usage is
// pool-grain: sibling workspaces of an organization draw down one counter,
// so it is bounded by all of their rows together. A request may hold a slot
// while it is accepted and is a create, a status change to active other
// than a swap's activating half (a swap moves one site out of active and one
// in, and takes no slot), or a delete whose site row is gone and whose
// outcome, which releases the slot, is not yet recorded.
func (q *Queries) CountSiteSlotsByPool(ctx context.Context, poolID string) (CountSiteSlotsByPoolRow, error) {
row := q.db.QueryRowContext(ctx, countSiteSlotsByPool, poolID)
var i CountSiteSlotsByPoolRow
err := row.Scan(&i.Active, &i.OpenHolders)
return i, err
}
const countSitesByWorkspace = `-- name: CountSitesByWorkspace :one
SELECT COUNT(*) AS count FROM fedwiki.sites
WHERE workspace_id = $1
@@ -103,6 +154,24 @@ func (q *Queries) DeleteSiteByDomain(ctx context.Context, domain string) error {
return err
}
const deleteSiteReturningStatus = `-- name: DeleteSiteReturningStatus :one
DELETE FROM fedwiki.sites
WHERE domain = $1
RETURNING status
`
// A member's permanent delete removes the row and reads the status it held
// in the same statement, so the slot it releases is the one the row held
// when it was deleted, not the one it held when the request was made: a
// downgrade park that took the site out of active in between has already
// released it.
func (q *Queries) DeleteSiteReturningStatus(ctx context.Context, domain string) (string, error) {
row := q.db.QueryRowContext(ctx, deleteSiteReturningStatus, domain)
var status string
err := row.Scan(&status)
return status, err
}
const getSiteByDomain = `-- name: GetSiteByDomain :one
SELECT s.site_id, s.workspace_id, s.domain, s.created_at, s.updated_at, s.status, s.storage_bytes, s.last_modified_at, s.force_reduced_at, s.archived_at, (EXISTS (
SELECT 1 FROM domains.placements p
@@ -182,8 +251,9 @@ type GetSiteByIDRow struct {
// the generated field stays a plain `bool` named IsCustomDomain, keeping
// every render site unchanged. Only the queries that feed member, API, and
// operator display carry it; the reconcile/sweep queries
// (ListActiveSitesByWorkspace, ListForceReducedSitesByWorkspace,
// ListArchivedSitesArchivedBefore) do not render it and stay plain
// (ListActiveSitesByWorkspace, ListActiveSitesByPool,
// ListForceReducedSitesByPool, ListArchivedSitesArchivedBefore) do not
// render it and stay plain
// SELECT * so they keep returning the Site model.
func (q *Queries) GetSiteByID(ctx context.Context, siteID string) (GetSiteByIDRow, error) {
row := q.db.QueryRowContext(ctx, getSiteByID, siteID)
@@ -215,6 +285,52 @@ func (q *Queries) GetSiteSwapPolicy(ctx context.Context, workspaceID string) (ti
return last_swap_at, err
}
const listActiveSitesByPool = `-- name: ListActiveSitesByPool :many
SELECT site_id, workspace_id, domain, created_at, updated_at, status, storage_bytes, last_modified_at, force_reduced_at, archived_at FROM fedwiki.sites
WHERE status = 'active' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = $1::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC
`
// The active sites of every workspace whose active primary assignment is the
// pool, most-recently-modified first: the force_reduce keep/park order across
// the workspaces that draw down the pool's one `fedwiki_sites` counter.
func (q *Queries) ListActiveSitesByPool(ctx context.Context, poolID string) ([]Site, error) {
rows, err := q.db.QueryContext(ctx, listActiveSitesByPool, poolID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Site{}
for rows.Next() {
var i Site
if err := rows.Scan(
&i.SiteID,
&i.WorkspaceID,
&i.Domain,
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
&i.StorageBytes,
&i.LastModifiedAt,
&i.ForceReducedAt,
&i.ArchivedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listActiveSitesByWorkspace = `-- name: ListActiveSitesByWorkspace :many
SELECT site_id, workspace_id, domain, created_at, updated_at, status, storage_bytes, last_modified_at, force_reduced_at, archived_at FROM fedwiki.sites
WHERE workspace_id = $1 AND status = 'active'
@@ -460,16 +576,65 @@ func (q *Queries) ListArchivedSitesArchivedBefore(ctx context.Context, archivedA
return items, nil
}
const listForceReducedSitesByWorkspace = `-- name: ListForceReducedSitesByWorkspace :many
const listDomainsWithOpenRequests = `-- name: ListDomainsWithOpenRequests :many
SELECT (lr.parameters ->> 'domain')::text AS domain
FROM core.lifecycle_requests lr
JOIN core.provider_connections pc ON pc.connection_id = lr.connection_id
WHERE pc.provider = 'fedwiki'
AND lr.operation = 'create'
AND lr.status = 'accepted'
AND lr.parameters ->> 'domain' IS NOT NULL
UNION
SELECT s.domain
FROM fedwiki.sites s
JOIN core.lifecycle_requests lr ON lr.instance_id = s.site_id
WHERE lr.status = 'accepted'
ORDER BY 1
`
// Domains a FedWiki lifecycle request still `accepted` acts on: a create's
// resolved domain (parameters.domain), and the domain of every site row whose
// instance has an open request. The sync leaves these sites alone: the create
// workflow owns an open create's row insert, and a status change, swap or
// delete writes the site's status and its slot in its own record step.
func (q *Queries) ListDomainsWithOpenRequests(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listDomainsWithOpenRequests)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var domain string
if err := rows.Scan(&domain); err != nil {
return nil, err
}
items = append(items, domain)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listForceReducedSitesByPool = `-- name: ListForceReducedSitesByPool :many
SELECT site_id, workspace_id, domain, created_at, updated_at, status, storage_bytes, last_modified_at, force_reduced_at, archived_at FROM fedwiki.sites
WHERE workspace_id = $1 AND force_reduced_at IS NOT NULL
WHERE force_reduced_at IS NOT NULL AND status = 'readonly' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = $1::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC
`
// Sites parked read-only by a downgrade, most-recently-modified first — the
// re-upgrade reactivation order.
func (q *Queries) ListForceReducedSitesByWorkspace(ctx context.Context, workspaceID string) ([]Site, error) {
rows, err := q.db.QueryContext(ctx, listForceReducedSitesByWorkspace, workspaceID)
// Sites a downgrade parked that are still read-only, in every workspace
// whose active primary assignment is the pool, most-recently-modified first:
// the re-upgrade reactivation order. A parked site the member archived since
// is in the trash, not in reserve.
func (q *Queries) ListForceReducedSitesByPool(ctx context.Context, poolID string) ([]Site, error) {
rows, err := q.db.QueryContext(ctx, listForceReducedSitesByPool, poolID)
if err != nil {
return nil, err
}
@@ -508,6 +673,14 @@ FROM domains.placements p
WHERE p.provider = 'fedwiki'
AND NOT EXISTS (
SELECT 1 FROM fedwiki.sites s WHERE s.site_id::text = p.resource_ref
)
AND NOT EXISTS (
SELECT 1 FROM core.lifecycle_requests lr
JOIN core.provider_connections pc ON pc.connection_id = lr.connection_id
WHERE pc.provider = 'fedwiki'
AND lr.operation = 'create'
AND lr.status = 'accepted'
AND lr.instance_id::text = p.resource_ref
)
ORDER BY p.fqdn ASC
`
@@ -523,7 +696,10 @@ type ListOrphanedPlacementsRow struct {
// names no `fedwiki.sites` row. A create saga whose compensation never ran and
// a delete interrupted between freeing the row and freeing the name both leave
// one, and until it is released `/domains/ask` keeps authorizing certificates
// for a name nothing serves. Ordered by name for a stable pass.
// for a name nothing serves. A placement whose resource_ref is the instance
// of a create request still `accepted` is not an orphan: the running create
// workflow reserved it and has not inserted the row yet. Ordered by name for
// a stable pass.
func (q *Queries) ListOrphanedPlacements(ctx context.Context) ([]ListOrphanedPlacementsRow, error) {
rows, err := q.db.QueryContext(ctx, listOrphanedPlacements)
if err != nil {
@@ -685,6 +861,29 @@ func (q *Queries) ListWorkspaceIDsWithSites(ctx context.Context) ([]string, erro
return items, nil
}
const lockSiteStatusByDomain = `-- name: LockSiteStatusByDomain :one
SELECT status, force_reduced_at FROM fedwiki.sites
WHERE domain = $1
FOR UPDATE
`
type LockSiteStatusByDomainRow struct {
Status string `json:"status"`
ForceReducedAt sql.NullTime `json:"force_reduced_at"`
}
// The site's status, its row locked until the transaction ends. A path that
// releases or takes a `fedwiki_sites` slot for a status change reads the
// status here first, so of two paths that move one site out of active (a
// member's request and the downgrade sweep) only the first to commit
// releases its slot.
func (q *Queries) LockSiteStatusByDomain(ctx context.Context, domain string) (LockSiteStatusByDomainRow, error) {
row := q.db.QueryRowContext(ctx, lockSiteStatusByDomain, domain)
var i LockSiteStatusByDomainRow
err := row.Scan(&i.Status, &i.ForceReducedAt)
return i, err
}
const setForceReducedByDomain = `-- name: SetForceReducedByDomain :exec
UPDATE fedwiki.sites
SET force_reduced_at = NOW(), updated_at = NOW()
@@ -704,6 +903,7 @@ SET status = $2,
WHEN $2 <> 'archived' THEN NULL
ELSE archived_at
END,
force_reduced_at = CASE WHEN $2 = 'readonly' THEN force_reduced_at END,
updated_at = NOW()
WHERE domain = $1
RETURNING site_id, workspace_id, domain, created_at, updated_at, status, storage_bytes, last_modified_at, force_reduced_at, archived_at
@@ -716,7 +916,10 @@ type SetSiteStatusByDomainParams struct {
// Project a lifecycle status change locally. archived_at is set on entering
// `archived` (idempotent — preserves the original timestamp) and cleared on
// leaving it, driving the Trash retention purge.
// leaving it, driving the Trash retention purge. force_reduced_at marks a
// site a downgrade left read-only, so it is kept only while the site stays
// `readonly`: a site that becomes active or archived is no longer parked,
// and the re-upgrade sweep must not bring it back.
func (q *Queries) SetSiteStatusByDomain(ctx context.Context, arg SetSiteStatusByDomainParams) (Site, error) {
row := q.db.QueryRowContext(ctx, setSiteStatusByDomain, arg.Domain, arg.Status)
var i Site
@@ -735,6 +938,23 @@ func (q *Queries) SetSiteStatusByDomain(ctx context.Context, arg SetSiteStatusBy
return i, err
}
const siteHasOpenRequest = `-- name: SiteHasOpenRequest :one
SELECT EXISTS (
SELECT 1 FROM core.lifecycle_requests
WHERE instance_id = $1 AND status = 'accepted'
) AS open
`
// Whether a lifecycle request on the site is still accepted. The downgrade
// sweep leaves such a site alone, as the sync leaves an open create's name:
// the member's request decides its status.
func (q *Queries) SiteHasOpenRequest(ctx context.Context, instanceID string) (bool, error) {
row := q.db.QueryRowContext(ctx, siteHasOpenRequest, instanceID)
var open bool
err := row.Scan(&open)
return open, err
}
const upsertSiteByDomain = `-- name: UpsertSiteByDomain :one
INSERT INTO fedwiki.sites (workspace_id, domain)
VALUES ($1, $2)
@@ -1,14 +0,0 @@
{{- /* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial */ -}}
{{- /* SPDX-FileCopyrightText: 2025-2026 Christian Galo */ -}}
<!-- Create site success partial -->
<div class="modal-body">
<div class="alert alert-success" role="alert">
<strong>Done!</strong> Site <strong>{{ .Domain }}</strong> has been created.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">
Close
</button>
</div>
@@ -17,7 +17,16 @@
create-form's zero-domains guard, task 4.6) — the service's fault,
not the member's, so it must read differently and never point the
member at their plan. -->
<!-- The partial's root (integration-delivery-core design D32): while a
request is open, the hidden poller below re-renders it three seconds
after each render, and each render decides again, so polling stops on
the first render with nothing open. -->
<div id="fedwiki-sites">
{{ if .Polling }}
<div hidden hx-get="/partials/fedwiki/sites" hx-trigger="every 3s" hx-target="#fedwiki-sites" hx-swap="outerHTML"></div>
{{ end }}
{{ if and (eq (len .Sites) 0) (eq (len .ArchivedSites) 0) (not .EntitlementCheckFailed) (or (not .HasEntitlement) .NoDomainsConfigured) }}
{{ template "fedwikiRequestNotices" .Notices }}
{{ if .NoDomainsConfigured }}
{{ template "emptyState" .NoDomainsEmpty }}
{{ else if .NoPublishedPlan }}
@@ -61,8 +70,12 @@
</div>
{{ end }}
<!-- Inline target for archive/restore/delete action errors -->
<div id="siteActionAlert"></div>
<!-- Inline target for row-action answers. hx-preserve keeps its text across
the list refresh a conflict answer triggers, so the sentence survives
the re-render it asks for. -->
<div id="siteActionAlert" hx-preserve></div>
{{ template "fedwikiRequestNotices" .Notices }}
<!-- Verified domains with no site yet. Verifying a domain no longer creates
one (domains-registry D4), so this banner offers the one-click create it
@@ -80,7 +93,7 @@
<button type="button" class="btn btn-link btn-sm p-0"
data-bs-toggle="modal" data-bs-target="#createSiteModal"
hx-post="/partials/fedwiki/sites"
hx-vals='{"domain": "{{ .Domain }}", "isCustomDomain": "true", "customDomainsEnabled": "true"}'
hx-vals='{"domain": "{{ .Domain }}", "isCustomDomain": "true", "customDomainsEnabled": "true", "instance": "{{ .Instance }}", "generation": "{{ .Generation }}"}'
hx-target="#createSiteModalBody" hx-swap="innerHTML">
Create your site
</button>
@@ -111,6 +124,9 @@
{{ range .Sites }}
<tr>
<th scope="row" class="text-nowrap">
{{ if .Requested }}
{{ .Domain }}
{{ else }}
<a href="{{ .URL }}" target="_blank" class="text-decoration-none">
{{ .Domain }}
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-box-arrow-up-right ms-1" viewBox="0 0 16 16">
@@ -118,6 +134,7 @@
<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z"/>
</svg>
</a>
{{ end }}
</th>
<td>
{{ template "statusBadge" .TypeBadge }}
@@ -127,12 +144,14 @@
</td>
<td class="text-nowrap">{{ .CreatedAt }}</td>
<td class="text-end">
{{ if not .Pending }}
{{ if .IsReadonly }}
{{ if $.CooldownMsg }}
{{ template "disabledControl" (.KeepActiveControl $.CooldownMsg) }}
{{ else }}
<button class="btn btn-outline-primary btn-sm ms-1"
hx-post="{{ routeURL "/partials/fedwiki/sites/{domain}/keep-active" .Domain }}"
hx-vals='{"instance": "{{ .ID }}", "generation": "{{ .Generation }}"}'
hx-target="#siteActionAlert"
hx-swap="innerHTML"
title="Keep this site editable (parks another read-only if you're at your limit)">
@@ -146,12 +165,14 @@
data-action-url="{{ routeURL "/partials/fedwiki/sites/{domain}/archive" .Domain }}"
data-action-method="post"
data-action-target="#siteActionAlert"
data-action-fields='{"instance": "{{ .ID }}", "generation": "{{ .Generation }}"}'
data-action-title="Archive site"
data-action-body="Archive {{ .Domain }}? The site goes offline but its content is kept; restore it any time within 30 days."
data-action-confirm-label="Archive"
data-action-style="primary">
Archive
</button>
{{ end }}
</td>
</tr>
{{ end }}
@@ -169,13 +190,18 @@
<tbody>
{{ range .ArchivedSites }}
<tr>
<td class="text-muted text-nowrap">{{ .Domain }}</td>
<td class="text-muted text-nowrap">
{{ .Domain }}
{{ if .Pending }}{{ template "statusBadge" .StatusBadge }}{{ end }}
</td>
<td class="text-end">
{{ if not .Pending }}
{{ if and .IsForceReduced $.CooldownMsg }}
{{ template "disabledControl" (.RestoreControl $.CooldownMsg) }}
{{ else }}
<button class="btn btn-outline-primary btn-sm ms-1"
hx-post="{{ routeURL "/partials/fedwiki/sites/{domain}/restore" .Domain }}"
hx-vals='{"instance": "{{ .ID }}", "generation": "{{ .Generation }}"}'
hx-target="#siteActionAlert"
hx-swap="innerHTML">
Restore
@@ -187,11 +213,13 @@
data-action-url="{{ routeURL "/partials/fedwiki/sites/{domain}" .Domain }}"
data-action-method="delete"
data-action-target="#siteActionAlert"
data-action-fields='{"instance": "{{ .ID }}", "generation": "{{ .Generation }}"}'
data-action-title="Delete site"
data-action-body="Permanently delete {{ .Domain }}? This cannot be undone."
data-action-confirm-label="Delete permanently">
Delete permanently
</button>
{{ end }}
</td>
</tr>
{{ end }}
@@ -220,10 +248,12 @@
(dashboard-display-genericity: core's index.html renders only generic
card shells, so integration furniture arrives inside the integration's
own HTMX fragment). It carries hx-preserve: the create flow leaves the
modal OPEN when its success response fires refreshSites and this
partial re-renders; a plain swap would replace the open modal's node
and orphan Bootstrap's backdrop; hx-preserve carries the same-id DOM
node (and its open state) across the swap. Rendered outside the
modal open across list re-renders (a refused create re-renders the
form inside it, and the list's own poller re-renders the root while a
request is open); a plain swap would replace the open modal's node and
orphan Bootstrap's backdrop; hx-preserve carries the same-id DOM node
(and its open state) across the swap. An accepted create closes it
(fedwiki-create-form.js, on fedwikiCreateAccepted). Rendered outside the
entitlement branches so the node exists on every re-render, keeping
preservation stable. Delete is a confirmAction on the shared modal
(data-action-* above), not a modal of its own (finding FA-41: member
@@ -252,3 +282,25 @@
</div>
</div>
</div>
</div>
<!-- Declined and failed requests the member has not dismissed, newest
first (integration-delivery-core design D32): an outcome is something
that happened, not a state of a site, so it is a notice above the list
and never a row. The markup is the operator setup banner's (a flex
alert with the close beside the text), at this card's alert density
(py-2 small): its close posts the dismissal and swaps the notice out
with the empty answer; a failed dismissal answers 500, which is not
swapped, so the notice stays. The notices sit inside #fedwiki-sites,
so the poller's swap brings in the notice of a request that finishes
while the page is open. -->
{{ define "fedwikiRequestNotices" }}
{{ range . }}
<div class="alert alert-{{ .Tone }} py-2 small d-flex align-items-center justify-content-between gap-3" role="alert">
<span>{{ .Sentence }}</span>
<button type="button" class="btn-close" aria-label="Dismiss"
hx-post="{{ routeURL "/partials/fedwiki/requests/{instance}/{generation}/dismiss" .Instance .Generation }}"
hx-target="closest .alert" hx-swap="outerHTML"></button>
</div>
{{ end }}
{{ end }}
+66 -36
View File
@@ -21,20 +21,27 @@ import (
// ResourceKey is the entitlements resource key sites are counted under.
const ResourceKey = "fedwiki_sites"
// ActiveSites counts a workspace's active site rows: the display truth for
// every quota surface (design D1).
func ActiveSites(ctx context.Context, siteQ fwmod.Querier, workspaceID string) (int64, error) {
return siteQ.CountActiveSitesByWorkspace(ctx, workspaceID)
// ActiveSites counts the active site rows of every workspace whose active
// primary assignment is the pool: the display truth for every quota surface
// (design D1). Usage is pool-grain, so a workspace's own rows alone would
// show a member of an organization with sibling workspaces below a limit
// the pool has reached.
func ActiveSites(ctx context.Context, siteQ fwmod.Querier, poolID string) (int64, error) {
return siteQ.CountActiveSitesByPool(ctx, poolID)
}
// ReconcileWorkspace raises the workspace's counter to its active site row
// count, never lowering it: between a create workflow's gated increment and
// its row insert the counter legitimately exceeds the rows, and lowering there
// would erase that reservation. Real deletions are lowered by the decrement
// and status-drift paths, and by the exact set at boot.
// ReconcileWorkspace raises the counter of the workspace's primary pool to
// the active site rows of every workspace that pool is primary for, never
// lowering it: between a create workflow's gated increment and its row insert
// the counter legitimately exceeds the rows, and lowering there would erase
// that reservation. Real deletions are lowered by the decrement and
// status-drift paths, and by BoundWorkspace at boot. The count is the pool's,
// not the workspace's, because sibling workspaces draw down one counter: the
// workspace's own rows could not raise a shared counter to what the pool
// holds, and the create would pass a limit the pool has reached.
//
// Returns the observed active row count. A workspace with no primary pool
// assignment has no counter to converge on and is not an error.
// Returns the pool's observed active row count. A workspace with no primary
// pool assignment has no counter to converge on and is not an error.
func ReconcileWorkspace(ctx context.Context, entQ entitlements.Querier, siteQ fwmod.Querier, workspaceID string) (int64, error) {
observed, poolID, err := observe(ctx, entQ, siteQ, workspaceID)
if err != nil || poolID == "" {
@@ -48,37 +55,60 @@ func ReconcileWorkspace(ctx context.Context, entQ entitlements.Querier, siteQ fw
return observed, err
}
// SetWorkspaceExact sets the workspace's counter to its active site row count,
// for boot reconciliation only: no create workflow holds an unmaterialized
// reservation then, so the rows are the whole truth and an overstated counter
// (which refuses creates) comes back down.
// BoundWorkspace sets the counter of the workspace's primary pool between the
// active site rows of every workspace that pool is primary for and that count
// plus their open requests that may hold a slot, for boot reconciliation.
// Usage is pool-grain (sibling workspaces draw down one counter), so a
// workspace's own rows alone would set a shared counter to that workspace's
// share. A create, an activation or a delete still running across a
// restart holds a slot no row shows yet, so the counter comes down only to
// what the rows and those requests can hold together, and comes up to the
// rows when it is below them. With no request open the result is the active
// row count. It locks the counter before counting, so an increment or
// decrement on another process either is counted or waits.
//
// Reports whether a counter row was updated; a workspace whose pool has no
// usage row for the key has nothing to repair (the materializer creates the
// row when the entitlement is materialized).
func SetWorkspaceExact(ctx context.Context, entQ entitlements.Querier, siteQ fwmod.Querier, workspaceID string) (observed int64, updated bool, err error) {
observed, poolID, err := observe(ctx, entQ, siteQ, workspaceID)
if err != nil || poolID == "" {
return observed, false, err
// Reports the active row count and whether a counter row was updated; a
// workspace whose pool has no usage row for the key has nothing to repair
// (the materializer creates the row when the entitlement is materialized).
func BoundWorkspace(ctx context.Context, database *sql.DB, workspaceID string) (observed int64, updated bool, err error) {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return 0, false, err
}
res, err := entQ.SetNumericUsage(ctx, entitlements.SetNumericUsageParams{
PoolID: poolID,
defer func() { _ = tx.Rollback() }()
entQ := entitlements.New(tx)
assignment, err := entQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
if _, err := entQ.LockNumericUsage(ctx, entitlements.LockNumericUsageParams{
PoolID: assignment.PoolID, ResourceKey: ResourceKey,
}); errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
} else if err != nil {
return 0, false, err
}
slots, err := fwmod.New(tx).CountSiteSlotsByPool(ctx, assignment.PoolID)
if err != nil {
return 0, false, err
}
if _, err := entQ.BoundNumericUsage(ctx, entitlements.BoundNumericUsageParams{
Floor: slots.Active,
Ceiling: slots.Active + slots.OpenHolders,
PoolID: assignment.PoolID,
ResourceKey: ResourceKey,
Observed: observed,
})
if err != nil {
return observed, false, err
}); err != nil {
return 0, false, err
}
rows, err := res.RowsAffected()
if err != nil {
return observed, false, err
}
return observed, rows > 0, nil
return slots.Active, true, tx.Commit()
}
// observe resolves the workspace's primary pool (the resolution every quota
// read uses) and counts its active site rows. An absent assignment yields an
// empty pool id rather than an error: nothing to reconcile.
// read uses) and counts that pool's active site rows. An absent assignment
// yields an empty pool id rather than an error: nothing to reconcile.
func observe(ctx context.Context, entQ entitlements.Querier, siteQ fwmod.Querier, workspaceID string) (int64, string, error) {
assignment, err := entQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
if errors.Is(err, sql.ErrNoRows) {
@@ -87,7 +117,7 @@ func observe(ctx context.Context, entQ entitlements.Querier, siteQ fwmod.Querier
if err != nil {
return 0, "", err
}
observed, err := siteQ.CountActiveSitesByWorkspace(ctx, workspaceID)
observed, err := siteQ.CountActiveSitesByPool(ctx, assignment.PoolID)
if err != nil {
return 0, "", err
}
@@ -12,7 +12,7 @@ import (
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
)
@@ -89,10 +89,13 @@ func TestBootRepairsUsageCountersExactly(t *testing.T) {
understated := seedCountedWorkspace(t, database, "under", 4, 2, 1, true)
overstated := seedCountedWorkspace(t, database, "over", 3, 0, 20, true)
uncounted := seedCountedWorkspace(t, database, "none", 2, 0, 0, false)
// No site row and a leaked slot: a first create whose run died after
// taking its slot, or a workspace whose sites were all deleted.
siteless := seedCountedWorkspace(t, database, "siteless", 0, 0, 1, true)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := repairSiteUsageCounters(context.Background(),
entitlements.New(database), fwmod.New(database), logger); err != nil {
database, fwmod.New(database), logger); err != nil {
t.Fatalf("repair: %v", err)
}
@@ -102,6 +105,9 @@ func TestBootRepairsUsageCountersExactly(t *testing.T) {
if got := counterOf(t, database, overstated); got != 3 {
t.Errorf("overstated counter = %d, want 3", got)
}
if got := counterOf(t, database, siteless); got != 0 {
t.Errorf("counter of a workspace with no site row and no open request = %d, want 0", got)
}
var rows int
if err := database.QueryRowContext(context.Background(), `
SELECT count(*) FROM core.numeric_entitlement_usage neu
@@ -113,3 +119,202 @@ func TestBootRepairsUsageCountersExactly(t *testing.T) {
t.Errorf("repair created %d usage rows for a workspace that had none", rows)
}
}
// TestBootRepairKeepsSlotsOpenRequestsHold covers a create, an activation and
// a delete whose workflows run across a restart: each holds a slot no active
// row shows, so boot reconciliation lowers an overstated counter only to the
// active rows plus those requests, and a finished or declined request holds
// nothing.
func TestBootRepairKeepsSlotsOpenRequestsHold(t *testing.T) {
database := newReconcileDomainsDB(t)
ctx := context.Background()
if err := integration.RegisterProviders(ctx, database, []integration.ProviderSource{fwmod.ProviderSource()}); err != nil {
t.Fatal(err)
}
conn, err := integration.New(database).GetConnectionByProvider(ctx, fwmod.ModuleName)
if err != nil {
t.Fatal(err)
}
// Two active sites and one read-only; the counter reads 2 + the three
// open requests' slots + 2 drifted.
ws := seedCountedWorkspace(t, database, "inflight", 2, 0, 7, true)
var readonlySite string
if err := database.QueryRowContext(ctx,
`INSERT INTO fedwiki.sites (workspace_id, domain, status) VALUES ($1, $2, 'readonly') RETURNING site_id`,
ws, fmt.Sprintf("inflight-ro-%d.localtest.me", time.Now().UnixNano())).Scan(&readonlySite); err != nil {
t.Fatal(err)
}
record := func(instance, operation, params string) {
t.Helper()
if instance == "" {
var err error
if instance, err = integration.NewInstanceID(ctx, database); err != nil {
t.Fatal(err)
}
}
gen, err := integration.NewLifecycleGeneration(ctx, database)
if err != nil {
t.Fatal(err)
}
if a, err := integration.RecordLifecycleRequest(ctx, database, integration.LifecycleRequestSpec{
InstanceID: instance, Generation: gen, ConnectionID: conn.ConnectionID, ResourceKey: "fedwiki_sites",
WorkspaceID: ws, Operation: operation, Parameters: []byte(params),
WorkflowID: "usage-repair-" + instance,
}); err != nil || a.Kind != integration.LifecycleRecorded {
t.Fatalf("record %s: %+v, %v", operation, a, err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(), `DELETE FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
})
}
record("", integration.LifecycleCreate, `{"domain": "inflight-create.localtest.me"}`)
record(readonlySite, integration.LifecycleSetStatus, `{"domain": "inflight-ro.localtest.me", "status": "active"}`)
record("", integration.LifecycleDelete, `{"domain": "inflight-deleted.localtest.me"}`) // its row is already gone
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := repairSiteUsageCounters(ctx, database, fwmod.New(database), logger); err != nil {
t.Fatalf("repair: %v", err)
}
if got := counterOf(t, database, ws); got != 5 {
t.Fatalf("counter = %d, want 5: two active sites and the slots of three open requests", got)
}
// Once the requests are finished they hold nothing, and the next boot
// brings the counter to the rows.
if _, err := database.ExecContext(ctx,
`UPDATE core.lifecycle_requests SET status = 'failed', reason_code = 'retries_exhausted', finished_at = NOW()
WHERE workspace_id = $1`, ws); err != nil {
t.Fatal(err)
}
if err := repairSiteUsageCounters(ctx, database, fwmod.New(database), logger); err != nil {
t.Fatalf("repair: %v", err)
}
if got := counterOf(t, database, ws); got != 2 {
t.Errorf("counter = %d after the requests finished, want 2", got)
}
// A swap moves one site out of active and one in, so its activating
// half holds no slot: an open swap leaves a leaked slot to the boot.
var activeSite string
if err := database.QueryRowContext(ctx,
`SELECT site_id FROM fedwiki.sites WHERE workspace_id = $1 AND status = 'active' LIMIT 1`, ws).Scan(&activeSite); err != nil {
t.Fatal(err)
}
if _, err := database.ExecContext(ctx,
`UPDATE core.numeric_entitlement_usage SET current_usage = 3
WHERE pool_id = (SELECT pool_id FROM core.pool_assignments WHERE workspace_id = $1 AND is_primary)`, ws); err != nil {
t.Fatal(err)
}
record(readonlySite, integration.LifecycleSetStatus,
fmt.Sprintf(`{"domain": "inflight-ro.localtest.me", "status": "active", "swap_park_site_id": %q}`, activeSite))
record(activeSite, integration.LifecycleSetStatus,
fmt.Sprintf(`{"domain": "inflight-active.localtest.me", "status": "readonly", "swap_activate_site_id": %q}`, readonlySite))
if err := repairSiteUsageCounters(ctx, database, fwmod.New(database), logger); err != nil {
t.Fatalf("repair: %v", err)
}
if got := counterOf(t, database, ws); got != 2 {
t.Errorf("counter = %d with an open swap and one leaked slot, want the 2 active rows", got)
}
}
// A workspace with no site row keeps the slot its open create holds, and
// gives it back once the create has finished.
func TestBootRepairBoundsAWorkspaceWithNoSiteRow(t *testing.T) {
database := newReconcileDomainsDB(t)
ctx := context.Background()
if err := integration.RegisterProviders(ctx, database, []integration.ProviderSource{fwmod.ProviderSource()}); err != nil {
t.Fatal(err)
}
conn, err := integration.New(database).GetConnectionByProvider(ctx, fwmod.ModuleName)
if err != nil {
t.Fatal(err)
}
ws := seedCountedWorkspace(t, database, "firstcreate", 0, 0, 2, true)
instance, err := integration.NewInstanceID(ctx, database)
if err != nil {
t.Fatal(err)
}
gen, err := integration.NewLifecycleGeneration(ctx, database)
if err != nil {
t.Fatal(err)
}
if a, err := integration.RecordLifecycleRequest(ctx, database, integration.LifecycleRequestSpec{
InstanceID: instance, Generation: gen, ConnectionID: conn.ConnectionID, ResourceKey: "fedwiki_sites",
WorkspaceID: ws, Operation: integration.LifecycleCreate, Parameters: []byte(`{"domain": "first-create.localtest.me"}`),
WorkflowID: "usage-repair-" + instance,
}); err != nil || a.Kind != integration.LifecycleRecorded {
t.Fatalf("record create: %+v, %v", a, err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(), `DELETE FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := repairSiteUsageCounters(ctx, database, fwmod.New(database), logger); err != nil {
t.Fatalf("repair: %v", err)
}
if got := counterOf(t, database, ws); got != 1 {
t.Fatalf("counter = %d, want 1: no site row and the slot of one open create", got)
}
if _, err := database.ExecContext(ctx,
`UPDATE core.lifecycle_requests SET status = 'failed', reason_code = 'retries_exhausted', finished_at = NOW()
WHERE instance_id = $1`, instance); err != nil {
t.Fatal(err)
}
if err := repairSiteUsageCounters(ctx, database, fwmod.New(database), logger); err != nil {
t.Fatalf("repair: %v", err)
}
if got := counterOf(t, database, ws); got != 0 {
t.Errorf("counter = %d after the create finished, want 0", got)
}
}
// Usage is pool-grain: every workspace whose primary assignment is the pool
// draws down one counter (resource-pools invariant 7). The repair counts the
// slots of all of them, so a sibling workspace with no site row, or with
// sites of its own, never sets the shared counter to its own count alone.
func TestBootRepairCountsEveryWorkspaceOfThePool(t *testing.T) {
database := newReconcileDomainsDB(t)
ctx := context.Background()
first := seedCountedWorkspace(t, database, "pool-first", 2, 0, 2, true)
sibling := func(label string, active int) string {
var ws string
if err := database.QueryRowContext(ctx, `
WITH w AS (
INSERT INTO core.workspaces (org_id, name)
SELECT org_id, $2 FROM core.workspaces WHERE workspace_id = $1
RETURNING workspace_id)
INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary, status)
SELECT pa.pool_id, w.workspace_id, TRUE, 'active'
FROM core.pool_assignments pa, w
WHERE pa.workspace_id = $1 AND pa.is_primary
RETURNING workspace_id`, first, label).Scan(&ws); err != nil {
t.Fatalf("sibling workspace: %v", err)
}
for i := 0; i < active; i++ {
if _, err := database.ExecContext(ctx,
`INSERT INTO fedwiki.sites (workspace_id, domain, status) VALUES ($1, $2, 'active')`,
ws, fmt.Sprintf("%s-%d-%d.localtest.me", label, time.Now().UnixNano(), i)); err != nil {
t.Fatalf("sibling site: %v", err)
}
}
return ws
}
sibling("Empty", 0)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := repairSiteUsageCounters(ctx, database, fwmod.New(database), logger); err != nil {
t.Fatalf("repair: %v", err)
}
if got := counterOf(t, database, first); got != 2 {
t.Errorf("shared counter = %d beside a sibling workspace with no site, want 2", got)
}
sibling("Busy", 1)
if err := repairSiteUsageCounters(ctx, database, fwmod.New(database), logger); err != nil {
t.Fatalf("repair: %v", err)
}
if got := counterOf(t, database, first); got != 3 {
t.Errorf("shared counter = %d with 2 and 1 active sites in two workspaces, want 3", got)
}
}
@@ -27,6 +27,10 @@ func init() {
Label: "Restore", Method: "POST", Path: "/partials/fedwiki/sites/{domain}/restore",
Note: "Recoverable (an archived site is already off the active set), so it fires with no confirm modal.",
})
forms.RegisterTrigger(forms.ActionTrigger{
Label: "Dismiss", Method: "POST", Path: "/partials/fedwiki/requests/{instance}/{generation}/dismiss",
Note: "The close control of a declined or failed request's notice. It records only that the member dismissed the outcome, so it fires with no confirm modal.",
})
forms.RegisterTrigger(forms.ActionTrigger{
Label: "Cancel verification", Method: "POST", Path: "/partials/fedwiki/custom-domains/{claimID}/cancel", Modal: true,
})
+286 -79
View File
@@ -28,10 +28,10 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
siteusage "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/usage"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/google/uuid"
"go.temporal.io/sdk/client"
)
@@ -55,6 +55,9 @@ type FedWikiHandler struct {
// registry enforces the same gate at ClaimExternal.
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
// ConnectionID returns FedWiki's connection identifier, bound at boot
// after provider registration; every request the API records names it.
ConnectionID func() string
}
// FedWikiHandlerConfig holds configuration for creating a FedWikiHandler.
@@ -71,6 +74,7 @@ type FedWikiHandlerConfig struct {
CustomDomainTarget string
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
ConnectionID func() string
}
// NewFedWikiHandler creates a new FedWikiHandler.
@@ -88,9 +92,17 @@ func NewFedWikiHandler(cfg FedWikiHandlerConfig) *FedWikiHandler {
CustomDomainTarget: cfg.CustomDomainTarget,
CustomDomainsGate: cfg.CustomDomainsGate,
SupportURL: cfg.SupportURL,
ConnectionID: cfg.ConnectionID,
}
}
func (h *FedWikiHandler) connectionID() string {
if h.ConnectionID == nil {
return ""
}
return h.ConnectionID()
}
// RegisterRoutes registers all FedWiki API routes.
func (h *FedWikiHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/fedwiki/sites", h.ListSites)
@@ -191,13 +203,75 @@ func (h *FedWikiHandler) buildSiteURL(domain string, isCustomDomain bool) string
type CreateSiteRequest struct {
Domain string `json:"domain"`
IsCustomDomain bool `json:"isCustomDomain"`
// Instance is the client's identifier for the site it asks for (a
// UUID). Optional: without it the handler mints one, and the client
// gets no idempotent retry. Repeating a create with its instance is how
// a client polls (integration-delivery-core design D25, D31).
Instance string `json:"instance,omitempty"`
}
// CreateSiteResponse is the response for creating a site.
type CreateSiteResponse struct {
// AcceptedResponse answers a new lifecycle request, or a repeat of one still
// open: 202, with the address the request was recorded under.
type AcceptedResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
WorkflowID string `json:"workflowId,omitempty"`
Instance string `json:"instance"`
Generation int64 `json:"generation"`
WorkflowID string `json:"workflowId"`
Status string `json:"status"`
}
// OutcomeResponse answers a repeat of a finished lifecycle request: 200,
// with its stored status, its reason code and the console's sentence for
// it, and the site id when it succeeded.
type OutcomeResponse struct {
Success bool `json:"success"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
SiteID string `json:"siteId,omitempty"`
}
// msgAPIConflict answers an address another request holds.
const msgAPIConflict = "Another request holds this instance and generation."
// respondRepeat answers a repeat from its record: 202 while it is open, 200
// with the stored outcome once it finished.
func respondRepeat(w http.ResponseWriter, held integration.LifecycleRequest, message string) {
if held.Status == integration.LifecycleAccepted {
respondJSON(w, http.StatusAccepted, AcceptedResponse{
Success: true, Message: message, Instance: held.InstanceID, Generation: held.Generation,
WorkflowID: held.WorkflowID, Status: held.Status,
})
return
}
out := OutcomeResponse{Success: held.Status == integration.LifecycleSucceeded, Status: held.Status}
if held.ReasonCode.Valid {
out.Reason = held.ReasonCode.String
out.Message = reasonSentence(held.ReasonCode.String)
}
if held.Status == integration.LifecycleSucceeded && held.Operation == integration.LifecycleCreate {
out.SiteID = held.InstanceID
}
respondJSON(w, http.StatusOK, out)
}
// sameCreate reports whether a recorded create is the one a request would
// record: the same requesting person and the same resolved domain and
// custom-domain flag (design D26's content; the workspace was matched by the
// lookup).
func sameCreate(held integration.LifecycleRequest, personID string, params requestParams) bool {
if held.Operation != integration.LifecycleCreate {
return false
}
heldPerson := ""
if held.RequestedByPersonID.Valid {
heldPerson = held.RequestedByPersonID.UUID.String()
}
p := decodeParams(held.Parameters)
return heldPerson == personID && p.Domain == params.Domain &&
(p.IsCustomDomain != nil) == (params.IsCustomDomain != nil) &&
(p.IsCustomDomain == nil || *p.IsCustomDomain == *params.IsCustomDomain)
}
// CreateSite handles POST /api/fedwiki/sites - initiates site creation workflow.
@@ -224,6 +298,40 @@ func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
return
}
const acceptedMessage = "Site creation initiated. This may take a few minutes."
// A create is always its instance's first request, so a client's
// instance finds its create alone (design D25), and a repeat is answered
// from its record before the name is checked again: once the first
// request's workflow has placed a custom domain, the registry answers
// that name as taken. Without an instance the handler mints one, and the
// client gets no idempotent retry.
instance := ""
if req.Instance != "" {
id, err := uuid.Parse(req.Instance)
if err != nil {
respondError(w, http.StatusBadRequest, "instance must be a UUID", "")
return
}
instance = id.String()
_, held, found, err := integration.FirstLifecycleRequest(r.Context(), h.Database, session.WorkspaceID, instance)
if err != nil {
h.Logger.Error("failed to look up a create request", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site creation", h.SupportURL)
return
}
if found {
fqdn, ok := resolveCreateDomain(req.Domain, "", req.IsCustomDomain, h.FedWikiAllowedDomains)
custom := req.IsCustomDomain
if !ok || !sameCreate(held, session.PersonID, requestParams{Domain: fqdn, IsCustomDomain: &custom}) {
respondError(w, http.StatusConflict, msgAPIConflict, "")
return
}
respondRepeat(w, held, acceptedMessage)
return
}
}
// External-domain branch: the registry's own four-branch disposition
// (design D4, and the fedwiki-sites delta's "Site creation allocates its
// name through the domains registry"):
@@ -250,7 +358,7 @@ func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
disposition, userMsg, err := classifyCustomDomain(r.Context(), h.Registry, session.WorkspaceID, req.Domain)
if err != nil {
h.Logger.Error("domain availability check failed", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "We couldn't check that domain right now. Try again.", h.SupportURL)
respondError(w, http.StatusInternalServerError, "Couldn't check that domain right now. Try again.", h.SupportURL)
return
}
switch disposition {
@@ -295,48 +403,102 @@ func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
// Check if Temporal client is available
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
respondError(w, http.StatusServiceUnavailable, "Site creation service is not available", h.SupportURL)
respondError(w, http.StatusServiceUnavailable, msgServiceAvailable, h.SupportURL)
return
}
// Start the workflow
workflowID := "create-fedwiki-site-" + uuid.New().String()
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
// The parent-domain fallback resolves before recording, so the request
// and the site agree whatever the configured domains become.
fqdn, ok := resolveCreateDomain(req.Domain, "", req.IsCustomDomain, h.FedWikiAllowedDomains)
if !ok {
respondError(w, http.StatusServiceUnavailable, msgServiceAvailable, h.SupportURL)
return
}
custom := req.IsCustomDomain
params := requestParams{Domain: fqdn, IsCustomDomain: &custom}
input := workflows.CreateFedWikiSiteWorkflowInput{
WorkspaceID: session.WorkspaceID,
Domain: req.Domain,
OwnerName: session.Username,
OwnerID: session.OIDCSubject,
IsCustomDomain: req.IsCustomDomain,
SupportURL: h.SupportURL,
if instance != "" {
// An instance that already names a site, such as one created before
// lifecycle requests or one another request created, is not a new
// create: its row insert would refuse the site the farm made.
taken, err := siteHoldsInstance(r.Context(), h.SiteQ, instance)
if err != nil {
h.Logger.Error("failed to look up a site by instance", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site creation", h.SupportURL)
return
}
if taken {
respondError(w, http.StatusConflict, msgAPIConflict, "")
return
}
} else {
minted, err := integration.NewInstanceID(r.Context(), h.Database)
if err != nil {
h.Logger.Error("failed to mint an instance", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site creation", h.SupportURL)
return
}
instance = minted
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.CreateFedWikiSiteWorkflow, input)
generation, err := integration.NewLifecycleGeneration(r.Context(), h.Database)
if err != nil {
h.Logger.Error("failed to start workflow",
slog.String("workflowID", workflowID),
slog.Any("error", err))
h.Logger.Error("failed to mint a generation", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site creation", h.SupportURL)
return
}
addr := requestAddress{Instance: instance, Generation: generation}
workflowID := workflows.CreateRequestWorkflowID(instance)
answer, err := integration.RecordFirstLifecycleRequest(r.Context(), h.Database, newRequestSpec(
h.connectionID(), session.WorkspaceID, session.PersonID, integration.LifecycleCreate, addr, workflowID, params))
if err != nil {
h.Logger.Error("failed to record a create request", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site creation", h.SupportURL)
return
}
if answer.Kind != integration.LifecycleRecorded {
// Another submission recorded this instance between the lookup and
// the insert. A client repeating an unanswered create sends the same
// instance again, so this caller's identical create is answered as
// the repeat it is; anything else, including another workspace's
// request, is a conflict, never answered with that content.
if req.Instance != "" {
_, held, found, err := integration.FirstLifecycleRequest(r.Context(), h.Database, session.WorkspaceID, instance)
if err == nil && found && sameCreate(held, session.PersonID, params) {
respondRepeat(w, held, acceptedMessage)
return
}
}
respondError(w, http.StatusConflict, msgAPIConflict, "")
return
}
h.Logger.Info("site creation workflow started",
slog.String("workflowID", we.GetID()),
slog.String("runID", we.GetRunID()),
slog.String("domain", req.Domain))
respondJSON(w, http.StatusAccepted, CreateSiteResponse{
Success: true,
Message: "Site creation initiated. This may take a few minutes.",
WorkflowID: we.GetID(),
started := startRequestWorkflow(r.Context(), h.TemporalClient, h.Database, h.Logger,
workflowID, workflows.CreateFedWikiSiteRequestWorkflow, workflows.CreateSiteRequestInput{
ConnectionID: h.connectionID(),
InstanceID: instance,
Generation: generation,
WorkspaceID: session.WorkspaceID,
Domain: fqdn,
OwnerName: session.Username,
OwnerID: session.OIDCSubject,
}, session.WorkspaceID, addr)
if !started {
respondError(w, http.StatusInternalServerError, "Failed to initiate site creation", h.SupportURL)
return
}
h.Logger.Info("site creation requested", slog.String("workflowID", workflowID), slog.String("domain", fqdn))
respondJSON(w, http.StatusAccepted, AcceptedResponse{
Success: true, Message: acceptedMessage, Instance: instance, Generation: generation,
WorkflowID: workflowID, Status: integration.LifecycleAccepted,
})
}
// DeleteSite handles DELETE /api/fedwiki/sites/{domain} - initiates site deletion workflow.
// DeleteSite handles DELETE /api/fedwiki/sites/{domain} - initiates site
// deletion. Optional instance and generation query parameters make it
// idempotent: a repeat with them is answered from its record, even after the
// site is gone. Without them the handler uses the site's id and a fresh
// generation, and a repeat after the deletion finished answers 404, which
// HTTP's definition of an idempotent DELETE allows (design D31).
func (h *FedWikiHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
session, err := h.getSessionFromRequest(r)
if err != nil {
@@ -350,15 +512,48 @@ func (h *FedWikiHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "Domain is required", "")
return
}
const acceptedMessage = "Site deletion initiated. This may take a few minutes."
query := r.URL.Query()
withAddress := query.Has("instance") || query.Has("generation")
var addr requestAddress
if withAddress {
var ok bool
if addr, ok = parseAddressValues(query.Get("instance"), query.Get("generation")); !ok {
respondError(w, http.StatusBadRequest, "instance must be a UUID and generation a positive integer", "")
return
}
}
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
respondError(w, http.StatusServiceUnavailable, msgServiceAvailable, h.SupportURL)
return
}
if withAddress {
_, held, found, err := integration.LookupLifecycleRequest(r.Context(), h.Database, session.WorkspaceID, addr.Instance, addr.Generation)
if err != nil {
h.Logger.Error("failed to look up a delete request", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site deletion", h.SupportURL)
return
}
if found {
if !sameRowRequest(held, integration.LifecycleDelete, domain, "", session.PersonID) {
respondError(w, http.StatusConflict, msgAPIConflict, "")
return
}
respondRepeat(w, held, acceptedMessage)
return
}
}
// Verify the site belongs to the user's workspace
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err != nil {
h.Logger.Error("failed to get site", slog.Any("error", err))
respondError(w, http.StatusNotFound, "Site not found", "")
return
}
if site.WorkspaceID != session.WorkspaceID {
// A distinct answer here is an existence oracle for other tenants'
// domains (security-audit-remediation-2 design D5): answer exactly
@@ -371,54 +566,65 @@ func (h *FedWikiHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "Site not found", "")
return
}
// Check if Temporal client is available
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
respondError(w, http.StatusServiceUnavailable, "Site creation service is not available", h.SupportURL)
return
}
// Start the workflow. Its id is the site's, so a second delete for the
// same site while one runs is refused by Temporal rather than queued
// behind the first, where it would act on whatever holds the name by
// then (security-audit-remediation-3 design D3).
workflowID := workflows.DeleteWorkflowID(site.SiteID)
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := workflows.DeleteFedWikiSiteWorkflowInput{
SiteID: site.SiteID,
Domain: domain,
WorkspaceID: session.WorkspaceID,
WasActive: site.Status == "active",
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.DeleteFedWikiSiteWorkflow, input)
if err != nil {
if workflows.AlreadyRunning(err) {
respondError(w, http.StatusConflict, "Site deletion is already in progress", "")
if withAddress {
if site.SiteID != addr.Instance {
respondError(w, http.StatusConflict, msgAPIConflict, "")
return
}
h.Logger.Error("failed to start workflow",
slog.String("workflowID", workflowID),
slog.Any("error", err))
} else {
gens, err := integration.NextGenerations(r.Context(), h.Database, []string{site.SiteID})
if err != nil {
h.Logger.Error("failed to compute a generation", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site deletion", h.SupportURL)
return
}
addr = requestAddress{Instance: site.SiteID, Generation: gens[site.SiteID]}
}
workflowID := workflows.DeleteRequestWorkflowID(site.SiteID, addr.Generation)
answer, err := integration.RecordLifecycleRequest(r.Context(), h.Database, newRequestSpec(
h.connectionID(), session.WorkspaceID, session.PersonID, integration.LifecycleDelete, addr, workflowID,
requestParams{Domain: site.Domain}))
if err != nil {
h.Logger.Error("failed to record a delete request", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site deletion", h.SupportURL)
return
}
switch answer.Kind {
case integration.LifecycleRecorded:
case integration.LifecycleBusy:
respondError(w, http.StatusConflict, msgDeletionBusy, "")
return
case integration.LifecycleRepeatOpen, integration.LifecycleRepeatFinished:
// Recorded by an identical request between the lookup and here.
_, held, _, err := integration.LookupLifecycleRequest(r.Context(), h.Database, session.WorkspaceID, addr.Instance, addr.Generation)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to initiate site deletion", h.SupportURL)
return
}
respondRepeat(w, held, acceptedMessage)
return
default:
respondError(w, http.StatusConflict, msgAPIConflict, "")
return
}
h.Logger.Info("site deletion workflow started",
slog.String("workflowID", we.GetID()),
slog.String("runID", we.GetRunID()),
slog.String("domain", domain))
respondJSON(w, http.StatusAccepted, map[string]interface{}{
"success": true,
"message": "Site deletion initiated. This may take a few minutes.",
"workflowId": we.GetID(),
started := startRequestWorkflow(r.Context(), h.TemporalClient, h.Database, h.Logger,
workflowID, workflows.DeleteFedWikiSiteRequestWorkflow, workflows.DeleteSiteRequestInput{
ConnectionID: h.connectionID(),
InstanceID: site.SiteID,
Generation: addr.Generation,
WorkspaceID: session.WorkspaceID,
Domain: site.Domain,
}, session.WorkspaceID, addr)
if !started {
respondError(w, http.StatusInternalServerError, "Failed to initiate site deletion", h.SupportURL)
return
}
h.Logger.Info("site deletion requested", slog.String("workflowID", workflowID), slog.String("domain", domain))
respondJSON(w, http.StatusAccepted, AcceptedResponse{
Success: true, Message: acceptedMessage, Instance: addr.Instance, Generation: addr.Generation,
WorkflowID: workflowID, Status: integration.LifecycleAccepted,
})
}
@@ -493,11 +699,12 @@ func (h *FedWikiHandler) getWorkspaceQuota(ctx context.Context, workspaceID stri
// Rows, not the reservation counter, are what the member is shown
// (design D1): the counter is a create-time reservation and an import or
// a farm sync writes rows without touching it.
// a farm sync writes rows without touching it. The rows are the pool's,
// every workspace it is primary for, because the limit is the pool's.
// The pool and entitlement lookups above run first on purpose: a handler
// built without a site store (the zero-domains render tests) fails on the
// missing pool row instead of dereferencing a nil SiteQ here.
current, err := siteusage.ActiveSites(ctx, h.SiteQ, workspaceID)
current, err := siteusage.ActiveSites(ctx, h.SiteQ, assignment.PoolID)
if err != nil {
return nil, err
}
@@ -81,6 +81,9 @@ func TestCreateFormZeroDomainsGuard(t *testing.T) {
t.Run("configured domains render the ordinary submittable form", func(t *testing.T) {
h, ctx := authedFedWikiHandler(t, "ws-1", []string{"example.test"})
// The form carries an instance and generation the database mints
// (integration-delivery-core design D25).
h.Database = newClaimTestDB(t)
req := httptest.NewRequest("GET", "/partials/fedwiki/create-form", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.GetCreateForm(rec, req)
@@ -95,6 +98,9 @@ func TestCreateFormZeroDomainsGuard(t *testing.T) {
if strings.Contains(body, "No site domains are configured") {
t.Errorf("guard message rendered even though a domain is configured: %s", body)
}
if !hiddenFieldSet(body, "instance") || !hiddenFieldSet(body, "generation") {
t.Errorf("the form carries no minted instance and generation: %s", body)
}
})
}
@@ -406,6 +406,7 @@ func TestCreateFormAffordanceSurvivesRevocation(t *testing.T) {
t.Run(name, func(t *testing.T) {
h := newRenderHandler(t)
h.Registry = registry
h.Database = database // mints the create's instance and generation
h.EntitlementsQ = &gateEntitlements{granted: tc.granted}
h.CustomDomainTarget = "connect.example.test"
// This test is about the custom-domain affordance, not the
@@ -99,6 +99,23 @@ var siteCreateForm = forms.Register(forms.FormSpec{
RuntimeOptions: true,
ShowIf: forms.ShowIf{Field: "isCustomDomain", Equals: []string{""}},
},
// instance and generation address the create's lifecycle request
// (integration-delivery-core design D25). The render mints them, so
// a double submit repeats one address and records one request.
// Optional to the parse: the handler checks them and answers a
// missing or malformed pair as a stale form.
{
Name: "instance",
Label: "Instance",
Control: forms.Hidden,
Optional: true,
},
{
Name: "generation",
Label: "Generation",
Control: forms.Hidden,
Optional: true,
},
},
Commit: "Create site",
WayOut: forms.DismissModal("Cancel"),
@@ -359,7 +359,14 @@ func (s SiteViewModel) TypeBadge() server.Badge {
return server.StatusBadge("subdomain")
}
// StatusBadge is the row's Status cell: Pending while a request is open on
// it, and otherwise the site's observed status, which a declined or failed
// request leaves in place. Outcomes are notices above the list, never a
// status (integration-delivery-core design D32).
func (s SiteViewModel) StatusBadge() server.Badge {
if s.Pending {
return server.StatusBadge("pending")
}
if s.IsReadonly {
return server.StatusBadge("read_only")
}
@@ -10,10 +10,13 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"go.temporal.io/sdk/mocks"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
@@ -70,6 +73,11 @@ const (
oracleOwnerWorkspace = "ws-owner"
)
// oracleAddress is a posted (instance, generation) no request holds.
func oracleAddress() string {
return url.Values{"instance": {uuid.NewString()}, "generation": {"1"}}.Encode()
}
func oracleFixtureSiteQ() oracleSiteQ {
return oracleSiteQ{rows: map[string]fwmod.GetSiteByDomainRow{
oracleForeignDomain: {
@@ -89,6 +97,8 @@ func TestAPIDeleteSiteOracle(t *testing.T) {
h := &FedWikiHandler{
SiteQ: oracleFixtureSiteQ(),
Logger: discardLogger(),
// Never called: both requests are refused before any start.
TemporalClient: &mocks.Client{},
}
cfg, sctx := oracleSession(t, oracleCallerWorkspace)
h.AuthConfig = cfg
@@ -118,15 +128,20 @@ func TestAPIDeleteSiteOracle(t *testing.T) {
// TestPartialsDeleteSiteOracle covers task 4.2/4.3(b): the HTMX partial's
// DeleteSite handler must answer a foreign site exactly as a nonexistent one.
func TestPartialsDeleteSiteOracle(t *testing.T) {
// The row action looks its request up before it resolves the domain
// (integration-delivery-core design D31), so it needs the database and
// a posted address; no request exists at it, so the site decides.
h := &FedWikiPartialsHandler{
SiteQ: oracleFixtureSiteQ(),
Logger: discardLogger(),
SiteQ: oracleFixtureSiteQ(),
Logger: discardLogger(),
Database: newClaimTestDB(t),
TemporalClient: &mocks.Client{},
}
cfg, sctx := oracleSession(t, oracleCallerWorkspace)
h.AuthConfig = cfg
deleteRequest := func(domain string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, "/partials/fedwiki/sites/"+domain, nil).WithContext(sctx)
req := httptest.NewRequest(http.MethodDelete, "/partials/fedwiki/sites/"+domain+"?"+oracleAddress(), nil).WithContext(sctx)
req.SetPathValue("domain", domain)
rec := httptest.NewRecorder()
h.DeleteSite(rec, req)
@@ -151,15 +166,21 @@ func TestPartialsDeleteSiteOracle(t *testing.T) {
// path (changeSiteStatus, backing ArchiveSite here) must answer a foreign
// site exactly as a nonexistent one.
func TestPartialsStatusChangeOracle(t *testing.T) {
// The row action looks its request up before it resolves the domain
// (integration-delivery-core design D31), so it needs the database and
// a posted address; no request exists at it, so the site decides.
h := &FedWikiPartialsHandler{
SiteQ: oracleFixtureSiteQ(),
Logger: discardLogger(),
SiteQ: oracleFixtureSiteQ(),
Logger: discardLogger(),
Database: newClaimTestDB(t),
TemporalClient: &mocks.Client{},
}
cfg, sctx := oracleSession(t, oracleCallerWorkspace)
h.AuthConfig = cfg
archiveRequest := func(domain string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/partials/fedwiki/sites/"+domain+"/archive", nil).WithContext(sctx)
req := httptest.NewRequest(http.MethodPost, "/partials/fedwiki/sites/"+domain+"/archive", strings.NewReader(oracleAddress())).WithContext(sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("domain", domain)
rec := httptest.NewRecorder()
h.ArchiveSite(rec, req)
+94 -720
View File
@@ -12,20 +12,17 @@ import (
"io/fs"
"log/slog"
"net/http"
"sort"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
siteusage "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/usage"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
coreweb "git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/google/uuid"
"go.temporal.io/sdk/client"
)
@@ -51,7 +48,12 @@ type FedWikiPartialsHandler struct {
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
SwapCooldown time.Duration // active-site rotation cooldown window
Templates *server.SafeTemplates
// ConnectionID returns FedWiki's connection identifier, which boot binds
// after provider registration; read per request because the handlers
// are built before the binding (design D28). Every lifecycle request
// FedWiki records names it.
ConnectionID func() string
Templates *server.SafeTemplates
}
// FedWikiPartialsConfig holds configuration for the FedWiki partials handler.
@@ -69,6 +71,7 @@ type FedWikiPartialsConfig struct {
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
SwapCooldown time.Duration
ConnectionID func() string
// TemplatesFS is FedWiki's own template directory (fedwiki_*.html),
// supplied by the Adapter from its embedded templates tree — see
// internal/integrations/fedwiki.Adapter.Templates (the UIProvider hook)
@@ -100,7 +103,6 @@ func NewFedWikiPartialsHandler(cfg FedWikiPartialsConfig) (*FedWikiPartialsHandl
}).ParseFS(cfg.TemplatesFS,
"fedwiki_sites.html",
"fedwiki_create_form.html",
"fedwiki_create_success.html",
"fedwiki_custom_domain_pending.html",
)
if err != nil {
@@ -127,6 +129,7 @@ func NewFedWikiPartialsHandler(cfg FedWikiPartialsConfig) (*FedWikiPartialsHandl
CustomDomainsGate: cfg.CustomDomainsGate,
SupportURL: cfg.SupportURL,
SwapCooldown: cfg.SwapCooldown,
ConnectionID: cfg.ConnectionID,
Templates: server.NewSafeTemplates(tmpl, cfg.Logger),
}, nil
}
@@ -143,6 +146,7 @@ func (h *FedWikiPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /partials/fedwiki/sites/{domain}/restore", h.RestoreSite)
mux.HandleFunc("POST /partials/fedwiki/sites/{domain}/keep-active", h.KeepSiteActive)
mux.HandleFunc("DELETE /partials/fedwiki/sites/{domain}", h.DeleteSite)
mux.HandleFunc("POST /partials/fedwiki/requests/{instance}/{generation}/dismiss", h.DismissRequest)
}
// SiteViewModel represents a site for template rendering.
@@ -156,6 +160,28 @@ type SiteViewModel struct {
IsReadonly bool
IsArchived bool
IsForceReduced bool // parked by a downgrade/swap rotation, not archived by choice — Keep active/Restore on this site is cooldown-gated
// Generation is the generation this site's next request carries: above
// every request recorded for it, at least the clock (design D25). Each
// row action posts it with the site id.
Generation int64
// Pending marks a site, or a requested create, with an open request: the
// row shows Pending and renders no actions (design D32).
Pending bool
// Requested marks a row that is an open create rather than a site:
// nothing serves it yet, so its domain is not a link.
Requested bool
}
// RequestNotice is a declined or failed request the member has not
// dismissed. It renders as a notice above the list, never on a row: an
// outcome is something that happened, not a state of a site (design D32).
// Instance and Generation address the request its close control
// acknowledges.
type RequestNotice struct {
Sentence string // "<domain> wasn't <action>." and the reason
Tone string // warning for declined, danger for failed
Instance string
Generation int64
}
// SitesData holds data for the sites list partial.
@@ -203,7 +229,14 @@ type SitesData struct {
// (dissolve-member-domains): they already surface via the dashboard's
// pending notice and the embedded Domains section below the sites list.
CustomDomainClaims []CustomDomainClaimViewModel
Error string
// Polling is true while any of the workspace's requests is open: the
// list then re-renders itself three seconds after each render, and
// stops on the first render with nothing open (design D32).
Polling bool
// Notices are the workspace's undismissed declined and failed
// requests, newest first, shown above the list (design D32).
Notices []RequestNotice
Error string
}
// CustomDomainClaimViewModel is one verified external domain claim without a
@@ -212,6 +245,10 @@ type CustomDomainClaimViewModel struct {
ClaimID string
Domain string
CreatedAt string
// Instance and Generation address the one-click create, minted per
// render (design D25).
Instance string
Generation int64
}
// CreateControl builds the shared disabledControl part's data (design D6,
@@ -278,10 +315,22 @@ func (h *FedWikiPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request
return
}
// The workspace's open and unacknowledged requests: the open ones merged
// onto the rows they concern, the declined and failed ones as notices
// (design D32). Best-effort: a failed read lists the sites as they stand.
requests := h.listRequests(r.Context(), workspaceID)
// Build view models, splitting the live list (active + read-only) from the
// Archived section. Archived sites are hidden from the active list.
var siteVMs, archivedVMs []SiteViewModel
hasReadonly := false
siteIDs := make([]string, 0, len(sites))
siteStatus := make(map[string]string, len(sites))
for _, site := range sites {
siteIDs = append(siteIDs, site.SiteID)
siteStatus[site.SiteID] = site.Status
}
nextGen := h.nextGenerations(r.Context(), siteIDs)
for _, site := range sites {
vm := SiteViewModel{
ID: site.SiteID,
@@ -293,7 +342,9 @@ func (h *FedWikiPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request
IsReadonly: site.Status == "readonly",
IsArchived: site.Status == "archived",
IsForceReduced: site.ForceReducedAt.Valid,
Generation: nextGen[site.SiteID],
}
requests.apply(&vm)
switch site.Status {
case "archived":
archivedVMs = append(archivedVMs, vm)
@@ -304,9 +355,14 @@ func (h *FedWikiPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request
siteVMs = append(siteVMs, vm)
}
}
// Counted before the requested creates join the list: a pending create
// is not active yet (design D32).
liveSites := int64(len(siteVMs))
siteVMs = append(siteVMs, requests.createRows(siteIDs)...)
sort.SliceStable(siteVMs, func(i, j int) bool { return siteVMs[i].Domain < siteVMs[j].Domain })
// Get quota from entitlements
count := int64(len(siteVMs))
count := liveSites
var quota int64
canCreate := false
hasEntitlement := false
@@ -349,8 +405,10 @@ func (h *FedWikiPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request
NoDomainsConfigured: len(h.FedWikiAllowedDomains) == 0,
NoPublishedPlan: noPublishedPlan,
CooldownMsg: cooldownMsg,
CustomDomainClaims: h.customDomainClaims(r.Context(), workspaceID),
CustomDomainClaims: h.bannerRows(r.Context(), workspaceID, requests),
DomainsSurface: h.domainsSurface(r),
Polling: requests.anyOpen(),
Notices: requests.notices(siteStatus),
}
h.Templates.Render(w, "fedwiki_sites.html", data)
@@ -528,6 +586,17 @@ func (h *FedWikiPartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Re
}
values.Set("customDomainsEnabled", enabledRaw)
// The create's instance and generation are minted here, at render, so a
// double submit repeats one address (design D25). A deployment with no
// site domains renders no form, so nothing is minted for it.
if len(h.FedWikiAllowedDomains) > 0 {
if err := h.mintCreateAddress(r.Context(), &values); err != nil {
h.Logger.Error("failed to mint a create address", slog.Any("error", err))
http.Error(w, "Failed to load the form", http.StatusInternalServerError)
return
}
}
view := forms.Render(siteCreateForm, forms.Binding{
Mode: forms.ModeUnbound,
Values: values,
@@ -541,713 +610,6 @@ func (h *FedWikiPartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Re
})
}
// CreateSite handles POST /partials/fedwiki/sites
func (h *FedWikiPartialsHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.Logger.Error("failed to get session")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
values, errs := siteCreateForm.ParseWith(r, siteCreateOptions(h.FedWikiAllowedDomains))
if errs.Any() {
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Normalize before anything reads the name: the registry stores and
// compares normalized names, so an absolute FQDN ("wiki.example.org.")
// must lose its root label here or the row and the farm call disagree.
domain := dnsname.Normalize(values.String("domain"))
// selectedDomain is "" for a custom-domain submission: the field is not
// shown while isCustomDomain is checked (forms.go's field-order
// comment), and the workflow input documents SiteDomain as ignored for
// custom domains, so an empty value there is correct, not missing data.
selectedDomain := values.String("selectedDomain")
isCustomDomain := values.Bool("isCustomDomain")
// External-domain branch: the registry's four-branch disposition (design
// D4). Only the unclaimed branch starts a verification; a name already
// inside the workspace's active claim falls through to the ordinary
// create path below and is created instantly.
//
// Classification comes FIRST, and the entitlement gates the unclaimed
// branch alone: the boolean buys the right to claim a NEW external
// domain, not the right to use one already claimed. A name under a claim
// the workspace already holds places instantly whatever the plan says
// ("Nested site under an owned claim needs no verification"), so a
// revoked entitlement must not strand a live claim.
if isCustomDomain {
disposition, userMsg, err := classifyCustomDomain(r.Context(), h.Registry, session.WorkspaceID, domain)
if err != nil {
h.Logger.Error("domain availability check failed", slog.Any("error", err))
errs.Form("We couldn't check that domain right now. Try again.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
switch disposition {
case dispositionRefused, dispositionOwnPending:
// userMsg is about the typed domain itself, so it lands under
// that field rather than in the form-level slot (finding
// FA-27, carrying FA-8's field-attribution rule to this form).
errs.Field("domain", userMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
case dispositionUnclaimed:
enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID)
if err != nil {
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err))
errs.Form("We couldn't check your plan right now. Try again.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
if !enabled {
errs.Form("Your current plan doesn't include custom domains.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
initiation, msg := startClaimVerification(r.Context(), h.Registry, h.TemporalClient, h.Logger,
session.WorkspaceID, domain, h.CustomDomainTarget)
if msg != "" {
errs.Form(msg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
h.Templates.Render(w, "fedwiki_custom_domain_pending.html", CustomDomainPendingData{
ClaimID: initiation.ClaimID,
Domain: initiation.Domain,
Records: newCustomDomainRecords(initiation.Domain,
initiation.TXTRecordName, initiation.TXTRecordValue, initiation.ConnectTarget),
StatusText: "Waiting for your DNS records…",
// With the time of day, matching the status view: the
// verification window is hours, not days.
ExpiresAt: initiation.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
Polling: true,
})
// The banner gains the pending claim.
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
return
}
// dispositionOwnActive falls through: the claim already proves control.
} else {
// Hosted path only: a custom domain is a full FQDN, not a label
// under one of the farm domains. selectedDomain is already one of
// h.FedWikiAllowedDomains here: Parse refused anything else against
// siteCreateOptions above, so the old manual membership check is
// gone.
// Validate domain is a legal DNS label (lowercase enforced by
// dnsname.Normalize above).
if errMsg := dnsname.ValidateDNSLabel(domain); errMsg != "" {
errs.Field("domain", errMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Defense-in-depth: check assembled FQDN length (RFC 1035 §2.3.4)
if errMsg := dnsname.ValidateFQDN(domain, selectedDomain); errMsg != "" {
errs.Field("domain", errMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
}
// Check Temporal client
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
errs.Form("Site creation service is not available")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Start the workflow
workflowID := "create-fedwiki-site-" + uuid.New().String()
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := workflows.CreateFedWikiSiteWorkflowInput{
WorkspaceID: session.WorkspaceID,
Domain: domain,
SiteDomain: selectedDomain,
OwnerName: session.Username,
OwnerID: session.OIDCSubject,
IsCustomDomain: isCustomDomain,
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.CreateFedWikiSiteWorkflow, input)
if err != nil {
h.Logger.Error("failed to start workflow", slog.Any("error", err))
errs.Form("Failed to initiate site creation")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
h.Logger.Info("site creation workflow started",
slog.String("workflowID", we.GetID()),
slog.String("domain", domain),
slog.String("siteDomain", selectedDomain))
// Wait for the workflow to complete — but only within the wait budget, so
// the still-working answer can land before the server's write deadline.
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
defer cancelWait()
var result workflows.CreateFedWikiSiteWorkflowOutput
if err := we.Get(waitCtx, &result); err != nil {
if workflowWaitInterrupted(err) {
h.Logger.Warn("site creation outlived the request wait; workflow still running",
slog.String("workflowID", we.GetID()), slog.Any("error", err))
h.siteActionPending(w)
return
}
h.Logger.Error("workflow execution failed", slog.Any("error", err))
errs.Form("Site creation failed. Try again.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Check if the workflow reported failure
if !result.Success {
h.Logger.Warn("site creation workflow returned failure",
slog.String("domain", domain),
slog.String("error", result.ErrorMessage))
errs.Form(result.ErrorMessage)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Render success partial with HX-Trigger to refresh sites list
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
data := struct{ Domain string }{Domain: result.Domain}
h.Templates.Render(w, "fedwiki_create_success.html", data)
}
// DeleteSite handles DELETE /partials/fedwiki/sites/{domain}: permanent
// delete, triggered through the shared confirmAction modal like every
// other site-status action in this file (finding FA-41: the delete-confirm
// modal this once opened, and its GET route, were dead weight, superseded
// by that shared trigger and never actually reachable through it; its
// hx-target named a node, #deleteSiteModalBody, no live button pointed
// at). Errors and success both answer through siteActionResult, exactly as
// ArchiveSite, RestoreSite and KeepSiteActive already do, into the
// "Delete permanently" trigger's own data-action-target: the modal-shaped
// success and error partials this replaced were swapping into that same
// small inline target and had never fit it.
func (h *FedWikiPartialsHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.Logger.Error("failed to get session")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
domain := r.PathValue("domain")
if domain == "" {
h.siteActionResult(w, "Domain is required")
return
}
// Verify site belongs to user's workspace
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err != nil {
h.Logger.Error("failed to get site", slog.Any("error", err))
h.siteActionResult(w, "Site not found")
return
}
if site.WorkspaceID != session.WorkspaceID {
// A distinct answer here is an existence oracle for other tenants'
// domains (security-audit-remediation-2 design D5): answer exactly
// as the failed-lookup branch above, and keep the distinction in
// the log only.
h.Logger.Debug("site delete refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", site.WorkspaceID))
h.siteActionResult(w, "Site not found")
return
}
// Check Temporal client
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
h.siteActionResult(w, "Site creation service is not available")
return
}
// Start the workflow. Its id is the site's, so a second delete for the
// same site while one runs is refused by Temporal rather than queued
// (security-audit-remediation-3 design D3).
workflowID := workflows.DeleteWorkflowID(site.SiteID)
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := workflows.DeleteFedWikiSiteWorkflowInput{
SiteID: site.SiteID,
Domain: domain,
WorkspaceID: session.WorkspaceID,
WasActive: site.Status == "active",
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.DeleteFedWikiSiteWorkflow, input)
if err != nil {
if workflows.AlreadyRunning(err) {
h.siteActionResult(w, "Site deletion is already in progress")
return
}
h.Logger.Error("failed to start workflow", slog.Any("error", err))
h.siteActionResult(w, "Failed to initiate site deletion")
return
}
h.Logger.Info("site deletion workflow started",
slog.String("workflowID", we.GetID()),
slog.String("domain", domain))
// Wait for the workflow to complete — bounded like CreateSite's wait.
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
defer cancelWait()
var result workflows.DeleteFedWikiSiteWorkflowOutput
if err := we.Get(waitCtx, &result); err != nil {
if workflowWaitInterrupted(err) {
h.Logger.Warn("site deletion outlived the request wait; workflow still running",
slog.String("workflowID", we.GetID()), slog.Any("error", err))
h.siteActionPending(w)
return
}
h.Logger.Error("workflow execution failed", slog.Any("error", err))
h.siteActionResult(w, "Site deletion failed. Try again.")
return
}
// Check if the workflow reported failure
if !result.Success {
h.Logger.Warn("site deletion workflow returned failure",
slog.String("domain", domain),
slog.String("error", result.ErrorMessage))
h.siteActionResult(w, result.ErrorMessage)
return
}
// Success: HX-Trigger refreshes the sites list, which is the
// confirmation; the deleted row is simply gone from it.
h.siteActionResult(w, "")
}
// changeSiteStatus verifies ownership and runs SetSiteStatusWorkflow to move the
// caller's site to target (active|readonly|archived). Returns a user-facing error
// message ("" on success) plus pending=true when the request stopped waiting but
// the workflow is still running (see workflowWaitInterrupted).
//
// Every member status transition in this file — ArchiveSite, RestoreSite, and
// KeepSiteActive's below-the-limit branch — funnels through here, and the
// swap branch funnels through SwapActiveSiteWorkflow. That is why no handler
// in this package touches the domains registry directly: the placement's
// `servable` flag is written where the status write actually lands, in
// workflows.SetSiteStatusActivity (archived ⇒ false, active/readonly ⇒ true),
// and the swap path crosses no servable boundary at all. DeleteSite's permanent
// delete likewise releases the placement inside DeleteFedWikiSiteActivity.
// Keeping the registry write beside the status write means a transition can
// never land locally while the name's servability silently disagrees.
func (h *FedWikiPartialsHandler) changeSiteStatus(r *http.Request, domain, target string) (errMsg string, pending bool) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
return "Unauthorized", false
}
if domain == "" {
return "Domain is required", false
}
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err != nil {
return "Site not found", false
}
if site.WorkspaceID != session.WorkspaceID {
// A distinct answer here is an existence oracle for other tenants'
// domains (security-audit-remediation-2 design D5): answer exactly
// as the failed-lookup branch above, and keep the distinction in
// the log only. RestoreSite and KeepSiteActive already gate
// ownership before calling in, so this branch is reached through
// ArchiveSite.
h.Logger.Debug("site status change refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("target_status", target),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", site.WorkspaceID))
return "Site not found", false
}
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
return "Site creation service is not available", false
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(),
client.StartWorkflowOptions{ID: workflows.SetStatusWorkflowID(site.SiteID), TaskQueue: queues.Main},
workflows.SetSiteStatusWorkflow,
workflows.SetSiteStatusWorkflowInput{
SiteID: site.SiteID,
Domain: domain,
WorkspaceID: session.WorkspaceID,
CurrentStatus: site.Status,
TargetStatus: target,
SupportURL: h.SupportURL,
})
if err != nil {
if workflows.AlreadyRunning(err) {
return "A change to this site is already in progress", false
}
h.Logger.Error("failed to start status workflow", slog.Any("error", err))
return "Failed to update the site. Try again.", false
}
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
defer cancelWait()
var result workflows.SetSiteStatusWorkflowOutput
if err := we.Get(waitCtx, &result); err != nil {
if workflowWaitInterrupted(err) {
h.Logger.Warn("status change outlived the request wait; workflow still running",
slog.String("workflowID", we.GetID()), slog.Any("error", err))
return "", true
}
h.Logger.Error("status workflow execution failed", slog.Any("error", err))
return "Failed to update the site. Try again.", false
}
if !result.Success {
return result.ErrorMessage, false
}
return "", false
}
// workflowWaitBudget caps how long a partial handler blocks on a Temporal
// workflow before answering "still working" (audit #19). It must sit well
// inside http.Server's 8s WriteTimeout: a pending response written after that
// network deadline never reaches the browser.
const workflowWaitBudget = 6 * time.Second
// workflowWaitInterrupted reports whether an error from WorkflowRun.Get means
// this request stopped waiting (wait budget spent or client disconnect)
// rather than the workflow failing — the workflow keeps running on its own.
func workflowWaitInterrupted(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}
// siteActionResult writes the outcome of an archive/restore action: on success an
// HX-Trigger refreshes the list; on failure an inline alert is returned to the
// action's target.
func (h *FedWikiPartialsHandler) siteActionResult(w http.ResponseWriter, errMsg string) {
if errMsg != "" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<div class="alert alert-danger py-2 small mb-2" role="alert">` + template.HTMLEscapeString(errMsg) + `</div>`))
return
}
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
w.WriteHeader(http.StatusOK)
}
// siteActionPending answers an action whose workflow outlived the request's
// wait window (audit #19 interim guard): honest still-working copy plus a
// list refresh instead of a false failure, pending a real pending-state
// lifecycle design.
func (h *FedWikiPartialsHandler) siteActionPending(w http.ResponseWriter) {
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<div class="alert alert-info py-2 small mb-2" role="status">Still working; this is taking longer than expected. The site list will update when it finishes; refresh in a minute if it hasn't.</div>`))
}
// ArchiveSite handles POST /partials/fedwiki/sites/{domain}/archive — moves the
// site to the archived state (recoverable; frees a quota slot).
func (h *FedWikiPartialsHandler) ArchiveSite(w http.ResponseWriter, r *http.Request) {
msg, pending := h.changeSiteStatus(r, r.PathValue("domain"), "archived")
if pending {
h.siteActionPending(w)
return
}
h.siteActionResult(w, msg)
}
// RestoreSite handles POST /partials/fedwiki/sites/{domain}/restore — returns an
// archived site to active. Restoring a site that is NOT force-reduced (your own
// active/archived site — your incumbent) is free. Restoring a force-reduced site
// (one a downgrade parked) brings a reserve back into the active set, which is a
// rotation gated by the swap cooldown. Quota-gated either way.
func (h *FedWikiPartialsHandler) RestoreSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.siteActionResult(w, "Unauthorized")
return
}
domain := r.PathValue("domain")
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err == nil && site.WorkspaceID != session.WorkspaceID {
// Same answer as the failed lookup below (design D5); the log
// keeps the distinction for operators.
h.Logger.Debug("site action refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", site.WorkspaceID))
h.siteActionResult(w, "Site not found")
return
}
if err != nil {
h.siteActionResult(w, "Site not found")
return
}
// Identity gate: a force-reduced site is a downgrade-parked reserve — bringing
// it back is a rotation. A non-force-reduced site is the member's own
// incumbent and restores freely.
rotation := site.ForceReducedAt.Valid
if rotation {
// Claim the cooldown slot atomically (check-and-stamp under a
// per-workspace advisory lock) before running the workflow, so a
// second tab cannot also pass the check and double-rotate (audit #51).
if msg := h.claimRotationSlot(r.Context(), session.WorkspaceID); msg != "" {
h.siteActionResult(w, msg)
return
}
}
msg, pending := h.changeSiteStatus(r, domain, "active")
if pending {
// Outcome unknown — leave the force-reduced marker alone; a stale
// marker is corrected by the next reconcile/rotation.
h.siteActionPending(w)
return
}
if msg == "" && rotation {
// Cooldown already stamped by claimRotationSlot; the site is now the
// member's active choice, so drop the downgrade-parked marker.
h.clearForceReduced(r.Context(), domain)
}
h.siteActionResult(w, msg)
}
// KeepSiteActive handles POST /partials/fedwiki/sites/{domain}/keep-active —
// makes a read-only (force-reduced) site the active one. When the workspace is
// below its limit this is a plain reactivation; at the limit it is a swap (park
// the least-recently-modified active site, promote the chosen one) gated by the
// per-workspace swap cooldown.
func (h *FedWikiPartialsHandler) KeepSiteActive(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
domain := r.PathValue("domain")
chosen, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err == nil && chosen.WorkspaceID != session.WorkspaceID {
// Same answer as the failed lookup below (design D5); the log
// keeps the distinction for operators.
h.Logger.Debug("site action refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", chosen.WorkspaceID))
h.siteActionResult(w, "Site not found")
return
}
if err != nil {
h.siteActionResult(w, "Site not found")
return
}
if chosen.Status == "active" {
h.siteActionResult(w, "") // already active — no-op
return
}
q, qErr := h.getWorkspaceQuota(r.Context(), session.WorkspaceID)
active, aErr := h.SiteQ.ListActiveSitesByWorkspace(r.Context(), session.WorkspaceID)
if aErr != nil {
h.siteActionResult(w, "Could not load your sites. Try again.")
return
}
belowLimit := qErr != nil || int64(len(active)) < q.resourceLimit
// A rotation brings a site into the active set in place of another: any
// activation at the limit (a swap that parks an active site), or reactivating
// a force-reduced (downgrade-parked) reserve. Reactivating your own incumbent
// below the limit is a free fill. Rotations are gated by the swap cooldown.
rotation := !belowLimit || chosen.ForceReducedAt.Valid
// Below the limit → plain reactivation. When it is a rotation, claim the
// cooldown slot atomically (check-and-stamp under the per-workspace advisory
// lock) first, so two tabs cannot both pass the check and both rotate.
if belowLimit {
if rotation {
if msg := h.claimRotationSlot(r.Context(), session.WorkspaceID); msg != "" {
h.siteActionResult(w, msg)
return
}
}
msg, pending := h.changeSiteStatus(r, domain, "active")
if pending {
h.siteActionPending(w)
return
}
if msg == "" && rotation {
h.clearForceReduced(r.Context(), domain)
}
h.siteActionResult(w, msg)
return
}
// At the limit → swap. Temporal is required; check it up front (read-only) so
// a provisioning outage does not burn the member's cooldown window.
if h.TemporalClient == nil {
h.siteActionResult(w, "Site creation service is not available")
return
}
// Select the park target and stamp the cooldown atomically under a
// per-workspace Postgres advisory lock, BEFORE starting the swap workflow
// (audit #51). Serializing the cooldown check, the park-target selection, and
// the cooldown stamp means two near-simultaneous tabs can no longer both pass
// the check, compute the same park target, and double-decrement usage — the
// second tab now observes the stamped cooldown and is refused. The long
// workflow wait runs after the lock is released so it is never held across
// multi-minute farm I/O.
var parkDomain, parkSiteID, swapRefusal string
if err := h.withWorkspaceLock(r.Context(), session.WorkspaceID, func(ctx context.Context, q fwmod.Querier) error {
if msg := h.cooldownMsgFor(ctx, q, session.WorkspaceID); msg != "" {
swapRefusal = msg
return nil // still cooling down — commit is a no-op, nothing stamped
}
locked, err := q.ListActiveSitesByWorkspace(ctx, session.WorkspaceID)
if err != nil {
return err
}
if len(locked) == 0 {
swapRefusal = "Site creation service is not available"
return nil
}
parkDomain = locked[len(locked)-1].Domain // least-recently-modified active
parkSiteID = locked[len(locked)-1].SiteID
return q.UpsertSiteSwapPolicy(ctx, session.WorkspaceID)
}); err != nil {
h.Logger.Error("failed to claim swap slot", slog.Any("error", err))
h.siteActionResult(w, "Failed to change the active site. Try again.")
return
}
if swapRefusal != "" {
h.siteActionResult(w, swapRefusal)
return
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(),
client.StartWorkflowOptions{ID: workflows.SwapWorkflowID(session.WorkspaceID), TaskQueue: queues.Main},
workflows.SwapActiveSiteWorkflow,
workflows.SwapActiveSiteInput{
WorkspaceID: session.WorkspaceID,
ParkSiteID: parkSiteID,
ActivateSiteID: chosen.SiteID,
ParkDomain: parkDomain,
ActivateDomain: domain,
SupportURL: h.SupportURL,
})
if err != nil {
if workflows.AlreadyRunning(err) {
h.siteActionResult(w, "A change to your active site is already in progress")
return
}
h.Logger.Error("failed to start swap workflow", slog.Any("error", err))
h.siteActionResult(w, "Failed to change the active site. Try again.")
return
}
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
defer cancelWait()
var result workflows.SwapActiveSiteOutput
if err := we.Get(waitCtx, &result); err != nil {
if workflowWaitInterrupted(err) {
h.Logger.Warn("swap outlived the request wait; workflow still running",
slog.String("workflowID", we.GetID()), slog.Any("error", err))
h.siteActionPending(w)
return
}
h.siteActionResult(w, "Failed to change the active site. Try again.")
return
}
if !result.Success {
msg := "Failed to change the active site. Try again."
if result.ErrorMessage != "" {
msg = result.ErrorMessage
}
h.siteActionResult(w, msg)
return
}
h.siteActionResult(w, "")
}
// clearForceReduced drops a site's downgrade-parked marker after the member has
// (re)activated it — it is now their active choice, not a reserve. Best-effort:
// a failure here only leaves a stale marker the next reconcile/rotation corrects,
// so it must not fail the user-facing action.
func (h *FedWikiPartialsHandler) clearForceReduced(ctx context.Context, domain string) {
if err := h.SiteQ.ClearForceReducedByDomain(ctx, domain); err != nil {
h.Logger.Warn("failed to clear force_reduced after rotation", slog.Any("error", err))
}
}
// withWorkspaceLock runs fn inside a transaction that holds a per-workspace
// Postgres advisory lock (pg_advisory_xact_lock, released on commit/rollback),
// serializing a workspace's active-site rotations so two tabs cannot interleave
// the cooldown check-and-stamp or the park-target selection (audit #51). fn
// receives a transaction-scoped querier; returning an error rolls the
// transaction back (nothing is stamped), otherwise it commits. This mirrors the
// advisory-lock idiom already used to serialize concurrent reconciles of one
// subscription in internal/fulfillment/reconcile.go — the established per-entity
// serialization pattern in this repo (Temporal workflow-ID conflict policies
// here guard only singleton daemon workflows, not per-request work).
func (h *FedWikiPartialsHandler) withWorkspaceLock(ctx context.Context, workspaceID string, fn func(context.Context, fwmod.Querier) error) error {
if h.Database == nil {
// No pooled DB to take an advisory lock on (e.g. unit tests) — run
// without cross-tab serialization rather than panicking on a nil handle.
return fn(ctx, h.SiteQ)
}
tx, err := h.Database.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// hashtext maps the workspace UUID text onto the bigint the advisory-lock API
// takes; an occasional hash collision only serializes two unrelated
// workspaces briefly, which is harmless.
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, workspaceID); err != nil {
return err
}
if err := fn(ctx, fwmod.New(tx)); err != nil {
return err
}
return tx.Commit()
}
// claimRotationSlot atomically checks the swap cooldown and, when the rotation is
// allowed, stamps it — both under the per-workspace advisory lock — so two
// concurrent tabs cannot both pass the check and both rotate the active slot
// (audit #51). It returns a user-facing refusal message if the workspace is
// still cooling down, or "" if the caller now owns the rotation for this window.
// The stamp lands BEFORE the caller runs its workflow: a later workflow failure
// burns the window, the accepted trade for never leaving the bypass open. On an
// infrastructure error it fails closed (refuses) so a lock or DB fault can never
// reopen that bypass.
func (h *FedWikiPartialsHandler) claimRotationSlot(ctx context.Context, workspaceID string) string {
if h.SwapCooldown <= 0 {
return "" // cooldown disabled → nothing to serialize or stamp
}
var refusal string
if err := h.withWorkspaceLock(ctx, workspaceID, func(ctx context.Context, q fwmod.Querier) error {
if msg := h.cooldownMsgFor(ctx, q, workspaceID); msg != "" {
refusal = msg
return nil // still cooling down — commit is a no-op, nothing stamped
}
return q.UpsertSiteSwapPolicy(ctx, workspaceID)
}); err != nil {
h.Logger.Error("failed to claim rotation slot", slog.Any("error", err))
return "We couldn't change which site is active. Try again later."
}
return refusal
}
// checkSwapCooldown returns a user-facing message if the workspace's active-site
// rotation is still within the configured cooldown window, or "" if it is
// allowed (no prior rotation, the window elapsed, or the cooldown is disabled).
@@ -1300,8 +662,19 @@ func (h *FedWikiPartialsHandler) renderError(w http.ResponseWriter, tmplName str
// declaration in submission mode: every submitted value carried back, each
// field's error under its control, and a refusal that belongs to no field
// in the form-level slot the part always renders (design D9; findings
// FA-17, FA-22, FA-27).
func (h *FedWikiPartialsHandler) renderCreateFormRefusal(w http.ResponseWriter, r *http.Request, workspaceID string, values forms.Values, errs *forms.Errors) {
// FA-17, FA-22, FA-27). The submitted instance and generation are carried
// back too, because nothing was recorded for them; a conflict passes
// freshAddress, because something already holds that address, and the
// re-render then carries a newly minted one (integration-delivery-core
// design D25).
func (h *FedWikiPartialsHandler) renderCreateFormRefusal(w http.ResponseWriter, r *http.Request, workspaceID string, values forms.Values, errs *forms.Errors, freshAddress bool) {
if freshAddress {
if err := h.mintCreateAddress(r.Context(), &values); err != nil {
h.Logger.Error("failed to mint a create address", slog.Any("error", err))
http.Error(w, "Failed to load the form", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusUnprocessableEntity)
customDomains, commitState := h.resolveCreateFormAffordances(r.Context(), workspaceID)
@@ -1345,11 +718,12 @@ func (h *FedWikiPartialsHandler) getWorkspaceQuota(ctx context.Context, workspac
// Rows, not the reservation counter, are what the member is shown
// (design D1): the counter is a create-time reservation and an import or
// a farm sync writes rows without touching it.
// a farm sync writes rows without touching it. The rows are the pool's,
// every workspace it is primary for, because the limit is the pool's.
// The pool and entitlement lookups above run first on purpose: a handler
// built without a site store (the zero-domains render tests) fails on the
// missing pool row instead of dereferencing a nil SiteQ here.
current, err := siteusage.ActiveSites(ctx, h.SiteQ, workspaceID)
current, err := siteusage.ActiveSites(ctx, h.SiteQ, assignment.PoolID)
if err != nil {
return nil, err
}
@@ -0,0 +1,265 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"go.temporal.io/api/serviceerror"
"go.temporal.io/sdk/client"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
siteusage "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/usage"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
)
// Lifecycle requests on FedWiki's surfaces (integration-delivery-core design
// D25 to D27, D31, D32). Every create, archive, restore, keep-active and
// permanent delete a member starts is recorded as a lifecycle request
// addressed by (instance, generation) before its workflow starts, and every
// handler answers as soon as the start is confirmed.
// reasonSentences are the console's sentences for the reason codes a FedWiki
// request ends with (design D37). The list's notices and the JSON API's
// message show these and never text from the farm or a workflow.
var reasonSentences = map[string]string{
workflows.ReasonSiteLimitReached: "You have reached your site limit.",
workflows.ReasonActiveLimitReached: "You have reached your active site limit.",
workflows.ReasonNameRefused: "That name can't be reserved. Choose another.",
workflows.ReasonNameTaken: "A site with this name already exists.",
workflows.ReasonSiteChanged: "This site changed since the request.",
workflows.ReasonQuotaUnavailable: "Couldn't check your site limit. Try again later.",
workflows.ReasonRetriesExhausted: "Couldn't finish. Try again later.",
workflows.ReasonNotStarted: "Couldn't start. Try again.",
}
// reasonSentence returns the sentence for a reason code. A code with no
// sentence reads as the generic failure, never as the code itself.
func reasonSentence(code string) string {
if s, ok := reasonSentences[code]; ok {
return s
}
return reasonSentences[workflows.ReasonRetriesExhausted]
}
// noticeReasons replace a failed code's sentence in a notice where its
// first clause only says the request did not happen, which the notice's
// own first sentence already says (design D37).
var noticeReasons = map[string]string{
workflows.ReasonRetriesExhausted: "Try again later.",
workflows.ReasonNotStarted: "Try again.",
}
// noticeReason is the second sentence of a notice. A code with no sentence
// reads as the generic failure, never as the code itself.
func noticeReason(code string) string {
if s, ok := noticeReasons[code]; ok {
return s
}
if s, ok := reasonSentences[code]; ok {
return s
}
return noticeReasons[workflows.ReasonRetriesExhausted]
}
// noticeSentence is a declined or failed request's notice: what the member
// asked for, then why it did not happen.
func noticeSentence(domain, action, code string) string {
return domain + " wasn't " + action + ". " + noticeReason(code)
}
// requestAction is the past participle a notice names a request by: the
// action the member took. Restore and Keep active record the same status
// change; a declined or failed one leaves the site where it was (design
// D32), so a request to make an archived site active was a restore. A
// swap is always a Keep active.
func requestAction(operation string, p requestParams, siteStatus string) string {
switch operation {
case integration.LifecycleCreate:
return "created"
case integration.LifecycleDelete:
return "deleted"
case integration.LifecycleSetStatus:
switch {
case p.Status == "archived":
return "archived"
case p.Status == "active" && p.SwapParkSiteID == "" && siteStatus == "archived":
return "restored"
case p.Status == "active":
return "kept active"
}
}
return "changed"
}
// Answers the row actions and the create form give for a request that holds
// another request's address (design D31, D37).
const (
msgSiteChanged = "This site changed since the list loaded."
msgCreateFormStale = "This form is out of date. Submit it again."
msgSiteBusy = "A change to this site is already in progress"
msgActiveSiteBusy = "A change to your active site is already in progress"
msgDeletionBusy = "Site deletion is already in progress"
msgServiceAvailable = "Site creation service is not available"
)
// requestParams is a FedWiki request's parameters: identifiers and public
// DNS names only (the column comment on core.lifecycle_requests.parameters).
// Domain is the fully resolved name, under the key the fedwiki.sites column
// uses (design Open Questions 17); IsCustomDomain only on a create; Status is
// a status change's target; the swap keys name the other site of a swap.
type requestParams struct {
Domain string `json:"domain"`
IsCustomDomain *bool `json:"is_custom_domain,omitempty"`
Status string `json:"status,omitempty"`
SwapParkSiteID string `json:"swap_park_site_id,omitempty"`
SwapActivateSiteID string `json:"swap_activate_site_id,omitempty"`
}
func (p requestParams) raw() json.RawMessage {
b, err := json.Marshal(p)
if err != nil {
// A struct of strings and a bool always marshals.
panic(err)
}
return b
}
func decodeParams(raw json.RawMessage) requestParams {
var p requestParams
_ = json.Unmarshal(raw, &p)
return p
}
// requestAddress is the (instance, generation) a form or row action posts.
type requestAddress struct {
Instance string
Generation int64
}
// parseAddress reads instance and generation from a request: the form body
// of a POST, or the query string, where htmx 4 puts a DELETE's values
// (design D31). r.FormValue reads both. A missing or malformed address is
// reported as not ok.
func parseAddress(r *http.Request) (requestAddress, bool) {
return parseAddressValues(r.FormValue("instance"), r.FormValue("generation"))
}
func parseAddressValues(instanceRaw, generationRaw string) (requestAddress, bool) {
id, err := uuid.Parse(instanceRaw)
if err != nil {
return requestAddress{}, false
}
gen, err := strconv.ParseInt(generationRaw, 10, 64)
if err != nil || gen <= 0 {
return requestAddress{}, false
}
return requestAddress{Instance: id.String(), Generation: gen}, true
}
// sameRowRequest reports whether a recorded request is the one a row action
// would record at its address: the same operation, site domain, target
// status and requesting person (design D26's content; the workspace was
// already matched by the lookup).
func sameRowRequest(held integration.LifecycleRequest, operation, domain, status, personID string) bool {
if held.Operation != operation {
return false
}
heldPerson := ""
if held.RequestedByPersonID.Valid {
heldPerson = held.RequestedByPersonID.UUID.String()
}
if heldPerson != personID {
return false
}
p := decodeParams(held.Parameters)
return p.Domain == domain && p.Status == status
}
// newRequestSpec builds the spec FedWiki records for one request.
func newRequestSpec(connectionID, workspaceID, personID, operation string, addr requestAddress, workflowID string, params requestParams) integration.LifecycleRequestSpec {
return integration.LifecycleRequestSpec{
InstanceID: addr.Instance,
Generation: addr.Generation,
ConnectionID: connectionID,
ResourceKey: siteusage.ResourceKey,
WorkspaceID: workspaceID,
Operation: operation,
Parameters: params.raw(),
WorkflowID: workflowID,
RequestedByPersonID: personID,
}
}
// workflowStartTimeout bounds a request's workflow start and the Describe
// that disambiguates a failed one, once they no longer end with the member's
// connection.
const workflowStartTimeout = 10 * time.Second
// startRequestWorkflow starts the workflow a recorded request names, after
// the request committed (design D27). A start that succeeds, or that answers
// that the workflow already started, is accepted. Any other start error is
// ambiguous, so the workflow is described: only when Temporal answers that
// it does not exist are the requests deleted and false returned, so the
// member's retry is a fresh request; on any other answer the requests stay
// accepted for the workflow or the boot pass to settle.
//
// The calls run detached from the member's request: net/http cancels its
// context when the client goes away, which would fail both calls before
// they reach Temporal and keep a request accepted that nothing started.
func startRequestWorkflow(ctx context.Context, c client.Client, database *sql.DB, logger *slog.Logger,
workflowID string, wf any, input any, workspaceID string, addrs ...requestAddress) bool {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), workflowStartTimeout)
defer cancel()
_, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ID: workflowID, TaskQueue: queues.Main}, wf, input)
if err == nil || workflows.AlreadyRunning(err) {
return true
}
logger.Warn("lifecycle workflow start failed; describing it", slog.String("workflowID", workflowID), slog.Any("error", err))
_, descErr := c.DescribeWorkflowExecution(ctx, workflowID, "")
var notFound *serviceerror.NotFound
if !errors.As(descErr, &notFound) {
if descErr != nil {
logger.Warn("lifecycle workflow describe failed; keeping the request",
slog.String("workflowID", workflowID), slog.Any("error", descErr))
}
return true
}
for _, a := range addrs {
if _, err := integration.DeleteUnstartedLifecycleRequest(ctx, database, workspaceID, a.Instance, a.Generation); err != nil {
logger.Error("failed to delete an unstarted lifecycle request",
slog.String("instance", a.Instance), slog.Int64("generation", a.Generation), slog.Any("error", err))
}
}
return false
}
// siteHoldsInstance reports whether a site row already carries the instance
// as its id. A create under such an instance is not a new create: the farm
// would create the site and the workflow's row insert would then refuse it,
// leaving the request open with the name and the slot held.
func siteHoldsInstance(ctx context.Context, q fwmod.Querier, instance string) (bool, error) {
_, err := q.GetSiteByID(ctx, instance)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// errRollback ends a locked transaction without committing; the caller has
// already decided its answer.
var errRollback = errors.New("rollback")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
)
// addSiblingWorkspace adds a second workspace to ws's organization whose
// active primary assignment is ws's pool (resource-pools invariant 7) and
// gives it n active sites.
func addSiblingWorkspace(t *testing.T, database *sql.DB, ws string, n int) string {
t.Helper()
ctx := context.Background()
var sibling string
if err := database.QueryRowContext(ctx, `
WITH w AS (
INSERT INTO core.workspaces (org_id, name)
SELECT org_id, 'Sibling' FROM core.workspaces WHERE workspace_id = $1
RETURNING workspace_id)
INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary, status)
SELECT pa.pool_id, w.workspace_id, TRUE, 'active'
FROM core.pool_assignments pa, w
WHERE pa.workspace_id = $1 AND pa.is_primary
RETURNING workspace_id`, ws).Scan(&sibling); err != nil {
t.Fatalf("sibling workspace: %v", err)
}
for i := 0; i < n; i++ {
if _, err := database.ExecContext(ctx,
`INSERT INTO fedwiki.sites (workspace_id, domain, status) VALUES ($1, $2, 'active')`,
sibling, fmt.Sprintf("sibling%d-%d.localtest.me", i, time.Now().UnixNano())); err != nil {
t.Fatalf("sibling site: %v", err)
}
}
return sibling
}
// Keep active decides between a swap and a plain activation from the pool:
// with the pool's two slots held by this workspace and a sibling, the
// workspace is at the limit though its own rows are fewer, so the chosen
// site is swapped in for the workspace's own active site rather than
// recorded as an activation the workflow's pool-gated increment refuses.
// A swap parks only the member's own active site, so when siblings hold
// every slot and the workspace has none, the answer is the active-site
// limit, not the create form's outage message.
func TestKeepActiveSwapsWhenSiblingsFillThePool(t *testing.T) {
t.Run("own active site swapped out", func(t *testing.T) {
e := newSiteEnv(t, 2)
ent := e.id(`SELECT entitlement_id FROM core.numeric_entitlements WHERE pool_id = $1`, e.pool)
e.exec(`INSERT INTO core.numeric_entitlement_usage (entitlement_id, pool_id, resource_key, current_usage) VALUES ($1, $2, 'fedwiki_sites', 2)`, ent, e.pool)
addSiblingWorkspace(t, e.db, e.ws, 1)
e.site("incumbent"+e.uniq, "active")
chosenID, chosenDomain := e.site("chosen"+e.uniq, "readonly")
e.exec(`UPDATE fedwiki.sites SET force_reduced_at = NOW() WHERE site_id = $1`, chosenID)
gen := e.nextGen(chosenID)
rec := e.rowAction(e.h.KeepSiteActive, "keep-active", chosenDomain, chosenID, gen)
starts := e.temporal.starts()
if want := workflows.SwapRequestWorkflowID(chosenID, gen); len(starts) != 1 || starts[0] != want {
t.Errorf("keep-active in a pool holding 2 of 2 across two workspaces: %d, started %v, want the swap %s", rec.Code, starts, want)
}
})
t.Run("no own active site", func(t *testing.T) {
e := newSiteEnv(t, 2)
ent := e.id(`SELECT entitlement_id FROM core.numeric_entitlements WHERE pool_id = $1`, e.pool)
e.exec(`INSERT INTO core.numeric_entitlement_usage (entitlement_id, pool_id, resource_key, current_usage) VALUES ($1, $2, 'fedwiki_sites', 2)`, ent, e.pool)
addSiblingWorkspace(t, e.db, e.ws, 2)
chosenID, chosenDomain := e.site("chosen"+e.uniq, "readonly")
e.exec(`UPDATE fedwiki.sites SET force_reduced_at = NOW() WHERE site_id = $1`, chosenID)
gen := e.nextGen(chosenID)
rec := e.rowAction(e.h.KeepSiteActive, "keep-active", chosenDomain, chosenID, gen)
body := rec.Body.String()
if starts := e.temporal.starts(); len(starts) != 0 || !strings.Contains(body, reasonSentence(workflows.ReasonActiveLimitReached)) {
t.Errorf("keep-active with siblings holding 2 of 2 and no own active site: %d, started %v, body %s; want the active-site limit and no start", rec.Code, starts, body)
}
if strings.Contains(body, msgServiceAvailable) {
t.Errorf("keep-active answered the create form's outage message: %s", body)
}
if n := e.requestCount(chosenID); n != 0 {
t.Errorf("recorded %d requests for a refused keep-active, want 0", n)
}
})
}
// The quota surfaces count the pool's active sites, every workspace it is
// primary for, against the pool's limit (fedwiki-sites "UI displays quota
// usage"): a workspace with one active site whose sibling holds two more
// under a limit of three is at its limit.
func TestQuotaSurfacesCountThePool(t *testing.T) {
database := newClaimTestDB(t)
workspaceID := seedUnderstatedQuota(t, database, 1, 3, 3)
addSiblingWorkspace(t, database, workspaceID, 2)
authConfig, ctx := sessionFor(t, workspaceID)
h, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{
SiteQ: fwmod.New(database),
EntitlementsQ: entitlements.New(database),
Database: database,
Logger: slog.Default(),
TemplatesFS: os.DirFS("../templates"),
FedWikiAllowedDomains: []string{"localtest.me"},
})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
h.AuthConfig = authConfig
rec := httptest.NewRecorder()
h.GetSites(rec, httptest.NewRequest("GET", "/partials/fedwiki/sites", nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "3 of 3 active sites used") {
t.Errorf("card does not count the pool's active sites: %s", body)
}
if !strings.Contains(body, "Site limit reached") {
t.Errorf("Create is offered with the pool at its limit: %s", body)
}
api := NewFedWikiHandler(FedWikiHandlerConfig{
SiteQ: fwmod.New(database),
EntitlementsQ: entitlements.New(database),
Database: database,
Logger: slog.Default(),
AuthConfig: authConfig,
})
rec = httptest.NewRecorder()
api.GetQuota(rec, httptest.NewRequest("GET", "/api/fedwiki/quota", nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
var got QuotaResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v (%s)", err, rec.Body.String())
}
if got.CurrentCount != 3 || got.Quota != 3 || got.CanCreate {
t.Errorf("quota = %+v, want currentCount 3, quota 3, canCreate false", got)
}
}
@@ -0,0 +1,981 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web
import (
"context"
"database/sql"
"errors"
"html/template"
"log/slog"
"net/http"
"sort"
"strconv"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
)
// The member's site actions (integration-delivery-core design D25 to D27,
// D31, D32). Each handler runs its synchronous checks, records the request
// in one transaction, commits, starts the workflow under the request's id,
// and answers as soon as the start is confirmed; the list shows the request
// on its row until it finishes.
func (h *FedWikiPartialsHandler) connectionID() string {
if h.ConnectionID == nil {
return ""
}
return h.ConnectionID()
}
// CreateSite handles POST /partials/fedwiki/sites.
func (h *FedWikiPartialsHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.Logger.Error("failed to get session")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
values, errs := siteCreateForm.ParseWith(r, siteCreateOptions(h.FedWikiAllowedDomains))
if errs.Any() {
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
// Normalize before anything reads the name: the registry stores and
// compares normalized names, so an absolute FQDN ("wiki.example.org.")
// must lose its root label here or the row and the farm call disagree.
domain := dnsname.Normalize(values.String("domain"))
// selectedDomain is "" for a custom-domain submission: the field is not
// shown while isCustomDomain is checked (forms.go's field-order
// comment).
selectedDomain := values.String("selectedDomain")
isCustomDomain := values.Bool("isCustomDomain")
// A repeat is answered from its record before the name is checked again
// (design D26, D31): once the first submission's workflow has placed a
// custom domain, the registry answers that name as taken.
if h.answerCreateRepeat(w, r, session, values, errs, domain, selectedDomain, isCustomDomain) {
return
}
// External-domain branch: the registry's four-branch disposition (design
// D4). Only the unclaimed branch starts a verification, and it records
// no lifecycle request, because it creates no instance; a name already
// inside the workspace's active claim falls through to the ordinary
// create path below and is created instantly.
//
// Classification comes FIRST, and the entitlement gates the unclaimed
// branch alone: the boolean buys the right to claim a NEW external
// domain, not the right to use one already claimed.
if isCustomDomain {
disposition, userMsg, err := classifyCustomDomain(r.Context(), h.Registry, session.WorkspaceID, domain)
if err != nil {
h.Logger.Error("domain availability check failed", slog.Any("error", err))
errs.Form("Couldn't check that domain right now. Try again.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
switch disposition {
case dispositionRefused, dispositionOwnPending:
// userMsg is about the typed domain itself, so it lands under
// that field rather than in the form-level slot (finding FA-27).
errs.Field("domain", userMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
case dispositionUnclaimed:
enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID)
if err != nil {
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err))
errs.Form("Couldn't check your plan right now. Try again.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
if !enabled {
errs.Form("Your current plan doesn't include custom domains.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
initiation, msg := startClaimVerification(r.Context(), h.Registry, h.TemporalClient, h.Logger,
session.WorkspaceID, domain, h.CustomDomainTarget)
if msg != "" {
errs.Form(msg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
h.Templates.Render(w, "fedwiki_custom_domain_pending.html", CustomDomainPendingData{
ClaimID: initiation.ClaimID,
Domain: initiation.Domain,
Records: newCustomDomainRecords(initiation.Domain,
initiation.TXTRecordName, initiation.TXTRecordValue, initiation.ConnectTarget),
StatusText: "Waiting for your DNS records…",
// With the time of day, matching the status view: the
// verification window is hours, not days.
ExpiresAt: initiation.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
Polling: true,
})
// The banner gains the pending claim.
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
return
}
// dispositionOwnActive falls through: the claim already proves control.
} else {
// Hosted path only: a custom domain is a full FQDN, not a label
// under one of the farm domains. selectedDomain is already one of
// h.FedWikiAllowedDomains here: Parse refused anything else.
if errMsg := dnsname.ValidateDNSLabel(domain); errMsg != "" {
errs.Field("domain", errMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
// Defense-in-depth: check assembled FQDN length (RFC 1035 §2.3.4)
if errMsg := dnsname.ValidateFQDN(domain, selectedDomain); errMsg != "" {
errs.Field("domain", errMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
}
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
errs.Form(msgServiceAvailable)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
// The address the form was rendered with. A form without one predates
// lifecycle requests or was tampered with; it is answered as a stale
// form under a fresh address.
addr, ok := parseAddressValues(values.String("instance"), values.String("generation"))
if !ok {
errs.Form(msgCreateFormStale)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, true)
return
}
// The parent-domain fallback resolves here, before recording, so the
// request, the placement and the row agree, and a later change to the
// configured farm domains cannot change what an accepted request
// creates (design D26).
fqdn, ok := resolveCreateDomain(domain, selectedDomain, isCustomDomain, h.FedWikiAllowedDomains)
if !ok {
errs.Form(msgServiceAvailable)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
// An instance a site row already carries is not a new create; only a
// form edited by hand can post one.
if taken, err := siteHoldsInstance(r.Context(), h.SiteQ, addr.Instance); err != nil || taken {
if err != nil {
h.Logger.Error("failed to look up a site by instance", slog.Any("error", err))
}
errs.Form(msgCreateFormStale)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, true)
return
}
workflowID := workflows.CreateRequestWorkflowID(addr.Instance)
custom := isCustomDomain
answer, err := integration.RecordFirstLifecycleRequest(r.Context(), h.Database, newRequestSpec(
h.connectionID(), session.WorkspaceID, session.PersonID, integration.LifecycleCreate, addr, workflowID,
requestParams{Domain: fqdn, IsCustomDomain: &custom}))
if err != nil {
h.Logger.Error("failed to record a create request", slog.Any("error", err))
errs.Form("Failed to initiate site creation")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
switch answer.Kind {
case integration.LifecycleRecorded:
case integration.LifecycleRepeatOpen, integration.LifecycleRepeatFinished:
// The answer the first submission got: the list shows the outcome.
createAccepted(w)
return
default:
// Something else holds the address (a conflict, a busy instance, or
// another workspace's request, which the form answers as it answers
// any conflict and never with its content).
errs.Form(msgCreateFormStale)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, true)
return
}
started := startRequestWorkflow(r.Context(), h.TemporalClient, h.Database, h.Logger,
workflowID, workflows.CreateFedWikiSiteRequestWorkflow, workflows.CreateSiteRequestInput{
ConnectionID: h.connectionID(),
InstanceID: addr.Instance,
Generation: addr.Generation,
WorkspaceID: session.WorkspaceID,
Domain: fqdn,
OwnerName: session.Username,
OwnerID: session.OIDCSubject,
}, session.WorkspaceID, addr)
if !started {
errs.Form("Failed to initiate site creation")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
return
}
h.Logger.Info("site creation requested",
slog.String("workflowID", workflowID), slog.String("domain", fqdn))
createAccepted(w)
}
// answerCreateRepeat answers a create whose address already holds a request
// in the caller's workspace: accepted when it is the same create, and the
// stale-form answer under a fresh address otherwise. It reports whether it
// answered; a form without an address, or an address that holds nothing the
// caller may see, goes on to the checks and the recording.
func (h *FedWikiPartialsHandler) answerCreateRepeat(w http.ResponseWriter, r *http.Request, session *auth.UserSession,
values forms.Values, errs *forms.Errors, domain, selectedDomain string, isCustomDomain bool) bool {
addr, ok := parseAddressValues(values.String("instance"), values.String("generation"))
if !ok || h.Database == nil {
return false
}
_, held, found, err := integration.LookupLifecycleRequest(r.Context(), h.Database, session.WorkspaceID, addr.Instance, addr.Generation)
if err != nil {
// The recording below reads the address again under its lock.
h.Logger.Warn("failed to look up a create request", slog.Any("error", err))
return false
}
if !found {
return false
}
fqdn, ok := resolveCreateDomain(domain, selectedDomain, isCustomDomain, h.FedWikiAllowedDomains)
custom := isCustomDomain
if ok && sameCreate(held, session.PersonID, requestParams{Domain: fqdn, IsCustomDomain: &custom}) {
createAccepted(w)
return true
}
errs.Form(msgCreateFormStale)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, true)
return true
}
// createAccepted answers an accepted create: an empty body, a list refresh,
// and the event on which fedwiki-create-form.js closes the modal. The new
// row is the confirmation (design D31).
func createAccepted(w http.ResponseWriter) {
w.Header().Set("HX-Trigger", `{"refreshSites": true, "fedwikiCreateAccepted": true}`)
w.WriteHeader(http.StatusOK)
}
// resolveCreateDomain builds a create's fully resolved name: a custom domain
// as given, a hosted label under its chosen farm domain, or under the first
// configured one when none was chosen (the JSON API's path). It reports
// false when a hosted name has no farm domain to sit under.
func resolveCreateDomain(domain, selectedDomain string, isCustomDomain bool, allowed []string) (string, bool) {
if isCustomDomain {
return domain, true
}
if selectedDomain == "" {
if len(allowed) == 0 {
return "", false
}
selectedDomain = allowed[0]
}
return workflows.BuildFullDomain(domain, selectedDomain, false), true
}
// mintCreateAddress mints a create's instance and generation from the
// database into the form's hidden fields (design D25).
func (h *FedWikiPartialsHandler) mintCreateAddress(ctx context.Context, values *forms.Values) error {
if h.Database == nil {
return errors.New("no database to mint a create address")
}
instance, err := integration.NewInstanceID(ctx, h.Database)
if err != nil {
return err
}
generation, err := integration.NewLifecycleGeneration(ctx, h.Database)
if err != nil {
return err
}
values.Set("instance", instance)
values.Set("generation", strconv.FormatInt(generation, 10))
return nil
}
// rowAction is what the checks every row action shares established.
type rowAction struct {
session *auth.UserSession
addr requestAddress
site fwmod.GetSiteByDomainRow
}
// beginRowAction runs the checks every row action shares, in design D31's
// order: the session, the posted address and the Temporal client; then the
// request at the address, before the path's domain is resolved, so a repeat
// that arrives after a permanent delete finished is answered from its
// record; then the site, which must be the caller's and must be the posted
// instance. It answers and returns false when the action ends here.
func (h *FedWikiPartialsHandler) beginRowAction(w http.ResponseWriter, r *http.Request, operation, status string) (rowAction, bool) {
ctx := r.Context()
session := h.AuthConfig.GetUserSession(ctx)
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return rowAction{}, false
}
domain := r.PathValue("domain")
if domain == "" {
h.siteActionResult(w, "Domain is required")
return rowAction{}, false
}
addr, ok := parseAddress(r)
if !ok {
h.siteActionConflict(w)
return rowAction{}, false
}
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
h.siteActionResult(w, msgServiceAvailable)
return rowAction{}, false
}
_, held, found, err := integration.LookupLifecycleRequest(ctx, h.Database, session.WorkspaceID, addr.Instance, addr.Generation)
if err != nil {
h.Logger.Error("failed to look up a lifecycle request", slog.Any("error", err))
h.siteActionResult(w, "Couldn't update the site. Try again.")
return rowAction{}, false
}
if found {
// A repeat is answered as the first submission was, whether the
// request is still open or finished: the list shows its outcome.
if sameRowRequest(held, operation, domain, status, session.PersonID) {
h.siteActionResult(w, "")
} else {
h.siteActionConflict(w)
}
return rowAction{}, false
}
site, err := h.SiteQ.GetSiteByDomain(ctx, domain)
if err != nil {
h.siteActionResult(w, "Site not found")
return rowAction{}, false
}
if site.WorkspaceID != session.WorkspaceID {
// A distinct answer here is an existence oracle for other tenants'
// domains (security-audit-remediation-2 design D5): answer exactly
// as the failed lookup above, and keep the distinction in the log.
h.Logger.Debug("site action refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", site.WorkspaceID))
h.siteActionResult(w, "Site not found")
return rowAction{}, false
}
if site.SiteID != addr.Instance {
// The domain now names another site than the one the list showed.
h.siteActionConflict(w)
return rowAction{}, false
}
return rowAction{session: session, addr: addr, site: site}, true
}
// answerRecord answers a recording that did not record, and reports whether
// the request was recorded and its workflow should start.
func (h *FedWikiPartialsHandler) answerRecord(w http.ResponseWriter, answer integration.LifecycleAnswer) bool {
switch answer.Kind {
case integration.LifecycleRecorded:
return true
case integration.LifecycleRepeatOpen, integration.LifecycleRepeatFinished:
h.siteActionResult(w, "")
case integration.LifecycleBusy:
h.siteActionResult(w, msgSiteBusy)
case integration.LifecycleNotFound:
h.siteActionResult(w, "Site not found")
default:
h.siteActionConflict(w)
}
return false
}
// ArchiveSite handles POST /partials/fedwiki/sites/{domain}/archive — moves the
// site to the archived state (recoverable; frees a quota slot).
func (h *FedWikiPartialsHandler) ArchiveSite(w http.ResponseWriter, r *http.Request) {
act, ok := h.beginRowAction(w, r, integration.LifecycleSetStatus, "archived")
if !ok {
return
}
h.changeStatus(w, r, act, "archived", false)
}
// RestoreSite handles POST /partials/fedwiki/sites/{domain}/restore — returns an
// archived site to active. Restoring a site that is NOT force-reduced (your own
// active/archived site — your incumbent) is free. Restoring a force-reduced site
// (one a downgrade parked) brings a reserve back into the active set, which is a
// rotation gated by the swap cooldown. Quota-gated either way, inside the
// workflow.
func (h *FedWikiPartialsHandler) RestoreSite(w http.ResponseWriter, r *http.Request) {
act, ok := h.beginRowAction(w, r, integration.LifecycleSetStatus, "active")
if !ok {
return
}
h.changeStatus(w, r, act, "active", act.site.ForceReducedAt.Valid)
}
// KeepSiteActive handles POST /partials/fedwiki/sites/{domain}/keep-active —
// makes a read-only (force-reduced) site the active one. When the pool is
// below its limit this is a plain reactivation; at the limit it is a swap (park
// the workspace's least-recently-modified active site, promote the chosen one)
// gated by the per-workspace swap cooldown. The limit is the pool's, so the
// comparison counts the active sites of every workspace the pool is primary
// for: a workspace whose siblings hold the pool's other slots is at the limit
// though its own rows are fewer, and a plain activation would be refused.
func (h *FedWikiPartialsHandler) KeepSiteActive(w http.ResponseWriter, r *http.Request) {
act, ok := h.beginRowAction(w, r, integration.LifecycleSetStatus, "active")
if !ok {
return
}
if act.site.Status == "active" {
h.siteActionResult(w, "") // already active — no-op
return
}
ws := act.session.WorkspaceID
q, qErr := h.getWorkspaceQuota(r.Context(), ws)
if qErr != nil && !errors.Is(qErr, sql.ErrNoRows) {
h.siteActionResult(w, "Couldn't load your sites. Try again.")
return
}
belowLimit := qErr != nil || q.currentUsage < q.resourceLimit
if belowLimit {
// Reactivating a force-reduced (downgrade-parked) reserve is a
// rotation; reactivating the member's own incumbent is a free fill.
h.changeStatus(w, r, act, "active", act.site.ForceReducedAt.Valid)
return
}
h.swapActiveSite(w, r, act)
}
// changeStatus records a status change on the action's site and starts its
// workflow. A rotation (bringing a force-reduced site back into the active
// set) checks and stamps the swap cooldown in the transaction that records
// the request, under the per-workspace lock, so a refused or repeated
// request never burns the window (audit #51); the workflow clears the
// site's downgrade-parked mark with its new status.
func (h *FedWikiPartialsHandler) changeStatus(w http.ResponseWriter, r *http.Request, act rowAction, target string, rotation bool) {
ctx := r.Context()
ws := act.session.WorkspaceID
workflowID := workflows.SetStatusRequestWorkflowID(act.site.SiteID, act.addr.Generation)
spec := newRequestSpec(h.connectionID(), ws, act.session.PersonID, integration.LifecycleSetStatus, act.addr, workflowID,
requestParams{Domain: act.site.Domain, Status: target})
var answer integration.LifecycleAnswer
if rotation && h.SwapCooldown > 0 {
var refusal string
err := h.withWorkspaceTx(ctx, ws, func(ctx context.Context, tx *sql.Tx, q fwmod.Querier) error {
a, err := h.lockedRepeat(ctx, tx, act, integration.LifecycleSetStatus, target)
if err != nil {
return err
}
if a.Kind != 0 {
answer = a
return errRollback
}
if msg := h.cooldownMsgFor(ctx, q, ws); msg != "" {
refusal = msg
return errRollback
}
if answer, err = integration.RecordLifecycleRequest(ctx, tx, spec); err != nil {
return err
}
if answer.Kind != integration.LifecycleRecorded {
return errRollback
}
return q.UpsertSiteSwapPolicy(ctx, ws)
})
if err != nil && !errors.Is(err, errRollback) {
h.Logger.Error("failed to record a rotation", slog.Any("error", err))
h.siteActionResult(w, "Couldn't change which site is active. Try again later.")
return
}
if refusal != "" {
h.siteActionResult(w, refusal)
return
}
} else {
var err error
if answer, err = integration.RecordLifecycleRequest(ctx, h.Database, spec); err != nil {
h.Logger.Error("failed to record a status change", slog.Any("error", err))
h.siteActionResult(w, "Couldn't update the site. Try again.")
return
}
}
if !h.answerRecord(w, answer) {
return
}
started := startRequestWorkflow(ctx, h.TemporalClient, h.Database, h.Logger,
workflowID, workflows.SetFedWikiSiteStatusRequestWorkflow, workflows.SetSiteStatusRequestInput{
ConnectionID: h.connectionID(),
InstanceID: act.site.SiteID,
Generation: act.addr.Generation,
WorkspaceID: ws,
Domain: act.site.Domain,
CurrentStatus: act.site.Status,
TargetStatus: target,
ClearForceReduced: rotation,
}, ws, act.addr)
if !started {
h.siteActionResult(w, "Couldn't update the site. Try again.")
return
}
h.siteActionResult(w, "")
}
// lockedRepeat looks the action's address up again inside the workspace
// lock: a second submission that passed the first lookup before the first
// committed is then answered as a repeat, not refused by the cooldown the
// first one stamped. It returns a zero answer when the address is empty.
func (h *FedWikiPartialsHandler) lockedRepeat(ctx context.Context, tx *sql.Tx, act rowAction, operation, status string) (integration.LifecycleAnswer, error) {
_, held, found, err := integration.LookupLifecycleRequest(ctx, tx, act.session.WorkspaceID, act.addr.Instance, act.addr.Generation)
if err != nil || !found {
return integration.LifecycleAnswer{}, err
}
if sameRowRequest(held, operation, act.site.Domain, status, act.session.PersonID) {
return integration.LifecycleAnswer{Kind: integration.LifecycleRepeatOpen}, nil
}
return integration.LifecycleAnswer{Kind: integration.LifecycleConflict}, nil
}
// swapActiveSite parks the workspace's least-recently-modified active site
// and activates the action's site. In the transaction that holds the
// per-workspace lock and stamps the cooldown it records the activated site's
// request and a set_status request for the parked site, both under the
// swap's workflow id, so the one-open rule covers the parked site while the
// swap runs (design D27).
func (h *FedWikiPartialsHandler) swapActiveSite(w http.ResponseWriter, r *http.Request, act rowAction) {
ctx := r.Context()
ws := act.session.WorkspaceID
conn := h.connectionID()
workflowID := workflows.SwapRequestWorkflowID(act.site.SiteID, act.addr.Generation)
var (
answer integration.LifecycleAnswer
refusal string
park requestAddress
parkDom string
)
err := h.withWorkspaceTx(ctx, ws, func(ctx context.Context, tx *sql.Tx, q fwmod.Querier) error {
a, err := h.lockedRepeat(ctx, tx, act, integration.LifecycleSetStatus, "active")
if err != nil {
return err
}
if a.Kind != 0 {
answer = a
return errRollback
}
if msg := h.cooldownMsgFor(ctx, q, ws); msg != "" {
refusal = msg
return errRollback
}
open, err := integration.OpenRequestsByWorkspace(ctx, tx, ws, conn)
if err != nil {
return err
}
for _, req := range open {
if req.Status == integration.LifecycleAccepted && workflows.IsSwapRequestWorkflowID(req.WorkflowID) {
refusal = msgActiveSiteBusy
return errRollback
}
}
locked, err := q.ListActiveSitesByWorkspace(ctx, ws)
if err != nil {
return err
}
if len(locked) == 0 {
// Sibling workspaces hold every slot of the pool and this
// workspace has no active site of its own to park, so the
// pool is at its limit for this member (Open Questions 65).
refusal = reasonSentence(workflows.ReasonActiveLimitReached)
return errRollback
}
parked := locked[len(locked)-1] // least-recently-modified active
parkDom = parked.Domain
answer, err = integration.RecordLifecycleRequest(ctx, tx, newRequestSpec(conn, ws, act.session.PersonID,
integration.LifecycleSetStatus, act.addr, workflowID,
requestParams{Domain: act.site.Domain, Status: "active", SwapParkSiteID: parked.SiteID}))
if err != nil {
return err
}
if answer.Kind != integration.LifecycleRecorded {
return errRollback
}
gens, err := integration.NextGenerations(ctx, tx, []string{parked.SiteID})
if err != nil {
return err
}
park = requestAddress{Instance: parked.SiteID, Generation: gens[parked.SiteID]}
parkAnswer, err := integration.RecordLifecycleRequest(ctx, tx, newRequestSpec(conn, ws, act.session.PersonID,
integration.LifecycleSetStatus, park, workflowID,
requestParams{Domain: parked.Domain, Status: "readonly", SwapActivateSiteID: act.site.SiteID}))
if err != nil {
return err
}
if parkAnswer.Kind != integration.LifecycleRecorded {
// The site the swap would park has a request of its own open.
refusal = msgActiveSiteBusy
return errRollback
}
return q.UpsertSiteSwapPolicy(ctx, ws)
})
if err != nil && !errors.Is(err, errRollback) {
h.Logger.Error("failed to record a swap", slog.Any("error", err))
h.siteActionResult(w, "Couldn't change the active site. Try again.")
return
}
if refusal != "" {
h.siteActionResult(w, refusal)
return
}
if !h.answerRecord(w, answer) {
return
}
started := startRequestWorkflow(ctx, h.TemporalClient, h.Database, h.Logger,
workflowID, workflows.SwapFedWikiActiveSiteRequestWorkflow, workflows.SwapSiteRequestInput{
ConnectionID: conn,
InstanceID: act.site.SiteID,
Generation: act.addr.Generation,
ParkInstanceID: park.Instance,
ParkGeneration: park.Generation,
WorkspaceID: ws,
ActivateDomain: act.site.Domain,
ParkDomain: parkDom,
}, ws, act.addr, park)
if !started {
h.siteActionResult(w, "Couldn't change the active site. Try again.")
return
}
h.siteActionResult(w, "")
}
// DeleteSite handles DELETE /partials/fedwiki/sites/{domain}: permanent
// delete, triggered through the shared confirmAction modal like every
// other site-status action (finding FA-41). htmx 4 sends a DELETE's values
// in the query string, where r.FormValue reads instance and generation.
func (h *FedWikiPartialsHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
act, ok := h.beginRowAction(w, r, integration.LifecycleDelete, "")
if !ok {
return
}
ctx := r.Context()
ws := act.session.WorkspaceID
workflowID := workflows.DeleteRequestWorkflowID(act.site.SiteID, act.addr.Generation)
answer, err := integration.RecordLifecycleRequest(ctx, h.Database, newRequestSpec(h.connectionID(), ws, act.session.PersonID,
integration.LifecycleDelete, act.addr, workflowID, requestParams{Domain: act.site.Domain}))
if err != nil {
h.Logger.Error("failed to record a delete", slog.Any("error", err))
h.siteActionResult(w, "Failed to initiate site deletion")
return
}
if !h.answerRecord(w, answer) {
return
}
started := startRequestWorkflow(ctx, h.TemporalClient, h.Database, h.Logger,
workflowID, workflows.DeleteFedWikiSiteRequestWorkflow, workflows.DeleteSiteRequestInput{
ConnectionID: h.connectionID(),
InstanceID: act.site.SiteID,
Generation: act.addr.Generation,
WorkspaceID: ws,
Domain: act.site.Domain,
}, ws, act.addr)
if !started {
h.siteActionResult(w, "Failed to initiate site deletion")
return
}
h.siteActionResult(w, "")
}
// DismissRequest handles POST /partials/fedwiki/requests/{instance}/{generation}/dismiss:
// the close control of a declined or failed request's notice. It records
// that the member dismissed the outcome and nothing else, so it has no
// confirm step. The empty 200 swaps the notice out (the notice's own
// hx-target and outerHTML swap), as the operator setup banner's close does;
// a failure answers 500, which htmx-config leaves unswapped, so the notice
// stays and the error toast reports it.
func (h *FedWikiPartialsHandler) DismissRequest(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
addr, ok := parseAddressValues(r.PathValue("instance"), r.PathValue("generation"))
if ok {
if err := h.acknowledgeRequest(r.Context(), session.WorkspaceID, addr); err != nil {
h.Logger.Error("failed to dismiss a request outcome", slog.Any("error", err))
http.Error(w, "Couldn't dismiss. Try again.", http.StatusInternalServerError)
return
}
}
// Dismissing twice, or an address the workspace does not hold, changes
// nothing; the notice leaves the page either way.
w.WriteHeader(http.StatusOK)
}
// acknowledgeRequest records the dismissal of the outcome at addr, in one
// transaction. A swap is one request recorded as two rows under one
// workflow id (design D27), and only the activated site's row has a
// notice, so dismissing it acknowledges the parked site's row as well.
func (h *FedWikiPartialsHandler) acknowledgeRequest(ctx context.Context, workspaceID string, addr requestAddress) error {
if h.Database == nil {
return errors.New("no database to record a dismissal")
}
tx, err := h.Database.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := integration.AcknowledgeLifecycleRequest(ctx, tx, workspaceID, addr.Instance, addr.Generation); err != nil {
return err
}
_, held, found, err := integration.LookupLifecycleRequest(ctx, tx, workspaceID, addr.Instance, addr.Generation)
if err != nil {
return err
}
if found && decodeParams(held.Parameters).SwapParkSiteID != "" {
rows, err := integration.OpenRequestsByWorkspace(ctx, tx, workspaceID, held.ConnectionID)
if err != nil {
return err
}
for _, row := range rows {
if row.WorkflowID != held.WorkflowID || row.InstanceID == held.InstanceID || row.Status == integration.LifecycleAccepted {
continue
}
if _, err := integration.AcknowledgeLifecycleRequest(ctx, tx, workspaceID, row.InstanceID, row.Generation); err != nil {
return err
}
}
}
return tx.Commit()
}
// siteActionResult writes the outcome of a row action: on success an
// HX-Trigger refreshes the list; on failure an inline alert is returned to
// the action's target.
func (h *FedWikiPartialsHandler) siteActionResult(w http.ResponseWriter, errMsg string) {
if errMsg != "" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<div class="alert alert-danger py-2 small mb-2" role="alert">` + template.HTMLEscapeString(errMsg) + `</div>`))
return
}
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
w.WriteHeader(http.StatusOK)
}
// siteActionConflict answers a row action whose address another request
// holds, or whose site changed since the list rendered: the sentence, and a
// list refresh so the rows carry current addresses (design D31). The alert
// target keeps its text across the refresh (hx-preserve).
func (h *FedWikiPartialsHandler) siteActionConflict(w http.ResponseWriter) {
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<div class="alert alert-danger py-2 small mb-2" role="alert">` + template.HTMLEscapeString(msgSiteChanged) + `</div>`))
}
// withWorkspaceTx runs fn inside a transaction that holds a per-workspace
// Postgres advisory lock (pg_advisory_xact_lock, released on commit or
// rollback), serializing a workspace's active-site rotations so two tabs
// cannot interleave the cooldown check-and-stamp or the park-target
// selection (audit #51). fn receives the transaction and a querier on it;
// returning an error rolls the transaction back, otherwise it commits.
func (h *FedWikiPartialsHandler) withWorkspaceTx(ctx context.Context, workspaceID string, fn func(context.Context, *sql.Tx, fwmod.Querier) error) error {
if h.Database == nil {
return errors.New("no database for the workspace lock")
}
tx, err := h.Database.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// hashtext maps the workspace UUID text onto the bigint the advisory-lock
// API takes; an occasional hash collision only serializes two unrelated
// workspaces briefly, which is harmless.
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, workspaceID); err != nil {
return err
}
if err := fn(ctx, tx, fwmod.New(tx)); err != nil {
return err
}
return tx.Commit()
}
// requestsView is a workspace's open and unacknowledged requests: the open
// ones mark their rows, the declined and failed ones become notices above
// the list (design D32).
type requestsView struct {
open map[string]integration.LifecycleRequest // accepted, by instance
outcomes []integration.LifecycleRequest // declined or failed, not dismissed
rows []integration.LifecycleRequest
}
func (h *FedWikiPartialsHandler) listRequests(ctx context.Context, workspaceID string) requestsView {
v := requestsView{open: map[string]integration.LifecycleRequest{}}
conn := h.connectionID()
if h.Database == nil || conn == "" {
return v
}
rows, err := integration.OpenRequestsByWorkspace(ctx, h.Database, workspaceID, conn)
if err != nil {
h.Logger.Error("failed to list lifecycle requests", slog.Any("error", err))
return v
}
for _, row := range rows {
if row.Status == integration.LifecycleAccepted {
v.open[row.InstanceID] = row
} else {
v.outcomes = append(v.outcomes, row)
}
v.rows = append(v.rows, row)
}
return v
}
func (v requestsView) anyOpen() bool { return len(v.open) > 0 }
// apply marks a site's row with its open request: Pending, no actions.
func (v requestsView) apply(vm *SiteViewModel) {
if _, ok := v.open[vm.ID]; ok {
vm.Pending = true
}
}
// createRows are the open creates that have no site row yet, each a
// Pending row. A declined or failed create is a notice, never a row.
func (v requestsView) createRows(siteIDs []string) []SiteViewModel {
have := make(map[string]bool, len(siteIDs))
for _, id := range siteIDs {
have[id] = true
}
var out []SiteViewModel
for _, row := range v.rows {
if row.Operation != integration.LifecycleCreate || row.Status != integration.LifecycleAccepted || have[row.InstanceID] {
continue
}
have[row.InstanceID] = true
p := decodeParams(row.Parameters)
out = append(out, SiteViewModel{
ID: row.InstanceID,
Domain: p.Domain,
IsCustomDomain: p.IsCustomDomain != nil && *p.IsCustomDomain,
CreatedAt: row.CreatedAt.Format("Jan 2, 2006"),
Requested: true,
Pending: true,
})
}
return out
}
// notices are the undismissed declined and failed requests, newest first,
// one per request the member made. A swap's parked site is left out: the
// swap is the member's Keep active on the other site, whose notice tells
// it. siteStatus is each site's current status, by id.
func (v requestsView) notices(siteStatus map[string]string) []RequestNotice {
rows := make([]integration.LifecycleRequest, 0, len(v.outcomes))
for _, row := range v.outcomes {
if _, open := v.open[row.InstanceID]; open {
continue
}
if decodeParams(row.Parameters).SwapActivateSiteID != "" {
continue
}
rows = append(rows, row)
}
sort.SliceStable(rows, func(i, j int) bool { return finishedAt(rows[i]).After(finishedAt(rows[j])) })
out := make([]RequestNotice, 0, len(rows))
for _, row := range rows {
p := decodeParams(row.Parameters)
tone := "warning"
if row.Status == integration.LifecycleFailed {
tone = "danger"
}
out = append(out, RequestNotice{
Sentence: noticeSentence(p.Domain, requestAction(row.Operation, p, siteStatus[row.InstanceID]), row.ReasonCode.String),
Tone: tone,
Instance: row.InstanceID,
Generation: row.Generation,
})
}
return out
}
// finishedAt is when a request's outcome was recorded, or when the request
// was recorded for a row that carries no finish time.
func finishedAt(row integration.LifecycleRequest) time.Time {
if row.FinishedAt.Valid {
return row.FinishedAt.Time
}
return row.CreatedAt
}
// openCreateDomains are the domains of the workspace's open creates.
func (v requestsView) openCreateDomains() map[string]bool {
out := map[string]bool{}
for _, row := range v.open {
if row.Operation == integration.LifecycleCreate {
out[decodeParams(row.Parameters).Domain] = true
}
}
return out
}
// nextGenerations is the generation each site's next request carries,
// computed for the whole list in one query (design D25).
func (h *FedWikiPartialsHandler) nextGenerations(ctx context.Context, siteIDs []string) map[string]int64 {
if h.Database == nil || len(siteIDs) == 0 {
return map[string]int64{}
}
gens, err := integration.NextGenerations(ctx, h.Database, siteIDs)
if err != nil {
h.Logger.Error("failed to compute next generations", slog.Any("error", err))
return map[string]int64{}
}
return gens
}
// bannerRows are the verified-domain banner's one-click creates, each with
// an instance minted for this render. A domain with an open create is left
// out: the list re-renders while the create runs, and a banner entry under a
// new instance would record a second create (design D25).
func (h *FedWikiPartialsHandler) bannerRows(ctx context.Context, workspaceID string, requests requestsView) []CustomDomainClaimViewModel {
claims := h.customDomainClaims(ctx, workspaceID)
if len(claims) == 0 {
return nil
}
busy := requests.openCreateDomains()
var generation int64
if h.Database != nil {
gen, err := integration.NewLifecycleGeneration(ctx, h.Database)
if err != nil {
h.Logger.Error("failed to mint a banner generation", slog.Any("error", err))
}
generation = gen
}
var out []CustomDomainClaimViewModel
for _, claim := range claims {
if busy[claim.Domain] {
continue
}
if h.Database != nil && generation > 0 {
instance, err := integration.NewInstanceID(ctx, h.Database)
if err != nil {
h.Logger.Error("failed to mint a banner instance", slog.Any("error", err))
} else {
claim.Instance = instance
claim.Generation = generation
}
}
out = append(out, claim)
}
return out
}
@@ -120,12 +120,16 @@ Returns information about a specific site.
```json
{
"name": "test.localtest.me",
"owner": "admin",
"owner": { "name": "admin", "oauth2": { "id": "…", "username": "admin" } },
"pages": 42,
"status": "active"
}
```
`owner` is the site's `owner.json` as an object, as in the list. The console's
create read-back compares `owner.oauth2.id` with the requester's subject, so it
depends on this shape (wiki-plugin-farmmanager 0.4.1 or later).
**Error Response (404):**
```json
{
@@ -255,13 +255,13 @@ ActivityOptions{
### Non-Retryable Errors
Certain errors should not be retried because they represent permanent failures:
A farm create's definitive answers stop its retries at once, each with its own error type, and the request-driven create reads the farm's entry for the domain before it compensates (`requests.go`):
- **409 Conflict** (Site already exists) - "A site with this name already exists. Please choose a different name."
- **403 Forbidden** (Permission denied) - "You don't have permission to perform this action."
- **401 Unauthorized** (Bad credentials) - "Authentication failed. Please contact support."
- **409 Conflict**: `FarmNameHeld`. Ambiguous, because the console's own earlier attempt may hold the name.
- **400 Bad Request, 422 Unprocessable Entity**: `FarmNameRefused`. The request ends `declined` with `name_refused` when the read-back finds no site.
- **Any other 4xx except 408 and 429**: `FarmCreateRefused`, such as a credential the farm does not accept.
These are wrapped using `temporal.NewNonRetryableApplicationError()` with the type `FarmManagerAPIError`.
A request records a reason code, never the farm's text; the member-facing sentence for each code lives in the web package.
### Retryable Errors
@@ -271,25 +271,8 @@ Network errors, 5xx server errors, and other transient failures are retried with
Delete operations treat HTTP 404 (Not Found) as success - if the site doesn't exist, the desired state has been achieved.
## Synchronous Execution Pattern
## Lifecycle Requests
Unlike typical async workflows, these workflows are executed **synchronously** from HTTP handlers:
HTTP handlers do not wait for these workflows. A handler records a lifecycle request in `core.lifecycle_requests`, commits, starts the workflow under the request's workflow id, and answers as soon as the start is confirmed. The workflow ends by recording the request's status and reason code, and the site list shows an open request as Pending on its row and an undismissed declined or failed one as a notice above the list. A request whose workflow never recorded an outcome is settled by the boot pass in `settle.go`.
```go
// Start workflow
we, err := temporalClient.ExecuteWorkflow(ctx, options, workflow, input)
// Wait for completion (blocks until workflow finishes)
var result WorkflowOutput
err = we.Get(ctx, &result)
// Handle result
if !result.Success {
// Show error to user
}
```
This provides immediate feedback to users while still benefiting from Temporal's:
- Automatic retries with backoff
- Workflow history and debugging
- Graceful failure handling
The workflow types created before lifecycle requests (`CreateFedWikiSiteWorkflow`, `SetSiteStatusWorkflow`, `SwapActiveSiteWorkflow`, `DeleteFedWikiSiteWorkflow`) stay registered for one release so runs in flight at an upgrade finish; nothing starts them any more.
@@ -99,6 +99,10 @@ type AllocateDomainInput struct {
Domain string // The subdomain part (e.g., "mysite"), or the full name for a custom domain
SiteDomain string // The parent domain (e.g., "localtest.me") - ignored for custom domains
IsCustomDomain bool
// FQDN, when set, is the name a lifecycle request already resolved and
// recorded (design D26); Domain, SiteDomain and IsCustomDomain are then
// ignored, so a later change to the farm domains cannot change it.
FQDN string
}
// AllocateDomainOutput carries the reservation: the site id the placement is
@@ -120,9 +124,13 @@ type AllocateDomainOutput struct {
// Re-running it against a reservation it already made is a no-op that returns
// the same output, so a lost result costs nothing but a second attempt.
func (a *Activities) AllocateDomainActivity(ctx context.Context, input AllocateDomainInput) (*AllocateDomainOutput, error) {
fullDomain, err := a.resolveFullDomain(input.Domain, input.SiteDomain, input.IsCustomDomain)
if err != nil {
return nil, err
fullDomain := input.FQDN
if fullDomain == "" {
resolved, err := a.resolveFullDomain(input.Domain, input.SiteDomain, input.IsCustomDomain)
if err != nil {
return nil, err
}
fullDomain = resolved
}
if a.Registry == nil {
return nil, temporal.NewNonRetryableApplicationError(
@@ -159,24 +167,21 @@ func (a *Activities) AllocateDomainActivity(ctx context.Context, input AllocateD
return &AllocateDomainOutput{SiteID: siteID, Domain: fullDomain}, nil
}
// allocationRefusal maps a registry error onto the member-facing message for a
// permanent refusal, or "" when the failure was transient (a database blip,
// say) and retrying is right. ErrNameUnavailable collapses to the registry's
// own no-reason message; the structural refusals get their own copy because
// they describe what the member must do next, not who holds what.
// allocationRefusal names a permanent registry refusal for the activity's
// error, or returns "" when the failure was transient (a database blip, say)
// and retrying is right. The workflow ends the request declined with
// name_refused; the member sees the console's sentence for that code, never
// this label, so the label says nothing about who holds the name.
func allocationRefusal(err error) string {
var unavailable *domains.Unavailable
switch {
case errors.As(err, &unavailable):
return unavailable.MemberMessage()
case errors.As(err, &unavailable), errors.Is(err, domains.ErrNameUnavailable),
errors.Is(err, domains.ErrForeignClaim), errors.Is(err, domains.ErrClaimNotActive):
return "name unavailable"
case errors.Is(err, domains.ErrInvalidName):
return "That domain name is not valid. Please check it and try again."
case errors.Is(err, domains.ErrNameUnavailable):
return domains.Availability{Verdict: domains.VerdictTaken}.MemberMessage()
return "name invalid"
case errors.Is(err, domains.ErrNoLiveClaim), errors.Is(err, domains.ErrClaimRequired):
return "You need to verify that you control this domain before creating a site on it."
case errors.Is(err, domains.ErrForeignClaim), errors.Is(err, domains.ErrClaimNotActive):
return domains.Availability{Verdict: domains.VerdictTaken}.MemberMessage()
return "domain claim required"
default:
return ""
}
@@ -263,9 +268,9 @@ func (a *Activities) CreateFedWikiSiteActivity(ctx context.Context, input Create
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
return nil, temporal.NewNonRetryableApplicationError(
apiErr.UserMessage(),
fmt.Sprintf("farm answered status %d", apiErr.StatusCode),
"FarmManagerAPIError",
err,
nil,
)
}
return nil, fmt.Errorf("failed to create site on FedWiki farm: %w", err)
@@ -349,8 +354,12 @@ func (a *Activities) authorizeSite(ctx context.Context, siteID, workspaceID, dom
}
// DeleteFedWikiSiteOutput is the output for the DeleteFedWikiSite activity.
// DeletedStatus is the status the row held when this attempt removed it,
// empty when the row was already gone (an earlier attempt removed it and
// lost its answer, or the site left by another path).
type DeleteFedWikiSiteOutput struct {
Success bool
Success bool
DeletedStatus string
}
// DeleteFedWikiSiteActivity deletes a site from the FedWiki farm and removes it from the database.
@@ -386,9 +395,9 @@ func (a *Activities) DeleteFedWikiSiteActivity(ctx context.Context, input Delete
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
return nil, temporal.NewNonRetryableApplicationError(
apiErr.UserMessage(),
fmt.Sprintf("farm answered status %d", apiErr.StatusCode),
"FarmManagerAPIError",
err,
nil,
)
}
return nil, fmt.Errorf("failed to delete site on FedWiki farm: %w", err)
@@ -414,9 +423,11 @@ func (a *Activities) DeleteFedWikiSiteActivity(ctx context.Context, input Delete
}
}
// Remove the site from our database
err = a.SiteQ.DeleteSiteByDomain(ctx, input.Domain)
if err != nil {
// Remove the site from our database, reading the status the row held
// as it goes: a downgrade park that took the site out of active since
// the request has already released its slot.
deletedStatus, err := a.SiteQ.DeleteSiteReturningStatus(ctx, input.Domain)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
a.Logger.Error("failed to remove site from database",
slog.String("domain", input.Domain),
slog.Any("error", err))
@@ -426,7 +437,7 @@ func (a *Activities) DeleteFedWikiSiteActivity(ctx context.Context, input Delete
a.Logger.Info("FedWiki site deleted successfully",
slog.String("domain", input.Domain))
return &DeleteFedWikiSiteOutput{Success: true}, nil
return &DeleteFedWikiSiteOutput{Success: true, DeletedStatus: deletedStatus}, nil
}
// SetSiteStatusInput is the input for the SetSiteStatus activity.
@@ -478,9 +489,9 @@ func (a *Activities) SetSiteStatusActivity(ctx context.Context, input SetSiteSta
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
return nil, temporal.NewNonRetryableApplicationError(
apiErr.UserMessage(),
fmt.Sprintf("farm answered status %d", apiErr.StatusCode),
"FarmManagerAPIError",
err,
nil,
)
}
return nil, fmt.Errorf("failed to set site status on FedWiki farm: %w", err)
@@ -22,13 +22,17 @@ type FarmManagerClient struct {
httpClient *http.Client
}
// FarmClientTimeout bounds one call to the farm. A create's read-back waits
// twice this long after an ambiguous failure (FarmReadBackWait).
const FarmClientTimeout = 30 * time.Second
// NewFarmManagerClient creates a new FarmManager API client.
func NewFarmManagerClient(baseURL, adminToken string) *FarmManagerClient {
return &FarmManagerClient{
baseURL: baseURL,
adminToken: adminToken,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Timeout: FarmClientTimeout,
},
}
}
@@ -108,18 +112,6 @@ func (e *FarmManagerAPIError) IsNonRetryable() bool {
e.StatusCode == http.StatusUnauthorized
}
// UserMessage returns a user-friendly error message.
func (e *FarmManagerAPIError) UserMessage() string {
switch e.StatusCode {
case http.StatusConflict:
return "A site with this name already exists. Please choose a different name."
case http.StatusForbidden, http.StatusUnauthorized:
return "We're experiencing a temporary issue with our site creation service. Please try again later or contact support if the problem persists."
default:
return "An unexpected error occurred. Please try again later."
}
}
// doRequest performs an HTTP request with authentication.
func (c *FarmManagerClient) doRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
var reqBody io.Reader
@@ -19,8 +19,9 @@ import (
const ArchiveRetention = 30 * 24 * time.Hour
// ReconcileFedWikiQuotaInput is the input for the quota reconcile activity. An
// empty WorkspaceID sweeps every workspace that owns sites (the periodic-sync
// path); a set WorkspaceID reconciles just that one (the immediate path).
// empty WorkspaceID sweeps the primary pool of every workspace that owns sites
// (the periodic-sync path); a set WorkspaceID reconciles just that workspace's
// pool (the immediate path).
type ReconcileFedWikiQuotaInput struct {
WorkspaceID string
}
@@ -32,8 +33,10 @@ type ReconcileFedWikiQuotaOutput struct {
}
// ReconcileFedWikiQuotaActivity enforces the "quota counts active sites" rule.
// For each workspace it compares the active-site count against the pool's
// fedwiki_sites limit and:
// For the primary pool of each workspace it visits, once per pool, it compares
// the active sites of every workspace that pool is primary for against the
// pool's fedwiki_sites limit (usage is pool-grain: an organization's
// workspaces draw down one counter against one limit) and:
// - parks the excess read-only (oldest by last-activity first), stamping
// force_reduced_at, when active > limit and the rule governing the pool's
// fedwiki_sites limit carries the force_reduce reduction policy (Decision
@@ -56,8 +59,20 @@ func (a *Activities) ReconcileFedWikiQuotaActivity(ctx context.Context, input Re
}
out := &ReconcileFedWikiQuotaOutput{}
seen := make(map[string]bool)
for _, wsID := range workspaceIDs {
p, r := a.reconcileWorkspaceQuota(ctx, wsID)
assignment, err := a.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, wsID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
a.Logger.Warn("quota reconcile: no pool assignment", slog.String("workspace", wsID), slog.Any("error", err))
}
continue
}
if seen[assignment.PoolID] {
continue
}
seen[assignment.PoolID] = true
p, r := a.reconcilePoolQuota(ctx, assignment.PoolID)
out.Parked += p
out.Reactivated += r
}
@@ -111,18 +126,14 @@ func (a *Activities) PurgeExpiredArchivedActivity(ctx context.Context, _ struct{
return out, nil
}
// reconcileWorkspaceQuota reconciles a single workspace. Best-effort: a failure
// on one site is logged and the sweep continues (the next tick retries).
func (a *Activities) reconcileWorkspaceQuota(ctx context.Context, workspaceID string) (parked, reactivated int) {
assignment, err := a.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
a.Logger.Warn("quota reconcile: no pool assignment", slog.String("workspace", workspaceID), slog.Any("error", err))
}
return 0, 0
}
// reconcilePoolQuota reconciles one pool across every workspace it is
// primary for: the surplus parked is the pool's least recently modified
// active sites, whichever workspace holds them, and the headroom reactivation
// fills is the pool's. Best-effort: a failure on one site is logged and the
// sweep continues (the next tick retries).
func (a *Activities) reconcilePoolQuota(ctx context.Context, poolID string) (parked, reactivated int) {
ent, err := a.EntitlementsQ.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: assignment.PoolID,
PoolID: poolID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
@@ -131,18 +142,18 @@ func (a *Activities) reconcileWorkspaceQuota(ctx context.Context, workspaceID st
}
limit := int(ent.ResourceLimit)
policy, err := a.EntitlementsQ.GetGoverningReductionPolicy(ctx, entitlements.GetGoverningReductionPolicyParams{
PoolID: assignment.PoolID,
PoolID: poolID,
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
a.Logger.Warn("quota reconcile: read reduction policy", slog.String("workspace", workspaceID), slog.Any("error", err))
a.Logger.Warn("quota reconcile: read reduction policy", slog.String("pool", poolID), slog.Any("error", err))
return 0, 0
}
parksExcess := err == nil && policy == "force_reduce"
active, err := a.SiteQ.ListActiveSitesByWorkspace(ctx, workspaceID)
active, err := a.SiteQ.ListActiveSitesByPool(ctx, poolID)
if err != nil {
a.Logger.Warn("quota reconcile: list active sites", slog.String("workspace", workspaceID), slog.Any("error", err))
a.Logger.Warn("quota reconcile: list active sites", slog.String("pool", poolID), slog.Any("error", err))
return 0, 0
}
@@ -156,9 +167,9 @@ func (a *Activities) reconcileWorkspaceQuota(ctx context.Context, workspaceID st
}
}
case len(active) < limit:
forceReduced, err := a.SiteQ.ListForceReducedSitesByWorkspace(ctx, workspaceID)
forceReduced, err := a.SiteQ.ListForceReducedSitesByPool(ctx, poolID)
if err != nil {
a.Logger.Warn("quota reconcile: list force-reduced sites", slog.String("workspace", workspaceID), slog.Any("error", err))
a.Logger.Warn("quota reconcile: list force-reduced sites", slog.String("pool", poolID), slog.Any("error", err))
return parked, 0
}
headroom := limit - len(active)
@@ -178,38 +189,62 @@ func (a *Activities) reconcileWorkspaceQuota(ctx context.Context, workspaceID st
// parkSite moves an active site to read-only on the farm, stamps force_reduced_at,
// and releases its quota slot. Reports whether it succeeded.
//
// A site with an accepted lifecycle request is left alone: the member's
// request decides its status. The local half (status, mark, slot) is one
// transaction that first locks the row and finds it still active, so a
// member's request that took the site out of active while the farm call was
// in flight keeps its status and is the only one to release the slot (Open
// Questions 66).
//
// Deliberately no registry write: park moves active→readonly and reactivateSite
// below moves readonly→active, and BOTH states are servable (design D8 — a
// read-only wiki still serves). Only the archived boundary changes servability,
// and neither of these crosses it. The same holds for SwapActiveSiteActivity in
// swap.go, which is a park plus a reactivate.
func (a *Activities) parkSite(ctx context.Context, site fwmod.Site) bool {
if a.siteRequestOpen(ctx, site) {
return false
}
if _, err := a.Client.SetSiteStatus(ctx, site.Domain, "readonly"); err != nil {
a.Logger.Warn("force_reduce: park PATCH failed", slog.String("domain", site.Domain), slog.Any("error", err))
return false
}
if _, err := a.SiteQ.SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{Domain: site.Domain, Status: "readonly"}); err != nil {
parked, held, err := a.sweepLocally(ctx, site, "active", func(tx *sql.Tx) error {
q := fwmod.New(tx)
if _, err := q.SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{Domain: site.Domain, Status: "readonly"}); err != nil {
return err
}
if err := q.SetForceReducedByDomain(ctx, site.Domain); err != nil {
return err
}
_, err := entitlements.New(tx).AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
WorkspaceID: site.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
return err
})
if err != nil {
a.Logger.Warn("force_reduce: park local update failed", slog.String("domain", site.Domain), slog.Any("error", err))
return false
}
if err := a.SiteQ.SetForceReducedByDomain(ctx, site.Domain); err != nil {
a.Logger.Warn("force_reduce: stamp force_reduced_at failed", slog.String("domain", site.Domain), slog.Any("error", err))
}
entQ := entitlements.New(a.Database)
if _, err := entQ.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
WorkspaceID: site.WorkspaceID,
ResourceKey: "fedwiki_sites",
}); err != nil {
a.Logger.Warn("force_reduce: decrement usage failed", slog.String("domain", site.Domain), slog.Any("error", err))
if !parked {
a.sendFarmBack(ctx, site.Domain, held)
return false
}
a.Logger.Info("force_reduce: parked site read-only", slog.String("domain", site.Domain))
return true
}
// reactivateSite returns a force-reduced site to active when quota allows,
// clearing force_reduced_at. Quota-gated: if the workspace is at its limit the
// reservation is a no-op and the site stays read-only.
// clearing force_reduced_at. Quota-gated: if the pool is at its limit the
// reservation is a no-op and the site stays read-only. Like parkSite it
// leaves a site with an accepted request alone, and writes the status only
// while the locked row is still parked read-only; otherwise it gives the
// reserved slot back in that transaction.
func (a *Activities) reactivateSite(ctx context.Context, site fwmod.Site) bool {
if a.siteRequestOpen(ctx, site) {
return false
}
entQ := entitlements.New(a.Database)
res, err := entQ.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: site.WorkspaceID,
@@ -222,20 +257,82 @@ func (a *Activities) reactivateSite(ctx context.Context, site fwmod.Site) bool {
if n, _ := res.RowsAffected(); n == 0 {
return false // at limit; leave parked
}
release := func() {
_, _ = entQ.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{WorkspaceID: site.WorkspaceID, ResourceKey: "fedwiki_sites"})
}
if _, err := a.Client.SetSiteStatus(ctx, site.Domain, "active"); err != nil {
// Compensate the reserved slot.
_, _ = entQ.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{WorkspaceID: site.WorkspaceID, ResourceKey: "fedwiki_sites"})
release() // Compensate the reserved slot.
a.Logger.Warn("force_reduce: reactivate PATCH failed", slog.String("domain", site.Domain), slog.Any("error", err))
return false
}
if _, err := a.SiteQ.SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{Domain: site.Domain, Status: "active"}); err != nil {
reactivated, held, err := a.sweepLocally(ctx, site, "readonly", func(tx *sql.Tx) error {
_, err := fwmod.New(tx).SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{Domain: site.Domain, Status: "active"})
return err
})
if err != nil {
release()
a.Logger.Warn("force_reduce: reactivate local update failed", slog.String("domain", site.Domain), slog.Any("error", err))
return false
}
if err := a.SiteQ.ClearForceReducedByDomain(ctx, site.Domain); err != nil {
a.Logger.Warn("force_reduce: clear force_reduced_at failed", slog.String("domain", site.Domain), slog.Any("error", err))
if !reactivated {
release()
a.sendFarmBack(ctx, site.Domain, held)
return false
}
a.Logger.Info("force_reduce: reactivated site", slog.String("domain", site.Domain))
return true
}
// siteRequestOpen reports whether the site has an accepted lifecycle
// request, which the sweep leaves to decide the site's status. A failed
// read counts as open, so the next tick tries again.
func (a *Activities) siteRequestOpen(ctx context.Context, site fwmod.Site) bool {
open, err := a.SiteQ.SiteHasOpenRequest(ctx, site.SiteID)
if err != nil {
a.Logger.Warn("force_reduce: read open requests", slog.String("domain", site.Domain), slog.Any("error", err))
return true
}
return open
}
// sweepLocally runs apply in a transaction that locks the site row first,
// only when the row still holds the status the sweep listed it in (a
// reactivation also wants its downgrade mark). It returns whether apply ran,
// and otherwise the status the row holds now ("" when the row is gone).
func (a *Activities) sweepLocally(ctx context.Context, site fwmod.Site, listed string, apply func(*sql.Tx) error) (bool, string, error) {
tx, err := a.Database.BeginTx(ctx, nil)
if err != nil {
return false, "", err
}
defer func() { _ = tx.Rollback() }()
held, err := fwmod.New(tx).LockSiteStatusByDomain(ctx, site.Domain)
if errors.Is(err, sql.ErrNoRows) {
return false, "", nil
}
if err != nil {
return false, "", err
}
if held.Status != listed || (listed == "readonly" && !held.ForceReducedAt.Valid) {
return false, held.Status, nil
}
if err := apply(tx); err != nil {
return false, "", err
}
return true, "", tx.Commit()
}
// sendFarmBack PATCHes the farm to the status the console holds for a site
// whose row changed while the sweep's farm call was in flight, so the farm
// does not keep the sweep's status. Best-effort: a member's request that
// changed the row sends its own PATCH as well.
func (a *Activities) sendFarmBack(ctx context.Context, domain, status string) {
if status == "" {
return
}
a.Logger.Info("force_reduce: the site changed during the sweep; left as the console records it",
slog.String("domain", domain), slog.String("status", status))
if _, err := a.Client.SetSiteStatus(ctx, domain, status); err != nil {
a.Logger.Warn("force_reduce: send the farm back failed", slog.String("domain", domain), slog.Any("error", err))
}
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import "go.temporal.io/sdk/workflow"
// WorkflowRegistry is the registration surface a Temporal worker and a
// workflow replayer share, so the worker and the replay tests register the
// same set of workflow types.
type WorkflowRegistry interface {
RegisterWorkflowWithOptions(w interface{}, options workflow.RegisterOptions)
}
// RegisterWorkflows registers every FedWiki workflow type.
//
// The four lifecycle types created before lifecycle requests existed
// (create, set status, swap, delete) stay registered for one release beside
// their request-driven successors, so a run started by the previous release
// finishes on the code that started it: Temporal replays a run against the
// code it finds, and the successors' command sequences differ (design D28).
// The following change removes them.
func RegisterWorkflows(r WorkflowRegistry) {
for _, wf := range []any{
// Types started before lifecycle requests, kept for runs in flight.
CreateFedWikiSiteWorkflow,
DeleteFedWikiSiteWorkflow,
SetSiteStatusWorkflow,
SwapActiveSiteWorkflow,
// The farm sync.
SyncFedWikiSitesWorkflow,
// Request-driven lifecycle types (design D28), and the reconcile the
// boot pass starts for a create that closed without an outcome.
CreateFedWikiSiteRequestWorkflow,
SetFedWikiSiteStatusRequestWorkflow,
SwapFedWikiActiveSiteRequestWorkflow,
DeleteFedWikiSiteRequestWorkflow,
ReconcileFedWikiSiteCreateWorkflow,
} {
r.RegisterWorkflowWithOptions(wf, workflow.RegisterOptions{})
}
}
@@ -0,0 +1,262 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
enumspb "go.temporal.io/api/enums/v1"
historypb "go.temporal.io/api/history/v1"
"go.temporal.io/api/temporalproto"
"go.temporal.io/sdk/activity"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/worker"
)
// Replay tests (integration-delivery-core task 11.8). Each file under
// testdata/replay is the recorded history of one run: old-*.json of a type
// created before lifecycle requests, new-*.json of a request-driven type.
// Every history replays against RegisterWorkflows, the registration the
// worker uses, so a change that breaks a run in flight at deploy fails here.
//
// The histories are recorded against a Temporal server with stub activities
// by TestRecordReplayHistories, which runs only when FEDWIKI_REPLAY_RECORD
// names a server (host:port), for example a local `temporal server
// start-dev`. Record old-* histories from the code a release shipped, never
// from edited code: they stand for runs that release started.
const replayDir = "testdata/replay"
func TestWorkflowHistoriesReplay(t *testing.T) {
files, err := filepath.Glob(filepath.Join(replayDir, "*.json"))
if err != nil {
t.Fatal(err)
}
var old, fresh int
for _, f := range files {
switch {
case strings.HasPrefix(filepath.Base(f), "old-"):
old++
case strings.HasPrefix(filepath.Base(f), "new-"):
fresh++
}
}
if old == 0 || fresh == 0 {
t.Fatalf("recorded histories: %d old, %d new; want both kinds under %s", old, fresh, replayDir)
}
for _, f := range files {
t.Run(filepath.Base(f), func(t *testing.T) {
replayer := worker.NewWorkflowReplayer()
RegisterWorkflows(replayer)
if err := replayer.ReplayWorkflowHistoryFromJSONFile(nil, f); err != nil {
t.Fatalf("replay %s: %v", f, err)
}
})
}
}
// replayScenario is one recorded run: the history file it writes, the
// workflow it starts and its input.
type replayScenario struct {
file string
workflow any
input any
}
func replayScenarios() []replayScenario {
return []replayScenario{
{"old-create-succeeded.json", CreateFedWikiSiteWorkflow, CreateFedWikiSiteWorkflowInput{
WorkspaceID: "workspace-replay", Domain: "replay", SiteDomain: "example.test", OwnerName: "owner", OwnerID: "subject"}},
{"old-create-farm-refused.json", CreateFedWikiSiteWorkflow, CreateFedWikiSiteWorkflowInput{
WorkspaceID: "workspace-replay", Domain: "refused", SiteDomain: "example.test", OwnerName: "owner", OwnerID: "subject"}},
{"old-set-status-activate.json", SetSiteStatusWorkflow, SetSiteStatusWorkflowInput{
SiteID: "site-replay", Domain: "replay.example.test", WorkspaceID: "workspace-replay", CurrentStatus: "archived", TargetStatus: "active"}},
{"old-set-status-archive.json", SetSiteStatusWorkflow, SetSiteStatusWorkflowInput{
SiteID: "site-replay", Domain: "replay.example.test", WorkspaceID: "workspace-replay", CurrentStatus: "active", TargetStatus: "archived"}},
{"old-swap.json", SwapActiveSiteWorkflow, SwapActiveSiteInput{
WorkspaceID: "workspace-replay", ParkSiteID: "site-park", ActivateSiteID: "site-activate",
ParkDomain: "park.example.test", ActivateDomain: "activate.example.test"}},
{"old-delete.json", DeleteFedWikiSiteWorkflow, DeleteFedWikiSiteWorkflowInput{
SiteID: "site-replay", Domain: "replay.example.test", WorkspaceID: "workspace-replay", WasActive: true}},
{"new-create-succeeded.json", CreateFedWikiSiteRequestWorkflow, CreateSiteRequestInput{
ConnectionID: "connection-replay", InstanceID: "instance-made", Generation: 1, WorkspaceID: "workspace-replay",
Domain: "made.example.test", OwnerName: "owner", OwnerID: "subject"}},
{"new-create-held-after-wait.json", CreateFedWikiSiteRequestWorkflow, CreateSiteRequestInput{
ConnectionID: "connection-replay", InstanceID: "instance-slow", Generation: 1, WorkspaceID: "workspace-replay",
Domain: "slow.example.test", OwnerName: "owner", OwnerID: "subject"}},
{"new-create-name-taken.json", CreateFedWikiSiteRequestWorkflow, CreateSiteRequestInput{
ConnectionID: "connection-replay", InstanceID: "instance-taken", Generation: 1, WorkspaceID: "workspace-replay",
Domain: "taken.example.test", OwnerName: "owner", OwnerID: "subject"}},
{"new-set-status-activate.json", SetFedWikiSiteStatusRequestWorkflow, SetSiteStatusRequestInput{
ConnectionID: "connection-replay", InstanceID: "site-replay", Generation: 2, WorkspaceID: "workspace-replay",
Domain: "replay.example.test", CurrentStatus: "readonly", TargetStatus: "active", ClearForceReduced: true}},
{"new-swap.json", SwapFedWikiActiveSiteRequestWorkflow, SwapSiteRequestInput{
ConnectionID: "connection-replay", InstanceID: "site-activate", Generation: 2, ParkInstanceID: "site-park", ParkGeneration: 3,
WorkspaceID: "workspace-replay", ActivateDomain: "activate.example.test", ParkDomain: "park.example.test"}},
{"new-swap-farm-refused.json", SwapFedWikiActiveSiteRequestWorkflow, SwapSiteRequestInput{
ConnectionID: "connection-replay", InstanceID: "site-refused", Generation: 2, ParkInstanceID: "site-park", ParkGeneration: 3,
WorkspaceID: "workspace-replay", ActivateDomain: "refused.example.test", ParkDomain: "park.example.test"}},
{"new-delete.json", DeleteFedWikiSiteRequestWorkflow, DeleteSiteRequestInput{
ConnectionID: "connection-replay", InstanceID: "site-replay", Generation: 4, WorkspaceID: "workspace-replay",
Domain: "replay.example.test"}},
{"new-reconcile-held.json", ReconcileFedWikiSiteCreateWorkflow, ReconcileSiteCreateInput{
ConnectionID: "connection-replay", InstanceID: "instance-held", Generation: 1}},
}
}
// registerReplayStubs registers an answer for every activity the recorded
// scenarios run, under the names the workflows schedule them by. A domain
// starting with "refused" makes the farm refuse (for a swap, the domain it
// activates), so a refused swap records its restore and its failed outcome.
func registerReplayStubs(w worker.Worker) {
stub := func(name string, fn any) {
w.RegisterActivityWithOptions(fn, activity.RegisterOptions{Name: name})
}
farmRefusal := temporal.NewNonRetryableApplicationError("farm refused", "FarmManagerAPIError", nil)
stub("CheckQuotaActivity", func(context.Context, CheckQuotaInput) (*CheckQuotaOutput, error) {
return &CheckQuotaOutput{CurrentCount: 0, Quota: 3, CanCreate: true}, nil
})
stub("IncrementUsageActivity", func(context.Context, IncrementUsageInput) error { return nil })
stub("DecrementUsageActivity", func(context.Context, DecrementUsageInput) error { return nil })
stub("AllocateDomainActivity", func(_ context.Context, in AllocateDomainInput) (*AllocateDomainOutput, error) {
return &AllocateDomainOutput{SiteID: in.SiteID, Domain: in.Domain + "." + in.SiteDomain}, nil
})
stub("ReleaseDomainActivity", func(context.Context, ReleaseDomainInput) error { return nil })
stub("CreateFedWikiSiteActivity", func(_ context.Context, in CreateFedWikiSiteInput) (*CreateFedWikiSiteOutput, error) {
if strings.HasPrefix(in.Domain, "refused") {
return nil, farmRefusal
}
return &CreateFedWikiSiteOutput{SiteID: in.SiteID, Domain: in.Domain + "." + in.SiteDomain}, nil
})
stub("SetSiteStatusActivity", func(context.Context, SetSiteStatusInput) (*SetSiteStatusOutput, error) {
return &SetSiteStatusOutput{Success: true}, nil
})
stub("DeleteFedWikiSiteActivity", func(context.Context, DeleteFedWikiSiteInput) (*DeleteFedWikiSiteOutput, error) {
return &DeleteFedWikiSiteOutput{Success: true}, nil
})
stub("SwapActiveSiteActivity", func(context.Context, SwapActiveSiteInput) (*SwapActiveSiteOutput, error) {
return &SwapActiveSiteOutput{Success: true}, nil
})
// Request-driven types. "slow" is held by the farm only on the second
// read, so its history records the read-back's wait; "taken" is held by
// someone else.
nameHeld := temporal.NewNonRetryableApplicationError("farm create answered status 409", FarmNameHeld, nil)
var readsMu sync.Mutex
reads := map[string]int{}
stub("CreateFarmSiteActivity", func(_ context.Context, in CreateFarmSiteInput) error {
if strings.HasPrefix(in.Domain, "slow") || strings.HasPrefix(in.Domain, "taken") {
return nameHeld
}
return nil
})
stub("ReadFarmSiteActivity", func(_ context.Context, in ReadFarmSiteInput) (*ReadFarmSiteOutput, error) {
readsMu.Lock()
reads[in.Domain]++
n := reads[in.Domain]
readsMu.Unlock()
switch {
case strings.HasPrefix(in.Domain, "taken"):
return &ReadFarmSiteOutput{Found: true}, nil
case strings.HasPrefix(in.Domain, "slow") && n == 1:
return &ReadFarmSiteOutput{}, nil
default:
return &ReadFarmSiteOutput{Found: true, OwnedByRequester: true}, nil
}
})
stub("RecordCreatedSiteActivity", func(context.Context, RecordCreatedSiteInput) error { return nil })
stub("FinishLifecycleRequestsActivity", func(context.Context, FinishLifecycleRequestsInput) error { return nil })
stub("ApplyFarmSiteStatusActivity", func(context.Context, SetSiteStatusInput) error { return nil })
stub("RecordSiteStatusActivity", func(context.Context, RecordSiteStatusInput) error { return nil })
stub("RecordSiteDeletedActivity", func(context.Context, RecordSiteDeletedInput) error { return nil })
stub("SwapFarmSitesActivity", func(_ context.Context, in SwapActiveSiteInput) error {
if strings.HasPrefix(in.ActivateDomain, "refused") {
return farmRefusal
}
return nil
})
stub("RestoreSwapFarmStatusesActivity", func(context.Context, SwapActiveSiteInput) error { return nil })
stub("RecordSwapActivity", func(context.Context, RecordSwapInput) error { return nil })
stub("LoadCreateRequestActivity", func(_ context.Context, in LoadCreateRequestInput) (*CreateRequestState, error) {
return &CreateRequestState{Status: "accepted", WorkspaceID: "workspace-replay", Domain: "held.example.test"}, nil
})
stub("ReleaseCreateReservationActivity", func(context.Context, ReleaseCreateReservationInput) error { return nil })
}
// TestRecordReplayHistories records each scenario's history into
// testdata/replay. FEDWIKI_REPLAY_ONLY, when set, limits it to files whose
// name starts with that prefix (record "new-" without re-recording "old-").
func TestRecordReplayHistories(t *testing.T) {
hostPort := os.Getenv("FEDWIKI_REPLAY_RECORD")
if hostPort == "" {
t.Skip("FEDWIKI_REPLAY_RECORD not set")
}
only := os.Getenv("FEDWIKI_REPLAY_ONLY")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// A fixed identity keeps the recording machine's name out of the files.
c, err := client.Dial(client.Options{HostPort: hostPort, Identity: "replay-recorder"})
if err != nil {
t.Fatalf("dial %s: %v", hostPort, err)
}
defer c.Close()
queue := "fedwiki-replay-record-" + uuid.NewString()
w := worker.New(c, queue, worker.Options{})
RegisterWorkflows(w)
registerReplayStubs(w)
if err := w.Start(); err != nil {
t.Fatal(err)
}
defer w.Stop()
if err := os.MkdirAll(replayDir, 0o755); err != nil {
t.Fatal(err)
}
for _, sc := range replayScenarios() {
if only != "" && !strings.HasPrefix(sc.file, only) {
continue
}
run, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: "replay-" + strings.TrimSuffix(sc.file, ".json") + "-" + uuid.NewString(),
TaskQueue: queue,
}, sc.workflow, sc.input)
if err != nil {
t.Fatalf("%s: start: %v", sc.file, err)
}
if err := run.Get(ctx, nil); err != nil {
t.Fatalf("%s: run: %v", sc.file, err)
}
hist := &historypb.History{}
iter := c.GetWorkflowHistory(ctx, run.GetID(), run.GetRunID(), false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
for iter.HasNext() {
ev, err := iter.Next()
if err != nil {
t.Fatalf("%s: history: %v", sc.file, err)
}
hist.Events = append(hist.Events, ev)
}
out, err := temporalproto.CustomJSONMarshalOptions{Indent: " "}.Marshal(hist)
if err != nil {
t.Fatal(err)
}
// The worker's sticky task queue carries the host name; replay never
// reads it, so the files name the recorder instead.
if host, err := os.Hostname(); err == nil && host != "" {
out = []byte(strings.ReplaceAll(string(out), host+":", "replay-recorder:"))
}
if err := os.WriteFile(filepath.Join(replayDir, sc.file), append(out, '\n'), 0o644); err != nil {
t.Fatal(err)
}
t.Logf("recorded %s (%d events)", sc.file, len(hist.Events))
}
}
@@ -0,0 +1,467 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"go.temporal.io/sdk/temporal"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
)
// Activities of the request-driven lifecycle workflows (requests.go).
// CreateFarmSiteInput is the farm half of a create: the call only. The row
// insert is RecordCreatedSiteActivity, so a retry after a lost answer re-runs
// only the half that did not finish (design D28).
type CreateFarmSiteInput struct {
Domain string // the fully resolved name
OwnerName string
OwnerID string // the requester's identity-provider subject
}
// CreateFarmSiteActivity asks the farm to create the site. The farm's
// definitive answers stop the retries at once with a type the workflow
// reads: FarmNameHeld (409), FarmNameRefused (400, 422) and
// FarmCreateRefused (any other 4xx but 408 and 429). Everything else, a
// transport error, a timeout, 408, 429 or a 5xx, is retried. No error
// carries the farm's own text.
func (a *Activities) CreateFarmSiteActivity(ctx context.Context, in CreateFarmSiteInput) error {
if in.Domain == "" {
return temporal.NewNonRetryableApplicationError("create request names no domain", "ConfigurationError", nil)
}
_, err := a.Client.CreateSite(ctx, in.Domain, in.OwnerName, in.OwnerID)
if err == nil {
a.Logger.Info("farm site created", slog.String("domain", in.Domain))
return nil
}
var apiErr *FarmManagerAPIError
if !errors.As(err, &apiErr) {
return fmt.Errorf("farm create of %s: %w", in.Domain, err)
}
a.Logger.Warn("farm create answered", slog.String("domain", in.Domain), slog.Int("status", apiErr.StatusCode))
msg := fmt.Sprintf("farm create answered status %d", apiErr.StatusCode)
switch kind := classifyFarmCreateStatus(apiErr.StatusCode); kind {
case "":
return errors.New(msg)
default:
return temporal.NewNonRetryableApplicationError(msg, kind, nil)
}
}
// classifyFarmCreateStatus names the application error type for a farm
// create's HTTP answer, or "" when the answer is worth retrying.
func classifyFarmCreateStatus(status int) string {
switch {
case status == http.StatusConflict:
return FarmNameHeld
case status == http.StatusBadRequest || status == http.StatusUnprocessableEntity:
return FarmNameRefused
case status == http.StatusRequestTimeout || status == http.StatusTooManyRequests:
return ""
case status >= 400 && status < 500:
return FarmCreateRefused
default:
return ""
}
}
// ReadFarmSiteInput reads the farm's entry for a domain. OwnerID is the
// requester's subject when the workflow already holds it; when empty, the
// activity reads it through the request (InstanceID, Generation), so a
// workflow that never held it does not gain it in its history.
type ReadFarmSiteInput struct {
Domain string
OwnerID string
InstanceID string
Generation int64
}
// ReadFarmSiteOutput is what the farm's entry says: whether the farm holds
// the name, and whether it holds it for the requester. No subject leaves
// the activity.
type ReadFarmSiteOutput struct {
Found bool
OwnedByRequester bool
}
// ReadFarmSiteActivity reads the farm's per-domain entry (design D29). A 404
// is "not held"; any other failure is an error the workflow retries without
// limit.
func (a *Activities) ReadFarmSiteActivity(ctx context.Context, in ReadFarmSiteInput) (*ReadFarmSiteOutput, error) {
owner := in.OwnerID
if owner == "" {
subject, err := integration.LifecycleRequesterSubject(ctx, a.Database, in.InstanceID, in.Generation)
if err != nil {
return nil, err
}
owner = subject
}
site, err := a.Client.GetSite(ctx, in.Domain)
if err != nil {
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) {
if apiErr.StatusCode == http.StatusNotFound {
return &ReadFarmSiteOutput{}, nil
}
return nil, fmt.Errorf("farm read of %s answered status %d", in.Domain, apiErr.StatusCode)
}
return nil, fmt.Errorf("farm read of %s: %w", in.Domain, err)
}
return &ReadFarmSiteOutput{
Found: true,
OwnedByRequester: owner != "" && site.Owner.OAuth2.ID == owner,
}, nil
}
// FinishLifecycleRequestsInput records one outcome on each request.
type FinishLifecycleRequestsInput struct {
Requests []LifecycleRequestRef
Status string
ReasonCode string
FinishedAt time.Time
}
// FinishLifecycleRequestsActivity records the outcome on every request in
// one transaction. A request that cannot take it fails the activity with
// LifecycleRequestMissing, which is not retried.
func (a *Activities) FinishLifecycleRequestsActivity(ctx context.Context, in FinishLifecycleRequestsInput) error {
tx, err := a.Database.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, ref := range in.Requests {
if err := integration.FinishLifecycleRequest(ctx, tx, integration.LifecycleOutcome{
InstanceID: ref.InstanceID,
Generation: ref.Generation,
Status: in.Status,
ReasonCode: in.ReasonCode,
FinishedAt: in.FinishedAt,
}); err != nil {
return outcomeError(err)
}
}
return tx.Commit()
}
func outcomeError(err error) error {
if errors.Is(err, integration.ErrLifecycleRequestNotFound) {
return temporal.NewNonRetryableApplicationError(err.Error(), LifecycleRequestMissing, nil)
}
return err
}
// finishWith runs apply and then records the outcome on the request, in one
// transaction serialized with every other write to the instance's requests.
// When the request already holds this outcome, an earlier attempt committed
// and lost its answer: nothing is applied twice. A request that is missing,
// or finished with another outcome, fails with LifecycleRequestMissing.
func (a *Activities) finishWith(ctx context.Context, out integration.LifecycleOutcome, apply func(*sql.Tx) error) (applied bool, err error) {
return a.finishAllWith(ctx, []integration.LifecycleOutcome{out}, apply)
}
// finishAllWith is finishWith for several requests that finish together,
// such as a swap's two: it locks their instances in the order given, which
// is the order their handler recorded them in, so the two never wait on each
// other; and it applies and records only when every request is still
// accepted. When every
// request already holds its outcome, an earlier attempt committed: nothing
// is applied twice.
func (a *Activities) finishAllWith(ctx context.Context, outs []integration.LifecycleOutcome, apply func(*sql.Tx) error) (applied bool, err error) {
tx, err := a.Database.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
q := integration.New(tx)
ordered := outs
finished := 0
for _, out := range ordered {
if err := q.LockLifecycleInstance(ctx, out.InstanceID); err != nil {
return false, err
}
held, err := q.GetLifecycleRequestByAddress(ctx, integration.GetLifecycleRequestByAddressParams{
InstanceID: out.InstanceID,
Generation: out.Generation,
})
if errors.Is(err, sql.ErrNoRows) {
return false, outcomeError(fmt.Errorf("%w: instance %s generation %d", integration.ErrLifecycleRequestNotFound, out.InstanceID, out.Generation))
}
if err != nil {
return false, err
}
if held.Status != integration.LifecycleAccepted {
if held.Status == out.Status && held.ReasonCode.String == out.ReasonCode {
finished++
continue
}
return false, outcomeError(fmt.Errorf("%w: instance %s generation %d is %s", integration.ErrLifecycleRequestNotFound, out.InstanceID, out.Generation, held.Status))
}
}
switch finished {
case len(ordered):
return false, nil
case 0:
default:
// The requests finish together, so one finished without the other
// was finished by something else.
return false, outcomeError(fmt.Errorf("%w: only some of the requests are finished", integration.ErrLifecycleRequestNotFound))
}
if err := apply(tx); err != nil {
return false, err
}
for _, out := range ordered {
if err := integration.FinishLifecycleRequest(ctx, tx, out); err != nil {
return false, outcomeError(err)
}
}
return true, tx.Commit()
}
// RecordCreatedSiteInput finishes a successful create.
type RecordCreatedSiteInput struct {
InstanceID string
Generation int64
WorkspaceID string
Domain string
FinishedAt time.Time
}
// RecordCreatedSiteActivity inserts the site row under the request's
// instance and marks the request succeeded in one transaction, so the list
// never shows the pending create and the new site together (design D28).
func (a *Activities) RecordCreatedSiteActivity(ctx context.Context, in RecordCreatedSiteInput) error {
_, err := a.finishWith(ctx, integration.LifecycleOutcome{
InstanceID: in.InstanceID,
Generation: in.Generation,
Status: integration.LifecycleSucceeded,
FinishedAt: in.FinishedAt,
}, func(tx *sql.Tx) error {
q := fwmod.New(tx)
existing, err := q.GetSiteByID(ctx, in.InstanceID)
switch {
case err == nil:
if existing.WorkspaceID != in.WorkspaceID || existing.Domain != in.Domain {
return temporal.NewNonRetryableApplicationError(
fmt.Sprintf("site %s exists with another workspace or domain", in.InstanceID), SiteConflict, nil)
}
return nil
case !errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("read site %s: %w", in.InstanceID, err)
}
if _, err := q.CreateSite(ctx, fwmod.CreateSiteParams{
SiteID: in.InstanceID,
WorkspaceID: in.WorkspaceID,
Domain: in.Domain,
}); err != nil {
return fmt.Errorf("record site %s: %w", in.Domain, err)
}
return nil
})
if err == nil {
a.Logger.Info("site created", slog.String("site_id", in.InstanceID), slog.String("domain", in.Domain))
}
return err
}
// ApplyFarmSiteStatusActivity changes a site's status on the farm, after
// re-reading the site by id (authorizeSite). The local projection is
// RecordSiteStatusActivity's, in the transaction that records the outcome.
func (a *Activities) ApplyFarmSiteStatusActivity(ctx context.Context, in SetSiteStatusInput) error {
if _, err := a.authorizeSite(ctx, in.SiteID, in.WorkspaceID, in.Domain); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return temporal.NewNonRetryableApplicationError("the site no longer exists", SiteAuthorizationError, err)
}
return err
}
if _, err := a.Client.SetSiteStatus(ctx, in.Domain, in.Status); err != nil {
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) {
msg := fmt.Sprintf("farm status change answered status %d", apiErr.StatusCode)
if apiErr.IsNonRetryable() {
return temporal.NewNonRetryableApplicationError(msg, "FarmManagerAPIError", nil)
}
return errors.New(msg)
}
return fmt.Errorf("farm status change of %s: %w", in.Domain, err)
}
return nil
}
// RecordSiteStatusInput is a status change's local half.
type RecordSiteStatusInput struct {
InstanceID string // the site id
Generation int64
WorkspaceID string
Domain string
Status string
ClearForceReduced bool
FinishedAt time.Time
}
// RecordSiteStatusActivity writes the site's new status, the usage slot it
// releases, the clearing of its downgrade-parked mark, and the request's
// outcome in one transaction (design D28). Placement servability follows
// after the commit, best-effort, as SetSiteStatusActivity does.
//
// The slot is released only when the site row, locked here, is still
// active: the downgrade sweep may have parked the site after the member
// asked, and it released the slot then (Open Questions 66).
func (a *Activities) RecordSiteStatusActivity(ctx context.Context, in RecordSiteStatusInput) error {
applied, err := a.finishWith(ctx, integration.LifecycleOutcome{
InstanceID: in.InstanceID,
Generation: in.Generation,
Status: integration.LifecycleSucceeded,
FinishedAt: in.FinishedAt,
}, func(tx *sql.Tx) error {
q := fwmod.New(tx)
held, err := q.LockSiteStatusByDomain(ctx, in.Domain)
if err != nil {
return fmt.Errorf("record status of %s: %w", in.Domain, err)
}
if _, err := q.SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{Domain: in.Domain, Status: in.Status}); err != nil {
return fmt.Errorf("record status of %s: %w", in.Domain, err)
}
if held.Status == "active" && in.Status != "active" && in.WorkspaceID != "" {
if _, err := entitlements.New(tx).AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
WorkspaceID: in.WorkspaceID,
ResourceKey: "fedwiki_sites",
}); err != nil {
return fmt.Errorf("release usage slot: %w", err)
}
}
if in.ClearForceReduced {
if err := q.ClearForceReducedByDomain(ctx, in.Domain); err != nil {
return fmt.Errorf("clear force_reduced on %s: %w", in.Domain, err)
}
}
return nil
})
if err != nil {
return err
}
if applied {
a.setPlacementServability(ctx, in.InstanceID, in.Domain, in.Status)
}
return nil
}
// RecordSiteDeletedInput is a permanent delete's local half. ReleaseSlot
// is whether the row was active when the delete activity removed it.
type RecordSiteDeletedInput struct {
InstanceID string
Generation int64
WorkspaceID string
ReleaseSlot bool
FinishedAt time.Time
}
// RecordSiteDeletedActivity releases the deleted site's usage slot and marks
// the request succeeded in one transaction, so a retry never releases twice.
func (a *Activities) RecordSiteDeletedActivity(ctx context.Context, in RecordSiteDeletedInput) error {
_, err := a.finishWith(ctx, integration.LifecycleOutcome{
InstanceID: in.InstanceID,
Generation: in.Generation,
Status: integration.LifecycleSucceeded,
FinishedAt: in.FinishedAt,
}, func(tx *sql.Tx) error {
if !in.ReleaseSlot {
return nil
}
if _, err := entitlements.New(tx).AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
WorkspaceID: in.WorkspaceID,
ResourceKey: "fedwiki_sites",
}); err != nil {
return fmt.Errorf("release usage slot: %w", err)
}
return nil
})
return err
}
// LoadCreateRequestInput names the create request a reconcile settles.
type LoadCreateRequestInput struct {
InstanceID string
Generation int64
}
// CreateRequestState is what a reconcile needs from its request:
// identifiers, the public domain and the status. No name or subject.
type CreateRequestState struct {
Status string
ReasonCode string
WorkspaceID string
Domain string
}
// LoadCreateRequestActivity reads a create request for the reconcile
// workflow.
func (a *Activities) LoadCreateRequestActivity(ctx context.Context, in LoadCreateRequestInput) (*CreateRequestState, error) {
row, err := integration.New(a.Database).GetLifecycleRequestByAddress(ctx, integration.GetLifecycleRequestByAddressParams{
InstanceID: in.InstanceID,
Generation: in.Generation,
})
if errors.Is(err, sql.ErrNoRows) {
return nil, temporal.NewNonRetryableApplicationError(
fmt.Sprintf("no lifecycle request at instance %s generation %d", in.InstanceID, in.Generation), LifecycleRequestMissing, nil)
}
if err != nil {
return nil, err
}
if row.Operation != integration.LifecycleCreate {
return nil, temporal.NewNonRetryableApplicationError(
fmt.Sprintf("lifecycle request %s is a %s, not a create", row.RequestID, row.Operation), LifecycleRequestMissing, nil)
}
var params struct {
Domain string `json:"domain"`
}
if err := json.Unmarshal(row.Parameters, &params); err != nil || params.Domain == "" {
return nil, temporal.NewNonRetryableApplicationError(
fmt.Sprintf("create request %s records no domain", row.RequestID), "ConfigurationError", err)
}
return &CreateRequestState{
Status: row.Status,
ReasonCode: row.ReasonCode.String,
WorkspaceID: row.WorkspaceID,
Domain: params.Domain,
}, nil
}
// ReleaseCreateReservationInput names a create whose run closed without an
// outcome.
type ReleaseCreateReservationInput struct {
InstanceID string
WorkspaceID string
}
// ReleaseCreateReservationActivity releases what a closed create run
// reserved: its placement, and the usage slot the saga took before placing
// it. The closed run may have died before either reservation; a placement
// proves the slot was taken, so the slot is released only with a placement.
// A slot taken by a run that died before placing stays counted until boot
// reconciliation sets the counter to the site rows.
func (a *Activities) ReleaseCreateReservationActivity(ctx context.Context, in ReleaseCreateReservationInput) error {
if a.Registry == nil {
return nil
}
result, err := ReleaseSiteDomain(ctx, a.Registry, in.InstanceID)
if err != nil {
return fmt.Errorf("release placement of %s: %w", in.InstanceID, err)
}
if !result.Found {
return nil
}
return a.DecrementUsageActivity(ctx, DecrementUsageInput{WorkspaceID: in.WorkspaceID})
}
@@ -0,0 +1,614 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"errors"
"strconv"
"strings"
"time"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// Request-driven lifecycle workflows (integration-delivery-core design D27
// to D29). Each runs for one recorded lifecycle request, takes its
// identifiers from the request, and ends by recording the request's outcome:
// a status, a reason code, never text.
// Reason codes a FedWiki request ends with (design D37). The member-facing
// sentence for each lives in FedWiki's web package.
const (
ReasonSiteLimitReached = "site_limit_reached"
ReasonActiveLimitReached = "active_limit_reached"
ReasonNameRefused = "name_refused"
ReasonNameTaken = "name_taken"
ReasonSiteChanged = "site_changed"
ReasonQuotaUnavailable = "quota_unavailable"
ReasonRetriesExhausted = "retries_exhausted"
ReasonNotStarted = "not_started"
)
const (
// FarmReadBackWait is how long a create waits, after an ambiguous farm
// failure, before reading the domain a second time: twice the farm
// client's own timeout, so a create the farm accepted has that long to
// finish (design D29). It is measured from when the workflow saw the
// failure, which is never earlier than when the last attempt was sent.
FarmReadBackWait = 2 * FarmClientTimeout
// farmReadBackMaxInterval caps the backoff of a read-back the farm
// cannot answer. The read has no attempt limit: the console never frees
// a name the farm may be serving, so it waits for the farm.
farmReadBackMaxInterval = 5 * time.Minute
// outcomeRetryWindow bounds the retries of the step that records a
// request's outcome, which has no attempt limit (design D28): the
// outcome must land, and a database outage is the only thing that stops
// it. Ten years is "until it lands" for any real run.
outcomeRetryWindow = 10 * 365 * 24 * time.Hour
// outcomeMaxInterval caps that step's backoff.
outcomeMaxInterval = time.Minute
// SettleRequestAge is how old an accepted request must be before the
// boot pass asks Temporal about its workflow (design D27): a handler
// commits the request and starts the workflow within moments, so two
// minutes separates a request whose start is in flight from one whose
// process died.
SettleRequestAge = 2 * time.Minute
)
// Application error types the request-driven workflows branch on.
const (
// FarmNameHeld: the farm answered 409, the name is held. Ambiguous: the
// console's own earlier attempt may be what holds it (design D29).
FarmNameHeld = "FarmNameHeld"
// FarmNameRefused: the farm refused the request itself (400 or 422),
// a definitive refusal of the name.
FarmNameRefused = "FarmNameRefused"
// FarmCreateRefused: any other definitive 4xx, such as a credential the
// farm does not accept. The farm did not create the site.
FarmCreateRefused = "FarmCreateRefused"
// LifecycleRequestMissing: the outcome step found no request that could
// take the outcome. Retrying cannot help, so the workflow fails with it
// in its history.
LifecycleRequestMissing = "LifecycleRequestMissing"
// SiteConflict: a create's site id already names a row of another
// workspace or domain. Retrying cannot help.
SiteConflict = "SiteConflict"
)
// Workflow identifiers for request-driven runs (design D28). One request
// maps to one workflow id; a swap's two requests share the activated site's.
func CreateRequestWorkflowID(instanceID string) string { return "create-fedwiki-site-" + instanceID }
func SetStatusRequestWorkflowID(siteID string, generation int64) string {
return "set-fedwiki-status-" + siteID + "-" + itoa(generation)
}
func SwapRequestWorkflowID(activateSiteID string, generation int64) string {
return swapRequestWorkflowPrefix + activateSiteID + "-" + itoa(generation)
}
// IsSwapRequestWorkflowID reports whether a request's workflow id names a
// request-driven swap, so a handler can tell a workspace's open swap from
// its other open requests.
func IsSwapRequestWorkflowID(workflowID string) bool {
return strings.HasPrefix(workflowID, swapRequestWorkflowPrefix)
}
const swapRequestWorkflowPrefix = "swap-fedwiki-site-"
func DeleteRequestWorkflowID(siteID string, generation int64) string {
return "delete-fedwiki-site-" + siteID + "-" + itoa(generation)
}
func ReconcileCreateWorkflowID(instanceID string) string {
return "reconcile-fedwiki-site-" + instanceID
}
// LifecycleRequestRef addresses one recorded request.
type LifecycleRequestRef struct {
InstanceID string
Generation int64
}
// LifecycleWorkflowOutput is how a request-driven run ended: the outcome it
// recorded on its request. It carries no text.
type LifecycleWorkflowOutput struct {
Status string
ReasonCode string
}
// CreateSiteRequestInput starts CreateFedWikiSiteRequestWorkflow. The site
// id is the request's instance. Domain is the fully resolved name the
// request recorded. OwnerName and OwnerID stay in the input because the farm
// records the owner's session name, which no table stores (design D28).
type CreateSiteRequestInput struct {
ConnectionID string
InstanceID string
Generation int64
WorkspaceID string
Domain string
OwnerName string
OwnerID string
}
// SetSiteStatusRequestInput starts SetFedWikiSiteStatusRequestWorkflow for
// one site (InstanceID is the site id). ClearForceReduced clears the site's
// downgrade-parked mark in the transaction that records the new status.
type SetSiteStatusRequestInput struct {
ConnectionID string
InstanceID string
Generation int64
WorkspaceID string
Domain string
CurrentStatus string
TargetStatus string
ClearForceReduced bool
}
// SwapSiteRequestInput starts SwapFedWikiActiveSiteRequestWorkflow: the
// activated site's request (InstanceID, Generation) and the parked site's
// set_status request recorded beside it.
type SwapSiteRequestInput struct {
ConnectionID string
InstanceID string
Generation int64
ParkInstanceID string
ParkGeneration int64
WorkspaceID string
ActivateDomain string
ParkDomain string
}
// DeleteSiteRequestInput starts DeleteFedWikiSiteRequestWorkflow for one
// site. The slot the delete releases is read from the row it removes, not
// from the site's status when the member asked (Open Questions 66).
type DeleteSiteRequestInput struct {
ConnectionID string
InstanceID string
Generation int64
WorkspaceID string
Domain string
}
// ReconcileSiteCreateInput starts ReconcileFedWikiSiteCreateWorkflow. It
// carries the request's identifiers only; the activities read the rest.
type ReconcileSiteCreateInput struct {
ConnectionID string
InstanceID string
Generation int64
}
// farmCreateActivityOptions retries the farm create as FedWikiActivityOptions
// does, and stops at once on the farm's definitive answers, which the
// read-back then interprets.
func farmCreateActivityOptions() workflow.ActivityOptions {
opts := FedWikiActivityOptions()
opts.RetryPolicy.NonRetryableErrorTypes = []string{FarmNameHeld, FarmNameRefused, FarmCreateRefused, "ConfigurationError"}
return opts
}
// farmReadBackActivityOptions retries a read the farm cannot answer with no
// attempt limit and a capped backoff (design D29).
func farmReadBackActivityOptions() workflow.ActivityOptions {
return workflow.ActivityOptions{
StartToCloseTimeout: 2 * FarmClientTimeout,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: farmReadBackMaxInterval,
MaximumAttempts: 0,
},
}
}
// outcomeActivityOptions is the local activity that records a request's
// outcome: retried without an attempt limit, except when no request can take
// the outcome.
func outcomeActivityOptions() workflow.LocalActivityOptions {
return workflow.LocalActivityOptions{
StartToCloseTimeout: 30 * time.Second,
ScheduleToCloseTimeout: outcomeRetryWindow,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: outcomeMaxInterval,
MaximumAttempts: 0,
NonRetryableErrorTypes: []string{LifecycleRequestMissing, SiteConflict},
},
}
}
func errorType(err error) string {
var appErr *temporal.ApplicationError
if errors.As(err, &appErr) {
return appErr.Type()
}
return ""
}
// finishRequests records one outcome on each request and returns it.
func finishRequests(ctx workflow.Context, refs []LifecycleRequestRef, status, reason string) (*LifecycleWorkflowOutput, error) {
var acts *Activities
lctx := workflow.WithLocalActivityOptions(ctx, outcomeActivityOptions())
if err := workflow.ExecuteLocalActivity(lctx, acts.FinishLifecycleRequestsActivity, FinishLifecycleRequestsInput{
Requests: refs,
Status: status,
ReasonCode: reason,
FinishedAt: workflow.Now(ctx),
}).Get(ctx, nil); err != nil {
return nil, err
}
return &LifecycleWorkflowOutput{Status: status, ReasonCode: reason}, nil
}
// recordCreatedSite inserts the site row and marks the request succeeded in
// one transaction (design D28).
func recordCreatedSite(ctx workflow.Context, instanceID string, generation int64, workspaceID, domain string) (*LifecycleWorkflowOutput, error) {
var acts *Activities
lctx := workflow.WithLocalActivityOptions(ctx, outcomeActivityOptions())
if err := workflow.ExecuteLocalActivity(lctx, acts.RecordCreatedSiteActivity, RecordCreatedSiteInput{
InstanceID: instanceID,
Generation: generation,
WorkspaceID: workspaceID,
Domain: domain,
FinishedAt: workflow.Now(ctx),
}).Get(ctx, nil); err != nil {
return nil, err
}
return &LifecycleWorkflowOutput{Status: integration.LifecycleSucceeded}, nil
}
// farmVerdict is what reading the farm's entry for a domain established.
type farmVerdict int
const (
farmAbsent farmVerdict = iota + 1 // the farm does not hold the name
farmOwned // the farm holds it for the requester
farmOther // the farm holds it for someone else, or no one
)
// readBackCreate reads the farm's entry for a failed create's domain (design
// D29). After an ambiguous failure a 404 is not proof: it waits
// FarmReadBackWait and reads again. A read the farm cannot answer retries
// without limit inside the activity, so an error here means the run was
// cancelled, and the caller compensates nothing.
func readBackCreate(ctx workflow.Context, in ReadFarmSiteInput, ambiguous bool) (farmVerdict, error) {
var acts *Activities
rctx := workflow.WithActivityOptions(ctx, farmReadBackActivityOptions())
read := func() (farmVerdict, error) {
var out *ReadFarmSiteOutput
if err := workflow.ExecuteActivity(rctx, acts.ReadFarmSiteActivity, in).Get(ctx, &out); err != nil {
return 0, err
}
switch {
case !out.Found:
return farmAbsent, nil
case out.OwnedByRequester:
return farmOwned, nil
default:
return farmOther, nil
}
}
v, err := read()
if err != nil || v != farmAbsent || !ambiguous {
return v, err
}
if err := workflow.Sleep(ctx, FarmReadBackWait); err != nil {
return 0, err
}
return read()
}
// CreateFedWikiSiteRequestWorkflow creates the site a create request asked
// for. It reserves quota, then the name (under the request's instance as the
// site id), then calls the farm. Success inserts the site row and finishes
// the request in one transaction. Any farm failure reads the farm's entry
// for the domain before compensating (design D29).
func CreateFedWikiSiteRequestWorkflow(ctx workflow.Context, in CreateSiteRequestInput) (*LifecycleWorkflowOutput, error) {
logger := workflow.GetLogger(ctx)
var acts *Activities
local := workflow.WithActivityOptions(ctx, LocalActivityOptions())
self := []LifecycleRequestRef{{InstanceID: in.InstanceID, Generation: in.Generation}}
var quota *CheckQuotaOutput
if err := workflow.ExecuteActivity(local, acts.CheckQuotaActivity, CheckQuotaInput{WorkspaceID: in.WorkspaceID}).Get(ctx, &quota); err != nil {
logger.Error("quota check failed", "error", err)
return finishRequests(ctx, self, integration.LifecycleFailed, ReasonQuotaUnavailable)
}
if !quota.CanCreate {
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonSiteLimitReached)
}
if err := workflow.ExecuteActivity(local, acts.IncrementUsageActivity, IncrementUsageInput{WorkspaceID: in.WorkspaceID}).Get(ctx, nil); err != nil {
if errorType(err) == "QuotaExceeded" {
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonSiteLimitReached)
}
logger.Error("quota reservation failed", "error", err)
return finishRequests(ctx, self, integration.LifecycleFailed, ReasonQuotaUnavailable)
}
decrement := func() {
if err := workflow.ExecuteActivity(local, acts.DecrementUsageActivity, DecrementUsageInput{WorkspaceID: in.WorkspaceID}).Get(ctx, nil); err != nil {
logger.Error("failed to compensate usage", "error", err)
}
}
if err := workflow.ExecuteActivity(local, acts.AllocateDomainActivity, AllocateDomainInput{
SiteID: in.InstanceID,
WorkspaceID: in.WorkspaceID,
FQDN: in.Domain,
}).Get(ctx, nil); err != nil {
logger.Error("name allocation failed, compensating usage", "error", err)
decrement()
if errorType(err) == "InvalidDomain" {
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonNameRefused)
}
return finishRequests(ctx, self, integration.LifecycleFailed, ReasonRetriesExhausted)
}
farmCtx := workflow.WithActivityOptions(ctx, farmCreateActivityOptions())
createErr := workflow.ExecuteActivity(farmCtx, acts.CreateFarmSiteActivity, CreateFarmSiteInput{
Domain: in.Domain,
OwnerName: in.OwnerName,
OwnerID: in.OwnerID,
}).Get(ctx, nil)
if createErr == nil {
return recordCreatedSite(ctx, in.InstanceID, in.Generation, in.WorkspaceID, in.Domain)
}
if temporal.IsCanceledError(createErr) {
return nil, createErr
}
kind := errorType(createErr)
definitive := kind == FarmNameRefused || kind == FarmCreateRefused
logger.Warn("farm create failed, reading the farm's entry", "error", createErr, "definitive", definitive)
verdict, err := readBackCreate(ctx, ReadFarmSiteInput{
Domain: in.Domain,
OwnerID: in.OwnerID,
InstanceID: in.InstanceID,
Generation: in.Generation,
}, !definitive)
if err != nil {
// Cancelled while the farm could not answer: the name and the slot
// stay held, and the request stays accepted.
return nil, err
}
if verdict == farmOwned {
return recordCreatedSite(ctx, in.InstanceID, in.Generation, in.WorkspaceID, in.Domain)
}
// Compensate in reverse allocation order, registry first: the lock-order
// invariant puts the registry lock ahead of the usage row's.
if err := workflow.ExecuteActivity(local, acts.ReleaseDomainActivity, ReleaseDomainInput{SiteID: in.InstanceID}).Get(ctx, nil); err != nil {
logger.Error("failed to compensate name allocation", "error", err)
}
decrement()
switch {
case verdict == farmOther:
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonNameTaken)
case kind == FarmNameRefused:
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonNameRefused)
default:
return finishRequests(ctx, self, integration.LifecycleFailed, ReasonRetriesExhausted)
}
}
// SetFedWikiSiteStatusRequestWorkflow moves one site between active,
// readonly and archived. Entering active reserves a slot first; the farm call
// follows; the site's status, the slot it releases, the clearing of its
// downgrade-parked mark and the request's outcome then commit together.
func SetFedWikiSiteStatusRequestWorkflow(ctx workflow.Context, in SetSiteStatusRequestInput) (*LifecycleWorkflowOutput, error) {
logger := workflow.GetLogger(ctx)
var acts *Activities
local := workflow.WithActivityOptions(ctx, LocalActivityOptions())
farmCtx := workflow.WithActivityOptions(ctx, FedWikiActivityOptions())
self := []LifecycleRequestRef{{InstanceID: in.InstanceID, Generation: in.Generation}}
const active = "active"
changing := in.CurrentStatus != in.TargetStatus
activating := changing && in.TargetStatus == active
reserves := activating && in.WorkspaceID != ""
if reserves {
if err := workflow.ExecuteActivity(local, acts.IncrementUsageActivity, IncrementUsageInput{WorkspaceID: in.WorkspaceID}).Get(ctx, nil); err != nil {
if errorType(err) == "QuotaExceeded" {
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonActiveLimitReached)
}
logger.Error("quota reservation failed", "error", err)
return finishRequests(ctx, self, integration.LifecycleFailed, ReasonQuotaUnavailable)
}
}
if changing {
if err := workflow.ExecuteActivity(farmCtx, acts.ApplyFarmSiteStatusActivity, SetSiteStatusInput{
SiteID: in.InstanceID,
WorkspaceID: in.WorkspaceID,
Domain: in.Domain,
Status: in.TargetStatus,
}).Get(ctx, nil); err != nil {
if temporal.IsCanceledError(err) {
return nil, err
}
logger.Error("status change failed", "error", err)
if reserves {
if compErr := workflow.ExecuteActivity(local, acts.DecrementUsageActivity, DecrementUsageInput{WorkspaceID: in.WorkspaceID}).Get(ctx, nil); compErr != nil {
logger.Error("failed to compensate usage", "error", compErr)
}
}
if errorType(err) == SiteAuthorizationError {
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonSiteChanged)
}
return finishRequests(ctx, self, integration.LifecycleFailed, ReasonRetriesExhausted)
}
}
lctx := workflow.WithLocalActivityOptions(ctx, outcomeActivityOptions())
if err := workflow.ExecuteLocalActivity(lctx, acts.RecordSiteStatusActivity, RecordSiteStatusInput{
InstanceID: in.InstanceID,
Generation: in.Generation,
WorkspaceID: in.WorkspaceID,
Domain: in.Domain,
Status: in.TargetStatus,
ClearForceReduced: in.ClearForceReduced,
FinishedAt: workflow.Now(ctx),
}).Get(ctx, nil); err != nil {
return nil, err
}
return &LifecycleWorkflowOutput{Status: integration.LifecycleSucceeded}, nil
}
// SwapFedWikiActiveSiteRequestWorkflow parks one active site and activates
// another on the farm, then writes both statuses and finishes both of its
// requests in one transaction. A swap changes no usage count unless the
// downgrade sweep moved one of its sites meanwhile (Open Questions 70).
func SwapFedWikiActiveSiteRequestWorkflow(ctx workflow.Context, in SwapSiteRequestInput) (*LifecycleWorkflowOutput, error) {
var acts *Activities
activate := LifecycleRequestRef{InstanceID: in.InstanceID, Generation: in.Generation}
park := LifecycleRequestRef{InstanceID: in.ParkInstanceID, Generation: in.ParkGeneration}
refs := []LifecycleRequestRef{activate, park}
sites := SwapActiveSiteInput{
WorkspaceID: in.WorkspaceID,
ParkSiteID: in.ParkInstanceID,
ActivateSiteID: in.InstanceID,
ParkDomain: in.ParkDomain,
ActivateDomain: in.ActivateDomain,
}
farmCtx := workflow.WithActivityOptions(ctx, FedWikiActivityOptions())
err := workflow.ExecuteActivity(farmCtx, acts.SwapFarmSitesActivity, sites).Get(ctx, nil)
switch {
case err == nil:
case temporal.IsCanceledError(err):
return nil, err
case errorType(err) == SiteAuthorizationError:
return finishRequests(ctx, refs, integration.LifecycleDeclined, ReasonSiteChanged)
default:
workflow.GetLogger(ctx).Error("swap failed", "error", err)
// The farm may hold a status the console does not: an attempt's
// own send-back of the parked site can fail too.
if err := restoreSwapFarm(ctx, sites); err != nil {
return nil, err
}
return finishRequests(ctx, refs, integration.LifecycleFailed, ReasonRetriesExhausted)
}
lctx := workflow.WithLocalActivityOptions(ctx, outcomeActivityOptions())
if err := workflow.ExecuteLocalActivity(lctx, acts.RecordSwapActivity, RecordSwapInput{
Activate: activate,
Park: park,
WorkspaceID: in.WorkspaceID,
ActivateDomain: in.ActivateDomain,
ParkDomain: in.ParkDomain,
FinishedAt: workflow.Now(ctx),
}).Get(ctx, nil); err != nil {
if errorType(err) != "QuotaExceeded" {
return nil, err
}
// The sweep parked the site the swap parks, and the pool has no
// slot for the activation: the farm goes back to the console's
// statuses and the member reads the limit.
if err := restoreSwapFarm(ctx, sites); err != nil {
return nil, err
}
return finishRequests(ctx, refs, integration.LifecycleDeclined, ReasonActiveLimitReached)
}
return &LifecycleWorkflowOutput{Status: integration.LifecycleSucceeded}, nil
}
// restoreSwapFarm sends both of a swap's sites back, on the farm, to the
// statuses the console records, retried as the D29 read-back is, without an
// attempt limit while the farm cannot answer (Open Questions 61). A final
// refusal is logged and the swap still finishes; only a cancellation is
// returned.
func restoreSwapFarm(ctx workflow.Context, sites SwapActiveSiteInput) error {
var acts *Activities
restoreCtx := workflow.WithActivityOptions(ctx, farmReadBackActivityOptions())
if err := workflow.ExecuteActivity(restoreCtx, acts.RestoreSwapFarmStatusesActivity, sites).Get(ctx, nil); err != nil {
if temporal.IsCanceledError(err) {
return err
}
workflow.GetLogger(ctx).Error("swap: the farm refused to take the sites back", "error", err)
}
return nil
}
// DeleteFedWikiSiteRequestWorkflow deletes one site permanently, then
// releases the active slot it held and finishes its request together.
func DeleteFedWikiSiteRequestWorkflow(ctx workflow.Context, in DeleteSiteRequestInput) (*LifecycleWorkflowOutput, error) {
var acts *Activities
self := []LifecycleRequestRef{{InstanceID: in.InstanceID, Generation: in.Generation}}
farmCtx := workflow.WithActivityOptions(ctx, FedWikiActivityOptions())
var deleted DeleteFedWikiSiteOutput
if err := workflow.ExecuteActivity(farmCtx, acts.DeleteFedWikiSiteActivity, DeleteFedWikiSiteInput{
SiteID: in.InstanceID,
WorkspaceID: in.WorkspaceID,
Domain: in.Domain,
}).Get(ctx, &deleted); err != nil {
if temporal.IsCanceledError(err) {
return nil, err
}
if errorType(err) == SiteAuthorizationError {
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonSiteChanged)
}
workflow.GetLogger(ctx).Error("delete failed", "error", err)
return finishRequests(ctx, self, integration.LifecycleFailed, ReasonRetriesExhausted)
}
lctx := workflow.WithLocalActivityOptions(ctx, outcomeActivityOptions())
if err := workflow.ExecuteLocalActivity(lctx, acts.RecordSiteDeletedActivity, RecordSiteDeletedInput{
InstanceID: in.InstanceID,
Generation: in.Generation,
WorkspaceID: in.WorkspaceID,
ReleaseSlot: deleted.DeletedStatus == "active" && in.WorkspaceID != "",
FinishedAt: workflow.Now(ctx),
}).Get(ctx, nil); err != nil {
return nil, err
}
return &LifecycleWorkflowOutput{Status: integration.LifecycleSucceeded}, nil
}
// ReconcileFedWikiSiteCreateWorkflow settles a create request whose workflow
// closed without recording an outcome (design D27's boot pass). It reads the
// farm's entry for the request's domain as a failed create does, treating
// the closed run's last attempt as ambiguous, then records the site and
// succeeds, or releases what the run reserved and ends the request.
func ReconcileFedWikiSiteCreateWorkflow(ctx workflow.Context, in ReconcileSiteCreateInput) (*LifecycleWorkflowOutput, error) {
var acts *Activities
local := workflow.WithActivityOptions(ctx, LocalActivityOptions())
self := []LifecycleRequestRef{{InstanceID: in.InstanceID, Generation: in.Generation}}
var req *CreateRequestState
if err := workflow.ExecuteActivity(local, acts.LoadCreateRequestActivity, LoadCreateRequestInput{
InstanceID: in.InstanceID,
Generation: in.Generation,
}).Get(ctx, &req); err != nil {
return nil, err
}
if req.Status != integration.LifecycleAccepted {
// Settled since the boot pass looked.
return &LifecycleWorkflowOutput{Status: req.Status, ReasonCode: req.ReasonCode}, nil
}
verdict, err := readBackCreate(ctx, ReadFarmSiteInput{
Domain: req.Domain,
InstanceID: in.InstanceID,
Generation: in.Generation,
}, true)
if err != nil {
return nil, err
}
if verdict == farmOwned {
return recordCreatedSite(ctx, in.InstanceID, in.Generation, req.WorkspaceID, req.Domain)
}
if err := workflow.ExecuteActivity(local, acts.ReleaseCreateReservationActivity, ReleaseCreateReservationInput{
InstanceID: in.InstanceID,
WorkspaceID: req.WorkspaceID,
}).Get(ctx, nil); err != nil {
return nil, err
}
if verdict == farmOther {
return finishRequests(ctx, self, integration.LifecycleDeclined, ReasonNameTaken)
}
return finishRequests(ctx, self, integration.LifecycleFailed, ReasonRetriesExhausted)
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"database/sql"
"errors"
"log/slog"
"time"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/api/serviceerror"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/sdk/client"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// LifecycleClient is the part of the Temporal client the boot pass uses.
type LifecycleClient interface {
DescribeWorkflowExecution(ctx context.Context, workflowID, runID string) (*workflowservice.DescribeWorkflowExecutionResponse, error)
ExecuteWorkflow(ctx context.Context, options client.StartWorkflowOptions, workflow interface{}, args ...interface{}) (client.WorkflowRun, error)
}
// SettleCounts reports what one boot pass did, for the boot log.
type SettleCounts struct {
Seen int // accepted requests older than SettleRequestAge
NotStarted int // ended failed with not_started
Running int // left to their running workflow
Reconciling int // closed creates handed to the reconcile workflow
Exhausted int // closed non-creates ended failed with retries_exhausted
Unanswered int // Temporal did not answer; left for the next boot
FinishErrored int // an outcome could not be recorded
}
// SettleOpenRequests settles the connection's accepted requests that are
// older than SettleRequestAge and whose workflow has not recorded an outcome
// (design D27). It runs at the top of FedWiki's startup, before the sync
// check, wherever Temporal is configured:
//
// - NotFound: the process died between commit and start; the request ends
// failed with not_started. It is never restarted.
// - running: nothing; the workflow records its own outcome.
// - closed, a create: the farm call may have succeeded, so the reconcile
// workflow reads the farm's entry and records the outcome.
// - closed, any other operation: the request ends failed with
// retries_exhausted, and the site keeps its observed status.
// - any other error: nothing; the next boot tries again.
func SettleOpenRequests(ctx context.Context, c LifecycleClient, database *sql.DB, connectionID, taskQueue string, now time.Time, logger *slog.Logger) (SettleCounts, error) {
var counts SettleCounts
if connectionID == "" {
return counts, errors.New("settle lifecycle requests: no connection bound")
}
rows, err := integration.AcceptedLifecycleRequestsOlderThan(ctx, database, connectionID, SettleRequestAge)
if err != nil {
return counts, err
}
counts.Seen = len(rows)
finish := func(r integration.LifecycleRequest, reason string) {
if err := integration.FinishLifecycleRequest(ctx, database, integration.LifecycleOutcome{
InstanceID: r.InstanceID,
Generation: r.Generation,
Status: integration.LifecycleFailed,
ReasonCode: reason,
FinishedAt: now,
}); err != nil {
counts.FinishErrored++
logger.Error("could not settle lifecycle request",
slog.String("request_id", r.RequestID), slog.String("reason", reason), slog.Any("error", err))
}
}
for _, r := range rows {
desc, err := c.DescribeWorkflowExecution(ctx, r.WorkflowID, "")
var notFound *serviceerror.NotFound
switch {
case errors.As(err, &notFound):
counts.NotStarted++
finish(r, ReasonNotStarted)
case err != nil:
counts.Unanswered++
logger.Warn("lifecycle request left open: Temporal did not describe its workflow",
slog.String("request_id", r.RequestID), slog.String("workflow_id", r.WorkflowID), slog.Any("error", err))
case desc.GetWorkflowExecutionInfo().GetStatus() == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING:
counts.Running++
case r.Operation == integration.LifecycleCreate:
if _, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: ReconcileCreateWorkflowID(r.InstanceID),
TaskQueue: taskQueue,
}, ReconcileFedWikiSiteCreateWorkflow, ReconcileSiteCreateInput{
ConnectionID: r.ConnectionID,
InstanceID: r.InstanceID,
Generation: r.Generation,
}); err != nil && !AlreadyRunning(err) {
counts.Unanswered++
logger.Warn("could not start the create reconcile",
slog.String("request_id", r.RequestID), slog.Any("error", err))
continue
}
counts.Reconciling++
default:
counts.Exhausted++
finish(r, ReasonRetriesExhausted)
}
}
return counts, nil
}
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
)
// siblingWorkspace adds a second workspace to ws's organization whose active
// primary assignment is ws's pool (resource-pools invariant 7: an
// organization's workspaces draw down one pool) and gives it n active sites.
func siblingWorkspace(t *testing.T, database *sql.DB, ws string, n int, label string) string {
t.Helper()
ctx := context.Background()
var sibling string
if err := database.QueryRowContext(ctx, `
WITH w AS (
INSERT INTO core.workspaces (org_id, name)
SELECT org_id, $2 FROM core.workspaces WHERE workspace_id = $1
RETURNING workspace_id)
INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary, status)
SELECT pa.pool_id, w.workspace_id, TRUE, 'active'
FROM core.pool_assignments pa, w
WHERE pa.workspace_id = $1 AND pa.is_primary
RETURNING workspace_id`, ws, label).Scan(&sibling); err != nil {
t.Fatalf("sibling workspace: %v", err)
}
for i := 0; i < n; i++ {
if _, err := database.ExecContext(ctx,
`INSERT INTO fedwiki.sites (workspace_id, domain, status) VALUES ($1, $2, 'active')`,
sibling, fmt.Sprintf("%s-%d-%d.localtest.me", label, time.Now().UnixNano(), i)); err != nil {
t.Fatalf("sibling site: %v", err)
}
}
return sibling
}
// poolActiveSites counts the active sites of every workspace whose active
// primary assignment is ws's pool.
func poolActiveSites(t *testing.T, database *sql.DB, ws string) int {
t.Helper()
var n int
if err := database.QueryRowContext(context.Background(), `
SELECT count(*) FROM fedwiki.sites s
JOIN core.pool_assignments pa ON pa.workspace_id = s.workspace_id AND pa.is_primary AND pa.status = 'active'
WHERE s.status = 'active' AND pa.pool_id = (
SELECT pool_id FROM core.pool_assignments WHERE workspace_id = $1 AND is_primary AND status = 'active')`,
ws).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// The create quota check's raise-only convergence counts the pool, not the
// workspace (fedwiki-sites "Import understated the counter and a member tries
// to create", for an organization whose workspaces share a pool): an
// understated shared counter is raised to the pool's active rows before it is
// compared, so a pool at its limit across two workspaces refuses the create.
func TestCreateQuotaCountsEveryWorkspaceOfThePool_DB(t *testing.T) {
e := newReqEnv(t, 3, 1)
e.insertSite("own0."+e.root, "active", false)
siblingWorkspace(t, e.db, e.ws, 2, "sibling")
env := e.testEnv()
env.ExecuteWorkflow(CreateFedWikiSiteRequestWorkflow, e.startCreate("fourth"))
out := runOutput(t, env)
if out.Status != "declined" || out.ReasonCode != ReasonSiteLimitReached {
t.Errorf("create in a pool holding 3 of 3 across two workspaces = %+v, want declined %s", out, ReasonSiteLimitReached)
}
if got := poolActiveSites(t, e.db, e.ws); got != 3 {
t.Errorf("pool active sites = %d, want 3", got)
}
if got := e.usage(); got != 3 {
t.Errorf("counter = %d after the quota check, want it raised to the pool's 3", got)
}
}
// The force_reduce sweep parks a pool's surplus across the workspaces that
// share it, and reactivation fills the pool's headroom, not each
// workspace's.
func TestForceReduceSweepCountsThePool_DB(t *testing.T) {
acts, database := newReconcileTestEnv(t)
ctx := context.Background()
ws, _ := setupQuotaFixture(t, database, 3, 5) // 3 active sites, limit 3
sibling := siblingWorkspace(t, database, ws, 2, "sweep") // 2 more in the same pool
governFedwikiLimit(t, database, ws, "force_reduce")
out, err := acts.ReconcileFedWikiQuotaActivity(ctx, ReconcileFedWikiQuotaInput{WorkspaceID: ws})
if err != nil {
t.Fatal(err)
}
if out.Parked != 2 {
t.Errorf("parked = %d, want the pool's surplus of 2", out.Parked)
}
if got := poolActiveSites(t, database, ws); got != 3 {
t.Errorf("pool active sites after the sweep = %d, want the limit of 3", got)
}
if got := usageOf(t, database, ws); got != 3 {
t.Errorf("counter after the sweep = %d, want 3", got)
}
// A second sweep of the same pool, reached from the other workspace, has
// nothing left to park.
if out, err = acts.ReconcileFedWikiQuotaActivity(ctx, ReconcileFedWikiQuotaInput{WorkspaceID: sibling}); err != nil {
t.Fatal(err)
} else if out.Parked != 0 || out.Reactivated != 0 {
t.Errorf("second sweep = %+v, want nothing changed", out)
}
// Raise the limit by one while the shared counter reads one below the
// pool's rows (an understatement the boot repair has not yet corrected):
// the pool has one slot of headroom, which the sweep fills, though the
// counter would admit two increments.
if _, err := database.ExecContext(ctx, `
UPDATE core.numeric_entitlements ne SET resource_limit = 4
FROM core.pool_assignments pa
WHERE pa.pool_id = ne.pool_id AND pa.workspace_id = $1 AND ne.resource_key = 'fedwiki_sites'`, ws); err != nil {
t.Fatalf("raise limit: %v", err)
}
if _, err := database.ExecContext(ctx, `
UPDATE core.numeric_entitlement_usage neu SET current_usage = 2
FROM core.pool_assignments pa
WHERE pa.pool_id = neu.pool_id AND pa.workspace_id = $1 AND pa.is_primary AND neu.resource_key = 'fedwiki_sites'`, ws); err != nil {
t.Fatalf("understate counter: %v", err)
}
out, err = acts.ReconcileFedWikiQuotaActivity(ctx, ReconcileFedWikiQuotaInput{WorkspaceID: ws})
if err != nil {
t.Fatal(err)
}
if out.Reactivated != 1 {
t.Errorf("reactivated = %d with one slot of pool headroom, want 1", out.Reactivated)
}
if got := poolActiveSites(t, database, ws); got != 4 {
t.Errorf("pool active sites after the re-upgrade = %d, want 4", got)
}
}
+197 -5
View File
@@ -8,8 +8,11 @@ import (
"database/sql"
"errors"
"fmt"
"net/http"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
@@ -33,7 +36,10 @@ type SwapActiveSiteOutput struct {
ErrorMessage string
}
// SwapActiveSiteActivity performs the two-step swap. Park first (frees a slot),
// SwapActiveSiteActivity performs the two-step swap for the pre-request
// SwapActiveSiteWorkflow, registered for one release so runs in flight at a
// deploy finish (design D28); request-driven swaps use SwapFarmSitesActivity
// and RecordSwapActivity instead. It performs the two-step swap. Park first (frees a slot),
// then activate (reclaims it). If a step fails partway the periodic reconcile
// converges the workspace back to compliance, so this is best-effort with a
// compensating decrement if the activate PATCH fails after reserving.
@@ -102,6 +108,193 @@ func (a *Activities) SwapActiveSiteActivity(ctx context.Context, input SwapActiv
return &SwapActiveSiteOutput{Success: true}, nil
}
// SwapFarmSitesActivity is the farm half of a request-driven swap
// (SwapFedWikiActiveSiteRequestWorkflow): it re-reads both sites, parks the
// outgoing one read-only and activates the chosen one on the farm, and
// writes nothing locally. If the activation fails it sends the parked site
// back to active, so a retry, or the request's failure, finds both sites on
// the farm as the console still records them. Every step is idempotent, so a
// retried attempt repeats them without changing any count; the local half,
// both statuses and both outcomes, is RecordSwapActivity's single
// transaction. A swap changes no usage count: one site leaves active and one
// enters it.
func (a *Activities) SwapFarmSitesActivity(ctx context.Context, input SwapActiveSiteInput) error {
for _, site := range []struct{ id, domain string }{{input.ParkSiteID, input.ParkDomain}, {input.ActivateSiteID, input.ActivateDomain}} {
if _, err := a.authorizeSite(ctx, site.id, input.WorkspaceID, site.domain); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return temporal.NewNonRetryableApplicationError(
"a site in the swap no longer exists", SiteAuthorizationError, err)
}
return err
}
}
if err := a.applyFarmStatus(ctx, input.ParkDomain, "readonly"); err != nil {
return err
}
if err := a.applyFarmStatus(ctx, input.ActivateDomain, "active"); err != nil {
if compErr := a.applyFarmStatus(ctx, input.ParkDomain, "active"); compErr != nil {
a.Logger.Error("swap: could not send the parked site back to active; the failed swap sends it back",
"domain", input.ParkDomain, "error", compErr)
}
return err
}
return nil
}
// RestoreSwapFarmStatusesActivity runs when a request-driven swap ends
// failed: it sends both sites back, on the farm, to the statuses the console
// still records for them, the activated one first so the farm never holds
// both active. SwapFarmSitesActivity already sends the parked site back when
// an activation is refused; this covers an attempt whose own send-back the
// farm refused too, and an activation whose answer was lost. A site that is
// gone, or that is no longer the request's, is left alone, and so is a site
// the farm answers 404 for: the farm holds no such site, so there is nothing
// to send back. Retried while the farm cannot answer (a transport error, a
// timeout, 408, 429 or a 5xx), so the requests stay Pending until the farm
// matches the console (fedwiki-sites: a failed swap keeps both sites'
// statuses); any other answer is final for its site. A final answer for one
// site does not stop the other's send-back (Open Questions 68): the activity
// fails without a retry once both have been tried, so the swap finishes
// failed instead of waiting for ever on a refusal that will not change.
func (a *Activities) RestoreSwapFarmStatusesActivity(ctx context.Context, input SwapActiveSiteInput) error {
var final error
for _, site := range []struct{ id, domain string }{{input.ActivateSiteID, input.ActivateDomain}, {input.ParkSiteID, input.ParkDomain}} {
row, err := a.authorizeSite(ctx, site.id, input.WorkspaceID, site.domain)
if errors.Is(err, sql.ErrNoRows) || errorType(err) == SiteAuthorizationError {
continue
}
if err != nil {
return err
}
if _, err := a.Client.SetSiteStatus(ctx, site.domain, row.Status); err != nil {
var apiErr *FarmManagerAPIError
if !errors.As(err, &apiErr) {
return fmt.Errorf("farm status change of %s: %w", site.domain, err)
}
msg := fmt.Sprintf("farm status change answered status %d", apiErr.StatusCode)
switch {
case apiErr.StatusCode == http.StatusNotFound:
a.Logger.Warn("swap: the farm holds no such site; nothing to send back", "domain", site.domain)
case farmAnswerIsFinal(apiErr.StatusCode):
a.Logger.Error("swap: the farm refused to take a site back", "domain", site.domain, "status", apiErr.StatusCode)
if final == nil {
final = temporal.NewNonRetryableApplicationError(msg, "FarmManagerAPIError", nil)
}
default:
return errors.New(msg)
}
}
}
return final
}
// farmAnswerIsFinal reports whether a farm answer is a refusal that a retry
// of the same request would meet again: a 4xx other than 408 Request Timeout
// (RFC 9110, section 15.5.9) and 429 Too Many Requests (RFC 6585, section
// 4), a timeout and a rate limit that a later retry can pass.
func farmAnswerIsFinal(status int) bool {
return status >= 400 && status < 500 &&
status != http.StatusRequestTimeout && status != http.StatusTooManyRequests
}
// applyFarmStatus changes one site's status on the farm, classifying the
// farm's answer as ApplyFarmSiteStatusActivity does.
func (a *Activities) applyFarmStatus(ctx context.Context, domain, status string) error {
if _, err := a.Client.SetSiteStatus(ctx, domain, status); err != nil {
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) {
msg := fmt.Sprintf("farm status change answered status %d", apiErr.StatusCode)
if apiErr.IsNonRetryable() {
return temporal.NewNonRetryableApplicationError(msg, "FarmManagerAPIError", nil)
}
return errors.New(msg)
}
return fmt.Errorf("farm status change of %s: %w", domain, err)
}
return nil
}
// RecordSwapInput is a request-driven swap's local half.
type RecordSwapInput struct {
Activate LifecycleRequestRef
Park LifecycleRequestRef
WorkspaceID string // whose pool's slot the swap may take or release
ActivateDomain string
ParkDomain string
FinishedAt time.Time
}
// RecordSwapActivity writes both sites' statuses, the parked site's
// downgrade-parked mark and the cleared mark of the activated one, and
// finishes both requests as succeeded, in one transaction: a retry after a
// lost answer finds both requests finished and writes nothing again
// (fedwiki-sites: a status change writes its status, usage and outcome
// together).
//
// A swap moves no slot when its parked site is still active and its
// activated site is not, which is what the member asked for. The downgrade
// sweep may have parked the one, or reactivated the other, while the farm
// calls ran (it skips a site with an open request only before its own farm
// call), so the transaction locks both rows and moves the slots their
// statuses call for (Open Questions 70): the parked site releases a slot
// only when it is still active, and the activated site takes one only when
// it is not active yet. That take is gated by the pool's limit; when the
// pool is full the activity fails with QuotaExceeded and writes nothing,
// and the workflow sends the farm back and declines both requests.
func (a *Activities) RecordSwapActivity(ctx context.Context, in RecordSwapInput) error {
outcome := func(ref LifecycleRequestRef) integration.LifecycleOutcome {
return integration.LifecycleOutcome{InstanceID: ref.InstanceID, Generation: ref.Generation,
Status: integration.LifecycleSucceeded, FinishedAt: in.FinishedAt}
}
_, err := a.finishAllWith(ctx, []integration.LifecycleOutcome{outcome(in.Activate), outcome(in.Park)}, func(tx *sql.Tx) error {
q := fwmod.New(tx)
parked, err := q.LockSiteStatusByDomain(ctx, in.ParkDomain)
if err != nil {
return fmt.Errorf("swap: lock %s: %w", in.ParkDomain, err)
}
activated, err := q.LockSiteStatusByDomain(ctx, in.ActivateDomain)
if err != nil {
return fmt.Errorf("swap: lock %s: %w", in.ActivateDomain, err)
}
releases := parked.Status == "active"
takes := activated.Status != "active"
entQ := entitlements.New(tx)
switch {
case takes && !releases:
res, err := entQ.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: in.WorkspaceID, ResourceKey: "fedwiki_sites",
})
if err != nil {
return fmt.Errorf("swap: reserve a slot for %s: %w", in.ActivateDomain, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return temporal.NewNonRetryableApplicationError(
"the parked site no longer holds a slot and the pool is at its limit", "QuotaExceeded", nil)
}
case releases && !takes:
if _, err := entQ.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
WorkspaceID: in.WorkspaceID, ResourceKey: "fedwiki_sites",
}); err != nil {
return fmt.Errorf("swap: release the slot of %s: %w", in.ParkDomain, err)
}
}
if _, err := q.SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{Domain: in.ParkDomain, Status: "readonly"}); err != nil {
return fmt.Errorf("swap: park %s locally: %w", in.ParkDomain, err)
}
if err := q.SetForceReducedByDomain(ctx, in.ParkDomain); err != nil {
return fmt.Errorf("swap: mark %s parked: %w", in.ParkDomain, err)
}
if _, err := q.SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{Domain: in.ActivateDomain, Status: "active"}); err != nil {
return fmt.Errorf("swap: activate %s locally: %w", in.ActivateDomain, err)
}
if err := q.ClearForceReducedByDomain(ctx, in.ActivateDomain); err != nil {
return fmt.Errorf("swap: clear the parked mark of %s: %w", in.ActivateDomain, err)
}
return nil
})
return err
}
// SwapActiveSiteWorkflow runs the swap activity (farm I/O + DB) and reports the
// outcome to the caller. The cooldown is enforced by the HTTP handler before
// starting this workflow.
@@ -114,10 +307,9 @@ func SwapActiveSiteWorkflow(ctx workflow.Context, input SwapActiveSiteInput) (*S
var out *SwapActiveSiteOutput
if err := workflow.ExecuteActivity(ctx, activities.SwapActiveSiteActivity, input).Get(ctx, &out); err != nil {
var appErr *temporal.ApplicationError
msg := "We couldn't change which site is active. Please try again later."
if errors.As(err, &appErr) {
msg = appErr.Message()
msg := failureReason(err)
if errorType(err) == "QuotaExceeded" {
msg = ReasonActiveLimitReached
}
return &SwapActiveSiteOutput{Success: false, ErrorMessage: msg}, nil
}
@@ -0,0 +1,307 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"net/http"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// swapRequests records a Keep active swap's two requests as the handler
// does, the activation first.
func (e *reqEnv) swapRequests(activateID, parkID, wf string) (gen, parkGen int64) {
e.t.Helper()
gen = e.record(activateID, integration.LifecycleSetStatus, wf, map[string]any{"status": "active", "swap_park_site_id": parkID})
parkGen = e.record(parkID, integration.LifecycleSetStatus, wf, map[string]any{"status": "readonly", "swap_activate_site_id": activateID})
return gen, parkGen
}
func (e *reqEnv) runSwap(activateID, parkID string, gen, parkGen int64, activate, park string) LifecycleWorkflowOutput {
e.t.Helper()
env := e.testEnv()
env.ExecuteWorkflow(SwapFedWikiActiveSiteRequestWorkflow, SwapSiteRequestInput{
ConnectionID: e.conn, InstanceID: activateID, Generation: gen, ParkInstanceID: parkID, ParkGeneration: parkGen,
WorkspaceID: e.ws, ActivateDomain: activate, ParkDomain: park})
return runOutput(e.t, env)
}
func (e *reqEnv) siteStatus(id string) string {
e.t.Helper()
_, status, _ := e.siteByID(id)
return status
}
// A swap reads both site rows under their locks when it records itself: the
// downgrade sweep may have parked the site the swap parks, or reactivated the
// one it activates, while the swap's farm calls ran. The swap takes a slot
// for its activation only when the parked site no longer holds one, and
// releases the parked site's slot only when the activated site already holds
// one, so the counter keeps the pool's active rows (Open Questions 70).
func TestSwapRecordsWhatTheRowsHold_DB(t *testing.T) {
t.Run("the sweep parks the swap's site in a shared pool", func(t *testing.T) {
e := newReqEnv(t, 2, 3)
governFedwikiLimit(t, e.db, e.ws, "force_reduce")
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
park := "incumbent." + e.root
parkID := e.insertSite(park, "active", false)
e.exec(`UPDATE fedwiki.sites SET last_modified_at = $1 WHERE site_id = $2`, base, parkID)
activate := "reserve." + e.root
activateID := e.insertSite(activate, "readonly", true)
sib := siblingWorkspace(t, e.db, e.ws, 2, "sib")
e.exec(`UPDATE fedwiki.sites SET last_modified_at = $1 WHERE workspace_id = $2`, base.Add(48*time.Hour), sib)
var gen, parkGen int64
fired := false
e.farm.patchAnswer = func(_ int, domain, status string) int {
if !fired && domain == park && status == "readonly" {
// Keep active at the limit records the swap while the
// sweep's park of the same site is at the farm.
fired = true
gen, parkGen = e.swapRequests(activateID, parkID, "swap-sweep-park")
}
return 0
}
if out, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil || out.Parked != 1 {
t.Fatalf("sweep = %+v, %v; want one park", out, err)
}
e.farm.patchAnswer = nil
o := e.runSwap(activateID, parkID, gen, parkGen, activate, park)
if o.Status != integration.LifecycleDeclined || o.ReasonCode != ReasonActiveLimitReached {
t.Errorf("swap = %+v, want declined %s: the pool is at its limit once the sweep parked the site", o, ReasonActiveLimitReached)
}
if p, a := e.siteStatus(parkID), e.siteStatus(activateID); p != "readonly" || a != "readonly" {
t.Errorf("console park=%s activate=%s, want both readonly", p, a)
}
if last := lastFarmStatus(e, activate); last != activate+"=readonly" {
t.Errorf("the farm's last status for the site the swap activated = %q, want readonly", last)
}
e.assertCounterMatchesRows("after the sweep's park and the swap")
if _, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil {
t.Fatal(err)
}
e.assertCounterMatchesRows("after the next sweep")
if err := e.acts.IncrementUsageActivity(e.ctx, IncrementUsageInput{WorkspaceID: e.ws}); err == nil {
t.Errorf("an activation was granted a slot with %d active rows at a limit of 2", poolActiveSites(t, e.db, e.ws))
}
})
t.Run("the sweep parks the swap's site at a limit of 1", func(t *testing.T) {
e := newReqEnv(t, 2, 2)
ids, domains := e.activeSitesAt(2, "s")
activate := "reserve." + e.root
activateID := e.insertSite(activate, "readonly", true)
e.setLimit(1)
governFedwikiLimit(t, e.db, e.ws, "force_reduce")
var gen, parkGen int64
fired := false
e.farm.patchAnswer = func(_ int, domain, status string) int {
if !fired && domain == domains[0] && status == "readonly" {
fired = true
gen, parkGen = e.swapRequests(activateID, ids[0], "swap-sweep-park-1")
}
return 0
}
if _, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil {
t.Fatal(err)
}
e.farm.patchAnswer = nil
if o := e.runSwap(activateID, ids[0], gen, parkGen, activate, domains[0]); o.Status != integration.LifecycleDeclined {
t.Errorf("swap = %+v, want declined", o)
}
e.assertCounterMatchesRows("after the sweep's park and the swap")
if _, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil {
t.Fatal(err)
}
if err := e.acts.IncrementUsageActivity(e.ctx, IncrementUsageInput{WorkspaceID: e.ws}); err == nil {
t.Errorf("an activation passed the limit of 1 with %d active rows", poolActiveSites(t, e.db, e.ws))
}
})
t.Run("the sweep reactivates the swap's site", func(t *testing.T) {
e := newReqEnv(t, 2, 1)
park := "incumbent." + e.root
parkID := e.insertSite(park, "active", false)
activate := "reserve." + e.root
activateID := e.insertSite(activate, "readonly", true)
var gen, parkGen int64
fired := false
e.farm.patchAnswer = func(_ int, domain, status string) int {
if !fired && domain == activate && status == "active" {
fired = true
gen, parkGen = e.swapRequests(activateID, parkID, "swap-sweep-reactivate")
}
return 0
}
if out, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil || out.Reactivated != 1 {
t.Fatalf("sweep = %+v, %v; want one reactivation", out, err)
}
e.farm.patchAnswer = nil
if o := e.runSwap(activateID, parkID, gen, parkGen, activate, park); o.Status != integration.LifecycleSucceeded {
t.Fatalf("swap = %+v", o)
}
if p, a := e.siteStatus(parkID), e.siteStatus(activateID); p != "readonly" || a != "active" {
t.Errorf("console park=%s activate=%s", p, a)
}
e.assertCounterMatchesRows("after the sweep's reactivation and the swap")
})
t.Run("a parked site that left active lets the activation take a free slot", func(t *testing.T) {
e := newReqEnv(t, 2, 1)
park := "incumbent." + e.root
parkID := e.insertSite(park, "active", false)
activate := "reserve." + e.root
activateID := e.insertSite(activate, "readonly", true)
gen, parkGen := e.swapRequests(activateID, parkID, "swap-free-slot")
// Something outside the request released the parked site's slot.
e.exec(`UPDATE fedwiki.sites SET status = 'readonly' WHERE site_id = $1`, parkID)
e.exec(`UPDATE core.numeric_entitlement_usage SET current_usage = 0
WHERE resource_key = 'fedwiki_sites' AND pool_id = (
SELECT pool_id FROM core.pool_assignments WHERE workspace_id = $1 AND is_primary)`, e.ws)
if o := e.runSwap(activateID, parkID, gen, parkGen, activate, park); o.Status != integration.LifecycleSucceeded {
t.Fatalf("swap = %+v", o)
}
if e.siteStatus(activateID) != "active" {
t.Errorf("activated site = %s", e.siteStatus(activateID))
}
e.assertCounterMatchesRows("after a swap that took a slot")
})
}
// The sync leaves a site with an accepted lifecycle request alone, as it
// leaves an open create's name: the request's own record step decides the
// site's status and its slot, and a failed swap's restore then reads the
// statuses the sites had before the swap (Open Questions 71).
func TestSyncLeavesSitesWithOpenRequests_DB(t *testing.T) {
t.Run("a sync between the swap's farm calls and its record", func(t *testing.T) {
e := newReqEnv(t, 2, 2)
park := "zz-incumbent." + e.root
parkID := e.insertSite(park, "active", false)
activate := "aa-reserve." + e.root
activateID := e.insertSite(activate, "readonly", true)
siblingWorkspace(t, e.db, e.ws, 1, "sib")
stub := siblingWorkspace(t, e.db, e.ws, 0, "stub")
gen, parkGen := e.swapRequests(activateID, parkID, "swap-sync")
in := SwapActiveSiteInput{WorkspaceID: e.ws, ParkSiteID: parkID, ActivateSiteID: activateID, ParkDomain: park, ActivateDomain: activate}
if err := e.acts.SwapFarmSitesActivity(e.ctx, in); err != nil {
t.Fatal(err)
}
if _, err := e.acts.SyncSitesToDBActivity(e.ctx, SyncSitesToDBInput{DefaultWorkspaceID: stub, FarmSites: []SiteInfo{
{Name: activate, Status: "active"}, {Name: park, Status: "readonly"}}}); err != nil {
t.Fatal(err)
}
if p, a := e.siteStatus(parkID), e.siteStatus(activateID); p != "active" || a != "readonly" {
t.Errorf("after the sync: console park=%s activate=%s, want the swap's sites left as they were", p, a)
}
if err := e.acts.RecordSwapActivity(e.ctx, RecordSwapInput{
Activate: LifecycleRequestRef{InstanceID: activateID, Generation: gen}, Park: LifecycleRequestRef{InstanceID: parkID, Generation: parkGen},
WorkspaceID: e.ws, ActivateDomain: activate, ParkDomain: park, FinishedAt: time.Now()}); err != nil {
t.Fatal(err)
}
e.assertCounterMatchesRows("after the sync met the swap")
if err := e.acts.IncrementUsageActivity(e.ctx, IncrementUsageInput{WorkspaceID: e.ws}); err == nil {
t.Errorf("an activation was granted a slot with %d active rows at a limit of 2", poolActiveSites(t, e.db, e.ws))
}
})
t.Run("a sync during a failed swap leaves the restore the statuses before it", func(t *testing.T) {
e := newReqEnv(t, 1, 1)
park := "incumbent." + e.root
parkID := e.insertSite(park, "active", false)
activate := "reserve." + e.root
activateID := e.insertSite(activate, "readonly", true)
stub := siblingWorkspace(t, e.db, e.ws, 0, "stub")
synced, restoring := false, false
e.farm.patchAnswer = func(_ int, domain, status string) int {
switch {
case domain == activate && status == "active":
if !synced {
// The farm holds both sites read-only now.
synced = true
if _, err := e.acts.SyncSitesToDBActivity(e.ctx, SyncSitesToDBInput{DefaultWorkspaceID: stub, FarmSites: []SiteInfo{
{Name: activate, Status: "readonly"}, {Name: park, Status: "readonly"}}}); err != nil {
t.Error(err)
}
}
return http.StatusServiceUnavailable
case domain == activate && status == "readonly":
restoring = true
case domain == park && status == "active" && !restoring:
return http.StatusServiceUnavailable
}
return 0
}
gen, parkGen := e.swapRequests(activateID, parkID, "swap-sync-failed")
if o := e.runSwap(activateID, parkID, gen, parkGen, activate, park); o.Status != integration.LifecycleFailed {
t.Fatalf("swap = %+v, want failed", o)
}
if p, a := e.siteStatus(parkID), e.siteStatus(activateID); p != "active" || a != "readonly" {
t.Errorf("console park=%s activate=%s, want active and readonly as before the swap", p, a)
}
if last := lastFarmStatus(e, park); last != park+"=active" {
t.Errorf("the farm's last status for the parked site = %q, want active", last)
}
if u := e.usage(); u != 1 {
t.Errorf("counter = %d, want 1", u)
}
})
t.Run("a sync during a successful swap", func(t *testing.T) {
e := newReqEnv(t, 1, 1)
park := "incumbent." + e.root
parkID := e.insertSite(park, "active", false)
activate := "reserve." + e.root
activateID := e.insertSite(activate, "readonly", true)
stub := siblingWorkspace(t, e.db, e.ws, 0, "stub")
synced := false
e.farm.patchAnswer = func(_ int, domain, status string) int {
if !synced && domain == activate && status == "active" {
synced = true
if _, err := e.acts.SyncSitesToDBActivity(e.ctx, SyncSitesToDBInput{DefaultWorkspaceID: stub, FarmSites: []SiteInfo{
{Name: activate, Status: "readonly"}, {Name: park, Status: "readonly"}}}); err != nil {
t.Error(err)
}
}
return 0
}
gen, parkGen := e.swapRequests(activateID, parkID, "swap-sync-ok")
if o := e.runSwap(activateID, parkID, gen, parkGen, activate, park); o.Status != integration.LifecycleSucceeded {
t.Fatalf("swap = %+v", o)
}
if p, a := e.siteStatus(parkID), e.siteStatus(activateID); p != "readonly" || a != "active" {
t.Errorf("console park=%s activate=%s", p, a)
}
e.assertCounterMatchesRows("after a sync during the swap")
})
t.Run("a sync during a plain activation", func(t *testing.T) {
e := newReqEnv(t, 3, 1)
e.insertSite("kept."+e.root, "active", false)
domain := "woken." + e.root
id := e.insertSite(domain, "readonly", false)
stub := siblingWorkspace(t, e.db, e.ws, 0, "stub")
synced := false
e.farm.patchAnswer = func(_ int, d, status string) int {
if !synced && d == domain && status == "active" {
synced = true
if _, err := e.acts.SyncSitesToDBActivity(e.ctx, SyncSitesToDBInput{DefaultWorkspaceID: stub, FarmSites: []SiteInfo{
{Name: domain, Status: "active"}}}); err != nil {
t.Error(err)
}
}
return 0
}
gen := e.record(id, integration.LifecycleSetStatus, "wf-"+id, map[string]any{"status": "active"})
env := e.testEnv()
env.ExecuteWorkflow(SetFedWikiSiteStatusRequestWorkflow, SetSiteStatusRequestInput{
ConnectionID: e.conn, InstanceID: id, Generation: gen, WorkspaceID: e.ws, Domain: domain,
CurrentStatus: "readonly", TargetStatus: "active"})
if o := runOutput(t, env); o.Status != integration.LifecycleSucceeded {
t.Fatalf("activation = %+v", o)
}
e.assertCounterMatchesRows("after a sync during the activation")
})
}
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// RecordSwapActivity's outcomes commit with its statuses, not before them:
// with the activated site's row held, the activity waits on that site's
// status write while both requests still read accepted from another
// connection. Outcomes committed first would let a status write that fails
// afterwards leave both requests succeeded over the old statuses, with a
// retry that finds them finished and writes nothing. The reverse order,
// statuses visible before the outcomes, is
// TestRecordSwapWritesStatusesWithItsOutcomes_DB.
func TestRecordSwapCommitsItsOutcomesWithItsStatuses_DB(t *testing.T) {
e := newReqEnv(t, 1, 1)
park := "incumbent." + e.root
activate := "reserve." + e.root
parkID := e.insertSite(park, "active", false)
activateID := e.insertSite(activate, "readonly", true)
gen := e.record(activateID, integration.LifecycleSetStatus, "swap-tx-out", map[string]any{"status": "active"})
parkGen := e.record(parkID, integration.LifecycleSetStatus, "swap-tx-out", map[string]any{"status": "readonly", "swap": activateID})
ctx, cancel := context.WithTimeout(e.ctx, 30*time.Second)
defer cancel()
holder, err := e.db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = holder.Rollback() }()
var holderPID int
if err := holder.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&holderPID); err != nil {
t.Fatal(err)
}
if _, err := holder.ExecContext(ctx, `SELECT 1 FROM fedwiki.sites WHERE site_id = $1 FOR UPDATE`, activateID); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- e.acts.RecordSwapActivity(ctx, RecordSwapInput{
Activate: LifecycleRequestRef{InstanceID: activateID, Generation: gen},
Park: LifecycleRequestRef{InstanceID: parkID, Generation: parkGen},
ActivateDomain: activate, ParkDomain: park, FinishedAt: time.Now(),
})
}()
for {
var n int
if err := e.db.QueryRowContext(ctx,
`SELECT count(*) FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid))`, holderPID).Scan(&n); err != nil {
t.Fatal(err)
}
if n > 0 {
break
}
select {
case err := <-done:
t.Fatalf("the record finished without writing the activated site's status: %v", err)
case <-ctx.Done():
t.Fatal("the record never waited on the activated site's row")
case <-time.After(5 * time.Millisecond):
}
}
for _, ref := range []LifecycleRequestRef{{activateID, gen}, {parkID, parkGen}} {
if status, _ := e.request(ref.InstanceID, ref.Generation); status != integration.LifecycleAccepted {
t.Errorf("request on %s = %s while the swap's status write waits, want accepted until the statuses commit with it", ref.InstanceID, status)
}
}
if err := holder.Rollback(); err != nil {
t.Fatal(err)
}
if err := <-done; err != nil {
t.Fatalf("record swap: %v", err)
}
for _, ref := range []LifecycleRequestRef{{activateID, gen}, {parkID, parkGen}} {
if status, _ := e.request(ref.InstanceID, ref.Generation); status != integration.LifecycleSucceeded {
t.Errorf("request on %s = %s, want succeeded", ref.InstanceID, status)
}
}
if status, fr := siteState(t, e.db, activate); status != "active" || fr {
t.Errorf("activated site = %s, force-reduced %v", status, fr)
}
}
@@ -0,0 +1,207 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"errors"
"net/http"
"slices"
"strings"
"testing"
"go.temporal.io/sdk/temporal"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// The failed swap's restore retries only while the farm cannot answer: a
// 404 for a site means the farm holds nothing to send back, and any other
// definite refusal ends the swap failed, so neither keeps both requests
// Pending for ever (Open Questions 61, 62).
func TestSwapRestoreTakesTheFarmsDefiniteAnswer_DB(t *testing.T) {
t.Run("the activity classifies each answer", func(t *testing.T) {
for _, tc := range []struct {
answer int
wantErr bool
nonRetryable bool
}{
{http.StatusNotFound, false, false},
{http.StatusBadRequest, true, true},
{http.StatusForbidden, true, true},
{http.StatusUnprocessableEntity, true, true},
{http.StatusRequestTimeout, true, false},
{http.StatusTooManyRequests, true, false},
{http.StatusServiceUnavailable, true, false},
} {
e := newReqEnv(t, 1, 1)
park := "incumbent." + e.root
activate := "reserve." + e.root
parkID := e.insertSite(park, "active", false)
activateID := e.insertSite(activate, "readonly", true)
answer := tc.answer
e.farm.patchAnswer = func(_ int, domain, _ string) int {
if domain == activate {
return answer
}
return 0
}
err := e.acts.RestoreSwapFarmStatusesActivity(e.ctx, SwapActiveSiteInput{
WorkspaceID: e.ws, ParkSiteID: parkID, ActivateSiteID: activateID, ParkDomain: park, ActivateDomain: activate})
var appErr *temporal.ApplicationError
nonRetryable := errors.As(err, &appErr) && appErr.NonRetryable()
if (err != nil) != tc.wantErr || nonRetryable != tc.nonRetryable {
t.Errorf("farm answers %d: error %v (non-retryable %v), want error %v, non-retryable %v",
tc.answer, err, nonRetryable, tc.wantErr, tc.nonRetryable)
}
if tc.answer == http.StatusNotFound || tc.nonRetryable {
// A definite answer for the activated site still sends the
// parked one back (Open Questions 68).
e.farm.mu.Lock()
sent := slices.Contains(e.farm.patchLog, park+"=active")
e.farm.mu.Unlock()
if !sent {
t.Errorf("after a %d for the activated site the parked site was not sent back to active", tc.answer)
}
}
}
})
t.Run("a site the farm does not hold ends the swap", func(t *testing.T) {
// The console lists the site to activate, the farm has no such site:
// every PATCH of it answers 404.
e := newReqEnv(t, 1, 1)
park := "incumbent." + e.root
activate := "reserve." + e.root
parkID := e.insertSite(park, "active", false)
activateID := e.insertSite(activate, "readonly", true)
restores := 0
e.farm.patchAnswer = func(_ int, domain, status string) int {
if domain != activate {
return 0
}
if status == "readonly" {
restores++
}
return http.StatusNotFound
}
gen := e.record(activateID, integration.LifecycleSetStatus, "swap-gone", map[string]any{"status": "active"})
parkGen := e.record(parkID, integration.LifecycleSetStatus, "swap-gone", map[string]any{"status": "readonly", "swap": activateID})
env := e.testEnv()
env.ExecuteWorkflow(SwapFedWikiActiveSiteRequestWorkflow, SwapSiteRequestInput{
ConnectionID: e.conn, InstanceID: activateID, Generation: gen, ParkInstanceID: parkID, ParkGeneration: parkGen,
WorkspaceID: e.ws, ActivateDomain: activate, ParkDomain: park})
if out := runOutput(t, env); out.Status != "failed" {
t.Fatalf("output = %+v, want failed", out)
}
if restores != 1 {
t.Errorf("the restore PATCHed the site the farm does not hold %d times, want once", restores)
}
for _, id := range []string{activateID, parkID} {
g := gen
if id == parkID {
g = parkGen
}
if status, _ := e.request(id, g); status != "failed" {
t.Errorf("request on %s = %s, want failed", id, status)
}
}
e.farm.mu.Lock()
log := slices.Clone(e.farm.patchLog)
e.farm.mu.Unlock()
last := ""
for _, p := range log {
if strings.HasPrefix(p, park+"=") {
last = p
}
}
if last != park+"=active" {
t.Errorf("the farm's last status for the parked site = %q, want active (PATCHes %v)", last, log)
}
})
t.Run("a refusal for the activated site still sends the parked site back", func(t *testing.T) {
// The farm refuses every PATCH of the site to activate, and the
// swap's own send-back of the parked site meets an outage.
e := newReqEnv(t, 1, 1)
park := "incumbent." + e.root
activate := "reserve." + e.root
parkID := e.insertSite(park, "active", false)
activateID := e.insertSite(activate, "readonly", true)
sendBacks := 0
e.farm.patchAnswer = func(_ int, domain, status string) int {
switch {
case domain == activate:
return http.StatusForbidden
case status == "active":
sendBacks++
if sendBacks == 1 {
return http.StatusServiceUnavailable
}
}
return 0
}
gen := e.record(activateID, integration.LifecycleSetStatus, "swap-refused", map[string]any{"status": "active"})
parkGen := e.record(parkID, integration.LifecycleSetStatus, "swap-refused", map[string]any{"status": "readonly", "swap": activateID})
env := e.testEnv()
env.ExecuteWorkflow(SwapFedWikiActiveSiteRequestWorkflow, SwapSiteRequestInput{
ConnectionID: e.conn, InstanceID: activateID, Generation: gen, ParkInstanceID: parkID, ParkGeneration: parkGen,
WorkspaceID: e.ws, ActivateDomain: activate, ParkDomain: park})
if out := runOutput(t, env); out.Status != "failed" {
t.Fatalf("output = %+v, want failed", out)
}
if last := lastFarmStatus(e, park); last != park+"=active" {
t.Errorf("the farm's last status for the parked site = %q while the console records active (send-backs tried %d)", last, sendBacks)
}
})
t.Run("the restore outlasts the forward attempts' limit", func(t *testing.T) {
// The activation meets an outage on every attempt, and so do the
// first 17 send-backs of the parked site: the forward activity's 8
// attempts and 9 of the restore's. The restore has no attempt limit
// (Open Questions 61), so its tenth attempt sends the site back.
e := newReqEnv(t, 1, 1)
park := "incumbent." + e.root
activate := "reserve." + e.root
parkID := e.insertSite(park, "active", false)
activateID := e.insertSite(activate, "readonly", true)
sendBacks := 0
e.farm.patchAnswer = func(_ int, domain, status string) int {
switch {
case domain == activate && status == "active":
return http.StatusServiceUnavailable
case domain == park && status == "active":
sendBacks++
if sendBacks <= 17 {
return http.StatusServiceUnavailable
}
}
return 0
}
gen := e.record(activateID, integration.LifecycleSetStatus, "swap-outage", map[string]any{"status": "active"})
parkGen := e.record(parkID, integration.LifecycleSetStatus, "swap-outage", map[string]any{"status": "readonly", "swap": activateID})
env := e.testEnv()
env.ExecuteWorkflow(SwapFedWikiActiveSiteRequestWorkflow, SwapSiteRequestInput{
ConnectionID: e.conn, InstanceID: activateID, Generation: gen, ParkInstanceID: parkID, ParkGeneration: parkGen,
WorkspaceID: e.ws, ActivateDomain: activate, ParkDomain: park})
if out := runOutput(t, env); out.Status != "failed" {
t.Fatalf("output = %+v, want failed", out)
}
if last := lastFarmStatus(e, park); last != park+"=active" {
t.Errorf("the restore gave up before the farm took the parked site back: last status %q after %d send-backs", last, sendBacks)
}
})
}
// lastFarmStatus is the last "domain=status" the farm answered 200 for.
func lastFarmStatus(e *reqEnv, domain string) string {
e.farm.mu.Lock()
defer e.farm.mu.Unlock()
last := ""
for _, p := range e.farm.patchLog {
if strings.HasPrefix(p, domain+"=") {
last = p
}
}
return last
}
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// activeSitesAt inserts n active sites, oldest first by last activity, and
// returns their ids and domains in that order.
func (e *reqEnv) activeSitesAt(n int, label string) (ids, domains []string) {
e.t.Helper()
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
for i := 0; i < n; i++ {
domain := label + string(rune('a'+i)) + "." + e.root
id := e.insertSite(domain, "active", false)
e.exec(`UPDATE fedwiki.sites SET last_modified_at = $1 WHERE site_id = $2`, base.Add(time.Duration(i)*time.Hour), id)
ids, domains = append(ids, id), append(domains, domain)
}
return ids, domains
}
func (e *reqEnv) setLimit(limit int) {
e.t.Helper()
e.exec(`UPDATE core.numeric_entitlements SET resource_limit = $2
WHERE resource_key = 'fedwiki_sites' AND pool_id = (
SELECT pool_id FROM core.pool_assignments WHERE workspace_id = $1 AND is_primary)`, e.ws, limit)
}
// assertCounterMatchesRows checks that the pool's counter equals its active
// rows, which is what every slot change leaves when no request is open.
func (e *reqEnv) assertCounterMatchesRows(when string) {
e.t.Helper()
if rows, u := poolActiveSites(e.t, e.db, e.ws), e.usage(); int64(rows) != u {
e.t.Errorf("%s: counter = %d with %d active rows", when, u, rows)
}
}
// A slot is released once whichever of the downgrade sweep and a member's
// request takes the site out of active first: each release happens only
// where the release's own transaction finds the site row still active
// (Open Questions 66), and the sweep leaves a site with an accepted request
// alone.
func TestSweepAndRequestsReleaseEachSlotOnce_DB(t *testing.T) {
t.Run("archive requested before the sweep ran", func(t *testing.T) {
e := newReqEnv(t, 2, 3)
ids, domains := e.activeSitesAt(3, "s")
governFedwikiLimit(t, e.db, e.ws, "force_reduce")
gen := e.record(ids[0], integration.LifecycleSetStatus, "wf-"+ids[0], map[string]any{"status": "archived"})
if _, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil {
t.Fatal(err)
}
env := e.testEnv()
env.ExecuteWorkflow(SetFedWikiSiteStatusRequestWorkflow, SetSiteStatusRequestInput{
ConnectionID: e.conn, InstanceID: ids[0], Generation: gen, WorkspaceID: e.ws, Domain: domains[0],
CurrentStatus: "active", TargetStatus: "archived"})
if out := runOutput(t, env); out.Status != "succeeded" {
t.Errorf("output = %+v", out)
}
e.assertCounterMatchesRows("after the sweep and the archive")
if err := e.acts.IncrementUsageActivity(e.ctx, IncrementUsageInput{WorkspaceID: e.ws}); err == nil {
t.Errorf("an activation passed the limit of 2 with %d active rows", poolActiveSites(t, e.db, e.ws))
}
})
t.Run("status change after the site left active another way", func(t *testing.T) {
e := newReqEnv(t, 3, 2)
ids, domains := e.activeSitesAt(2, "c")
gen := e.record(ids[0], integration.LifecycleSetStatus, "wf-"+ids[0], map[string]any{"status": "archived"})
// A park that committed after the handler read the site as active.
e.exec(`UPDATE fedwiki.sites SET status = 'readonly', force_reduced_at = NOW() WHERE site_id = $1`, ids[0])
e.exec(`UPDATE core.numeric_entitlement_usage SET current_usage = current_usage - 1
WHERE pool_id = (SELECT pool_id FROM core.pool_assignments WHERE workspace_id = $1 AND is_primary)`, e.ws)
env := e.testEnv()
env.ExecuteWorkflow(SetFedWikiSiteStatusRequestWorkflow, SetSiteStatusRequestInput{
ConnectionID: e.conn, InstanceID: ids[0], Generation: gen, WorkspaceID: e.ws, Domain: domains[0],
CurrentStatus: "active", TargetStatus: "archived"})
if out := runOutput(t, env); out.Status != "succeeded" {
t.Errorf("output = %+v", out)
}
e.assertCounterMatchesRows("after the archive of a parked site")
})
t.Run("delete after the site left active another way", func(t *testing.T) {
e := newReqEnv(t, 3, 2)
ids, domains := e.activeSitesAt(2, "d")
gen := e.record(ids[0], integration.LifecycleDelete, DeleteRequestWorkflowID(ids[0], 1), map[string]any{})
e.exec(`UPDATE fedwiki.sites SET status = 'readonly', force_reduced_at = NOW() WHERE site_id = $1`, ids[0])
e.exec(`UPDATE core.numeric_entitlement_usage SET current_usage = current_usage - 1
WHERE pool_id = (SELECT pool_id FROM core.pool_assignments WHERE workspace_id = $1 AND is_primary)`, e.ws)
env := e.testEnv()
env.ExecuteWorkflow(DeleteFedWikiSiteRequestWorkflow, DeleteSiteRequestInput{
ConnectionID: e.conn, InstanceID: ids[0], Generation: gen, WorkspaceID: e.ws, Domain: domains[0]})
if out := runOutput(t, env); out.Status != "succeeded" {
t.Errorf("output = %+v", out)
}
e.assertCounterMatchesRows("after the delete of a parked site")
})
t.Run("park meets an archive committed during its farm call", func(t *testing.T) {
e := newReqEnv(t, 2, 3)
ids, domains := e.activeSitesAt(3, "p")
governFedwikiLimit(t, e.db, e.ws, "force_reduce")
e.farm.patchAnswer = func(_ int, domain, status string) int {
if domain == domains[0] && status == "readonly" {
// A member's archive recorded after the sweep listed the
// site, finishing while the sweep's PATCH is in flight.
e.exec(`UPDATE fedwiki.sites SET status = 'archived', archived_at = NOW() WHERE site_id = $1`, ids[0])
e.exec(`UPDATE core.numeric_entitlement_usage SET current_usage = current_usage - 1
WHERE pool_id = (SELECT pool_id FROM core.pool_assignments WHERE workspace_id = $1 AND is_primary)`, e.ws)
}
return 0
}
if _, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil {
t.Fatal(err)
}
if status, _ := siteState(t, e.db, domains[0]); status != "archived" {
t.Errorf("the sweep wrote %q over the member's archive", status)
}
e.assertCounterMatchesRows("after the park met the archive")
})
t.Run("reactivation meets an archive committed during its farm call", func(t *testing.T) {
e := newReqEnv(t, 3, 1)
e.activeSitesAt(1, "r")
parked := "parked." + e.root
id := e.insertSite(parked, "readonly", true)
e.farm.patchAnswer = func(_ int, domain, status string) int {
if domain == parked && status == "active" {
e.exec(`UPDATE fedwiki.sites SET status = 'archived', archived_at = NOW() WHERE site_id = $1`, id)
}
return 0
}
if _, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil {
t.Fatal(err)
}
if status, _ := siteState(t, e.db, parked); status != "archived" {
t.Errorf("the sweep wrote %q over the member's archive", status)
}
e.assertCounterMatchesRows("after the reactivation met the archive")
})
}
// The re-upgrade sweep reactivates only sites a downgrade left read-only:
// a parked site the member archived since stays in the trash, and its
// archive clears the mark (Open Questions 67).
func TestReupgradeLeavesArchivedParkedSites_DB(t *testing.T) {
t.Run("archived through a request", func(t *testing.T) {
e := newReqEnv(t, 3, 3)
ids, domains := e.activeSitesAt(3, "u")
governFedwikiLimit(t, e.db, e.ws, "force_reduce")
e.setLimit(2)
if out, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil || out.Parked != 1 {
t.Fatalf("downgrade sweep = %+v, %v; want 1 parked", out, err)
}
gen := e.record(ids[0], integration.LifecycleSetStatus, "wf-"+ids[0], map[string]any{"status": "archived"})
env := e.testEnv()
env.ExecuteWorkflow(SetFedWikiSiteStatusRequestWorkflow, SetSiteStatusRequestInput{
ConnectionID: e.conn, InstanceID: ids[0], Generation: gen, WorkspaceID: e.ws, Domain: domains[0],
CurrentStatus: "readonly", TargetStatus: "archived"})
if out := runOutput(t, env); out.Status != "succeeded" {
t.Fatalf("archive = %+v", out)
}
if _, marked := siteState(t, e.db, domains[0]); marked {
t.Error("the archive kept the downgrade mark")
}
e.setLimit(3)
if out, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil || out.Reactivated != 0 {
t.Errorf("re-upgrade sweep = %+v, %v; want nothing reactivated", out, err)
}
if status, _ := siteState(t, e.db, domains[0]); status != "archived" {
t.Errorf("the archived parked site is now %q", status)
}
e.assertCounterMatchesRows("after the re-upgrade")
})
t.Run("archived with its mark left in place", func(t *testing.T) {
e := newReqEnv(t, 3, 1)
e.activeSitesAt(1, "m")
trashed := "trashed." + e.root
e.insertSite(trashed, "archived", true)
if out, err := e.acts.ReconcileFedWikiQuotaActivity(e.ctx, ReconcileFedWikiQuotaInput{WorkspaceID: e.ws}); err != nil || out.Reactivated != 0 {
t.Errorf("re-upgrade sweep = %+v, %v; want nothing reactivated", out, err)
}
if status, _ := siteState(t, e.db, trashed); status != "archived" {
t.Errorf("the archived marked site is now %q", status)
}
})
}
@@ -232,6 +232,25 @@ func (a *Activities) SyncSitesToDBActivity(ctx context.Context, input SyncSitesT
for _, site := range input.FarmSites {
farmDomainMap[site.Name] = site
}
// A farm site that an `accepted` lifecycle request acts on belongs to
// that request. An open create's workflow inserts the row under the
// request's instance and the member's workspace: projecting it here first
// would file it under the System workspace with a new id, and the
// create's own insert would then fail on the domain. A status change, a
// swap or a delete writes the site's status and its slot in its own
// record step, from the status it finds under the row lock; adopting the
// farm's status here in the middle of it would move the slot a second
// time, and would leave a failed swap's restore the farm's transient
// status to send back (Open Questions 71).
openDomains, err := a.SiteQ.ListDomainsWithOpenRequests(ctx)
if err != nil {
a.Logger.Error("failed to list domains of open lifecycle requests", slog.Any("error", err))
return nil, fmt.Errorf("failed to list domains of open lifecycle requests: %w", err)
}
openRequest := make(map[string]bool, len(openDomains))
for _, domain := range openDomains {
openRequest[domain] = true
}
sitesAdded := 0
sitesUpdated := 0
@@ -246,6 +265,9 @@ func (a *Activities) SyncSitesToDBActivity(ctx context.Context, input SyncSitesT
// Project every farm site: insert new (default workspace) or update the
// observed status/storage/last-modified on an existing row.
for _, farmSite := range input.FarmSites {
if openRequest[farmSite.Name] {
continue
}
status := normalizeFarmStatus(farmSite.Status)
var storage sql.NullInt64
if farmSite.StorageBytes > 0 {
@@ -224,3 +224,75 @@ func TestSyncRestoresServabilityOnUnarchive(t *testing.T) {
t.Errorf("placement for %s is still unservable after the farm reactivated it", name)
}
}
// TestSyncSkipsAFarmSiteAnOpenCreateHolds covers the sync's half of design
// D30 (integration-delivery-core task 3.6): a farm site whose name is the
// domain of an `accepted` create request is left to that create's workflow,
// so the sync neither files it under the System workspace nor gives it an id
// of its own. Once the request has finished, the sync projects the farm site
// like any other.
func TestSyncSkipsAFarmSiteAnOpenCreateHolds(t *testing.T) {
acts, database := newReconcileTestEnv(t)
ctx := context.Background()
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
systemWS, err := systemtenant.Ensure(ctx, database)
if err != nil {
t.Fatalf("ensure system tenant: %v", err)
}
memberWS, _ := setupQuotaFixture(t, database, 5, 0)
// FedWiki's provider and connection, as boot registration leaves them.
// Either may already exist in the shared test database.
exec := func(q string, args ...any) {
t.Helper()
if _, err := database.ExecContext(ctx, q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
exec(`INSERT INTO core.providers (provider, provider_kind, display_name) VALUES ('fedwiki', 'provisioning', 'FedWiki')
ON CONFLICT (provider) DO NOTHING`)
exec(`INSERT INTO core.provider_operations (provider, operation) VALUES ('fedwiki', 'create') ON CONFLICT DO NOTHING`)
exec(`INSERT INTO core.provider_connections (provider, display_name, approval_state, approved_at)
VALUES ('fedwiki', 'FedWiki', 'approved', NOW()) ON CONFLICT (provider) DO NOTHING`)
held := "held" + uniq + ".localtest.me"
instance := uuid.New().String()
exec(`INSERT INTO core.lifecycle_requests
(instance_id, generation, connection_id, resource_key, workspace_id, operation, parameters, workflow_id)
SELECT $1::uuid, core.next_generation(NULL, NULL), pc.connection_id, 'fedwiki_sites', $2, 'create',
jsonb_build_object('domain', $3::text), 'create-fedwiki-site-' || $1::text
FROM core.provider_connections pc WHERE pc.provider = 'fedwiki'`,
instance, memberWS, held)
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(), `DELETE FROM core.lifecycle_requests WHERE instance_id = $1`, instance)
})
sync := func() {
t.Helper()
if _, err := acts.SyncSitesToDBActivity(ctx, SyncSitesToDBInput{
DefaultWorkspaceID: systemWS,
FarmSites: []SiteInfo{{Name: held, Status: "active"}},
}); err != nil {
t.Fatalf("sync: %v", err)
}
}
sync()
if siteExists(t, database, held) {
t.Fatalf("the sync projected %s while a create request for it is accepted", held)
}
// The create failed: the name is no longer held, and the sync projects
// the farm site under the default workspace.
exec(`UPDATE core.lifecycle_requests SET status = 'failed', reason_code = 'retries_exhausted', finished_at = NOW()
WHERE instance_id = $1`, instance)
sync()
var ws string
if err := database.QueryRowContext(ctx, `SELECT workspace_id FROM fedwiki.sites WHERE domain = $1`, held).Scan(&ws); err != nil {
t.Fatalf("after the create finished, %s was not projected: %v", held, err)
}
if ws != systemWS {
t.Errorf("%s projected under workspace %s, want the default %s", held, ws, systemWS)
}
}
@@ -0,0 +1,925 @@
{
"events": [
{
"eventId": "1",
"eventTime": "2026-10-05T22:05:20.415461572Z",
"eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED",
"taskId": "1049797",
"workflowExecutionStartedEventAttributes": {
"workflowType": {
"name": "CreateFedWikiSiteRequestWorkflow"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJDb25uZWN0aW9uSUQiOiJjb25uZWN0aW9uLXJlcGxheSIsIkluc3RhbmNlSUQiOiJpbnN0YW5jZS1zbG93IiwiR2VuZXJhdGlvbiI6MSwiV29ya3NwYWNlSUQiOiJ3b3Jrc3BhY2UtcmVwbGF5IiwiRG9tYWluIjoic2xvdy5leGFtcGxlLnRlc3QiLCJPd25lck5hbWUiOiJvd25lciIsIk93bmVySUQiOiJzdWJqZWN0In0="
}
]
},
"workflowExecutionTimeout": "0s",
"workflowRunTimeout": "0s",
"workflowTaskTimeout": "10s",
"originalExecutionRunId": "01a10e19-969f-7708-9699-fa9b0666946e",
"identity": "replay-recorder",
"firstExecutionRunId": "01a10e19-969f-7708-9699-fa9b0666946e",
"attempt": 1,
"firstWorkflowTaskBackoff": "0s",
"header": {},
"workflowId": "replay-new-create-held-after-wait-e88aa52d-af14-4540-b4f1-3788271137ef"
}
},
{
"eventId": "2",
"eventTime": "2026-10-05T22:05:20.415501297Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049798",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "3",
"eventTime": "2026-10-05T22:05:20.417206302Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049803",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "2",
"identity": "replay-recorder",
"requestId": "70bd5b85-f7bd-468b-958c-0a2b4fe9d5f4",
"historySizeBytes": "623",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "4",
"eventTime": "2026-10-05T22:05:20.418886289Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049807",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "2",
"startedEventId": "3",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {
"langUsedFlags": [
3
],
"sdkName": "temporal-go",
"sdkVersion": "1.38.0"
},
"meteringMetadata": {}
}
},
{
"eventId": "5",
"eventTime": "2026-10-05T22:05:20.418922397Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049808",
"activityTaskScheduledEventAttributes": {
"activityId": "5",
"activityType": {
"name": "CheckQuotaActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJXb3Jrc3BhY2VJRCI6IndvcmtzcGFjZS1yZXBsYXkifQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "30s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "4",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "30s",
"maximumAttempts": 3
},
"useWorkflowBuildId": true
}
},
{
"eventId": "6",
"eventTime": "2026-10-05T22:05:20.419968690Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049814",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "5",
"identity": "replay-recorder",
"requestId": "8b2f1f71-592b-4ed0-9e79-00dcdda5f5e6",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "7",
"eventTime": "2026-10-05T22:05:20.421958090Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1049815",
"activityTaskCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJDdXJyZW50Q291bnQiOjAsIlF1b3RhIjozLCJDYW5DcmVhdGUiOnRydWV9"
}
]
},
"scheduledEventId": "5",
"startedEventId": "6",
"identity": "replay-recorder"
}
},
{
"eventId": "8",
"eventTime": "2026-10-05T22:05:20.421963440Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049816",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "9",
"eventTime": "2026-10-05T22:05:20.422914072Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049820",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "8",
"identity": "replay-recorder",
"requestId": "b3d0acf0-d9e9-4d1d-8989-a74031928a4d",
"historySizeBytes": "1459",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "10",
"eventTime": "2026-10-05T22:05:20.424209134Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049824",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "8",
"startedEventId": "9",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "11",
"eventTime": "2026-10-05T22:05:20.424238049Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049825",
"activityTaskScheduledEventAttributes": {
"activityId": "11",
"activityType": {
"name": "IncrementUsageActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJXb3Jrc3BhY2VJRCI6IndvcmtzcGFjZS1yZXBsYXkifQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "30s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "10",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "30s",
"maximumAttempts": 3
},
"useWorkflowBuildId": true
}
},
{
"eventId": "12",
"eventTime": "2026-10-05T22:05:20.425405740Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049830",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "11",
"identity": "replay-recorder",
"requestId": "e2aab199-0c3a-4ddf-804c-313a06b03c9e",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "13",
"eventTime": "2026-10-05T22:05:20.426468734Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1049831",
"activityTaskCompletedEventAttributes": {
"scheduledEventId": "11",
"startedEventId": "12",
"identity": "replay-recorder"
}
},
{
"eventId": "14",
"eventTime": "2026-10-05T22:05:20.426472141Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049832",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "15",
"eventTime": "2026-10-05T22:05:20.427282037Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049836",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "14",
"identity": "replay-recorder",
"requestId": "0e54111b-c3a1-4f59-8319-a2fc3ab6f978",
"historySizeBytes": "2201",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "16",
"eventTime": "2026-10-05T22:05:20.428595413Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049840",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "14",
"startedEventId": "15",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "17",
"eventTime": "2026-10-05T22:05:20.428616253Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049841",
"activityTaskScheduledEventAttributes": {
"activityId": "17",
"activityType": {
"name": "AllocateDomainActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJTaXRlSUQiOiJpbnN0YW5jZS1zbG93IiwiV29ya3NwYWNlSUQiOiJ3b3Jrc3BhY2UtcmVwbGF5IiwiRG9tYWluIjoiIiwiU2l0ZURvbWFpbiI6IiIsIklzQ3VzdG9tRG9tYWluIjpmYWxzZSwiRlFETiI6InNsb3cuZXhhbXBsZS50ZXN0In0="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "30s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "16",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "30s",
"maximumAttempts": 3
},
"useWorkflowBuildId": true
}
},
{
"eventId": "18",
"eventTime": "2026-10-05T22:05:20.429444284Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049846",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "17",
"identity": "replay-recorder",
"requestId": "043837d3-0cb0-44a0-8605-dea199735a50",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "19",
"eventTime": "2026-10-05T22:05:20.430523238Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1049847",
"activityTaskCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJTaXRlSUQiOiJpbnN0YW5jZS1zbG93IiwiRG9tYWluIjoiLiJ9"
}
]
},
"scheduledEventId": "17",
"startedEventId": "18",
"identity": "replay-recorder"
}
},
{
"eventId": "20",
"eventTime": "2026-10-05T22:05:20.430527176Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049848",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "21",
"eventTime": "2026-10-05T22:05:20.431348584Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049852",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "20",
"identity": "replay-recorder",
"requestId": "99aea368-42b6-43d0-b8c4-6ea9033f64b2",
"historySizeBytes": "3118",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "22",
"eventTime": "2026-10-05T22:05:20.432574135Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049856",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "20",
"startedEventId": "21",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "23",
"eventTime": "2026-10-05T22:05:20.432602749Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049857",
"activityTaskScheduledEventAttributes": {
"activityId": "23",
"activityType": {
"name": "CreateFarmSiteActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJEb21haW4iOiJzbG93LmV4YW1wbGUudGVzdCIsIk93bmVyTmFtZSI6Im93bmVyIiwiT3duZXJJRCI6InN1YmplY3QifQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "300s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "22",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "300s",
"maximumAttempts": 8,
"nonRetryableErrorTypes": [
"FarmNameHeld",
"FarmNameRefused",
"FarmCreateRefused",
"ConfigurationError"
]
},
"useWorkflowBuildId": true
}
},
{
"eventId": "24",
"eventTime": "2026-10-05T22:05:20.433838097Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049862",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "23",
"identity": "replay-recorder",
"requestId": "6919f6f9-abbf-4009-9701-fef69c874be2",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "25",
"eventTime": "2026-10-05T22:05:20.435034102Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_FAILED",
"taskId": "1049863",
"activityTaskFailedEventAttributes": {
"failure": {
"message": "farm create answered status 409",
"source": "GoSDK",
"applicationFailureInfo": {
"type": "FarmNameHeld",
"nonRetryable": true
}
},
"scheduledEventId": "23",
"startedEventId": "24",
"identity": "replay-recorder",
"retryState": "RETRY_STATE_NON_RETRYABLE_FAILURE"
}
},
{
"eventId": "26",
"eventTime": "2026-10-05T22:05:20.435038440Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049864",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "27",
"eventTime": "2026-10-05T22:05:20.436070887Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049868",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "26",
"identity": "replay-recorder",
"requestId": "c6a9e982-cf49-458c-a1f1-8015d58ee420",
"historySizeBytes": "4030",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "28",
"eventTime": "2026-10-05T22:05:20.437485334Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049872",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "26",
"startedEventId": "27",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "29",
"eventTime": "2026-10-05T22:05:20.437509159Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049873",
"activityTaskScheduledEventAttributes": {
"activityId": "29",
"activityType": {
"name": "ReadFarmSiteActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJEb21haW4iOiJzbG93LmV4YW1wbGUudGVzdCIsIk93bmVySUQiOiJzdWJqZWN0IiwiSW5zdGFuY2VJRCI6Imluc3RhbmNlLXNsb3ciLCJHZW5lcmF0aW9uIjoxfQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "60s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "28",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "300s"
},
"useWorkflowBuildId": true
}
},
{
"eventId": "30",
"eventTime": "2026-10-05T22:05:20.438569407Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049878",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "29",
"identity": "replay-recorder",
"requestId": "7f3c5bd2-cae8-4628-8092-f00e187d86c4",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "31",
"eventTime": "2026-10-05T22:05:20.439757086Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1049879",
"activityTaskCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJGb3VuZCI6ZmFsc2UsIk93bmVkQnlSZXF1ZXN0ZXIiOmZhbHNlfQ=="
}
]
},
"scheduledEventId": "29",
"startedEventId": "30",
"identity": "replay-recorder"
}
},
{
"eventId": "32",
"eventTime": "2026-10-05T22:05:20.439760613Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049880",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "33",
"eventTime": "2026-10-05T22:05:20.440697179Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049884",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "32",
"identity": "replay-recorder",
"requestId": "32ceefdd-d2a7-4cac-950c-af9e78919d8a",
"historySizeBytes": "4899",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "34",
"eventTime": "2026-10-05T22:05:20.442126163Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049888",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "32",
"startedEventId": "33",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "35",
"eventTime": "2026-10-05T22:05:20.442158835Z",
"eventType": "EVENT_TYPE_TIMER_STARTED",
"taskId": "1049889",
"userMetadata": {
"summary": {
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "IlNsZWVwIg=="
}
},
"timerStartedEventAttributes": {
"timerId": "35",
"startToFireTimeout": "60s",
"workflowTaskCompletedEventId": "34"
}
},
{
"eventId": "36",
"eventTime": "2026-10-05T22:06:20.445052259Z",
"eventType": "EVENT_TYPE_TIMER_FIRED",
"taskId": "1049892",
"timerFiredEventAttributes": {
"timerId": "35",
"startedEventId": "35"
}
},
{
"eventId": "37",
"eventTime": "2026-10-05T22:06:20.445059663Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049893",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "38",
"eventTime": "2026-10-05T22:06:20.446692151Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049897",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "37",
"identity": "replay-recorder",
"requestId": "7338203f-3866-4b0f-8996-1277b5f58111",
"historySizeBytes": "5356",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "39",
"eventTime": "2026-10-05T22:06:20.449150796Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049901",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "37",
"startedEventId": "38",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "40",
"eventTime": "2026-10-05T22:06:20.449184209Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049902",
"activityTaskScheduledEventAttributes": {
"activityId": "40",
"activityType": {
"name": "ReadFarmSiteActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJEb21haW4iOiJzbG93LmV4YW1wbGUudGVzdCIsIk93bmVySUQiOiJzdWJqZWN0IiwiSW5zdGFuY2VJRCI6Imluc3RhbmNlLXNsb3ciLCJHZW5lcmF0aW9uIjoxfQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "60s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "39",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "300s"
},
"useWorkflowBuildId": true
}
},
{
"eventId": "41",
"eventTime": "2026-10-05T22:06:20.450325230Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049907",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "40",
"identity": "replay-recorder",
"requestId": "541569c2-fca3-440a-8248-5cf693342f6e",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "42",
"eventTime": "2026-10-05T22:06:20.451938843Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1049908",
"activityTaskCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJGb3VuZCI6dHJ1ZSwiT3duZWRCeVJlcXVlc3RlciI6dHJ1ZX0="
}
]
},
"scheduledEventId": "40",
"startedEventId": "41",
"identity": "replay-recorder"
}
},
{
"eventId": "43",
"eventTime": "2026-10-05T22:06:20.451943682Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049909",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "44",
"eventTime": "2026-10-05T22:06:20.453008249Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049913",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "43",
"identity": "replay-recorder",
"requestId": "0bbeb883-4aa3-474e-8f4a-045cbdb8a33b",
"historySizeBytes": "6223",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "45",
"eventTime": "2026-10-05T22:06:20.454929361Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049917",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "43",
"startedEventId": "44",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "46",
"eventTime": "2026-10-05T22:06:20.454973604Z",
"eventType": "EVENT_TYPE_MARKER_RECORDED",
"taskId": "1049918",
"markerRecordedEventAttributes": {
"markerName": "LocalActivity",
"details": {
"data": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJBY3Rpdml0eUlEIjoiMSIsIkFjdGl2aXR5VHlwZSI6IlJlY29yZENyZWF0ZWRTaXRlQWN0aXZpdHkiLCJSZXBsYXlUaW1lIjoiMjAyNi0xMC0wNVQyMjowNjoyMC40NTMxNDIzMDFaIiwiQXR0ZW1wdCI6MSwiQmFja29mZiI6MH0="
}
]
}
},
"workflowTaskCompletedEventId": "45"
}
},
{
"eventId": "47",
"eventTime": "2026-10-05T22:06:20.454980186Z",
"eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED",
"taskId": "1049919",
"workflowExecutionCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJTdGF0dXMiOiJzdWNjZWVkZWQiLCJSZWFzb25Db2RlIjoiIn0="
}
]
},
"workflowTaskCompletedEventId": "45"
}
}
]
}
@@ -0,0 +1,952 @@
{
"events": [
{
"eventId": "1",
"eventTime": "2026-10-05T22:06:20.460636690Z",
"eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED",
"taskId": "1049924",
"workflowExecutionStartedEventAttributes": {
"workflowType": {
"name": "CreateFedWikiSiteRequestWorkflow"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJDb25uZWN0aW9uSUQiOiJjb25uZWN0aW9uLXJlcGxheSIsIkluc3RhbmNlSUQiOiJpbnN0YW5jZS10YWtlbiIsIkdlbmVyYXRpb24iOjEsIldvcmtzcGFjZUlEIjoid29ya3NwYWNlLXJlcGxheSIsIkRvbWFpbiI6InRha2VuLmV4YW1wbGUudGVzdCIsIk93bmVyTmFtZSI6Im93bmVyIiwiT3duZXJJRCI6InN1YmplY3QifQ=="
}
]
},
"workflowExecutionTimeout": "0s",
"workflowRunTimeout": "0s",
"workflowTaskTimeout": "10s",
"originalExecutionRunId": "01a10e1a-812c-79b3-b499-c32989f2ca8a",
"identity": "replay-recorder",
"firstExecutionRunId": "01a10e1a-812c-79b3-b499-c32989f2ca8a",
"attempt": 1,
"firstWorkflowTaskBackoff": "0s",
"header": {},
"workflowId": "replay-new-create-name-taken-df75d97f-e173-42fb-ab56-21be6da81c52"
}
},
{
"eventId": "2",
"eventTime": "2026-10-05T22:06:20.460684901Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049925",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "3",
"eventTime": "2026-10-05T22:06:20.462802003Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049930",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "2",
"identity": "replay-recorder",
"requestId": "fcf2130b-859f-42ef-a40b-b3536920eb30",
"historySizeBytes": "620",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "4",
"eventTime": "2026-10-05T22:06:20.464931558Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049934",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "2",
"startedEventId": "3",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {
"langUsedFlags": [
3
],
"sdkName": "temporal-go",
"sdkVersion": "1.38.0"
},
"meteringMetadata": {}
}
},
{
"eventId": "5",
"eventTime": "2026-10-05T22:06:20.464969760Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049935",
"activityTaskScheduledEventAttributes": {
"activityId": "5",
"activityType": {
"name": "CheckQuotaActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJXb3Jrc3BhY2VJRCI6IndvcmtzcGFjZS1yZXBsYXkifQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "30s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "4",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "30s",
"maximumAttempts": 3
},
"useWorkflowBuildId": true
}
},
{
"eventId": "6",
"eventTime": "2026-10-05T22:06:20.466159102Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049941",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "5",
"identity": "replay-recorder",
"requestId": "425b4078-b598-4ec3-9b44-800ff9d6a713",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "7",
"eventTime": "2026-10-05T22:06:20.468652663Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1049942",
"activityTaskCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJDdXJyZW50Q291bnQiOjAsIlF1b3RhIjozLCJDYW5DcmVhdGUiOnRydWV9"
}
]
},
"scheduledEventId": "5",
"startedEventId": "6",
"identity": "replay-recorder"
}
},
{
"eventId": "8",
"eventTime": "2026-10-05T22:06:20.468657312Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049943",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "9",
"eventTime": "2026-10-05T22:06:20.469769649Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049947",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "8",
"identity": "replay-recorder",
"requestId": "0b6e85ae-4872-4215-8fb5-99334f18007b",
"historySizeBytes": "1456",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "10",
"eventTime": "2026-10-05T22:06:20.471543403Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049951",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "8",
"startedEventId": "9",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "11",
"eventTime": "2026-10-05T22:06:20.471571346Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049952",
"activityTaskScheduledEventAttributes": {
"activityId": "11",
"activityType": {
"name": "IncrementUsageActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJXb3Jrc3BhY2VJRCI6IndvcmtzcGFjZS1yZXBsYXkifQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "30s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "10",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "30s",
"maximumAttempts": 3
},
"useWorkflowBuildId": true
}
},
{
"eventId": "12",
"eventTime": "2026-10-05T22:06:20.472806594Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049957",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "11",
"identity": "replay-recorder",
"requestId": "aeb06648-d621-4399-a47e-85df85db678f",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "13",
"eventTime": "2026-10-05T22:06:20.474238744Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1049958",
"activityTaskCompletedEventAttributes": {
"scheduledEventId": "11",
"startedEventId": "12",
"identity": "replay-recorder"
}
},
{
"eventId": "14",
"eventTime": "2026-10-05T22:06:20.474243113Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049959",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "15",
"eventTime": "2026-10-05T22:06:20.475362754Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049963",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "14",
"identity": "replay-recorder",
"requestId": "c1d3c142-deb7-4e43-ba4f-0b4135cf4d3f",
"historySizeBytes": "2198",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "16",
"eventTime": "2026-10-05T22:06:20.477064863Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049967",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "14",
"startedEventId": "15",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "17",
"eventTime": "2026-10-05T22:06:20.477094569Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049968",
"activityTaskScheduledEventAttributes": {
"activityId": "17",
"activityType": {
"name": "AllocateDomainActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJTaXRlSUQiOiJpbnN0YW5jZS10YWtlbiIsIldvcmtzcGFjZUlEIjoid29ya3NwYWNlLXJlcGxheSIsIkRvbWFpbiI6IiIsIlNpdGVEb21haW4iOiIiLCJJc0N1c3RvbURvbWFpbiI6ZmFsc2UsIkZRRE4iOiJ0YWtlbi5leGFtcGxlLnRlc3QifQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "30s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "16",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "30s",
"maximumAttempts": 3
},
"useWorkflowBuildId": true
}
},
{
"eventId": "18",
"eventTime": "2026-10-05T22:06:20.478347220Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049973",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "17",
"identity": "replay-recorder",
"requestId": "8a634fef-1c58-4b68-8ded-a843493918b2",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "19",
"eventTime": "2026-10-05T22:06:20.480011748Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1049974",
"activityTaskCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJTaXRlSUQiOiJpbnN0YW5jZS10YWtlbiIsIkRvbWFpbiI6Ii4ifQ=="
}
]
},
"scheduledEventId": "17",
"startedEventId": "18",
"identity": "replay-recorder"
}
},
{
"eventId": "20",
"eventTime": "2026-10-05T22:06:20.480016227Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049975",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "21",
"eventTime": "2026-10-05T22:06:20.481302422Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049979",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "20",
"identity": "replay-recorder",
"requestId": "a96f8efc-7abc-4d48-a244-7e490b9ff719",
"historySizeBytes": "3118",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "22",
"eventTime": "2026-10-05T22:06:20.483577841Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049983",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "20",
"startedEventId": "21",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "23",
"eventTime": "2026-10-05T22:06:20.483611104Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1049984",
"activityTaskScheduledEventAttributes": {
"activityId": "23",
"activityType": {
"name": "CreateFarmSiteActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJEb21haW4iOiJ0YWtlbi5leGFtcGxlLnRlc3QiLCJPd25lck5hbWUiOiJvd25lciIsIk93bmVySUQiOiJzdWJqZWN0In0="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "300s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "22",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "300s",
"maximumAttempts": 8,
"nonRetryableErrorTypes": [
"FarmNameHeld",
"FarmNameRefused",
"FarmCreateRefused",
"ConfigurationError"
]
},
"useWorkflowBuildId": true
}
},
{
"eventId": "24",
"eventTime": "2026-10-05T22:06:20.484941352Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1049989",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "23",
"identity": "replay-recorder",
"requestId": "fb04aaf3-fad5-4a27-bf7b-8c1639405c4d",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "25",
"eventTime": "2026-10-05T22:06:20.486408498Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_FAILED",
"taskId": "1049990",
"activityTaskFailedEventAttributes": {
"failure": {
"message": "farm create answered status 409",
"source": "GoSDK",
"applicationFailureInfo": {
"type": "FarmNameHeld",
"nonRetryable": true
}
},
"scheduledEventId": "23",
"startedEventId": "24",
"identity": "replay-recorder",
"retryState": "RETRY_STATE_NON_RETRYABLE_FAILURE"
}
},
{
"eventId": "26",
"eventTime": "2026-10-05T22:06:20.486413217Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1049991",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "27",
"eventTime": "2026-10-05T22:06:20.487588753Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1049995",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "26",
"identity": "replay-recorder",
"requestId": "bf11d0d9-b663-40b7-85a6-a64f4dd5c3ff",
"historySizeBytes": "4031",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "28",
"eventTime": "2026-10-05T22:06:20.489422270Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1049999",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "26",
"startedEventId": "27",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "29",
"eventTime": "2026-10-05T22:06:20.489451976Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1050000",
"activityTaskScheduledEventAttributes": {
"activityId": "29",
"activityType": {
"name": "ReadFarmSiteActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJEb21haW4iOiJ0YWtlbi5leGFtcGxlLnRlc3QiLCJPd25lcklEIjoic3ViamVjdCIsIkluc3RhbmNlSUQiOiJpbnN0YW5jZS10YWtlbiIsIkdlbmVyYXRpb24iOjF9"
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "60s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "28",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "300s"
},
"useWorkflowBuildId": true
}
},
{
"eventId": "30",
"eventTime": "2026-10-05T22:06:20.490663520Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1050005",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "29",
"identity": "replay-recorder",
"requestId": "3c37486d-49ef-418f-8014-7f7984fb31fa",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "31",
"eventTime": "2026-10-05T22:06:20.492270700Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1050006",
"activityTaskCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJGb3VuZCI6dHJ1ZSwiT3duZWRCeVJlcXVlc3RlciI6ZmFsc2V9"
}
]
},
"scheduledEventId": "29",
"startedEventId": "30",
"identity": "replay-recorder"
}
},
{
"eventId": "32",
"eventTime": "2026-10-05T22:06:20.492275229Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1050007",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "33",
"eventTime": "2026-10-05T22:06:20.493431288Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1050011",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "32",
"identity": "replay-recorder",
"requestId": "1aa15e86-b5ea-4f45-b75e-5a4f4ee86633",
"historySizeBytes": "4901",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "34",
"eventTime": "2026-10-05T22:06:20.495500369Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1050015",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "32",
"startedEventId": "33",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "35",
"eventTime": "2026-10-05T22:06:20.495530716Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1050016",
"activityTaskScheduledEventAttributes": {
"activityId": "35",
"activityType": {
"name": "ReleaseDomainActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJTaXRlSUQiOiJpbnN0YW5jZS10YWtlbiJ9"
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "30s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "34",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "30s",
"maximumAttempts": 3
},
"useWorkflowBuildId": true
}
},
{
"eventId": "36",
"eventTime": "2026-10-05T22:06:20.496812062Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1050021",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "35",
"identity": "replay-recorder",
"requestId": "93454b1f-6314-4ceb-8c47-ef12000f3f61",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "37",
"eventTime": "2026-10-05T22:06:20.498477271Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1050022",
"activityTaskCompletedEventAttributes": {
"scheduledEventId": "35",
"startedEventId": "36",
"identity": "replay-recorder"
}
},
{
"eventId": "38",
"eventTime": "2026-10-05T22:06:20.498482150Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1050023",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "39",
"eventTime": "2026-10-05T22:06:20.499594107Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1050027",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "38",
"identity": "replay-recorder",
"requestId": "adcfba8f-3f92-42f1-91a5-96cc7787bcdf",
"historySizeBytes": "5635",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "40",
"eventTime": "2026-10-05T22:06:20.501468501Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1050031",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "38",
"startedEventId": "39",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "41",
"eventTime": "2026-10-05T22:06:20.501498257Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"taskId": "1050032",
"activityTaskScheduledEventAttributes": {
"activityId": "41",
"activityType": {
"name": "DecrementUsageActivity"
},
"taskQueue": {
"name": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJXb3Jrc3BhY2VJRCI6IndvcmtzcGFjZS1yZXBsYXkifQ=="
}
]
},
"scheduleToCloseTimeout": "0s",
"scheduleToStartTimeout": "0s",
"startToCloseTimeout": "30s",
"heartbeatTimeout": "0s",
"workflowTaskCompletedEventId": "40",
"retryPolicy": {
"initialInterval": "1s",
"backoffCoefficient": 2,
"maximumInterval": "30s",
"maximumAttempts": 3
},
"useWorkflowBuildId": true
}
},
{
"eventId": "42",
"eventTime": "2026-10-05T22:06:20.502872407Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"taskId": "1050037",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "41",
"identity": "replay-recorder",
"requestId": "377b6ba4-5fe3-403c-b517-2d848addde2b",
"attempt": 1,
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "43",
"eventTime": "2026-10-05T22:06:20.504643386Z",
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"taskId": "1050038",
"activityTaskCompletedEventAttributes": {
"scheduledEventId": "41",
"startedEventId": "42",
"identity": "replay-recorder"
}
},
{
"eventId": "44",
"eventTime": "2026-10-05T22:06:20.504648436Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"taskId": "1050039",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "replay-recorder:6f25fbd4-ecc4-4751-a4cf-b50a01364747",
"kind": "TASK_QUEUE_KIND_STICKY",
"normalName": "fedwiki-replay-record-9a39461f-ff5c-4a02-a185-88aca06de139"
},
"startToCloseTimeout": "10s",
"attempt": 1
}
},
{
"eventId": "45",
"eventTime": "2026-10-05T22:06:20.505986929Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"taskId": "1050043",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "44",
"identity": "replay-recorder",
"requestId": "40c16f29-bc50-4622-b0e2-9a45c4f4392d",
"historySizeBytes": "6377",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
}
}
},
{
"eventId": "46",
"eventTime": "2026-10-05T22:06:20.508135751Z",
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"taskId": "1050047",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "44",
"startedEventId": "45",
"identity": "replay-recorder",
"workerVersion": {
"buildId": "10576f509455abb80627775038ee1d58"
},
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "47",
"eventTime": "2026-10-05T22:06:20.508168372Z",
"eventType": "EVENT_TYPE_MARKER_RECORDED",
"taskId": "1050048",
"markerRecordedEventAttributes": {
"markerName": "LocalActivity",
"details": {
"data": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJBY3Rpdml0eUlEIjoiMSIsIkFjdGl2aXR5VHlwZSI6IkZpbmlzaExpZmVjeWNsZVJlcXVlc3RzQWN0aXZpdHkiLCJSZXBsYXlUaW1lIjoiMjAyNi0xMC0wNVQyMjowNjoyMC41MDYxMjYyODFaIiwiQXR0ZW1wdCI6MSwiQmFja29mZiI6MH0="
}
]
}
},
"workflowTaskCompletedEventId": "46"
}
},
{
"eventId": "48",
"eventTime": "2026-10-05T22:06:20.508173482Z",
"eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED",
"taskId": "1050049",
"workflowExecutionCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJTdGF0dXMiOiJkZWNsaW5lZCIsIlJlYXNvbkNvZGUiOiJuYW1lX3Rha2VuIn0="
}
]
},
"workflowTaskCompletedEventId": "46"
}
}
]
}

Some files were not shown because too many files have changed in this diff Show More