slotlock 0.1.0 → 0.2.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 +138 -0
- package/CONTRIBUTING.md +35 -9
- package/README.md +153 -79
- package/SECURITY.md +40 -4
- package/SPEC.md +19 -6
- package/agent-server.d.ts +64 -7
- package/agent-server.js +273 -68
- package/agent-store-backend.d.ts +16 -0
- package/agent-store-backend.js +257 -2
- package/api-keys.d.ts +11 -4
- package/api-keys.js +9 -2
- package/application-api.d.ts +54 -0
- package/application-api.js +290 -0
- package/dashboard.d.ts +4 -1
- package/dashboard.js +6 -4
- package/ddl.d.ts +2 -1
- package/ddl.js +143 -15
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/index.d.ts +8 -4
- package/index.js +4 -2
- package/maintenance.d.ts +57 -0
- package/maintenance.js +181 -0
- package/node-server.js +121 -5
- package/package.json +1 -1
- package/rest-api.d.ts +33 -18
- package/rest-api.js +131 -63
- package/rules.d.ts +19 -0
- package/rules.js +32 -4
- package/self-host.d.ts +5 -2
- package/self-host.js +127 -20
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +30 -8
- package/store.js +69 -27
- package/sync.js +530 -49
- package/timezone.d.ts +19 -0
- package/timezone.js +16 -1
- package/types.d.ts +4 -1
- package/weekly-hours.js +2 -2
package/dashboard.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// is held in the database too.
|
|
15
15
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
16
16
|
import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
|
|
17
|
+
import { ignoreRejection } from './hooks.js';
|
|
17
18
|
import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
|
|
18
19
|
/** Resources one dashboard tenant may hold. */
|
|
19
20
|
export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
|
|
@@ -54,6 +55,7 @@ const DAY_MS = 86_400_000;
|
|
|
54
55
|
const KEY_ACCESS = Object.freeze({
|
|
55
56
|
read: ['read'],
|
|
56
57
|
read_write: ['read', 'write'],
|
|
58
|
+
full: ['read', 'write', 'admin'],
|
|
57
59
|
});
|
|
58
60
|
const KEY_EXPIRY_DAYS = Object.freeze({
|
|
59
61
|
never: null,
|
|
@@ -432,7 +434,7 @@ export function createSlotlockDashboard(options) {
|
|
|
432
434
|
let signInsInFlight = 0;
|
|
433
435
|
function report(message) {
|
|
434
436
|
try {
|
|
435
|
-
options.onError?.(new Error(message));
|
|
437
|
+
ignoreRejection(options.onError?.(new Error(message)));
|
|
436
438
|
}
|
|
437
439
|
catch {
|
|
438
440
|
// A failing reporter must not change the response.
|
|
@@ -559,7 +561,7 @@ ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
|
|
|
559
561
|
return html `<tr>
|
|
560
562
|
<td data-label="Name">${key.name}</td>
|
|
561
563
|
<td data-label="Key"><code>${key.prefix}…</code></td>
|
|
562
|
-
<td data-label="Access">${key.scopes.includes('write') ? 'Read and write' : 'Read only'}</td>
|
|
564
|
+
<td data-label="Access">${key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only'}</td>
|
|
563
565
|
<td data-label="Created">${formatDate(key.createdAt)}</td>
|
|
564
566
|
<td data-label="Last used">${key.lastUsedAt ? formatDate(key.lastUsedAt) : 'Never'}</td>
|
|
565
567
|
<td data-label="Expires">${key.expiresAt ? formatDate(key.expiresAt) : 'Never'}</td>
|
|
@@ -588,7 +590,7 @@ ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
|
|
|
588
590
|
<input type="hidden" name="csrf" value="${token}">
|
|
589
591
|
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
590
592
|
<label>Name<input name="name" required maxlength="100" placeholder="Booking agent"></label>
|
|
591
|
-
<label>Access<select name="access"><option value="read_write">Read and write</option><option value="read">Read only</option></select></label>
|
|
593
|
+
<label>Access<select name="access"><option value="read_write">Read and write</option><option value="read">Read only</option><option value="full">Full, with the application API</option></select></label>
|
|
592
594
|
<label>Expires<select name="expires"><option value="90">In 90 days</option><option value="30">In 30 days</option><option value="365">In a year</option><option value="never">Never</option></select></label>
|
|
593
595
|
<button class="primary" type="submit">Create key</button>
|
|
594
596
|
</form>
|
|
@@ -1089,7 +1091,7 @@ ${state.unshown
|
|
|
1089
1091
|
}
|
|
1090
1092
|
catch (error) {
|
|
1091
1093
|
try {
|
|
1092
|
-
options.onError?.(error);
|
|
1094
|
+
ignoreRejection(options.onError?.(error));
|
|
1093
1095
|
}
|
|
1094
1096
|
catch {
|
|
1095
1097
|
// A failing reporter must not change the response.
|
package/ddl.d.ts
CHANGED
|
@@ -31,7 +31,8 @@ export declare const SLOTLOCK_CORE_DDL: string;
|
|
|
31
31
|
export declare const SLOTLOCK_TENANT_CONTEXT_SETTING = "slotlock.tenant_ref";
|
|
32
32
|
/**
|
|
33
33
|
* Every table the store reads or writes at runtime, each under forced tenant RLS.
|
|
34
|
-
* `slotlock.rls_policy_contracts`
|
|
34
|
+
* `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
|
|
35
|
+
* deployment-only and deliberately absent.
|
|
35
36
|
*/
|
|
36
37
|
export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage"];
|
|
37
38
|
/**
|
package/ddl.js
CHANGED
|
@@ -18,6 +18,58 @@ SELECT pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
|
|
|
18
18
|
`;
|
|
19
19
|
const DEPLOYMENT_SEARCH_PATH_RESTORE = `SELECT pg_catalog.set_config('search_path', pg_catalog.current_setting('${CALLER_SEARCH_PATH_SETTING}'), true);
|
|
20
20
|
`;
|
|
21
|
+
// applyTenantRls forces row-level security, which binds the table owner too, and the deployment
|
|
22
|
+
// sets no tenant: its data steps (reaping holds and listing conflicts before the overlap
|
|
23
|
+
// constraint, merging duplicate resources, binding archives, backfilling event children) would see
|
|
24
|
+
// no rows, skip what they repair and leave a constraint to meet rows its own check never saw. The
|
|
25
|
+
// deployment lifts FORCE from the tables that have it before its first data step and puts it back
|
|
26
|
+
// after its last, on those tables only. applySchema runs both in one transaction, which holds the
|
|
27
|
+
// tables locked in between; a failure rolls the lift back with everything else. Run statement by
|
|
28
|
+
// statement, the lift commits on its own and a refused data step stops the run before the restore,
|
|
29
|
+
// so the tables lifted are recorded in a table, not the session: the next deployment, from any
|
|
30
|
+
// session or connection, puts FORCE back on every table recorded, and `slotlock serve` refuses to
|
|
31
|
+
// start while a tenant table is left without it.
|
|
32
|
+
const DEPLOYMENT_LIFT_FORCED_ROW_SECURITY = `CREATE TABLE IF NOT EXISTS slotlock.deployment_lifted_row_security (
|
|
33
|
+
table_name text PRIMARY KEY
|
|
34
|
+
);
|
|
35
|
+
REVOKE ALL ON slotlock.deployment_lifted_row_security FROM PUBLIC;
|
|
36
|
+
DO $slotlock_lift_forced_row_security$
|
|
37
|
+
DECLARE
|
|
38
|
+
forced_table text;
|
|
39
|
+
BEGIN
|
|
40
|
+
INSERT INTO slotlock.deployment_lifted_row_security (table_name)
|
|
41
|
+
SELECT rel.relname::text
|
|
42
|
+
FROM pg_catalog.pg_class rel
|
|
43
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
44
|
+
WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p') AND rel.relforcerowsecurity
|
|
45
|
+
ON CONFLICT (table_name) DO NOTHING;
|
|
46
|
+
FOR forced_table IN
|
|
47
|
+
SELECT rel.relname::text
|
|
48
|
+
FROM pg_catalog.pg_class rel
|
|
49
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
50
|
+
WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p') AND rel.relforcerowsecurity
|
|
51
|
+
ORDER BY rel.relname
|
|
52
|
+
LOOP
|
|
53
|
+
EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I NO FORCE ROW LEVEL SECURITY', forced_table);
|
|
54
|
+
END LOOP;
|
|
55
|
+
END
|
|
56
|
+
$slotlock_lift_forced_row_security$;
|
|
57
|
+
`;
|
|
58
|
+
const DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY = `DO $slotlock_restore_forced_row_security$
|
|
59
|
+
DECLARE
|
|
60
|
+
forced_table text;
|
|
61
|
+
BEGIN
|
|
62
|
+
FOR forced_table IN
|
|
63
|
+
SELECT table_name FROM slotlock.deployment_lifted_row_security ORDER BY table_name
|
|
64
|
+
LOOP
|
|
65
|
+
IF pg_catalog.to_regclass(pg_catalog.format('slotlock.%I', forced_table)) IS NOT NULL THEN
|
|
66
|
+
EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I FORCE ROW LEVEL SECURITY', forced_table);
|
|
67
|
+
END IF;
|
|
68
|
+
END LOOP;
|
|
69
|
+
DELETE FROM slotlock.deployment_lifted_row_security;
|
|
70
|
+
END
|
|
71
|
+
$slotlock_restore_forced_row_security$;
|
|
72
|
+
`;
|
|
21
73
|
/** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
|
|
22
74
|
export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
|
|
23
75
|
// Every slotlock.<name> the deployment uses resolves in that schema, and the deployment writes rows
|
|
@@ -191,8 +243,13 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
|
191
243
|
CONSTRAINT slotlock_api_keys_name_valid
|
|
192
244
|
CHECK (char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'),
|
|
193
245
|
CONSTRAINT slotlock_api_keys_prefix_valid CHECK (prefix ~ '^slk_[0-9A-Za-z]{8}$'),
|
|
194
|
-
|
|
195
|
-
|
|
246
|
+
-- A non-empty list of read, write and admin, each once, in that order (api-keys.ts).
|
|
247
|
+
CONSTRAINT slotlock_api_keys_scopes_known
|
|
248
|
+
CHECK (scopes IN (
|
|
249
|
+
ARRAY['read']::text[], ARRAY['write']::text[], ARRAY['admin']::text[],
|
|
250
|
+
ARRAY['read', 'write']::text[], ARRAY['read', 'admin']::text[], ARRAY['write', 'admin']::text[],
|
|
251
|
+
ARRAY['read', 'write', 'admin']::text[]
|
|
252
|
+
)),
|
|
196
253
|
CONSTRAINT slotlock_api_keys_created_by_valid
|
|
197
254
|
CHECK (created_by IS NULL OR (octet_length(created_by) BETWEEN 1 AND 200
|
|
198
255
|
AND created_by !~ '[[:cntrl:]]')),
|
|
@@ -204,6 +261,27 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
|
204
261
|
CREATE INDEX IF NOT EXISTS slotlock_api_keys_tenant_idx
|
|
205
262
|
ON slotlock.api_keys (tenant_ref, created_at DESC, id DESC);
|
|
206
263
|
ALTER TABLE slotlock.api_keys ENABLE ROW LEVEL SECURITY;
|
|
264
|
+
|
|
265
|
+
-- Schemas from 0.1 allow only read and write. The CHECK with admin admits every row the old one
|
|
266
|
+
-- did, so it is added NOT VALID (no table scan under the lock) and the old one dropped in the same
|
|
267
|
+
-- transaction.
|
|
268
|
+
DO $slotlock_api_key_scopes$
|
|
269
|
+
BEGIN
|
|
270
|
+
IF NOT EXISTS (
|
|
271
|
+
SELECT 1 FROM pg_constraint
|
|
272
|
+
WHERE conname = 'slotlock_api_keys_scopes_known'
|
|
273
|
+
AND conrelid = 'slotlock.api_keys'::regclass
|
|
274
|
+
) THEN
|
|
275
|
+
ALTER TABLE slotlock.api_keys
|
|
276
|
+
ADD CONSTRAINT slotlock_api_keys_scopes_known CHECK (scopes IN (
|
|
277
|
+
ARRAY['read']::text[], ARRAY['write']::text[], ARRAY['admin']::text[],
|
|
278
|
+
ARRAY['read', 'write']::text[], ARRAY['read', 'admin']::text[], ARRAY['write', 'admin']::text[],
|
|
279
|
+
ARRAY['read', 'write', 'admin']::text[]
|
|
280
|
+
)) NOT VALID;
|
|
281
|
+
END IF;
|
|
282
|
+
ALTER TABLE slotlock.api_keys DROP CONSTRAINT IF EXISTS slotlock_api_keys_scopes_valid;
|
|
283
|
+
END
|
|
284
|
+
$slotlock_api_key_scopes$;
|
|
207
285
|
REVOKE ALL ON slotlock.api_keys FROM PUBLIC;
|
|
208
286
|
|
|
209
287
|
CREATE TABLE IF NOT EXISTS slotlock.api_key_retired_digests (
|
|
@@ -496,7 +574,7 @@ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
|
|
|
496
574
|
export const SLOTLOCK_CORE_DDL = `
|
|
497
575
|
${DEPLOYMENT_SEARCH_PATH_PIN}CREATE SCHEMA IF NOT EXISTS slotlock;
|
|
498
576
|
${SLOTLOCK_SCHEMA_CONTROL_CHECK}CREATE EXTENSION IF NOT EXISTS btree_gist SCHEMA slotlock;
|
|
499
|
-
|
|
577
|
+
${DEPLOYMENT_LIFT_FORCED_ROW_SECURITY}
|
|
500
578
|
-- Records the exact catalog representation installed for each owned RLS policy. Health checks
|
|
501
579
|
-- compare live policy fingerprints with this contract so ALTER POLICY drift cannot hide behind a
|
|
502
580
|
-- retained name/comment. This table contains configuration only, never tenant or calendar data.
|
|
@@ -552,7 +630,7 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
|
|
|
552
630
|
),
|
|
553
631
|
CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
554
632
|
resource_id WITH =,
|
|
555
|
-
tstzrange(starts_at, ends_at, '[)') WITH &&
|
|
633
|
+
tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
556
634
|
)
|
|
557
635
|
);
|
|
558
636
|
|
|
@@ -749,10 +827,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_resources_legacy_external_ref_key
|
|
|
749
827
|
ON slotlock.resources (external_ref)
|
|
750
828
|
WHERE tenant_ref IS NULL AND external_ref IS NOT NULL;
|
|
751
829
|
|
|
752
|
-
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_reservations_tenant_external_ref_key
|
|
753
|
-
ON slotlock.reservations (tenant_ref, external_ref)
|
|
754
|
-
WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL;
|
|
755
|
-
|
|
756
830
|
DO $$
|
|
757
831
|
BEGIN
|
|
758
832
|
IF NOT EXISTS (
|
|
@@ -855,18 +929,62 @@ BEGIN
|
|
|
855
929
|
buffer_after_ms >= 0 AND buffer_after_ms <= 2592000000
|
|
856
930
|
) NOT VALID;
|
|
857
931
|
END IF;
|
|
932
|
+
END $$;
|
|
933
|
+
|
|
934
|
+
-- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
|
|
935
|
+
-- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
|
|
936
|
+
-- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
|
|
937
|
+
-- reaps expired holds in the way, as the next writer would, then refuses to go on while a live row
|
|
938
|
+
-- is in the way: a constraint is never made to fit by deleting a booking or a live hold. Rows from
|
|
939
|
+
-- before the occupied-end trigger may lack occupied_ends_at; their end is where they stop.
|
|
940
|
+
DO $slotlock_occupied_overlap$
|
|
941
|
+
DECLARE
|
|
942
|
+
conflicts bigint;
|
|
943
|
+
examples text;
|
|
944
|
+
BEGIN
|
|
858
945
|
IF NOT EXISTS (
|
|
859
946
|
SELECT 1 FROM pg_constraint
|
|
860
|
-
WHERE conname = '
|
|
947
|
+
WHERE conname = 'slotlock_reservations_no_overlap'
|
|
861
948
|
AND conrelid = 'slotlock.reservations'::regclass
|
|
949
|
+
AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_ends_at%'
|
|
862
950
|
) THEN
|
|
951
|
+
DELETE FROM slotlock.reservations hold
|
|
952
|
+
USING slotlock.reservations buffered
|
|
953
|
+
WHERE hold.status = 'held'
|
|
954
|
+
AND hold.expires_at <= now()
|
|
955
|
+
AND buffered.resource_id = hold.resource_id
|
|
956
|
+
AND buffered.id <> hold.id
|
|
957
|
+
AND buffered.buffer_after_ms > 0
|
|
958
|
+
AND tstzrange(buffered.starts_at, COALESCE(buffered.occupied_ends_at, buffered.ends_at), '[)')
|
|
959
|
+
&& tstzrange(hold.starts_at, hold.ends_at, '[)');
|
|
960
|
+
SELECT count(*),
|
|
961
|
+
string_agg(buffered.id::text || ' and ' || other.id::text, ', ' ORDER BY buffered.id, other.id)
|
|
962
|
+
INTO conflicts, examples
|
|
963
|
+
FROM slotlock.reservations buffered
|
|
964
|
+
JOIN slotlock.reservations other
|
|
965
|
+
ON other.resource_id = buffered.resource_id
|
|
966
|
+
AND other.id <> buffered.id
|
|
967
|
+
AND tstzrange(buffered.starts_at, COALESCE(buffered.occupied_ends_at, buffered.ends_at), '[)')
|
|
968
|
+
&& tstzrange(other.starts_at, COALESCE(other.occupied_ends_at, other.ends_at), '[)')
|
|
969
|
+
WHERE buffered.buffer_after_ms > 0
|
|
970
|
+
-- A pair of buffered rows counts once.
|
|
971
|
+
AND (other.buffer_after_ms = 0 OR buffered.id < other.id);
|
|
972
|
+
IF conflicts > 0 THEN
|
|
973
|
+
RAISE EXCEPTION 'Slotlock cannot upgrade: % reservations or holds overlap another''s turnaround buffer', conflicts
|
|
974
|
+
USING ERRCODE = '23P01',
|
|
975
|
+
DETAIL = 'Reservation ids, in pairs: ' || left(examples, 2000),
|
|
976
|
+
HINT = 'Move, cancel or release one of each pair (slotlock.reservations), then migrate again.';
|
|
977
|
+
END IF;
|
|
978
|
+
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_no_overlap;
|
|
863
979
|
ALTER TABLE slotlock.reservations
|
|
864
|
-
ADD CONSTRAINT
|
|
980
|
+
ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
865
981
|
resource_id WITH =,
|
|
866
|
-
tstzrange(starts_at, occupied_ends_at, '[)') WITH &&
|
|
867
|
-
)
|
|
982
|
+
tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
983
|
+
);
|
|
868
984
|
END IF;
|
|
869
|
-
|
|
985
|
+
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_buffered_no_overlap;
|
|
986
|
+
END
|
|
987
|
+
$slotlock_occupied_overlap$;
|
|
870
988
|
|
|
871
989
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_resource_time_idx
|
|
872
990
|
ON slotlock.reservations (resource_id, starts_at, ends_at);
|
|
@@ -1279,6 +1397,15 @@ BEGIN
|
|
|
1279
1397
|
END IF;
|
|
1280
1398
|
END $$;
|
|
1281
1399
|
|
|
1400
|
+
-- A booking's reference is unique in its tenant. The rows a calendar event occupies carry a
|
|
1401
|
+
-- reference derived from the event in the same columns, but they are not bookings and are left out:
|
|
1402
|
+
-- a booking with the same reference neither collides with one nor answers for it. Up to 0.1 one
|
|
1403
|
+
-- index covered both; every row it admitted, this one admits.
|
|
1404
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_reservations_booking_external_ref_key
|
|
1405
|
+
ON slotlock.reservations (tenant_ref, external_ref)
|
|
1406
|
+
WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL AND calendar_event_id IS NULL;
|
|
1407
|
+
DROP INDEX IF EXISTS slotlock.slotlock_reservations_tenant_external_ref_key;
|
|
1408
|
+
|
|
1282
1409
|
-- Materialized occurrences point at reservations created under the SAME exclusion arbiter as
|
|
1283
1410
|
-- bookings and holds. Transparent events retain occurrence visibility with a NULL reservation.
|
|
1284
1411
|
CREATE TABLE IF NOT EXISTS slotlock.calendar_event_occurrences (
|
|
@@ -1454,11 +1581,12 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
1454
1581
|
ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
|
|
1455
1582
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
1456
1583
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
1457
|
-
${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
1584
|
+
${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
1458
1585
|
export const SLOTLOCK_TENANT_CONTEXT_SETTING = 'slotlock.tenant_ref';
|
|
1459
1586
|
/**
|
|
1460
1587
|
* Every table the store reads or writes at runtime, each under forced tenant RLS.
|
|
1461
|
-
* `slotlock.rls_policy_contracts`
|
|
1588
|
+
* `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
|
|
1589
|
+
* deployment-only and deliberately absent.
|
|
1462
1590
|
*/
|
|
1463
1591
|
export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
1464
1592
|
'resources',
|
package/hooks.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Let a hook's promise fail unseen. `onEvent` and `onError` are typed as returning nothing, but an
|
|
3
|
+
* async one returns a promise, and its rejection, left alone, is an unhandled rejection: Node ends
|
|
4
|
+
* the process on one by default. A hook that throws is caught where it is called; this is the same
|
|
5
|
+
* for one that rejects.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ignoreRejection(result: unknown): void;
|
package/hooks.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Let a hook's promise fail unseen. `onEvent` and `onError` are typed as returning nothing, but an
|
|
3
|
+
* async one returns a promise, and its rejection, left alone, is an unhandled rejection: Node ends
|
|
4
|
+
* the process on one by default. A hook that throws is caught where it is called; this is the same
|
|
5
|
+
* for one that rejects.
|
|
6
|
+
*/
|
|
7
|
+
export function ignoreRejection(result) {
|
|
8
|
+
if ((typeof result === 'object' || typeof result === 'function') &&
|
|
9
|
+
result !== null &&
|
|
10
|
+
typeof result.then === 'function') {
|
|
11
|
+
Promise.resolve(result).catch(() => undefined);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -5,12 +5,16 @@ export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SL
|
|
|
5
5
|
export type { HoursWindow, Weekday, WeeklyHours } from './weekly-hours.js';
|
|
6
6
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
7
7
|
export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
|
|
8
|
-
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
8
|
+
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
9
9
|
export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
|
|
10
10
|
export { createSlotlockNodeServer } from './node-server.js';
|
|
11
11
|
export type { SlotlockNodeListenOptions, SlotlockNodeServer, SlotlockNodeServerAddress, SlotlockNodeServerCloseResult, SlotlockNodeServerErrorPhase, SlotlockNodeServerFetchTarget, SlotlockNodeServerOptions, } from './node-server.js';
|
|
12
|
-
export { createSlotlockStoreAgentBackend, projectSlotlockCalendarEvent } from './agent-store-backend.js';
|
|
13
|
-
export type { SlotlockStoreAgentBackendOptions } from './agent-store-backend.js';
|
|
12
|
+
export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
|
|
13
|
+
export type { SlotlockStoreAgentBackendOptions, SlotlockStoreApplicationBackendOptions, } from './agent-store-backend.js';
|
|
14
|
+
export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, } from './application-api.js';
|
|
15
|
+
export type { SlotlockApplicationBackend, SlotlockApplicationInvocationContext, SlotlockApplicationOperation, SlotlockApplicationOperationDefinition, } from './application-api.js';
|
|
16
|
+
export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
17
|
+
export type { SlotlockMaintenanceResult, SlotlockMaintenanceScheduler, SlotlockMaintenanceSchedulerOptions, } from './maintenance.js';
|
|
14
18
|
export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
|
|
15
19
|
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
16
20
|
export type { SlotlockDashboard, SlotlockDashboardOptions, SlotlockDashboardResources, SlotlockDashboardState, } from './dashboard.js';
|
|
@@ -20,5 +24,5 @@ export type { ICalendarWorkerPool, ICalendarWorkerPoolOptions } from './ical-wor
|
|
|
20
24
|
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseTrustedICalendarEvents, } from './sync.js';
|
|
21
25
|
export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, ITipMethod, } from './sync.js';
|
|
22
26
|
export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
23
|
-
export { calendarEventRollingHorizon, createSlotlockStore, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MAX_EVENT_DURATION_DAYS, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantStore, } from './store.js';
|
|
27
|
+
export { calendarEventRollingHorizon, createSlotlockStore, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MAX_EVENT_DURATION_DAYS, type SlotlockHoldTenantOptions, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantStore, } from './store.js';
|
|
24
28
|
export type { AcquireHoldResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockHold, SlotlockCalendarEvent, SlotlockReservation, SlotlockResource, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, WeeklyAvailabilityRule, } from './types.js';
|
package/index.js
CHANGED
|
@@ -7,9 +7,11 @@ export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
|
|
|
7
7
|
export { expandRules } from './rules.js';
|
|
8
8
|
export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
|
|
9
9
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
10
|
-
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
10
|
+
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
11
11
|
export { createSlotlockNodeServer } from './node-server.js';
|
|
12
|
-
export { createSlotlockStoreAgentBackend, projectSlotlockCalendarEvent } from './agent-store-backend.js';
|
|
12
|
+
export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
|
|
13
|
+
export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, } from './application-api.js';
|
|
14
|
+
export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
13
15
|
export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
|
|
14
16
|
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
15
17
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
package/maintenance.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Sql } from 'postgres';
|
|
2
|
+
import { type SlotlockStore } from './store.js';
|
|
3
|
+
export interface SlotlockMaintenanceResult {
|
|
4
|
+
/** Recurring events whose stored occurrences now reach the horizon. */
|
|
5
|
+
extended: number;
|
|
6
|
+
/** Series that could not extend because an occurrence would overlap a booking. */
|
|
7
|
+
conflicts: number;
|
|
8
|
+
/** Series over the expansion budget; they keep their old coverage. */
|
|
9
|
+
refused: number;
|
|
10
|
+
/** Idempotency commands and tombstones deleted. */
|
|
11
|
+
pruned: number;
|
|
12
|
+
/** More was left than one pass does: run another. */
|
|
13
|
+
capped: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then prune
|
|
17
|
+
* past the retention window, each in at most `batches` batches (1-100, default 10), every batch its
|
|
18
|
+
* own transaction. Aborting `signal` stops it between batches with the signal's reason.
|
|
19
|
+
*/
|
|
20
|
+
export declare function maintainSlotlockTenant(store: SlotlockStore, tenantRef: string, options?: {
|
|
21
|
+
batches?: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}): Promise<SlotlockMaintenanceResult>;
|
|
24
|
+
/**
|
|
25
|
+
* Run `work` holding the tenant's maintenance lock on a connection of its own, or return
|
|
26
|
+
* `undefined` without running it when another server holds the lock.
|
|
27
|
+
*/
|
|
28
|
+
export declare function withSlotlockMaintenanceLock<T>(sql: Sql, tenantRef: string, work: () => Promise<T>): Promise<T | undefined>;
|
|
29
|
+
export interface SlotlockMaintenanceScheduler {
|
|
30
|
+
/**
|
|
31
|
+
* A tenant the server just served. Unless it was maintained within the interval, it is queued
|
|
32
|
+
* now, and from then on maintained every interval with the others.
|
|
33
|
+
*/
|
|
34
|
+
touch(tenantRef: string): void;
|
|
35
|
+
/** Stop: no new pass starts, and the one running finishes first. */
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export interface SlotlockMaintenanceSchedulerOptions {
|
|
39
|
+
store: SlotlockStore;
|
|
40
|
+
sql: Sql;
|
|
41
|
+
/** Tenants maintained from the start, whether or not a key of theirs calls. */
|
|
42
|
+
tenants: readonly string[];
|
|
43
|
+
/** How often each tenant is maintained, at most a day. */
|
|
44
|
+
intervalMs: number;
|
|
45
|
+
/** Tenants remembered; the one served longest ago is forgotten first. Default 10,000. */
|
|
46
|
+
maxTenants?: number;
|
|
47
|
+
/** Batches of each task per pass (maintainSlotlockTenant). */
|
|
48
|
+
batches?: number;
|
|
49
|
+
/** A pass that ran: counts only, never the tenant. Not awaited; what it throws is ignored. */
|
|
50
|
+
onPass?: (result: SlotlockMaintenanceResult) => void;
|
|
51
|
+
/**
|
|
52
|
+
* A pass that failed. The scheduler carries on with the next tenant. Not awaited; what it throws,
|
|
53
|
+
* or its promise rejects with, is ignored.
|
|
54
|
+
*/
|
|
55
|
+
onError?: (error: unknown) => void;
|
|
56
|
+
}
|
|
57
|
+
export declare function startSlotlockMaintenance(options: SlotlockMaintenanceSchedulerOptions): SlotlockMaintenanceScheduler;
|
package/maintenance.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { ignoreRejection } from './hooks.js';
|
|
2
|
+
import { calendarEventRollingHorizon } from './store.js';
|
|
3
|
+
/**
|
|
4
|
+
* One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then prune
|
|
5
|
+
* past the retention window, each in at most `batches` batches (1-100, default 10), every batch its
|
|
6
|
+
* own transaction. Aborting `signal` stops it between batches with the signal's reason.
|
|
7
|
+
*/
|
|
8
|
+
export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
9
|
+
const batches = options.batches ?? 10;
|
|
10
|
+
if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
|
|
11
|
+
throw new RangeError('batches must be a whole number from 1 to 100');
|
|
12
|
+
}
|
|
13
|
+
const window = calendarEventRollingHorizon();
|
|
14
|
+
let extended = 0;
|
|
15
|
+
let conflicts = 0;
|
|
16
|
+
let refused = 0;
|
|
17
|
+
let rollCapped = true;
|
|
18
|
+
let after = null;
|
|
19
|
+
for (let batch = 0; batch < batches; batch++) {
|
|
20
|
+
options.signal?.throwIfAborted();
|
|
21
|
+
const from = after;
|
|
22
|
+
const roll = await store.withTenant(tenantRef, (tenant) => tenant.rollCalendarEventHorizon({ tenantRef, window, ...(from ? { after: from } : {}) }));
|
|
23
|
+
extended += roll.extended;
|
|
24
|
+
conflicts += roll.conflicts;
|
|
25
|
+
refused += roll.refused;
|
|
26
|
+
after = roll.next;
|
|
27
|
+
if (!after) {
|
|
28
|
+
rollCapped = false;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let pruned = 0;
|
|
33
|
+
let pruneCapped = true;
|
|
34
|
+
for (let batch = 0; batch < batches; batch++) {
|
|
35
|
+
options.signal?.throwIfAborted();
|
|
36
|
+
const prune = await store.withTenant(tenantRef, (tenant) => tenant.pruneCalendarEventRetention({ tenantRef }));
|
|
37
|
+
pruned += prune.commandsDeleted + prune.tombstonesDeleted;
|
|
38
|
+
if (!prune.hasMore) {
|
|
39
|
+
pruneCapped = false;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { extended, conflicts, refused, pruned, capped: rollCapped || pruneCapped };
|
|
44
|
+
}
|
|
45
|
+
/** The advisory lock key for one tenant's maintenance (64-bit, from the tenant reference). */
|
|
46
|
+
const LOCK_NAMESPACE = 'slotlock.maintenance:';
|
|
47
|
+
/**
|
|
48
|
+
* Run `work` holding the tenant's maintenance lock on a connection of its own, or return
|
|
49
|
+
* `undefined` without running it when another server holds the lock.
|
|
50
|
+
*/
|
|
51
|
+
export async function withSlotlockMaintenanceLock(sql, tenantRef, work) {
|
|
52
|
+
const reserved = await sql.reserve();
|
|
53
|
+
try {
|
|
54
|
+
const key = `${LOCK_NAMESPACE}${tenantRef}`;
|
|
55
|
+
const [row] = await reserved `
|
|
56
|
+
SELECT pg_catalog.pg_try_advisory_lock(pg_catalog.hashtextextended(${key}, 0)) AS locked`;
|
|
57
|
+
if (!row?.locked)
|
|
58
|
+
return undefined;
|
|
59
|
+
try {
|
|
60
|
+
return await work();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await reserved `SELECT pg_catalog.pg_advisory_unlock(pg_catalog.hashtextextended(${key}, 0))`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
reserved.release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const DAY_MS = 86_400_000;
|
|
71
|
+
export function startSlotlockMaintenance(options) {
|
|
72
|
+
const { intervalMs } = options;
|
|
73
|
+
if (!(Number.isSafeInteger(intervalMs) && intervalMs >= 1_000 && intervalMs <= DAY_MS)) {
|
|
74
|
+
throw new RangeError('intervalMs must be from one second to one day');
|
|
75
|
+
}
|
|
76
|
+
const maxTenants = options.maxTenants ?? 10_000;
|
|
77
|
+
/** Configured tenants, never forgotten: tenant reference to when its last pass finished (0: never). */
|
|
78
|
+
const pinned = new Map(options.tenants.map((tenantRef) => [tenantRef, 0]));
|
|
79
|
+
/** Served tenants, in the order last served; the oldest is forgotten past `maxTenants`. */
|
|
80
|
+
const served = new Map();
|
|
81
|
+
const queue = [];
|
|
82
|
+
/** Tenants waiting or in a pass: a tenant served during its own pass is not queued again. */
|
|
83
|
+
const queued = new Set();
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
let working = null;
|
|
86
|
+
const lastPass = (tenantRef) => pinned.get(tenantRef) ?? served.get(tenantRef);
|
|
87
|
+
const recordPass = (tenantRef, finishedAt) => {
|
|
88
|
+
if (pinned.has(tenantRef))
|
|
89
|
+
pinned.set(tenantRef, finishedAt);
|
|
90
|
+
else if (served.has(tenantRef))
|
|
91
|
+
served.set(tenantRef, finishedAt);
|
|
92
|
+
};
|
|
93
|
+
const due = (tenantRef) => Date.now() - (lastPass(tenantRef) ?? 0) >= intervalMs;
|
|
94
|
+
const enqueue = (tenantRef) => {
|
|
95
|
+
if (controller.signal.aborted || queued.has(tenantRef))
|
|
96
|
+
return;
|
|
97
|
+
queued.add(tenantRef);
|
|
98
|
+
queue.push(tenantRef);
|
|
99
|
+
if (!working)
|
|
100
|
+
working = drain();
|
|
101
|
+
};
|
|
102
|
+
const drain = async () => {
|
|
103
|
+
try {
|
|
104
|
+
for (;;) {
|
|
105
|
+
const tenantRef = queue.shift();
|
|
106
|
+
if (tenantRef === undefined || controller.signal.aborted)
|
|
107
|
+
return;
|
|
108
|
+
let again = false;
|
|
109
|
+
try {
|
|
110
|
+
const result = await withSlotlockMaintenanceLock(options.sql, tenantRef, () => maintainSlotlockTenant(options.store, tenantRef, {
|
|
111
|
+
...(options.batches !== undefined ? { batches: options.batches } : {}),
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
}));
|
|
114
|
+
recordPass(tenantRef, Date.now());
|
|
115
|
+
if (result) {
|
|
116
|
+
try {
|
|
117
|
+
ignoreRejection(options.onPass?.(result));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Reporting must never stop maintenance, or make a pass that ran look failed.
|
|
121
|
+
}
|
|
122
|
+
// A capped pass that got somewhere goes round again at once; one that only met series it
|
|
123
|
+
// cannot extend waits for the next interval rather than examine them again and again.
|
|
124
|
+
again = result.capped && result.extended + result.pruned > 0;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (controller.signal.aborted)
|
|
129
|
+
return;
|
|
130
|
+
recordPass(tenantRef, Date.now());
|
|
131
|
+
try {
|
|
132
|
+
ignoreRejection(options.onError?.(error));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Reporting must never stop maintenance.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
queued.delete(tenantRef);
|
|
140
|
+
}
|
|
141
|
+
if (again)
|
|
142
|
+
enqueue(tenantRef);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
working = null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const tick = () => {
|
|
150
|
+
for (const tenantRef of [...pinned.keys(), ...served.keys()]) {
|
|
151
|
+
if (due(tenantRef))
|
|
152
|
+
enqueue(tenantRef);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const timer = setInterval(tick, Math.min(intervalMs, 60_000));
|
|
156
|
+
timer.unref();
|
|
157
|
+
tick();
|
|
158
|
+
return {
|
|
159
|
+
touch(tenantRef) {
|
|
160
|
+
if (controller.signal.aborted || pinned.has(tenantRef)) {
|
|
161
|
+
if (pinned.has(tenantRef) && due(tenantRef))
|
|
162
|
+
enqueue(tenantRef);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const previous = served.get(tenantRef);
|
|
166
|
+
served.delete(tenantRef);
|
|
167
|
+
served.set(tenantRef, previous ?? 0);
|
|
168
|
+
while (served.size > maxTenants)
|
|
169
|
+
served.delete(served.keys().next().value);
|
|
170
|
+
if (due(tenantRef))
|
|
171
|
+
enqueue(tenantRef);
|
|
172
|
+
},
|
|
173
|
+
async close() {
|
|
174
|
+
clearInterval(timer);
|
|
175
|
+
controller.abort(new Error('Slotlock maintenance stopped'));
|
|
176
|
+
queue.length = 0;
|
|
177
|
+
queued.clear();
|
|
178
|
+
await working;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|