slotlock 0.6.1 → 0.6.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 +34 -0
- package/README.md +13 -6
- package/SECURITY.md +15 -4
- package/agent-server.js +1 -5
- package/agent-store-backend.d.ts +5 -6
- package/agent-store-backend.js +41 -67
- package/api-keys.js +1 -1
- package/application-api.d.ts +3 -3
- package/application-api.js +16 -24
- package/assistant.d.ts +18 -27
- package/assistant.js +36 -53
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +17 -2
- package/config.js +44 -6
- package/dashboard.js +14 -1
- package/ddl.d.ts +20 -27
- package/ddl.js +166 -108
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +23 -31
- package/event-feed.js +30 -45
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/interop.js +1 -4
- package/maintenance.d.ts +14 -10
- package/maintenance.js +43 -26
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.js +0 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/rate-limit.d.ts +6 -1
- package/rate-limit.js +24 -8
- package/rest-api.d.ts +13 -14
- package/rest-api.js +11 -12
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/self-host.d.ts +16 -2
- package/self-host.js +42 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +109 -161
- package/store.js +253 -371
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/timezone.d.ts +25 -33
- package/timezone.js +37 -52
- package/types.d.ts +35 -56
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/ddl.js
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
// Slotlock owns its schema
|
|
2
|
-
//
|
|
1
|
+
// Slotlock owns its schema: this DDL lives in the `slotlock` Postgres schema, is applied by
|
|
2
|
+
// store.applySchema, and is idempotent.
|
|
3
3
|
//
|
|
4
|
-
// The EXCLUDE constraint is the load-bearing piece
|
|
5
|
-
//
|
|
6
|
-
// The same pattern scales from an integration's booking tables to a first-class reservation store.
|
|
4
|
+
// The EXCLUDE constraint is the load-bearing piece: the database, not application code, arbitrates
|
|
5
|
+
// double-booking per resource on the half-open `[)` range.
|
|
7
6
|
//
|
|
8
|
-
// The deployment role owns every table, so
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// resolves its own names as before. Apply it in one transaction: under autocommit every statement
|
|
13
|
-
// is a transaction of its own, and the pin ends with the statement that sets it.
|
|
7
|
+
// The deployment role owns every table, so a function or operator resolved through a schema another
|
|
8
|
+
// role can write to ("$user", public) would run with that ownership. The DDL pins search_path to
|
|
9
|
+
// pg_catalog and pg_temp while it runs and restores the caller's at the end. Apply it in one
|
|
10
|
+
// transaction: under autocommit the pin ends with its own statement.
|
|
14
11
|
import { SLOTLOCK_CHANGE_ORIGINS, legacyCoverageSourceDigestSql } from './event-feed.js';
|
|
15
12
|
import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
|
|
16
13
|
const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
|
|
@@ -19,17 +16,11 @@ SELECT pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
|
|
|
19
16
|
`;
|
|
20
17
|
const DEPLOYMENT_SEARCH_PATH_RESTORE = `SELECT pg_catalog.set_config('search_path', pg_catalog.current_setting('${CALLER_SEARCH_PATH_SETTING}'), true);
|
|
21
18
|
`;
|
|
22
|
-
// applyTenantRls forces
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// after its last, on those tables only. applySchema runs both in one transaction, which holds the
|
|
28
|
-
// tables locked in between; a failure rolls the lift back with everything else. Run statement by
|
|
29
|
-
// statement, the lift commits on its own and a refused data step stops the run before the restore,
|
|
30
|
-
// so the tables lifted are recorded in a table, not the session: the next deployment, from any
|
|
31
|
-
// session or connection, puts FORCE back on every table recorded, and `slotlock serve` refuses to
|
|
32
|
-
// start while a tenant table is left without it.
|
|
19
|
+
// applyTenantRls forces RLS, which binds the owner too, and the deployment sets no tenant, so its
|
|
20
|
+
// data steps would see no rows. FORCE is lifted from the tables that have it before the first data
|
|
21
|
+
// step and restored after the last. The lifted tables are recorded in a table, not the session, so
|
|
22
|
+
// if a statement-by-statement run stops before the restore, the next deployment from any session
|
|
23
|
+
// restores them, and `slotlock serve` refuses to start meanwhile.
|
|
33
24
|
const DEPLOYMENT_LIFT_FORCED_ROW_SECURITY = `CREATE TABLE IF NOT EXISTS slotlock.deployment_lifted_row_security (
|
|
34
25
|
table_name text PRIMARY KEY
|
|
35
26
|
);
|
|
@@ -74,19 +65,18 @@ $slotlock_restore_forced_row_security$;
|
|
|
74
65
|
/** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
|
|
75
66
|
export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
|
|
76
67
|
/**
|
|
77
|
-
* The version
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
68
|
+
* The schema version this release creates, recorded in `slotlock.schema_version`. Up to 0.2
|
|
69
|
+
* recorded nothing (0.1 was 1, 0.2 was 2); 0.3 to 0.5 recorded 3. 0.6 is 4, so a 0.5 release
|
|
70
|
+
* refuses it rather than restoring the trigger that names a coverage source by its guessable
|
|
71
|
+
* digest.
|
|
81
72
|
*/
|
|
82
73
|
export const SLOTLOCK_SCHEMA_VERSION = 4;
|
|
83
74
|
/** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
|
|
84
75
|
export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
|
|
85
76
|
/** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
|
|
86
77
|
const MIN_SERVER_VERSION_NUM = 160000;
|
|
87
|
-
// Before anything changes:
|
|
88
|
-
//
|
|
89
|
-
// migrated is refused, since this release's DDL would put back what that release replaced.
|
|
78
|
+
// Before anything changes: an old server fails here with a message naming the version rather than
|
|
79
|
+
// midway, and a schema a newer release migrated is refused.
|
|
90
80
|
const SLOTLOCK_SCHEMA_PRECONDITIONS = `DO $slotlock_schema_preconditions$
|
|
91
81
|
DECLARE
|
|
92
82
|
installed integer;
|
|
@@ -107,14 +97,11 @@ BEGIN
|
|
|
107
97
|
END
|
|
108
98
|
$slotlock_schema_preconditions$;
|
|
109
99
|
`;
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// there can DROP EXTENSION ... CASCADE and take the exclusion constraints with it. The deployment
|
|
116
|
-
// refuses before it touches anything there, listing each finding as a JSON array in the error's
|
|
117
|
-
// DETAIL. Superusers are trusted: they can do all of this anyway.
|
|
100
|
+
// The deployment writes rows as the owner of every table, firing their triggers. A role that owns
|
|
101
|
+
// or can create objects in the schema, owns an object or trigger function there, or may create a
|
|
102
|
+
// trigger could therefore run code as the deployment role; one owning an extension there could DROP
|
|
103
|
+
// EXTENSION ... CASCADE and take the exclusion constraints. The deployment refuses before touching
|
|
104
|
+
// anything, with findings as a JSON array in DETAIL. Superusers are trusted.
|
|
118
105
|
const SLOTLOCK_SCHEMA_CONTROL_CHECK = `DO $slotlock_schema_control$
|
|
119
106
|
DECLARE
|
|
120
107
|
refusals text[];
|
|
@@ -190,9 +177,8 @@ END
|
|
|
190
177
|
$slotlock_schema_control$;
|
|
191
178
|
`;
|
|
192
179
|
/**
|
|
193
|
-
* What every migration checks before
|
|
194
|
-
*
|
|
195
|
-
* and the schema version. Run with search_path pinned, as the DDL pins it.
|
|
180
|
+
* What every migration checks before changing anything, even when it finds nothing to change: no
|
|
181
|
+
* other role controls the schema, then server and schema version. Run with search_path pinned.
|
|
196
182
|
*/
|
|
197
183
|
export const SLOTLOCK_DEPLOYMENT_CHECKS = `${SLOTLOCK_SCHEMA_CONTROL_CHECK}${SLOTLOCK_SCHEMA_PRECONDITIONS}`;
|
|
198
184
|
/** Active (unrevoked, unexpired) API keys one tenant may hold. */
|
|
@@ -243,6 +229,7 @@ export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
|
|
|
243
229
|
'webhook_tenants',
|
|
244
230
|
'rate_buckets',
|
|
245
231
|
'assistant_tenants',
|
|
232
|
+
'maintenance_tenants',
|
|
246
233
|
]);
|
|
247
234
|
/**
|
|
248
235
|
* The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
|
|
@@ -257,6 +244,8 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
|
|
|
257
244
|
'slotlock.take_rate_token(text, double precision, double precision)',
|
|
258
245
|
'slotlock.register_assistant_tenant(text, timestamptz)',
|
|
259
246
|
'slotlock.sweep_assistant_conversations(integer)',
|
|
247
|
+
'slotlock.list_maintenance_tenant()',
|
|
248
|
+
'slotlock.maintenance_tenants(text)',
|
|
260
249
|
]);
|
|
261
250
|
/**
|
|
262
251
|
* Functions only the deployment role runs: suspending and lifting a suspension. No other role is
|
|
@@ -301,15 +290,13 @@ const apiKeySelect = (alias) => ['id', 'tenant_ref', 'name', 'prefix', 'scopes',
|
|
|
301
290
|
.concat(['last_used_at', 'revoked_at'])
|
|
302
291
|
.map((column) => `${alias}.${column}`)
|
|
303
292
|
.join(', ');
|
|
304
|
-
// API keys (api-keys.ts)
|
|
305
|
-
//
|
|
306
|
-
//
|
|
293
|
+
// API keys (api-keys.ts): SHA-256 digests only, never the key. RLS is enabled with no policy and no
|
|
294
|
+
// grants, so only the owner reaches the tables, through the functions below, which the serving role
|
|
295
|
+
// may execute.
|
|
307
296
|
//
|
|
308
|
-
// Each function takes its tenant as an argument
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// stay in api_key_retired_digests (digests only, nothing about the tenant), and a revoked key's row
|
|
312
|
-
// stays in api_keys until erasure, so the unique digest refuses it too.
|
|
297
|
+
// Each function takes its tenant as an argument. A call cannot reach another tenant's keys, read a
|
|
298
|
+
// digest, or reuse a digest rotated out or erased: those stay in api_key_retired_digests (digests
|
|
299
|
+
// only), and a revoked key's row stays until erasure.
|
|
313
300
|
const SLOTLOCK_API_KEYS_DDL = `
|
|
314
301
|
CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
315
302
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -603,19 +590,14 @@ CREATE OR REPLACE TRIGGER slotlock_outbox_api_key_change
|
|
|
603
590
|
AFTER INSERT OR UPDATE OF revoked_at, secret_hash ON slotlock.api_keys
|
|
604
591
|
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_api_key_change();
|
|
605
592
|
`;
|
|
606
|
-
// The dashboard's records (dashboard.ts)
|
|
607
|
-
// (
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// nothing here names a tenant. Row-level security is enabled with no policy and no role is granted
|
|
611
|
-
// anything, so the serving role reaches the table only through the two functions below.
|
|
593
|
+
// The dashboard's shared records (dashboard.ts): finished sign-ins (a callback works once), sent
|
|
594
|
+
// forms (run once) and signed-out sessions (a copied cookie stays out), each a SHA-256 digest; form
|
|
595
|
+
// records also carry the sender's digest so the database can cap them. RLS with no policy and no
|
|
596
|
+
// grants: reached only through the two functions below.
|
|
612
597
|
//
|
|
613
|
-
// The database's clock decides
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
// token. So the functions refuse a token whose expiry the database's clock has passed, or that
|
|
617
|
-
// claims to outlive a day (no dashboard token lives longer than 12 hours), and keep each record
|
|
618
|
-
// until exactly that expiry: while the database would accept the token, its record is there.
|
|
598
|
+
// The database's clock decides: server clocks may drift, so the functions refuse a token the
|
|
599
|
+
// database's clock says has expired, or one claiming to outlive a day, and keep each record until
|
|
600
|
+
// exactly that expiry.
|
|
619
601
|
const SLOTLOCK_DASHBOARD_DDL = `
|
|
620
602
|
CREATE TABLE IF NOT EXISTS slotlock.dashboard_tokens (
|
|
621
603
|
kind text NOT NULL,
|
|
@@ -736,11 +718,10 @@ AS $slotlock_dashboard_session_ended$
|
|
|
736
718
|
$slotlock_dashboard_session_ended$;
|
|
737
719
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
|
|
738
720
|
`;
|
|
739
|
-
// The schema's version
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
// anything, so a server reads the version only through installed_schema_version().
|
|
721
|
+
// The schema's version: one row, written by the DDL itself, so running SLOTLOCK_CORE_DDL directly
|
|
722
|
+
// is versioned too. applySchema adds digests of the DDL and catalog so a later no-op migration can
|
|
723
|
+
// skip without locking. RLS with no policy and no grants: read only through
|
|
724
|
+
// installed_schema_version().
|
|
744
725
|
const SLOTLOCK_SCHEMA_VERSION_DDL = `
|
|
745
726
|
CREATE TABLE IF NOT EXISTS slotlock.schema_version (
|
|
746
727
|
singleton boolean PRIMARY KEY DEFAULT true,
|
|
@@ -772,16 +753,14 @@ AS $slotlock_installed_schema_version$
|
|
|
772
753
|
$slotlock_installed_schema_version$;
|
|
773
754
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_SCHEMA_FUNCTIONS[0]} FROM PUBLIC;
|
|
774
755
|
`;
|
|
775
|
-
// Tenant suspension and erasure. A suspended tenant
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
// owns it, suspends or lifts a suspension. Erasure keeps the suspension, so an erased abuser stays
|
|
780
|
-
// suspended.
|
|
756
|
+
// Tenant suspension and erasure. A suspended tenant may read and write nothing; its keys still
|
|
757
|
+
// authenticate but it gets no new key. The table names tenants and reasons, so RLS with no policy
|
|
758
|
+
// and no grants: servers read it only through tenant_suspended(), and only the deployment role
|
|
759
|
+
// (owner) suspends. Erasure keeps the suspension.
|
|
781
760
|
//
|
|
782
|
-
// Every server transaction for a tenant holds
|
|
783
|
-
// suspension
|
|
784
|
-
//
|
|
761
|
+
// Every server transaction for a tenant holds its lock shared before reading the suspension;
|
|
762
|
+
// suspension and erasure take it exclusively, so a suspension waits for transactions in flight and
|
|
763
|
+
// every later one sees it.
|
|
785
764
|
const SLOTLOCK_TENANT_ADMIN_DDL = `
|
|
786
765
|
CREATE TABLE IF NOT EXISTS slotlock.tenant_suspensions (
|
|
787
766
|
tenant_ref text PRIMARY KEY,
|
|
@@ -970,6 +949,10 @@ BEGIN
|
|
|
970
949
|
DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
|
|
971
950
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
972
951
|
RETURN NEXT;
|
|
952
|
+
erased_table := 'maintenance_tenants';
|
|
953
|
+
DELETE FROM slotlock.maintenance_tenants WHERE tenant_ref = requested_tenant_ref;
|
|
954
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
955
|
+
RETURN NEXT;
|
|
973
956
|
erased_table := 'api_keys';
|
|
974
957
|
WITH erased AS (
|
|
975
958
|
DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
|
|
@@ -1192,17 +1175,100 @@ BEGIN
|
|
|
1192
1175
|
END
|
|
1193
1176
|
$slotlock_sweep_assistant_conversations$;
|
|
1194
1177
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[7]} FROM PUBLIC;
|
|
1178
|
+
|
|
1179
|
+
-- The tenants with data, which \`serve\` maintains every hour whether or not their keys call: what
|
|
1180
|
+
-- retention ends is deleted for a tenant gone quiet too. Forced row-level security hides every
|
|
1181
|
+
-- tenant's rows from the owner as well, so this list (names only) is how a server finds them. A
|
|
1182
|
+
-- tenant is listed by the first row it writes to a table maintenance prunes (each change on its
|
|
1183
|
+
-- feed is one, so every booking, event, resource and endpoint counts), and erasure takes it off.
|
|
1184
|
+
-- While it is empty (first created), it starts from every tenant already in a tenant table: the
|
|
1185
|
+
-- deployment has lifted FORCE by now. No role but its owner reads it.
|
|
1186
|
+
CREATE TABLE IF NOT EXISTS slotlock.maintenance_tenants (
|
|
1187
|
+
tenant_ref text PRIMARY KEY,
|
|
1188
|
+
CONSTRAINT slotlock_maintenance_tenants_tenant_ref_valid
|
|
1189
|
+
CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
|
|
1190
|
+
);
|
|
1191
|
+
DO $slotlock_maintenance_tenants_known$
|
|
1192
|
+
DECLARE
|
|
1193
|
+
tenant_table text;
|
|
1194
|
+
BEGIN
|
|
1195
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.maintenance_tenants) THEN
|
|
1196
|
+
FOR tenant_table IN
|
|
1197
|
+
SELECT rel.relname::text
|
|
1198
|
+
FROM pg_catalog.pg_class rel
|
|
1199
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
1200
|
+
JOIN pg_catalog.pg_attribute att ON att.attrelid = rel.oid
|
|
1201
|
+
WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p')
|
|
1202
|
+
AND att.attname = 'tenant_ref' AND NOT att.attisdropped
|
|
1203
|
+
AND rel.relname <> 'maintenance_tenants'
|
|
1204
|
+
ORDER BY rel.relname
|
|
1205
|
+
LOOP
|
|
1206
|
+
EXECUTE pg_catalog.format(
|
|
1207
|
+
'INSERT INTO slotlock.maintenance_tenants (tenant_ref)
|
|
1208
|
+
SELECT DISTINCT tenant_ref FROM slotlock.%I
|
|
1209
|
+
WHERE octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
1210
|
+
ON CONFLICT (tenant_ref) DO NOTHING', tenant_table);
|
|
1211
|
+
END LOOP;
|
|
1212
|
+
END IF;
|
|
1213
|
+
END
|
|
1214
|
+
$slotlock_maintenance_tenants_known$;
|
|
1215
|
+
ALTER TABLE slotlock.maintenance_tenants ENABLE ROW LEVEL SECURITY;
|
|
1216
|
+
REVOKE ALL ON slotlock.maintenance_tenants FROM PUBLIC;
|
|
1217
|
+
|
|
1218
|
+
-- Lists the tenants of the rows a statement appended (only the writer's own: RLS checks each row).
|
|
1219
|
+
-- Once listed, a tenant costs each later statement one index probe.
|
|
1220
|
+
CREATE OR REPLACE FUNCTION slotlock.list_maintenance_tenant()
|
|
1221
|
+
RETURNS trigger
|
|
1222
|
+
LANGUAGE plpgsql
|
|
1223
|
+
SECURITY DEFINER
|
|
1224
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1225
|
+
AS $slotlock_list_maintenance_tenant$
|
|
1226
|
+
BEGIN
|
|
1227
|
+
INSERT INTO slotlock.maintenance_tenants (tenant_ref)
|
|
1228
|
+
SELECT DISTINCT appended.tenant_ref FROM appended
|
|
1229
|
+
WHERE appended.tenant_ref IS NOT NULL
|
|
1230
|
+
ON CONFLICT (tenant_ref) DO NOTHING;
|
|
1231
|
+
RETURN NULL;
|
|
1232
|
+
END
|
|
1233
|
+
$slotlock_list_maintenance_tenant$;
|
|
1234
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[8]} FROM PUBLIC;
|
|
1235
|
+
-- The feed, and what can be written without a change on it: a reference retired with no booking,
|
|
1236
|
+
-- an event's cancellation and idempotency answers, a hold's answer, a waitlist entry.
|
|
1237
|
+
DO $slotlock_maintenance_tenants_triggers$
|
|
1238
|
+
DECLARE
|
|
1239
|
+
tenant_table text;
|
|
1240
|
+
BEGIN
|
|
1241
|
+
FOREACH tenant_table IN ARRAY ARRAY[
|
|
1242
|
+
'outbox', 'reservation_tombstones', 'calendar_event_tombstones', 'calendar_event_commands',
|
|
1243
|
+
'hold_commands', 'waitlist_entries'
|
|
1244
|
+
] LOOP
|
|
1245
|
+
EXECUTE pg_catalog.format(
|
|
1246
|
+
'CREATE OR REPLACE TRIGGER slotlock_list_maintenance_tenant
|
|
1247
|
+
AFTER INSERT ON slotlock.%I REFERENCING NEW TABLE AS appended
|
|
1248
|
+
FOR EACH STATEMENT EXECUTE FUNCTION slotlock.list_maintenance_tenant()', tenant_table);
|
|
1249
|
+
END LOOP;
|
|
1250
|
+
END
|
|
1251
|
+
$slotlock_maintenance_tenants_triggers$;
|
|
1252
|
+
|
|
1253
|
+
-- A page of 1,000 tenant names after \`after\` (NULL: from the start); a caller pages to the end.
|
|
1254
|
+
CREATE OR REPLACE FUNCTION slotlock.maintenance_tenants(after text)
|
|
1255
|
+
RETURNS SETOF text
|
|
1256
|
+
LANGUAGE sql
|
|
1257
|
+
STABLE
|
|
1258
|
+
SECURITY DEFINER
|
|
1259
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1260
|
+
AS $slotlock_maintenance_tenants$
|
|
1261
|
+
SELECT tenant_ref FROM slotlock.maintenance_tenants
|
|
1262
|
+
WHERE after IS NULL OR tenant_ref > after
|
|
1263
|
+
ORDER BY tenant_ref LIMIT 1000;
|
|
1264
|
+
$slotlock_maintenance_tenants$;
|
|
1265
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[9]} FROM PUBLIC;
|
|
1195
1266
|
`;
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
// (PostgreSQL 17, ALTER TABLE and Explicit Locking). It sees every row whatever row-level security
|
|
1202
|
-
// the table forces: the check scans the table itself, and referential checks bypass row security.
|
|
1203
|
-
// One that old rows still break stays NOT VALID, with a notice, rather than stopping the migration:
|
|
1204
|
-
// it binds every new row either way, and applySchema tries it again at each later migration. Once
|
|
1205
|
-
// valid, a constraint is never checked again.
|
|
1267
|
+
// Validate constraints an upgrade added NOT VALID. applySchema runs this in its own transaction
|
|
1268
|
+
// after the DDL commits: inside the DDL's, altered tables stay ACCESS EXCLUSIVE and the scan would
|
|
1269
|
+
// block serving queries. Alone, VALIDATE takes SHARE UPDATE EXCLUSIVE, which no read or row write
|
|
1270
|
+
// conflicts with (PostgreSQL 17 docs), and it sees every row regardless of RLS. One old rows still
|
|
1271
|
+
// break stays NOT VALID with a notice (it still binds new rows) and is retried at each migration.
|
|
1206
1272
|
const DEPLOYMENT_VALIDATE_CONSTRAINTS = `DO $slotlock_validate_constraints$
|
|
1207
1273
|
DECLARE
|
|
1208
1274
|
unchecked record;
|
|
@@ -1228,7 +1294,6 @@ $slotlock_validate_constraints$;
|
|
|
1228
1294
|
`;
|
|
1229
1295
|
/** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
|
|
1230
1296
|
export const SLOTLOCK_VALIDATION_DDL = DEPLOYMENT_VALIDATE_CONSTRAINTS;
|
|
1231
|
-
// The digests that let the next migration skip are recorded by applySchema once it has validated.
|
|
1232
1297
|
const DEPLOYMENT_RECORD_VERSION = `INSERT INTO slotlock.schema_version (singleton, version, applied_at)
|
|
1233
1298
|
VALUES (true, ${SLOTLOCK_SCHEMA_VERSION}, now())
|
|
1234
1299
|
ON CONFLICT (singleton) DO UPDATE
|
|
@@ -3206,9 +3271,9 @@ function coverageChangeInShapeSql(row) {
|
|
|
3206
3271
|
AND NOT ${row}.data ?| ARRAY['source', 'last_error'])`;
|
|
3207
3272
|
}
|
|
3208
3273
|
/**
|
|
3209
|
-
* The source_id an earlier release's coverage change `row` names
|
|
3210
|
-
*
|
|
3211
|
-
*
|
|
3274
|
+
* The source_id an earlier release's coverage change `row` names: the source matching its tenant,
|
|
3275
|
+
* resource and source (0.5.2 and earlier: the subject or `data.source`) or 0.5.3 digest, else
|
|
3276
|
+
* src_redacted.
|
|
3212
3277
|
*/
|
|
3213
3278
|
function coverageChangeSourceIdSql(row) {
|
|
3214
3279
|
return `COALESCE((
|
|
@@ -3224,16 +3289,12 @@ function coverageChangeSourceIdSql(row) {
|
|
|
3224
3289
|
/** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
|
|
3225
3290
|
export const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5_000;
|
|
3226
3291
|
/**
|
|
3227
|
-
* One batch of the rename of coverage changes an earlier release wrote: of the outbox rows
|
|
3228
|
-
*
|
|
3229
|
-
*
|
|
3230
|
-
*
|
|
3231
|
-
*
|
|
3232
|
-
*
|
|
3233
|
-
* can match it any more, and a made-up id would look like a source the tenant could find. Selects
|
|
3234
|
-
* the last id read (`last`, null when there was none) and how many it renamed (`renamed`), `INTO`
|
|
3235
|
-
* `into` when given (PL/pgSQL). Rows in the new shape are left alone, so a batch run again changes
|
|
3236
|
-
* nothing.
|
|
3292
|
+
* One batch of the rename of coverage changes an earlier release wrote: of the outbox rows after
|
|
3293
|
+
* `after` up to `head` (bigint SQL expressions), the first `batch` by id; each `coverage.*` row not
|
|
3294
|
+
* yet in shape gets its source_id as subject and `data.source_id`, and loses `source` and
|
|
3295
|
+
* `last_error` (before 0.5.3 these carried the source itself, a URL with a token; 0.5.3's unkeyed
|
|
3296
|
+
* digest could confirm a guess). A deleted source becomes `src_redacted`. Selects `last` (null when
|
|
3297
|
+
* none) and `renamed`, `INTO` `into` when given. Idempotent.
|
|
3237
3298
|
*/
|
|
3238
3299
|
export function slotlockCoverageChangeRenameSql(after, head, batch, into = '') {
|
|
3239
3300
|
return `WITH scanned AS MATERIALIZED (
|
|
@@ -3263,10 +3324,9 @@ SELECT (SELECT max(scanned.id) FROM scanned)::text AS last,
|
|
|
3263
3324
|
(SELECT count(*) FROM renamed)::integer AS renamed${into ? `
|
|
3264
3325
|
INTO ${into}` : ''}`;
|
|
3265
3326
|
}
|
|
3266
|
-
// The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction
|
|
3267
|
-
//
|
|
3268
|
-
//
|
|
3269
|
-
// runs it in batches after the structure commits instead, each its own short transaction.
|
|
3327
|
+
// The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction while FORCE is lifted (the
|
|
3328
|
+
// outbox is under ACCESS EXCLUSIVE throughout). Resumes where the last pass stopped and is skipped
|
|
3329
|
+
// once done; applySchema instead runs it in batches after the structure commits.
|
|
3270
3330
|
const DEPLOYMENT_RENAME_COVERAGE_CHANGES = `DO $slotlock_rename_coverage_changes$
|
|
3271
3331
|
DECLARE
|
|
3272
3332
|
scan_after bigint;
|
|
@@ -3336,12 +3396,10 @@ const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
|
3336
3396
|
/** Names GRANT reads as a pseudo-role (PUBLIC reaches every role) or PostgreSQL reserves. */
|
|
3337
3397
|
const RESERVED_ROLE_NAMES = new Set(['public', 'current_role', 'current_user', 'session_user']);
|
|
3338
3398
|
/**
|
|
3339
|
-
* Build the grants for the application role: USAGE on the
|
|
3340
|
-
*
|
|
3341
|
-
*
|
|
3342
|
-
*
|
|
3343
|
-
* the known ways out; these statements check nothing). Rerun after every `applySchema()`, since a
|
|
3344
|
-
* release may add a table.
|
|
3399
|
+
* Build the grants for the application role: USAGE on the schema, DML on the store's tables and
|
|
3400
|
+
* EXECUTE on the API key and dashboard functions, nothing else. The role must not be able to leave
|
|
3401
|
+
* forced RLS (`grantApplicationRole` checks; these statements do not). Rerun after every
|
|
3402
|
+
* `applySchema()`, since a release may add a table.
|
|
3345
3403
|
*/
|
|
3346
3404
|
export function createSlotlockApplicationRoleGrantsDdl(role) {
|
|
3347
3405
|
if (!POSTGRES_ROLE_NAME.test(role) || RESERVED_ROLE_NAMES.has(role) || role.startsWith('pg_')) {
|
package/engine.d.ts
CHANGED
|
@@ -4,22 +4,19 @@ export declare function mergeIntervals(intervals: Interval[]): Interval[];
|
|
|
4
4
|
/** Free sub-intervals of `window` once `busy` is carved out (busy need not be sorted/merged). */
|
|
5
5
|
export declare function subtractBusy(window: Interval, busy: Interval[]): Interval[];
|
|
6
6
|
/**
|
|
7
|
-
* Earliest `[s, s + durationMs)`
|
|
8
|
-
*
|
|
9
|
-
* non-positive duration — a zero-width slot is never a useful answer).
|
|
7
|
+
* Earliest `[s, s + durationMs)` inside one of `windows` without touching `busy`, or null when
|
|
8
|
+
* nothing fits (including a non-positive duration).
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* shifts, no straddling" is unexpressible via touching windows — model it as a gap or a future
|
|
14
|
-
* constraint layer.
|
|
10
|
+
* Touching windows merge (RFC 7953 set-union), so a slot may span the seam of two abutting rules;
|
|
11
|
+
* "no straddling" must be modelled as a gap.
|
|
15
12
|
*/
|
|
16
13
|
export declare function findNextAvailable(params: {
|
|
17
14
|
busy: Interval[];
|
|
18
15
|
windows: Interval[];
|
|
19
16
|
durationMs: number;
|
|
20
17
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* The first allowed start at or after a candidate (a booking rule's step); must not move a start
|
|
19
|
+
* backwards.
|
|
23
20
|
*/
|
|
24
21
|
align?: (start: Date) => Date;
|
|
25
22
|
}): Interval | null;
|
package/engine.js
CHANGED
|
@@ -34,14 +34,11 @@ export function subtractBusy(window, busy) {
|
|
|
34
34
|
return free;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Earliest `[s, s + durationMs)`
|
|
38
|
-
*
|
|
39
|
-
* non-positive duration — a zero-width slot is never a useful answer).
|
|
37
|
+
* Earliest `[s, s + durationMs)` inside one of `windows` without touching `busy`, or null when
|
|
38
|
+
* nothing fits (including a non-positive duration).
|
|
40
39
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* shifts, no straddling" is unexpressible via touching windows — model it as a gap or a future
|
|
44
|
-
* constraint layer.
|
|
40
|
+
* Touching windows merge (RFC 7953 set-union), so a slot may span the seam of two abutting rules;
|
|
41
|
+
* "no straddling" must be modelled as a gap.
|
|
45
42
|
*/
|
|
46
43
|
export function findNextAvailable(params) {
|
|
47
44
|
const { busy, windows, durationMs } = params;
|
|
@@ -49,8 +46,7 @@ export function findNextAvailable(params) {
|
|
|
49
46
|
return null;
|
|
50
47
|
const slotAt = (start) => ({ start, end: new Date(start.getTime() + durationMs) });
|
|
51
48
|
const align = params.align ?? ((start) => start);
|
|
52
|
-
// One walk of
|
|
53
|
-
// ending by a window's start ends before every later one too.
|
|
49
|
+
// One walk of merged busy alongside merged windows, both by start.
|
|
54
50
|
const merged = mergeIntervals(busy);
|
|
55
51
|
let next = 0;
|
|
56
52
|
for (const window of mergeIntervals(windows)) {
|
package/event-feed.d.ts
CHANGED
|
@@ -4,39 +4,33 @@ type SlotlockSql = Sql | TransactionSql;
|
|
|
4
4
|
export declare const SLOTLOCK_CHANGE_TYPES: readonly ["hold.created", "hold.updated", "hold.confirmed", "hold.released", "hold.expired", "reservation.created", "reservation.updated", "reservation.cancelled", "reservation.checked_in", "reservation.no_show", "event.created", "event.updated", "event.cancelled", "event.timezone_shifted", "event.timezone_conflict", "coverage.stale", "waitlist.offered", "waitlist.expired", "webhook.disabled", "resource.created", "resource.updated", "resource.archived", "resource.restored", "resource.deleted", "coverage.recorded", "coverage.deleted", "webhook.created", "webhook.enabled", "webhook.deleted", "key.created", "key.rotated", "key.revoked"];
|
|
5
5
|
export type SlotlockChangeType = (typeof SLOTLOCK_CHANGE_TYPES)[number];
|
|
6
6
|
/**
|
|
7
|
-
* Where a change came from
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* store itself (`library`). Never an address or anything else about a person.
|
|
7
|
+
* Where a change came from (withTenant's `origin`): an agent protocol (`mcp`, `a2a`), the REST
|
|
8
|
+
* API's agent routes (`rest`) or application operations (`application`), the dashboard, its
|
|
9
|
+
* assistant, maintenance, or direct store calls (`library`). Never anything about a person.
|
|
11
10
|
*/
|
|
12
11
|
export declare const SLOTLOCK_CHANGE_ORIGINS: readonly ["mcp", "a2a", "rest", "application", "dashboard", "dashboard-assistant", "maintenance", "library"];
|
|
13
12
|
export type SlotlockChangeOrigin = (typeof SLOTLOCK_CHANGE_ORIGINS)[number];
|
|
14
13
|
/** Whether `value` is one of SLOTLOCK_CHANGE_ORIGINS. */
|
|
15
14
|
export declare function isSlotlockChangeOrigin(value: unknown): value is SlotlockChangeOrigin;
|
|
16
15
|
/**
|
|
17
|
-
* The digest 0.5.3 named a coverage source by
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the upgrade uses this only to find the changes 0.5.3 wrote and rename them. `tenant` and `source`
|
|
21
|
-
* are SQL expressions.
|
|
16
|
+
* The unkeyed digest 0.5.3 named a coverage source by (`src_` + 24 hex of sha256(tenant, newline,
|
|
17
|
+
* source)); anyone knowing the tenant could confirm a guess. Used only by the upgrade to find and
|
|
18
|
+
* rename those changes. `tenant` and `source` are SQL expressions.
|
|
22
19
|
*/
|
|
23
20
|
export declare function legacyCoverageSourceDigestSql(tenant: string, source: string): string;
|
|
24
21
|
/**
|
|
25
|
-
* 0.
|
|
26
|
-
*
|
|
27
|
-
* only the database knows it, so this throws (`coverage_source_id_unavailable`) rather than give a
|
|
28
|
-
* digest nothing carries any more.
|
|
22
|
+
* Throws `coverage_source_id_unavailable`: since 0.6 a source's `source_id` is random and only the
|
|
23
|
+
* database knows it (0.5.3's unkeyed digest let anyone confirm a guessed source).
|
|
29
24
|
*
|
|
30
25
|
* @deprecated Read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`). It may
|
|
31
26
|
* be removed in 0.7.0.
|
|
32
27
|
*/
|
|
33
28
|
export declare function slotlockCoverageSourceId(_tenantRef: string, _source: string): never;
|
|
34
29
|
/**
|
|
35
|
-
* A change's subject and data as the feed serves them. A `coverage.*` change never carries
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* earlier release's server writes during a rolling upgrade, is served redacted.
|
|
30
|
+
* A change's subject and data as the feed serves them. A `coverage.*` change never carries `source`
|
|
31
|
+
* or `last_error` (which can quote it), and is named by a current `source_id` or `src_redacted`:
|
|
32
|
+
* rows an earlier release wrote are renamed in batches after upgrade, so one not reached yet is
|
|
33
|
+
* served redacted.
|
|
40
34
|
*/
|
|
41
35
|
export declare function slotlockServedChange(type: string, subject: string, data: Record<string, unknown>): {
|
|
42
36
|
subject: string;
|
|
@@ -115,11 +109,10 @@ export type ListChangesResult = {
|
|
|
115
109
|
};
|
|
116
110
|
export interface SlotlockEventFeed {
|
|
117
111
|
/**
|
|
118
|
-
* The tenant's changes after `after` (
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* any store has pruned is `cursor_expired`: read from the start again.
|
|
112
|
+
* The tenant's changes after `after` (or from the oldest kept), oldest first, up to `limit`
|
|
113
|
+
* (1-500, default 100). Only ended transactions are read, so a reader following `next` never
|
|
114
|
+
* skips a late commit. `next` is the last position, or `after` when there is none: poll with it.
|
|
115
|
+
* A position before a pruned change is `cursor_expired`.
|
|
123
116
|
*/
|
|
124
117
|
listChanges(params: {
|
|
125
118
|
tenantRef: string;
|
|
@@ -165,9 +158,9 @@ export interface SlotlockEventFeed {
|
|
|
165
158
|
code: 'changes_pruned';
|
|
166
159
|
}>;
|
|
167
160
|
/**
|
|
168
|
-
* Lease the tenant's endpoints
|
|
169
|
-
*
|
|
170
|
-
*
|
|
161
|
+
* Lease the tenant's endpoints with changes due, each with its next `batch` (1-100) changes, for
|
|
162
|
+
* `leaseMs` (1,000-300,000). An expired lease is claimed again by any worker, so a crash costs a
|
|
163
|
+
* redelivery, never a lost change.
|
|
171
164
|
*/
|
|
172
165
|
claimWebhookDeliveries(params: {
|
|
173
166
|
tenantRef: string;
|
|
@@ -193,11 +186,10 @@ export interface SlotlockEventFeed {
|
|
|
193
186
|
};
|
|
194
187
|
}): Promise<void>;
|
|
195
188
|
/**
|
|
196
|
-
* History
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* `id` as `before` for the page after. Kept as long as the feed keeps changes.
|
|
189
|
+
* History of one resource (`resourceId`: its bookings, events and configuration) or one record
|
|
190
|
+
* (`subject`), newest first, `limit` (1-100) at a time, each with `actor`, `keyId`, `origin` and
|
|
191
|
+
* the prior state where there was one. Pass the last `id` as `before` for the next page. Kept as
|
|
192
|
+
* long as the feed.
|
|
201
193
|
*/
|
|
202
194
|
listHistory(params: {
|
|
203
195
|
tenantRef: string;
|