slotlock 0.2.0 → 0.3.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 (51) hide show
  1. package/CHANGELOG.md +330 -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 +277 -53
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +12 -3
  10. package/agent-server.js +108 -78
  11. package/agent-store-backend.d.ts +6 -0
  12. package/agent-store-backend.js +355 -36
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +14 -1
  16. package/application-api.js +274 -12
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +6 -0
  20. package/dashboard.js +17 -1
  21. package/ddl.d.ts +45 -3
  22. package/ddl.js +538 -12
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/ical-worker-thread.js +4 -2
  27. package/ical-worker.d.ts +8 -5
  28. package/ical-worker.js +9 -2
  29. package/index.d.ts +5 -5
  30. package/index.js +3 -3
  31. package/maintenance.d.ts +15 -5
  32. package/maintenance.js +59 -27
  33. package/node-server.d.ts +9 -1
  34. package/node-server.js +7 -2
  35. package/openapi.json +6024 -0
  36. package/package.json +3 -2
  37. package/rate-limit.d.ts +71 -0
  38. package/rate-limit.js +718 -0
  39. package/rest-api.js +10 -0
  40. package/rules.js +6 -3
  41. package/self-host.d.ts +22 -8
  42. package/self-host.js +307 -34
  43. package/slotlock-manifest.schema.json +6 -1
  44. package/slotlock.manifest.json +7 -2
  45. package/store.d.ts +334 -24
  46. package/store.js +2029 -290
  47. package/sync.d.ts +43 -2
  48. package/sync.js +1515 -323
  49. package/timezone.d.ts +58 -3
  50. package/timezone.js +249 -20
  51. package/types.d.ts +213 -9
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,51 @@ 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(['tenant_suspensions']);
239
+ /**
240
+ * The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
241
+ * tenant, by signature.
242
+ */
243
+ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
244
+ 'slotlock.tenant_suspended(text)',
245
+ 'slotlock.erase_tenant(text)',
246
+ ]);
247
+ /**
248
+ * Functions only the deployment role runs: suspending and lifting a suspension. No other role is
249
+ * granted them, so a serving role cannot lift a suspension.
250
+ */
251
+ export const SLOTLOCK_OPERATOR_FUNCTIONS = Object.freeze([
252
+ 'slotlock.suspend_tenant(text, text)',
253
+ 'slotlock.unsuspend_tenant(text)',
254
+ ]);
193
255
  /** Every SECURITY DEFINER function the serving role may execute, by signature. */
194
256
  export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
195
257
  ...SLOTLOCK_API_KEY_FUNCTIONS,
196
258
  ...SLOTLOCK_DASHBOARD_FUNCTIONS,
259
+ ...SLOTLOCK_SCHEMA_FUNCTIONS,
260
+ ...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
197
261
  ]);
198
262
  /** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
199
263
  export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
200
264
  ...SLOTLOCK_API_KEY_TABLES,
201
265
  ...SLOTLOCK_DASHBOARD_TABLES,
266
+ ...SLOTLOCK_SCHEMA_TABLES,
267
+ ...SLOTLOCK_TENANT_ADMIN_TABLES,
202
268
  ]);
269
+ /**
270
+ * The advisory lock every server transaction for a tenant holds shared (store.ts withTenant), and
271
+ * suspension and erasure take exclusively, keyed by this text's hashtextextended(…, 0).
272
+ */
273
+ export function slotlockTenantLockKey(tenantRef) {
274
+ return `slotlock:tenant:${tenantRef}`;
275
+ }
203
276
  const API_KEY_COLUMNS = `key_id uuid,
204
277
  key_tenant_ref text,
205
278
  key_name text,
@@ -316,6 +389,11 @@ BEGIN
316
389
  USING ERRCODE = 'invalid_transaction_state';
317
390
  END IF;
318
391
  PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
392
+ IF EXISTS (SELECT 1 FROM slotlock.tenant_suspensions suspended
393
+ WHERE suspended.tenant_ref = requested_tenant_ref) THEN
394
+ RAISE EXCEPTION 'Slotlock tenant is suspended: it gets no new API key'
395
+ USING ERRCODE = 'insufficient_privilege', HINT = 'tenant_suspended';
396
+ END IF;
319
397
  IF EXISTS (SELECT 1 FROM slotlock.api_key_retired_digests retired
320
398
  WHERE retired.secret_hash = requested_secret_hash) THEN
321
399
  RAISE EXCEPTION 'Slotlock never uses a retired API key digest again'
@@ -370,6 +448,13 @@ AS $slotlock_rotate_api_key$
370
448
  DECLARE
371
449
  retiring bytea;
372
450
  BEGIN
451
+ -- Ordered with suspension, which takes this lock too.
452
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
453
+ IF EXISTS (SELECT 1 FROM slotlock.tenant_suspensions suspended
454
+ WHERE suspended.tenant_ref = requested_tenant_ref) THEN
455
+ RAISE EXCEPTION 'Slotlock tenant is suspended: it gets no new API key secret'
456
+ USING ERRCODE = 'insufficient_privilege', HINT = 'tenant_suspended';
457
+ END IF;
373
458
  IF EXISTS (SELECT 1 FROM slotlock.api_key_retired_digests retired
374
459
  WHERE retired.secret_hash = requested_secret_hash) THEN
375
460
  RAISE EXCEPTION 'Slotlock never uses a retired API key digest again'
@@ -570,11 +655,269 @@ AS $slotlock_dashboard_session_ended$
570
655
  $slotlock_dashboard_session_ended$;
571
656
  REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
572
657
  `;
658
+ // The schema's version. One row, written by the DDL itself at its end, so a schema migrated by
659
+ // running SLOTLOCK_CORE_DDL directly is versioned too. applySchema adds digests of the DDL and of the
660
+ // catalog it left, so that a later migration with nothing to change can tell, and take no lock on
661
+ // any table. Nothing here names a tenant; RLS is enabled with no policy and no role is granted
662
+ // anything, so a server reads the version only through installed_schema_version().
663
+ const SLOTLOCK_SCHEMA_VERSION_DDL = `
664
+ CREATE TABLE IF NOT EXISTS slotlock.schema_version (
665
+ singleton boolean PRIMARY KEY DEFAULT true,
666
+ version integer NOT NULL,
667
+ ddl_sha256 text,
668
+ catalog_sha256 text,
669
+ applied_at timestamptz NOT NULL DEFAULT now(),
670
+ CONSTRAINT slotlock_schema_version_singleton CHECK (singleton),
671
+ CONSTRAINT slotlock_schema_version_valid CHECK (version > 0)
672
+ );
673
+ ALTER TABLE slotlock.schema_version ENABLE ROW LEVEL SECURITY;
674
+ REVOKE ALL ON slotlock.schema_version FROM PUBLIC;
675
+
676
+ -- NULL when no migration has recorded a version (a schema from 0.2 or earlier).
677
+ CREATE OR REPLACE FUNCTION slotlock.installed_schema_version()
678
+ RETURNS integer
679
+ LANGUAGE sql
680
+ STABLE
681
+ SECURITY DEFINER
682
+ SET search_path = pg_catalog, slotlock, pg_temp
683
+ AS $slotlock_installed_schema_version$
684
+ SELECT version FROM slotlock.schema_version WHERE singleton;
685
+ $slotlock_installed_schema_version$;
686
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_SCHEMA_FUNCTIONS[0]} FROM PUBLIC;
687
+ `;
688
+ // Tenant suspension and erasure. A suspended tenant's server transactions (withTenant) may read,
689
+ // and write nothing; its keys still authenticate, but it gets no new key or secret. The table names
690
+ // tenants and why they were suspended, so RLS is enabled with no policy and no role is granted
691
+ // anything: a server reads it only through tenant_suspended(), and only the deployment role, which
692
+ // owns it, suspends or lifts a suspension. Erasure keeps the suspension, so an erased abuser stays
693
+ // suspended.
694
+ //
695
+ // Every server transaction for a tenant holds the tenant's lock shared, from before it reads the
696
+ // suspension; suspension and erasure take it exclusively. So a suspension waits for the tenant's
697
+ // transactions in flight, and every one that starts after it sees it.
698
+ const SLOTLOCK_TENANT_ADMIN_DDL = `
699
+ CREATE TABLE IF NOT EXISTS slotlock.tenant_suspensions (
700
+ tenant_ref text PRIMARY KEY,
701
+ reason text,
702
+ suspended_at timestamptz NOT NULL DEFAULT now(),
703
+ CONSTRAINT slotlock_tenant_suspensions_tenant_ref_valid
704
+ CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
705
+ CONSTRAINT slotlock_tenant_suspensions_reason_valid
706
+ CHECK (reason IS NULL OR (char_length(reason) BETWEEN 1 AND 500 AND reason !~ '[[:cntrl:]]'))
707
+ );
708
+ ALTER TABLE slotlock.tenant_suspensions ENABLE ROW LEVEL SECURITY;
709
+ REVOKE ALL ON slotlock.tenant_suspensions FROM PUBLIC;
710
+
711
+ CREATE OR REPLACE FUNCTION slotlock.tenant_suspended(requested_tenant_ref text)
712
+ RETURNS boolean
713
+ LANGUAGE sql
714
+ STABLE
715
+ SECURITY DEFINER
716
+ SET search_path = pg_catalog, slotlock, pg_temp
717
+ AS $slotlock_tenant_suspended$
718
+ SELECT EXISTS (
719
+ SELECT 1 FROM slotlock.tenant_suspensions suspended
720
+ WHERE suspended.tenant_ref = requested_tenant_ref
721
+ );
722
+ $slotlock_tenant_suspended$;
723
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[0]} FROM PUBLIC;
724
+
725
+ -- True when the tenant was not suspended already; a second suspension keeps the first's reason.
726
+ CREATE OR REPLACE FUNCTION slotlock.suspend_tenant(requested_tenant_ref text, requested_reason text)
727
+ RETURNS boolean
728
+ LANGUAGE plpgsql
729
+ VOLATILE
730
+ SET search_path = pg_catalog, slotlock, pg_temp
731
+ AS $slotlock_suspend_tenant$
732
+ BEGIN
733
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
734
+ -- The tenant's key lock too: a key or secret being issued commits first, and one issued after
735
+ -- this commits sees the suspension and is refused.
736
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
737
+ INSERT INTO slotlock.tenant_suspensions (tenant_ref, reason)
738
+ VALUES (requested_tenant_ref, requested_reason)
739
+ ON CONFLICT (tenant_ref) DO NOTHING;
740
+ RETURN FOUND;
741
+ END
742
+ $slotlock_suspend_tenant$;
743
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_OPERATOR_FUNCTIONS[0]} FROM PUBLIC;
744
+
745
+ -- True when the tenant was suspended.
746
+ CREATE OR REPLACE FUNCTION slotlock.unsuspend_tenant(requested_tenant_ref text)
747
+ RETURNS boolean
748
+ LANGUAGE plpgsql
749
+ VOLATILE
750
+ SET search_path = pg_catalog, slotlock, pg_temp
751
+ AS $slotlock_unsuspend_tenant$
752
+ BEGIN
753
+ DELETE FROM slotlock.tenant_suspensions WHERE tenant_ref = requested_tenant_ref;
754
+ RETURN FOUND;
755
+ END
756
+ $slotlock_unsuspend_tenant$;
757
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_OPERATOR_FUNCTIONS[1]} FROM PUBLIC;
758
+
759
+ -- Tenant erasure: every row the tenant owns, one row of the result per table with how many were
760
+ -- deleted, in an order no foreign key refuses. Holds carry no tenant: those on the tenant's resources
761
+ -- are its. API key digests are retired, as erase_api_keys retires them, so no erased key works again.
762
+ -- A personal (github:<id>) tenant's dashboard form records carry a digest of the person, the
763
+ -- dashboard's (dashboard.ts ownerDigest). Running it again deletes nothing. Forced row-level security
764
+ -- binds the function's owner too, so it names the tenant in every setting a policy reads, and puts
765
+ -- the caller's values back after.
766
+ CREATE OR REPLACE FUNCTION slotlock.erase_tenant(requested_tenant_ref text)
767
+ RETURNS TABLE (erased_table text, erased_rows bigint)
768
+ LANGUAGE plpgsql
769
+ VOLATILE
770
+ SECURITY DEFINER
771
+ SET search_path = pg_catalog, slotlock, pg_temp
772
+ AS $slotlock_erase_tenant$
773
+ DECLARE
774
+ setting text;
775
+ previous jsonb := '{}'::jsonb;
776
+ BEGIN
777
+ IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
778
+ RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
779
+ USING ERRCODE = 'invalid_parameter_value';
780
+ END IF;
781
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
782
+ -- A key being issued commits first, so its row is deleted below with the rest.
783
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
784
+ FOR setting IN
785
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
786
+ LOOP
787
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
788
+ PERFORM set_config(setting, requested_tenant_ref, true);
789
+ END LOOP;
790
+
791
+ erased_table := 'calendar_event_attendees';
792
+ DELETE FROM slotlock.calendar_event_attendees WHERE tenant_ref = requested_tenant_ref;
793
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
794
+ RETURN NEXT;
795
+ erased_table := 'calendar_event_reminders';
796
+ DELETE FROM slotlock.calendar_event_reminders WHERE tenant_ref = requested_tenant_ref;
797
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
798
+ RETURN NEXT;
799
+ erased_table := 'calendar_event_exceptions';
800
+ DELETE FROM slotlock.calendar_event_exceptions WHERE tenant_ref = requested_tenant_ref;
801
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
802
+ RETURN NEXT;
803
+ erased_table := 'calendar_event_occurrences';
804
+ DELETE FROM slotlock.calendar_event_occurrences WHERE tenant_ref = requested_tenant_ref;
805
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
806
+ RETURN NEXT;
807
+ erased_table := 'reservations';
808
+ DELETE FROM slotlock.reservations
809
+ WHERE tenant_ref = requested_tenant_ref
810
+ OR resource_id IN (SELECT owned.id FROM slotlock.resources owned
811
+ WHERE owned.tenant_ref = requested_tenant_ref);
812
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
813
+ RETURN NEXT;
814
+ erased_table := 'calendar_events';
815
+ DELETE FROM slotlock.calendar_events WHERE tenant_ref = requested_tenant_ref;
816
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
817
+ RETURN NEXT;
818
+ erased_table := 'calendar_event_tombstones';
819
+ DELETE FROM slotlock.calendar_event_tombstones WHERE tenant_ref = requested_tenant_ref;
820
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
821
+ RETURN NEXT;
822
+ erased_table := 'calendar_event_commands';
823
+ DELETE FROM slotlock.calendar_event_commands WHERE tenant_ref = requested_tenant_ref;
824
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
825
+ RETURN NEXT;
826
+ erased_table := 'reservation_tombstones';
827
+ DELETE FROM slotlock.reservation_tombstones WHERE tenant_ref = requested_tenant_ref;
828
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
829
+ RETURN NEXT;
830
+ erased_table := 'reservation_conflict_archive';
831
+ DELETE FROM slotlock.reservation_conflict_archive WHERE tenant_ref = requested_tenant_ref;
832
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
833
+ RETURN NEXT;
834
+ erased_table := 'calendar_coverage';
835
+ DELETE FROM slotlock.calendar_coverage WHERE tenant_ref = requested_tenant_ref;
836
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
837
+ RETURN NEXT;
838
+ erased_table := 'hold_commands';
839
+ DELETE FROM slotlock.hold_commands WHERE tenant_ref = requested_tenant_ref;
840
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
841
+ RETURN NEXT;
842
+ erased_table := 'resources';
843
+ DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
844
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
845
+ RETURN NEXT;
846
+ erased_table := 'api_keys';
847
+ WITH erased AS (
848
+ DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
849
+ ), retired AS (
850
+ INSERT INTO slotlock.api_key_retired_digests (secret_hash)
851
+ SELECT erased.secret_hash FROM erased
852
+ ON CONFLICT DO NOTHING
853
+ )
854
+ SELECT count(*) INTO erased_rows FROM erased;
855
+ RETURN NEXT;
856
+ erased_table := 'dashboard_tokens';
857
+ erased_rows := 0;
858
+ IF requested_tenant_ref LIKE 'github:%' THEN
859
+ DELETE FROM slotlock.dashboard_tokens
860
+ WHERE kind = 'form'
861
+ AND owner_hash = sha256(convert_to(
862
+ 'slotlock-dashboard-owner.' || substr(requested_tenant_ref, 8), 'UTF8'));
863
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
864
+ END IF;
865
+ RETURN NEXT;
866
+
867
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
868
+ PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
869
+ END LOOP;
870
+ END
871
+ $slotlock_erase_tenant$;
872
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
873
+ `;
874
+ // A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
875
+ // here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
876
+ // DDL's transaction every table it altered stays under ACCESS EXCLUSIVE until commit, so the scan
877
+ // would hold up every serving query. On its own, each VALIDATE takes SHARE UPDATE EXCLUSIVE (and
878
+ // ROW SHARE on a foreign key's referenced table), which no read or row write conflicts with
879
+ // (PostgreSQL 17, ALTER TABLE and Explicit Locking). It sees every row whatever row-level security
880
+ // the table forces: the check scans the table itself, and referential checks bypass row security.
881
+ // One that old rows still break stays NOT VALID, with a notice, rather than stopping the migration:
882
+ // it binds every new row either way, and applySchema tries it again at each later migration. Once
883
+ // valid, a constraint is never checked again.
884
+ const DEPLOYMENT_VALIDATE_CONSTRAINTS = `DO $slotlock_validate_constraints$
885
+ DECLARE
886
+ unchecked record;
887
+ BEGIN
888
+ FOR unchecked IN
889
+ SELECT rel.relname::text AS table_name, con.conname::text AS constraint_name
890
+ FROM pg_catalog.pg_constraint con
891
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
892
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
893
+ WHERE ns.nspname = 'slotlock' AND NOT con.convalidated
894
+ ORDER BY rel.relname, con.conname
895
+ LOOP
896
+ BEGIN
897
+ EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I VALIDATE CONSTRAINT %I',
898
+ unchecked.table_name, unchecked.constraint_name);
899
+ EXCEPTION WHEN check_violation OR foreign_key_violation OR not_null_violation THEN
900
+ RAISE NOTICE 'Slotlock left %.% NOT VALID: existing rows break it',
901
+ unchecked.table_name, unchecked.constraint_name;
902
+ END;
903
+ END LOOP;
904
+ END
905
+ $slotlock_validate_constraints$;
906
+ `;
907
+ /** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
908
+ export const SLOTLOCK_VALIDATION_DDL = DEPLOYMENT_VALIDATE_CONSTRAINTS;
909
+ // The digests that let the next migration skip are recorded by applySchema once it has validated.
910
+ const DEPLOYMENT_RECORD_VERSION = `INSERT INTO slotlock.schema_version (singleton, version, applied_at)
911
+ VALUES (true, ${SLOTLOCK_SCHEMA_VERSION}, now())
912
+ ON CONFLICT (singleton) DO UPDATE
913
+ SET version = EXCLUDED.version, ddl_sha256 = NULL, catalog_sha256 = NULL,
914
+ applied_at = EXCLUDED.applied_at;
915
+ `;
573
916
  // btree_gist is created in slotlock when it is missing; one installed elsewhere stays where it is.
574
- export const SLOTLOCK_CORE_DDL = `
917
+ const CORE_DDL_BODY = `
575
918
  ${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}
919
+ ${SLOTLOCK_DEPLOYMENT_CHECKS}CREATE EXTENSION IF NOT EXISTS btree_gist SCHEMA slotlock;
920
+ ${DEPLOYMENT_LIFT_FORCED_ROW_SECURITY}${SLOTLOCK_SCHEMA_VERSION_DDL}
578
921
  -- Records the exact catalog representation installed for each owned RLS policy. Health checks
579
922
  -- compare live policy fingerprints with this contract so ALTER POLICY drift cannot hide behind a
580
923
  -- retained name/comment. This table contains configuration only, never tenant or calendar data.
@@ -643,6 +986,11 @@ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS tenant_ref text;
643
986
  -- Its CHECK holds SLOTLOCK_MAX_AVAILABILITY_RULES as it was when first added: changing the cap
644
987
  -- needs a constraint under a new name.
645
988
  ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS availability_rules jsonb;
989
+ -- Who sees a resource: 'shared' ones are listed to agents and booked through them, 'private' ones
990
+ -- (a tax-deadline calendar, say) only by the tenant's own code. Fixed when the resource is created.
991
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'shared';
992
+ -- When the tenant archived it: listed nowhere and booked no more, its history kept. NULL: in use.
993
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS archived_at timestamptz;
646
994
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
647
995
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
648
996
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
@@ -689,6 +1037,20 @@ CREATE TABLE IF NOT EXISTS slotlock.reservation_tombstones (
689
1037
  ),
690
1038
  CONSTRAINT slotlock_reservation_tombstones_revision_valid CHECK (revision > 0)
691
1039
  );
1040
+ -- When the reference was retired, so maintenance can prune it past the retention window. Rows from
1041
+ -- before 0.3 read '-infinity', earlier than every time, so none counts as a recent cancellation
1042
+ -- against the tenant's limits; new rows take now(). An ADD COLUMN default is evaluated once and
1043
+ -- kept for the rows already there, with no table rewrite, and SET DEFAULT changes only later
1044
+ -- inserts (PostgreSQL 16, ALTER TABLE notes; Date/Time Types, special values).
1045
+ ALTER TABLE slotlock.reservation_tombstones
1046
+ ADD COLUMN IF NOT EXISTS cancelled_at timestamptz NOT NULL DEFAULT '-infinity';
1047
+ ALTER TABLE slotlock.reservation_tombstones ALTER COLUMN cancelled_at SET DEFAULT now();
1048
+ -- True when the cancel retired a reference no booking held (its create may still be on the way).
1049
+ -- Only those are pruned past the retention window. A cancelled booking's tombstone is kept until
1050
+ -- the tenant is erased, so a delayed or replayed create never books it again; rows from before 0.3
1051
+ -- are kept too.
1052
+ ALTER TABLE slotlock.reservation_tombstones
1053
+ ADD COLUMN IF NOT EXISTS stray boolean NOT NULL DEFAULT false;
692
1054
  -- Resource identity: one resource per tenant/external_ref namespace (plus one isolated NULL-tenant
693
1055
  -- legacy namespace). Dedupe first (repoint reservations to the keeper, drop the rest) so the
694
1056
  -- unique indexes also bind on DBs poisoned by the pre-index
@@ -850,6 +1212,14 @@ BEGIN
850
1212
  ELSE false END
851
1213
  );
852
1214
  END IF;
1215
+ IF NOT EXISTS (
1216
+ SELECT 1 FROM pg_constraint
1217
+ WHERE conname = 'slotlock_resources_visibility_valid'
1218
+ AND conrelid = 'slotlock.resources'::regclass
1219
+ ) THEN
1220
+ ALTER TABLE slotlock.resources
1221
+ ADD CONSTRAINT slotlock_resources_visibility_valid CHECK (visibility IN ('shared', 'private'));
1222
+ END IF;
853
1223
  IF NOT EXISTS (
854
1224
  SELECT 1 FROM pg_constraint
855
1225
  WHERE conname = 'slotlock_reservations_revision_valid'
@@ -1028,12 +1398,13 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_events (
1028
1398
  AND octet_length(external_ref) BETWEEN 1 AND 500
1029
1399
  ),
1030
1400
  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 (
1401
+ -- A recurring series expands inside a window of at most 369 days (a 367-day search from any
1402
+ -- moment of the day, plus the day before it). A one-off event is its own window, so a lease or
1403
+ -- long rental may span up to 3,660 days (the store's hard ceiling).
1404
+ CONSTRAINT slotlock_calendar_events_materialization_window_limit CHECK (
1034
1405
  materialized_ends_at > materialized_starts_at
1035
1406
  AND (
1036
- materialized_ends_at - materialized_starts_at <= interval '367 days'
1407
+ materialized_ends_at - materialized_starts_at <= interval '369 days'
1037
1408
  OR (
1038
1409
  recurrence_rule IS NULL
1039
1410
  AND materialized_starts_at = starts_at
@@ -1112,7 +1483,8 @@ DO $slotlock_calendar_event_window_bounds$
1112
1483
  BEGIN
1113
1484
  IF NOT EXISTS (
1114
1485
  SELECT 1 FROM pg_constraint
1115
- WHERE conname = 'slotlock_calendar_events_materialization_window_bounded'
1486
+ WHERE conname IN ('slotlock_calendar_events_materialization_window_bounded',
1487
+ 'slotlock_calendar_events_materialization_window_limit')
1116
1488
  AND conrelid = 'slotlock.calendar_events'::regclass
1117
1489
  ) THEN
1118
1490
  ALTER TABLE slotlock.calendar_events
@@ -1134,6 +1506,34 @@ $slotlock_calendar_event_window_bounds$;
1134
1506
  ALTER TABLE slotlock.calendar_events
1135
1507
  DROP CONSTRAINT IF EXISTS slotlock_calendar_events_materialization_window_valid;
1136
1508
 
1509
+ -- Up to 0.2 a series' window was at most 367 days. The 369-day CHECK admits every row the 367-day
1510
+ -- one did: add it NOT VALID and drop the old one in the same transaction.
1511
+ DO $slotlock_calendar_event_window_limit$
1512
+ BEGIN
1513
+ IF NOT EXISTS (
1514
+ SELECT 1 FROM pg_constraint
1515
+ WHERE conname = 'slotlock_calendar_events_materialization_window_limit'
1516
+ AND conrelid = 'slotlock.calendar_events'::regclass
1517
+ ) THEN
1518
+ ALTER TABLE slotlock.calendar_events
1519
+ ADD CONSTRAINT slotlock_calendar_events_materialization_window_limit CHECK (
1520
+ materialized_ends_at > materialized_starts_at
1521
+ AND (
1522
+ materialized_ends_at - materialized_starts_at <= interval '369 days'
1523
+ OR (
1524
+ recurrence_rule IS NULL
1525
+ AND materialized_starts_at = starts_at
1526
+ AND materialized_ends_at = ends_at
1527
+ AND ends_at - starts_at <= interval '3660 days'
1528
+ )
1529
+ )
1530
+ ) NOT VALID;
1531
+ END IF;
1532
+ END
1533
+ $slotlock_calendar_event_window_limit$;
1534
+ ALTER TABLE slotlock.calendar_events
1535
+ DROP CONSTRAINT IF EXISTS slotlock_calendar_events_materialization_window_bounded;
1536
+
1137
1537
  -- Where a recurring series' last expansion stopped (ical.js's iterator state as JSON), so the next
1138
1538
  -- one resumes there instead of searching again from the series' start. Only Slotlock writes it. A
1139
1539
  -- cursor that does not fit the row's rule and start is ignored, so NULL, or a cursor left by an
@@ -1142,6 +1542,14 @@ ALTER TABLE slotlock.calendar_events
1142
1542
  -- not tie the cursor to a rule: a release from before cursors may turn a series into a one-off and
1143
1543
  -- leave its cursor, which is then never read.
1144
1544
  ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_cursor jsonb;
1545
+ -- When the horizon roll may next examine a series it could not extend (an occurrence collided, or
1546
+ -- the rule is over the expansion budget): until then it waits behind the series that can roll, so
1547
+ -- a pile of blocked series never starves a healthy one. NULL: due as usual.
1548
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS roll_retry_at timestamptz;
1549
+ -- For a recurring series, a time by which every occurrence ends, moved ones included; 'infinity'
1550
+ -- when its rule has no end, or none Slotlock could follow it to. NULL: not worked out yet (a series
1551
+ -- from before 0.3, until its next roll), and so treated as going on. Unused for a one-off event.
1552
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by timestamptz;
1145
1553
  ALTER TABLE slotlock.calendar_events
1146
1554
  DROP CONSTRAINT IF EXISTS slotlock_calendar_events_recurrence_cursor_bounded;
1147
1555
  DO $slotlock_calendar_event_recurrence_cursor$
@@ -1406,6 +1814,38 @@ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_reservations_booking_external_ref_key
1406
1814
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL AND calendar_event_id IS NULL;
1407
1815
  DROP INDEX IF EXISTS slotlock.slotlock_reservations_tenant_external_ref_key;
1408
1816
 
1817
+ -- Up to 0.2 a confirmed hold, and a createReservation booking, on a tenant's resource kept no tenant
1818
+ -- or reference, so no cancel or move could ever reach it. Each takes its resource's tenant and its
1819
+ -- own id as reference, as both now do when written. A tombstone already holding that reference was
1820
+ -- written by cancelling it as a reference no booking held (the only way to try): it retired nothing
1821
+ -- and is dropped, so the booking can be cancelled. A booking another booking's reference already
1822
+ -- uses (never written by Slotlock) is left as it was.
1823
+ DELETE FROM slotlock.reservation_tombstones tombstone
1824
+ USING slotlock.reservations booking
1825
+ JOIN slotlock.resources resource ON resource.id = booking.resource_id
1826
+ WHERE booking.tenant_ref IS NULL
1827
+ AND booking.status = 'confirmed'
1828
+ AND booking.calendar_event_id IS NULL
1829
+ AND resource.tenant_ref IS NOT NULL
1830
+ AND tombstone.tenant_ref = resource.tenant_ref
1831
+ AND tombstone.external_ref = booking.id::text;
1832
+ UPDATE slotlock.reservations booking
1833
+ SET tenant_ref = resource.tenant_ref,
1834
+ external_ref = booking.id::text,
1835
+ occupied_ends_at = booking.ends_at + booking.buffer_after_ms * interval '1 millisecond'
1836
+ FROM slotlock.resources resource
1837
+ WHERE resource.id = booking.resource_id
1838
+ AND booking.tenant_ref IS NULL
1839
+ AND booking.status = 'confirmed'
1840
+ AND booking.calendar_event_id IS NULL
1841
+ AND resource.tenant_ref IS NOT NULL
1842
+ AND NOT EXISTS (
1843
+ SELECT 1 FROM slotlock.reservations taken
1844
+ WHERE taken.tenant_ref = resource.tenant_ref
1845
+ AND taken.external_ref = booking.id::text
1846
+ AND taken.calendar_event_id IS NULL
1847
+ );
1848
+
1409
1849
  -- Materialized occurrences point at reservations created under the SAME exclusion arbiter as
1410
1850
  -- bookings and holds. Transparent events retain occurrence visibility with a NULL reservation.
1411
1851
  CREATE TABLE IF NOT EXISTS slotlock.calendar_event_occurrences (
@@ -1566,6 +2006,68 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_coverage (
1566
2006
  CONSTRAINT slotlock_calendar_coverage_revision_valid CHECK (revision > 0)
1567
2007
  );
1568
2008
 
2009
+ -- A keyed hold's first answer, by the key its caller gave inside its tenant and principal, so a
2010
+ -- retried request answers the same hold instead of overlapping it. The answer stays as given until
2011
+ -- the hold would have expired, even once it is confirmed, released or reaped, and counts against
2012
+ -- the tenant's hold limit while its hold is not live; then maintenance, or a hold at that limit,
2013
+ -- deletes it (store.ts forgetExpiredHoldAnswers).
2014
+ CREATE TABLE IF NOT EXISTS slotlock.hold_commands (
2015
+ tenant_ref text NOT NULL,
2016
+ principal_ref text NOT NULL,
2017
+ idempotency_key text NOT NULL,
2018
+ payload_hash text NOT NULL,
2019
+ hold_id uuid NOT NULL,
2020
+ resource_id uuid NOT NULL,
2021
+ starts_at timestamptz NOT NULL,
2022
+ ends_at timestamptz NOT NULL,
2023
+ buffer_after_ms bigint NOT NULL,
2024
+ expires_at timestamptz NOT NULL,
2025
+ source text,
2026
+ created_at timestamptz NOT NULL DEFAULT now(),
2027
+ CONSTRAINT slotlock_hold_commands_key PRIMARY KEY (tenant_ref, principal_ref, idempotency_key),
2028
+ CONSTRAINT slotlock_hold_commands_resource_tenant_fkey
2029
+ FOREIGN KEY (resource_id, tenant_ref)
2030
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
2031
+ CONSTRAINT slotlock_hold_commands_identity_valid CHECK (
2032
+ octet_length(tenant_ref) BETWEEN 1 AND 500
2033
+ AND octet_length(principal_ref) BETWEEN 1 AND 500
2034
+ AND octet_length(idempotency_key) BETWEEN 1 AND 500
2035
+ AND octet_length(payload_hash) = 64
2036
+ )
2037
+ );
2038
+ CREATE INDEX IF NOT EXISTS slotlock_hold_commands_expiry_idx
2039
+ ON slotlock.hold_commands (tenant_ref, expires_at);
2040
+ -- References retired with no booking: a tenant's count of them, and maintenance's prune.
2041
+ CREATE INDEX IF NOT EXISTS slotlock_reservation_tombstones_stray_idx
2042
+ ON slotlock.reservation_tombstones (tenant_ref, cancelled_at) WHERE stray;
2043
+ -- Holds by resource and expiry: a tenant's live-hold count, and maintenance's purge of expired ones.
2044
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_held_expiry_idx
2045
+ ON slotlock.reservations (resource_id, expires_at) WHERE status = 'held';
2046
+ -- Confirmed bookings with no tenant, by resource: maintenance finds those a 0.2 server wrote on a
2047
+ -- tenant's resource after the migration (store.ts pruneBookingRetention).
2048
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_untenanted_booking_idx
2049
+ ON slotlock.reservations (resource_id)
2050
+ WHERE tenant_ref IS NULL AND status = 'confirmed' AND calendar_event_id IS NULL;
2051
+ -- What the tenant limits count (store.ts tenantLimitCount), each read as a range so a count stops
2052
+ -- at the limit: bookings and events by when they end (one-offs and series apart) and by when they
2053
+ -- were made, and cancellations by when they were made.
2054
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_booking_end_idx
2055
+ ON slotlock.reservations (tenant_ref, occupied_ends_at)
2056
+ WHERE external_ref IS NOT NULL AND calendar_event_id IS NULL;
2057
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_booking_created_idx
2058
+ ON slotlock.reservations (tenant_ref, created_at)
2059
+ WHERE external_ref IS NOT NULL AND calendar_event_id IS NULL;
2060
+ CREATE INDEX IF NOT EXISTS slotlock_reservation_tombstones_cancelled_idx
2061
+ ON slotlock.reservation_tombstones (tenant_ref, cancelled_at);
2062
+ CREATE INDEX IF NOT EXISTS slotlock_calendar_events_one_off_end_idx
2063
+ ON slotlock.calendar_events (tenant_ref, ends_at) WHERE recurrence_rule IS NULL;
2064
+ CREATE INDEX IF NOT EXISTS slotlock_calendar_events_series_end_idx
2065
+ ON slotlock.calendar_events (tenant_ref, recurrence_ends_by) WHERE recurrence_rule IS NOT NULL;
2066
+ CREATE INDEX IF NOT EXISTS slotlock_calendar_events_created_idx
2067
+ ON slotlock.calendar_events (tenant_ref, created_at);
2068
+ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_tombstones_cancelled_idx
2069
+ ON slotlock.calendar_event_tombstones (tenant_ref, cancelled_at);
2070
+
1569
2071
  CREATE INDEX IF NOT EXISTS slotlock_calendar_events_resource_time_idx
1570
2072
  ON slotlock.calendar_events (tenant_ref, resource_id, starts_at, ends_at);
1571
2073
  CREATE INDEX IF NOT EXISTS slotlock_calendar_events_owner_resource_time_idx
@@ -1581,7 +2083,14 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
1581
2083
  ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
1582
2084
  CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
1583
2085
  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}`;
2086
+ ${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
2087
+ /** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
2088
+ export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
2089
+ /**
2090
+ * SLOTLOCK_CORE_DDL without the validation, which applySchema runs as a step of its own after this
2091
+ * commits (SLOTLOCK_VALIDATION_DDL).
2092
+ */
2093
+ export const SLOTLOCK_STRUCTURE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
1585
2094
  export const SLOTLOCK_TENANT_CONTEXT_SETTING = 'slotlock.tenant_ref';
1586
2095
  /**
1587
2096
  * Every table the store reads or writes at runtime, each under forced tenant RLS.
@@ -1601,6 +2110,7 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
1601
2110
  'calendar_event_tombstones',
1602
2111
  'calendar_event_commands',
1603
2112
  'calendar_coverage',
2113
+ 'hold_commands',
1604
2114
  ]);
1605
2115
  const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
1606
2116
  const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -1636,7 +2146,7 @@ export function createSlotlockTenantRlsDdl(tenantContextSetting = SLOTLOCK_TENAN
1636
2146
  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
2147
  }
1638
2148
  return `
1639
- ${DEPLOYMENT_SEARCH_PATH_PIN}${SLOTLOCK_SCHEMA_CONTROL_CHECK}ALTER TABLE slotlock.resources ENABLE ROW LEVEL SECURITY;
2149
+ ${DEPLOYMENT_SEARCH_PATH_PIN}${SLOTLOCK_DEPLOYMENT_CHECKS}ALTER TABLE slotlock.resources ENABLE ROW LEVEL SECURITY;
1640
2150
  ALTER TABLE slotlock.resources FORCE ROW LEVEL SECURITY;
1641
2151
  ALTER TABLE slotlock.reservations ENABLE ROW LEVEL SECURITY;
1642
2152
  ALTER TABLE slotlock.reservations FORCE ROW LEVEL SECURITY;
@@ -1660,6 +2170,8 @@ ALTER TABLE slotlock.calendar_event_commands ENABLE ROW LEVEL SECURITY;
1660
2170
  ALTER TABLE slotlock.calendar_event_commands FORCE ROW LEVEL SECURITY;
1661
2171
  ALTER TABLE slotlock.calendar_coverage ENABLE ROW LEVEL SECURITY;
1662
2172
  ALTER TABLE slotlock.calendar_coverage FORCE ROW LEVEL SECURITY;
2173
+ ALTER TABLE slotlock.hold_commands ENABLE ROW LEVEL SECURITY;
2174
+ ALTER TABLE slotlock.hold_commands FORCE ROW LEVEL SECURITY;
1663
2175
 
1664
2176
  -- One database must have one tenant-context contract. Re-applying with an adapter-specific
1665
2177
  -- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
@@ -1683,7 +2195,8 @@ BEGIN
1683
2195
  'calendar_event_occurrences',
1684
2196
  'calendar_event_tombstones',
1685
2197
  'calendar_event_commands',
1686
- 'calendar_coverage'
2198
+ 'calendar_coverage',
2199
+ 'hold_commands'
1687
2200
  )
1688
2201
  LOOP
1689
2202
  EXECUTE format(
@@ -1844,6 +2357,16 @@ BEGIN
1844
2357
  USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
1845
2358
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
1846
2359
  END IF;
2360
+
2361
+ IF NOT EXISTS (
2362
+ SELECT 1 FROM pg_policies
2363
+ WHERE schemaname = 'slotlock' AND tablename = 'hold_commands'
2364
+ AND policyname = 'tenant_isolation_v1'
2365
+ ) THEN
2366
+ CREATE POLICY tenant_isolation_v1 ON slotlock.hold_commands
2367
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2368
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2369
+ END IF;
1847
2370
  END
1848
2371
  $slotlock_rls$;
1849
2372
 
@@ -1871,6 +2394,8 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.calendar_event_commands
1871
2394
  IS 'slotlock:tenant-context:${tenantContextSetting}';
1872
2395
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.calendar_coverage
1873
2396
  IS 'slotlock:tenant-context:${tenantContextSetting}';
2397
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.hold_commands
2398
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
1874
2399
 
1875
2400
  INSERT INTO slotlock.rls_policy_contracts (
1876
2401
  table_name, tenant_context_setting, policy_fingerprint, installed_at
@@ -1905,7 +2430,8 @@ WHERE schemaname = 'slotlock'
1905
2430
  'calendar_event_occurrences',
1906
2431
  'calendar_event_tombstones',
1907
2432
  'calendar_event_commands',
1908
- 'calendar_coverage'
2433
+ 'calendar_coverage',
2434
+ 'hold_commands'
1909
2435
  )
1910
2436
  AND policyname = 'tenant_isolation_v1'
1911
2437
  ON CONFLICT (table_name) DO UPDATE