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/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 = 3;
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 slotlock_webhook_endpoints_types_valid CHECK (
1279
- event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 20
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
  )