@saasicat/spec 1.0.0-rc.2 → 1.0.0-rc.20
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/README.md +30 -0
- package/acceptance/README.md +3 -3
- package/acceptance/mfa/totp-verify-good-and-bad-code.yaml +6 -6
- package/admin-api.openapi.yaml +328 -77
- package/cli-conventions.md +19 -19
- package/index.cjs +3 -0
- package/index.d.cts +3 -1
- package/index.d.ts +2 -0
- package/index.js +9 -1
- package/package.json +4 -2
- package/prisma-fragments/01-subscription.prisma +34 -47
- package/prisma-fragments/03-plan-versions.prisma +3 -4
- package/prisma-fragments/05-bundle.prisma +4 -5
- package/prisma-fragments/06-catalog-entries.prisma +24 -24
- package/prisma-fragments/07-promotion.prisma +2 -3
- package/prisma-fragments/08-subscription-contract.prisma +42 -5
- package/prisma-fragments/09-pending-registration.prisma +32 -27
- package/prisma-fragments/11-subscription-bundle.prisma +17 -0
- package/prisma-fragments/12-applied-settings.prisma +55 -0
- package/prisma-fragments/13-subscriber.prisma +94 -0
- package/prisma-fragments/14-payments.prisma +100 -0
- package/prisma-fragments/README.md +40 -22
- package/schemas/admin-manifest.schema.json +9 -2
- package/schemas/plan-catalog.schema.json +239 -18
- package/schemas/tenant-ledger.schema.json +236 -0
- package/sql/1.0-a-contract-names-its-subscriber.postgres.sql +383 -0
- package/sql/1.0-a-payment-method-is-a-gateway-reference.postgres.sql +177 -0
- package/sql/1.0-a-settings-change-carries-its-order.postgres.sql +73 -0
- package/sql/1.0-line-items-record-their-money.postgres.sql +134 -0
- package/sql/1.0-remove-project-key.postgres.sql +203 -0
- package/sql/1.0-the-applied-settings-are-recorded.postgres.sql +67 -0
- package/sql/constraints.postgres.sql +76 -0
- package/sql/reference-schema.postgres.sql +299 -77
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- SaaSiCat 1.0 — a contract line item records the currency and the tax it was
|
|
3
|
+
-- booked with.
|
|
4
|
+
-- =============================================================================
|
|
5
|
+
--
|
|
6
|
+
-- `contract_line_items` gains three columns: "currency", "taxRate" and
|
|
7
|
+
-- "taxAmount". They are NOT NULL in the shipped fragment, which a plain
|
|
8
|
+
-- `prisma db push` cannot produce on a table that already holds rows — so this
|
|
9
|
+
-- file adds them nullable, fills them, and only then tightens them. Run it
|
|
10
|
+
-- BEFORE `db push`, the same way as the other file in this directory:
|
|
11
|
+
--
|
|
12
|
+
-- psql "$DATABASE_URL" -f 1.0-line-items-record-their-money.postgres.sql
|
|
13
|
+
--
|
|
14
|
+
-- Where the values come from. Every contract carries a `priceSnapshot` holding
|
|
15
|
+
-- the currency and the VAT rate that were agreed for it, written in the same
|
|
16
|
+
-- moment as its line items — so an existing line reads the fact one level up.
|
|
17
|
+
-- "taxAmount" is the gap between the line's own net and gross, which is exact:
|
|
18
|
+
-- both are already held to two places.
|
|
19
|
+
--
|
|
20
|
+
-- The rate is a percentage, as every tax rate in SaaSiCat is: 19 means 19 %.
|
|
21
|
+
-- It is taken from the snapshot as it stands and checked, never read in some
|
|
22
|
+
-- other unit or converted. A contract stops the migration, named, where its
|
|
23
|
+
-- snapshot does not state a currency, does not state its vatRate as a number,
|
|
24
|
+
-- or states a rate outside 0 to 100 or between 0 and 1 — the shape of a
|
|
25
|
+
-- fraction such as 0.19. A line this file fills that already carries a rate of
|
|
26
|
+
-- its own is held to the same rule; a line that already holds all three values
|
|
27
|
+
-- is not touched. An installation that stored fractions converts them before it
|
|
28
|
+
-- runs this file; a row labelled 0.19 % because a number was there is worse
|
|
29
|
+
-- than a migration that did not run.
|
|
30
|
+
--
|
|
31
|
+
-- Safe to run again: the columns are added only where they are missing, a value
|
|
32
|
+
-- already in a column is kept rather than rewritten, and tightening a column
|
|
33
|
+
-- that is already NOT NULL is a no-op. On a database created from
|
|
34
|
+
-- `reference-schema.postgres.sql` the whole file does nothing at all — the
|
|
35
|
+
-- columns are already there, already tight, and there is nothing to fill.
|
|
36
|
+
|
|
37
|
+
BEGIN;
|
|
38
|
+
|
|
39
|
+
DO $$
|
|
40
|
+
DECLARE
|
|
41
|
+
unfillable text[];
|
|
42
|
+
BEGIN
|
|
43
|
+
IF to_regclass('contract_line_items') IS NULL THEN
|
|
44
|
+
RAISE NOTICE 'contract_line_items is not present — nothing to migrate.';
|
|
45
|
+
RETURN; -- an installation that never adopted this fragment
|
|
46
|
+
END IF;
|
|
47
|
+
|
|
48
|
+
ALTER TABLE "contract_line_items" ADD COLUMN IF NOT EXISTS "currency" TEXT;
|
|
49
|
+
ALTER TABLE "contract_line_items" ADD COLUMN IF NOT EXISTS "taxRate" DECIMAL(5,2);
|
|
50
|
+
ALTER TABLE "contract_line_items" ADD COLUMN IF NOT EXISTS "taxAmount" DECIMAL(10,2);
|
|
51
|
+
|
|
52
|
+
-- What each row is to end up holding, worked out once.
|
|
53
|
+
--
|
|
54
|
+
-- Once, because the guard below and the backfill after it have to agree:
|
|
55
|
+
-- two copies of this expression is two chances to answer differently, and
|
|
56
|
+
-- the one that would go unnoticed is the guard passing a row the backfill
|
|
57
|
+
-- then cannot fill.
|
|
58
|
+
--
|
|
59
|
+
-- A value already in a column is kept. The columns are added only where
|
|
60
|
+
-- they are missing, so on a schema that already had a `currency` of its own
|
|
61
|
+
-- this migration reads it rather than overwriting it — and a row that needs
|
|
62
|
+
-- only one of the three is still found, which keying off `currency` alone
|
|
63
|
+
-- would have missed.
|
|
64
|
+
CREATE TEMP TABLE _saasicat_line_money ON COMMIT DROP AS
|
|
65
|
+
SELECT li."id" AS line_id,
|
|
66
|
+
coalesce(c."id", format('line %s', li."id")) AS witness,
|
|
67
|
+
coalesce(
|
|
68
|
+
li."currency",
|
|
69
|
+
CASE
|
|
70
|
+
WHEN jsonb_typeof(c."priceSnapshot" -> 'currency') = 'string'
|
|
71
|
+
THEN nullif(c."priceSnapshot" ->> 'currency', '')
|
|
72
|
+
END
|
|
73
|
+
) AS currency,
|
|
74
|
+
-- The rate as it is stated, before it is rounded to the column's two
|
|
75
|
+
-- places: the rule is checked on what was written, as the guide's
|
|
76
|
+
-- query checks it, so 0.995 is a fraction here too.
|
|
77
|
+
coalesce(li."taxRate", snap.rate) AS stated_rate,
|
|
78
|
+
coalesce(li."taxRate", round(snap.rate, 2)) AS tax_rate,
|
|
79
|
+
coalesce(li."taxAmount", li."priceGross" - li."priceNet") AS tax_amount
|
|
80
|
+
FROM "contract_line_items" li
|
|
81
|
+
LEFT JOIN "subscription_contracts" c ON c."id" = li."contractId"
|
|
82
|
+
-- The rate is cast only once it is known to be a JSON number, so a
|
|
83
|
+
-- snapshot stating `"vatRate": "19"` reaches the refusal below with its
|
|
84
|
+
-- contract named rather than a cast error that names nothing.
|
|
85
|
+
CROSS JOIN LATERAL (
|
|
86
|
+
SELECT CASE WHEN jsonb_typeof(c."priceSnapshot" -> 'vatRate') = 'number'
|
|
87
|
+
THEN (c."priceSnapshot" ->> 'vatRate')::numeric END AS rate
|
|
88
|
+
) snap
|
|
89
|
+
WHERE li."currency" IS NULL
|
|
90
|
+
OR li."taxRate" IS NULL
|
|
91
|
+
OR li."taxAmount" IS NULL;
|
|
92
|
+
|
|
93
|
+
-- The contract names itself where there is one. A line whose contract is
|
|
94
|
+
-- missing names itself instead: the shipped fragment declares the foreign
|
|
95
|
+
-- key that makes that impossible, but a schema without it would otherwise
|
|
96
|
+
-- reach the message with nothing to say, and an installation this cannot
|
|
97
|
+
-- fill is exactly the one that needs telling which row to look at.
|
|
98
|
+
SELECT array_agg(DISTINCT witness)
|
|
99
|
+
INTO unfillable
|
|
100
|
+
FROM _saasicat_line_money
|
|
101
|
+
WHERE currency IS NULL
|
|
102
|
+
OR stated_rate IS NULL
|
|
103
|
+
OR stated_rate < 0
|
|
104
|
+
OR stated_rate > 100
|
|
105
|
+
-- The shape of a fraction: a percentage between 0 and 1 is refused
|
|
106
|
+
-- rather than recorded as a fraction of a per cent.
|
|
107
|
+
OR (stated_rate > 0 AND stated_rate < 1);
|
|
108
|
+
|
|
109
|
+
IF unfillable IS NOT NULL THEN
|
|
110
|
+
RAISE EXCEPTION
|
|
111
|
+
'Cannot record the money facts of % contract(s): their priceSnapshot does not state a '
|
|
112
|
+
'currency, or does not state its vatRate as a number, or states a tax rate — in the '
|
|
113
|
+
'snapshot, or on a line that already carries one — that is not a percentage from 0 '
|
|
114
|
+
'to 100, a value between 0 and 1 being refused as a fraction (%). The snapshot is '
|
|
115
|
+
'the only record of what was agreed, so this migration converts nothing. Repair those '
|
|
116
|
+
'rows and run it again. A line named on its own has no contract row at all.',
|
|
117
|
+
array_length(unfillable, 1),
|
|
118
|
+
array_to_string(unfillable[1:10], ', ')
|
|
119
|
+
|| CASE WHEN array_length(unfillable, 1) > 10 THEN ', …' ELSE '' END;
|
|
120
|
+
END IF;
|
|
121
|
+
|
|
122
|
+
UPDATE "contract_line_items" li
|
|
123
|
+
SET "currency" = m.currency,
|
|
124
|
+
"taxRate" = m.tax_rate,
|
|
125
|
+
"taxAmount" = m.tax_amount
|
|
126
|
+
FROM _saasicat_line_money m
|
|
127
|
+
WHERE m.line_id = li."id";
|
|
128
|
+
|
|
129
|
+
ALTER TABLE "contract_line_items" ALTER COLUMN "currency" SET NOT NULL;
|
|
130
|
+
ALTER TABLE "contract_line_items" ALTER COLUMN "taxRate" SET NOT NULL;
|
|
131
|
+
ALTER TABLE "contract_line_items" ALTER COLUMN "taxAmount" SET NOT NULL;
|
|
132
|
+
END $$;
|
|
133
|
+
|
|
134
|
+
COMMIT;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- SaaSiCat 1.0 — remove `projectKey` from the catalogue tables.
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
--
|
|
5
|
+
-- project-key-history: this file names the retired identifier because dropping
|
|
6
|
+
-- it is what it does.
|
|
7
|
+
--
|
|
8
|
+
-- One installation serves one application, so a plan key, a bundle key, a
|
|
9
|
+
-- feature key and a quota key are unique for the installation. The column that
|
|
10
|
+
-- suggested otherwise never had a second value to hold: nothing in the platform
|
|
11
|
+
-- could configure a second project, and `subscriptions.tenantId` is unique
|
|
12
|
+
-- installation-wide, so two applications in one database was never a shape this
|
|
13
|
+
-- model could carry.
|
|
14
|
+
--
|
|
15
|
+
-- Run this against an existing database, inside the transaction it opens. It is
|
|
16
|
+
-- a one-way door — the values are dropped, not archived — but it is safe to run
|
|
17
|
+
-- again: a table whose column has already gone is skipped, so a second run does
|
|
18
|
+
-- nothing rather than failing.
|
|
19
|
+
--
|
|
20
|
+
-- psql "$DATABASE_URL" -f 1.0-remove-project-key.postgres.sql
|
|
21
|
+
--
|
|
22
|
+
-- The first statement is a guard. If the affected tables between them hold rows
|
|
23
|
+
-- under more than one project, the migration STOPS and names the table each key
|
|
24
|
+
-- came from, rather than merging rows nobody meant to merge — two `STANDARD`
|
|
25
|
+
-- plans would otherwise collide on the new unique index, and which of them
|
|
26
|
+
-- survives is not a decision a migration should take.
|
|
27
|
+
--
|
|
28
|
+
-- The question is asked across the tables and not once per table. A per-table
|
|
29
|
+
-- count is one where `plans` holds only `alpha` and `bundles` only `beta`, and
|
|
30
|
+
-- that is a two-project installation whose evidence this migration would drop.
|
|
31
|
+
--
|
|
32
|
+
-- Applies to a database created from `reference-schema.postgres.sql` at 0.27 or
|
|
33
|
+
-- any release before 1.0.
|
|
34
|
+
|
|
35
|
+
BEGIN;
|
|
36
|
+
|
|
37
|
+
DO $$
|
|
38
|
+
DECLARE
|
|
39
|
+
affected CONSTANT text[] := ARRAY[
|
|
40
|
+
'plans',
|
|
41
|
+
'bundles',
|
|
42
|
+
'capability_catalog_entries',
|
|
43
|
+
'feature_catalog_entries',
|
|
44
|
+
'quota_catalog_entries',
|
|
45
|
+
'marketing_projections',
|
|
46
|
+
'marketing_settings',
|
|
47
|
+
'promotions',
|
|
48
|
+
'checkout_offers',
|
|
49
|
+
'subscription_contracts'
|
|
50
|
+
];
|
|
51
|
+
target text;
|
|
52
|
+
keys_here text[];
|
|
53
|
+
-- Every key seen anywhere, with the table it came from, so the message can
|
|
54
|
+
-- say where the disagreement is rather than only that there is one.
|
|
55
|
+
seen text[] := ARRAY[]::text[];
|
|
56
|
+
witnesses text[] := ARRAY[]::text[];
|
|
57
|
+
BEGIN
|
|
58
|
+
FOREACH target IN ARRAY affected LOOP
|
|
59
|
+
IF to_regclass(format('%I', target)) IS NULL THEN
|
|
60
|
+
CONTINUE; -- an installation that never adopted this fragment
|
|
61
|
+
END IF;
|
|
62
|
+
-- A table that has already been migrated says nothing, and asking it
|
|
63
|
+
-- would raise `column "projectKey" does not exist`. Everything below
|
|
64
|
+
-- this block is written with IF EXISTS / IF NOT EXISTS, so with this
|
|
65
|
+
-- check the whole file is a no-op on a second run rather than an error
|
|
66
|
+
-- — which is what a deploy that retries, or a container that restarts,
|
|
67
|
+
-- needs it to be.
|
|
68
|
+
IF NOT EXISTS (
|
|
69
|
+
SELECT 1 FROM information_schema.columns
|
|
70
|
+
WHERE table_schema = current_schema()
|
|
71
|
+
AND table_name = target
|
|
72
|
+
AND column_name = 'projectKey'
|
|
73
|
+
) THEN
|
|
74
|
+
CONTINUE;
|
|
75
|
+
END IF;
|
|
76
|
+
EXECUTE format('SELECT array_agg(DISTINCT "projectKey") FROM %I', target)
|
|
77
|
+
INTO keys_here;
|
|
78
|
+
IF keys_here IS NULL THEN
|
|
79
|
+
CONTINUE; -- an empty table says nothing about the installation
|
|
80
|
+
END IF;
|
|
81
|
+
seen := ARRAY(SELECT DISTINCT unnest(seen || keys_here) ORDER BY 1);
|
|
82
|
+
witnesses := witnesses || format('%s: %s', target, array_to_string(keys_here, ', '));
|
|
83
|
+
END LOOP;
|
|
84
|
+
|
|
85
|
+
IF array_length(seen, 1) > 1 THEN
|
|
86
|
+
RAISE EXCEPTION
|
|
87
|
+
'The catalogue holds rows under % different project keys (%). Removing the column '
|
|
88
|
+
'would merge them, and a duplicate key would then collide on the new unique index. '
|
|
89
|
+
'Per table: %. Decide which rows belong to this installation and delete the rest, '
|
|
90
|
+
'then run this migration again.',
|
|
91
|
+
array_length(seen, 1),
|
|
92
|
+
array_to_string(seen, ', '),
|
|
93
|
+
array_to_string(witnesses, ' | ');
|
|
94
|
+
END IF;
|
|
95
|
+
END $$;
|
|
96
|
+
|
|
97
|
+
-- ─── The indexes that carried the column ───
|
|
98
|
+
|
|
99
|
+
DROP INDEX IF EXISTS "checkout_offers_projectKey_status_idx";
|
|
100
|
+
DROP INDEX IF EXISTS "plans_projectKey_deletedAt_idx";
|
|
101
|
+
DROP INDEX IF EXISTS "plans_projectKey_planKey_key";
|
|
102
|
+
DROP INDEX IF EXISTS "bundles_projectKey_deletedAt_idx";
|
|
103
|
+
DROP INDEX IF EXISTS "bundles_projectKey_bundleKey_key";
|
|
104
|
+
DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_codeStatus_idx";
|
|
105
|
+
DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_featureKey_idx";
|
|
106
|
+
DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_capabilityKey_key";
|
|
107
|
+
DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_discoveryStatus_idx";
|
|
108
|
+
DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_plannedOnly_idx";
|
|
109
|
+
DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_featureKey_key";
|
|
110
|
+
DROP INDEX IF EXISTS "quota_catalog_entries_projectKey_discoveryStatus_idx";
|
|
111
|
+
DROP INDEX IF EXISTS "quota_catalog_entries_projectKey_quotaKey_key";
|
|
112
|
+
DROP INDEX IF EXISTS "marketing_projections_projectKey_targetType_locale_priority_idx";
|
|
113
|
+
DROP INDEX IF EXISTS "marketing_settings_projectKey_key";
|
|
114
|
+
DROP INDEX IF EXISTS "promotions_projectKey_targetType_validFrom_validTo_idx";
|
|
115
|
+
DROP INDEX IF EXISTS "subscription_contracts_projectKey_status_idx";
|
|
116
|
+
|
|
117
|
+
-- ─── The column, the tightened uniques, and the shrunken lookup indexes ───
|
|
118
|
+
--
|
|
119
|
+
-- Per table, and only where the table is there. The Prisma fragments are
|
|
120
|
+
-- adopted à la carte — `saasicat schema check` reports what an app did not take
|
|
121
|
+
-- — and an unconditional `ALTER TABLE "bundles"` raises `relation does not
|
|
122
|
+
-- exist` on an app that never adopted bundles, which rolls back everything,
|
|
123
|
+
-- including the tables it could have migrated. `IF EXISTS` on the column does
|
|
124
|
+
-- not help when it is the relation that is missing.
|
|
125
|
+
--
|
|
126
|
+
-- One list, so a table and what happens to it cannot drift apart.
|
|
127
|
+
|
|
128
|
+
DO $$
|
|
129
|
+
DECLARE
|
|
130
|
+
step record;
|
|
131
|
+
statement text;
|
|
132
|
+
BEGIN
|
|
133
|
+
FOR step IN
|
|
134
|
+
SELECT * FROM (VALUES
|
|
135
|
+
('plans', ARRAY[
|
|
136
|
+
'ALTER TABLE "plans" DROP COLUMN IF EXISTS "projectKey"',
|
|
137
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "plans_planKey_key" ON "plans"("planKey")',
|
|
138
|
+
'CREATE INDEX IF NOT EXISTS "plans_deletedAt_idx" ON "plans"("deletedAt")'
|
|
139
|
+
]),
|
|
140
|
+
('bundles', ARRAY[
|
|
141
|
+
'ALTER TABLE "bundles" DROP COLUMN IF EXISTS "projectKey"',
|
|
142
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "bundles_bundleKey_key" ON "bundles"("bundleKey")',
|
|
143
|
+
'CREATE INDEX IF NOT EXISTS "bundles_deletedAt_idx" ON "bundles"("deletedAt")'
|
|
144
|
+
]),
|
|
145
|
+
('capability_catalog_entries', ARRAY[
|
|
146
|
+
'ALTER TABLE "capability_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
|
|
147
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "capability_catalog_entries_capabilityKey_key" ON "capability_catalog_entries"("capabilityKey")',
|
|
148
|
+
'CREATE INDEX IF NOT EXISTS "capability_catalog_entries_codeStatus_idx" ON "capability_catalog_entries"("codeStatus")',
|
|
149
|
+
'CREATE INDEX IF NOT EXISTS "capability_catalog_entries_featureKey_idx" ON "capability_catalog_entries"("featureKey")'
|
|
150
|
+
]),
|
|
151
|
+
('feature_catalog_entries', ARRAY[
|
|
152
|
+
'ALTER TABLE "feature_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
|
|
153
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "feature_catalog_entries_featureKey_key" ON "feature_catalog_entries"("featureKey")',
|
|
154
|
+
'CREATE INDEX IF NOT EXISTS "feature_catalog_entries_discoveryStatus_idx" ON "feature_catalog_entries"("discoveryStatus")',
|
|
155
|
+
'CREATE INDEX IF NOT EXISTS "feature_catalog_entries_plannedOnly_idx" ON "feature_catalog_entries"("plannedOnly")'
|
|
156
|
+
]),
|
|
157
|
+
('quota_catalog_entries', ARRAY[
|
|
158
|
+
'ALTER TABLE "quota_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
|
|
159
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "quota_catalog_entries_quotaKey_key" ON "quota_catalog_entries"("quotaKey")',
|
|
160
|
+
'CREATE INDEX IF NOT EXISTS "quota_catalog_entries_discoveryStatus_idx" ON "quota_catalog_entries"("discoveryStatus")'
|
|
161
|
+
]),
|
|
162
|
+
('marketing_projections', ARRAY[
|
|
163
|
+
'ALTER TABLE "marketing_projections" DROP COLUMN IF EXISTS "projectKey"',
|
|
164
|
+
'CREATE INDEX IF NOT EXISTS "marketing_projections_targetType_locale_priority_idx" ON "marketing_projections"("targetType", "locale", "priority")'
|
|
165
|
+
]),
|
|
166
|
+
('promotions', ARRAY[
|
|
167
|
+
'ALTER TABLE "promotions" DROP COLUMN IF EXISTS "projectKey"',
|
|
168
|
+
'CREATE INDEX IF NOT EXISTS "promotions_targetType_validFrom_validTo_idx" ON "promotions"("targetType", "validFrom", "validTo")'
|
|
169
|
+
]),
|
|
170
|
+
('checkout_offers', ARRAY[
|
|
171
|
+
'ALTER TABLE "checkout_offers" DROP COLUMN IF EXISTS "projectKey"',
|
|
172
|
+
'CREATE INDEX IF NOT EXISTS "checkout_offers_status_idx" ON "checkout_offers"("status")'
|
|
173
|
+
]),
|
|
174
|
+
('subscription_contracts', ARRAY[
|
|
175
|
+
'ALTER TABLE "subscription_contracts" DROP COLUMN IF EXISTS "projectKey"',
|
|
176
|
+
'CREATE INDEX IF NOT EXISTS "subscription_contracts_status_idx" ON "subscription_contracts"("status")'
|
|
177
|
+
]),
|
|
178
|
+
-- `marketing_settings` loses the column and gains the identity that
|
|
179
|
+
-- replaces it: one row, under a constant primary key. The CHECK is
|
|
180
|
+
-- what actually holds it. A default only applies where the caller
|
|
181
|
+
-- omits the id and a primary key accepts every distinct value, so
|
|
182
|
+
-- without it a consumer writing the row directly could make as many
|
|
183
|
+
-- as it liked — which is what the comment claiming otherwise was
|
|
184
|
+
-- worth.
|
|
185
|
+
('marketing_settings', ARRAY[
|
|
186
|
+
'ALTER TABLE "marketing_settings" DROP COLUMN IF EXISTS "projectKey"',
|
|
187
|
+
'ALTER TABLE "marketing_settings" ALTER COLUMN "id" SET DEFAULT ''marketing-settings''',
|
|
188
|
+
'UPDATE "marketing_settings" SET "id" = ''marketing-settings''',
|
|
189
|
+
'ALTER TABLE "marketing_settings" DROP CONSTRAINT IF EXISTS "marketing_settings_is_a_singleton"',
|
|
190
|
+
'ALTER TABLE "marketing_settings" ADD CONSTRAINT "marketing_settings_is_a_singleton" CHECK ("id" = ''marketing-settings'')'
|
|
191
|
+
])
|
|
192
|
+
) AS t(relname, statements)
|
|
193
|
+
LOOP
|
|
194
|
+
IF to_regclass(format('%I', step.relname)) IS NULL THEN
|
|
195
|
+
CONTINUE; -- a fragment this installation never adopted
|
|
196
|
+
END IF;
|
|
197
|
+
FOREACH statement IN ARRAY step.statements LOOP
|
|
198
|
+
EXECUTE statement;
|
|
199
|
+
END LOOP;
|
|
200
|
+
END LOOP;
|
|
201
|
+
END $$;
|
|
202
|
+
|
|
203
|
+
COMMIT;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- SaaSiCat 1.0 — the platform records the configuration it applied.
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
--
|
|
5
|
+
-- Two tables, both additive: `applied_settings` holds the one row that says
|
|
6
|
+
-- which settings the installation applied at its last start, with a fingerprint
|
|
7
|
+
-- and the moment they took effect; `settings_changes` holds one row per start
|
|
8
|
+
-- that found the fingerprint moved. The platform writes both at boot and reads
|
|
9
|
+
-- them for the read-only settings screen — and for nothing else. The record is
|
|
10
|
+
-- a mirror of `config/saas.yaml`, never a source.
|
|
11
|
+
--
|
|
12
|
+
-- Run it the same way as the other files in this directory, before `db push`
|
|
13
|
+
-- where you use one:
|
|
14
|
+
--
|
|
15
|
+
-- psql "$DATABASE_URL" -f 1.0-the-applied-settings-are-recorded.postgres.sql
|
|
16
|
+
--
|
|
17
|
+
-- One row per installation. An installation serves one application and owns
|
|
18
|
+
-- its database, so the row's identity is the installation's, and the CHECK is
|
|
19
|
+
-- what holds it to one row — the constant default only lands a caller that
|
|
20
|
+
-- omits the id on the right one.
|
|
21
|
+
--
|
|
22
|
+
-- A table this file creates has the shape the fragments declare now, the `seq`
|
|
23
|
+
-- that orders the changes included: a fresh installation applies it after
|
|
24
|
+
-- `1.0-a-settings-change-carries-its-order.postgres.sql`, which finds no table
|
|
25
|
+
-- to add the column to, and `db push` refuses to add a unique column to a table
|
|
26
|
+
-- it cannot see is empty.
|
|
27
|
+
--
|
|
28
|
+
-- Safe to run again: both tables and the indexes are created only where they are
|
|
29
|
+
-- missing, and the constraint is dropped before it is added because
|
|
30
|
+
-- `ADD CONSTRAINT` has no `IF NOT EXISTS`. On a database created from
|
|
31
|
+
-- `reference-schema.postgres.sql` the whole file does nothing at all.
|
|
32
|
+
|
|
33
|
+
BEGIN;
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS "applied_settings" (
|
|
36
|
+
"id" TEXT NOT NULL DEFAULT 'installation',
|
|
37
|
+
"fingerprint" TEXT NOT NULL,
|
|
38
|
+
"settings" JSONB NOT NULL,
|
|
39
|
+
"source" TEXT NOT NULL,
|
|
40
|
+
"appliedAt" TIMESTAMP(3) NOT NULL,
|
|
41
|
+
|
|
42
|
+
CONSTRAINT "applied_settings_pkey" PRIMARY KEY ("id")
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
ALTER TABLE "applied_settings"
|
|
46
|
+
DROP CONSTRAINT IF EXISTS applied_settings_is_a_singleton;
|
|
47
|
+
ALTER TABLE "applied_settings"
|
|
48
|
+
ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS "settings_changes" (
|
|
51
|
+
"id" TEXT NOT NULL,
|
|
52
|
+
"seq" SERIAL NOT NULL,
|
|
53
|
+
"noticedAt" TIMESTAMP(3) NOT NULL,
|
|
54
|
+
"source" TEXT NOT NULL,
|
|
55
|
+
"previous" JSONB NOT NULL,
|
|
56
|
+
"current" JSONB NOT NULL,
|
|
57
|
+
"acknowledgedAt" TIMESTAMP(3),
|
|
58
|
+
"acknowledgedBy" TEXT,
|
|
59
|
+
|
|
60
|
+
CONSTRAINT "settings_changes_pkey" PRIMARY KEY ("id")
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE INDEX IF NOT EXISTS "settings_changes_acknowledgedAt_noticedAt_idx"
|
|
64
|
+
ON "settings_changes"("acknowledgedAt", "noticedAt");
|
|
65
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "settings_changes_seq_key" ON "settings_changes"("seq");
|
|
66
|
+
|
|
67
|
+
COMMIT;
|
|
@@ -17,3 +17,79 @@ CREATE UNIQUE INDEX IF NOT EXISTS plan_versions_draft_per_plan
|
|
|
17
17
|
|
|
18
18
|
CREATE UNIQUE INDEX IF NOT EXISTS bundle_versions_draft_per_bundle
|
|
19
19
|
ON bundle_versions ("bundleId") WHERE "publishedAt" IS NULL;
|
|
20
|
+
|
|
21
|
+
-- `marketing_settings` holds at most ONE row.
|
|
22
|
+
--
|
|
23
|
+
-- The Prisma DSL cannot say this, and the constant default on `id` does not: a
|
|
24
|
+
-- default applies only where the caller omits the value, and a primary key
|
|
25
|
+
-- accepts every distinct one. A consumer writing the row directly could make as
|
|
26
|
+
-- many as it liked, and the repository would then read one and ignore the rest.
|
|
27
|
+
--
|
|
28
|
+
-- `ADD CONSTRAINT` has no `IF NOT EXISTS`, and this file is applied again on
|
|
29
|
+
-- every deployment — so it is dropped first. Two plain statements rather than a
|
|
30
|
+
-- dollar-quoted `DO` block on purpose: every consumer of this file splits it at
|
|
31
|
+
-- the statement separator, and a dollar-quoted body would need each of those
|
|
32
|
+
-- splitters to become a SQL lexer. For the same reason no comment in this file
|
|
33
|
+
-- may contain that separator — one here did, and three splitters cut the
|
|
34
|
+
-- sentence in half.
|
|
35
|
+
ALTER TABLE marketing_settings
|
|
36
|
+
DROP CONSTRAINT IF EXISTS marketing_settings_is_a_singleton;
|
|
37
|
+
ALTER TABLE marketing_settings
|
|
38
|
+
ADD CONSTRAINT marketing_settings_is_a_singleton CHECK ("id" = 'marketing-settings');
|
|
39
|
+
|
|
40
|
+
-- `applied_settings` holds at most ONE row: the installation's.
|
|
41
|
+
--
|
|
42
|
+
-- Same reasoning as above, and the same two plain statements. The row mirrors
|
|
43
|
+
-- the settings the installation applied at its last start, and an installation
|
|
44
|
+
-- serves one application, so there is exactly one configuration to record.
|
|
45
|
+
ALTER TABLE applied_settings
|
|
46
|
+
DROP CONSTRAINT IF EXISTS applied_settings_is_a_singleton;
|
|
47
|
+
ALTER TABLE applied_settings
|
|
48
|
+
ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
|
|
49
|
+
|
|
50
|
+
-- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
|
|
51
|
+
-- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
|
|
52
|
+
-- tenants a subscriber had before stay in its history. Two partial unique
|
|
53
|
+
-- indexes, because a link that is over must not count against the next one.
|
|
54
|
+
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_tenant
|
|
55
|
+
ON subscriber_tenants ("tenantId") WHERE "unlinkedAt" IS NULL;
|
|
56
|
+
|
|
57
|
+
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_subscriber
|
|
58
|
+
ON subscriber_tenants ("subscriberId") WHERE "unlinkedAt" IS NULL;
|
|
59
|
+
|
|
60
|
+
-- A subscriber has at most ONE payment method in use. The one a newer payment
|
|
61
|
+
-- method replaced keeps its row as `REPLACED`, so it does not count here.
|
|
62
|
+
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_payment_methods_active_per_subscriber
|
|
63
|
+
ON subscriber_payment_methods ("subscriberId") WHERE "status" = 'ACTIVE';
|
|
64
|
+
|
|
65
|
+
-- A gateway session is confirmed ONCE.
|
|
66
|
+
--
|
|
67
|
+
-- A gateway delivers every event at least once, and it may report one session
|
|
68
|
+
-- through more than one event — a completed form and the payment method behind
|
|
69
|
+
-- it can arrive as two. `eventId` tells those apart, so both would be claimed,
|
|
70
|
+
-- and both would record the session's payment method: for a sign-up that means
|
|
71
|
+
-- a second tenant for one registration.
|
|
72
|
+
--
|
|
73
|
+
-- Both adapters claim with `ON CONFLICT DO NOTHING` and no conflict target, so
|
|
74
|
+
-- this index turns the second confirmation into no row rather than an error,
|
|
75
|
+
-- and the caller reads it as the duplicate it is. Partial on the kind, because
|
|
76
|
+
-- a failed setup and an event SaaSiCat does not act on say nothing about the
|
|
77
|
+
-- session being confirmed. A claim carrying no session does not collide:
|
|
78
|
+
-- PostgreSQL counts nulls as distinct in a unique index.
|
|
79
|
+
CREATE UNIQUE INDEX IF NOT EXISTS payment_event_log_confirmation_per_session
|
|
80
|
+
ON "PaymentEventLog" ("gatewayAccount", "sessionId")
|
|
81
|
+
WHERE "status" = 'payment-method-confirmed';
|
|
82
|
+
|
|
83
|
+
-- Customer numbers count from 10001, so a number has five digits up to 99999
|
|
84
|
+
-- and none reads as a count of subscribers. Two statements: the first makes
|
|
85
|
+
-- 10001 where the sequence starts over, which a restart of its identity reads,
|
|
86
|
+
-- and the second moves a sequence that has never handed out a number there. A
|
|
87
|
+
-- second run finds the start already set and the sequence used, and an
|
|
88
|
+
-- installation without the subscriber tables has no such sequence.
|
|
89
|
+
ALTER SEQUENCE IF EXISTS "subscribers_customerSequence_seq" START WITH 10001;
|
|
90
|
+
|
|
91
|
+
SELECT setval(format('%I.%I', schemaname, sequencename)::regclass, 10001, false)
|
|
92
|
+
FROM pg_sequences
|
|
93
|
+
WHERE schemaname = current_schema()
|
|
94
|
+
AND sequencename = 'subscribers_customerSequence_seq'
|
|
95
|
+
AND last_value IS NULL;
|