slotlock 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/README.md +152 -19
- package/SPEC.md +10 -7
- package/agent-server.d.ts +52 -4
- package/agent-server.js +479 -145
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +67 -7
- package/assistant.d.ts +263 -0
- package/assistant.js +772 -0
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/config.d.ts +10 -0
- package/config.js +11 -0
- package/dashboard.d.ts +64 -1
- package/dashboard.js +687 -26
- package/ddl.d.ts +5 -5
- package/ddl.js +594 -6
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +11 -8
- package/index.js +7 -5
- package/maintenance.d.ts +2 -1
- package/maintenance.js +33 -9
- package/openapi.json +1284 -82
- package/package.json +1 -1
- package/rate-limit.d.ts +8 -0
- package/rate-limit.js +20 -0
- package/self-host.d.ts +2 -2
- package/self-host.js +42 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +132 -3
- package/store.js +482 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +33 -2
- package/webhooks.js +102 -20
package/ddl.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// the caller's search_path back at the end, so a caller that keeps going in the same transaction
|
|
12
12
|
// resolves its own names as before. Apply it in one transaction: under autocommit every statement
|
|
13
13
|
// is a transaction of its own, and the pin ends with the statement that sets it.
|
|
14
|
+
import { SLOTLOCK_CHANGE_ORIGINS, slotlockCoverageSourceIdSql } from './event-feed.js';
|
|
14
15
|
import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
|
|
15
16
|
const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
|
|
16
17
|
const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
|
|
@@ -239,6 +240,7 @@ export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
|
|
|
239
240
|
'tenant_suspensions',
|
|
240
241
|
'webhook_tenants',
|
|
241
242
|
'rate_buckets',
|
|
243
|
+
'assistant_tenants',
|
|
242
244
|
]);
|
|
243
245
|
/**
|
|
244
246
|
* The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
|
|
@@ -251,6 +253,8 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
|
|
|
251
253
|
'slotlock.webhook_tenants(text)',
|
|
252
254
|
'slotlock.unregister_webhook_tenant(text)',
|
|
253
255
|
'slotlock.take_rate_token(text, double precision, double precision)',
|
|
256
|
+
'slotlock.register_assistant_tenant(text, timestamptz)',
|
|
257
|
+
'slotlock.sweep_assistant_conversations(integer)',
|
|
254
258
|
]);
|
|
255
259
|
/**
|
|
256
260
|
* Functions only the deployment role runs: suspending and lifting a suspension. No other role is
|
|
@@ -547,6 +551,55 @@ AS $slotlock_erase_api_keys$
|
|
|
547
551
|
SELECT count(*) FROM erased;
|
|
548
552
|
$slotlock_erase_api_keys$;
|
|
549
553
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[5]} FROM PUBLIC;
|
|
554
|
+
|
|
555
|
+
-- A key created, given a new secret or revoked appends to its tenant's feed (key.created,
|
|
556
|
+
-- key.rotated, key.revoked): its id, scopes and expiry, never its digest, prefix or name. Only the
|
|
557
|
+
-- functions above write keys, as the schema's owner, which forced row-level security on the feed
|
|
558
|
+
-- binds too: the row is appended under the tenant's name in every setting a policy reads, and the
|
|
559
|
+
-- caller's values are put back after. Who did it is the caller's actor, else the key's creator.
|
|
560
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_api_key_change()
|
|
561
|
+
RETURNS trigger
|
|
562
|
+
LANGUAGE plpgsql
|
|
563
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
564
|
+
AS $slotlock_outbox_api_key$
|
|
565
|
+
DECLARE
|
|
566
|
+
kind text;
|
|
567
|
+
setting text;
|
|
568
|
+
previous jsonb := '{}'::jsonb;
|
|
569
|
+
BEGIN
|
|
570
|
+
IF TG_OP = 'INSERT' THEN
|
|
571
|
+
kind := 'key.created';
|
|
572
|
+
ELSIF OLD.revoked_at IS NULL AND NEW.revoked_at IS NOT NULL THEN
|
|
573
|
+
kind := 'key.revoked';
|
|
574
|
+
ELSIF OLD.secret_hash IS DISTINCT FROM NEW.secret_hash THEN
|
|
575
|
+
kind := 'key.rotated';
|
|
576
|
+
ELSE
|
|
577
|
+
RETURN NULL;
|
|
578
|
+
END IF;
|
|
579
|
+
FOR setting IN
|
|
580
|
+
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
581
|
+
LOOP
|
|
582
|
+
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
583
|
+
PERFORM set_config(setting, NEW.tenant_ref, true);
|
|
584
|
+
END LOOP;
|
|
585
|
+
INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
|
|
586
|
+
VALUES (
|
|
587
|
+
NEW.tenant_ref, kind, NEW.id::text,
|
|
588
|
+
jsonb_build_object(
|
|
589
|
+
'id', NEW.id, 'scopes', to_jsonb(NEW.scopes), 'expires_at', NEW.expires_at,
|
|
590
|
+
'revoked_at', NEW.revoked_at),
|
|
591
|
+
COALESCE(NULLIF(current_setting('slotlock.actor', true), ''),
|
|
592
|
+
CASE WHEN TG_OP = 'INSERT' THEN NEW.created_by END)
|
|
593
|
+
);
|
|
594
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
595
|
+
PERFORM set_config(setting, COALESCE(previous ->> setting, ''), true);
|
|
596
|
+
END LOOP;
|
|
597
|
+
RETURN NULL;
|
|
598
|
+
END
|
|
599
|
+
$slotlock_outbox_api_key$;
|
|
600
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_api_key_change
|
|
601
|
+
AFTER INSERT OR UPDATE OF revoked_at, secret_hash ON slotlock.api_keys
|
|
602
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_api_key_change();
|
|
550
603
|
`;
|
|
551
604
|
// The dashboard's records (dashboard.ts), shared by every server on this database: a finished sign-in
|
|
552
605
|
// (its callback works once), a sent form (it runs once) and a signed-out session (a copied cookie
|
|
@@ -872,6 +925,18 @@ BEGIN
|
|
|
872
925
|
DELETE FROM slotlock.waitlist_entries WHERE tenant_ref = requested_tenant_ref;
|
|
873
926
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
874
927
|
RETURN NEXT;
|
|
928
|
+
erased_table := 'booking_rule_defaults';
|
|
929
|
+
DELETE FROM slotlock.booking_rule_defaults WHERE tenant_ref = requested_tenant_ref;
|
|
930
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
931
|
+
RETURN NEXT;
|
|
932
|
+
erased_table := 'assistant_conversations';
|
|
933
|
+
DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
|
|
934
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
935
|
+
RETURN NEXT;
|
|
936
|
+
erased_table := 'assistant_settings';
|
|
937
|
+
DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
|
|
938
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
939
|
+
RETURN NEXT;
|
|
875
940
|
erased_table := 'resources';
|
|
876
941
|
DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
|
|
877
942
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
@@ -889,6 +954,10 @@ BEGIN
|
|
|
889
954
|
DELETE FROM slotlock.webhook_endpoints WHERE tenant_ref = requested_tenant_ref;
|
|
890
955
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
891
956
|
RETURN NEXT;
|
|
957
|
+
erased_table := 'assistant_tenants';
|
|
958
|
+
DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = requested_tenant_ref;
|
|
959
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
960
|
+
RETURN NEXT;
|
|
892
961
|
erased_table := 'webhook_tenants';
|
|
893
962
|
DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
|
|
894
963
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
@@ -1015,6 +1084,106 @@ BEGIN
|
|
|
1015
1084
|
END
|
|
1016
1085
|
$slotlock_take_rate_token$;
|
|
1017
1086
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[5]} FROM PUBLIC;
|
|
1087
|
+
|
|
1088
|
+
-- The tenants with a dashboard assistant conversation, and when the first of them expires. Forced
|
|
1089
|
+
-- row-level security hides every other tenant's conversations, from the owner too, so this list
|
|
1090
|
+
-- (names and a time only) is how maintenance finds the tenants whose conversations have expired,
|
|
1091
|
+
-- whichever server stored them: one only the dashboard (or its Worker) ever used included. No role
|
|
1092
|
+
-- but its owner reads it.
|
|
1093
|
+
CREATE TABLE IF NOT EXISTS slotlock.assistant_tenants (
|
|
1094
|
+
tenant_ref text PRIMARY KEY,
|
|
1095
|
+
next_expiry timestamptz NOT NULL,
|
|
1096
|
+
CONSTRAINT slotlock_assistant_tenants_tenant_ref_valid
|
|
1097
|
+
CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
|
|
1098
|
+
);
|
|
1099
|
+
CREATE INDEX IF NOT EXISTS slotlock_assistant_tenants_expiry_idx
|
|
1100
|
+
ON slotlock.assistant_tenants (next_expiry);
|
|
1101
|
+
ALTER TABLE slotlock.assistant_tenants ENABLE ROW LEVEL SECURITY;
|
|
1102
|
+
REVOKE ALL ON slotlock.assistant_tenants FROM PUBLIC;
|
|
1103
|
+
|
|
1104
|
+
-- Record that the tenant has a conversation expiring at expires (the earliest is kept).
|
|
1105
|
+
CREATE OR REPLACE FUNCTION slotlock.register_assistant_tenant(requested_tenant_ref text, expires timestamptz)
|
|
1106
|
+
RETURNS void
|
|
1107
|
+
LANGUAGE plpgsql
|
|
1108
|
+
VOLATILE
|
|
1109
|
+
SECURITY DEFINER
|
|
1110
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1111
|
+
AS $slotlock_register_assistant_tenant$
|
|
1112
|
+
BEGIN
|
|
1113
|
+
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
|
|
1114
|
+
OR expires IS NULL THEN
|
|
1115
|
+
RAISE EXCEPTION 'Slotlock assistant tenant arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
|
|
1116
|
+
END IF;
|
|
1117
|
+
INSERT INTO slotlock.assistant_tenants AS kept (tenant_ref, next_expiry)
|
|
1118
|
+
VALUES (requested_tenant_ref, expires)
|
|
1119
|
+
ON CONFLICT (tenant_ref) DO UPDATE SET next_expiry = LEAST(kept.next_expiry, EXCLUDED.next_expiry);
|
|
1120
|
+
END
|
|
1121
|
+
$slotlock_register_assistant_tenant$;
|
|
1122
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[6]} FROM PUBLIC;
|
|
1123
|
+
|
|
1124
|
+
-- Delete expired assistant conversations across every tenant: up to max_rows of them, from the
|
|
1125
|
+
-- tenants whose earliest expiry has passed, longest overdue first, each under its own tenant
|
|
1126
|
+
-- context (forced row-level security binds the owner too), putting the caller's settings back
|
|
1127
|
+
-- after. A tenant left with none leaves the list; one with more is listed at its next expiry.
|
|
1128
|
+
-- Only a count comes back.
|
|
1129
|
+
CREATE OR REPLACE FUNCTION slotlock.sweep_assistant_conversations(max_rows integer)
|
|
1130
|
+
RETURNS bigint
|
|
1131
|
+
LANGUAGE plpgsql
|
|
1132
|
+
VOLATILE
|
|
1133
|
+
SECURITY DEFINER
|
|
1134
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1135
|
+
AS $slotlock_sweep_assistant_conversations$
|
|
1136
|
+
DECLARE
|
|
1137
|
+
setting text;
|
|
1138
|
+
previous jsonb := '{}'::jsonb;
|
|
1139
|
+
due record;
|
|
1140
|
+
deleted bigint := 0;
|
|
1141
|
+
step bigint;
|
|
1142
|
+
upcoming timestamptz;
|
|
1143
|
+
BEGIN
|
|
1144
|
+
IF max_rows IS NULL OR max_rows NOT BETWEEN 1 AND 100000 THEN
|
|
1145
|
+
RAISE EXCEPTION 'Slotlock assistant sweep size must be 1-100,000' USING ERRCODE = 'invalid_parameter_value';
|
|
1146
|
+
END IF;
|
|
1147
|
+
FOR setting IN
|
|
1148
|
+
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
1149
|
+
LOOP
|
|
1150
|
+
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
1151
|
+
END LOOP;
|
|
1152
|
+
FOR due IN
|
|
1153
|
+
SELECT listed.tenant_ref FROM slotlock.assistant_tenants listed
|
|
1154
|
+
WHERE listed.next_expiry <= now()
|
|
1155
|
+
ORDER BY listed.next_expiry
|
|
1156
|
+
LIMIT 1000
|
|
1157
|
+
FOR UPDATE SKIP LOCKED
|
|
1158
|
+
LOOP
|
|
1159
|
+
EXIT WHEN deleted >= max_rows;
|
|
1160
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
1161
|
+
PERFORM set_config(setting, due.tenant_ref, true);
|
|
1162
|
+
END LOOP;
|
|
1163
|
+
DELETE FROM slotlock.assistant_conversations gone
|
|
1164
|
+
WHERE gone.tenant_ref = due.tenant_ref
|
|
1165
|
+
AND gone.member_ref IN (
|
|
1166
|
+
SELECT expired.member_ref FROM slotlock.assistant_conversations expired
|
|
1167
|
+
WHERE expired.tenant_ref = due.tenant_ref AND expired.expires_at <= now()
|
|
1168
|
+
LIMIT max_rows - deleted)
|
|
1169
|
+
AND gone.expires_at <= now();
|
|
1170
|
+
GET DIAGNOSTICS step = ROW_COUNT;
|
|
1171
|
+
deleted := deleted + step;
|
|
1172
|
+
SELECT min(live.expires_at) INTO upcoming FROM slotlock.assistant_conversations live
|
|
1173
|
+
WHERE live.tenant_ref = due.tenant_ref;
|
|
1174
|
+
IF upcoming IS NULL THEN
|
|
1175
|
+
DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = due.tenant_ref;
|
|
1176
|
+
ELSE
|
|
1177
|
+
UPDATE slotlock.assistant_tenants SET next_expiry = upcoming WHERE tenant_ref = due.tenant_ref;
|
|
1178
|
+
END IF;
|
|
1179
|
+
END LOOP;
|
|
1180
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
1181
|
+
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
1182
|
+
END LOOP;
|
|
1183
|
+
RETURN deleted;
|
|
1184
|
+
END
|
|
1185
|
+
$slotlock_sweep_assistant_conversations$;
|
|
1186
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[7]} FROM PUBLIC;
|
|
1018
1187
|
`;
|
|
1019
1188
|
// A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
|
|
1020
1189
|
// here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
|
|
@@ -1079,6 +1248,36 @@ CREATE TABLE IF NOT EXISTS slotlock.outbox (
|
|
|
1079
1248
|
);
|
|
1080
1249
|
-- Who made the change (withTenant's actor: an API key, an agent), for the history.
|
|
1081
1250
|
ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS actor text;
|
|
1251
|
+
-- Where it came from (withTenant's origin: mcp, a2a, rest, application, dashboard,
|
|
1252
|
+
-- dashboard-assistant, maintenance, library) and the API key that made it (withTenant's keyId).
|
|
1253
|
+
-- Added bare, so the rows already there keep null (an earlier release) and nothing is scanned under
|
|
1254
|
+
-- the lock; the defaults then bind only new rows. A write that names no origin (code calling the
|
|
1255
|
+
-- store outside withTenant, or SQL of its own) is \`library\`.
|
|
1256
|
+
ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS origin text;
|
|
1257
|
+
ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS key_id uuid;
|
|
1258
|
+
ALTER TABLE slotlock.outbox
|
|
1259
|
+
ALTER COLUMN origin SET DEFAULT COALESCE(NULLIF(current_setting('slotlock.origin', true), ''), 'library'),
|
|
1260
|
+
ALTER COLUMN key_id SET DEFAULT NULLIF(current_setting('slotlock.key_id', true), '')::uuid;
|
|
1261
|
+
-- The origins the API reads, and no other. NOT VALID, so adding it scans nothing under the lock;
|
|
1262
|
+
-- DEPLOYMENT_VALIDATE_CONSTRAINTS validates it after the migration commits. Its comment is the list
|
|
1263
|
+
-- it was built from: a release that changes the list replaces it, so an upgraded database never
|
|
1264
|
+
-- keeps refusing an origin the release writes.
|
|
1265
|
+
DO $slotlock_outbox_origin$
|
|
1266
|
+
BEGIN
|
|
1267
|
+
ALTER TABLE slotlock.outbox DROP CONSTRAINT IF EXISTS slotlock_outbox_origin_valid;
|
|
1268
|
+
IF (
|
|
1269
|
+
SELECT pg_catalog.obj_description(oid, 'pg_constraint') FROM pg_catalog.pg_constraint
|
|
1270
|
+
WHERE conrelid = 'slotlock.outbox'::regclass AND conname = 'slotlock_outbox_origin_known'
|
|
1271
|
+
) IS DISTINCT FROM '${SLOTLOCK_CHANGE_ORIGINS.join(' ')}' THEN
|
|
1272
|
+
ALTER TABLE slotlock.outbox DROP CONSTRAINT IF EXISTS slotlock_outbox_origin_known;
|
|
1273
|
+
ALTER TABLE slotlock.outbox
|
|
1274
|
+
ADD CONSTRAINT slotlock_outbox_origin_known
|
|
1275
|
+
CHECK (origin IN (${SLOTLOCK_CHANGE_ORIGINS.map((origin) => `'${origin}'`).join(', ')})) NOT VALID;
|
|
1276
|
+
COMMENT ON CONSTRAINT slotlock_outbox_origin_known ON slotlock.outbox
|
|
1277
|
+
IS '${SLOTLOCK_CHANGE_ORIGINS.join(' ')}';
|
|
1278
|
+
END IF;
|
|
1279
|
+
END
|
|
1280
|
+
$slotlock_outbox_origin$;
|
|
1082
1281
|
CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_resource_idx
|
|
1083
1282
|
ON slotlock.outbox (tenant_ref, resource_id, id) WHERE resource_id IS NOT NULL;
|
|
1084
1283
|
CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_subject_idx
|
|
@@ -1133,6 +1332,10 @@ $slotlock_outbox_mark_pruned$;
|
|
|
1133
1332
|
CREATE OR REPLACE TRIGGER slotlock_outbox_mark_pruned
|
|
1134
1333
|
AFTER DELETE ON slotlock.outbox REFERENCING OLD TABLE AS pruned
|
|
1135
1334
|
FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_mark_pruned();
|
|
1335
|
+
-- Live updates poll each tenant's feed (change-watch.ts). A pre-release build woke readers with a
|
|
1336
|
+
-- notification trigger here, which made every outbox commit take the cluster-wide notify lock: drop it.
|
|
1337
|
+
DROP TRIGGER IF EXISTS slotlock_outbox_notify ON slotlock.outbox;
|
|
1338
|
+
DROP FUNCTION IF EXISTS slotlock.outbox_notify();
|
|
1136
1339
|
|
|
1137
1340
|
CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
|
|
1138
1341
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -1160,14 +1363,31 @@ CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
|
|
|
1160
1363
|
AND octet_length(url) BETWEEN 1 AND 2048
|
|
1161
1364
|
AND secret ~ '^whsec_[A-Za-z0-9+/]{43}=$'
|
|
1162
1365
|
),
|
|
1163
|
-
CONSTRAINT
|
|
1164
|
-
event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND
|
|
1366
|
+
CONSTRAINT slotlock_webhook_endpoints_type_count_valid CHECK (
|
|
1367
|
+
event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 64
|
|
1165
1368
|
),
|
|
1166
1369
|
CONSTRAINT slotlock_webhook_endpoints_error_valid CHECK (
|
|
1167
1370
|
last_error IS NULL OR char_length(last_error) <= 500
|
|
1168
1371
|
)
|
|
1169
1372
|
);
|
|
1170
1373
|
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS changes_pruned boolean NOT NULL DEFAULT false;
|
|
1374
|
+
-- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
|
|
1375
|
+
-- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
|
|
1376
|
+
DO $slotlock_webhook_type_count$
|
|
1377
|
+
BEGIN
|
|
1378
|
+
IF NOT EXISTS (
|
|
1379
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
1380
|
+
WHERE conrelid = 'slotlock.webhook_endpoints'::regclass
|
|
1381
|
+
AND conname = 'slotlock_webhook_endpoints_type_count_valid'
|
|
1382
|
+
) THEN
|
|
1383
|
+
ALTER TABLE slotlock.webhook_endpoints
|
|
1384
|
+
ADD CONSTRAINT slotlock_webhook_endpoints_type_count_valid CHECK (
|
|
1385
|
+
event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 64
|
|
1386
|
+
) NOT VALID;
|
|
1387
|
+
END IF;
|
|
1388
|
+
ALTER TABLE slotlock.webhook_endpoints DROP CONSTRAINT IF EXISTS slotlock_webhook_endpoints_types_valid;
|
|
1389
|
+
END
|
|
1390
|
+
$slotlock_webhook_type_count$;
|
|
1171
1391
|
CREATE INDEX IF NOT EXISTS slotlock_webhook_endpoints_tenant_idx
|
|
1172
1392
|
ON slotlock.webhook_endpoints (tenant_ref, created_at);
|
|
1173
1393
|
|
|
@@ -1206,6 +1426,71 @@ CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
|
|
|
1206
1426
|
CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
|
|
1207
1427
|
ON slotlock.waitlist_entries (tenant_ref, starts_at);
|
|
1208
1428
|
|
|
1429
|
+
-- Default booking rules (booking-rules.ts), for the tenant (pool_ref '') or for one of its pools:
|
|
1430
|
+
-- a resource's rules are the tenant's, overlaid by its pool's, overlaid by its own.
|
|
1431
|
+
CREATE TABLE IF NOT EXISTS slotlock.booking_rule_defaults (
|
|
1432
|
+
tenant_ref text NOT NULL,
|
|
1433
|
+
pool_ref text NOT NULL DEFAULT '',
|
|
1434
|
+
rules jsonb NOT NULL,
|
|
1435
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1436
|
+
CONSTRAINT slotlock_booking_rule_defaults_pkey PRIMARY KEY (tenant_ref, pool_ref),
|
|
1437
|
+
CONSTRAINT slotlock_booking_rule_defaults_identity_valid CHECK (
|
|
1438
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
1439
|
+
AND (pool_ref = '' OR pool_ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$')
|
|
1440
|
+
),
|
|
1441
|
+
CONSTRAINT slotlock_booking_rule_defaults_rules_valid CHECK (
|
|
1442
|
+
jsonb_typeof(rules) = 'object' AND octet_length(rules::text) <= 4096
|
|
1443
|
+
)
|
|
1444
|
+
);
|
|
1445
|
+
|
|
1446
|
+
-- The model a tenant's dashboard assistant uses (dashboard.ts, assistant.ts), one row a tenant.
|
|
1447
|
+
-- The API key is stored only sealed (AES-256-GCM under the deployment's SLOTLOCK_ASSISTANT_SECRET,
|
|
1448
|
+
-- bound to the tenant), with its last four characters to show.
|
|
1449
|
+
CREATE TABLE IF NOT EXISTS slotlock.assistant_settings (
|
|
1450
|
+
tenant_ref text PRIMARY KEY,
|
|
1451
|
+
provider text NOT NULL,
|
|
1452
|
+
base_url text NOT NULL,
|
|
1453
|
+
model text NOT NULL,
|
|
1454
|
+
sealed_key text,
|
|
1455
|
+
key_hint text,
|
|
1456
|
+
updated_by text,
|
|
1457
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1458
|
+
CONSTRAINT slotlock_assistant_settings_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
|
|
1459
|
+
CONSTRAINT slotlock_assistant_settings_provider_valid CHECK (provider IN ('anthropic', 'openai')),
|
|
1460
|
+
CONSTRAINT slotlock_assistant_settings_base_url_valid CHECK (octet_length(base_url) BETWEEN 1 AND 2048),
|
|
1461
|
+
CONSTRAINT slotlock_assistant_settings_model_valid CHECK (octet_length(model) BETWEEN 1 AND 200),
|
|
1462
|
+
CONSTRAINT slotlock_assistant_settings_key_valid CHECK (
|
|
1463
|
+
(sealed_key IS NULL) = (key_hint IS NULL)
|
|
1464
|
+
AND (sealed_key IS NULL OR octet_length(sealed_key) BETWEEN 1 AND 8192)
|
|
1465
|
+
AND (key_hint IS NULL OR char_length(key_hint) BETWEEN 1 AND 4)
|
|
1466
|
+
),
|
|
1467
|
+
CONSTRAINT slotlock_assistant_settings_updated_by_valid
|
|
1468
|
+
CHECK (updated_by IS NULL OR octet_length(updated_by) BETWEEN 1 AND 200)
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
-- One member's short-lived conversation with the dashboard assistant, and the writes it proposed
|
|
1472
|
+
-- (actions, by id, each pending until the member approves or dismisses it).
|
|
1473
|
+
CREATE TABLE IF NOT EXISTS slotlock.assistant_conversations (
|
|
1474
|
+
tenant_ref text NOT NULL,
|
|
1475
|
+
member_ref text NOT NULL,
|
|
1476
|
+
messages jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
1477
|
+
actions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
1478
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1479
|
+
expires_at timestamptz NOT NULL,
|
|
1480
|
+
CONSTRAINT slotlock_assistant_conversations_pkey PRIMARY KEY (tenant_ref, member_ref),
|
|
1481
|
+
CONSTRAINT slotlock_assistant_conversations_identity_valid CHECK (
|
|
1482
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(member_ref) BETWEEN 1 AND 500
|
|
1483
|
+
),
|
|
1484
|
+
CONSTRAINT slotlock_assistant_conversations_messages_valid CHECK (
|
|
1485
|
+
jsonb_typeof(messages) = 'array' AND octet_length(messages::text) <= 262144
|
|
1486
|
+
),
|
|
1487
|
+
CONSTRAINT slotlock_assistant_conversations_actions_valid CHECK (
|
|
1488
|
+
jsonb_typeof(actions) = 'object' AND octet_length(actions::text) <= 131072
|
|
1489
|
+
)
|
|
1490
|
+
);
|
|
1491
|
+
CREATE INDEX IF NOT EXISTS slotlock_assistant_conversations_expiry_idx
|
|
1492
|
+
ON slotlock.assistant_conversations (tenant_ref, expires_at);
|
|
1493
|
+
|
|
1209
1494
|
-- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
|
|
1210
1495
|
CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
|
|
1211
1496
|
RETURNS trigger
|
|
@@ -1380,6 +1665,163 @@ ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error text;
|
|
|
1380
1665
|
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error_at timestamptz;
|
|
1381
1666
|
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS stale_reported_at timestamptz;
|
|
1382
1667
|
|
|
1668
|
+
-- Configuration changes (RFC 0002 P1-6): a resource's own settings, the sources its coverage is
|
|
1669
|
+
-- read from, and the tenant's webhook endpoints. Each change that alters something appends one row,
|
|
1670
|
+
-- with what it was before and after for the fields it changed; writing the same value again appends
|
|
1671
|
+
-- none. Erasure is not reported, as above.
|
|
1672
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_erasing(owner text)
|
|
1673
|
+
RETURNS boolean
|
|
1674
|
+
LANGUAGE sql
|
|
1675
|
+
STABLE
|
|
1676
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1677
|
+
AS $slotlock_outbox_erasing$
|
|
1678
|
+
SELECT owner = current_setting('slotlock.erasing', true)
|
|
1679
|
+
AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
|
|
1680
|
+
WHERE nspname = 'slotlock');
|
|
1681
|
+
$slotlock_outbox_erasing$;
|
|
1682
|
+
|
|
1683
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_resource_change()
|
|
1684
|
+
RETURNS trigger
|
|
1685
|
+
LANGUAGE plpgsql
|
|
1686
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1687
|
+
AS $slotlock_outbox_resource$
|
|
1688
|
+
DECLARE
|
|
1689
|
+
changed slotlock.resources%ROWTYPE;
|
|
1690
|
+
kind text;
|
|
1691
|
+
before jsonb := '{}'::jsonb;
|
|
1692
|
+
after jsonb := '{}'::jsonb;
|
|
1693
|
+
details jsonb;
|
|
1694
|
+
BEGIN
|
|
1695
|
+
changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1696
|
+
IF changed.tenant_ref IS NULL OR slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
|
|
1697
|
+
IF TG_OP = 'INSERT' THEN
|
|
1698
|
+
kind := 'resource.created';
|
|
1699
|
+
details := jsonb_build_object(
|
|
1700
|
+
'timezone', NEW.timezone, 'visibility', NEW.visibility, 'pool', NEW.pool_ref,
|
|
1701
|
+
'availability', NEW.availability_rules, 'booking_rules', NEW.booking_rules);
|
|
1702
|
+
ELSIF TG_OP = 'DELETE' THEN
|
|
1703
|
+
kind := 'resource.deleted';
|
|
1704
|
+
details := '{}'::jsonb;
|
|
1705
|
+
ELSIF (OLD.archived_at IS NULL) <> (NEW.archived_at IS NULL) THEN
|
|
1706
|
+
kind := CASE WHEN NEW.archived_at IS NULL THEN 'resource.restored' ELSE 'resource.archived' END;
|
|
1707
|
+
details := jsonb_build_object('archived_at', NEW.archived_at);
|
|
1708
|
+
ELSE
|
|
1709
|
+
-- Only what changed, under the names the API gives it; a null is kept (no pool, no rules).
|
|
1710
|
+
IF OLD.external_ref IS DISTINCT FROM NEW.external_ref THEN
|
|
1711
|
+
before := before || jsonb_build_object('reference', OLD.external_ref);
|
|
1712
|
+
after := after || jsonb_build_object('reference', NEW.external_ref);
|
|
1713
|
+
END IF;
|
|
1714
|
+
IF OLD.timezone IS DISTINCT FROM NEW.timezone THEN
|
|
1715
|
+
before := before || jsonb_build_object('timezone', OLD.timezone);
|
|
1716
|
+
after := after || jsonb_build_object('timezone', NEW.timezone);
|
|
1717
|
+
END IF;
|
|
1718
|
+
IF OLD.pool_ref IS DISTINCT FROM NEW.pool_ref THEN
|
|
1719
|
+
before := before || jsonb_build_object('pool', OLD.pool_ref);
|
|
1720
|
+
after := after || jsonb_build_object('pool', NEW.pool_ref);
|
|
1721
|
+
END IF;
|
|
1722
|
+
IF OLD.availability_rules IS DISTINCT FROM NEW.availability_rules THEN
|
|
1723
|
+
before := before || jsonb_build_object('availability', OLD.availability_rules);
|
|
1724
|
+
after := after || jsonb_build_object('availability', NEW.availability_rules);
|
|
1725
|
+
END IF;
|
|
1726
|
+
IF OLD.booking_rules IS DISTINCT FROM NEW.booking_rules THEN
|
|
1727
|
+
before := before || jsonb_build_object('booking_rules', OLD.booking_rules);
|
|
1728
|
+
after := after || jsonb_build_object('booking_rules', NEW.booking_rules);
|
|
1729
|
+
END IF;
|
|
1730
|
+
IF after = '{}'::jsonb THEN RETURN NULL; END IF;
|
|
1731
|
+
kind := 'resource.updated';
|
|
1732
|
+
details := jsonb_build_object('before', before, 'after', after);
|
|
1733
|
+
END IF;
|
|
1734
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
1735
|
+
VALUES (
|
|
1736
|
+
changed.tenant_ref, changed.id, kind, changed.id::text,
|
|
1737
|
+
jsonb_build_object('id', changed.id, 'reference', changed.external_ref) || details,
|
|
1738
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
1739
|
+
);
|
|
1740
|
+
RETURN NULL;
|
|
1741
|
+
END
|
|
1742
|
+
$slotlock_outbox_resource$;
|
|
1743
|
+
|
|
1744
|
+
-- A source first recorded, and deleted. Reading it again (a new revision) is progress, not a change
|
|
1745
|
+
-- of what the resource is read from, and appends nothing; neither does a resource's deletion, which
|
|
1746
|
+
-- takes its coverage with it and is its own change. The source is named by its digest (source_id):
|
|
1747
|
+
-- it can be an iCalendar URL with a token in it, or an address.
|
|
1748
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_change()
|
|
1749
|
+
RETURNS trigger
|
|
1750
|
+
LANGUAGE plpgsql
|
|
1751
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1752
|
+
AS $slotlock_outbox_coverage$
|
|
1753
|
+
DECLARE
|
|
1754
|
+
changed slotlock.calendar_coverage%ROWTYPE;
|
|
1755
|
+
BEGIN
|
|
1756
|
+
changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1757
|
+
IF slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
|
|
1758
|
+
IF TG_OP = 'DELETE'
|
|
1759
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.resources resource WHERE resource.id = OLD.resource_id) THEN
|
|
1760
|
+
RETURN NULL;
|
|
1761
|
+
END IF;
|
|
1762
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
1763
|
+
VALUES (
|
|
1764
|
+
changed.tenant_ref, changed.resource_id,
|
|
1765
|
+
CASE WHEN TG_OP = 'DELETE' THEN 'coverage.deleted' ELSE 'coverage.recorded' END,
|
|
1766
|
+
${slotlockCoverageSourceIdSql('changed.tenant_ref', 'changed.source')},
|
|
1767
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
1768
|
+
'resource_id', changed.resource_id,
|
|
1769
|
+
'source_id', ${slotlockCoverageSourceIdSql('changed.tenant_ref', 'changed.source')},
|
|
1770
|
+
'start', CASE WHEN TG_OP = 'INSERT' THEN changed.starts_at END,
|
|
1771
|
+
'end', CASE WHEN TG_OP = 'INSERT' THEN changed.ends_at END,
|
|
1772
|
+
'revision', CASE WHEN TG_OP = 'INSERT' THEN changed.revision END)),
|
|
1773
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
1774
|
+
);
|
|
1775
|
+
RETURN NULL;
|
|
1776
|
+
END
|
|
1777
|
+
$slotlock_outbox_coverage$;
|
|
1778
|
+
|
|
1779
|
+
-- An endpoint added, enabled again or deleted (disabling is webhook.disabled, from the delivery that
|
|
1780
|
+
-- gave up). Its id and the types it takes, never its URL, which can carry a token, or its secret.
|
|
1781
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_webhook_change()
|
|
1782
|
+
RETURNS trigger
|
|
1783
|
+
LANGUAGE plpgsql
|
|
1784
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1785
|
+
AS $slotlock_outbox_webhook$
|
|
1786
|
+
DECLARE
|
|
1787
|
+
changed slotlock.webhook_endpoints%ROWTYPE;
|
|
1788
|
+
kind text;
|
|
1789
|
+
BEGIN
|
|
1790
|
+
changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1791
|
+
IF slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
|
|
1792
|
+
IF TG_OP = 'INSERT' THEN
|
|
1793
|
+
kind := 'webhook.created';
|
|
1794
|
+
ELSIF TG_OP = 'DELETE' THEN
|
|
1795
|
+
kind := 'webhook.deleted';
|
|
1796
|
+
ELSIF OLD.disabled_at IS NOT NULL AND NEW.disabled_at IS NULL THEN
|
|
1797
|
+
kind := 'webhook.enabled';
|
|
1798
|
+
ELSE
|
|
1799
|
+
RETURN NULL;
|
|
1800
|
+
END IF;
|
|
1801
|
+
INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
|
|
1802
|
+
VALUES (
|
|
1803
|
+
changed.tenant_ref, kind, changed.id::text,
|
|
1804
|
+
jsonb_build_object('id', changed.id, 'event_types', to_jsonb(changed.event_types)),
|
|
1805
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
1806
|
+
);
|
|
1807
|
+
RETURN NULL;
|
|
1808
|
+
END
|
|
1809
|
+
$slotlock_outbox_webhook$;
|
|
1810
|
+
|
|
1811
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
|
|
1812
|
+
AFTER INSERT OR UPDATE OR DELETE ON slotlock.resources
|
|
1813
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_resource_change();
|
|
1814
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
|
|
1815
|
+
AFTER INSERT OR DELETE ON slotlock.calendar_coverage
|
|
1816
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
|
|
1817
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
|
|
1818
|
+
AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
|
|
1819
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
1820
|
+
-- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
|
|
1821
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
|
|
1822
|
+
AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
|
|
1823
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
1824
|
+
|
|
1383
1825
|
DROP TRIGGER IF EXISTS slotlock_outbox_reservation_change ON slotlock.reservations;
|
|
1384
1826
|
CREATE TRIGGER slotlock_outbox_reservation_change
|
|
1385
1827
|
AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
|
|
@@ -1421,6 +1863,8 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
|
|
|
1421
1863
|
ends_at timestamptz NOT NULL,
|
|
1422
1864
|
buffer_after_ms bigint NOT NULL DEFAULT 0,
|
|
1423
1865
|
occupied_ends_at timestamptz,
|
|
1866
|
+
buffer_before_ms bigint NOT NULL DEFAULT 0,
|
|
1867
|
+
occupied_starts_at timestamptz,
|
|
1424
1868
|
revision bigint NOT NULL DEFAULT 1,
|
|
1425
1869
|
status text NOT NULL DEFAULT 'confirmed',
|
|
1426
1870
|
expires_at timestamptz,
|
|
@@ -1441,6 +1885,15 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
|
|
|
1441
1885
|
AND occupied_ends_at = ends_at + buffer_after_ms * interval '1 millisecond'
|
|
1442
1886
|
)
|
|
1443
1887
|
),
|
|
1888
|
+
CONSTRAINT slotlock_reservations_buffer_before_valid CHECK (
|
|
1889
|
+
buffer_before_ms >= 0 AND buffer_before_ms <= 86400000
|
|
1890
|
+
),
|
|
1891
|
+
-- NULL only without a leading buffer: a NULL occupied start reads as the start itself.
|
|
1892
|
+
CONSTRAINT slotlock_reservations_occupied_start_valid CHECK (
|
|
1893
|
+
(occupied_starts_at IS NULL AND buffer_before_ms = 0)
|
|
1894
|
+
OR (occupied_starts_at IS NOT NULL
|
|
1895
|
+
AND occupied_starts_at = starts_at - buffer_before_ms * interval '1 millisecond')
|
|
1896
|
+
),
|
|
1444
1897
|
CONSTRAINT slotlock_reservations_status_valid CHECK (status IN ('held', 'confirmed')),
|
|
1445
1898
|
-- A hold must carry its expiry; a confirmed reservation must not (expiry is hold-only state).
|
|
1446
1899
|
CONSTRAINT slotlock_reservations_expiry_shape CHECK (
|
|
@@ -1449,7 +1902,7 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
|
|
|
1449
1902
|
),
|
|
1450
1903
|
CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
1451
1904
|
resource_id WITH =,
|
|
1452
|
-
tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
1905
|
+
tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
1453
1906
|
)
|
|
1454
1907
|
);
|
|
1455
1908
|
|
|
@@ -1481,6 +1934,11 @@ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
|
1481
1934
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
|
|
1482
1935
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
|
|
1483
1936
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_ends_at timestamptz;
|
|
1937
|
+
-- The leading buffer a booking keeps before its start (its resource's bufferBeforeMinutes when it
|
|
1938
|
+
-- was made), and where its occupied time starts: its start less that buffer. A row written before
|
|
1939
|
+
-- leading buffers has none, so its occupied time starts at its start (NULL reads as starts_at).
|
|
1940
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_before_ms bigint NOT NULL DEFAULT 0;
|
|
1941
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_starts_at timestamptz;
|
|
1484
1942
|
-- A keyed hold's holder (its principal) and the group of alternatives it was taken in: an agent
|
|
1485
1943
|
-- releases or books only its own holds, and booking one releases the rest of its group.
|
|
1486
1944
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS holder_ref text;
|
|
@@ -1518,6 +1976,31 @@ BEGIN
|
|
|
1518
1976
|
END IF;
|
|
1519
1977
|
END $$;
|
|
1520
1978
|
|
|
1979
|
+
-- The occupied start, materialized for the same reason as the occupied end.
|
|
1980
|
+
CREATE OR REPLACE FUNCTION slotlock.set_reservation_occupied_start()
|
|
1981
|
+
RETURNS trigger
|
|
1982
|
+
LANGUAGE plpgsql
|
|
1983
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1984
|
+
AS $$
|
|
1985
|
+
BEGIN
|
|
1986
|
+
NEW.occupied_starts_at := NEW.starts_at - NEW.buffer_before_ms * interval '1 millisecond';
|
|
1987
|
+
RETURN NEW;
|
|
1988
|
+
END $$;
|
|
1989
|
+
|
|
1990
|
+
DO $$
|
|
1991
|
+
BEGIN
|
|
1992
|
+
IF NOT EXISTS (
|
|
1993
|
+
SELECT 1 FROM pg_trigger
|
|
1994
|
+
WHERE tgname = 'slotlock_reservations_set_occupied_start'
|
|
1995
|
+
AND tgrelid = 'slotlock.reservations'::regclass
|
|
1996
|
+
AND NOT tgisinternal
|
|
1997
|
+
) THEN
|
|
1998
|
+
CREATE TRIGGER slotlock_reservations_set_occupied_start
|
|
1999
|
+
BEFORE INSERT OR UPDATE OF starts_at, buffer_before_ms ON slotlock.reservations
|
|
2000
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.set_reservation_occupied_start();
|
|
2001
|
+
END IF;
|
|
2002
|
+
END $$;
|
|
2003
|
+
|
|
1521
2004
|
-- Cancellation is an idempotency fact, not the absence of a reservation. Keeping a tombstone
|
|
1522
2005
|
-- prevents delayed at-least-once create delivery from resurrecting a cancelled booking key.
|
|
1523
2006
|
CREATE TABLE IF NOT EXISTS slotlock.reservation_tombstones (
|
|
@@ -1812,6 +2295,34 @@ BEGIN
|
|
|
1812
2295
|
buffer_after_ms >= 0 AND buffer_after_ms <= 2592000000
|
|
1813
2296
|
) NOT VALID;
|
|
1814
2297
|
END IF;
|
|
2298
|
+
-- Every existing row has no leading buffer, so both hold of it; added NOT VALID (no scan under
|
|
2299
|
+
-- the lock) and validated after the migration commits.
|
|
2300
|
+
IF NOT EXISTS (
|
|
2301
|
+
SELECT 1 FROM pg_constraint
|
|
2302
|
+
WHERE conname = 'slotlock_reservations_buffer_before_valid'
|
|
2303
|
+
AND conrelid = 'slotlock.reservations'::regclass
|
|
2304
|
+
) THEN
|
|
2305
|
+
ALTER TABLE slotlock.reservations
|
|
2306
|
+
ADD CONSTRAINT slotlock_reservations_buffer_before_valid CHECK (
|
|
2307
|
+
buffer_before_ms >= 0 AND buffer_before_ms <= 86400000
|
|
2308
|
+
) NOT VALID;
|
|
2309
|
+
END IF;
|
|
2310
|
+
-- A first form of this check let a NULL occupied start through with a leading buffer (the
|
|
2311
|
+
-- comparison is NULL, which a CHECK passes): replaced wherever it lacks the IS NOT NULL guard.
|
|
2312
|
+
IF NOT EXISTS (
|
|
2313
|
+
SELECT 1 FROM pg_constraint
|
|
2314
|
+
WHERE conname = 'slotlock_reservations_occupied_start_valid'
|
|
2315
|
+
AND conrelid = 'slotlock.reservations'::regclass
|
|
2316
|
+
AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_starts_at IS NOT NULL%'
|
|
2317
|
+
) THEN
|
|
2318
|
+
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_occupied_start_valid;
|
|
2319
|
+
ALTER TABLE slotlock.reservations
|
|
2320
|
+
ADD CONSTRAINT slotlock_reservations_occupied_start_valid CHECK (
|
|
2321
|
+
(occupied_starts_at IS NULL AND buffer_before_ms = 0)
|
|
2322
|
+
OR (occupied_starts_at IS NOT NULL
|
|
2323
|
+
AND occupied_starts_at = starts_at - buffer_before_ms * interval '1 millisecond')
|
|
2324
|
+
) NOT VALID;
|
|
2325
|
+
END IF;
|
|
1815
2326
|
END $$;
|
|
1816
2327
|
|
|
1817
2328
|
-- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
|
|
@@ -1862,13 +2373,35 @@ BEGIN
|
|
|
1862
2373
|
ALTER TABLE slotlock.reservations
|
|
1863
2374
|
ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
1864
2375
|
resource_id WITH =,
|
|
1865
|
-
tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
2376
|
+
tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
1866
2377
|
);
|
|
1867
2378
|
END IF;
|
|
1868
2379
|
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_buffered_no_overlap;
|
|
1869
2380
|
END
|
|
1870
2381
|
$slotlock_occupied_overlap$;
|
|
1871
2382
|
|
|
2383
|
+
-- A row occupies from its occupied start too: its start less its leading buffer. Every row an
|
|
2384
|
+
-- upgrade finds was written before leading buffers and keeps none, so its range is the one the old
|
|
2385
|
+
-- constraint already kept apart and none can conflict: the constraint is replaced in place, its
|
|
2386
|
+
-- index built once under the migration's lock.
|
|
2387
|
+
DO $slotlock_occupied_start_overlap$
|
|
2388
|
+
BEGIN
|
|
2389
|
+
IF NOT EXISTS (
|
|
2390
|
+
SELECT 1 FROM pg_constraint
|
|
2391
|
+
WHERE conname = 'slotlock_reservations_no_overlap'
|
|
2392
|
+
AND conrelid = 'slotlock.reservations'::regclass
|
|
2393
|
+
AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_starts_at%'
|
|
2394
|
+
) THEN
|
|
2395
|
+
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_no_overlap;
|
|
2396
|
+
ALTER TABLE slotlock.reservations
|
|
2397
|
+
ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
2398
|
+
resource_id WITH =,
|
|
2399
|
+
tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
2400
|
+
);
|
|
2401
|
+
END IF;
|
|
2402
|
+
END
|
|
2403
|
+
$slotlock_occupied_start_overlap$;
|
|
2404
|
+
|
|
1872
2405
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_resource_time_idx
|
|
1873
2406
|
ON slotlock.reservations (resource_id, starts_at, ends_at);
|
|
1874
2407
|
|
|
@@ -2068,6 +2601,10 @@ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by
|
|
|
2068
2601
|
ALTER TABLE slotlock.calendar_events
|
|
2069
2602
|
ADD COLUMN IF NOT EXISTS buffer_after_ms integer NOT NULL DEFAULT 0
|
|
2070
2603
|
CONSTRAINT slotlock_calendar_events_buffer_valid CHECK (buffer_after_ms BETWEEN 0 AND 86400000);
|
|
2604
|
+
-- The leading buffer a booked event keeps before each occurrence, kept for a later roll alike.
|
|
2605
|
+
ALTER TABLE slotlock.calendar_events
|
|
2606
|
+
ADD COLUMN IF NOT EXISTS buffer_before_ms integer NOT NULL DEFAULT 0
|
|
2607
|
+
CONSTRAINT slotlock_calendar_events_buffer_before_valid CHECK (buffer_before_ms BETWEEN 0 AND 86400000);
|
|
2071
2608
|
ALTER TABLE slotlock.calendar_events
|
|
2072
2609
|
DROP CONSTRAINT IF EXISTS slotlock_calendar_events_recurrence_cursor_bounded;
|
|
2073
2610
|
DO $slotlock_calendar_event_recurrence_cursor$
|
|
@@ -2633,6 +3170,9 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
2633
3170
|
'outbox_pruned',
|
|
2634
3171
|
'webhook_endpoints',
|
|
2635
3172
|
'waitlist_entries',
|
|
3173
|
+
'booking_rule_defaults',
|
|
3174
|
+
'assistant_settings',
|
|
3175
|
+
'assistant_conversations',
|
|
2636
3176
|
]);
|
|
2637
3177
|
const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
2638
3178
|
const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
@@ -2702,6 +3242,12 @@ ALTER TABLE slotlock.webhook_endpoints ENABLE ROW LEVEL SECURITY;
|
|
|
2702
3242
|
ALTER TABLE slotlock.webhook_endpoints FORCE ROW LEVEL SECURITY;
|
|
2703
3243
|
ALTER TABLE slotlock.waitlist_entries ENABLE ROW LEVEL SECURITY;
|
|
2704
3244
|
ALTER TABLE slotlock.waitlist_entries FORCE ROW LEVEL SECURITY;
|
|
3245
|
+
ALTER TABLE slotlock.booking_rule_defaults ENABLE ROW LEVEL SECURITY;
|
|
3246
|
+
ALTER TABLE slotlock.booking_rule_defaults FORCE ROW LEVEL SECURITY;
|
|
3247
|
+
ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
|
|
3248
|
+
ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
|
|
3249
|
+
ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
|
|
3250
|
+
ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
|
|
2705
3251
|
|
|
2706
3252
|
-- One database must have one tenant-context contract. Re-applying with an adapter-specific
|
|
2707
3253
|
-- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
|
|
@@ -2730,7 +3276,10 @@ BEGIN
|
|
|
2730
3276
|
'outbox',
|
|
2731
3277
|
'outbox_pruned',
|
|
2732
3278
|
'webhook_endpoints',
|
|
2733
|
-
'waitlist_entries'
|
|
3279
|
+
'waitlist_entries',
|
|
3280
|
+
'booking_rule_defaults',
|
|
3281
|
+
'assistant_settings',
|
|
3282
|
+
'assistant_conversations'
|
|
2734
3283
|
)
|
|
2735
3284
|
LOOP
|
|
2736
3285
|
EXECUTE format(
|
|
@@ -2941,6 +3490,36 @@ BEGIN
|
|
|
2941
3490
|
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
2942
3491
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
2943
3492
|
END IF;
|
|
3493
|
+
|
|
3494
|
+
IF NOT EXISTS (
|
|
3495
|
+
SELECT 1 FROM pg_policies
|
|
3496
|
+
WHERE schemaname = 'slotlock' AND tablename = 'booking_rule_defaults'
|
|
3497
|
+
AND policyname = 'tenant_isolation_v1'
|
|
3498
|
+
) THEN
|
|
3499
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.booking_rule_defaults
|
|
3500
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
3501
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
3502
|
+
END IF;
|
|
3503
|
+
|
|
3504
|
+
IF NOT EXISTS (
|
|
3505
|
+
SELECT 1 FROM pg_policies
|
|
3506
|
+
WHERE schemaname = 'slotlock' AND tablename = 'assistant_settings'
|
|
3507
|
+
AND policyname = 'tenant_isolation_v1'
|
|
3508
|
+
) THEN
|
|
3509
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_settings
|
|
3510
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
3511
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
3512
|
+
END IF;
|
|
3513
|
+
|
|
3514
|
+
IF NOT EXISTS (
|
|
3515
|
+
SELECT 1 FROM pg_policies
|
|
3516
|
+
WHERE schemaname = 'slotlock' AND tablename = 'assistant_conversations'
|
|
3517
|
+
AND policyname = 'tenant_isolation_v1'
|
|
3518
|
+
) THEN
|
|
3519
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
|
|
3520
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
3521
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
3522
|
+
END IF;
|
|
2944
3523
|
END
|
|
2945
3524
|
$slotlock_rls$;
|
|
2946
3525
|
|
|
@@ -2978,6 +3557,12 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.webhook_endpoints
|
|
|
2978
3557
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
2979
3558
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.waitlist_entries
|
|
2980
3559
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3560
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.booking_rule_defaults
|
|
3561
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3562
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
|
|
3563
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3564
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
|
|
3565
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
2981
3566
|
|
|
2982
3567
|
INSERT INTO slotlock.rls_policy_contracts (
|
|
2983
3568
|
table_name, tenant_context_setting, policy_fingerprint, installed_at
|
|
@@ -3017,7 +3602,10 @@ WHERE schemaname = 'slotlock'
|
|
|
3017
3602
|
'outbox',
|
|
3018
3603
|
'outbox_pruned',
|
|
3019
3604
|
'webhook_endpoints',
|
|
3020
|
-
'waitlist_entries'
|
|
3605
|
+
'waitlist_entries',
|
|
3606
|
+
'booking_rule_defaults',
|
|
3607
|
+
'assistant_settings',
|
|
3608
|
+
'assistant_conversations'
|
|
3021
3609
|
)
|
|
3022
3610
|
AND policyname = 'tenant_isolation_v1'
|
|
3023
3611
|
ON CONFLICT (table_name) DO UPDATE
|