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.
@@ -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`. A step that does not
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
- * trailing buffer each slot keeps, and how a candidate start moves to a step. `null` when no start
72
- * in the window is allowed.
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
- window: Interval;
82
- bufferAfterMs: number;
83
- align?: (start: Date) => Date;
84
- } | null;
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
- function onStep(instant, stepMinutes, timezone) {
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`. A step that does not
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 < 4; 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 && !onStep(interval.start, rules.slotStepMinutes, params.timezone)) {
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
- * trailing buffer each slot keeps, and how a candidate start moves to a step. `null` when no start
116
- * in the window is allowed.
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
- let end = window.end.getTime();
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 = align ? align(from) : from;
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 step === undefined
147
- ? { window: searched, bufferAfterMs }
148
- : { window: searched, bufferAfterMs, align: (from) => nextStepStart(from, step, params.timezone) };
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
+ };
@@ -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). */