slotlock 0.5.2 → 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 +98 -0
- package/README.md +97 -18
- package/SPEC.md +10 -7
- package/agent-server.d.ts +38 -4
- package/agent-server.js +464 -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.js +2 -1
- 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/dashboard.d.ts +3 -1
- package/dashboard.js +98 -15
- package/ddl.d.ts +1 -1
- package/ddl.js +395 -4
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +7 -6
- package/index.js +5 -4
- package/maintenance.js +12 -10
- package/openapi.json +1284 -82
- package/package.json +1 -1
- package/self-host.js +8 -4
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +32 -3
- package/store.js +324 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +9 -2
- package/webhooks.js +6 -1
package/booking-rules.d.ts
CHANGED
|
@@ -14,6 +14,18 @@ export interface SlotlockBookingRules {
|
|
|
14
14
|
* (15: on the quarter hour): 1-1,440.
|
|
15
15
|
*/
|
|
16
16
|
slotStepMinutes?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Where the steps fall: this many minutes past each step from local midnight (with a 30-minute
|
|
19
|
+
* step, 15 starts bookings on :15 and :45): 0 to one less than the step. Without a step it is
|
|
20
|
+
* kept and does nothing.
|
|
21
|
+
*/
|
|
22
|
+
stepOffsetMinutes?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Minutes kept free before each booking made under the rule (preparation, travel), as its
|
|
25
|
+
* leading buffer: the database refuses a booking whose time, or trailing buffer, reaches into
|
|
26
|
+
* it. 0-1,440. A booking made before the rule, or imported, keeps none.
|
|
27
|
+
*/
|
|
28
|
+
bufferBeforeMinutes?: number;
|
|
17
29
|
/**
|
|
18
30
|
* Minutes kept free after each booking made under the rule (cleaning, handover, travel), as its
|
|
19
31
|
* trailing buffer, so the database refuses a booking that would start inside it: 0-1,440. A
|
|
@@ -42,19 +54,41 @@ export declare const SLOTLOCK_BOOKING_RULE_LIMITS: Readonly<{
|
|
|
42
54
|
readonly minNoticeMinutes: readonly [0, 525600];
|
|
43
55
|
readonly maxAdvanceDays: readonly [1, 3660];
|
|
44
56
|
readonly slotStepMinutes: readonly [1, number];
|
|
57
|
+
readonly stepOffsetMinutes: readonly [0, number];
|
|
58
|
+
readonly bufferBeforeMinutes: readonly [0, number];
|
|
45
59
|
readonly turnaroundMinutes: readonly [0, number];
|
|
46
60
|
readonly checkInMinutes: readonly [1, number];
|
|
47
61
|
}>;
|
|
48
62
|
/** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
|
|
49
63
|
export declare function parseBookingRules(value: unknown): SlotlockBookingRules;
|
|
64
|
+
/**
|
|
65
|
+
* The rules that apply to a resource, merged from its own and its pool's and tenant's defaults
|
|
66
|
+
* (each validated when it was set), read leniently: each field is kept when it is valid on its own
|
|
67
|
+
* and dropped (named in `dropped`) when not. No cross-field check: a merge may join a pool's offset
|
|
68
|
+
* to a resource's own shorter step, and the offset then applies modulo the step. A merge never
|
|
69
|
+
* reads as refusing every booking.
|
|
70
|
+
*/
|
|
71
|
+
export declare function parseEffectiveBookingRules(value: unknown): {
|
|
72
|
+
rules: SlotlockBookingRules;
|
|
73
|
+
dropped: string[];
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Default booking rules for a tenant or a pool from untrusted input: booking rules without
|
|
77
|
+
* `checkInMinutes`, which only a resource's own rules set (it releases the bookings made after it
|
|
78
|
+
* was set, and that moment is the resource's).
|
|
79
|
+
*/
|
|
80
|
+
export declare function parseBookingRuleDefaults(value: unknown): SlotlockBookingRules;
|
|
50
81
|
/** The trailing buffer a booking made under the rules keeps: the larger of its own and the rule's. */
|
|
51
82
|
export declare function bookingBufferAfterMs(rules: SlotlockBookingRules | undefined, ownMs?: number): number;
|
|
83
|
+
/** The leading buffer a booking made under the rules keeps. */
|
|
84
|
+
export declare function bookingBufferBeforeMs(rules: SlotlockBookingRules | undefined): number;
|
|
52
85
|
/**
|
|
53
|
-
* The first instant at or after `from` that starts on a step in `timezone
|
|
86
|
+
* The first instant at or after `from` that starts on a step in `timezone`, `offsetMinutes` past
|
|
87
|
+
* each multiple of the step from local midnight (0 to the step less one). A step that does not
|
|
54
88
|
* divide the day starts again at local midnight. Across a clock change it moves forward until the
|
|
55
89
|
* local time is on a step.
|
|
56
90
|
*/
|
|
57
|
-
export declare function nextStepStart(from: Date, stepMinutes: number, timezone: string): Date;
|
|
91
|
+
export declare function nextStepStart(from: Date, stepMinutes: number, timezone: string, offsetMinutes?: number): Date;
|
|
58
92
|
/**
|
|
59
93
|
* The rule a booking of `interval` made at `now` breaks, or null. `hours` are the resource's
|
|
60
94
|
* bookable hours (its own, or the default), read only when the rules enforce them.
|
|
@@ -66,10 +100,21 @@ export declare function bookingRuleBroken(params: {
|
|
|
66
100
|
interval: Interval;
|
|
67
101
|
now: Date;
|
|
68
102
|
}): SlotlockBookingRule | null;
|
|
103
|
+
/** How a search honours the rules (`bookingSearch`). */
|
|
104
|
+
export interface SlotlockBookingSearch {
|
|
105
|
+
/** The part of the asked window a booking made now may start in. */
|
|
106
|
+
window: Interval;
|
|
107
|
+
/** The trailing buffer each slot keeps. */
|
|
108
|
+
bufferAfterMs: number;
|
|
109
|
+
/** The leading buffer each slot keeps. */
|
|
110
|
+
bufferBeforeMs: number;
|
|
111
|
+
/** The first allowed start at or after a candidate. */
|
|
112
|
+
align?: (start: Date) => Date;
|
|
113
|
+
}
|
|
69
114
|
/**
|
|
70
115
|
* How a search honours the rules: the part of `window` a booking made at `now` may start in, the
|
|
71
|
-
*
|
|
72
|
-
*
|
|
116
|
+
* buffers each slot keeps, and how a candidate start moves to a step. `null` when no start in the
|
|
117
|
+
* window is allowed.
|
|
73
118
|
*/
|
|
74
119
|
export declare function bookingSearch(params: {
|
|
75
120
|
rules: SlotlockBookingRules | undefined;
|
|
@@ -77,8 +122,15 @@ export declare function bookingSearch(params: {
|
|
|
77
122
|
window: Interval;
|
|
78
123
|
now: Date;
|
|
79
124
|
bufferAfterMs?: number;
|
|
80
|
-
}):
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
125
|
+
}): SlotlockBookingSearch | null;
|
|
126
|
+
/**
|
|
127
|
+
* The time a search must read as free or busy: its window, widened by the buffers a slot in it
|
|
128
|
+
* keeps (before its first start and after its last end).
|
|
129
|
+
*/
|
|
130
|
+
export declare function bookingSearchReach(search: SlotlockBookingSearch): Interval;
|
|
131
|
+
/**
|
|
132
|
+
* Busy time as the slot times it rules out. A slot [s, s + d) keeping buffers occupies
|
|
133
|
+
* [s - before, s + d + after), so it meets busy [b, e) exactly when [s, s + d) meets
|
|
134
|
+
* [b - after, e + before): the database refuses the same booking.
|
|
135
|
+
*/
|
|
136
|
+
export declare function bookingSearchBlocked(busy: readonly Interval[], search: SlotlockBookingSearch): Interval[];
|
package/booking-rules.js
CHANGED
|
@@ -7,6 +7,8 @@ export const SLOTLOCK_BOOKING_RULE_LIMITS = Object.freeze({
|
|
|
7
7
|
minNoticeMinutes: [0, 525_600],
|
|
8
8
|
maxAdvanceDays: [1, 3_660],
|
|
9
9
|
slotStepMinutes: [1, DAY_MINUTES],
|
|
10
|
+
stepOffsetMinutes: [0, DAY_MINUTES - 1],
|
|
11
|
+
bufferBeforeMinutes: [0, DAY_MINUTES],
|
|
10
12
|
turnaroundMinutes: [0, DAY_MINUTES],
|
|
11
13
|
checkInMinutes: [1, DAY_MINUTES],
|
|
12
14
|
});
|
|
@@ -44,33 +46,96 @@ export function parseBookingRules(value) {
|
|
|
44
46
|
}
|
|
45
47
|
rules.requireCertain = input.requireCertain;
|
|
46
48
|
}
|
|
49
|
+
if (rules.stepOffsetMinutes !== undefined &&
|
|
50
|
+
rules.slotStepMinutes !== undefined &&
|
|
51
|
+
rules.stepOffsetMinutes >= rules.slotStepMinutes) {
|
|
52
|
+
throw new TypeError('Slotlock booking rule stepOffsetMinutes must be less than slotStepMinutes');
|
|
53
|
+
}
|
|
54
|
+
return rules;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The rules that apply to a resource, merged from its own and its pool's and tenant's defaults
|
|
58
|
+
* (each validated when it was set), read leniently: each field is kept when it is valid on its own
|
|
59
|
+
* and dropped (named in `dropped`) when not. No cross-field check: a merge may join a pool's offset
|
|
60
|
+
* to a resource's own shorter step, and the offset then applies modulo the step. A merge never
|
|
61
|
+
* reads as refusing every booking.
|
|
62
|
+
*/
|
|
63
|
+
export function parseEffectiveBookingRules(value) {
|
|
64
|
+
const rules = {};
|
|
65
|
+
const dropped = [];
|
|
66
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
67
|
+
return { rules, dropped: ['(not an object)'] };
|
|
68
|
+
}
|
|
69
|
+
for (const [key, field] of Object.entries(value)) {
|
|
70
|
+
if (key === 'enforceHours' || key === 'requireCertain') {
|
|
71
|
+
if (typeof field === 'boolean')
|
|
72
|
+
rules[key] = field;
|
|
73
|
+
else
|
|
74
|
+
dropped.push(key);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const limits = LIMITS[key];
|
|
78
|
+
if (limits === undefined ||
|
|
79
|
+
typeof field !== 'number' ||
|
|
80
|
+
!Number.isSafeInteger(field) ||
|
|
81
|
+
field < limits[0] ||
|
|
82
|
+
field > limits[1]) {
|
|
83
|
+
dropped.push(key);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
rules[key] = field;
|
|
87
|
+
}
|
|
88
|
+
return { rules, dropped };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Default booking rules for a tenant or a pool from untrusted input: booking rules without
|
|
92
|
+
* `checkInMinutes`, which only a resource's own rules set (it releases the bookings made after it
|
|
93
|
+
* was set, and that moment is the resource's).
|
|
94
|
+
*/
|
|
95
|
+
export function parseBookingRuleDefaults(value) {
|
|
96
|
+
const rules = parseBookingRules(value);
|
|
97
|
+
if (rules.checkInMinutes !== undefined) {
|
|
98
|
+
throw new TypeError('Slotlock default booking rules have no checkInMinutes: set it on the resource');
|
|
99
|
+
}
|
|
47
100
|
return rules;
|
|
48
101
|
}
|
|
49
102
|
/** The trailing buffer a booking made under the rules keeps: the larger of its own and the rule's. */
|
|
50
103
|
export function bookingBufferAfterMs(rules, ownMs = 0) {
|
|
51
104
|
return Math.max(ownMs, (rules?.turnaroundMinutes ?? 0) * MINUTE_MS);
|
|
52
105
|
}
|
|
106
|
+
/** The leading buffer a booking made under the rules keeps. */
|
|
107
|
+
export function bookingBufferBeforeMs(rules) {
|
|
108
|
+
return (rules?.bufferBeforeMinutes ?? 0) * MINUTE_MS;
|
|
109
|
+
}
|
|
110
|
+
/** The step's offset as it applies: none without a step, and always less than the step. */
|
|
111
|
+
function stepOffset(rules) {
|
|
112
|
+
const step = rules?.slotStepMinutes;
|
|
113
|
+
return step === undefined ? 0 : (rules?.stepOffsetMinutes ?? 0) % step;
|
|
114
|
+
}
|
|
53
115
|
/** Minutes past local midnight in `timezone`, with the fraction. */
|
|
54
116
|
function localMinutes(instant, timezone) {
|
|
55
117
|
const local = instant.getTime() + zonedOffsetMinutes(instant, timezone) * MINUTE_MS;
|
|
56
118
|
return (((local / MINUTE_MS) % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES;
|
|
57
119
|
}
|
|
58
|
-
|
|
120
|
+
/** Whether `instant` falls on a step: `offset` minutes past a multiple of the step, local time. */
|
|
121
|
+
function onStep(instant, stepMinutes, timezone, offset = 0) {
|
|
59
122
|
const minutes = localMinutes(instant, timezone);
|
|
60
|
-
return Number.isInteger(minutes) && minutes % stepMinutes === 0;
|
|
123
|
+
return Number.isInteger(minutes) && minutes >= offset && (minutes - offset) % stepMinutes === 0;
|
|
61
124
|
}
|
|
62
125
|
/**
|
|
63
|
-
* The first instant at or after `from` that starts on a step in `timezone
|
|
126
|
+
* The first instant at or after `from` that starts on a step in `timezone`, `offsetMinutes` past
|
|
127
|
+
* each multiple of the step from local midnight (0 to the step less one). A step that does not
|
|
64
128
|
* divide the day starts again at local midnight. Across a clock change it moves forward until the
|
|
65
129
|
* local time is on a step.
|
|
66
130
|
*/
|
|
67
|
-
export function nextStepStart(from, stepMinutes, timezone) {
|
|
131
|
+
export function nextStepStart(from, stepMinutes, timezone, offsetMinutes = 0) {
|
|
132
|
+
const offset = offsetMinutes % stepMinutes;
|
|
68
133
|
let candidate = from;
|
|
69
|
-
for (let attempt = 0; attempt <
|
|
70
|
-
if (onStep(candidate, stepMinutes, timezone))
|
|
134
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
135
|
+
if (onStep(candidate, stepMinutes, timezone, offset))
|
|
71
136
|
return candidate;
|
|
72
137
|
const minutes = localMinutes(candidate, timezone);
|
|
73
|
-
const next = Math.min(Math.ceil(minutes / stepMinutes + 1e-9) * stepMinutes, DAY_MINUTES);
|
|
138
|
+
const next = Math.min(offset + Math.max(0, Math.ceil((minutes - offset) / stepMinutes + 1e-9)) * stepMinutes, DAY_MINUTES);
|
|
74
139
|
const ahead = Math.max(next - minutes, 1 / 60_000);
|
|
75
140
|
candidate = new Date(Math.ceil(candidate.getTime() + ahead * MINUTE_MS));
|
|
76
141
|
}
|
|
@@ -92,7 +157,8 @@ export function bookingRuleBroken(params) {
|
|
|
92
157
|
start > now.getTime() + rules.maxAdvanceDays * DAY_MINUTES * MINUTE_MS) {
|
|
93
158
|
return 'max_advance';
|
|
94
159
|
}
|
|
95
|
-
if (rules.slotStepMinutes !== undefined &&
|
|
160
|
+
if (rules.slotStepMinutes !== undefined &&
|
|
161
|
+
!onStep(interval.start, rules.slotStepMinutes, params.timezone, stepOffset(rules))) {
|
|
96
162
|
return 'slot_step';
|
|
97
163
|
}
|
|
98
164
|
if (rules.enforceHours === true) {
|
|
@@ -112,30 +178,33 @@ export function bookingRuleBroken(params) {
|
|
|
112
178
|
}
|
|
113
179
|
/**
|
|
114
180
|
* How a search honours the rules: the part of `window` a booking made at `now` may start in, the
|
|
115
|
-
*
|
|
116
|
-
*
|
|
181
|
+
* buffers each slot keeps, and how a candidate start moves to a step. `null` when no start in the
|
|
182
|
+
* window is allowed.
|
|
117
183
|
*/
|
|
118
184
|
export function bookingSearch(params) {
|
|
119
185
|
const { rules, window, now } = params;
|
|
120
186
|
let start = window.start.getTime();
|
|
121
|
-
|
|
187
|
+
const end = window.end.getTime();
|
|
122
188
|
if (rules?.minNoticeMinutes !== undefined) {
|
|
123
189
|
start = Math.max(start, now.getTime() + rules.minNoticeMinutes * MINUTE_MS);
|
|
124
190
|
}
|
|
125
191
|
const bufferAfterMs = bookingBufferAfterMs(rules, params.bufferAfterMs ?? 0);
|
|
192
|
+
const bufferBeforeMs = bookingBufferBeforeMs(rules);
|
|
126
193
|
const step = rules?.slotStepMinutes;
|
|
194
|
+
const offset = stepOffset(rules);
|
|
195
|
+
const toStep = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone, offset);
|
|
127
196
|
const searched = { start: new Date(start), end: new Date(end) };
|
|
128
197
|
if (rules?.maxAdvanceDays !== undefined) {
|
|
129
198
|
// The latest allowed start bounds where a slot may start; it may still end after it.
|
|
130
199
|
const latestStart = now.getTime() + rules.maxAdvanceDays * DAY_MINUTES * MINUTE_MS;
|
|
131
200
|
if (latestStart < start || end <= start)
|
|
132
201
|
return null;
|
|
133
|
-
const align = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone);
|
|
134
202
|
return {
|
|
135
203
|
window: searched,
|
|
136
204
|
bufferAfterMs,
|
|
205
|
+
bufferBeforeMs,
|
|
137
206
|
align: (from) => {
|
|
138
|
-
const aligned =
|
|
207
|
+
const aligned = toStep ? toStep(from) : from;
|
|
139
208
|
// Past the latest start, no slot: move it beyond the window so it cannot fit.
|
|
140
209
|
return aligned.getTime() > latestStart ? new Date(end + 1) : aligned;
|
|
141
210
|
},
|
|
@@ -143,7 +212,28 @@ export function bookingSearch(params) {
|
|
|
143
212
|
}
|
|
144
213
|
if (end <= start)
|
|
145
214
|
return null;
|
|
146
|
-
return
|
|
147
|
-
? { window: searched, bufferAfterMs }
|
|
148
|
-
: { window: searched, bufferAfterMs, align:
|
|
215
|
+
return toStep === undefined
|
|
216
|
+
? { window: searched, bufferAfterMs, bufferBeforeMs }
|
|
217
|
+
: { window: searched, bufferAfterMs, bufferBeforeMs, align: toStep };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The time a search must read as free or busy: its window, widened by the buffers a slot in it
|
|
221
|
+
* keeps (before its first start and after its last end).
|
|
222
|
+
*/
|
|
223
|
+
export function bookingSearchReach(search) {
|
|
224
|
+
return {
|
|
225
|
+
start: new Date(search.window.start.getTime() - search.bufferBeforeMs),
|
|
226
|
+
end: new Date(search.window.end.getTime() + search.bufferAfterMs),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Busy time as the slot times it rules out. A slot [s, s + d) keeping buffers occupies
|
|
231
|
+
* [s - before, s + d + after), so it meets busy [b, e) exactly when [s, s + d) meets
|
|
232
|
+
* [b - after, e + before): the database refuses the same booking.
|
|
233
|
+
*/
|
|
234
|
+
export function bookingSearchBlocked(busy, search) {
|
|
235
|
+
return busy.map((interval) => ({
|
|
236
|
+
start: new Date(interval.start.getTime() - search.bufferAfterMs),
|
|
237
|
+
end: new Date(interval.end.getTime() + search.bufferBeforeMs),
|
|
238
|
+
}));
|
|
149
239
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** What a reader needs from a change: the calendar resource it touched, if any. */
|
|
2
|
+
export interface SlotlockAgentChange {
|
|
3
|
+
resourceId: string | null;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A tenant's change feed, as the agent server follows it (SlotlockEventFeed.listChanges, read under
|
|
7
|
+
* the tenant's row-level security). Every call names one tenant, the one whose subscribers are
|
|
8
|
+
* waiting, and must return that tenant's changes only.
|
|
9
|
+
*/
|
|
10
|
+
export interface SlotlockAgentChangeSource {
|
|
11
|
+
/** A position at or before every change that has not ended yet: where a new reader starts. */
|
|
12
|
+
head(tenantRef: string): Promise<string>;
|
|
13
|
+
listChanges(params: {
|
|
14
|
+
tenantRef: string;
|
|
15
|
+
after: string;
|
|
16
|
+
limit: number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
ok: true;
|
|
19
|
+
changes: readonly SlotlockAgentChange[];
|
|
20
|
+
next: string;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
code: string;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* Optional, for a custom source that learns of new changes itself: call `wake` whenever the
|
|
27
|
+
* tenant's feed may have grown, until the returned stop function is called. A wake carries nothing
|
|
28
|
+
* but itself, and reads stay at least SLOTLOCK_FEED_BUSY_MS apart. Without it (the store's own
|
|
29
|
+
* source) the feed is polled. If it throws or rejects, the reader polls instead.
|
|
30
|
+
*/
|
|
31
|
+
watch?(tenantRef: string, wake: () => void): Promise<() => unknown>;
|
|
32
|
+
}
|
|
33
|
+
/** One subscription's interest in its tenant's feed. */
|
|
34
|
+
export interface SlotlockChangeWatcher {
|
|
35
|
+
readonly resourceIds: ReadonlySet<string>;
|
|
36
|
+
/** Some of its resources changed (a change with no resource counts for every one). */
|
|
37
|
+
touched(): void;
|
|
38
|
+
/** The cursor expired: changes may have been missed, so every resource is announced once. */
|
|
39
|
+
resync(): void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* How soon a tenant's feed is read again after a read that found changes, in ms; also the least time
|
|
43
|
+
* between the starts of two reads of one tenant's feed, however often it is woken.
|
|
44
|
+
*/
|
|
45
|
+
export declare const SLOTLOCK_FEED_BUSY_MS = 250;
|
|
46
|
+
/** How often a quiet tenant's feed is read, in ms: the poll backs off to this from the busy rate. */
|
|
47
|
+
export declare const SLOTLOCK_FEED_IDLE_MS = 1000;
|
|
48
|
+
/**
|
|
49
|
+
* The readers of one server process. `join` adds a watcher to its tenant's reader (starting it) and
|
|
50
|
+
* returns the function that removes it (stopping the reader with its last watcher).
|
|
51
|
+
*/
|
|
52
|
+
export declare function createSlotlockChangeReaders(source: SlotlockAgentChangeSource, onError?: (error: unknown) => void): {
|
|
53
|
+
join(tenantRef: string, watcher: SlotlockChangeWatcher): () => void;
|
|
54
|
+
/** Tenants with a reader now. */
|
|
55
|
+
readonly size: number;
|
|
56
|
+
};
|
package/change-watch.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Following a tenant's change feed for live updates (RFC 0002 P1-5): one reader per tenant per
|
|
2
|
+
// server, shared by every open subscription of that tenant, that reads the feed by cursor and tells
|
|
3
|
+
// each subscription which of its resources a change touched. It polls the feed on a short adaptive
|
|
4
|
+
// interval: SLOTLOCK_FEED_BUSY_MS after a read that found changes, backing off to
|
|
5
|
+
// SLOTLOCK_FEED_IDLE_MS when quiet, so one tenant with open subscriptions costs one indexed query per
|
|
6
|
+
// interval. A reader knows only its own tenant: it reads that tenant's feed and tells that tenant's
|
|
7
|
+
// watchers. Its reads and callbacks run outside any request's async context.
|
|
8
|
+
import { AsyncResource } from 'node:async_hooks';
|
|
9
|
+
/** Changes read per page. */
|
|
10
|
+
const FEED_PAGE = 500;
|
|
11
|
+
/**
|
|
12
|
+
* How soon a tenant's feed is read again after a read that found changes, in ms; also the least time
|
|
13
|
+
* between the starts of two reads of one tenant's feed, however often it is woken.
|
|
14
|
+
*/
|
|
15
|
+
export const SLOTLOCK_FEED_BUSY_MS = 250;
|
|
16
|
+
/** How often a quiet tenant's feed is read, in ms: the poll backs off to this from the busy rate. */
|
|
17
|
+
export const SLOTLOCK_FEED_IDLE_MS = 1_000;
|
|
18
|
+
/** How often a quiet feed is read while a custom source's `watch` is listening, in ms. */
|
|
19
|
+
const FEED_LISTENING_IDLE_MS = 15_000;
|
|
20
|
+
function unref(timer) {
|
|
21
|
+
timer.unref?.();
|
|
22
|
+
return timer;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The readers of one server process. `join` adds a watcher to its tenant's reader (starting it) and
|
|
26
|
+
* returns the function that removes it (stopping the reader with its last watcher).
|
|
27
|
+
*/
|
|
28
|
+
export function createSlotlockChangeReaders(source, onError = () => { }) {
|
|
29
|
+
const readers = new Map();
|
|
30
|
+
// Created with the server, outside any request: a reader shared by many subscriptions must not run
|
|
31
|
+
// its reads and its watchers' callbacks in the async context of whichever request started it.
|
|
32
|
+
const scope = new AsyncResource('SlotlockChangeReader');
|
|
33
|
+
function schedule(tenantRef, reader, delay) {
|
|
34
|
+
if (reader.closed)
|
|
35
|
+
return;
|
|
36
|
+
clearTimeout(reader.timer);
|
|
37
|
+
reader.timer = unref(setTimeout(() => void read(tenantRef, reader), delay));
|
|
38
|
+
}
|
|
39
|
+
function stop(tenantRef, reader) {
|
|
40
|
+
reader.closed = true;
|
|
41
|
+
clearTimeout(reader.timer);
|
|
42
|
+
if (readers.get(tenantRef) === reader)
|
|
43
|
+
readers.delete(tenantRef);
|
|
44
|
+
try {
|
|
45
|
+
Promise.resolve(reader.stopWatch?.()).catch(onError);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
onError(error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function read(tenantRef, reader) {
|
|
52
|
+
if (reader.closed)
|
|
53
|
+
return;
|
|
54
|
+
if (reader.reading) {
|
|
55
|
+
reader.again = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const wait = reader.lastStart + SLOTLOCK_FEED_BUSY_MS - Date.now();
|
|
59
|
+
if (wait > 0) {
|
|
60
|
+
// Too soon after the last read began: read once when the spacing is up.
|
|
61
|
+
schedule(tenantRef, reader, wait);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
reader.lastStart = Date.now();
|
|
65
|
+
reader.reading = true;
|
|
66
|
+
clearTimeout(reader.timer);
|
|
67
|
+
const joined = [...reader.joined];
|
|
68
|
+
reader.joined.clear();
|
|
69
|
+
const idle = reader.listening ? FEED_LISTENING_IDLE_MS : SLOTLOCK_FEED_IDLE_MS;
|
|
70
|
+
let found = false;
|
|
71
|
+
try {
|
|
72
|
+
let touched = new Set();
|
|
73
|
+
let expired = false;
|
|
74
|
+
if (reader.cursor === undefined)
|
|
75
|
+
reader.cursor = await source.head(tenantRef);
|
|
76
|
+
// The reader's cursor moves only once every page is read and its watchers are about to be
|
|
77
|
+
// told: a page that fails leaves it where it was, so the next read sees those changes again.
|
|
78
|
+
let cursor = reader.cursor;
|
|
79
|
+
for (let page = 0; page < 20 && !reader.closed; page += 1) {
|
|
80
|
+
const result = await source.listChanges({
|
|
81
|
+
tenantRef,
|
|
82
|
+
after: cursor,
|
|
83
|
+
limit: FEED_PAGE,
|
|
84
|
+
});
|
|
85
|
+
if (!result.ok) {
|
|
86
|
+
// Too far behind (or a cursor no store knows): start again from now, and since changes
|
|
87
|
+
// may have been missed, announce every watched resource once.
|
|
88
|
+
cursor = await source.head(tenantRef);
|
|
89
|
+
expired = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
cursor = result.next;
|
|
93
|
+
for (const change of result.changes) {
|
|
94
|
+
if (touched === 'all')
|
|
95
|
+
break;
|
|
96
|
+
if (change.resourceId === null)
|
|
97
|
+
touched = 'all';
|
|
98
|
+
else
|
|
99
|
+
touched.add(change.resourceId);
|
|
100
|
+
}
|
|
101
|
+
if (result.changes.length > 0)
|
|
102
|
+
found = true;
|
|
103
|
+
if (result.changes.length < FEED_PAGE)
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (reader.closed)
|
|
107
|
+
return;
|
|
108
|
+
reader.cursor = cursor;
|
|
109
|
+
const told = new Set();
|
|
110
|
+
for (const watcher of reader.watchers) {
|
|
111
|
+
if (expired) {
|
|
112
|
+
watcher.resync();
|
|
113
|
+
told.add(watcher);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (touched === 'all' || [...watcher.resourceIds].some((id) => touched.has(id))) {
|
|
117
|
+
watcher.touched();
|
|
118
|
+
told.add(watcher);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// A watcher's first read was before this one began, so whatever committed in between is
|
|
122
|
+
// below the cursor now: it reads once more to see it.
|
|
123
|
+
for (const watcher of joined) {
|
|
124
|
+
if (!told.has(watcher) && reader.watchers.has(watcher))
|
|
125
|
+
watcher.touched();
|
|
126
|
+
}
|
|
127
|
+
reader.delay = found
|
|
128
|
+
? SLOTLOCK_FEED_BUSY_MS
|
|
129
|
+
: Math.min(Math.max(reader.delay * 2, SLOTLOCK_FEED_BUSY_MS), idle);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
onError(error);
|
|
133
|
+
for (const watcher of joined)
|
|
134
|
+
reader.joined.add(watcher);
|
|
135
|
+
reader.delay = idle;
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
reader.reading = false;
|
|
139
|
+
}
|
|
140
|
+
if (reader.closed)
|
|
141
|
+
return;
|
|
142
|
+
if (reader.again) {
|
|
143
|
+
reader.again = false;
|
|
144
|
+
schedule(tenantRef, reader, 0);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
schedule(tenantRef, reader, reader.delay);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function join(tenantRef, watcher) {
|
|
151
|
+
let reader = readers.get(tenantRef);
|
|
152
|
+
if (!reader) {
|
|
153
|
+
const created = {
|
|
154
|
+
watchers: new Set(),
|
|
155
|
+
joined: new Set(),
|
|
156
|
+
reading: false,
|
|
157
|
+
again: false,
|
|
158
|
+
closed: false,
|
|
159
|
+
listening: false,
|
|
160
|
+
delay: SLOTLOCK_FEED_BUSY_MS,
|
|
161
|
+
lastStart: 0,
|
|
162
|
+
};
|
|
163
|
+
reader = created;
|
|
164
|
+
readers.set(tenantRef, created);
|
|
165
|
+
const { watch } = source;
|
|
166
|
+
if (watch) {
|
|
167
|
+
// A wake comes in whatever context the source calls it from; the read runs in the reader's.
|
|
168
|
+
const wake = scope.bind(() => {
|
|
169
|
+
// A wake while quiet reads at once; a read under way reads again when it ends.
|
|
170
|
+
if (!created.closed)
|
|
171
|
+
void read(tenantRef, created);
|
|
172
|
+
});
|
|
173
|
+
// A source that throws, rejects or returns no stop function leaves the reader polling.
|
|
174
|
+
Promise.resolve()
|
|
175
|
+
.then(() => watch.call(source, tenantRef, wake))
|
|
176
|
+
.then((stopWatch) => {
|
|
177
|
+
if (typeof stopWatch !== 'function') {
|
|
178
|
+
throw new Error('slotlock: subscriptions.changes.watch returned no stop function');
|
|
179
|
+
}
|
|
180
|
+
if (created.closed) {
|
|
181
|
+
return Promise.resolve(stopWatch()).then(() => undefined);
|
|
182
|
+
}
|
|
183
|
+
created.stopWatch = stopWatch;
|
|
184
|
+
created.listening = true;
|
|
185
|
+
return undefined;
|
|
186
|
+
})
|
|
187
|
+
.catch(onError);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const current = reader;
|
|
191
|
+
current.watchers.add(watcher);
|
|
192
|
+
current.joined.add(watcher);
|
|
193
|
+
void read(tenantRef, current);
|
|
194
|
+
let left = false;
|
|
195
|
+
return () => {
|
|
196
|
+
if (left)
|
|
197
|
+
return;
|
|
198
|
+
left = true;
|
|
199
|
+
current.watchers.delete(watcher);
|
|
200
|
+
current.joined.delete(watcher);
|
|
201
|
+
if (current.watchers.size === 0)
|
|
202
|
+
stop(tenantRef, current);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
get size() {
|
|
207
|
+
return readers.size;
|
|
208
|
+
},
|
|
209
|
+
join(tenantRef, watcher) {
|
|
210
|
+
return scope.runInAsyncScope(() => join(tenantRef, watcher));
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
package/dashboard.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { type SlotlockRate } from './config.js';
|
|
|
5
5
|
import { type SlotlockBookingRules } from './booking-rules.js';
|
|
6
6
|
import { type CreateWebhookEndpointResult, type SlotlockChange, type SlotlockWebhookEndpoint } from './event-feed.js';
|
|
7
7
|
import type { SlotlockSql, SlotlockStore, SlotlockWaitlistEntry } from './store.js';
|
|
8
|
-
import type { Interval, SlotlockAgendaEntry, SlotlockResource, WeeklyAvailabilityRule } from './types.js';
|
|
8
|
+
import type { Interval, SlotlockAgendaEntry, SlotlockResource, SlotlockBookingRuleDefaults, WeeklyAvailabilityRule } from './types.js';
|
|
9
9
|
/** Resources one dashboard tenant may hold. */
|
|
10
10
|
export declare const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
|
|
11
11
|
/**
|
|
@@ -37,6 +37,8 @@ export interface SlotlockDashboardResources {
|
|
|
37
37
|
* null when it is not the tenant's. Invalid rules throw with `code: 'invalid_booking_rules'`.
|
|
38
38
|
*/
|
|
39
39
|
setRules?(tenantRef: string, id: string, rules: SlotlockBookingRules | null, actor?: string): Promise<SlotlockResource | null>;
|
|
40
|
+
/** The tenant's default booking rules and its pools', which a resource's own rules overlay. */
|
|
41
|
+
ruleDefaults?(tenantRef: string): Promise<SlotlockBookingRuleDefaults>;
|
|
40
42
|
/** Put one of the tenant's resources in a pool, or none: as setRules; `code: 'invalid_pool'`. */
|
|
41
43
|
setPool?(tenantRef: string, id: string, pool: string | null, actor?: string): Promise<SlotlockResource | null>;
|
|
42
44
|
/** The tenant's waitlist, oldest first, and leaving it (false when the entry is not the tenant's). */
|