slotlock 0.5.2 → 0.6.0
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/CHANGELOG.md +194 -0
- package/README.md +159 -66
- package/SECURITY.md +26 -13
- package/SPEC.md +10 -7
- package/agent-server.d.ts +47 -4
- package/agent-server.js +492 -147
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +70 -7
- package/assistant.js +2 -1
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/dashboard.d.ts +3 -1
- package/dashboard.js +98 -15
- package/ddl.d.ts +21 -4
- package/ddl.js +555 -7
- package/event-feed.d.ts +54 -4
- package/event-feed.js +107 -8
- package/index.d.ts +8 -7
- package/index.js +5 -4
- package/maintenance.js +12 -10
- package/openapi.json +1309 -82
- package/package.json +1 -1
- package/rest-api.js +12 -0
- package/self-host.js +58 -6
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +35 -3
- package/store.js +447 -135
- package/types.d.ts +14 -1
- package/webhooks.d.ts +10 -2
- package/webhooks.js +11 -3
package/ddl.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// the caller's search_path back at the end, so a caller that keeps going in the same transaction
|
|
12
12
|
// resolves its own names as before. Apply it in one transaction: under autocommit every statement
|
|
13
13
|
// is a transaction of its own, and the pin ends with the statement that sets it.
|
|
14
|
+
import { SLOTLOCK_CHANGE_ORIGINS, legacyCoverageSourceDigestSql } from './event-feed.js';
|
|
14
15
|
import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
|
|
15
16
|
const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
|
|
16
17
|
const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
|
|
@@ -74,9 +75,11 @@ $slotlock_restore_forced_row_security$;
|
|
|
74
75
|
export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
|
|
75
76
|
/**
|
|
76
77
|
* The version of the schema this release creates, recorded in `slotlock.schema_version` by every
|
|
77
|
-
* migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2.
|
|
78
|
+
* migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2; 0.3 to 0.5
|
|
79
|
+
* recorded 3. 0.6 is 4, so a 0.5 release refuses it before it changes anything: its DDL would put
|
|
80
|
+
* back the trigger that names a coverage source by its guessable digest.
|
|
78
81
|
*/
|
|
79
|
-
export const SLOTLOCK_SCHEMA_VERSION =
|
|
82
|
+
export const SLOTLOCK_SCHEMA_VERSION = 4;
|
|
80
83
|
/** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
|
|
81
84
|
export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
|
|
82
85
|
/** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
|
|
@@ -550,6 +553,55 @@ AS $slotlock_erase_api_keys$
|
|
|
550
553
|
SELECT count(*) FROM erased;
|
|
551
554
|
$slotlock_erase_api_keys$;
|
|
552
555
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[5]} FROM PUBLIC;
|
|
556
|
+
|
|
557
|
+
-- A key created, given a new secret or revoked appends to its tenant's feed (key.created,
|
|
558
|
+
-- key.rotated, key.revoked): its id, scopes and expiry, never its digest, prefix or name. Only the
|
|
559
|
+
-- functions above write keys, as the schema's owner, which forced row-level security on the feed
|
|
560
|
+
-- binds too: the row is appended under the tenant's name in every setting a policy reads, and the
|
|
561
|
+
-- caller's values are put back after. Who did it is the caller's actor, else the key's creator.
|
|
562
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_api_key_change()
|
|
563
|
+
RETURNS trigger
|
|
564
|
+
LANGUAGE plpgsql
|
|
565
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
566
|
+
AS $slotlock_outbox_api_key$
|
|
567
|
+
DECLARE
|
|
568
|
+
kind text;
|
|
569
|
+
setting text;
|
|
570
|
+
previous jsonb := '{}'::jsonb;
|
|
571
|
+
BEGIN
|
|
572
|
+
IF TG_OP = 'INSERT' THEN
|
|
573
|
+
kind := 'key.created';
|
|
574
|
+
ELSIF OLD.revoked_at IS NULL AND NEW.revoked_at IS NOT NULL THEN
|
|
575
|
+
kind := 'key.revoked';
|
|
576
|
+
ELSIF OLD.secret_hash IS DISTINCT FROM NEW.secret_hash THEN
|
|
577
|
+
kind := 'key.rotated';
|
|
578
|
+
ELSE
|
|
579
|
+
RETURN NULL;
|
|
580
|
+
END IF;
|
|
581
|
+
FOR setting IN
|
|
582
|
+
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
583
|
+
LOOP
|
|
584
|
+
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
585
|
+
PERFORM set_config(setting, NEW.tenant_ref, true);
|
|
586
|
+
END LOOP;
|
|
587
|
+
INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
|
|
588
|
+
VALUES (
|
|
589
|
+
NEW.tenant_ref, kind, NEW.id::text,
|
|
590
|
+
jsonb_build_object(
|
|
591
|
+
'id', NEW.id, 'scopes', to_jsonb(NEW.scopes), 'expires_at', NEW.expires_at,
|
|
592
|
+
'revoked_at', NEW.revoked_at),
|
|
593
|
+
COALESCE(NULLIF(current_setting('slotlock.actor', true), ''),
|
|
594
|
+
CASE WHEN TG_OP = 'INSERT' THEN NEW.created_by END)
|
|
595
|
+
);
|
|
596
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
597
|
+
PERFORM set_config(setting, COALESCE(previous ->> setting, ''), true);
|
|
598
|
+
END LOOP;
|
|
599
|
+
RETURN NULL;
|
|
600
|
+
END
|
|
601
|
+
$slotlock_outbox_api_key$;
|
|
602
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_api_key_change
|
|
603
|
+
AFTER INSERT OR UPDATE OF revoked_at, secret_hash ON slotlock.api_keys
|
|
604
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_api_key_change();
|
|
553
605
|
`;
|
|
554
606
|
// The dashboard's records (dashboard.ts), shared by every server on this database: a finished sign-in
|
|
555
607
|
// (its callback works once), a sent form (it runs once) and a signed-out session (a copied cookie
|
|
@@ -701,6 +753,12 @@ CREATE TABLE IF NOT EXISTS slotlock.schema_version (
|
|
|
701
753
|
);
|
|
702
754
|
ALTER TABLE slotlock.schema_version ENABLE ROW LEVEL SECURITY;
|
|
703
755
|
REVOKE ALL ON slotlock.schema_version FROM PUBLIC;
|
|
756
|
+
-- The rename of coverage changes earlier releases wrote (0.6, slotlockCoverageChangeRenameSql): the
|
|
757
|
+
-- last outbox id a pass has read (null before the first), and whether it is done. A pass after the
|
|
758
|
+
-- first that finds nothing to rename marks it done, and no migration reads the outbox for it again.
|
|
759
|
+
ALTER TABLE slotlock.schema_version ADD COLUMN IF NOT EXISTS coverage_changes_renamed_through bigint;
|
|
760
|
+
ALTER TABLE slotlock.schema_version
|
|
761
|
+
ADD COLUMN IF NOT EXISTS coverage_changes_renamed boolean NOT NULL DEFAULT false;
|
|
704
762
|
|
|
705
763
|
-- NULL when no migration has recorded a version (a schema from 0.2 or earlier).
|
|
706
764
|
CREATE OR REPLACE FUNCTION slotlock.installed_schema_version()
|
|
@@ -875,6 +933,10 @@ BEGIN
|
|
|
875
933
|
DELETE FROM slotlock.waitlist_entries WHERE tenant_ref = requested_tenant_ref;
|
|
876
934
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
877
935
|
RETURN NEXT;
|
|
936
|
+
erased_table := 'booking_rule_defaults';
|
|
937
|
+
DELETE FROM slotlock.booking_rule_defaults WHERE tenant_ref = requested_tenant_ref;
|
|
938
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
939
|
+
RETURN NEXT;
|
|
878
940
|
erased_table := 'assistant_conversations';
|
|
879
941
|
DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
|
|
880
942
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
@@ -1194,6 +1256,36 @@ CREATE TABLE IF NOT EXISTS slotlock.outbox (
|
|
|
1194
1256
|
);
|
|
1195
1257
|
-- Who made the change (withTenant's actor: an API key, an agent), for the history.
|
|
1196
1258
|
ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS actor text;
|
|
1259
|
+
-- Where it came from (withTenant's origin: mcp, a2a, rest, application, dashboard,
|
|
1260
|
+
-- dashboard-assistant, maintenance, library) and the API key that made it (withTenant's keyId).
|
|
1261
|
+
-- Added bare, so the rows already there keep null (an earlier release) and nothing is scanned under
|
|
1262
|
+
-- the lock; the defaults then bind only new rows. A write that names no origin (code calling the
|
|
1263
|
+
-- store outside withTenant, or SQL of its own) is \`library\`.
|
|
1264
|
+
ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS origin text;
|
|
1265
|
+
ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS key_id uuid;
|
|
1266
|
+
ALTER TABLE slotlock.outbox
|
|
1267
|
+
ALTER COLUMN origin SET DEFAULT COALESCE(NULLIF(current_setting('slotlock.origin', true), ''), 'library'),
|
|
1268
|
+
ALTER COLUMN key_id SET DEFAULT NULLIF(current_setting('slotlock.key_id', true), '')::uuid;
|
|
1269
|
+
-- The origins the API reads, and no other. NOT VALID, so adding it scans nothing under the lock;
|
|
1270
|
+
-- DEPLOYMENT_VALIDATE_CONSTRAINTS validates it after the migration commits. Its comment is the list
|
|
1271
|
+
-- it was built from: a release that changes the list replaces it, so an upgraded database never
|
|
1272
|
+
-- keeps refusing an origin the release writes.
|
|
1273
|
+
DO $slotlock_outbox_origin$
|
|
1274
|
+
BEGIN
|
|
1275
|
+
ALTER TABLE slotlock.outbox DROP CONSTRAINT IF EXISTS slotlock_outbox_origin_valid;
|
|
1276
|
+
IF (
|
|
1277
|
+
SELECT pg_catalog.obj_description(oid, 'pg_constraint') FROM pg_catalog.pg_constraint
|
|
1278
|
+
WHERE conrelid = 'slotlock.outbox'::regclass AND conname = 'slotlock_outbox_origin_known'
|
|
1279
|
+
) IS DISTINCT FROM '${SLOTLOCK_CHANGE_ORIGINS.join(' ')}' THEN
|
|
1280
|
+
ALTER TABLE slotlock.outbox DROP CONSTRAINT IF EXISTS slotlock_outbox_origin_known;
|
|
1281
|
+
ALTER TABLE slotlock.outbox
|
|
1282
|
+
ADD CONSTRAINT slotlock_outbox_origin_known
|
|
1283
|
+
CHECK (origin IN (${SLOTLOCK_CHANGE_ORIGINS.map((origin) => `'${origin}'`).join(', ')})) NOT VALID;
|
|
1284
|
+
COMMENT ON CONSTRAINT slotlock_outbox_origin_known ON slotlock.outbox
|
|
1285
|
+
IS '${SLOTLOCK_CHANGE_ORIGINS.join(' ')}';
|
|
1286
|
+
END IF;
|
|
1287
|
+
END
|
|
1288
|
+
$slotlock_outbox_origin$;
|
|
1197
1289
|
CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_resource_idx
|
|
1198
1290
|
ON slotlock.outbox (tenant_ref, resource_id, id) WHERE resource_id IS NOT NULL;
|
|
1199
1291
|
CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_subject_idx
|
|
@@ -1248,6 +1340,10 @@ $slotlock_outbox_mark_pruned$;
|
|
|
1248
1340
|
CREATE OR REPLACE TRIGGER slotlock_outbox_mark_pruned
|
|
1249
1341
|
AFTER DELETE ON slotlock.outbox REFERENCING OLD TABLE AS pruned
|
|
1250
1342
|
FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_mark_pruned();
|
|
1343
|
+
-- Live updates poll each tenant's feed (change-watch.ts). A pre-release build woke readers with a
|
|
1344
|
+
-- notification trigger here, which made every outbox commit take the cluster-wide notify lock: drop it.
|
|
1345
|
+
DROP TRIGGER IF EXISTS slotlock_outbox_notify ON slotlock.outbox;
|
|
1346
|
+
DROP FUNCTION IF EXISTS slotlock.outbox_notify();
|
|
1251
1347
|
|
|
1252
1348
|
CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
|
|
1253
1349
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -1275,14 +1371,31 @@ CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
|
|
|
1275
1371
|
AND octet_length(url) BETWEEN 1 AND 2048
|
|
1276
1372
|
AND secret ~ '^whsec_[A-Za-z0-9+/]{43}=$'
|
|
1277
1373
|
),
|
|
1278
|
-
CONSTRAINT
|
|
1279
|
-
event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND
|
|
1374
|
+
CONSTRAINT slotlock_webhook_endpoints_type_count_valid CHECK (
|
|
1375
|
+
event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 64
|
|
1280
1376
|
),
|
|
1281
1377
|
CONSTRAINT slotlock_webhook_endpoints_error_valid CHECK (
|
|
1282
1378
|
last_error IS NULL OR char_length(last_error) <= 500
|
|
1283
1379
|
)
|
|
1284
1380
|
);
|
|
1285
1381
|
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS changes_pruned boolean NOT NULL DEFAULT false;
|
|
1382
|
+
-- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
|
|
1383
|
+
-- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
|
|
1384
|
+
DO $slotlock_webhook_type_count$
|
|
1385
|
+
BEGIN
|
|
1386
|
+
IF NOT EXISTS (
|
|
1387
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
1388
|
+
WHERE conrelid = 'slotlock.webhook_endpoints'::regclass
|
|
1389
|
+
AND conname = 'slotlock_webhook_endpoints_type_count_valid'
|
|
1390
|
+
) THEN
|
|
1391
|
+
ALTER TABLE slotlock.webhook_endpoints
|
|
1392
|
+
ADD CONSTRAINT slotlock_webhook_endpoints_type_count_valid CHECK (
|
|
1393
|
+
event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 64
|
|
1394
|
+
) NOT VALID;
|
|
1395
|
+
END IF;
|
|
1396
|
+
ALTER TABLE slotlock.webhook_endpoints DROP CONSTRAINT IF EXISTS slotlock_webhook_endpoints_types_valid;
|
|
1397
|
+
END
|
|
1398
|
+
$slotlock_webhook_type_count$;
|
|
1286
1399
|
CREATE INDEX IF NOT EXISTS slotlock_webhook_endpoints_tenant_idx
|
|
1287
1400
|
ON slotlock.webhook_endpoints (tenant_ref, created_at);
|
|
1288
1401
|
|
|
@@ -1321,6 +1434,23 @@ CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
|
|
|
1321
1434
|
CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
|
|
1322
1435
|
ON slotlock.waitlist_entries (tenant_ref, starts_at);
|
|
1323
1436
|
|
|
1437
|
+
-- Default booking rules (booking-rules.ts), for the tenant (pool_ref '') or for one of its pools:
|
|
1438
|
+
-- a resource's rules are the tenant's, overlaid by its pool's, overlaid by its own.
|
|
1439
|
+
CREATE TABLE IF NOT EXISTS slotlock.booking_rule_defaults (
|
|
1440
|
+
tenant_ref text NOT NULL,
|
|
1441
|
+
pool_ref text NOT NULL DEFAULT '',
|
|
1442
|
+
rules jsonb NOT NULL,
|
|
1443
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1444
|
+
CONSTRAINT slotlock_booking_rule_defaults_pkey PRIMARY KEY (tenant_ref, pool_ref),
|
|
1445
|
+
CONSTRAINT slotlock_booking_rule_defaults_identity_valid CHECK (
|
|
1446
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
1447
|
+
AND (pool_ref = '' OR pool_ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$')
|
|
1448
|
+
),
|
|
1449
|
+
CONSTRAINT slotlock_booking_rule_defaults_rules_valid CHECK (
|
|
1450
|
+
jsonb_typeof(rules) = 'object' AND octet_length(rules::text) <= 4096
|
|
1451
|
+
)
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1324
1454
|
-- The model a tenant's dashboard assistant uses (dashboard.ts, assistant.ts), one row a tenant.
|
|
1325
1455
|
-- The API key is stored only sealed (AES-256-GCM under the deployment's SLOTLOCK_ASSISTANT_SECRET,
|
|
1326
1456
|
-- bound to the tenant), with its last four characters to show.
|
|
@@ -1542,6 +1672,212 @@ UPDATE slotlock.calendar_events
|
|
|
1542
1672
|
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error text;
|
|
1543
1673
|
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error_at timestamptz;
|
|
1544
1674
|
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS stale_reported_at timestamptz;
|
|
1675
|
+
-- How the feed names a source (0.6): \`src_\` and 32 random hex digits, made when the source is first
|
|
1676
|
+
-- recorded for the resource and kept until it is deleted, so nobody can confirm a guessed source
|
|
1677
|
+
-- against it (0.5.3 named it by an unkeyed digest of the tenant and the source). Added bare, its
|
|
1678
|
+
-- default set apart (a volatile default on ADD COLUMN would rewrite the table under the lock), the
|
|
1679
|
+
-- sources already there given one each, and its CHECK NOT VALID, validated after the migration
|
|
1680
|
+
-- commits. Only listCoverage gives the source beside it, to the tenant.
|
|
1681
|
+
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS source_id text;
|
|
1682
|
+
ALTER TABLE slotlock.calendar_coverage
|
|
1683
|
+
ALTER COLUMN source_id SET DEFAULT 'src_' || pg_catalog.replace(pg_catalog.gen_random_uuid()::text, '-', '');
|
|
1684
|
+
UPDATE slotlock.calendar_coverage SET source_id = DEFAULT WHERE source_id IS NULL;
|
|
1685
|
+
DO $slotlock_coverage_source_id$
|
|
1686
|
+
BEGIN
|
|
1687
|
+
IF NOT EXISTS (
|
|
1688
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
1689
|
+
WHERE conrelid = 'slotlock.calendar_coverage'::regclass
|
|
1690
|
+
AND conname = 'slotlock_calendar_coverage_source_id_valid'
|
|
1691
|
+
) THEN
|
|
1692
|
+
ALTER TABLE slotlock.calendar_coverage
|
|
1693
|
+
ADD CONSTRAINT slotlock_calendar_coverage_source_id_valid
|
|
1694
|
+
CHECK (source_id IS NOT NULL AND source_id ~ '^src_[0-9a-f]{32}$') NOT VALID;
|
|
1695
|
+
END IF;
|
|
1696
|
+
END
|
|
1697
|
+
$slotlock_coverage_source_id$;
|
|
1698
|
+
|
|
1699
|
+
-- Configuration changes (RFC 0002 P1-6): a resource's own settings, the sources its coverage is
|
|
1700
|
+
-- read from, and the tenant's webhook endpoints. Each change that alters something appends one row,
|
|
1701
|
+
-- with what it was before and after for the fields it changed; writing the same value again appends
|
|
1702
|
+
-- none. Erasure is not reported, as above.
|
|
1703
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_erasing(owner text)
|
|
1704
|
+
RETURNS boolean
|
|
1705
|
+
LANGUAGE sql
|
|
1706
|
+
STABLE
|
|
1707
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1708
|
+
AS $slotlock_outbox_erasing$
|
|
1709
|
+
SELECT owner = current_setting('slotlock.erasing', true)
|
|
1710
|
+
AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
|
|
1711
|
+
WHERE nspname = 'slotlock');
|
|
1712
|
+
$slotlock_outbox_erasing$;
|
|
1713
|
+
|
|
1714
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_resource_change()
|
|
1715
|
+
RETURNS trigger
|
|
1716
|
+
LANGUAGE plpgsql
|
|
1717
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1718
|
+
AS $slotlock_outbox_resource$
|
|
1719
|
+
DECLARE
|
|
1720
|
+
changed slotlock.resources%ROWTYPE;
|
|
1721
|
+
kind text;
|
|
1722
|
+
before jsonb := '{}'::jsonb;
|
|
1723
|
+
after jsonb := '{}'::jsonb;
|
|
1724
|
+
details jsonb;
|
|
1725
|
+
BEGIN
|
|
1726
|
+
changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1727
|
+
IF changed.tenant_ref IS NULL OR slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
|
|
1728
|
+
IF TG_OP = 'INSERT' THEN
|
|
1729
|
+
kind := 'resource.created';
|
|
1730
|
+
details := jsonb_build_object(
|
|
1731
|
+
'timezone', NEW.timezone, 'visibility', NEW.visibility, 'pool', NEW.pool_ref,
|
|
1732
|
+
'availability', NEW.availability_rules, 'booking_rules', NEW.booking_rules);
|
|
1733
|
+
ELSIF TG_OP = 'DELETE' THEN
|
|
1734
|
+
kind := 'resource.deleted';
|
|
1735
|
+
details := '{}'::jsonb;
|
|
1736
|
+
ELSIF (OLD.archived_at IS NULL) <> (NEW.archived_at IS NULL) THEN
|
|
1737
|
+
kind := CASE WHEN NEW.archived_at IS NULL THEN 'resource.restored' ELSE 'resource.archived' END;
|
|
1738
|
+
details := jsonb_build_object('archived_at', NEW.archived_at);
|
|
1739
|
+
ELSE
|
|
1740
|
+
-- Only what changed, under the names the API gives it; a null is kept (no pool, no rules).
|
|
1741
|
+
IF OLD.external_ref IS DISTINCT FROM NEW.external_ref THEN
|
|
1742
|
+
before := before || jsonb_build_object('reference', OLD.external_ref);
|
|
1743
|
+
after := after || jsonb_build_object('reference', NEW.external_ref);
|
|
1744
|
+
END IF;
|
|
1745
|
+
IF OLD.timezone IS DISTINCT FROM NEW.timezone THEN
|
|
1746
|
+
before := before || jsonb_build_object('timezone', OLD.timezone);
|
|
1747
|
+
after := after || jsonb_build_object('timezone', NEW.timezone);
|
|
1748
|
+
END IF;
|
|
1749
|
+
IF OLD.pool_ref IS DISTINCT FROM NEW.pool_ref THEN
|
|
1750
|
+
before := before || jsonb_build_object('pool', OLD.pool_ref);
|
|
1751
|
+
after := after || jsonb_build_object('pool', NEW.pool_ref);
|
|
1752
|
+
END IF;
|
|
1753
|
+
IF OLD.availability_rules IS DISTINCT FROM NEW.availability_rules THEN
|
|
1754
|
+
before := before || jsonb_build_object('availability', OLD.availability_rules);
|
|
1755
|
+
after := after || jsonb_build_object('availability', NEW.availability_rules);
|
|
1756
|
+
END IF;
|
|
1757
|
+
IF OLD.booking_rules IS DISTINCT FROM NEW.booking_rules THEN
|
|
1758
|
+
before := before || jsonb_build_object('booking_rules', OLD.booking_rules);
|
|
1759
|
+
after := after || jsonb_build_object('booking_rules', NEW.booking_rules);
|
|
1760
|
+
END IF;
|
|
1761
|
+
IF after = '{}'::jsonb THEN RETURN NULL; END IF;
|
|
1762
|
+
kind := 'resource.updated';
|
|
1763
|
+
details := jsonb_build_object('before', before, 'after', after);
|
|
1764
|
+
END IF;
|
|
1765
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
1766
|
+
VALUES (
|
|
1767
|
+
changed.tenant_ref, changed.id, kind, changed.id::text,
|
|
1768
|
+
jsonb_build_object('id', changed.id, 'reference', changed.external_ref) || details,
|
|
1769
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
1770
|
+
);
|
|
1771
|
+
RETURN NULL;
|
|
1772
|
+
END
|
|
1773
|
+
$slotlock_outbox_resource$;
|
|
1774
|
+
|
|
1775
|
+
-- A source first recorded, and deleted. Reading it again (a new revision) is progress, not a change
|
|
1776
|
+
-- of what the resource is read from, and appends nothing; neither does a resource's deletion, which
|
|
1777
|
+
-- takes its coverage with it and is its own change. The source is named by its random source_id:
|
|
1778
|
+
-- it can be an iCalendar URL with a token in it, or an address.
|
|
1779
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_change()
|
|
1780
|
+
RETURNS trigger
|
|
1781
|
+
LANGUAGE plpgsql
|
|
1782
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1783
|
+
AS $slotlock_outbox_coverage$
|
|
1784
|
+
DECLARE
|
|
1785
|
+
changed slotlock.calendar_coverage%ROWTYPE;
|
|
1786
|
+
BEGIN
|
|
1787
|
+
changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1788
|
+
IF slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
|
|
1789
|
+
IF TG_OP = 'DELETE'
|
|
1790
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.resources resource WHERE resource.id = OLD.resource_id) THEN
|
|
1791
|
+
RETURN NULL;
|
|
1792
|
+
END IF;
|
|
1793
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
1794
|
+
VALUES (
|
|
1795
|
+
changed.tenant_ref, changed.resource_id,
|
|
1796
|
+
CASE WHEN TG_OP = 'DELETE' THEN 'coverage.deleted' ELSE 'coverage.recorded' END,
|
|
1797
|
+
changed.source_id,
|
|
1798
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
1799
|
+
'resource_id', changed.resource_id,
|
|
1800
|
+
'source_id', changed.source_id,
|
|
1801
|
+
'start', CASE WHEN TG_OP = 'INSERT' THEN changed.starts_at END,
|
|
1802
|
+
'end', CASE WHEN TG_OP = 'INSERT' THEN changed.ends_at END,
|
|
1803
|
+
'revision', CASE WHEN TG_OP = 'INSERT' THEN changed.revision END)),
|
|
1804
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
1805
|
+
);
|
|
1806
|
+
RETURN NULL;
|
|
1807
|
+
END
|
|
1808
|
+
$slotlock_outbox_coverage$;
|
|
1809
|
+
|
|
1810
|
+
-- An endpoint added, enabled again or deleted (disabling is webhook.disabled, from the delivery that
|
|
1811
|
+
-- gave up). Its id and the types it takes, never its URL, which can carry a token, or its secret.
|
|
1812
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_webhook_change()
|
|
1813
|
+
RETURNS trigger
|
|
1814
|
+
LANGUAGE plpgsql
|
|
1815
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1816
|
+
AS $slotlock_outbox_webhook$
|
|
1817
|
+
DECLARE
|
|
1818
|
+
changed slotlock.webhook_endpoints%ROWTYPE;
|
|
1819
|
+
kind text;
|
|
1820
|
+
BEGIN
|
|
1821
|
+
changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1822
|
+
IF slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
|
|
1823
|
+
IF TG_OP = 'INSERT' THEN
|
|
1824
|
+
kind := 'webhook.created';
|
|
1825
|
+
ELSIF TG_OP = 'DELETE' THEN
|
|
1826
|
+
kind := 'webhook.deleted';
|
|
1827
|
+
ELSIF OLD.disabled_at IS NOT NULL AND NEW.disabled_at IS NULL THEN
|
|
1828
|
+
kind := 'webhook.enabled';
|
|
1829
|
+
ELSE
|
|
1830
|
+
RETURN NULL;
|
|
1831
|
+
END IF;
|
|
1832
|
+
INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
|
|
1833
|
+
VALUES (
|
|
1834
|
+
changed.tenant_ref, kind, changed.id::text,
|
|
1835
|
+
jsonb_build_object('id', changed.id, 'event_types', to_jsonb(changed.event_types)),
|
|
1836
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
1837
|
+
);
|
|
1838
|
+
RETURN NULL;
|
|
1839
|
+
END
|
|
1840
|
+
$slotlock_outbox_webhook$;
|
|
1841
|
+
|
|
1842
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
|
|
1843
|
+
AFTER INSERT OR UPDATE OR DELETE ON slotlock.resources
|
|
1844
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_resource_change();
|
|
1845
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
|
|
1846
|
+
AFTER INSERT OR DELETE ON slotlock.calendar_coverage
|
|
1847
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
|
|
1848
|
+
|
|
1849
|
+
-- A coverage change an earlier release's server (0.3-0.5) still running writes, as it is inserted:
|
|
1850
|
+
-- named by its source's source_id (src_redacted for a source it cannot find), without the source or
|
|
1851
|
+
-- the error read from it. 0.5.2 and earlier wrote the source itself (an iCalendar URL with a token,
|
|
1852
|
+
-- an address) and its last error; 0.5.3 its guessable digest. This release's own rows are in shape
|
|
1853
|
+
-- and pass unchanged. It runs as the inserting role, under its tenant's RLS like the insert itself.
|
|
1854
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_in_shape()
|
|
1855
|
+
RETURNS trigger
|
|
1856
|
+
LANGUAGE plpgsql
|
|
1857
|
+
SECURITY INVOKER
|
|
1858
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1859
|
+
AS $slotlock_outbox_coverage_in_shape$
|
|
1860
|
+
DECLARE
|
|
1861
|
+
named text;
|
|
1862
|
+
BEGIN
|
|
1863
|
+
IF ${coverageChangeInShapeSql('NEW')} THEN RETURN NEW; END IF;
|
|
1864
|
+
named := ${coverageChangeSourceIdSql('NEW')};
|
|
1865
|
+
NEW.subject := named;
|
|
1866
|
+
NEW.data := (NEW.data - 'source' - 'last_error') || pg_catalog.jsonb_build_object('source_id', named);
|
|
1867
|
+
RETURN NEW;
|
|
1868
|
+
END
|
|
1869
|
+
$slotlock_outbox_coverage_in_shape$;
|
|
1870
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_in_shape
|
|
1871
|
+
BEFORE INSERT ON slotlock.outbox
|
|
1872
|
+
FOR EACH ROW WHEN (NEW.type LIKE 'coverage.%')
|
|
1873
|
+
EXECUTE FUNCTION slotlock.outbox_coverage_in_shape();
|
|
1874
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
|
|
1875
|
+
AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
|
|
1876
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
1877
|
+
-- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
|
|
1878
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
|
|
1879
|
+
AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
|
|
1880
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
1545
1881
|
|
|
1546
1882
|
DROP TRIGGER IF EXISTS slotlock_outbox_reservation_change ON slotlock.reservations;
|
|
1547
1883
|
CREATE TRIGGER slotlock_outbox_reservation_change
|
|
@@ -1584,6 +1920,8 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
|
|
|
1584
1920
|
ends_at timestamptz NOT NULL,
|
|
1585
1921
|
buffer_after_ms bigint NOT NULL DEFAULT 0,
|
|
1586
1922
|
occupied_ends_at timestamptz,
|
|
1923
|
+
buffer_before_ms bigint NOT NULL DEFAULT 0,
|
|
1924
|
+
occupied_starts_at timestamptz,
|
|
1587
1925
|
revision bigint NOT NULL DEFAULT 1,
|
|
1588
1926
|
status text NOT NULL DEFAULT 'confirmed',
|
|
1589
1927
|
expires_at timestamptz,
|
|
@@ -1604,6 +1942,15 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
|
|
|
1604
1942
|
AND occupied_ends_at = ends_at + buffer_after_ms * interval '1 millisecond'
|
|
1605
1943
|
)
|
|
1606
1944
|
),
|
|
1945
|
+
CONSTRAINT slotlock_reservations_buffer_before_valid CHECK (
|
|
1946
|
+
buffer_before_ms >= 0 AND buffer_before_ms <= 86400000
|
|
1947
|
+
),
|
|
1948
|
+
-- NULL only without a leading buffer: a NULL occupied start reads as the start itself.
|
|
1949
|
+
CONSTRAINT slotlock_reservations_occupied_start_valid CHECK (
|
|
1950
|
+
(occupied_starts_at IS NULL AND buffer_before_ms = 0)
|
|
1951
|
+
OR (occupied_starts_at IS NOT NULL
|
|
1952
|
+
AND occupied_starts_at = starts_at - buffer_before_ms * interval '1 millisecond')
|
|
1953
|
+
),
|
|
1607
1954
|
CONSTRAINT slotlock_reservations_status_valid CHECK (status IN ('held', 'confirmed')),
|
|
1608
1955
|
-- A hold must carry its expiry; a confirmed reservation must not (expiry is hold-only state).
|
|
1609
1956
|
CONSTRAINT slotlock_reservations_expiry_shape CHECK (
|
|
@@ -1612,7 +1959,7 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
|
|
|
1612
1959
|
),
|
|
1613
1960
|
CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
1614
1961
|
resource_id WITH =,
|
|
1615
|
-
tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
1962
|
+
tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
1616
1963
|
)
|
|
1617
1964
|
);
|
|
1618
1965
|
|
|
@@ -1644,6 +1991,11 @@ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
|
1644
1991
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
|
|
1645
1992
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
|
|
1646
1993
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_ends_at timestamptz;
|
|
1994
|
+
-- The leading buffer a booking keeps before its start (its resource's bufferBeforeMinutes when it
|
|
1995
|
+
-- was made), and where its occupied time starts: its start less that buffer. A row written before
|
|
1996
|
+
-- leading buffers has none, so its occupied time starts at its start (NULL reads as starts_at).
|
|
1997
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_before_ms bigint NOT NULL DEFAULT 0;
|
|
1998
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_starts_at timestamptz;
|
|
1647
1999
|
-- A keyed hold's holder (its principal) and the group of alternatives it was taken in: an agent
|
|
1648
2000
|
-- releases or books only its own holds, and booking one releases the rest of its group.
|
|
1649
2001
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS holder_ref text;
|
|
@@ -1681,6 +2033,31 @@ BEGIN
|
|
|
1681
2033
|
END IF;
|
|
1682
2034
|
END $$;
|
|
1683
2035
|
|
|
2036
|
+
-- The occupied start, materialized for the same reason as the occupied end.
|
|
2037
|
+
CREATE OR REPLACE FUNCTION slotlock.set_reservation_occupied_start()
|
|
2038
|
+
RETURNS trigger
|
|
2039
|
+
LANGUAGE plpgsql
|
|
2040
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2041
|
+
AS $$
|
|
2042
|
+
BEGIN
|
|
2043
|
+
NEW.occupied_starts_at := NEW.starts_at - NEW.buffer_before_ms * interval '1 millisecond';
|
|
2044
|
+
RETURN NEW;
|
|
2045
|
+
END $$;
|
|
2046
|
+
|
|
2047
|
+
DO $$
|
|
2048
|
+
BEGIN
|
|
2049
|
+
IF NOT EXISTS (
|
|
2050
|
+
SELECT 1 FROM pg_trigger
|
|
2051
|
+
WHERE tgname = 'slotlock_reservations_set_occupied_start'
|
|
2052
|
+
AND tgrelid = 'slotlock.reservations'::regclass
|
|
2053
|
+
AND NOT tgisinternal
|
|
2054
|
+
) THEN
|
|
2055
|
+
CREATE TRIGGER slotlock_reservations_set_occupied_start
|
|
2056
|
+
BEFORE INSERT OR UPDATE OF starts_at, buffer_before_ms ON slotlock.reservations
|
|
2057
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.set_reservation_occupied_start();
|
|
2058
|
+
END IF;
|
|
2059
|
+
END $$;
|
|
2060
|
+
|
|
1684
2061
|
-- Cancellation is an idempotency fact, not the absence of a reservation. Keeping a tombstone
|
|
1685
2062
|
-- prevents delayed at-least-once create delivery from resurrecting a cancelled booking key.
|
|
1686
2063
|
CREATE TABLE IF NOT EXISTS slotlock.reservation_tombstones (
|
|
@@ -1975,6 +2352,34 @@ BEGIN
|
|
|
1975
2352
|
buffer_after_ms >= 0 AND buffer_after_ms <= 2592000000
|
|
1976
2353
|
) NOT VALID;
|
|
1977
2354
|
END IF;
|
|
2355
|
+
-- Every existing row has no leading buffer, so both hold of it; added NOT VALID (no scan under
|
|
2356
|
+
-- the lock) and validated after the migration commits.
|
|
2357
|
+
IF NOT EXISTS (
|
|
2358
|
+
SELECT 1 FROM pg_constraint
|
|
2359
|
+
WHERE conname = 'slotlock_reservations_buffer_before_valid'
|
|
2360
|
+
AND conrelid = 'slotlock.reservations'::regclass
|
|
2361
|
+
) THEN
|
|
2362
|
+
ALTER TABLE slotlock.reservations
|
|
2363
|
+
ADD CONSTRAINT slotlock_reservations_buffer_before_valid CHECK (
|
|
2364
|
+
buffer_before_ms >= 0 AND buffer_before_ms <= 86400000
|
|
2365
|
+
) NOT VALID;
|
|
2366
|
+
END IF;
|
|
2367
|
+
-- A first form of this check let a NULL occupied start through with a leading buffer (the
|
|
2368
|
+
-- comparison is NULL, which a CHECK passes): replaced wherever it lacks the IS NOT NULL guard.
|
|
2369
|
+
IF NOT EXISTS (
|
|
2370
|
+
SELECT 1 FROM pg_constraint
|
|
2371
|
+
WHERE conname = 'slotlock_reservations_occupied_start_valid'
|
|
2372
|
+
AND conrelid = 'slotlock.reservations'::regclass
|
|
2373
|
+
AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_starts_at IS NOT NULL%'
|
|
2374
|
+
) THEN
|
|
2375
|
+
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_occupied_start_valid;
|
|
2376
|
+
ALTER TABLE slotlock.reservations
|
|
2377
|
+
ADD CONSTRAINT slotlock_reservations_occupied_start_valid CHECK (
|
|
2378
|
+
(occupied_starts_at IS NULL AND buffer_before_ms = 0)
|
|
2379
|
+
OR (occupied_starts_at IS NOT NULL
|
|
2380
|
+
AND occupied_starts_at = starts_at - buffer_before_ms * interval '1 millisecond')
|
|
2381
|
+
) NOT VALID;
|
|
2382
|
+
END IF;
|
|
1978
2383
|
END $$;
|
|
1979
2384
|
|
|
1980
2385
|
-- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
|
|
@@ -2025,13 +2430,35 @@ BEGIN
|
|
|
2025
2430
|
ALTER TABLE slotlock.reservations
|
|
2026
2431
|
ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
2027
2432
|
resource_id WITH =,
|
|
2028
|
-
tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
2433
|
+
tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
2029
2434
|
);
|
|
2030
2435
|
END IF;
|
|
2031
2436
|
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_buffered_no_overlap;
|
|
2032
2437
|
END
|
|
2033
2438
|
$slotlock_occupied_overlap$;
|
|
2034
2439
|
|
|
2440
|
+
-- A row occupies from its occupied start too: its start less its leading buffer. Every row an
|
|
2441
|
+
-- upgrade finds was written before leading buffers and keeps none, so its range is the one the old
|
|
2442
|
+
-- constraint already kept apart and none can conflict: the constraint is replaced in place, its
|
|
2443
|
+
-- index built once under the migration's lock.
|
|
2444
|
+
DO $slotlock_occupied_start_overlap$
|
|
2445
|
+
BEGIN
|
|
2446
|
+
IF NOT EXISTS (
|
|
2447
|
+
SELECT 1 FROM pg_constraint
|
|
2448
|
+
WHERE conname = 'slotlock_reservations_no_overlap'
|
|
2449
|
+
AND conrelid = 'slotlock.reservations'::regclass
|
|
2450
|
+
AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_starts_at%'
|
|
2451
|
+
) THEN
|
|
2452
|
+
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_no_overlap;
|
|
2453
|
+
ALTER TABLE slotlock.reservations
|
|
2454
|
+
ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
2455
|
+
resource_id WITH =,
|
|
2456
|
+
tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
2457
|
+
);
|
|
2458
|
+
END IF;
|
|
2459
|
+
END
|
|
2460
|
+
$slotlock_occupied_start_overlap$;
|
|
2461
|
+
|
|
2035
2462
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_resource_time_idx
|
|
2036
2463
|
ON slotlock.reservations (resource_id, starts_at, ends_at);
|
|
2037
2464
|
|
|
@@ -2231,6 +2658,10 @@ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by
|
|
|
2231
2658
|
ALTER TABLE slotlock.calendar_events
|
|
2232
2659
|
ADD COLUMN IF NOT EXISTS buffer_after_ms integer NOT NULL DEFAULT 0
|
|
2233
2660
|
CONSTRAINT slotlock_calendar_events_buffer_valid CHECK (buffer_after_ms BETWEEN 0 AND 86400000);
|
|
2661
|
+
-- The leading buffer a booked event keeps before each occurrence, kept for a later roll alike.
|
|
2662
|
+
ALTER TABLE slotlock.calendar_events
|
|
2663
|
+
ADD COLUMN IF NOT EXISTS buffer_before_ms integer NOT NULL DEFAULT 0
|
|
2664
|
+
CONSTRAINT slotlock_calendar_events_buffer_before_valid CHECK (buffer_before_ms BETWEEN 0 AND 86400000);
|
|
2234
2665
|
ALTER TABLE slotlock.calendar_events
|
|
2235
2666
|
DROP CONSTRAINT IF EXISTS slotlock_calendar_events_recurrence_cursor_bounded;
|
|
2236
2667
|
DO $slotlock_calendar_event_recurrence_cursor$
|
|
@@ -2765,8 +3196,108 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
2765
3196
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
2766
3197
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
2767
3198
|
${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
3199
|
+
/**
|
|
3200
|
+
* Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
|
|
3201
|
+
* or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
|
|
3202
|
+
*/
|
|
3203
|
+
function coverageChangeInShapeSql(row) {
|
|
3204
|
+
return `(${row}.subject ~ '^src_([0-9a-f]{32}|redacted)$'
|
|
3205
|
+
AND ${row}.data ->> 'source_id' IS NOT DISTINCT FROM ${row}.subject
|
|
3206
|
+
AND NOT ${row}.data ?| ARRAY['source', 'last_error'])`;
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* The source_id an earlier release's coverage change `row` names its source by: the source with
|
|
3210
|
+
* its tenant and resource whose source (0.5.2 and earlier: the subject, or `data.source`) or 0.5.3
|
|
3211
|
+
* digest (the subject) it carries, else src_redacted.
|
|
3212
|
+
*/
|
|
3213
|
+
function coverageChangeSourceIdSql(row) {
|
|
3214
|
+
return `COALESCE((
|
|
3215
|
+
SELECT coverage.source_id FROM slotlock.calendar_coverage coverage
|
|
3216
|
+
WHERE coverage.tenant_ref = ${row}.tenant_ref
|
|
3217
|
+
AND coverage.resource_id = ${row}.resource_id
|
|
3218
|
+
AND coverage.source_id IS NOT NULL
|
|
3219
|
+
AND (coverage.source = ${row}.subject
|
|
3220
|
+
OR coverage.source = ${row}.data ->> 'source'
|
|
3221
|
+
OR ${legacyCoverageSourceDigestSql('coverage.tenant_ref', 'coverage.source')} = ${row}.subject)
|
|
3222
|
+
LIMIT 1), 'src_redacted')`;
|
|
3223
|
+
}
|
|
3224
|
+
/** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
|
|
3225
|
+
export const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5_000;
|
|
3226
|
+
/**
|
|
3227
|
+
* One batch of the rename of coverage changes an earlier release wrote: of the outbox rows with ids
|
|
3228
|
+
* after `after` up to `head` (SQL expressions, bigint), the first `batch` by id, each `coverage.*`
|
|
3229
|
+
* one not yet in this release's shape gets its source's source_id as subject and `data.source_id`,
|
|
3230
|
+
* and loses `source` and `last_error`. Before 0.5.3 coverage.stale carried the source itself (an
|
|
3231
|
+
* iCalendar URL with a token, an address) and the error read from it; 0.5.3 named it by an unkeyed
|
|
3232
|
+
* digest anyone could confirm a guess against. A source deleted since is `src_redacted`: nothing
|
|
3233
|
+
* can match it any more, and a made-up id would look like a source the tenant could find. Selects
|
|
3234
|
+
* the last id read (`last`, null when there was none) and how many it renamed (`renamed`), `INTO`
|
|
3235
|
+
* `into` when given (PL/pgSQL). Rows in the new shape are left alone, so a batch run again changes
|
|
3236
|
+
* nothing.
|
|
3237
|
+
*/
|
|
3238
|
+
export function slotlockCoverageChangeRenameSql(after, head, batch, into = '') {
|
|
3239
|
+
return `WITH scanned AS MATERIALIZED (
|
|
3240
|
+
SELECT id FROM slotlock.outbox
|
|
3241
|
+
WHERE id > ${after} AND id <= ${head}
|
|
3242
|
+
ORDER BY id
|
|
3243
|
+
LIMIT ${batch}
|
|
3244
|
+
), earlier AS (
|
|
3245
|
+
SELECT change.id, change.tenant_ref, change.resource_id, change.subject, change.data
|
|
3246
|
+
FROM slotlock.outbox change
|
|
3247
|
+
WHERE change.id IN (SELECT scanned.id FROM scanned)
|
|
3248
|
+
AND change.type LIKE 'coverage.%'
|
|
3249
|
+
AND NOT ${coverageChangeInShapeSql('change')}
|
|
3250
|
+
FOR UPDATE OF change
|
|
3251
|
+
), named AS (
|
|
3252
|
+
SELECT earlier.id, ${coverageChangeSourceIdSql('earlier')} AS source_id
|
|
3253
|
+
FROM earlier
|
|
3254
|
+
), renamed AS (
|
|
3255
|
+
UPDATE slotlock.outbox change
|
|
3256
|
+
SET subject = named.source_id,
|
|
3257
|
+
data = (change.data - 'source' - 'last_error') || jsonb_build_object('source_id', named.source_id)
|
|
3258
|
+
FROM named
|
|
3259
|
+
WHERE change.id = named.id
|
|
3260
|
+
RETURNING change.id
|
|
3261
|
+
)
|
|
3262
|
+
SELECT (SELECT max(scanned.id) FROM scanned)::text AS last,
|
|
3263
|
+
(SELECT count(*) FROM renamed)::integer AS renamed${into ? `
|
|
3264
|
+
INTO ${into}` : ''}`;
|
|
3265
|
+
}
|
|
3266
|
+
// The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction, while FORCE is lifted (the
|
|
3267
|
+
// structure has the outbox under ACCESS EXCLUSIVE for the whole transaction, so no change commits
|
|
3268
|
+
// in between). It goes on from where the last pass stopped, and is skipped once done. applySchema
|
|
3269
|
+
// runs it in batches after the structure commits instead, each its own short transaction.
|
|
3270
|
+
const DEPLOYMENT_RENAME_COVERAGE_CHANGES = `DO $slotlock_rename_coverage_changes$
|
|
3271
|
+
DECLARE
|
|
3272
|
+
scan_after bigint;
|
|
3273
|
+
scan_head bigint;
|
|
3274
|
+
scan_last text;
|
|
3275
|
+
scan_renamed integer;
|
|
3276
|
+
renamed_total integer := 0;
|
|
3277
|
+
first_pass boolean;
|
|
3278
|
+
BEGIN
|
|
3279
|
+
IF (SELECT coverage_changes_renamed FROM slotlock.schema_version WHERE singleton) THEN
|
|
3280
|
+
RETURN;
|
|
3281
|
+
END IF;
|
|
3282
|
+
SELECT coverage_changes_renamed_through INTO scan_after FROM slotlock.schema_version WHERE singleton;
|
|
3283
|
+
first_pass := scan_after IS NULL;
|
|
3284
|
+
scan_after := COALESCE(scan_after, 0);
|
|
3285
|
+
SELECT COALESCE(max(id), 0) INTO scan_head FROM slotlock.outbox;
|
|
3286
|
+
LOOP
|
|
3287
|
+
${slotlockCoverageChangeRenameSql('scan_after', 'scan_head', String(SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH), 'scan_last, scan_renamed')};
|
|
3288
|
+
EXIT WHEN scan_last IS NULL;
|
|
3289
|
+
scan_after := scan_last::bigint;
|
|
3290
|
+
renamed_total := renamed_total + scan_renamed;
|
|
3291
|
+
END LOOP;
|
|
3292
|
+
UPDATE slotlock.schema_version
|
|
3293
|
+
SET coverage_changes_renamed_through = GREATEST(scan_after, scan_head),
|
|
3294
|
+
coverage_changes_renamed = NOT first_pass AND renamed_total = 0
|
|
3295
|
+
WHERE singleton;
|
|
3296
|
+
END
|
|
3297
|
+
$slotlock_rename_coverage_changes$;
|
|
3298
|
+
`;
|
|
2768
3299
|
/** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
|
|
2769
|
-
export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
3300
|
+
export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_RENAME_COVERAGE_CHANGES}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
2770
3301
|
/**
|
|
2771
3302
|
* SLOTLOCK_CORE_DDL without the validation, which applySchema runs as a step of its own after this
|
|
2772
3303
|
* commits (SLOTLOCK_VALIDATION_DDL).
|
|
@@ -2796,6 +3327,7 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
2796
3327
|
'outbox_pruned',
|
|
2797
3328
|
'webhook_endpoints',
|
|
2798
3329
|
'waitlist_entries',
|
|
3330
|
+
'booking_rule_defaults',
|
|
2799
3331
|
'assistant_settings',
|
|
2800
3332
|
'assistant_conversations',
|
|
2801
3333
|
]);
|
|
@@ -2867,6 +3399,8 @@ ALTER TABLE slotlock.webhook_endpoints ENABLE ROW LEVEL SECURITY;
|
|
|
2867
3399
|
ALTER TABLE slotlock.webhook_endpoints FORCE ROW LEVEL SECURITY;
|
|
2868
3400
|
ALTER TABLE slotlock.waitlist_entries ENABLE ROW LEVEL SECURITY;
|
|
2869
3401
|
ALTER TABLE slotlock.waitlist_entries FORCE ROW LEVEL SECURITY;
|
|
3402
|
+
ALTER TABLE slotlock.booking_rule_defaults ENABLE ROW LEVEL SECURITY;
|
|
3403
|
+
ALTER TABLE slotlock.booking_rule_defaults FORCE ROW LEVEL SECURITY;
|
|
2870
3404
|
ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
|
|
2871
3405
|
ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
|
|
2872
3406
|
ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
|
|
@@ -2900,6 +3434,7 @@ BEGIN
|
|
|
2900
3434
|
'outbox_pruned',
|
|
2901
3435
|
'webhook_endpoints',
|
|
2902
3436
|
'waitlist_entries',
|
|
3437
|
+
'booking_rule_defaults',
|
|
2903
3438
|
'assistant_settings',
|
|
2904
3439
|
'assistant_conversations'
|
|
2905
3440
|
)
|
|
@@ -3113,6 +3648,16 @@ BEGIN
|
|
|
3113
3648
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
3114
3649
|
END IF;
|
|
3115
3650
|
|
|
3651
|
+
IF NOT EXISTS (
|
|
3652
|
+
SELECT 1 FROM pg_policies
|
|
3653
|
+
WHERE schemaname = 'slotlock' AND tablename = 'booking_rule_defaults'
|
|
3654
|
+
AND policyname = 'tenant_isolation_v1'
|
|
3655
|
+
) THEN
|
|
3656
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.booking_rule_defaults
|
|
3657
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
3658
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
3659
|
+
END IF;
|
|
3660
|
+
|
|
3116
3661
|
IF NOT EXISTS (
|
|
3117
3662
|
SELECT 1 FROM pg_policies
|
|
3118
3663
|
WHERE schemaname = 'slotlock' AND tablename = 'assistant_settings'
|
|
@@ -3169,6 +3714,8 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.webhook_endpoints
|
|
|
3169
3714
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3170
3715
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.waitlist_entries
|
|
3171
3716
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3717
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.booking_rule_defaults
|
|
3718
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3172
3719
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
|
|
3173
3720
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3174
3721
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
|
|
@@ -3213,6 +3760,7 @@ WHERE schemaname = 'slotlock'
|
|
|
3213
3760
|
'outbox_pruned',
|
|
3214
3761
|
'webhook_endpoints',
|
|
3215
3762
|
'waitlist_entries',
|
|
3763
|
+
'booking_rule_defaults',
|
|
3216
3764
|
'assistant_settings',
|
|
3217
3765
|
'assistant_conversations'
|
|
3218
3766
|
)
|