@saasicat/spec 1.0.0-rc.22 → 1.0.0-rc.23
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.
- package/admin-api.openapi.yaml +259 -1
- package/package.json +1 -1
- package/prisma-fragments/16-maintenance-window.prisma +47 -0
- package/prisma-fragments/README.md +1 -0
- package/schemas/admin-manifest.schema.json +2 -1
- package/sql/1.0-maintenance-windows-are-kept.postgres.sql +51 -0
- package/sql/constraints.postgres.sql +9 -0
- package/sql/reference-schema.postgres.sql +29 -0
package/admin-api.openapi.yaml
CHANGED
|
@@ -29,7 +29,7 @@ openapi: 3.1.0
|
|
|
29
29
|
|
|
30
30
|
info:
|
|
31
31
|
title: SaaS Platform SuperAdmin API
|
|
32
|
-
version: 1.0.0-rc.
|
|
32
|
+
version: 1.0.0-rc.23
|
|
33
33
|
description: |
|
|
34
34
|
Read and write operations for platform administration:
|
|
35
35
|
tenants, users, subscriptions, promo codes, audit log,
|
|
@@ -58,6 +58,7 @@ tags:
|
|
|
58
58
|
- name: manifest
|
|
59
59
|
- name: catalog
|
|
60
60
|
- name: settings
|
|
61
|
+
- name: maintenance
|
|
61
62
|
|
|
62
63
|
security:
|
|
63
64
|
- bearerAuth: []
|
|
@@ -1406,6 +1407,146 @@ paths:
|
|
|
1406
1407
|
'404':
|
|
1407
1408
|
description: No recorded change has this id (`SETTINGS_CHANGE_NOT_FOUND`)
|
|
1408
1409
|
|
|
1410
|
+
# ── Maintenance windows (MaintenanceModule, packages/nest/src/maintenance/*) ──
|
|
1411
|
+
#
|
|
1412
|
+
# An operator announces a window, locks the application for a deploy and
|
|
1413
|
+
# unlocks it. The announced times are what tenants are told; they never
|
|
1414
|
+
# start or end the lock. These routes stay reachable while the lock holds.
|
|
1415
|
+
# The status a tenant's page reads is `GET /public/maintenance`, outside
|
|
1416
|
+
# this document's prefix.
|
|
1417
|
+
|
|
1418
|
+
/maintenance:
|
|
1419
|
+
get:
|
|
1420
|
+
tags: [maintenance]
|
|
1421
|
+
summary: The open window, if any, and the ones before it
|
|
1422
|
+
responses:
|
|
1423
|
+
'200':
|
|
1424
|
+
description: The overview, read fresh
|
|
1425
|
+
content:
|
|
1426
|
+
application/json:
|
|
1427
|
+
schema: { $ref: '#/components/schemas/MaintenanceOverview' }
|
|
1428
|
+
post:
|
|
1429
|
+
tags: [maintenance]
|
|
1430
|
+
summary: Announce a window to the tenants
|
|
1431
|
+
description: >-
|
|
1432
|
+
Locks nobody out. Refused while another window is open. The application hears
|
|
1433
|
+
of it through its `MaintenanceNotificationPort`, where it binds one. Audited as
|
|
1434
|
+
MAINTENANCE_WINDOW_ANNOUNCE.
|
|
1435
|
+
requestBody:
|
|
1436
|
+
required: true
|
|
1437
|
+
content:
|
|
1438
|
+
application/json:
|
|
1439
|
+
schema: { $ref: '#/components/schemas/MaintenanceAnnouncement' }
|
|
1440
|
+
responses:
|
|
1441
|
+
'201':
|
|
1442
|
+
description: The window, announced
|
|
1443
|
+
content:
|
|
1444
|
+
application/json:
|
|
1445
|
+
schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
|
|
1446
|
+
'400':
|
|
1447
|
+
description: >-
|
|
1448
|
+
A time without its zone (`MAINTENANCE_TIME_INVALID`), an end not after the
|
|
1449
|
+
start (`MAINTENANCE_WINDOW_END_NOT_AFTER_START`) or in the past
|
|
1450
|
+
(`MAINTENANCE_WINDOW_END_IN_PAST`), a message too long
|
|
1451
|
+
(`MAINTENANCE_MESSAGE_TOO_LONG`)
|
|
1452
|
+
'409':
|
|
1453
|
+
description: Another window is open (`MAINTENANCE_WINDOW_ALREADY_OPEN`)
|
|
1454
|
+
|
|
1455
|
+
/maintenance/{id}:
|
|
1456
|
+
parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
|
|
1457
|
+
patch:
|
|
1458
|
+
tags: [maintenance]
|
|
1459
|
+
summary: Move the open window, or change its message
|
|
1460
|
+
description: >-
|
|
1461
|
+
What is left out stays; `message: null` takes the message away. A locked window
|
|
1462
|
+
keeps its start and may still move its expected end. Audited as
|
|
1463
|
+
MAINTENANCE_WINDOW_RESCHEDULE.
|
|
1464
|
+
requestBody:
|
|
1465
|
+
required: true
|
|
1466
|
+
content:
|
|
1467
|
+
application/json:
|
|
1468
|
+
schema: { $ref: '#/components/schemas/MaintenanceRevision' }
|
|
1469
|
+
responses:
|
|
1470
|
+
'200':
|
|
1471
|
+
description: The window as it now stands
|
|
1472
|
+
content:
|
|
1473
|
+
application/json:
|
|
1474
|
+
schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
|
|
1475
|
+
'400':
|
|
1476
|
+
description: As for announcing
|
|
1477
|
+
'404':
|
|
1478
|
+
description: No open window has this id (`MAINTENANCE_WINDOW_NOT_OPEN`)
|
|
1479
|
+
'409':
|
|
1480
|
+
description: The window is locked and its start cannot move (`MAINTENANCE_WINDOW_LOCKED`)
|
|
1481
|
+
|
|
1482
|
+
/maintenance/{id}/cancel:
|
|
1483
|
+
parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
|
|
1484
|
+
post:
|
|
1485
|
+
tags: [maintenance]
|
|
1486
|
+
summary: Call off an announced window
|
|
1487
|
+
description: Audited as MAINTENANCE_WINDOW_CANCEL. A locked window is ended by unlocking it.
|
|
1488
|
+
responses:
|
|
1489
|
+
'201':
|
|
1490
|
+
description: The window, cancelled
|
|
1491
|
+
content:
|
|
1492
|
+
application/json:
|
|
1493
|
+
schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
|
|
1494
|
+
'404':
|
|
1495
|
+
description: No open window has this id (`MAINTENANCE_WINDOW_NOT_OPEN`)
|
|
1496
|
+
'409':
|
|
1497
|
+
description: The window is locked (`MAINTENANCE_WINDOW_LOCKED`)
|
|
1498
|
+
|
|
1499
|
+
/maintenance/lock:
|
|
1500
|
+
post:
|
|
1501
|
+
tags: [maintenance]
|
|
1502
|
+
summary: Lock every tenant out
|
|
1503
|
+
description: >-
|
|
1504
|
+
Locks the open window, or opens one locked at once where none is open. Locking what
|
|
1505
|
+
is locked changes nothing and says so. Takes effect in every process within
|
|
1506
|
+
`takesEffectWithinSeconds`. Audited as MAINTENANCE_LOCK.
|
|
1507
|
+
x-mfa-required: true
|
|
1508
|
+
requestBody:
|
|
1509
|
+
required: false
|
|
1510
|
+
content:
|
|
1511
|
+
application/json:
|
|
1512
|
+
schema: { $ref: '#/components/schemas/MaintenanceLockRequest' }
|
|
1513
|
+
responses:
|
|
1514
|
+
'201':
|
|
1515
|
+
description: The lock
|
|
1516
|
+
content:
|
|
1517
|
+
application/json:
|
|
1518
|
+
schema: { $ref: '#/components/schemas/MaintenanceLockView' }
|
|
1519
|
+
'400':
|
|
1520
|
+
description: An end without its zone or in the past
|
|
1521
|
+
'404':
|
|
1522
|
+
description: The window named is not the open one (`MAINTENANCE_WINDOW_NOT_OPEN`)
|
|
1523
|
+
|
|
1524
|
+
/maintenance/unlock:
|
|
1525
|
+
post:
|
|
1526
|
+
tags: [maintenance]
|
|
1527
|
+
summary: Let tenants back in
|
|
1528
|
+
description: >-
|
|
1529
|
+
Ends the locked window. Where nothing is locked it changes nothing and says so; an
|
|
1530
|
+
announced window is left as it is. Audited as MAINTENANCE_UNLOCK.
|
|
1531
|
+
x-mfa-required: true
|
|
1532
|
+
requestBody:
|
|
1533
|
+
required: false
|
|
1534
|
+
content:
|
|
1535
|
+
application/json:
|
|
1536
|
+
schema:
|
|
1537
|
+
type: object
|
|
1538
|
+
properties:
|
|
1539
|
+
windowId:
|
|
1540
|
+
{ type: string, description: The window the operator means }
|
|
1541
|
+
responses:
|
|
1542
|
+
'201':
|
|
1543
|
+
description: What the unlock did
|
|
1544
|
+
content:
|
|
1545
|
+
application/json:
|
|
1546
|
+
schema: { $ref: '#/components/schemas/MaintenanceUnlockView' }
|
|
1547
|
+
'404':
|
|
1548
|
+
description: The window named is not the open one (`MAINTENANCE_WINDOW_NOT_OPEN`)
|
|
1549
|
+
|
|
1409
1550
|
# ── Discovery (scan/status, outside /catalog) ──
|
|
1410
1551
|
/discovery:
|
|
1411
1552
|
get:
|
|
@@ -1514,6 +1655,123 @@ components:
|
|
|
1514
1655
|
before: { description: 'Absent where the leaf did not exist before' }
|
|
1515
1656
|
after: { description: 'Absent where the leaf no longer exists' }
|
|
1516
1657
|
|
|
1658
|
+
# ────────── Maintenance windows (packages/core/src/maintenance-window.types.ts) ──────────
|
|
1659
|
+
MaintenanceWindowView:
|
|
1660
|
+
type: object
|
|
1661
|
+
required:
|
|
1662
|
+
[
|
|
1663
|
+
id,
|
|
1664
|
+
status,
|
|
1665
|
+
startsAt,
|
|
1666
|
+
endsAt,
|
|
1667
|
+
message,
|
|
1668
|
+
createdAt,
|
|
1669
|
+
createdBy,
|
|
1670
|
+
lockedAt,
|
|
1671
|
+
lockedBy,
|
|
1672
|
+
endedAt,
|
|
1673
|
+
endedBy,
|
|
1674
|
+
overrun,
|
|
1675
|
+
lapsed,
|
|
1676
|
+
]
|
|
1677
|
+
properties:
|
|
1678
|
+
id: { type: string }
|
|
1679
|
+
status: { type: string, enum: [announced, locked, ended, cancelled] }
|
|
1680
|
+
startsAt:
|
|
1681
|
+
{
|
|
1682
|
+
type: [string, 'null'],
|
|
1683
|
+
format: date-time,
|
|
1684
|
+
description: 'The announced start; null for a window locked without being announced',
|
|
1685
|
+
}
|
|
1686
|
+
endsAt:
|
|
1687
|
+
{
|
|
1688
|
+
type: [string, 'null'],
|
|
1689
|
+
format: date-time,
|
|
1690
|
+
description: 'The announced, expected end; null where nobody said',
|
|
1691
|
+
}
|
|
1692
|
+
message: { type: [string, 'null'], description: 'Shown to the tenants as written' }
|
|
1693
|
+
createdAt: { type: string, format: date-time }
|
|
1694
|
+
createdBy: { type: string, description: 'An actor tag, as the audit log writes it' }
|
|
1695
|
+
lockedAt: { type: [string, 'null'], format: date-time }
|
|
1696
|
+
lockedBy: { type: [string, 'null'] }
|
|
1697
|
+
endedAt: { type: [string, 'null'], format: date-time }
|
|
1698
|
+
endedBy: { type: [string, 'null'] }
|
|
1699
|
+
overrun: { type: boolean, description: 'Locked, and past its announced end' }
|
|
1700
|
+
lapsed:
|
|
1701
|
+
{
|
|
1702
|
+
type: boolean,
|
|
1703
|
+
description: 'Announced, never locked, and past its announced end: tenants no longer see it',
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
MaintenanceOverview:
|
|
1707
|
+
type: object
|
|
1708
|
+
required: [open, recent, takesEffectWithinSeconds]
|
|
1709
|
+
properties:
|
|
1710
|
+
open:
|
|
1711
|
+
oneOf:
|
|
1712
|
+
- $ref: '#/components/schemas/MaintenanceWindowView'
|
|
1713
|
+
- type: 'null'
|
|
1714
|
+
recent:
|
|
1715
|
+
type: array
|
|
1716
|
+
description: 'Newest first, the open one included'
|
|
1717
|
+
items: { $ref: '#/components/schemas/MaintenanceWindowView' }
|
|
1718
|
+
takesEffectWithinSeconds:
|
|
1719
|
+
{ type: integer, description: 'How long a lock takes to reach every process' }
|
|
1720
|
+
|
|
1721
|
+
MaintenanceAnnouncement:
|
|
1722
|
+
type: object
|
|
1723
|
+
required: [startsAt, endsAt]
|
|
1724
|
+
properties:
|
|
1725
|
+
startsAt:
|
|
1726
|
+
{
|
|
1727
|
+
type: string,
|
|
1728
|
+
description: 'Date and time with its zone, e.g. 2026-10-02T22:00+02:00',
|
|
1729
|
+
}
|
|
1730
|
+
endsAt:
|
|
1731
|
+
{
|
|
1732
|
+
type: string,
|
|
1733
|
+
description: 'Date and time with its zone; after startsAt, and ahead',
|
|
1734
|
+
}
|
|
1735
|
+
message: { type: [string, 'null'], maxLength: 500 }
|
|
1736
|
+
|
|
1737
|
+
MaintenanceRevision:
|
|
1738
|
+
type: object
|
|
1739
|
+
properties:
|
|
1740
|
+
startsAt: { type: string, description: 'Date and time with its zone' }
|
|
1741
|
+
endsAt: { type: string, description: 'Date and time with its zone' }
|
|
1742
|
+
message:
|
|
1743
|
+
{ type: [string, 'null'], maxLength: 500, description: 'null takes it away' }
|
|
1744
|
+
|
|
1745
|
+
MaintenanceLockRequest:
|
|
1746
|
+
type: object
|
|
1747
|
+
properties:
|
|
1748
|
+
windowId:
|
|
1749
|
+
{
|
|
1750
|
+
type: string,
|
|
1751
|
+
description: 'The window the operator means; left out, the open one or a new one',
|
|
1752
|
+
}
|
|
1753
|
+
endsAt: { type: string, description: 'The expected end, with its zone' }
|
|
1754
|
+
message: { type: [string, 'null'], maxLength: 500 }
|
|
1755
|
+
|
|
1756
|
+
MaintenanceLockView:
|
|
1757
|
+
type: object
|
|
1758
|
+
required: [window, alreadyLocked, takesEffectWithinSeconds]
|
|
1759
|
+
properties:
|
|
1760
|
+
window: { $ref: '#/components/schemas/MaintenanceWindowView' }
|
|
1761
|
+
alreadyLocked:
|
|
1762
|
+
{ type: boolean, description: 'The lock already held; nothing changed' }
|
|
1763
|
+
takesEffectWithinSeconds: { type: integer }
|
|
1764
|
+
|
|
1765
|
+
MaintenanceUnlockView:
|
|
1766
|
+
type: object
|
|
1767
|
+
required: [window, wasLocked]
|
|
1768
|
+
properties:
|
|
1769
|
+
window:
|
|
1770
|
+
oneOf:
|
|
1771
|
+
- $ref: '#/components/schemas/MaintenanceWindowView'
|
|
1772
|
+
- type: 'null'
|
|
1773
|
+
wasLocked: { type: boolean }
|
|
1774
|
+
|
|
1517
1775
|
# ────────── Catalog V2 (DTO = SSOT: packages/nest/src/catalog/dto/*) ──────────
|
|
1518
1776
|
CatalogPlanCreate:
|
|
1519
1777
|
type: object
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -45,6 +45,7 @@ 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` |
|
|
48
49
|
|
|
49
50
|
## How the consumer uses the fragments
|
|
50
51
|
|
|
@@ -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": {
|
|
@@ -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
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
-- prisma-fragments/13-subscriber.prisma
|
|
19
19
|
-- prisma-fragments/14-payments.prisma
|
|
20
20
|
-- prisma-fragments/15-subscriber-ledger.prisma
|
|
21
|
+
-- prisma-fragments/16-maintenance-window.prisma
|
|
21
22
|
-- plus the normative constraints from sql/constraints.postgres.sql.
|
|
22
23
|
-- Do not edit by hand — change the fragments/constraints and regenerate.
|
|
23
24
|
|
|
@@ -706,6 +707,22 @@ CREATE TABLE "subscriber_ledger_entries" (
|
|
|
706
707
|
CONSTRAINT "subscriber_ledger_entries_pkey" PRIMARY KEY ("id")
|
|
707
708
|
);
|
|
708
709
|
|
|
710
|
+
-- CreateTable
|
|
711
|
+
CREATE TABLE "maintenance_windows" (
|
|
712
|
+
"id" TEXT NOT NULL,
|
|
713
|
+
"startsAt" TIMESTAMP(3),
|
|
714
|
+
"endsAt" TIMESTAMP(3),
|
|
715
|
+
"message" TEXT,
|
|
716
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
717
|
+
"createdBy" TEXT NOT NULL,
|
|
718
|
+
"lockedAt" TIMESTAMP(3),
|
|
719
|
+
"lockedBy" TEXT,
|
|
720
|
+
"endedAt" TIMESTAMP(3),
|
|
721
|
+
"endedBy" TEXT,
|
|
722
|
+
|
|
723
|
+
CONSTRAINT "maintenance_windows_pkey" PRIMARY KEY ("id")
|
|
724
|
+
);
|
|
725
|
+
|
|
709
726
|
-- CreateIndex
|
|
710
727
|
CREATE UNIQUE INDEX "subscriptions_tenantId_key" ON "subscriptions"("tenantId");
|
|
711
728
|
|
|
@@ -940,6 +957,9 @@ CREATE INDEX "subscriber_ledger_entries_contractLineItemId_idx" ON "subscriber_l
|
|
|
940
957
|
-- CreateIndex
|
|
941
958
|
CREATE UNIQUE INDEX "subscriber_ledger_entries_subscriptionId_source_sourceRef_p_key" ON "subscriber_ledger_entries"("subscriptionId", "source", "sourceRef", "periodStart", "origin");
|
|
942
959
|
|
|
960
|
+
-- CreateIndex
|
|
961
|
+
CREATE INDEX "maintenance_windows_createdAt_idx" ON "maintenance_windows"("createdAt");
|
|
962
|
+
|
|
943
963
|
-- AddForeignKey
|
|
944
964
|
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planVersionId_fkey" FOREIGN KEY ("planVersionId") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
945
965
|
|
|
@@ -1049,6 +1069,15 @@ ALTER TABLE applied_settings
|
|
|
1049
1069
|
ALTER TABLE applied_settings
|
|
1050
1070
|
ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
|
|
1051
1071
|
|
|
1072
|
+
-- At most ONE maintenance window is open — announced or locked, not yet ended.
|
|
1073
|
+
--
|
|
1074
|
+
-- The index is on a constant, so every open row carries the same key and a
|
|
1075
|
+
-- second one collides. A window that ended leaves the index and makes room for
|
|
1076
|
+
-- the next. Two operators locking at the same moment therefore land one window,
|
|
1077
|
+
-- because the second insert meets the first and is told a window is open.
|
|
1078
|
+
CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
|
|
1079
|
+
ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
|
|
1080
|
+
|
|
1052
1081
|
-- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
|
|
1053
1082
|
-- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
|
|
1054
1083
|
-- tenants a subscriber had before stay in its history. Two partial unique
|