@saasicat/spec 1.0.0-rc.22 → 1.0.0-rc.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,7 +42,7 @@ model SuperAdminMfa {
42
42
  // admins live exclusively in `super_admin_users` may add the relation:
43
43
  // user SuperAdminUser @relation(fields: [userId], references: [id], onDelete: Cascade)
44
44
  userId String @id
45
- secret String? // TOTP secret; null after reset
45
+ secret String? // TOTP secret, sealed by the installation's SecretSealer; null after reset
46
46
  enabledAt DateTime? // set when a secret is stored, null after reset
47
47
 
48
48
  updatedAt DateTime @updatedAt
@@ -0,0 +1,47 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: MaintenanceWindow
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // One row per maintenance window: announced to the tenants ahead of time,
8
+ // locked while a deploy runs a migration that must not race with requests, and
9
+ // ended when the operator says so. A window can also be locked at once without
10
+ // having been announced, which leaves `startsAt` null.
11
+ //
12
+ // While a window is locked, every running version of the application reads this
13
+ // table on its requests — the one being replaced as well as the one replacing
14
+ // it. Its shape is therefore part of the contract between two releases: it
15
+ // gains columns, and never in a deploy the lock protects.
16
+ //
17
+ // At most ONE window is open (not yet ended) at a time. Prisma cannot express a
18
+ // partial unique index; the one that holds it is in
19
+ // sql/constraints.postgres.sql, and it is what makes two operators locking at
20
+ // the same moment land one window rather than two.
21
+ //
22
+ // Optional: adopt it where `maintenance` is turned on in
23
+ // `SaaSiCatModule.forRoot`, which refuses to start without a place to keep
24
+ // windows.
25
+
26
+ model MaintenanceWindow {
27
+ id String @id @default(uuid())
28
+ // What the tenants were told: the announced start and the expected end.
29
+ // Neither starts or ends the lock — that is the operator's command.
30
+ startsAt DateTime?
31
+ endsAt DateTime?
32
+ // The operator's words to the tenants, shown as written.
33
+ message String?
34
+ // When the window was recorded — announced, or locked at once — and by whom
35
+ // (an actor tag, as the audit log writes it).
36
+ createdAt DateTime @default(now())
37
+ createdBy String
38
+ // Null while the window is only announced.
39
+ lockedAt DateTime?
40
+ lockedBy String?
41
+ // Set when the window is over: unlocked, or cancelled before it locked.
42
+ endedAt DateTime?
43
+ endedBy String?
44
+
45
+ @@index([createdAt])
46
+ @@map("maintenance_windows")
47
+ }
@@ -0,0 +1,55 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: SubscriptionNotice
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // One row per notice a subscriber is due: that a newer version of their plan is
8
+ // offered to them, or that the version they are on is being retired — each told
9
+ // once. The platform records a notice before it has the
10
+ // application send it and confirms it after, so the unique key below is what
11
+ // makes a notice once — however many instances run the job — and the row is
12
+ // what shows later when it went out, to whom and how.
13
+ //
14
+ // A run takes a notice on by setting `claimedAt`, and lets it go again where the
15
+ // sending failed; a claim older than a quarter of an hour counts as abandoned.
16
+ // `deliveredAt`, `recipients` and `channel` are set together, when the
17
+ // application confirms it sent the notice.
18
+ //
19
+ // The record is evidence of what a subscriber was told, and outlives the
20
+ // subscription like the subscriber's account does (ADR 0012): `tenantId` and
21
+ // `subscriptionId` are kept as values, not as keys.
22
+ //
23
+ // `kind` is text holding a closed set: version-offered, version-retired,
24
+ // version-retirement-reminder.
25
+ //
26
+ // Optional: adopt it where `tenantBilling.versionNotices` is configured, which
27
+ // refuses to start without a place to keep notices; a retirement needs it too.
28
+ //
29
+ // Contract: @saasicat/core (SubscriptionNoticeRepository).
30
+
31
+ model SubscriptionNotice {
32
+ id String @id @default(uuid())
33
+ tenantId String
34
+ subscriptionId String
35
+
36
+ kind String
37
+ // What the notice is about — for version-offered, the id of the version
38
+ // offered; for version-retired and version-retirement-reminder, the id of
39
+ // the version retired, so a subscription is told of a version's retirement
40
+ // once and reminded of it once.
41
+ subject String
42
+ // The notice as it was last handed to the application.
43
+ content Json
44
+
45
+ createdAt DateTime @default(now())
46
+ claimedAt DateTime?
47
+ deliveredAt DateTime?
48
+ // Who it went to and how, in the application's own terms.
49
+ recipients Json?
50
+ channel String?
51
+
52
+ @@unique([subscriptionId, kind, subject])
53
+ @@index([kind, subject])
54
+ @@map("subscription_notices")
55
+ }
@@ -0,0 +1,44 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: VersionRetirement
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // One row per retirement an operator announces: a plan version ended for the
8
+ // subscriptions already on it, with the version they continue on. It is the one
9
+ // exception to a subscription keeping its version, so it is kept as evidence of
10
+ // what was announced, when and by whom.
11
+ //
12
+ // What it means for each subscription it reaches — the date it takes effect,
13
+ // the last day to cancel without notice, and to whom and how that was told — is
14
+ // a `subscription_notices` row of kind version-retired, written in the same
15
+ // transaction: its subject is the retired version, its content names this
16
+ // row's id as `retirementId`.
17
+ //
18
+ // The versions are kept as values, not as keys: the record outlives the
19
+ // catalogue entries it names, as the notices do (ADR 0012).
20
+ //
21
+ // Optional: adopt it where an operator may retire versions
22
+ // (`tenantBilling.orderlyRetirement` in config/saas.yaml), which refuses to
23
+ // start without a place to keep announcements.
24
+ //
25
+ // Contract: @saasicat/core (VersionRetirementRepository).
26
+
27
+ model VersionRetirement {
28
+ id String @id @default(uuid())
29
+
30
+ retiredPlanVersionId String
31
+ retiredPlanKey String
32
+ retiredVersion Int
33
+
34
+ replacementPlanVersionId String
35
+ replacementPlanKey String
36
+ replacementVersion Int
37
+
38
+ announcedAt DateTime @default(now())
39
+ // Who announced it — an actor tag, as the audit log writes it.
40
+ announcedBy String
41
+
42
+ @@index([retiredPlanVersionId])
43
+ @@map("version_retirements")
44
+ }
@@ -45,6 +45,8 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
45
45
  | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection` |
46
46
  | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
47
47
  | [`15-subscriber-ledger.prisma`](15-subscriber-ledger.prisma) | `SubscriberLedgerEntry` |
48
+ | [`16-maintenance-window.prisma`](16-maintenance-window.prisma) | `MaintenanceWindow` |
49
+ | [`17-subscription-notice.prisma`](17-subscription-notice.prisma) | `SubscriptionNotice` |
48
50
 
49
51
  ## How the consumer uses the fragments
50
52
 
@@ -141,7 +141,8 @@
141
141
  "marketingCatalog": { "$ref": "#/$defs/StandardPageDef" },
142
142
  "platformEmail": { "$ref": "#/$defs/StandardPageDef" },
143
143
  "platformEmailHistory": { "$ref": "#/$defs/StandardPageDef" },
144
- "settings": { "$ref": "#/$defs/StandardPageDef" }
144
+ "settings": { "$ref": "#/$defs/StandardPageDef" },
145
+ "maintenance": { "$ref": "#/$defs/StandardPageDef" }
145
146
  }
146
147
  },
147
148
  "projectPages": {
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "actorTag": {
40
40
  "type": ["string", "null"],
41
- "description": "Origin marker. Format: 'web:<email>:<sessionId>' or 'cli:<email>:<host>'."
41
+ "description": "Origin marker. Format: 'web:<email>:<sessionId>', 'cli:<email>:<host>', or 'job:platform:<job>' for a platform job acting on its own schedule."
42
42
  },
43
43
  "ipAddress": { "type": ["string", "null"] },
44
44
  "userAgent": { "type": ["string", "null"] },
@@ -48,7 +48,7 @@
48
48
  "$defs": {
49
49
  "ActorTagPattern": {
50
50
  "type": "string",
51
- "pattern": "^(web|cli):[^:]+:[^:]+$",
51
+ "pattern": "^(web|cli|job):[^:]+:[^:]+$",
52
52
  "description": "Mandatory format for actorTag."
53
53
  },
54
54
 
@@ -59,7 +59,7 @@
59
59
  "type": "object",
60
60
  "additionalProperties": false,
61
61
  "description": "Commercial settings for the tenant-facing self-service routes. Required, and required member by member: every one of these has a money or a legal consequence, and a value left out is still a decision — just an invisible one. The file is read at boot, so a change lands on the next restart.",
62
- "required": ["cancellationNoticeDays", "selfServiceBlockedPlans"],
62
+ "required": ["cancellationNoticeDays", "selfServiceBlockedPlans", "orderlyRetirement"],
63
63
  "properties": {
64
64
  "cancellationNoticeDays": {
65
65
  "type": "object",
@@ -100,6 +100,18 @@
100
100
  "description": "Plan IDs that may not be left via self-service — typically an active special contract, whose change goes through sales."
101
101
  }
102
102
  }
103
+ },
104
+ "orderlyRetirement": {
105
+ "type": "object",
106
+ "additionalProperties": false,
107
+ "description": "Whether the operator may retire a plan version for the subscriptions already on it — end it, or raise its price, with three calendar months of notice and a named replacement. It reaches contracts customers have already made, so it rests on a clause in the operator's terms, legally reviewed; without one only the rule that a subscription keeps its version applies.",
108
+ "required": ["termsConfirmed"],
109
+ "properties": {
110
+ "termsConfirmed": {
111
+ "type": "boolean",
112
+ "description": "true states that the operator's terms carry that clause. Only then does the admin offer a retirement and the server accept one; false refuses it."
113
+ }
114
+ }
103
115
  }
104
116
  }
105
117
  },
@@ -0,0 +1,33 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a newer version is only offered.
3
+ -- =============================================================================
4
+ --
5
+ -- A subscription keeps the plan version it is bound to, and a newer one reaches
6
+ -- it as an offer beside the plan (`SC-SUB-020`); taking it is a change like any
7
+ -- other. The pending version — announced, reminded about, accepted and rolled
8
+ -- forward at the end of the term — is gone, and so are the seven columns that
9
+ -- carried it. Dropping them discards every pending version still recorded,
10
+ -- accepted ones included: a newer version then reaches the subscription as an
11
+ -- offer.
12
+ --
13
+ -- Run it the same way as the other files in this directory, before `db push`
14
+ -- where you use one:
15
+ --
16
+ -- psql "$DATABASE_URL" -f 1.0-a-newer-version-is-only-offered.postgres.sql
17
+ --
18
+ -- Run it once nothing reads the columns any more: no running version of the
19
+ -- application, and no job of your own — a notice or renewal job that set or
20
+ -- rolled forward a pending version — still uses them. A column's index and its
21
+ -- foreign key go with it.
22
+ --
23
+ -- Safe to run again, and on a database without a subscriptions table: every
24
+ -- column is dropped only where it exists.
25
+
26
+ ALTER TABLE IF EXISTS "subscriptions"
27
+ DROP COLUMN IF EXISTS "pendingPlanVersionId",
28
+ DROP COLUMN IF EXISTS "pendingPlanVersionEffectiveAt",
29
+ DROP COLUMN IF EXISTS "pendingPlanVersionAccepted",
30
+ DROP COLUMN IF EXISTS "pendingPlanVersionAcceptedAt",
31
+ DROP COLUMN IF EXISTS "pendingPlanVersionAcceptedByUserId",
32
+ DROP COLUMN IF EXISTS "pendingPlanVersionNotifiedAt",
33
+ DROP COLUMN IF EXISTS "pendingPlanVersionReminderSentAt";
@@ -0,0 +1,43 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a retirement is announced.
3
+ -- =============================================================================
4
+ --
5
+ -- One table, additive: `version_retirements` keeps every retirement an operator
6
+ -- announces — a plan version ended for the subscriptions already on it, the
7
+ -- version they continue on, when and by whom. What it means for each
8
+ -- subscription is a `subscription_notices` row of kind `version-retired`, which
9
+ -- `1.0-a-subscriber-is-told-once.postgres.sql` creates.
10
+ --
11
+ -- Run it the same way as the other files in this directory, before `db push`
12
+ -- where you use one:
13
+ --
14
+ -- psql "$DATABASE_URL" -f 1.0-a-retirement-is-announced.postgres.sql
15
+ --
16
+ -- The record outlives the catalogue entries it names, so the versions are kept
17
+ -- as values and the table has no foreign key: nothing else has to exist for it
18
+ -- to be created.
19
+ --
20
+ -- Safe to run again: the table and its index are created only where they are
21
+ -- missing. On a database created from `reference-schema.postgres.sql` the whole
22
+ -- file does nothing at all.
23
+
24
+ BEGIN;
25
+
26
+ CREATE TABLE IF NOT EXISTS "version_retirements" (
27
+ "id" TEXT NOT NULL,
28
+ "retiredPlanVersionId" TEXT NOT NULL,
29
+ "retiredPlanKey" TEXT NOT NULL,
30
+ "retiredVersion" INTEGER NOT NULL,
31
+ "replacementPlanVersionId" TEXT NOT NULL,
32
+ "replacementPlanKey" TEXT NOT NULL,
33
+ "replacementVersion" INTEGER NOT NULL,
34
+ "announcedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
35
+ "announcedBy" TEXT NOT NULL,
36
+
37
+ CONSTRAINT "version_retirements_pkey" PRIMARY KEY ("id")
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS "version_retirements_retiredPlanVersionId_idx"
41
+ ON "version_retirements"("retiredPlanVersionId");
42
+
43
+ COMMIT;
@@ -0,0 +1,129 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a scheduled change to another plan binds the version it was
3
+ -- quoted at.
4
+ -- =============================================================================
5
+ --
6
+ -- `subscriptions` gains "pendingChangeVersionId": the version of "pendingPlan"
7
+ -- the plan-change preview showed when the change was scheduled. When the change
8
+ -- comes due, the platform binds that version rather than whichever is in effect
9
+ -- that day, so a version published in between does not reach the customer by a
10
+ -- change they already agreed to. Run it BEFORE `db push`, the same way as the
11
+ -- other files in this directory:
12
+ --
13
+ -- psql "$DATABASE_URL" -f 1.0-a-scheduled-change-keeps-its-quoted-version.postgres.sql
14
+ --
15
+ -- What happens to a change scheduled before this file. The version it was
16
+ -- shown was never recorded, so the one live and in effect now stands for it:
17
+ -- the newest published, not superseded, not ended, and — on a schema with
18
+ -- validity windows — already begun. A version published ahead of its date is
19
+ -- left out; the change then binds the version in effect when it comes due, as
20
+ -- it did before this file. A change that stays on its plan is left empty: it keeps the
21
+ -- version bound, whatever that is by the day it comes due. A plan with no live
22
+ -- version is left empty too, and binds the version in effect when it comes due.
23
+ --
24
+ -- The plan is found the way the Prisma adapter's `planBinding` stores it: by
25
+ -- its key in "plan_versions"."planId" (`legacy-plan-key`), or — where the
26
+ -- column holds the plan's row id (`normalized-plan-id`) — through the row of
27
+ -- "plans" that carries the key. The fragment generates that row id as a UUID,
28
+ -- so it does not coincide with a key and at most one of the two matches. The
29
+ -- second is left out where "plans" has no "planKey", which includes a schema
30
+ -- with no "plans" at all.
31
+ --
32
+ -- Safe to run again: the column, its index and its foreign key are added only
33
+ -- where they are missing, and the backfill runs only in the run that adds the
34
+ -- column. A second run therefore finds the column and changes nothing — it
35
+ -- does not pin a version published since the first run to a change scheduled
36
+ -- before it. On a database created from `reference-schema.postgres.sql` the
37
+ -- whole file does nothing: the column is already there.
38
+
39
+ BEGIN;
40
+
41
+ DO $$
42
+ BEGIN
43
+ IF to_regclass('subscriptions') IS NULL OR to_regclass('plan_versions') IS NULL THEN
44
+ RAISE NOTICE 'subscriptions or plan_versions is not present — nothing to migrate.';
45
+ RETURN; -- an installation that never adopted these fragments
46
+ END IF;
47
+
48
+ IF NOT EXISTS (
49
+ SELECT 1
50
+ FROM information_schema.columns
51
+ WHERE table_schema = current_schema()
52
+ AND table_name = 'subscriptions'
53
+ AND column_name = 'pendingChangeVersionId'
54
+ ) THEN
55
+ ALTER TABLE "subscriptions" ADD COLUMN "pendingChangeVersionId" TEXT;
56
+
57
+ -- Live and in effect: published, not superseded, and — where the
58
+ -- schema has the columns — not ended and already begun. An ended
59
+ -- version takes no new bookings, and one published ahead of its date
60
+ -- is not the one in effect. Each clause is added only where its column
61
+ -- exists; a schema from before it ends and delays nothing.
62
+ EXECUTE format(
63
+ $backfill$
64
+ UPDATE "subscriptions" AS s
65
+ SET "pendingChangeVersionId" = (
66
+ SELECT v."id"
67
+ FROM "plan_versions" AS v
68
+ WHERE (v."planId" = s."pendingPlan" %s)
69
+ AND v."publishedAt" IS NOT NULL
70
+ AND v."supersededAt" IS NULL
71
+ %s
72
+ %s
73
+ ORDER BY v."version" DESC
74
+ LIMIT 1
75
+ )
76
+ WHERE s."pendingPlan" IS NOT NULL
77
+ AND s."pendingPlan" <> s."plan"
78
+ $backfill$,
79
+ CASE
80
+ WHEN EXISTS (
81
+ SELECT 1
82
+ FROM information_schema.columns
83
+ WHERE table_schema = current_schema()
84
+ AND table_name = 'plans'
85
+ AND column_name = 'planKey'
86
+ )
87
+ THEN 'OR v."planId" IN (SELECT p."id" FROM "plans" AS p WHERE p."planKey" = s."pendingPlan")'
88
+ ELSE ''
89
+ END,
90
+ CASE
91
+ WHEN EXISTS (
92
+ SELECT 1
93
+ FROM information_schema.columns
94
+ WHERE table_schema = current_schema()
95
+ AND table_name = 'plan_versions'
96
+ AND column_name = 'endsAt'
97
+ )
98
+ THEN 'AND (v."endsAt" IS NULL OR v."endsAt" > NOW())'
99
+ ELSE ''
100
+ END,
101
+ CASE
102
+ WHEN EXISTS (
103
+ SELECT 1
104
+ FROM information_schema.columns
105
+ WHERE table_schema = current_schema()
106
+ AND table_name = 'plan_versions'
107
+ AND column_name = 'validFrom'
108
+ )
109
+ THEN 'AND (v."validFrom" IS NULL OR v."validFrom" <= NOW())'
110
+ ELSE ''
111
+ END
112
+ );
113
+ END IF;
114
+
115
+ IF NOT EXISTS (
116
+ SELECT 1 FROM pg_constraint WHERE conname = 'subscriptions_pendingChangeVersionId_fkey'
117
+ ) THEN
118
+ ALTER TABLE "subscriptions"
119
+ ADD CONSTRAINT "subscriptions_pendingChangeVersionId_fkey"
120
+ FOREIGN KEY ("pendingChangeVersionId") REFERENCES "plan_versions"("id")
121
+ ON DELETE RESTRICT ON UPDATE CASCADE;
122
+ END IF;
123
+
124
+ CREATE INDEX IF NOT EXISTS "subscriptions_pendingChangeVersionId_idx"
125
+ ON "subscriptions"("pendingChangeVersionId");
126
+ END
127
+ $$;
128
+
129
+ COMMIT;
@@ -0,0 +1,48 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a subscriber is told once.
3
+ -- =============================================================================
4
+ --
5
+ -- One table, additive: `subscription_notices` keeps the record of every notice
6
+ -- a subscriber is due — that a newer version of their plan is offered to them —
7
+ -- with when it went out, to whom and how. The platform writes it where
8
+ -- `tenantBilling.versionNotices` is turned on, and the unique key is what makes
9
+ -- each notice once rather than once per run.
10
+ --
11
+ -- Run it the same way as the other files in this directory, before `db push`
12
+ -- where you use one:
13
+ --
14
+ -- psql "$DATABASE_URL" -f 1.0-a-subscriber-is-told-once.postgres.sql
15
+ --
16
+ -- The record outlives the subscription, as the subscriber's account does, so
17
+ -- the table keeps the tenant and the subscription as values and has no foreign
18
+ -- key: nothing else has to exist for it to be created.
19
+ --
20
+ -- Safe to run again: the table and both indexes are created only where they are
21
+ -- missing. On a database created from `reference-schema.postgres.sql` the whole
22
+ -- file does nothing at all.
23
+
24
+ BEGIN;
25
+
26
+ CREATE TABLE IF NOT EXISTS "subscription_notices" (
27
+ "id" TEXT NOT NULL,
28
+ "tenantId" TEXT NOT NULL,
29
+ "subscriptionId" TEXT NOT NULL,
30
+ "kind" TEXT NOT NULL,
31
+ "subject" TEXT NOT NULL,
32
+ "content" JSONB NOT NULL,
33
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+ "claimedAt" TIMESTAMP(3),
35
+ "deliveredAt" TIMESTAMP(3),
36
+ "recipients" JSONB,
37
+ "channel" TEXT,
38
+
39
+ CONSTRAINT "subscription_notices_pkey" PRIMARY KEY ("id")
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS "subscription_notices_kind_subject_idx"
43
+ ON "subscription_notices"("kind", "subject");
44
+
45
+ CREATE UNIQUE INDEX IF NOT EXISTS "subscription_notices_subscriptionId_kind_subject_key"
46
+ ON "subscription_notices"("subscriptionId", "kind", "subject");
47
+
48
+ COMMIT;
@@ -0,0 +1,51 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — maintenance windows are kept.
3
+ -- =============================================================================
4
+ --
5
+ -- One table, additive: `maintenance_windows` holds one row per maintenance
6
+ -- window — announced to the tenants, locked while a deploy runs, ended when the
7
+ -- operator unlocks. The platform reads it on every request while it is turned
8
+ -- on (`maintenance` in `SaaSiCatModule.forRoot`), through a cache of a few
9
+ -- seconds.
10
+ --
11
+ -- Run it the same way as the other files in this directory, before `db push`
12
+ -- where you use one:
13
+ --
14
+ -- psql "$DATABASE_URL" -f 1.0-maintenance-windows-are-kept.postgres.sql
15
+ --
16
+ -- Apply it in an ordinary deploy, BEFORE the first deploy you lock: the lock
17
+ -- protects a deploy only when the version already running reads it, so the
18
+ -- table has to be there, and that version with it, before it is needed.
19
+ --
20
+ -- The index on a constant is what holds an installation to one open window:
21
+ -- every open row carries the same key, so a second one collides, and a window
22
+ -- that ended leaves the index.
23
+ --
24
+ -- Safe to run again: the table and both indexes are created only where they are
25
+ -- missing. On a database created from `reference-schema.postgres.sql` the whole
26
+ -- file does nothing at all.
27
+
28
+ BEGIN;
29
+
30
+ CREATE TABLE IF NOT EXISTS "maintenance_windows" (
31
+ "id" TEXT NOT NULL,
32
+ "startsAt" TIMESTAMP(3),
33
+ "endsAt" TIMESTAMP(3),
34
+ "message" TEXT,
35
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
+ "createdBy" TEXT NOT NULL,
37
+ "lockedAt" TIMESTAMP(3),
38
+ "lockedBy" TEXT,
39
+ "endedAt" TIMESTAMP(3),
40
+ "endedBy" TEXT,
41
+
42
+ CONSTRAINT "maintenance_windows_pkey" PRIMARY KEY ("id")
43
+ );
44
+
45
+ CREATE INDEX IF NOT EXISTS "maintenance_windows_createdAt_idx"
46
+ ON "maintenance_windows"("createdAt");
47
+
48
+ CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
49
+ ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
50
+
51
+ COMMIT;
@@ -47,6 +47,15 @@ ALTER TABLE applied_settings
47
47
  ALTER TABLE applied_settings
48
48
  ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
49
49
 
50
+ -- At most ONE maintenance window is open — announced or locked, not yet ended.
51
+ --
52
+ -- The index is on a constant, so every open row carries the same key and a
53
+ -- second one collides. A window that ended leaves the index and makes room for
54
+ -- the next. Two operators locking at the same moment therefore land one window,
55
+ -- because the second insert meets the first and is told a window is open.
56
+ CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
57
+ ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
58
+
50
59
  -- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
51
60
  -- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
52
61
  -- tenants a subscriber had before stay in its history. Two partial unique