slotlock 0.3.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.
- package/CHANGELOG.md +252 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +590 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/ddl.js
CHANGED
|
@@ -235,7 +235,11 @@ export const SLOTLOCK_SCHEMA_FUNCTIONS = Object.freeze([
|
|
|
235
235
|
'slotlock.installed_schema_version()',
|
|
236
236
|
]);
|
|
237
237
|
/** The table of suspended tenants: they may read, and write nothing (store.ts withTenant). */
|
|
238
|
-
export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
|
|
238
|
+
export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
|
|
239
|
+
'tenant_suspensions',
|
|
240
|
+
'webhook_tenants',
|
|
241
|
+
'rate_buckets',
|
|
242
|
+
]);
|
|
239
243
|
/**
|
|
240
244
|
* The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
|
|
241
245
|
* tenant, by signature.
|
|
@@ -243,6 +247,10 @@ export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze(['tenant_suspensions']
|
|
|
243
247
|
export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
|
|
244
248
|
'slotlock.tenant_suspended(text)',
|
|
245
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)',
|
|
246
254
|
]);
|
|
247
255
|
/**
|
|
248
256
|
* Functions only the deployment role runs: suspending and lifting a suspension. No other role is
|
|
@@ -560,12 +568,30 @@ CREATE TABLE IF NOT EXISTS slotlock.dashboard_tokens (
|
|
|
560
568
|
owner_hash bytea,
|
|
561
569
|
expires_at timestamptz NOT NULL,
|
|
562
570
|
CONSTRAINT slotlock_dashboard_tokens_pkey PRIMARY KEY (kind, token_hash),
|
|
563
|
-
CONSTRAINT slotlock_dashboard_tokens_kind_valid
|
|
571
|
+
CONSTRAINT slotlock_dashboard_tokens_kind_valid
|
|
572
|
+
CHECK (kind IN ('sign_in', 'form', 'ended_session', 'confirmation')),
|
|
564
573
|
CONSTRAINT slotlock_dashboard_tokens_hash_valid CHECK (octet_length(token_hash) = 32),
|
|
565
574
|
CONSTRAINT slotlock_dashboard_tokens_owner_valid
|
|
566
575
|
CHECK (owner_hash IS NULL OR octet_length(owner_hash) = 32),
|
|
567
576
|
CONSTRAINT slotlock_dashboard_tokens_form_owner CHECK (kind <> 'form' OR owner_hash IS NOT NULL)
|
|
568
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$;
|
|
569
595
|
CREATE INDEX IF NOT EXISTS slotlock_dashboard_tokens_expiry_idx
|
|
570
596
|
ON slotlock.dashboard_tokens (expires_at);
|
|
571
597
|
CREATE INDEX IF NOT EXISTS slotlock_dashboard_tokens_owner_idx
|
|
@@ -787,6 +813,9 @@ BEGIN
|
|
|
787
813
|
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
788
814
|
PERFORM set_config(setting, requested_tenant_ref, true);
|
|
789
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);
|
|
790
819
|
|
|
791
820
|
erased_table := 'calendar_event_attendees';
|
|
792
821
|
DELETE FROM slotlock.calendar_event_attendees WHERE tenant_ref = requested_tenant_ref;
|
|
@@ -839,10 +868,31 @@ BEGIN
|
|
|
839
868
|
DELETE FROM slotlock.hold_commands WHERE tenant_ref = requested_tenant_ref;
|
|
840
869
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
841
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;
|
|
842
875
|
erased_table := 'resources';
|
|
843
876
|
DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
|
|
844
877
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
845
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;
|
|
846
896
|
erased_table := 'api_keys';
|
|
847
897
|
WITH erased AS (
|
|
848
898
|
DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
|
|
@@ -870,6 +920,101 @@ BEGIN
|
|
|
870
920
|
END
|
|
871
921
|
$slotlock_erase_tenant$;
|
|
872
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;
|
|
873
1018
|
`;
|
|
874
1019
|
// A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
|
|
875
1020
|
// here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
|
|
@@ -913,6 +1058,336 @@ ON CONFLICT (singleton) DO UPDATE
|
|
|
913
1058
|
SET version = EXCLUDED.version, ddl_sha256 = NULL, catalog_sha256 = NULL,
|
|
914
1059
|
applied_at = EXCLUDED.applied_at;
|
|
915
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
|
+
`;
|
|
916
1391
|
// btree_gist is created in slotlock when it is missing; one installed elsewhere stays where it is.
|
|
917
1392
|
const CORE_DDL_BODY = `
|
|
918
1393
|
${DEPLOYMENT_SEARCH_PATH_PIN}CREATE SCHEMA IF NOT EXISTS slotlock;
|
|
@@ -986,6 +1461,16 @@ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
|
986
1461
|
-- Its CHECK holds SLOTLOCK_MAX_AVAILABILITY_RULES as it was when first added: changing the cap
|
|
987
1462
|
-- needs a constraint under a new name.
|
|
988
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;
|
|
989
1474
|
-- Who sees a resource: 'shared' ones are listed to agents and booked through them, 'private' ones
|
|
990
1475
|
-- (a tax-deadline calendar, say) only by the tenant's own code. Fixed when the resource is created.
|
|
991
1476
|
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'shared';
|
|
@@ -995,6 +1480,14 @@ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
|
995
1480
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
|
|
996
1481
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
|
|
997
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;
|
|
998
1491
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1;
|
|
999
1492
|
|
|
1000
1493
|
-- timestamptz + interval is STABLE (session timezone aware), so PostgreSQL correctly refuses it
|
|
@@ -1212,6 +1705,25 @@ BEGIN
|
|
|
1212
1705
|
ELSE false END
|
|
1213
1706
|
);
|
|
1214
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;
|
|
1215
1727
|
IF NOT EXISTS (
|
|
1216
1728
|
SELECT 1 FROM pg_constraint
|
|
1217
1729
|
WHERE conname = 'slotlock_resources_visibility_valid'
|
|
@@ -1550,6 +2062,11 @@ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS roll_retry_at time
|
|
|
1550
2062
|
-- when its rule has no end, or none Slotlock could follow it to. NULL: not worked out yet (a series
|
|
1551
2063
|
-- from before 0.3, until its next roll), and so treated as going on. Unused for a one-off event.
|
|
1552
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);
|
|
1553
2070
|
ALTER TABLE slotlock.calendar_events
|
|
1554
2071
|
DROP CONSTRAINT IF EXISTS slotlock_calendar_events_recurrence_cursor_bounded;
|
|
1555
2072
|
DO $slotlock_calendar_event_recurrence_cursor$
|
|
@@ -2083,7 +2600,7 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
2083
2600
|
ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
|
|
2084
2601
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
2085
2602
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
2086
|
-
${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
2603
|
+
${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
2087
2604
|
/** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
|
|
2088
2605
|
export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
2089
2606
|
/**
|
|
@@ -2111,6 +2628,10 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
2111
2628
|
'calendar_event_commands',
|
|
2112
2629
|
'calendar_coverage',
|
|
2113
2630
|
'hold_commands',
|
|
2631
|
+
'outbox',
|
|
2632
|
+
'outbox_pruned',
|
|
2633
|
+
'webhook_endpoints',
|
|
2634
|
+
'waitlist_entries',
|
|
2114
2635
|
]);
|
|
2115
2636
|
const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
2116
2637
|
const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
@@ -2172,6 +2693,14 @@ ALTER TABLE slotlock.calendar_coverage ENABLE ROW LEVEL SECURITY;
|
|
|
2172
2693
|
ALTER TABLE slotlock.calendar_coverage FORCE ROW LEVEL SECURITY;
|
|
2173
2694
|
ALTER TABLE slotlock.hold_commands ENABLE ROW LEVEL SECURITY;
|
|
2174
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;
|
|
2175
2704
|
|
|
2176
2705
|
-- One database must have one tenant-context contract. Re-applying with an adapter-specific
|
|
2177
2706
|
-- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
|
|
@@ -2196,7 +2725,11 @@ BEGIN
|
|
|
2196
2725
|
'calendar_event_tombstones',
|
|
2197
2726
|
'calendar_event_commands',
|
|
2198
2727
|
'calendar_coverage',
|
|
2199
|
-
'hold_commands'
|
|
2728
|
+
'hold_commands',
|
|
2729
|
+
'outbox',
|
|
2730
|
+
'outbox_pruned',
|
|
2731
|
+
'webhook_endpoints',
|
|
2732
|
+
'waitlist_entries'
|
|
2200
2733
|
)
|
|
2201
2734
|
LOOP
|
|
2202
2735
|
EXECUTE format(
|
|
@@ -2367,6 +2900,46 @@ BEGIN
|
|
|
2367
2900
|
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
2368
2901
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
2369
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;
|
|
2370
2943
|
END
|
|
2371
2944
|
$slotlock_rls$;
|
|
2372
2945
|
|
|
@@ -2396,6 +2969,14 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.calendar_coverage
|
|
|
2396
2969
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
2397
2970
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.hold_commands
|
|
2398
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}';
|
|
2399
2980
|
|
|
2400
2981
|
INSERT INTO slotlock.rls_policy_contracts (
|
|
2401
2982
|
table_name, tenant_context_setting, policy_fingerprint, installed_at
|
|
@@ -2431,7 +3012,11 @@ WHERE schemaname = 'slotlock'
|
|
|
2431
3012
|
'calendar_event_tombstones',
|
|
2432
3013
|
'calendar_event_commands',
|
|
2433
3014
|
'calendar_coverage',
|
|
2434
|
-
'hold_commands'
|
|
3015
|
+
'hold_commands',
|
|
3016
|
+
'outbox',
|
|
3017
|
+
'outbox_pruned',
|
|
3018
|
+
'webhook_endpoints',
|
|
3019
|
+
'waitlist_entries'
|
|
2435
3020
|
)
|
|
2436
3021
|
AND policyname = 'tenant_isolation_v1'
|
|
2437
3022
|
ON CONFLICT (table_name) DO UPDATE
|