slotlock 0.5.2 → 0.5.3

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, slotlockCoverageSourceIdSql } 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);
@@ -550,6 +551,55 @@ AS $slotlock_erase_api_keys$
550
551
  SELECT count(*) FROM erased;
551
552
  $slotlock_erase_api_keys$;
552
553
  REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[5]} FROM PUBLIC;
554
+
555
+ -- A key created, given a new secret or revoked appends to its tenant's feed (key.created,
556
+ -- key.rotated, key.revoked): its id, scopes and expiry, never its digest, prefix or name. Only the
557
+ -- functions above write keys, as the schema's owner, which forced row-level security on the feed
558
+ -- binds too: the row is appended under the tenant's name in every setting a policy reads, and the
559
+ -- caller's values are put back after. Who did it is the caller's actor, else the key's creator.
560
+ CREATE OR REPLACE FUNCTION slotlock.outbox_api_key_change()
561
+ RETURNS trigger
562
+ LANGUAGE plpgsql
563
+ SET search_path = pg_catalog, slotlock, pg_temp
564
+ AS $slotlock_outbox_api_key$
565
+ DECLARE
566
+ kind text;
567
+ setting text;
568
+ previous jsonb := '{}'::jsonb;
569
+ BEGIN
570
+ IF TG_OP = 'INSERT' THEN
571
+ kind := 'key.created';
572
+ ELSIF OLD.revoked_at IS NULL AND NEW.revoked_at IS NOT NULL THEN
573
+ kind := 'key.revoked';
574
+ ELSIF OLD.secret_hash IS DISTINCT FROM NEW.secret_hash THEN
575
+ kind := 'key.rotated';
576
+ ELSE
577
+ RETURN NULL;
578
+ END IF;
579
+ FOR setting IN
580
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
581
+ LOOP
582
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
583
+ PERFORM set_config(setting, NEW.tenant_ref, true);
584
+ END LOOP;
585
+ INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
586
+ VALUES (
587
+ NEW.tenant_ref, kind, NEW.id::text,
588
+ jsonb_build_object(
589
+ 'id', NEW.id, 'scopes', to_jsonb(NEW.scopes), 'expires_at', NEW.expires_at,
590
+ 'revoked_at', NEW.revoked_at),
591
+ COALESCE(NULLIF(current_setting('slotlock.actor', true), ''),
592
+ CASE WHEN TG_OP = 'INSERT' THEN NEW.created_by END)
593
+ );
594
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
595
+ PERFORM set_config(setting, COALESCE(previous ->> setting, ''), true);
596
+ END LOOP;
597
+ RETURN NULL;
598
+ END
599
+ $slotlock_outbox_api_key$;
600
+ CREATE OR REPLACE TRIGGER slotlock_outbox_api_key_change
601
+ AFTER INSERT OR UPDATE OF revoked_at, secret_hash ON slotlock.api_keys
602
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_api_key_change();
553
603
  `;
554
604
  // The dashboard's records (dashboard.ts), shared by every server on this database: a finished sign-in
555
605
  // (its callback works once), a sent form (it runs once) and a signed-out session (a copied cookie
@@ -875,6 +925,10 @@ BEGIN
875
925
  DELETE FROM slotlock.waitlist_entries WHERE tenant_ref = requested_tenant_ref;
876
926
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
877
927
  RETURN NEXT;
928
+ erased_table := 'booking_rule_defaults';
929
+ DELETE FROM slotlock.booking_rule_defaults WHERE tenant_ref = requested_tenant_ref;
930
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
931
+ RETURN NEXT;
878
932
  erased_table := 'assistant_conversations';
879
933
  DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
880
934
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
@@ -1194,6 +1248,36 @@ CREATE TABLE IF NOT EXISTS slotlock.outbox (
1194
1248
  );
1195
1249
  -- Who made the change (withTenant's actor: an API key, an agent), for the history.
1196
1250
  ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS actor text;
1251
+ -- Where it came from (withTenant's origin: mcp, a2a, rest, application, dashboard,
1252
+ -- dashboard-assistant, maintenance, library) and the API key that made it (withTenant's keyId).
1253
+ -- Added bare, so the rows already there keep null (an earlier release) and nothing is scanned under
1254
+ -- the lock; the defaults then bind only new rows. A write that names no origin (code calling the
1255
+ -- store outside withTenant, or SQL of its own) is \`library\`.
1256
+ ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS origin text;
1257
+ ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS key_id uuid;
1258
+ ALTER TABLE slotlock.outbox
1259
+ ALTER COLUMN origin SET DEFAULT COALESCE(NULLIF(current_setting('slotlock.origin', true), ''), 'library'),
1260
+ ALTER COLUMN key_id SET DEFAULT NULLIF(current_setting('slotlock.key_id', true), '')::uuid;
1261
+ -- The origins the API reads, and no other. NOT VALID, so adding it scans nothing under the lock;
1262
+ -- DEPLOYMENT_VALIDATE_CONSTRAINTS validates it after the migration commits. Its comment is the list
1263
+ -- it was built from: a release that changes the list replaces it, so an upgraded database never
1264
+ -- keeps refusing an origin the release writes.
1265
+ DO $slotlock_outbox_origin$
1266
+ BEGIN
1267
+ ALTER TABLE slotlock.outbox DROP CONSTRAINT IF EXISTS slotlock_outbox_origin_valid;
1268
+ IF (
1269
+ SELECT pg_catalog.obj_description(oid, 'pg_constraint') FROM pg_catalog.pg_constraint
1270
+ WHERE conrelid = 'slotlock.outbox'::regclass AND conname = 'slotlock_outbox_origin_known'
1271
+ ) IS DISTINCT FROM '${SLOTLOCK_CHANGE_ORIGINS.join(' ')}' THEN
1272
+ ALTER TABLE slotlock.outbox DROP CONSTRAINT IF EXISTS slotlock_outbox_origin_known;
1273
+ ALTER TABLE slotlock.outbox
1274
+ ADD CONSTRAINT slotlock_outbox_origin_known
1275
+ CHECK (origin IN (${SLOTLOCK_CHANGE_ORIGINS.map((origin) => `'${origin}'`).join(', ')})) NOT VALID;
1276
+ COMMENT ON CONSTRAINT slotlock_outbox_origin_known ON slotlock.outbox
1277
+ IS '${SLOTLOCK_CHANGE_ORIGINS.join(' ')}';
1278
+ END IF;
1279
+ END
1280
+ $slotlock_outbox_origin$;
1197
1281
  CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_resource_idx
1198
1282
  ON slotlock.outbox (tenant_ref, resource_id, id) WHERE resource_id IS NOT NULL;
1199
1283
  CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_subject_idx
@@ -1248,6 +1332,10 @@ $slotlock_outbox_mark_pruned$;
1248
1332
  CREATE OR REPLACE TRIGGER slotlock_outbox_mark_pruned
1249
1333
  AFTER DELETE ON slotlock.outbox REFERENCING OLD TABLE AS pruned
1250
1334
  FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_mark_pruned();
1335
+ -- Live updates poll each tenant's feed (change-watch.ts). A pre-release build woke readers with a
1336
+ -- notification trigger here, which made every outbox commit take the cluster-wide notify lock: drop it.
1337
+ DROP TRIGGER IF EXISTS slotlock_outbox_notify ON slotlock.outbox;
1338
+ DROP FUNCTION IF EXISTS slotlock.outbox_notify();
1251
1339
 
1252
1340
  CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
1253
1341
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1275,14 +1363,31 @@ CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
1275
1363
  AND octet_length(url) BETWEEN 1 AND 2048
1276
1364
  AND secret ~ '^whsec_[A-Za-z0-9+/]{43}=$'
1277
1365
  ),
1278
- CONSTRAINT slotlock_webhook_endpoints_types_valid CHECK (
1279
- event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 20
1366
+ CONSTRAINT slotlock_webhook_endpoints_type_count_valid CHECK (
1367
+ event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 64
1280
1368
  ),
1281
1369
  CONSTRAINT slotlock_webhook_endpoints_error_valid CHECK (
1282
1370
  last_error IS NULL OR char_length(last_error) <= 500
1283
1371
  )
1284
1372
  );
1285
1373
  ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS changes_pruned boolean NOT NULL DEFAULT false;
1374
+ -- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
1375
+ -- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
1376
+ DO $slotlock_webhook_type_count$
1377
+ BEGIN
1378
+ IF NOT EXISTS (
1379
+ SELECT 1 FROM pg_catalog.pg_constraint
1380
+ WHERE conrelid = 'slotlock.webhook_endpoints'::regclass
1381
+ AND conname = 'slotlock_webhook_endpoints_type_count_valid'
1382
+ ) THEN
1383
+ ALTER TABLE slotlock.webhook_endpoints
1384
+ ADD CONSTRAINT slotlock_webhook_endpoints_type_count_valid CHECK (
1385
+ event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 64
1386
+ ) NOT VALID;
1387
+ END IF;
1388
+ ALTER TABLE slotlock.webhook_endpoints DROP CONSTRAINT IF EXISTS slotlock_webhook_endpoints_types_valid;
1389
+ END
1390
+ $slotlock_webhook_type_count$;
1286
1391
  CREATE INDEX IF NOT EXISTS slotlock_webhook_endpoints_tenant_idx
1287
1392
  ON slotlock.webhook_endpoints (tenant_ref, created_at);
1288
1393
 
@@ -1321,6 +1426,23 @@ CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
1321
1426
  CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
1322
1427
  ON slotlock.waitlist_entries (tenant_ref, starts_at);
1323
1428
 
1429
+ -- Default booking rules (booking-rules.ts), for the tenant (pool_ref '') or for one of its pools:
1430
+ -- a resource's rules are the tenant's, overlaid by its pool's, overlaid by its own.
1431
+ CREATE TABLE IF NOT EXISTS slotlock.booking_rule_defaults (
1432
+ tenant_ref text NOT NULL,
1433
+ pool_ref text NOT NULL DEFAULT '',
1434
+ rules jsonb NOT NULL,
1435
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1436
+ CONSTRAINT slotlock_booking_rule_defaults_pkey PRIMARY KEY (tenant_ref, pool_ref),
1437
+ CONSTRAINT slotlock_booking_rule_defaults_identity_valid CHECK (
1438
+ octet_length(tenant_ref) BETWEEN 1 AND 500
1439
+ AND (pool_ref = '' OR pool_ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$')
1440
+ ),
1441
+ CONSTRAINT slotlock_booking_rule_defaults_rules_valid CHECK (
1442
+ jsonb_typeof(rules) = 'object' AND octet_length(rules::text) <= 4096
1443
+ )
1444
+ );
1445
+
1324
1446
  -- The model a tenant's dashboard assistant uses (dashboard.ts, assistant.ts), one row a tenant.
1325
1447
  -- The API key is stored only sealed (AES-256-GCM under the deployment's SLOTLOCK_ASSISTANT_SECRET,
1326
1448
  -- bound to the tenant), with its last four characters to show.
@@ -1543,6 +1665,163 @@ ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error text;
1543
1665
  ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error_at timestamptz;
1544
1666
  ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS stale_reported_at timestamptz;
1545
1667
 
1668
+ -- Configuration changes (RFC 0002 P1-6): a resource's own settings, the sources its coverage is
1669
+ -- read from, and the tenant's webhook endpoints. Each change that alters something appends one row,
1670
+ -- with what it was before and after for the fields it changed; writing the same value again appends
1671
+ -- none. Erasure is not reported, as above.
1672
+ CREATE OR REPLACE FUNCTION slotlock.outbox_erasing(owner text)
1673
+ RETURNS boolean
1674
+ LANGUAGE sql
1675
+ STABLE
1676
+ SET search_path = pg_catalog, slotlock, pg_temp
1677
+ AS $slotlock_outbox_erasing$
1678
+ SELECT owner = current_setting('slotlock.erasing', true)
1679
+ AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
1680
+ WHERE nspname = 'slotlock');
1681
+ $slotlock_outbox_erasing$;
1682
+
1683
+ CREATE OR REPLACE FUNCTION slotlock.outbox_resource_change()
1684
+ RETURNS trigger
1685
+ LANGUAGE plpgsql
1686
+ SET search_path = pg_catalog, slotlock, pg_temp
1687
+ AS $slotlock_outbox_resource$
1688
+ DECLARE
1689
+ changed slotlock.resources%ROWTYPE;
1690
+ kind text;
1691
+ before jsonb := '{}'::jsonb;
1692
+ after jsonb := '{}'::jsonb;
1693
+ details jsonb;
1694
+ BEGIN
1695
+ changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1696
+ IF changed.tenant_ref IS NULL OR slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
1697
+ IF TG_OP = 'INSERT' THEN
1698
+ kind := 'resource.created';
1699
+ details := jsonb_build_object(
1700
+ 'timezone', NEW.timezone, 'visibility', NEW.visibility, 'pool', NEW.pool_ref,
1701
+ 'availability', NEW.availability_rules, 'booking_rules', NEW.booking_rules);
1702
+ ELSIF TG_OP = 'DELETE' THEN
1703
+ kind := 'resource.deleted';
1704
+ details := '{}'::jsonb;
1705
+ ELSIF (OLD.archived_at IS NULL) <> (NEW.archived_at IS NULL) THEN
1706
+ kind := CASE WHEN NEW.archived_at IS NULL THEN 'resource.restored' ELSE 'resource.archived' END;
1707
+ details := jsonb_build_object('archived_at', NEW.archived_at);
1708
+ ELSE
1709
+ -- Only what changed, under the names the API gives it; a null is kept (no pool, no rules).
1710
+ IF OLD.external_ref IS DISTINCT FROM NEW.external_ref THEN
1711
+ before := before || jsonb_build_object('reference', OLD.external_ref);
1712
+ after := after || jsonb_build_object('reference', NEW.external_ref);
1713
+ END IF;
1714
+ IF OLD.timezone IS DISTINCT FROM NEW.timezone THEN
1715
+ before := before || jsonb_build_object('timezone', OLD.timezone);
1716
+ after := after || jsonb_build_object('timezone', NEW.timezone);
1717
+ END IF;
1718
+ IF OLD.pool_ref IS DISTINCT FROM NEW.pool_ref THEN
1719
+ before := before || jsonb_build_object('pool', OLD.pool_ref);
1720
+ after := after || jsonb_build_object('pool', NEW.pool_ref);
1721
+ END IF;
1722
+ IF OLD.availability_rules IS DISTINCT FROM NEW.availability_rules THEN
1723
+ before := before || jsonb_build_object('availability', OLD.availability_rules);
1724
+ after := after || jsonb_build_object('availability', NEW.availability_rules);
1725
+ END IF;
1726
+ IF OLD.booking_rules IS DISTINCT FROM NEW.booking_rules THEN
1727
+ before := before || jsonb_build_object('booking_rules', OLD.booking_rules);
1728
+ after := after || jsonb_build_object('booking_rules', NEW.booking_rules);
1729
+ END IF;
1730
+ IF after = '{}'::jsonb THEN RETURN NULL; END IF;
1731
+ kind := 'resource.updated';
1732
+ details := jsonb_build_object('before', before, 'after', after);
1733
+ END IF;
1734
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1735
+ VALUES (
1736
+ changed.tenant_ref, changed.id, kind, changed.id::text,
1737
+ jsonb_build_object('id', changed.id, 'reference', changed.external_ref) || details,
1738
+ NULLIF(current_setting('slotlock.actor', true), '')
1739
+ );
1740
+ RETURN NULL;
1741
+ END
1742
+ $slotlock_outbox_resource$;
1743
+
1744
+ -- A source first recorded, and deleted. Reading it again (a new revision) is progress, not a change
1745
+ -- of what the resource is read from, and appends nothing; neither does a resource's deletion, which
1746
+ -- takes its coverage with it and is its own change. The source is named by its digest (source_id):
1747
+ -- it can be an iCalendar URL with a token in it, or an address.
1748
+ CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_change()
1749
+ RETURNS trigger
1750
+ LANGUAGE plpgsql
1751
+ SET search_path = pg_catalog, slotlock, pg_temp
1752
+ AS $slotlock_outbox_coverage$
1753
+ DECLARE
1754
+ changed slotlock.calendar_coverage%ROWTYPE;
1755
+ BEGIN
1756
+ changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1757
+ IF slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
1758
+ IF TG_OP = 'DELETE'
1759
+ AND NOT EXISTS (SELECT 1 FROM slotlock.resources resource WHERE resource.id = OLD.resource_id) THEN
1760
+ RETURN NULL;
1761
+ END IF;
1762
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1763
+ VALUES (
1764
+ changed.tenant_ref, changed.resource_id,
1765
+ CASE WHEN TG_OP = 'DELETE' THEN 'coverage.deleted' ELSE 'coverage.recorded' END,
1766
+ ${slotlockCoverageSourceIdSql('changed.tenant_ref', 'changed.source')},
1767
+ jsonb_strip_nulls(jsonb_build_object(
1768
+ 'resource_id', changed.resource_id,
1769
+ 'source_id', ${slotlockCoverageSourceIdSql('changed.tenant_ref', 'changed.source')},
1770
+ 'start', CASE WHEN TG_OP = 'INSERT' THEN changed.starts_at END,
1771
+ 'end', CASE WHEN TG_OP = 'INSERT' THEN changed.ends_at END,
1772
+ 'revision', CASE WHEN TG_OP = 'INSERT' THEN changed.revision END)),
1773
+ NULLIF(current_setting('slotlock.actor', true), '')
1774
+ );
1775
+ RETURN NULL;
1776
+ END
1777
+ $slotlock_outbox_coverage$;
1778
+
1779
+ -- An endpoint added, enabled again or deleted (disabling is webhook.disabled, from the delivery that
1780
+ -- gave up). Its id and the types it takes, never its URL, which can carry a token, or its secret.
1781
+ CREATE OR REPLACE FUNCTION slotlock.outbox_webhook_change()
1782
+ RETURNS trigger
1783
+ LANGUAGE plpgsql
1784
+ SET search_path = pg_catalog, slotlock, pg_temp
1785
+ AS $slotlock_outbox_webhook$
1786
+ DECLARE
1787
+ changed slotlock.webhook_endpoints%ROWTYPE;
1788
+ kind text;
1789
+ BEGIN
1790
+ changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1791
+ IF slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
1792
+ IF TG_OP = 'INSERT' THEN
1793
+ kind := 'webhook.created';
1794
+ ELSIF TG_OP = 'DELETE' THEN
1795
+ kind := 'webhook.deleted';
1796
+ ELSIF OLD.disabled_at IS NOT NULL AND NEW.disabled_at IS NULL THEN
1797
+ kind := 'webhook.enabled';
1798
+ ELSE
1799
+ RETURN NULL;
1800
+ END IF;
1801
+ INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
1802
+ VALUES (
1803
+ changed.tenant_ref, kind, changed.id::text,
1804
+ jsonb_build_object('id', changed.id, 'event_types', to_jsonb(changed.event_types)),
1805
+ NULLIF(current_setting('slotlock.actor', true), '')
1806
+ );
1807
+ RETURN NULL;
1808
+ END
1809
+ $slotlock_outbox_webhook$;
1810
+
1811
+ CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
1812
+ AFTER INSERT OR UPDATE OR DELETE ON slotlock.resources
1813
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_resource_change();
1814
+ CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
1815
+ AFTER INSERT OR DELETE ON slotlock.calendar_coverage
1816
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
1817
+ CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
1818
+ AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
1819
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
1820
+ -- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
1821
+ CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
1822
+ AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
1823
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
1824
+
1546
1825
  DROP TRIGGER IF EXISTS slotlock_outbox_reservation_change ON slotlock.reservations;
1547
1826
  CREATE TRIGGER slotlock_outbox_reservation_change
1548
1827
  AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
@@ -1584,6 +1863,8 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
1584
1863
  ends_at timestamptz NOT NULL,
1585
1864
  buffer_after_ms bigint NOT NULL DEFAULT 0,
1586
1865
  occupied_ends_at timestamptz,
1866
+ buffer_before_ms bigint NOT NULL DEFAULT 0,
1867
+ occupied_starts_at timestamptz,
1587
1868
  revision bigint NOT NULL DEFAULT 1,
1588
1869
  status text NOT NULL DEFAULT 'confirmed',
1589
1870
  expires_at timestamptz,
@@ -1604,6 +1885,15 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
1604
1885
  AND occupied_ends_at = ends_at + buffer_after_ms * interval '1 millisecond'
1605
1886
  )
1606
1887
  ),
1888
+ CONSTRAINT slotlock_reservations_buffer_before_valid CHECK (
1889
+ buffer_before_ms >= 0 AND buffer_before_ms <= 86400000
1890
+ ),
1891
+ -- NULL only without a leading buffer: a NULL occupied start reads as the start itself.
1892
+ CONSTRAINT slotlock_reservations_occupied_start_valid CHECK (
1893
+ (occupied_starts_at IS NULL AND buffer_before_ms = 0)
1894
+ OR (occupied_starts_at IS NOT NULL
1895
+ AND occupied_starts_at = starts_at - buffer_before_ms * interval '1 millisecond')
1896
+ ),
1607
1897
  CONSTRAINT slotlock_reservations_status_valid CHECK (status IN ('held', 'confirmed')),
1608
1898
  -- A hold must carry its expiry; a confirmed reservation must not (expiry is hold-only state).
1609
1899
  CONSTRAINT slotlock_reservations_expiry_shape CHECK (
@@ -1612,7 +1902,7 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
1612
1902
  ),
1613
1903
  CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
1614
1904
  resource_id WITH =,
1615
- tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
1905
+ tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
1616
1906
  )
1617
1907
  );
1618
1908
 
@@ -1644,6 +1934,11 @@ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
1644
1934
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
1645
1935
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
1646
1936
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_ends_at timestamptz;
1937
+ -- The leading buffer a booking keeps before its start (its resource's bufferBeforeMinutes when it
1938
+ -- was made), and where its occupied time starts: its start less that buffer. A row written before
1939
+ -- leading buffers has none, so its occupied time starts at its start (NULL reads as starts_at).
1940
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_before_ms bigint NOT NULL DEFAULT 0;
1941
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_starts_at timestamptz;
1647
1942
  -- A keyed hold's holder (its principal) and the group of alternatives it was taken in: an agent
1648
1943
  -- releases or books only its own holds, and booking one releases the rest of its group.
1649
1944
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS holder_ref text;
@@ -1681,6 +1976,31 @@ BEGIN
1681
1976
  END IF;
1682
1977
  END $$;
1683
1978
 
1979
+ -- The occupied start, materialized for the same reason as the occupied end.
1980
+ CREATE OR REPLACE FUNCTION slotlock.set_reservation_occupied_start()
1981
+ RETURNS trigger
1982
+ LANGUAGE plpgsql
1983
+ SET search_path = pg_catalog, slotlock, pg_temp
1984
+ AS $$
1985
+ BEGIN
1986
+ NEW.occupied_starts_at := NEW.starts_at - NEW.buffer_before_ms * interval '1 millisecond';
1987
+ RETURN NEW;
1988
+ END $$;
1989
+
1990
+ DO $$
1991
+ BEGIN
1992
+ IF NOT EXISTS (
1993
+ SELECT 1 FROM pg_trigger
1994
+ WHERE tgname = 'slotlock_reservations_set_occupied_start'
1995
+ AND tgrelid = 'slotlock.reservations'::regclass
1996
+ AND NOT tgisinternal
1997
+ ) THEN
1998
+ CREATE TRIGGER slotlock_reservations_set_occupied_start
1999
+ BEFORE INSERT OR UPDATE OF starts_at, buffer_before_ms ON slotlock.reservations
2000
+ FOR EACH ROW EXECUTE FUNCTION slotlock.set_reservation_occupied_start();
2001
+ END IF;
2002
+ END $$;
2003
+
1684
2004
  -- Cancellation is an idempotency fact, not the absence of a reservation. Keeping a tombstone
1685
2005
  -- prevents delayed at-least-once create delivery from resurrecting a cancelled booking key.
1686
2006
  CREATE TABLE IF NOT EXISTS slotlock.reservation_tombstones (
@@ -1975,6 +2295,34 @@ BEGIN
1975
2295
  buffer_after_ms >= 0 AND buffer_after_ms <= 2592000000
1976
2296
  ) NOT VALID;
1977
2297
  END IF;
2298
+ -- Every existing row has no leading buffer, so both hold of it; added NOT VALID (no scan under
2299
+ -- the lock) and validated after the migration commits.
2300
+ IF NOT EXISTS (
2301
+ SELECT 1 FROM pg_constraint
2302
+ WHERE conname = 'slotlock_reservations_buffer_before_valid'
2303
+ AND conrelid = 'slotlock.reservations'::regclass
2304
+ ) THEN
2305
+ ALTER TABLE slotlock.reservations
2306
+ ADD CONSTRAINT slotlock_reservations_buffer_before_valid CHECK (
2307
+ buffer_before_ms >= 0 AND buffer_before_ms <= 86400000
2308
+ ) NOT VALID;
2309
+ END IF;
2310
+ -- A first form of this check let a NULL occupied start through with a leading buffer (the
2311
+ -- comparison is NULL, which a CHECK passes): replaced wherever it lacks the IS NOT NULL guard.
2312
+ IF NOT EXISTS (
2313
+ SELECT 1 FROM pg_constraint
2314
+ WHERE conname = 'slotlock_reservations_occupied_start_valid'
2315
+ AND conrelid = 'slotlock.reservations'::regclass
2316
+ AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_starts_at IS NOT NULL%'
2317
+ ) THEN
2318
+ ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_occupied_start_valid;
2319
+ ALTER TABLE slotlock.reservations
2320
+ ADD CONSTRAINT slotlock_reservations_occupied_start_valid CHECK (
2321
+ (occupied_starts_at IS NULL AND buffer_before_ms = 0)
2322
+ OR (occupied_starts_at IS NOT NULL
2323
+ AND occupied_starts_at = starts_at - buffer_before_ms * interval '1 millisecond')
2324
+ ) NOT VALID;
2325
+ END IF;
1978
2326
  END $$;
1979
2327
 
1980
2328
  -- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
@@ -2025,13 +2373,35 @@ BEGIN
2025
2373
  ALTER TABLE slotlock.reservations
2026
2374
  ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
2027
2375
  resource_id WITH =,
2028
- tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
2376
+ tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
2029
2377
  );
2030
2378
  END IF;
2031
2379
  ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_buffered_no_overlap;
2032
2380
  END
2033
2381
  $slotlock_occupied_overlap$;
2034
2382
 
2383
+ -- A row occupies from its occupied start too: its start less its leading buffer. Every row an
2384
+ -- upgrade finds was written before leading buffers and keeps none, so its range is the one the old
2385
+ -- constraint already kept apart and none can conflict: the constraint is replaced in place, its
2386
+ -- index built once under the migration's lock.
2387
+ DO $slotlock_occupied_start_overlap$
2388
+ BEGIN
2389
+ IF NOT EXISTS (
2390
+ SELECT 1 FROM pg_constraint
2391
+ WHERE conname = 'slotlock_reservations_no_overlap'
2392
+ AND conrelid = 'slotlock.reservations'::regclass
2393
+ AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_starts_at%'
2394
+ ) THEN
2395
+ ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_no_overlap;
2396
+ ALTER TABLE slotlock.reservations
2397
+ ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
2398
+ resource_id WITH =,
2399
+ tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
2400
+ );
2401
+ END IF;
2402
+ END
2403
+ $slotlock_occupied_start_overlap$;
2404
+
2035
2405
  CREATE INDEX IF NOT EXISTS slotlock_reservations_resource_time_idx
2036
2406
  ON slotlock.reservations (resource_id, starts_at, ends_at);
2037
2407
 
@@ -2231,6 +2601,10 @@ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by
2231
2601
  ALTER TABLE slotlock.calendar_events
2232
2602
  ADD COLUMN IF NOT EXISTS buffer_after_ms integer NOT NULL DEFAULT 0
2233
2603
  CONSTRAINT slotlock_calendar_events_buffer_valid CHECK (buffer_after_ms BETWEEN 0 AND 86400000);
2604
+ -- The leading buffer a booked event keeps before each occurrence, kept for a later roll alike.
2605
+ ALTER TABLE slotlock.calendar_events
2606
+ ADD COLUMN IF NOT EXISTS buffer_before_ms integer NOT NULL DEFAULT 0
2607
+ CONSTRAINT slotlock_calendar_events_buffer_before_valid CHECK (buffer_before_ms BETWEEN 0 AND 86400000);
2234
2608
  ALTER TABLE slotlock.calendar_events
2235
2609
  DROP CONSTRAINT IF EXISTS slotlock_calendar_events_recurrence_cursor_bounded;
2236
2610
  DO $slotlock_calendar_event_recurrence_cursor$
@@ -2796,6 +3170,7 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
2796
3170
  'outbox_pruned',
2797
3171
  'webhook_endpoints',
2798
3172
  'waitlist_entries',
3173
+ 'booking_rule_defaults',
2799
3174
  'assistant_settings',
2800
3175
  'assistant_conversations',
2801
3176
  ]);
@@ -2867,6 +3242,8 @@ ALTER TABLE slotlock.webhook_endpoints ENABLE ROW LEVEL SECURITY;
2867
3242
  ALTER TABLE slotlock.webhook_endpoints FORCE ROW LEVEL SECURITY;
2868
3243
  ALTER TABLE slotlock.waitlist_entries ENABLE ROW LEVEL SECURITY;
2869
3244
  ALTER TABLE slotlock.waitlist_entries FORCE ROW LEVEL SECURITY;
3245
+ ALTER TABLE slotlock.booking_rule_defaults ENABLE ROW LEVEL SECURITY;
3246
+ ALTER TABLE slotlock.booking_rule_defaults FORCE ROW LEVEL SECURITY;
2870
3247
  ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
2871
3248
  ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
2872
3249
  ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
@@ -2900,6 +3277,7 @@ BEGIN
2900
3277
  'outbox_pruned',
2901
3278
  'webhook_endpoints',
2902
3279
  'waitlist_entries',
3280
+ 'booking_rule_defaults',
2903
3281
  'assistant_settings',
2904
3282
  'assistant_conversations'
2905
3283
  )
@@ -3113,6 +3491,16 @@ BEGIN
3113
3491
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
3114
3492
  END IF;
3115
3493
 
3494
+ IF NOT EXISTS (
3495
+ SELECT 1 FROM pg_policies
3496
+ WHERE schemaname = 'slotlock' AND tablename = 'booking_rule_defaults'
3497
+ AND policyname = 'tenant_isolation_v1'
3498
+ ) THEN
3499
+ CREATE POLICY tenant_isolation_v1 ON slotlock.booking_rule_defaults
3500
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
3501
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
3502
+ END IF;
3503
+
3116
3504
  IF NOT EXISTS (
3117
3505
  SELECT 1 FROM pg_policies
3118
3506
  WHERE schemaname = 'slotlock' AND tablename = 'assistant_settings'
@@ -3169,6 +3557,8 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.webhook_endpoints
3169
3557
  IS 'slotlock:tenant-context:${tenantContextSetting}';
3170
3558
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.waitlist_entries
3171
3559
  IS 'slotlock:tenant-context:${tenantContextSetting}';
3560
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.booking_rule_defaults
3561
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
3172
3562
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
3173
3563
  IS 'slotlock:tenant-context:${tenantContextSetting}';
3174
3564
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
@@ -3213,6 +3603,7 @@ WHERE schemaname = 'slotlock'
3213
3603
  'outbox_pruned',
3214
3604
  'webhook_endpoints',
3215
3605
  'waitlist_entries',
3606
+ 'booking_rule_defaults',
3216
3607
  'assistant_settings',
3217
3608
  'assistant_conversations'
3218
3609
  )
package/event-feed.d.ts CHANGED
@@ -1,8 +1,30 @@
1
1
  import type { Sql, TransactionSql } from 'postgres';
2
2
  type SlotlockSql = Sql | TransactionSql;
3
3
  /** What a change can be, as the feed and a webhook's `event_types` name it. */
4
- export declare const SLOTLOCK_CHANGE_TYPES: readonly ["hold.created", "hold.updated", "hold.confirmed", "hold.released", "hold.expired", "reservation.created", "reservation.updated", "reservation.cancelled", "reservation.checked_in", "reservation.no_show", "event.created", "event.updated", "event.cancelled", "event.timezone_shifted", "event.timezone_conflict", "coverage.stale", "waitlist.offered", "waitlist.expired", "webhook.disabled"];
4
+ export declare const SLOTLOCK_CHANGE_TYPES: readonly ["hold.created", "hold.updated", "hold.confirmed", "hold.released", "hold.expired", "reservation.created", "reservation.updated", "reservation.cancelled", "reservation.checked_in", "reservation.no_show", "event.created", "event.updated", "event.cancelled", "event.timezone_shifted", "event.timezone_conflict", "coverage.stale", "waitlist.offered", "waitlist.expired", "webhook.disabled", "resource.created", "resource.updated", "resource.archived", "resource.restored", "resource.deleted", "coverage.recorded", "coverage.deleted", "webhook.created", "webhook.enabled", "webhook.deleted", "key.created", "key.rotated", "key.revoked"];
5
5
  export type SlotlockChangeType = (typeof SLOTLOCK_CHANGE_TYPES)[number];
6
+ /**
7
+ * Where a change came from, as the history and the feed record it (withTenant's `origin`): an agent
8
+ * protocol (`mcp`, `a2a`), the REST API's agent routes (`rest`) or its application operations
9
+ * (`application`), the dashboard, a member's dashboard assistant, maintenance, or code calling the
10
+ * store itself (`library`). Never an address or anything else about a person.
11
+ */
12
+ export declare const SLOTLOCK_CHANGE_ORIGINS: readonly ["mcp", "a2a", "rest", "application", "dashboard", "dashboard-assistant", "maintenance", "library"];
13
+ export type SlotlockChangeOrigin = (typeof SLOTLOCK_CHANGE_ORIGINS)[number];
14
+ /** Whether `value` is one of SLOTLOCK_CHANGE_ORIGINS. */
15
+ export declare function isSlotlockChangeOrigin(value: unknown): value is SlotlockChangeOrigin;
16
+ /**
17
+ * The SQL that names a coverage source on the feed (`coverage.recorded`, `.deleted`, `.stale`): a
18
+ * digest of the tenant and the source, stable for both, so the source itself (an iCalendar URL with
19
+ * a token, an address) never reaches a webhook or the history. `tenant` and `source` are SQL
20
+ * expressions; slotlockCoverageSourceId works out the same value.
21
+ */
22
+ export declare function slotlockCoverageSourceIdSql(tenant: string, source: string): string;
23
+ /**
24
+ * How the feed names a tenant's coverage source (`subject` and `data.source_id` of `coverage.*`
25
+ * changes; `source_id` of slotlock_get_coverage): `src_` and 24 hex digits.
26
+ */
27
+ export declare function slotlockCoverageSourceId(tenantRef: string, source: string): string;
6
28
  /** How long the feed keeps a change, and so how far back a cursor may reach. */
7
29
  export declare const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
8
30
  /** Webhook endpoints one tenant may have. */
@@ -20,6 +42,10 @@ export interface SlotlockChange {
20
42
  occurredAt: Date;
21
43
  /** Who made it (withTenant's `actor`: an API key, an agent), or null when unrecorded. */
22
44
  actor: string | null;
45
+ /** Where it came from (withTenant's `origin`), or null when unrecorded (before 0.6, say). */
46
+ origin: SlotlockChangeOrigin | null;
47
+ /** The API key that made it (withTenant's `keyId`), or null when no key did. */
48
+ keyId: string | null;
23
49
  /** Pass as `after` to read what follows this change. */
24
50
  position: string;
25
51
  }
@@ -150,9 +176,10 @@ export interface SlotlockEventFeed {
150
176
  };
151
177
  }): Promise<void>;
152
178
  /**
153
- * History: the changes to one resource (`resourceId`) or one record (`subject`: a booking's
154
- * reference, an event's or hold's id), newest first, `limit` (1-100) at a time, each with who
155
- * made it and, for a change of an existing record, what it was before. Pass the last one's
179
+ * History: the changes to one resource (`resourceId`: its bookings and events, and its own
180
+ * configuration) or one record (`subject`: a booking's reference, an event's, hold's, webhook's or
181
+ * API key's id), newest first, `limit` (1-100) at a time, each with who made it (`actor`, `keyId`),
182
+ * where from (`origin`) and, for a change of an existing record, what it was before. Pass the last one's
156
183
  * `id` as `before` for the page after. Kept as long as the feed keeps changes.
157
184
  */
158
185
  listHistory(params: {
@@ -175,4 +202,10 @@ export declare function isWebhookUrl(value: unknown): value is string;
175
202
  * `retentionDays` (default SLOTLOCK_CHANGE_RETENTION_DAYS), and a cursor reaches back as far.
176
203
  */
177
204
  export declare function slotlockEventFeedMethods(sql: SlotlockSql, retentionDays?: number): SlotlockEventFeed;
205
+ /**
206
+ * A position at or before every change that has not ended yet, so a reader that starts from it and
207
+ * follows `next` misses none still to come (it may read again some that ended just before). Live
208
+ * updates start here (agent-store-backend.ts, createSlotlockStoreChangeSource).
209
+ */
210
+ export declare function slotlockChangeFeedHead(sql: SlotlockSql): Promise<string>;
178
211
  export {};