slotlock 0.2.0 → 0.5.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/ddl.js CHANGED
@@ -72,6 +72,38 @@ $slotlock_restore_forced_row_security$;
72
72
  `;
73
73
  /** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
74
74
  export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
75
+ /**
76
+ * 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
+ */
79
+ export const SLOTLOCK_SCHEMA_VERSION = 3;
80
+ /** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
81
+ export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
82
+ /** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
83
+ const MIN_SERVER_VERSION_NUM = 160000;
84
+ // Before anything changes: a server too old for the DDL fails here, with a message that names the
85
+ // version, rather than part way through on a function it lacks; and a schema a newer release
86
+ // migrated is refused, since this release's DDL would put back what that release replaced.
87
+ const SLOTLOCK_SCHEMA_PRECONDITIONS = `DO $slotlock_schema_preconditions$
88
+ DECLARE
89
+ installed integer;
90
+ BEGIN
91
+ IF pg_catalog.current_setting('server_version_num')::integer < ${MIN_SERVER_VERSION_NUM} THEN
92
+ RAISE EXCEPTION 'Slotlock needs PostgreSQL 16 or later; this server is %',
93
+ pg_catalog.current_setting('server_version')
94
+ USING ERRCODE = 'feature_not_supported';
95
+ END IF;
96
+ IF pg_catalog.to_regclass('slotlock.schema_version') IS NOT NULL THEN
97
+ EXECUTE 'SELECT version FROM slotlock.schema_version WHERE singleton' INTO installed;
98
+ IF installed > ${SLOTLOCK_SCHEMA_VERSION} THEN
99
+ RAISE EXCEPTION '${SLOTLOCK_SCHEMA_NEWER_REFUSAL} (version %; this release is version ${SLOTLOCK_SCHEMA_VERSION})', installed
100
+ USING ERRCODE = 'object_not_in_prerequisite_state',
101
+ HINT = 'Migrate and serve it with the release that migrated it, or a later one.';
102
+ END IF;
103
+ END IF;
104
+ END
105
+ $slotlock_schema_preconditions$;
106
+ `;
75
107
  // Every slotlock.<name> the deployment uses resolves in that schema, and the deployment writes rows
76
108
  // into its tables, firing their triggers, as the owner of every table. A role that owns the schema,
77
109
  // can create objects in it, owns an object in it, owns the function a trigger on its tables runs, or
@@ -154,6 +186,12 @@ BEGIN
154
186
  END
155
187
  $slotlock_schema_control$;
156
188
  `;
189
+ /**
190
+ * What every migration checks before it changes anything, applySchema and applyTenantRls included
191
+ * when they find nothing to change: that no other role controls the schema, then the server version
192
+ * and the schema version. Run with search_path pinned, as the DDL pins it.
193
+ */
194
+ export const SLOTLOCK_DEPLOYMENT_CHECKS = `${SLOTLOCK_SCHEMA_CONTROL_CHECK}${SLOTLOCK_SCHEMA_PRECONDITIONS}`;
157
195
  /** Active (unrevoked, unexpired) API keys one tenant may hold. */
158
196
  export const SLOTLOCK_API_KEY_ACTIVE_LIMIT = 100;
159
197
  /** API keys one tenant may hold in all, revoked and expired ones included, so lists stay bounded. */
@@ -190,16 +228,59 @@ export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
190
228
  ]);
191
229
  /** Live form records one person may leave in the dashboard's records (12 hours of forms). */
192
230
  export const SLOTLOCK_DASHBOARD_FORM_LIMIT = 1_000;
231
+ /** The table that records which release migrated the schema (SLOTLOCK_SCHEMA_VERSION). */
232
+ export const SLOTLOCK_SCHEMA_TABLES = Object.freeze(['schema_version']);
233
+ /** The SECURITY DEFINER function through which a server reads the version it is serving. */
234
+ export const SLOTLOCK_SCHEMA_FUNCTIONS = Object.freeze([
235
+ 'slotlock.installed_schema_version()',
236
+ ]);
237
+ /** The table of suspended tenants: they may read, and write nothing (store.ts withTenant). */
238
+ export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
239
+ 'tenant_suspensions',
240
+ 'webhook_tenants',
241
+ 'rate_buckets',
242
+ ]);
243
+ /**
244
+ * The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
245
+ * tenant, by signature.
246
+ */
247
+ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
248
+ 'slotlock.tenant_suspended(text)',
249
+ 'slotlock.erase_tenant(text)',
250
+ 'slotlock.register_webhook_tenant(text)',
251
+ 'slotlock.webhook_tenants(text)',
252
+ 'slotlock.unregister_webhook_tenant(text)',
253
+ 'slotlock.take_rate_token(text, double precision, double precision)',
254
+ ]);
255
+ /**
256
+ * Functions only the deployment role runs: suspending and lifting a suspension. No other role is
257
+ * granted them, so a serving role cannot lift a suspension.
258
+ */
259
+ export const SLOTLOCK_OPERATOR_FUNCTIONS = Object.freeze([
260
+ 'slotlock.suspend_tenant(text, text)',
261
+ 'slotlock.unsuspend_tenant(text)',
262
+ ]);
193
263
  /** Every SECURITY DEFINER function the serving role may execute, by signature. */
194
264
  export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
195
265
  ...SLOTLOCK_API_KEY_FUNCTIONS,
196
266
  ...SLOTLOCK_DASHBOARD_FUNCTIONS,
267
+ ...SLOTLOCK_SCHEMA_FUNCTIONS,
268
+ ...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
197
269
  ]);
198
270
  /** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
199
271
  export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
200
272
  ...SLOTLOCK_API_KEY_TABLES,
201
273
  ...SLOTLOCK_DASHBOARD_TABLES,
274
+ ...SLOTLOCK_SCHEMA_TABLES,
275
+ ...SLOTLOCK_TENANT_ADMIN_TABLES,
202
276
  ]);
277
+ /**
278
+ * The advisory lock every server transaction for a tenant holds shared (store.ts withTenant), and
279
+ * suspension and erasure take exclusively, keyed by this text's hashtextextended(…, 0).
280
+ */
281
+ export function slotlockTenantLockKey(tenantRef) {
282
+ return `slotlock:tenant:${tenantRef}`;
283
+ }
203
284
  const API_KEY_COLUMNS = `key_id uuid,
204
285
  key_tenant_ref text,
205
286
  key_name text,
@@ -316,6 +397,11 @@ BEGIN
316
397
  USING ERRCODE = 'invalid_transaction_state';
317
398
  END IF;
318
399
  PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
400
+ IF EXISTS (SELECT 1 FROM slotlock.tenant_suspensions suspended
401
+ WHERE suspended.tenant_ref = requested_tenant_ref) THEN
402
+ RAISE EXCEPTION 'Slotlock tenant is suspended: it gets no new API key'
403
+ USING ERRCODE = 'insufficient_privilege', HINT = 'tenant_suspended';
404
+ END IF;
319
405
  IF EXISTS (SELECT 1 FROM slotlock.api_key_retired_digests retired
320
406
  WHERE retired.secret_hash = requested_secret_hash) THEN
321
407
  RAISE EXCEPTION 'Slotlock never uses a retired API key digest again'
@@ -370,6 +456,13 @@ AS $slotlock_rotate_api_key$
370
456
  DECLARE
371
457
  retiring bytea;
372
458
  BEGIN
459
+ -- Ordered with suspension, which takes this lock too.
460
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
461
+ IF EXISTS (SELECT 1 FROM slotlock.tenant_suspensions suspended
462
+ WHERE suspended.tenant_ref = requested_tenant_ref) THEN
463
+ RAISE EXCEPTION 'Slotlock tenant is suspended: it gets no new API key secret'
464
+ USING ERRCODE = 'insufficient_privilege', HINT = 'tenant_suspended';
465
+ END IF;
373
466
  IF EXISTS (SELECT 1 FROM slotlock.api_key_retired_digests retired
374
467
  WHERE retired.secret_hash = requested_secret_hash) THEN
375
468
  RAISE EXCEPTION 'Slotlock never uses a retired API key digest again'
@@ -475,12 +568,30 @@ CREATE TABLE IF NOT EXISTS slotlock.dashboard_tokens (
475
568
  owner_hash bytea,
476
569
  expires_at timestamptz NOT NULL,
477
570
  CONSTRAINT slotlock_dashboard_tokens_pkey PRIMARY KEY (kind, token_hash),
478
- CONSTRAINT slotlock_dashboard_tokens_kind_valid CHECK (kind IN ('sign_in', 'form', 'ended_session')),
571
+ CONSTRAINT slotlock_dashboard_tokens_kind_valid
572
+ CHECK (kind IN ('sign_in', 'form', 'ended_session', 'confirmation')),
479
573
  CONSTRAINT slotlock_dashboard_tokens_hash_valid CHECK (octet_length(token_hash) = 32),
480
574
  CONSTRAINT slotlock_dashboard_tokens_owner_valid
481
575
  CHECK (owner_hash IS NULL OR octet_length(owner_hash) = 32),
482
576
  CONSTRAINT slotlock_dashboard_tokens_form_owner CHECK (kind <> 'form' OR owner_hash IS NOT NULL)
483
577
  );
578
+ -- 0.3.1 records accepted write confirmations here too (kind 'confirmation'). A schema from an
579
+ -- earlier release has the narrower check; replace it only then, so a migration with nothing to
580
+ -- change takes no lock on the table.
581
+ DO $slotlock_dashboard_token_kinds$
582
+ BEGIN
583
+ IF NOT EXISTS (
584
+ SELECT 1 FROM pg_catalog.pg_constraint
585
+ WHERE conrelid = 'slotlock.dashboard_tokens'::regclass
586
+ AND conname = 'slotlock_dashboard_tokens_kind_valid'
587
+ AND pg_catalog.pg_get_constraintdef(oid) LIKE '%confirmation%'
588
+ ) THEN
589
+ ALTER TABLE slotlock.dashboard_tokens DROP CONSTRAINT IF EXISTS slotlock_dashboard_tokens_kind_valid;
590
+ ALTER TABLE slotlock.dashboard_tokens ADD CONSTRAINT slotlock_dashboard_tokens_kind_valid
591
+ CHECK (kind IN ('sign_in', 'form', 'ended_session', 'confirmation'));
592
+ END IF;
593
+ END
594
+ $slotlock_dashboard_token_kinds$;
484
595
  CREATE INDEX IF NOT EXISTS slotlock_dashboard_tokens_expiry_idx
485
596
  ON slotlock.dashboard_tokens (expires_at);
486
597
  CREATE INDEX IF NOT EXISTS slotlock_dashboard_tokens_owner_idx
@@ -570,11 +681,718 @@ AS $slotlock_dashboard_session_ended$
570
681
  $slotlock_dashboard_session_ended$;
571
682
  REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
572
683
  `;
684
+ // The schema's version. One row, written by the DDL itself at its end, so a schema migrated by
685
+ // running SLOTLOCK_CORE_DDL directly is versioned too. applySchema adds digests of the DDL and of the
686
+ // catalog it left, so that a later migration with nothing to change can tell, and take no lock on
687
+ // any table. Nothing here names a tenant; RLS is enabled with no policy and no role is granted
688
+ // anything, so a server reads the version only through installed_schema_version().
689
+ const SLOTLOCK_SCHEMA_VERSION_DDL = `
690
+ CREATE TABLE IF NOT EXISTS slotlock.schema_version (
691
+ singleton boolean PRIMARY KEY DEFAULT true,
692
+ version integer NOT NULL,
693
+ ddl_sha256 text,
694
+ catalog_sha256 text,
695
+ applied_at timestamptz NOT NULL DEFAULT now(),
696
+ CONSTRAINT slotlock_schema_version_singleton CHECK (singleton),
697
+ CONSTRAINT slotlock_schema_version_valid CHECK (version > 0)
698
+ );
699
+ ALTER TABLE slotlock.schema_version ENABLE ROW LEVEL SECURITY;
700
+ REVOKE ALL ON slotlock.schema_version FROM PUBLIC;
701
+
702
+ -- NULL when no migration has recorded a version (a schema from 0.2 or earlier).
703
+ CREATE OR REPLACE FUNCTION slotlock.installed_schema_version()
704
+ RETURNS integer
705
+ LANGUAGE sql
706
+ STABLE
707
+ SECURITY DEFINER
708
+ SET search_path = pg_catalog, slotlock, pg_temp
709
+ AS $slotlock_installed_schema_version$
710
+ SELECT version FROM slotlock.schema_version WHERE singleton;
711
+ $slotlock_installed_schema_version$;
712
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_SCHEMA_FUNCTIONS[0]} FROM PUBLIC;
713
+ `;
714
+ // Tenant suspension and erasure. A suspended tenant's server transactions (withTenant) may read,
715
+ // and write nothing; its keys still authenticate, but it gets no new key or secret. The table names
716
+ // tenants and why they were suspended, so RLS is enabled with no policy and no role is granted
717
+ // anything: a server reads it only through tenant_suspended(), and only the deployment role, which
718
+ // owns it, suspends or lifts a suspension. Erasure keeps the suspension, so an erased abuser stays
719
+ // suspended.
720
+ //
721
+ // Every server transaction for a tenant holds the tenant's lock shared, from before it reads the
722
+ // suspension; suspension and erasure take it exclusively. So a suspension waits for the tenant's
723
+ // transactions in flight, and every one that starts after it sees it.
724
+ const SLOTLOCK_TENANT_ADMIN_DDL = `
725
+ CREATE TABLE IF NOT EXISTS slotlock.tenant_suspensions (
726
+ tenant_ref text PRIMARY KEY,
727
+ reason text,
728
+ suspended_at timestamptz NOT NULL DEFAULT now(),
729
+ CONSTRAINT slotlock_tenant_suspensions_tenant_ref_valid
730
+ CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
731
+ CONSTRAINT slotlock_tenant_suspensions_reason_valid
732
+ CHECK (reason IS NULL OR (char_length(reason) BETWEEN 1 AND 500 AND reason !~ '[[:cntrl:]]'))
733
+ );
734
+ ALTER TABLE slotlock.tenant_suspensions ENABLE ROW LEVEL SECURITY;
735
+ REVOKE ALL ON slotlock.tenant_suspensions FROM PUBLIC;
736
+
737
+ CREATE OR REPLACE FUNCTION slotlock.tenant_suspended(requested_tenant_ref text)
738
+ RETURNS boolean
739
+ LANGUAGE sql
740
+ STABLE
741
+ SECURITY DEFINER
742
+ SET search_path = pg_catalog, slotlock, pg_temp
743
+ AS $slotlock_tenant_suspended$
744
+ SELECT EXISTS (
745
+ SELECT 1 FROM slotlock.tenant_suspensions suspended
746
+ WHERE suspended.tenant_ref = requested_tenant_ref
747
+ );
748
+ $slotlock_tenant_suspended$;
749
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[0]} FROM PUBLIC;
750
+
751
+ -- True when the tenant was not suspended already; a second suspension keeps the first's reason.
752
+ CREATE OR REPLACE FUNCTION slotlock.suspend_tenant(requested_tenant_ref text, requested_reason text)
753
+ RETURNS boolean
754
+ LANGUAGE plpgsql
755
+ VOLATILE
756
+ SET search_path = pg_catalog, slotlock, pg_temp
757
+ AS $slotlock_suspend_tenant$
758
+ BEGIN
759
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
760
+ -- The tenant's key lock too: a key or secret being issued commits first, and one issued after
761
+ -- this commits sees the suspension and is refused.
762
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
763
+ INSERT INTO slotlock.tenant_suspensions (tenant_ref, reason)
764
+ VALUES (requested_tenant_ref, requested_reason)
765
+ ON CONFLICT (tenant_ref) DO NOTHING;
766
+ RETURN FOUND;
767
+ END
768
+ $slotlock_suspend_tenant$;
769
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_OPERATOR_FUNCTIONS[0]} FROM PUBLIC;
770
+
771
+ -- True when the tenant was suspended.
772
+ CREATE OR REPLACE FUNCTION slotlock.unsuspend_tenant(requested_tenant_ref text)
773
+ RETURNS boolean
774
+ LANGUAGE plpgsql
775
+ VOLATILE
776
+ SET search_path = pg_catalog, slotlock, pg_temp
777
+ AS $slotlock_unsuspend_tenant$
778
+ BEGIN
779
+ DELETE FROM slotlock.tenant_suspensions WHERE tenant_ref = requested_tenant_ref;
780
+ RETURN FOUND;
781
+ END
782
+ $slotlock_unsuspend_tenant$;
783
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_OPERATOR_FUNCTIONS[1]} FROM PUBLIC;
784
+
785
+ -- Tenant erasure: every row the tenant owns, one row of the result per table with how many were
786
+ -- deleted, in an order no foreign key refuses. Holds carry no tenant: those on the tenant's resources
787
+ -- are its. API key digests are retired, as erase_api_keys retires them, so no erased key works again.
788
+ -- A personal (github:<id>) tenant's dashboard form records carry a digest of the person, the
789
+ -- dashboard's (dashboard.ts ownerDigest). Running it again deletes nothing. Forced row-level security
790
+ -- binds the function's owner too, so it names the tenant in every setting a policy reads, and puts
791
+ -- the caller's values back after.
792
+ CREATE OR REPLACE FUNCTION slotlock.erase_tenant(requested_tenant_ref text)
793
+ RETURNS TABLE (erased_table text, erased_rows bigint)
794
+ LANGUAGE plpgsql
795
+ VOLATILE
796
+ SECURITY DEFINER
797
+ SET search_path = pg_catalog, slotlock, pg_temp
798
+ AS $slotlock_erase_tenant$
799
+ DECLARE
800
+ setting text;
801
+ previous jsonb := '{}'::jsonb;
802
+ BEGIN
803
+ IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
804
+ RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
805
+ USING ERRCODE = 'invalid_parameter_value';
806
+ END IF;
807
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
808
+ -- A key being issued commits first, so its row is deleted below with the rest.
809
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
810
+ FOR setting IN
811
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
812
+ LOOP
813
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
814
+ PERFORM set_config(setting, requested_tenant_ref, true);
815
+ END LOOP;
816
+ -- What erasure deletes is not a change anyone is told of: the feed goes with the tenant.
817
+ previous := previous || jsonb_build_object('slotlock.erasing', current_setting('slotlock.erasing', true));
818
+ PERFORM set_config('slotlock.erasing', requested_tenant_ref, true);
819
+
820
+ erased_table := 'calendar_event_attendees';
821
+ DELETE FROM slotlock.calendar_event_attendees WHERE tenant_ref = requested_tenant_ref;
822
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
823
+ RETURN NEXT;
824
+ erased_table := 'calendar_event_reminders';
825
+ DELETE FROM slotlock.calendar_event_reminders WHERE tenant_ref = requested_tenant_ref;
826
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
827
+ RETURN NEXT;
828
+ erased_table := 'calendar_event_exceptions';
829
+ DELETE FROM slotlock.calendar_event_exceptions WHERE tenant_ref = requested_tenant_ref;
830
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
831
+ RETURN NEXT;
832
+ erased_table := 'calendar_event_occurrences';
833
+ DELETE FROM slotlock.calendar_event_occurrences WHERE tenant_ref = requested_tenant_ref;
834
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
835
+ RETURN NEXT;
836
+ erased_table := 'reservations';
837
+ DELETE FROM slotlock.reservations
838
+ WHERE tenant_ref = requested_tenant_ref
839
+ OR resource_id IN (SELECT owned.id FROM slotlock.resources owned
840
+ WHERE owned.tenant_ref = requested_tenant_ref);
841
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
842
+ RETURN NEXT;
843
+ erased_table := 'calendar_events';
844
+ DELETE FROM slotlock.calendar_events WHERE tenant_ref = requested_tenant_ref;
845
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
846
+ RETURN NEXT;
847
+ erased_table := 'calendar_event_tombstones';
848
+ DELETE FROM slotlock.calendar_event_tombstones WHERE tenant_ref = requested_tenant_ref;
849
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
850
+ RETURN NEXT;
851
+ erased_table := 'calendar_event_commands';
852
+ DELETE FROM slotlock.calendar_event_commands WHERE tenant_ref = requested_tenant_ref;
853
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
854
+ RETURN NEXT;
855
+ erased_table := 'reservation_tombstones';
856
+ DELETE FROM slotlock.reservation_tombstones WHERE tenant_ref = requested_tenant_ref;
857
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
858
+ RETURN NEXT;
859
+ erased_table := 'reservation_conflict_archive';
860
+ DELETE FROM slotlock.reservation_conflict_archive WHERE tenant_ref = requested_tenant_ref;
861
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
862
+ RETURN NEXT;
863
+ erased_table := 'calendar_coverage';
864
+ DELETE FROM slotlock.calendar_coverage WHERE tenant_ref = requested_tenant_ref;
865
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
866
+ RETURN NEXT;
867
+ erased_table := 'hold_commands';
868
+ DELETE FROM slotlock.hold_commands WHERE tenant_ref = requested_tenant_ref;
869
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
870
+ RETURN NEXT;
871
+ erased_table := 'waitlist_entries';
872
+ DELETE FROM slotlock.waitlist_entries WHERE tenant_ref = requested_tenant_ref;
873
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
874
+ RETURN NEXT;
875
+ erased_table := 'resources';
876
+ DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
877
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
878
+ RETURN NEXT;
879
+ -- After every booking table: what their deletion just appended goes with the rest.
880
+ erased_table := 'outbox';
881
+ DELETE FROM slotlock.outbox WHERE tenant_ref = requested_tenant_ref;
882
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
883
+ RETURN NEXT;
884
+ erased_table := 'outbox_pruned';
885
+ DELETE FROM slotlock.outbox_pruned WHERE tenant_ref = requested_tenant_ref;
886
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
887
+ RETURN NEXT;
888
+ erased_table := 'webhook_endpoints';
889
+ DELETE FROM slotlock.webhook_endpoints WHERE tenant_ref = requested_tenant_ref;
890
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
891
+ RETURN NEXT;
892
+ erased_table := 'webhook_tenants';
893
+ DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
894
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
895
+ RETURN NEXT;
896
+ erased_table := 'api_keys';
897
+ WITH erased AS (
898
+ DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
899
+ ), retired AS (
900
+ INSERT INTO slotlock.api_key_retired_digests (secret_hash)
901
+ SELECT erased.secret_hash FROM erased
902
+ ON CONFLICT DO NOTHING
903
+ )
904
+ SELECT count(*) INTO erased_rows FROM erased;
905
+ RETURN NEXT;
906
+ erased_table := 'dashboard_tokens';
907
+ erased_rows := 0;
908
+ IF requested_tenant_ref LIKE 'github:%' THEN
909
+ DELETE FROM slotlock.dashboard_tokens
910
+ WHERE kind = 'form'
911
+ AND owner_hash = sha256(convert_to(
912
+ 'slotlock-dashboard-owner.' || substr(requested_tenant_ref, 8), 'UTF8'));
913
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
914
+ END IF;
915
+ RETURN NEXT;
916
+
917
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
918
+ PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
919
+ END LOOP;
920
+ END
921
+ $slotlock_erase_tenant$;
922
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
923
+
924
+ -- The tenants with a webhook endpoint, which a server delivers for. Row-level security hides every
925
+ -- other tenant's endpoints from the serving role, so this list (names only) is how a server that
926
+ -- restarted finds the tenants whose events still wait. No role but its owner reads it.
927
+ CREATE TABLE IF NOT EXISTS slotlock.webhook_tenants (
928
+ tenant_ref text PRIMARY KEY,
929
+ registered_at timestamptz NOT NULL DEFAULT now(),
930
+ CONSTRAINT slotlock_webhook_tenants_tenant_ref_valid
931
+ CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
932
+ );
933
+ ALTER TABLE slotlock.webhook_tenants ENABLE ROW LEVEL SECURITY;
934
+ REVOKE ALL ON slotlock.webhook_tenants FROM PUBLIC;
935
+
936
+ CREATE OR REPLACE FUNCTION slotlock.register_webhook_tenant(requested_tenant_ref text)
937
+ RETURNS void
938
+ LANGUAGE sql
939
+ VOLATILE
940
+ SECURITY DEFINER
941
+ SET search_path = pg_catalog, slotlock, pg_temp
942
+ AS $slotlock_register_webhook_tenant$
943
+ INSERT INTO slotlock.webhook_tenants (tenant_ref) VALUES (requested_tenant_ref)
944
+ ON CONFLICT (tenant_ref) DO NOTHING;
945
+ $slotlock_register_webhook_tenant$;
946
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[2]} FROM PUBLIC;
947
+
948
+ -- A page of 1,000 tenant names after \`after\` (NULL: from the start); a caller pages to the end.
949
+ DROP FUNCTION IF EXISTS slotlock.webhook_tenants();
950
+ CREATE OR REPLACE FUNCTION slotlock.webhook_tenants(after text)
951
+ RETURNS SETOF text
952
+ LANGUAGE sql
953
+ STABLE
954
+ SECURITY DEFINER
955
+ SET search_path = pg_catalog, slotlock, pg_temp
956
+ AS $slotlock_webhook_tenants$
957
+ SELECT tenant_ref FROM slotlock.webhook_tenants
958
+ WHERE after IS NULL OR tenant_ref > after
959
+ ORDER BY tenant_ref LIMIT 1000;
960
+ $slotlock_webhook_tenants$;
961
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[3]} FROM PUBLIC;
962
+
963
+ -- Called when a tenant's last endpoint is deleted, so the list holds only tenants with one.
964
+ CREATE OR REPLACE FUNCTION slotlock.unregister_webhook_tenant(requested_tenant_ref text)
965
+ RETURNS void
966
+ LANGUAGE sql
967
+ VOLATILE
968
+ SECURITY DEFINER
969
+ SET search_path = pg_catalog, slotlock, pg_temp
970
+ AS $slotlock_unregister_webhook_tenant$
971
+ DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
972
+ $slotlock_unregister_webhook_tenant$;
973
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[4]} FROM PUBLIC;
974
+
975
+ -- Request budgets shared by every server of a deployment (SLOTLOCK_SHARED_LIMITS): one token bucket
976
+ -- per limit and tenant, keyed by a digest, refilled and taken in one statement so racing servers
977
+ -- never both take the last token. No role but its owner reads it.
978
+ CREATE TABLE IF NOT EXISTS slotlock.rate_buckets (
979
+ bucket text PRIMARY KEY,
980
+ tokens double precision NOT NULL,
981
+ at timestamptz NOT NULL,
982
+ CONSTRAINT slotlock_rate_buckets_bucket_valid CHECK (bucket ~ '^[0-9a-f]{64}$')
983
+ );
984
+ ALTER TABLE slotlock.rate_buckets ENABLE ROW LEVEL SECURITY;
985
+ REVOKE ALL ON slotlock.rate_buckets FROM PUBLIC;
986
+
987
+ -- True when a token was taken: the bucket refills at per_second up to burst, and starts full.
988
+ CREATE OR REPLACE FUNCTION slotlock.take_rate_token(
989
+ requested_bucket text, per_second double precision, burst double precision
990
+ )
991
+ RETURNS boolean
992
+ LANGUAGE plpgsql
993
+ VOLATILE
994
+ SECURITY DEFINER
995
+ SET search_path = pg_catalog, slotlock, pg_temp
996
+ AS $slotlock_take_rate_token$
997
+ DECLARE
998
+ taken boolean;
999
+ BEGIN
1000
+ IF requested_bucket !~ '^[0-9a-f]{64}$' OR NOT (per_second > 0) OR NOT (burst >= 1) THEN
1001
+ RAISE EXCEPTION 'Slotlock rate bucket arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
1002
+ END IF;
1003
+ INSERT INTO slotlock.rate_buckets AS kept (bucket, tokens, at)
1004
+ VALUES (requested_bucket, burst - 1, clock_timestamp())
1005
+ ON CONFLICT (bucket) DO UPDATE
1006
+ SET tokens = LEAST(burst, kept.tokens + EXTRACT(EPOCH FROM clock_timestamp() - kept.at) * per_second) - 1,
1007
+ at = clock_timestamp()
1008
+ WHERE LEAST(burst, kept.tokens + EXTRACT(EPOCH FROM clock_timestamp() - kept.at) * per_second) >= 1
1009
+ RETURNING true INTO taken;
1010
+ -- Now and then, buckets idle for an hour (full again long since) are dropped.
1011
+ IF random() < 0.001 THEN
1012
+ DELETE FROM slotlock.rate_buckets WHERE at < clock_timestamp() - interval '1 hour';
1013
+ END IF;
1014
+ RETURN COALESCE(taken, false);
1015
+ END
1016
+ $slotlock_take_rate_token$;
1017
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[5]} FROM PUBLIC;
1018
+ `;
1019
+ // A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
1020
+ // here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
1021
+ // DDL's transaction every table it altered stays under ACCESS EXCLUSIVE until commit, so the scan
1022
+ // would hold up every serving query. On its own, each VALIDATE takes SHARE UPDATE EXCLUSIVE (and
1023
+ // ROW SHARE on a foreign key's referenced table), which no read or row write conflicts with
1024
+ // (PostgreSQL 17, ALTER TABLE and Explicit Locking). It sees every row whatever row-level security
1025
+ // the table forces: the check scans the table itself, and referential checks bypass row security.
1026
+ // One that old rows still break stays NOT VALID, with a notice, rather than stopping the migration:
1027
+ // it binds every new row either way, and applySchema tries it again at each later migration. Once
1028
+ // valid, a constraint is never checked again.
1029
+ const DEPLOYMENT_VALIDATE_CONSTRAINTS = `DO $slotlock_validate_constraints$
1030
+ DECLARE
1031
+ unchecked record;
1032
+ BEGIN
1033
+ FOR unchecked IN
1034
+ SELECT rel.relname::text AS table_name, con.conname::text AS constraint_name
1035
+ FROM pg_catalog.pg_constraint con
1036
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
1037
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
1038
+ WHERE ns.nspname = 'slotlock' AND NOT con.convalidated
1039
+ ORDER BY rel.relname, con.conname
1040
+ LOOP
1041
+ BEGIN
1042
+ EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I VALIDATE CONSTRAINT %I',
1043
+ unchecked.table_name, unchecked.constraint_name);
1044
+ EXCEPTION WHEN check_violation OR foreign_key_violation OR not_null_violation THEN
1045
+ RAISE NOTICE 'Slotlock left %.% NOT VALID: existing rows break it',
1046
+ unchecked.table_name, unchecked.constraint_name;
1047
+ END;
1048
+ END LOOP;
1049
+ END
1050
+ $slotlock_validate_constraints$;
1051
+ `;
1052
+ /** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
1053
+ export const SLOTLOCK_VALIDATION_DDL = DEPLOYMENT_VALIDATE_CONSTRAINTS;
1054
+ // The digests that let the next migration skip are recorded by applySchema once it has validated.
1055
+ const DEPLOYMENT_RECORD_VERSION = `INSERT INTO slotlock.schema_version (singleton, version, applied_at)
1056
+ VALUES (true, ${SLOTLOCK_SCHEMA_VERSION}, now())
1057
+ ON CONFLICT (singleton) DO UPDATE
1058
+ SET version = EXCLUDED.version, ddl_sha256 = NULL, catalog_sha256 = NULL,
1059
+ applied_at = EXCLUDED.applied_at;
1060
+ `;
1061
+ // The event feed (RFC 0002 P1-5). Every committed change to a tenant's holds, bookings and calendar
1062
+ // events appends a row here in the same transaction, by trigger, so no write path can skip it and
1063
+ // nothing is recorded that did not commit. Rows carry ids, times and states, never titles,
1064
+ // descriptions or attendees. A reader orders rows by (txid, id) and reads only transactions below
1065
+ // its snapshot's xmin: every one of those has ended, so a transaction that commits late can never
1066
+ // add a row behind a position a reader has passed. Maintenance deletes rows past the retention.
1067
+ const SLOTLOCK_OUTBOX_DDL = `
1068
+ CREATE TABLE IF NOT EXISTS slotlock.outbox (
1069
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
1070
+ txid xid8 NOT NULL DEFAULT pg_current_xact_id(),
1071
+ tenant_ref text NOT NULL,
1072
+ resource_id uuid,
1073
+ type text NOT NULL,
1074
+ subject text NOT NULL,
1075
+ data jsonb NOT NULL,
1076
+ occurred_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1077
+ CONSTRAINT slotlock_outbox_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
1078
+ CONSTRAINT slotlock_outbox_type_valid CHECK (type ~ '^[a-z]+\\.[a-z_]+$')
1079
+ );
1080
+ -- Who made the change (withTenant's actor: an API key, an agent), for the history.
1081
+ ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS actor text;
1082
+ CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_resource_idx
1083
+ ON slotlock.outbox (tenant_ref, resource_id, id) WHERE resource_id IS NOT NULL;
1084
+ CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_subject_idx
1085
+ ON slotlock.outbox (tenant_ref, subject, id);
1086
+ CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_position_idx
1087
+ ON slotlock.outbox (tenant_ref, txid, id);
1088
+ CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_time_idx
1089
+ ON slotlock.outbox (tenant_ref, occurred_at);
1090
+ -- How far a tenant's feed has been pruned: no change after (txid, id) was ever deleted, so a cursor
1091
+ -- at or past it has missed none, and one before it may have, and is refused. Created once, it starts
1092
+ -- every tenant already known at its oldest kept change (or now): what an earlier version pruned is
1093
+ -- before it.
1094
+ DO $slotlock_outbox_pruned$
1095
+ BEGIN
1096
+ IF pg_catalog.to_regclass('slotlock.outbox_pruned') IS NULL THEN
1097
+ CREATE TABLE IF NOT EXISTS slotlock.outbox_pruned (
1098
+ tenant_ref text PRIMARY KEY,
1099
+ txid xid8 NOT NULL,
1100
+ id bigint NOT NULL,
1101
+ CONSTRAINT slotlock_outbox_pruned_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
1102
+ );
1103
+ INSERT INTO slotlock.outbox_pruned (tenant_ref, txid, id)
1104
+ SELECT known.tenant_ref, min(known.txid), 0
1105
+ FROM (
1106
+ SELECT tenant_ref, txid FROM slotlock.outbox
1107
+ UNION ALL
1108
+ SELECT tenant_ref, pg_catalog.pg_snapshot_xmin(pg_catalog.pg_current_snapshot())
1109
+ FROM slotlock.resources WHERE tenant_ref IS NOT NULL
1110
+ ) known
1111
+ GROUP BY known.tenant_ref;
1112
+ END IF;
1113
+ END
1114
+ $slotlock_outbox_pruned$;
1115
+ -- Whatever deletes changes (maintenance, an older server's, an operator's), the mark follows it.
1116
+ CREATE OR REPLACE FUNCTION slotlock.outbox_mark_pruned()
1117
+ RETURNS trigger
1118
+ LANGUAGE plpgsql
1119
+ SET search_path = pg_catalog, slotlock, pg_temp
1120
+ AS $slotlock_outbox_mark_pruned$
1121
+ BEGIN
1122
+ INSERT INTO slotlock.outbox_pruned AS mark (tenant_ref, txid, id)
1123
+ SELECT DISTINCT ON (pruned.tenant_ref) pruned.tenant_ref, pruned.txid, pruned.id
1124
+ FROM pruned
1125
+ ORDER BY pruned.tenant_ref, pruned.txid DESC, pruned.id DESC
1126
+ ON CONFLICT (tenant_ref) DO UPDATE SET txid = excluded.txid, id = excluded.id
1127
+ WHERE (excluded.txid, excluded.id) > (mark.txid, mark.id);
1128
+ RETURN NULL;
1129
+ END
1130
+ $slotlock_outbox_mark_pruned$;
1131
+ DROP TRIGGER IF EXISTS slotlock_outbox_mark_pruned ON slotlock.outbox;
1132
+ CREATE TRIGGER slotlock_outbox_mark_pruned
1133
+ AFTER DELETE ON slotlock.outbox REFERENCING OLD TABLE AS pruned
1134
+ FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_mark_pruned();
1135
+
1136
+ CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
1137
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1138
+ tenant_ref text NOT NULL,
1139
+ url text NOT NULL,
1140
+ secret text NOT NULL,
1141
+ event_types text[],
1142
+ cursor_txid xid8 NOT NULL,
1143
+ cursor_id bigint NOT NULL DEFAULT 0,
1144
+ -- Set when maintenance pruned a change this (disabled) endpoint had not received: enabling it
1145
+ -- again is refused rather than skipping that change unseen.
1146
+ changes_pruned boolean NOT NULL DEFAULT false,
1147
+ attempts integer NOT NULL DEFAULT 0,
1148
+ next_attempt_at timestamptz,
1149
+ failing_since timestamptz,
1150
+ last_status integer,
1151
+ last_error text,
1152
+ disabled_at timestamptz,
1153
+ disabled_reason text,
1154
+ lease_until timestamptz,
1155
+ lease_token uuid,
1156
+ created_at timestamptz NOT NULL DEFAULT now(),
1157
+ CONSTRAINT slotlock_webhook_endpoints_identity_valid CHECK (
1158
+ octet_length(tenant_ref) BETWEEN 1 AND 500
1159
+ AND octet_length(url) BETWEEN 1 AND 2048
1160
+ AND secret ~ '^whsec_[A-Za-z0-9+/]{43}=$'
1161
+ ),
1162
+ CONSTRAINT slotlock_webhook_endpoints_types_valid CHECK (
1163
+ event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 20
1164
+ ),
1165
+ CONSTRAINT slotlock_webhook_endpoints_error_valid CHECK (
1166
+ last_error IS NULL OR char_length(last_error) <= 500
1167
+ )
1168
+ );
1169
+ ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS changes_pruned boolean NOT NULL DEFAULT false;
1170
+ CREATE INDEX IF NOT EXISTS slotlock_webhook_endpoints_tenant_idx
1171
+ ON slotlock.webhook_endpoints (tenant_ref, created_at);
1172
+
1173
+ -- Who waits for time on a resource or in a pool, offered in order (offerWaitlist): the first whose
1174
+ -- window comes free gets a hold of it, for offer_ttl_ms, and the feed says so (waitlist.offered).
1175
+ CREATE TABLE IF NOT EXISTS slotlock.waitlist_entries (
1176
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1177
+ tenant_ref text NOT NULL,
1178
+ resource_id uuid,
1179
+ pool_ref text,
1180
+ starts_at timestamptz NOT NULL,
1181
+ ends_at timestamptz NOT NULL,
1182
+ holder_ref text NOT NULL,
1183
+ offer_ttl_ms bigint NOT NULL,
1184
+ status text NOT NULL DEFAULT 'waiting',
1185
+ hold_id uuid,
1186
+ offered_resource_id uuid,
1187
+ offered_at timestamptz,
1188
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1189
+ -- The resource is the entry's own tenant's, as the database sees it, whatever wrote the row.
1190
+ CONSTRAINT slotlock_waitlist_entries_resource_fk FOREIGN KEY (resource_id, tenant_ref)
1191
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
1192
+ CONSTRAINT slotlock_waitlist_entries_target_valid CHECK ((resource_id IS NULL) <> (pool_ref IS NULL)),
1193
+ CONSTRAINT slotlock_waitlist_entries_window_valid CHECK (ends_at > starts_at),
1194
+ CONSTRAINT slotlock_waitlist_entries_status_valid CHECK (status IN ('waiting', 'offered', 'expired')),
1195
+ CONSTRAINT slotlock_waitlist_entries_identity_valid CHECK (
1196
+ octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(holder_ref) BETWEEN 1 AND 500
1197
+ ),
1198
+ CONSTRAINT slotlock_waitlist_entries_ttl_valid CHECK (offer_ttl_ms BETWEEN 60000 AND 86400000),
1199
+ CONSTRAINT slotlock_waitlist_entries_offer_valid CHECK (
1200
+ (status = 'offered') = (hold_id IS NOT NULL AND offered_at IS NOT NULL AND offered_resource_id IS NOT NULL)
1201
+ )
1202
+ );
1203
+ CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
1204
+ ON slotlock.waitlist_entries (tenant_ref, created_at, id) WHERE status = 'waiting';
1205
+ CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
1206
+ ON slotlock.waitlist_entries (tenant_ref, starts_at);
1207
+
1208
+ -- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
1209
+ CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
1210
+ RETURNS trigger
1211
+ LANGUAGE plpgsql
1212
+ SET search_path = pg_catalog, slotlock, pg_temp
1213
+ AS $slotlock_outbox_reservation$
1214
+ DECLARE
1215
+ changed slotlock.reservations%ROWTYPE;
1216
+ owner text;
1217
+ kind text;
1218
+ BEGIN
1219
+ changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1220
+ -- A calendar event's occupation is reported as the event's change.
1221
+ IF changed.calendar_event_id IS NOT NULL THEN RETURN NULL; END IF;
1222
+ owner := changed.tenant_ref;
1223
+ IF owner IS NULL THEN
1224
+ SELECT resource.tenant_ref INTO owner FROM slotlock.resources resource
1225
+ WHERE resource.id = changed.resource_id;
1226
+ END IF;
1227
+ IF owner IS NULL THEN RETURN NULL; END IF;
1228
+ -- Erasure (erase_tenant, running as the schema's owner) is not reported. The marker is an ordinary
1229
+ -- setting, so it counts only when the owner set it: the serving role cannot hide its own writes.
1230
+ IF owner = current_setting('slotlock.erasing', true)
1231
+ AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
1232
+ WHERE nspname = 'slotlock') THEN
1233
+ RETURN NULL;
1234
+ END IF;
1235
+ IF TG_OP = 'INSERT' THEN
1236
+ kind := CASE WHEN NEW.status = 'held' THEN 'hold.created' ELSE 'reservation.created' END;
1237
+ ELSIF TG_OP = 'UPDATE' THEN
1238
+ IF OLD.status = 'held' AND NEW.status <> 'held' THEN
1239
+ kind := 'hold.confirmed';
1240
+ ELSIF (OLD.starts_at, OLD.ends_at, OLD.resource_id, OLD.revision, OLD.buffer_after_ms)
1241
+ IS NOT DISTINCT FROM
1242
+ (NEW.starts_at, NEW.ends_at, NEW.resource_id, NEW.revision, NEW.buffer_after_ms) THEN
1243
+ RETURN NULL;
1244
+ ELSE
1245
+ kind := CASE WHEN NEW.status = 'held' THEN 'hold.updated' ELSE 'reservation.updated' END;
1246
+ END IF;
1247
+ ELSE
1248
+ kind := CASE
1249
+ WHEN OLD.status <> 'held' THEN 'reservation.cancelled'
1250
+ WHEN OLD.expires_at <= statement_timestamp() THEN 'hold.expired'
1251
+ ELSE 'hold.released'
1252
+ END;
1253
+ END IF;
1254
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1255
+ VALUES (
1256
+ owner, changed.resource_id, kind,
1257
+ COALESCE(changed.external_ref, changed.id::text),
1258
+ jsonb_strip_nulls(jsonb_build_object(
1259
+ 'id', changed.id,
1260
+ 'reference', changed.external_ref,
1261
+ 'resource_id', changed.resource_id,
1262
+ 'start', changed.starts_at,
1263
+ 'end', changed.ends_at,
1264
+ 'buffer_after_ms', changed.buffer_after_ms,
1265
+ 'revision', CASE WHEN changed.status = 'held' THEN NULL ELSE changed.revision END,
1266
+ 'expires_at', CASE WHEN changed.status = 'held' THEN changed.expires_at END,
1267
+ -- A move says where it came from.
1268
+ 'before', CASE WHEN TG_OP = 'UPDATE' THEN jsonb_build_object(
1269
+ 'resource_id', OLD.resource_id, 'start', OLD.starts_at, 'end', OLD.ends_at,
1270
+ 'revision', OLD.revision) END
1271
+ )),
1272
+ NULLIF(current_setting('slotlock.actor', true), '')
1273
+ );
1274
+ RETURN NULL;
1275
+ END
1276
+ $slotlock_outbox_reservation$;
1277
+
1278
+ CREATE OR REPLACE FUNCTION slotlock.outbox_calendar_event_change()
1279
+ RETURNS trigger
1280
+ LANGUAGE plpgsql
1281
+ SET search_path = pg_catalog, slotlock, pg_temp
1282
+ AS $slotlock_outbox_calendar_event$
1283
+ DECLARE
1284
+ changed slotlock.calendar_events%ROWTYPE;
1285
+ kind text;
1286
+ BEGIN
1287
+ changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1288
+ -- Erasure (erase_tenant, running as the schema's owner) is not reported. The marker is an ordinary
1289
+ -- setting, so it counts only when the owner set it: the serving role cannot hide its own writes.
1290
+ IF changed.tenant_ref = current_setting('slotlock.erasing', true)
1291
+ AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
1292
+ WHERE nspname = 'slotlock') THEN
1293
+ RETURN NULL;
1294
+ END IF;
1295
+ IF TG_OP = 'INSERT' THEN
1296
+ kind := 'event.created';
1297
+ ELSIF TG_OP = 'UPDATE' THEN
1298
+ -- Extending a series to the rolling horizon changes nothing anyone wrote.
1299
+ IF OLD.revision = NEW.revision AND OLD.resource_id = NEW.resource_id THEN RETURN NULL; END IF;
1300
+ kind := 'event.updated';
1301
+ ELSE
1302
+ kind := 'event.cancelled';
1303
+ END IF;
1304
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1305
+ VALUES (
1306
+ changed.tenant_ref, changed.resource_id, kind, changed.id::text,
1307
+ jsonb_strip_nulls(jsonb_build_object(
1308
+ 'id', changed.id,
1309
+ 'resource_id', changed.resource_id,
1310
+ 'start', changed.starts_at,
1311
+ 'end', changed.ends_at,
1312
+ 'timezone', changed.timezone,
1313
+ 'status', changed.status,
1314
+ 'transparency', changed.transparency,
1315
+ 'recurring', changed.recurrence_rule IS NOT NULL,
1316
+ 'revision', changed.revision,
1317
+ 'before', CASE WHEN TG_OP = 'UPDATE' THEN jsonb_build_object(
1318
+ 'resource_id', OLD.resource_id, 'start', OLD.starts_at, 'end', OLD.ends_at,
1319
+ 'status', OLD.status, 'transparency', OLD.transparency, 'revision', OLD.revision) END
1320
+ )),
1321
+ NULLIF(current_setting('slotlock.actor', true), '')
1322
+ );
1323
+ RETURN NULL;
1324
+ END
1325
+ $slotlock_outbox_calendar_event$;
1326
+
1327
+ -- Time zone rules change (tzdata): an event is kept as the wall-clock time it was booked for in its
1328
+ -- zone as well as the instant, so maintenance can find a future one-off event whose wall time now
1329
+ -- falls at another instant. PostgreSQL's own tzdata resolves both. A zone it does not know gives
1330
+ -- NULL rather than an error, and such an event is left alone.
1331
+ CREATE OR REPLACE FUNCTION slotlock.zoned_wall(instant timestamptz, zone text)
1332
+ RETURNS timestamp LANGUAGE plpgsql STABLE SET search_path = pg_catalog, slotlock, pg_temp
1333
+ AS $slotlock_zoned_wall$
1334
+ BEGIN
1335
+ RETURN instant AT TIME ZONE zone;
1336
+ EXCEPTION WHEN OTHERS THEN
1337
+ RETURN NULL;
1338
+ END
1339
+ $slotlock_zoned_wall$;
1340
+ CREATE OR REPLACE FUNCTION slotlock.zoned_instant(wall timestamp, zone text)
1341
+ RETURNS timestamptz LANGUAGE plpgsql STABLE SET search_path = pg_catalog, slotlock, pg_temp
1342
+ AS $slotlock_zoned_instant$
1343
+ BEGIN
1344
+ RETURN wall AT TIME ZONE zone;
1345
+ EXCEPTION WHEN OTHERS THEN
1346
+ RETURN NULL;
1347
+ END
1348
+ $slotlock_zoned_instant$;
1349
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS wall_starts_at timestamp;
1350
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS wall_ends_at timestamp;
1351
+ -- Set when a shift was reported, so it is reported once; any change of the event's time clears it.
1352
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS zone_shift_reported_at timestamptz;
1353
+ CREATE OR REPLACE FUNCTION slotlock.calendar_event_wall_time()
1354
+ RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, slotlock, pg_temp
1355
+ AS $slotlock_calendar_event_wall_time$
1356
+ BEGIN
1357
+ IF TG_OP = 'INSERT'
1358
+ OR NEW.starts_at IS DISTINCT FROM OLD.starts_at OR NEW.ends_at IS DISTINCT FROM OLD.ends_at
1359
+ OR NEW.timezone IS DISTINCT FROM OLD.timezone THEN
1360
+ NEW.wall_starts_at := slotlock.zoned_wall(NEW.starts_at, NEW.timezone);
1361
+ NEW.wall_ends_at := slotlock.zoned_wall(NEW.ends_at, NEW.timezone);
1362
+ NEW.zone_shift_reported_at := NULL;
1363
+ END IF;
1364
+ RETURN NEW;
1365
+ END
1366
+ $slotlock_calendar_event_wall_time$;
1367
+ DROP TRIGGER IF EXISTS slotlock_calendar_event_wall_time ON slotlock.calendar_events;
1368
+ CREATE TRIGGER slotlock_calendar_event_wall_time
1369
+ BEFORE INSERT OR UPDATE ON slotlock.calendar_events
1370
+ FOR EACH ROW EXECUTE FUNCTION slotlock.calendar_event_wall_time();
1371
+ -- Events written before this release: their wall time as the rules read now.
1372
+ UPDATE slotlock.calendar_events
1373
+ SET wall_starts_at = slotlock.zoned_wall(starts_at, timezone),
1374
+ wall_ends_at = slotlock.zoned_wall(ends_at, timezone)
1375
+ WHERE wall_starts_at IS NULL AND ends_at > now();
1376
+
1377
+ -- Feed health: a source's last failure, and when its staleness was last reported on the feed.
1378
+ ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error text;
1379
+ ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error_at timestamptz;
1380
+ ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS stale_reported_at timestamptz;
1381
+
1382
+ DROP TRIGGER IF EXISTS slotlock_outbox_reservation_change ON slotlock.reservations;
1383
+ CREATE TRIGGER slotlock_outbox_reservation_change
1384
+ AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
1385
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_reservation_change();
1386
+ DROP TRIGGER IF EXISTS slotlock_outbox_calendar_event_change ON slotlock.calendar_events;
1387
+ CREATE TRIGGER slotlock_outbox_calendar_event_change
1388
+ AFTER INSERT OR UPDATE OR DELETE ON slotlock.calendar_events
1389
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_calendar_event_change();
1390
+ `;
573
1391
  // btree_gist is created in slotlock when it is missing; one installed elsewhere stays where it is.
574
- export const SLOTLOCK_CORE_DDL = `
1392
+ const CORE_DDL_BODY = `
575
1393
  ${DEPLOYMENT_SEARCH_PATH_PIN}CREATE SCHEMA IF NOT EXISTS slotlock;
576
- ${SLOTLOCK_SCHEMA_CONTROL_CHECK}CREATE EXTENSION IF NOT EXISTS btree_gist SCHEMA slotlock;
577
- ${DEPLOYMENT_LIFT_FORCED_ROW_SECURITY}
1394
+ ${SLOTLOCK_DEPLOYMENT_CHECKS}CREATE EXTENSION IF NOT EXISTS btree_gist SCHEMA slotlock;
1395
+ ${DEPLOYMENT_LIFT_FORCED_ROW_SECURITY}${SLOTLOCK_SCHEMA_VERSION_DDL}
578
1396
  -- Records the exact catalog representation installed for each owned RLS policy. Health checks
579
1397
  -- compare live policy fingerprints with this contract so ALTER POLICY drift cannot hide behind a
580
1398
  -- retained name/comment. This table contains configuration only, never tenant or calendar data.
@@ -643,10 +1461,33 @@ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS tenant_ref text;
643
1461
  -- Its CHECK holds SLOTLOCK_MAX_AVAILABILITY_RULES as it was when first added: changing the cap
644
1462
  -- needs a constraint under a new name.
645
1463
  ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS availability_rules jsonb;
1464
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS booking_rules jsonb;
1465
+ -- Since when the booking rules have asked for check-in: only bookings made after it are released
1466
+ -- unclaimed (releaseNoShows), so turning the rule on releases none of the bookings already made.
1467
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS check_in_since timestamptz;
1468
+ -- When the booking was claimed (checkInReservation); null while it has not been.
1469
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS checked_in_at timestamptz;
1470
+ -- The pool (a vehicle class, a room type) whose interchangeable members this resource is one of.
1471
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS pool_ref text;
1472
+ CREATE INDEX IF NOT EXISTS slotlock_resources_pool_idx
1473
+ ON slotlock.resources (tenant_ref, pool_ref, id) WHERE pool_ref IS NOT NULL;
1474
+ -- Who sees a resource: 'shared' ones are listed to agents and booked through them, 'private' ones
1475
+ -- (a tax-deadline calendar, say) only by the tenant's own code. Fixed when the resource is created.
1476
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'shared';
1477
+ -- When the tenant archived it: listed nowhere and booked no more, its history kept. NULL: in use.
1478
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS archived_at timestamptz;
646
1479
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
647
1480
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
648
1481
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
649
1482
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_ends_at timestamptz;
1483
+ -- A keyed hold's holder (its principal) and the group of alternatives it was taken in: an agent
1484
+ -- releases or books only its own holds, and booking one releases the rest of its group.
1485
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS holder_ref text;
1486
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS hold_group_id uuid;
1487
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_hold_group_idx
1488
+ ON slotlock.reservations (hold_group_id) WHERE hold_group_id IS NOT NULL;
1489
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_holder_idx
1490
+ ON slotlock.reservations (holder_ref, expires_at) WHERE status = 'held' AND holder_ref IS NOT NULL;
650
1491
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1;
651
1492
 
652
1493
  -- timestamptz + interval is STABLE (session timezone aware), so PostgreSQL correctly refuses it
@@ -689,6 +1530,20 @@ CREATE TABLE IF NOT EXISTS slotlock.reservation_tombstones (
689
1530
  ),
690
1531
  CONSTRAINT slotlock_reservation_tombstones_revision_valid CHECK (revision > 0)
691
1532
  );
1533
+ -- When the reference was retired, so maintenance can prune it past the retention window. Rows from
1534
+ -- before 0.3 read '-infinity', earlier than every time, so none counts as a recent cancellation
1535
+ -- against the tenant's limits; new rows take now(). An ADD COLUMN default is evaluated once and
1536
+ -- kept for the rows already there, with no table rewrite, and SET DEFAULT changes only later
1537
+ -- inserts (PostgreSQL 16, ALTER TABLE notes; Date/Time Types, special values).
1538
+ ALTER TABLE slotlock.reservation_tombstones
1539
+ ADD COLUMN IF NOT EXISTS cancelled_at timestamptz NOT NULL DEFAULT '-infinity';
1540
+ ALTER TABLE slotlock.reservation_tombstones ALTER COLUMN cancelled_at SET DEFAULT now();
1541
+ -- True when the cancel retired a reference no booking held (its create may still be on the way).
1542
+ -- Only those are pruned past the retention window. A cancelled booking's tombstone is kept until
1543
+ -- the tenant is erased, so a delayed or replayed create never books it again; rows from before 0.3
1544
+ -- are kept too.
1545
+ ALTER TABLE slotlock.reservation_tombstones
1546
+ ADD COLUMN IF NOT EXISTS stray boolean NOT NULL DEFAULT false;
692
1547
  -- Resource identity: one resource per tenant/external_ref namespace (plus one isolated NULL-tenant
693
1548
  -- legacy namespace). Dedupe first (repoint reservations to the keeper, drop the rest) so the
694
1549
  -- unique indexes also bind on DBs poisoned by the pre-index
@@ -850,6 +1705,33 @@ BEGIN
850
1705
  ELSE false END
851
1706
  );
852
1707
  END IF;
1708
+ IF NOT EXISTS (
1709
+ SELECT 1 FROM pg_constraint
1710
+ WHERE conname = 'slotlock_resources_pool_ref_valid'
1711
+ ) THEN
1712
+ ALTER TABLE slotlock.resources
1713
+ ADD CONSTRAINT slotlock_resources_pool_ref_valid CHECK (
1714
+ pool_ref IS NULL OR (char_length(pool_ref) BETWEEN 1 AND 100 AND pool_ref ~ '^[a-z0-9][a-z0-9._-]*$')
1715
+ ) NOT VALID;
1716
+ END IF;
1717
+ IF NOT EXISTS (
1718
+ SELECT 1 FROM pg_constraint
1719
+ WHERE conname = 'slotlock_resources_booking_rules_valid'
1720
+ AND conrelid = 'slotlock.resources'::regclass
1721
+ ) THEN
1722
+ ALTER TABLE slotlock.resources
1723
+ ADD CONSTRAINT slotlock_resources_booking_rules_valid CHECK (
1724
+ booking_rules IS NULL OR jsonb_typeof(booking_rules) = 'object'
1725
+ );
1726
+ END IF;
1727
+ IF NOT EXISTS (
1728
+ SELECT 1 FROM pg_constraint
1729
+ WHERE conname = 'slotlock_resources_visibility_valid'
1730
+ AND conrelid = 'slotlock.resources'::regclass
1731
+ ) THEN
1732
+ ALTER TABLE slotlock.resources
1733
+ ADD CONSTRAINT slotlock_resources_visibility_valid CHECK (visibility IN ('shared', 'private'));
1734
+ END IF;
853
1735
  IF NOT EXISTS (
854
1736
  SELECT 1 FROM pg_constraint
855
1737
  WHERE conname = 'slotlock_reservations_revision_valid'
@@ -1028,12 +1910,13 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_events (
1028
1910
  AND octet_length(external_ref) BETWEEN 1 AND 500
1029
1911
  ),
1030
1912
  CONSTRAINT slotlock_calendar_events_window_valid CHECK (ends_at > starts_at),
1031
- -- A recurring series expands inside a window of at most 367 days. A one-off event is its own
1032
- -- window, so a lease or long rental may span up to 3,660 days (the store's hard ceiling).
1033
- CONSTRAINT slotlock_calendar_events_materialization_window_bounded CHECK (
1913
+ -- A recurring series expands inside a window of at most 369 days (a 367-day search from any
1914
+ -- moment of the day, plus the day before it). A one-off event is its own window, so a lease or
1915
+ -- long rental may span up to 3,660 days (the store's hard ceiling).
1916
+ CONSTRAINT slotlock_calendar_events_materialization_window_limit CHECK (
1034
1917
  materialized_ends_at > materialized_starts_at
1035
1918
  AND (
1036
- materialized_ends_at - materialized_starts_at <= interval '367 days'
1919
+ materialized_ends_at - materialized_starts_at <= interval '369 days'
1037
1920
  OR (
1038
1921
  recurrence_rule IS NULL
1039
1922
  AND materialized_starts_at = starts_at
@@ -1112,7 +1995,8 @@ DO $slotlock_calendar_event_window_bounds$
1112
1995
  BEGIN
1113
1996
  IF NOT EXISTS (
1114
1997
  SELECT 1 FROM pg_constraint
1115
- WHERE conname = 'slotlock_calendar_events_materialization_window_bounded'
1998
+ WHERE conname IN ('slotlock_calendar_events_materialization_window_bounded',
1999
+ 'slotlock_calendar_events_materialization_window_limit')
1116
2000
  AND conrelid = 'slotlock.calendar_events'::regclass
1117
2001
  ) THEN
1118
2002
  ALTER TABLE slotlock.calendar_events
@@ -1134,6 +2018,34 @@ $slotlock_calendar_event_window_bounds$;
1134
2018
  ALTER TABLE slotlock.calendar_events
1135
2019
  DROP CONSTRAINT IF EXISTS slotlock_calendar_events_materialization_window_valid;
1136
2020
 
2021
+ -- Up to 0.2 a series' window was at most 367 days. The 369-day CHECK admits every row the 367-day
2022
+ -- one did: add it NOT VALID and drop the old one in the same transaction.
2023
+ DO $slotlock_calendar_event_window_limit$
2024
+ BEGIN
2025
+ IF NOT EXISTS (
2026
+ SELECT 1 FROM pg_constraint
2027
+ WHERE conname = 'slotlock_calendar_events_materialization_window_limit'
2028
+ AND conrelid = 'slotlock.calendar_events'::regclass
2029
+ ) THEN
2030
+ ALTER TABLE slotlock.calendar_events
2031
+ ADD CONSTRAINT slotlock_calendar_events_materialization_window_limit CHECK (
2032
+ materialized_ends_at > materialized_starts_at
2033
+ AND (
2034
+ materialized_ends_at - materialized_starts_at <= interval '369 days'
2035
+ OR (
2036
+ recurrence_rule IS NULL
2037
+ AND materialized_starts_at = starts_at
2038
+ AND materialized_ends_at = ends_at
2039
+ AND ends_at - starts_at <= interval '3660 days'
2040
+ )
2041
+ )
2042
+ ) NOT VALID;
2043
+ END IF;
2044
+ END
2045
+ $slotlock_calendar_event_window_limit$;
2046
+ ALTER TABLE slotlock.calendar_events
2047
+ DROP CONSTRAINT IF EXISTS slotlock_calendar_events_materialization_window_bounded;
2048
+
1137
2049
  -- Where a recurring series' last expansion stopped (ical.js's iterator state as JSON), so the next
1138
2050
  -- one resumes there instead of searching again from the series' start. Only Slotlock writes it. A
1139
2051
  -- cursor that does not fit the row's rule and start is ignored, so NULL, or a cursor left by an
@@ -1142,6 +2054,19 @@ ALTER TABLE slotlock.calendar_events
1142
2054
  -- not tie the cursor to a rule: a release from before cursors may turn a series into a one-off and
1143
2055
  -- leave its cursor, which is then never read.
1144
2056
  ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_cursor jsonb;
2057
+ -- When the horizon roll may next examine a series it could not extend (an occurrence collided, or
2058
+ -- the rule is over the expansion budget): until then it waits behind the series that can roll, so
2059
+ -- a pile of blocked series never starves a healthy one. NULL: due as usual.
2060
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS roll_retry_at timestamptz;
2061
+ -- For a recurring series, a time by which every occurrence ends, moved ones included; 'infinity'
2062
+ -- when its rule has no end, or none Slotlock could follow it to. NULL: not worked out yet (a series
2063
+ -- from before 0.3, until its next roll), and so treated as going on. Unused for a one-off event.
2064
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by timestamptz;
2065
+ -- The turnaround a booked event keeps after each occurrence (its resource's, when it was booked), so
2066
+ -- the occurrences a later roll adds keep it too. At most a day.
2067
+ ALTER TABLE slotlock.calendar_events
2068
+ ADD COLUMN IF NOT EXISTS buffer_after_ms integer NOT NULL DEFAULT 0
2069
+ CONSTRAINT slotlock_calendar_events_buffer_valid CHECK (buffer_after_ms BETWEEN 0 AND 86400000);
1145
2070
  ALTER TABLE slotlock.calendar_events
1146
2071
  DROP CONSTRAINT IF EXISTS slotlock_calendar_events_recurrence_cursor_bounded;
1147
2072
  DO $slotlock_calendar_event_recurrence_cursor$
@@ -1406,6 +2331,38 @@ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_reservations_booking_external_ref_key
1406
2331
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL AND calendar_event_id IS NULL;
1407
2332
  DROP INDEX IF EXISTS slotlock.slotlock_reservations_tenant_external_ref_key;
1408
2333
 
2334
+ -- Up to 0.2 a confirmed hold, and a createReservation booking, on a tenant's resource kept no tenant
2335
+ -- or reference, so no cancel or move could ever reach it. Each takes its resource's tenant and its
2336
+ -- own id as reference, as both now do when written. A tombstone already holding that reference was
2337
+ -- written by cancelling it as a reference no booking held (the only way to try): it retired nothing
2338
+ -- and is dropped, so the booking can be cancelled. A booking another booking's reference already
2339
+ -- uses (never written by Slotlock) is left as it was.
2340
+ DELETE FROM slotlock.reservation_tombstones tombstone
2341
+ USING slotlock.reservations booking
2342
+ JOIN slotlock.resources resource ON resource.id = booking.resource_id
2343
+ WHERE booking.tenant_ref IS NULL
2344
+ AND booking.status = 'confirmed'
2345
+ AND booking.calendar_event_id IS NULL
2346
+ AND resource.tenant_ref IS NOT NULL
2347
+ AND tombstone.tenant_ref = resource.tenant_ref
2348
+ AND tombstone.external_ref = booking.id::text;
2349
+ UPDATE slotlock.reservations booking
2350
+ SET tenant_ref = resource.tenant_ref,
2351
+ external_ref = booking.id::text,
2352
+ occupied_ends_at = booking.ends_at + booking.buffer_after_ms * interval '1 millisecond'
2353
+ FROM slotlock.resources resource
2354
+ WHERE resource.id = booking.resource_id
2355
+ AND booking.tenant_ref IS NULL
2356
+ AND booking.status = 'confirmed'
2357
+ AND booking.calendar_event_id IS NULL
2358
+ AND resource.tenant_ref IS NOT NULL
2359
+ AND NOT EXISTS (
2360
+ SELECT 1 FROM slotlock.reservations taken
2361
+ WHERE taken.tenant_ref = resource.tenant_ref
2362
+ AND taken.external_ref = booking.id::text
2363
+ AND taken.calendar_event_id IS NULL
2364
+ );
2365
+
1409
2366
  -- Materialized occurrences point at reservations created under the SAME exclusion arbiter as
1410
2367
  -- bookings and holds. Transparent events retain occurrence visibility with a NULL reservation.
1411
2368
  CREATE TABLE IF NOT EXISTS slotlock.calendar_event_occurrences (
@@ -1566,6 +2523,68 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_coverage (
1566
2523
  CONSTRAINT slotlock_calendar_coverage_revision_valid CHECK (revision > 0)
1567
2524
  );
1568
2525
 
2526
+ -- A keyed hold's first answer, by the key its caller gave inside its tenant and principal, so a
2527
+ -- retried request answers the same hold instead of overlapping it. The answer stays as given until
2528
+ -- the hold would have expired, even once it is confirmed, released or reaped, and counts against
2529
+ -- the tenant's hold limit while its hold is not live; then maintenance, or a hold at that limit,
2530
+ -- deletes it (store.ts forgetExpiredHoldAnswers).
2531
+ CREATE TABLE IF NOT EXISTS slotlock.hold_commands (
2532
+ tenant_ref text NOT NULL,
2533
+ principal_ref text NOT NULL,
2534
+ idempotency_key text NOT NULL,
2535
+ payload_hash text NOT NULL,
2536
+ hold_id uuid NOT NULL,
2537
+ resource_id uuid NOT NULL,
2538
+ starts_at timestamptz NOT NULL,
2539
+ ends_at timestamptz NOT NULL,
2540
+ buffer_after_ms bigint NOT NULL,
2541
+ expires_at timestamptz NOT NULL,
2542
+ source text,
2543
+ created_at timestamptz NOT NULL DEFAULT now(),
2544
+ CONSTRAINT slotlock_hold_commands_key PRIMARY KEY (tenant_ref, principal_ref, idempotency_key),
2545
+ CONSTRAINT slotlock_hold_commands_resource_tenant_fkey
2546
+ FOREIGN KEY (resource_id, tenant_ref)
2547
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
2548
+ CONSTRAINT slotlock_hold_commands_identity_valid CHECK (
2549
+ octet_length(tenant_ref) BETWEEN 1 AND 500
2550
+ AND octet_length(principal_ref) BETWEEN 1 AND 500
2551
+ AND octet_length(idempotency_key) BETWEEN 1 AND 500
2552
+ AND octet_length(payload_hash) = 64
2553
+ )
2554
+ );
2555
+ CREATE INDEX IF NOT EXISTS slotlock_hold_commands_expiry_idx
2556
+ ON slotlock.hold_commands (tenant_ref, expires_at);
2557
+ -- References retired with no booking: a tenant's count of them, and maintenance's prune.
2558
+ CREATE INDEX IF NOT EXISTS slotlock_reservation_tombstones_stray_idx
2559
+ ON slotlock.reservation_tombstones (tenant_ref, cancelled_at) WHERE stray;
2560
+ -- Holds by resource and expiry: a tenant's live-hold count, and maintenance's purge of expired ones.
2561
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_held_expiry_idx
2562
+ ON slotlock.reservations (resource_id, expires_at) WHERE status = 'held';
2563
+ -- Confirmed bookings with no tenant, by resource: maintenance finds those a 0.2 server wrote on a
2564
+ -- tenant's resource after the migration (store.ts pruneBookingRetention).
2565
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_untenanted_booking_idx
2566
+ ON slotlock.reservations (resource_id)
2567
+ WHERE tenant_ref IS NULL AND status = 'confirmed' AND calendar_event_id IS NULL;
2568
+ -- What the tenant limits count (store.ts tenantLimitCount), each read as a range so a count stops
2569
+ -- at the limit: bookings and events by when they end (one-offs and series apart) and by when they
2570
+ -- were made, and cancellations by when they were made.
2571
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_booking_end_idx
2572
+ ON slotlock.reservations (tenant_ref, occupied_ends_at)
2573
+ WHERE external_ref IS NOT NULL AND calendar_event_id IS NULL;
2574
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_booking_created_idx
2575
+ ON slotlock.reservations (tenant_ref, created_at)
2576
+ WHERE external_ref IS NOT NULL AND calendar_event_id IS NULL;
2577
+ CREATE INDEX IF NOT EXISTS slotlock_reservation_tombstones_cancelled_idx
2578
+ ON slotlock.reservation_tombstones (tenant_ref, cancelled_at);
2579
+ CREATE INDEX IF NOT EXISTS slotlock_calendar_events_one_off_end_idx
2580
+ ON slotlock.calendar_events (tenant_ref, ends_at) WHERE recurrence_rule IS NULL;
2581
+ CREATE INDEX IF NOT EXISTS slotlock_calendar_events_series_end_idx
2582
+ ON slotlock.calendar_events (tenant_ref, recurrence_ends_by) WHERE recurrence_rule IS NOT NULL;
2583
+ CREATE INDEX IF NOT EXISTS slotlock_calendar_events_created_idx
2584
+ ON slotlock.calendar_events (tenant_ref, created_at);
2585
+ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_tombstones_cancelled_idx
2586
+ ON slotlock.calendar_event_tombstones (tenant_ref, cancelled_at);
2587
+
1569
2588
  CREATE INDEX IF NOT EXISTS slotlock_calendar_events_resource_time_idx
1570
2589
  ON slotlock.calendar_events (tenant_ref, resource_id, starts_at, ends_at);
1571
2590
  CREATE INDEX IF NOT EXISTS slotlock_calendar_events_owner_resource_time_idx
@@ -1581,7 +2600,14 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
1581
2600
  ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
1582
2601
  CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
1583
2602
  ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
1584
- ${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
2603
+ ${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
2604
+ /** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
2605
+ export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
2606
+ /**
2607
+ * SLOTLOCK_CORE_DDL without the validation, which applySchema runs as a step of its own after this
2608
+ * commits (SLOTLOCK_VALIDATION_DDL).
2609
+ */
2610
+ export const SLOTLOCK_STRUCTURE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
1585
2611
  export const SLOTLOCK_TENANT_CONTEXT_SETTING = 'slotlock.tenant_ref';
1586
2612
  /**
1587
2613
  * Every table the store reads or writes at runtime, each under forced tenant RLS.
@@ -1601,6 +2627,11 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
1601
2627
  'calendar_event_tombstones',
1602
2628
  'calendar_event_commands',
1603
2629
  'calendar_coverage',
2630
+ 'hold_commands',
2631
+ 'outbox',
2632
+ 'outbox_pruned',
2633
+ 'webhook_endpoints',
2634
+ 'waitlist_entries',
1604
2635
  ]);
1605
2636
  const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
1606
2637
  const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -1636,7 +2667,7 @@ export function createSlotlockTenantRlsDdl(tenantContextSetting = SLOTLOCK_TENAN
1636
2667
  throw Object.assign(new Error(`Slotlock tenant context setting must be a dotted identifier other than ${CALLER_SEARCH_PATH_SETTING}`), { code: 'invalid_tenant_context_setting' });
1637
2668
  }
1638
2669
  return `
1639
- ${DEPLOYMENT_SEARCH_PATH_PIN}${SLOTLOCK_SCHEMA_CONTROL_CHECK}ALTER TABLE slotlock.resources ENABLE ROW LEVEL SECURITY;
2670
+ ${DEPLOYMENT_SEARCH_PATH_PIN}${SLOTLOCK_DEPLOYMENT_CHECKS}ALTER TABLE slotlock.resources ENABLE ROW LEVEL SECURITY;
1640
2671
  ALTER TABLE slotlock.resources FORCE ROW LEVEL SECURITY;
1641
2672
  ALTER TABLE slotlock.reservations ENABLE ROW LEVEL SECURITY;
1642
2673
  ALTER TABLE slotlock.reservations FORCE ROW LEVEL SECURITY;
@@ -1660,6 +2691,16 @@ ALTER TABLE slotlock.calendar_event_commands ENABLE ROW LEVEL SECURITY;
1660
2691
  ALTER TABLE slotlock.calendar_event_commands FORCE ROW LEVEL SECURITY;
1661
2692
  ALTER TABLE slotlock.calendar_coverage ENABLE ROW LEVEL SECURITY;
1662
2693
  ALTER TABLE slotlock.calendar_coverage FORCE ROW LEVEL SECURITY;
2694
+ ALTER TABLE slotlock.hold_commands ENABLE ROW LEVEL SECURITY;
2695
+ ALTER TABLE slotlock.hold_commands FORCE ROW LEVEL SECURITY;
2696
+ ALTER TABLE slotlock.outbox ENABLE ROW LEVEL SECURITY;
2697
+ ALTER TABLE slotlock.outbox FORCE ROW LEVEL SECURITY;
2698
+ ALTER TABLE slotlock.outbox_pruned ENABLE ROW LEVEL SECURITY;
2699
+ ALTER TABLE slotlock.outbox_pruned FORCE ROW LEVEL SECURITY;
2700
+ ALTER TABLE slotlock.webhook_endpoints ENABLE ROW LEVEL SECURITY;
2701
+ ALTER TABLE slotlock.webhook_endpoints FORCE ROW LEVEL SECURITY;
2702
+ ALTER TABLE slotlock.waitlist_entries ENABLE ROW LEVEL SECURITY;
2703
+ ALTER TABLE slotlock.waitlist_entries FORCE ROW LEVEL SECURITY;
1663
2704
 
1664
2705
  -- One database must have one tenant-context contract. Re-applying with an adapter-specific
1665
2706
  -- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
@@ -1683,7 +2724,12 @@ BEGIN
1683
2724
  'calendar_event_occurrences',
1684
2725
  'calendar_event_tombstones',
1685
2726
  'calendar_event_commands',
1686
- 'calendar_coverage'
2727
+ 'calendar_coverage',
2728
+ 'hold_commands',
2729
+ 'outbox',
2730
+ 'outbox_pruned',
2731
+ 'webhook_endpoints',
2732
+ 'waitlist_entries'
1687
2733
  )
1688
2734
  LOOP
1689
2735
  EXECUTE format(
@@ -1844,6 +2890,56 @@ BEGIN
1844
2890
  USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
1845
2891
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
1846
2892
  END IF;
2893
+
2894
+ IF NOT EXISTS (
2895
+ SELECT 1 FROM pg_policies
2896
+ WHERE schemaname = 'slotlock' AND tablename = 'hold_commands'
2897
+ AND policyname = 'tenant_isolation_v1'
2898
+ ) THEN
2899
+ CREATE POLICY tenant_isolation_v1 ON slotlock.hold_commands
2900
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2901
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2902
+ END IF;
2903
+
2904
+ IF NOT EXISTS (
2905
+ SELECT 1 FROM pg_policies
2906
+ WHERE schemaname = 'slotlock' AND tablename = 'outbox'
2907
+ AND policyname = 'tenant_isolation_v1'
2908
+ ) THEN
2909
+ CREATE POLICY tenant_isolation_v1 ON slotlock.outbox
2910
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2911
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2912
+ END IF;
2913
+
2914
+ IF NOT EXISTS (
2915
+ SELECT 1 FROM pg_policies
2916
+ WHERE schemaname = 'slotlock' AND tablename = 'outbox_pruned'
2917
+ AND policyname = 'tenant_isolation_v1'
2918
+ ) THEN
2919
+ CREATE POLICY tenant_isolation_v1 ON slotlock.outbox_pruned
2920
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2921
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2922
+ END IF;
2923
+
2924
+ IF NOT EXISTS (
2925
+ SELECT 1 FROM pg_policies
2926
+ WHERE schemaname = 'slotlock' AND tablename = 'webhook_endpoints'
2927
+ AND policyname = 'tenant_isolation_v1'
2928
+ ) THEN
2929
+ CREATE POLICY tenant_isolation_v1 ON slotlock.webhook_endpoints
2930
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2931
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2932
+ END IF;
2933
+
2934
+ IF NOT EXISTS (
2935
+ SELECT 1 FROM pg_policies
2936
+ WHERE schemaname = 'slotlock' AND tablename = 'waitlist_entries'
2937
+ AND policyname = 'tenant_isolation_v1'
2938
+ ) THEN
2939
+ CREATE POLICY tenant_isolation_v1 ON slotlock.waitlist_entries
2940
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2941
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2942
+ END IF;
1847
2943
  END
1848
2944
  $slotlock_rls$;
1849
2945
 
@@ -1871,6 +2967,16 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.calendar_event_commands
1871
2967
  IS 'slotlock:tenant-context:${tenantContextSetting}';
1872
2968
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.calendar_coverage
1873
2969
  IS 'slotlock:tenant-context:${tenantContextSetting}';
2970
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.hold_commands
2971
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
2972
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.outbox
2973
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
2974
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.outbox_pruned
2975
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
2976
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.webhook_endpoints
2977
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
2978
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.waitlist_entries
2979
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
1874
2980
 
1875
2981
  INSERT INTO slotlock.rls_policy_contracts (
1876
2982
  table_name, tenant_context_setting, policy_fingerprint, installed_at
@@ -1905,7 +3011,12 @@ WHERE schemaname = 'slotlock'
1905
3011
  'calendar_event_occurrences',
1906
3012
  'calendar_event_tombstones',
1907
3013
  'calendar_event_commands',
1908
- 'calendar_coverage'
3014
+ 'calendar_coverage',
3015
+ 'hold_commands',
3016
+ 'outbox',
3017
+ 'outbox_pruned',
3018
+ 'webhook_endpoints',
3019
+ 'waitlist_entries'
1909
3020
  )
1910
3021
  AND policyname = 'tenant_isolation_v1'
1911
3022
  ON CONFLICT (table_name) DO UPDATE