slotlock 0.2.0 → 0.3.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 +330 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +277 -53
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +12 -3
- package/agent-server.js +108 -78
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +355 -36
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +14 -1
- package/application-api.js +274 -12
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +6 -0
- package/dashboard.js +17 -1
- package/ddl.d.ts +45 -3
- package/ddl.js +538 -12
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +5 -5
- package/index.js +3 -3
- package/maintenance.d.ts +15 -5
- package/maintenance.js +59 -27
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.js +10 -0
- package/rules.js +6 -3
- package/self-host.d.ts +22 -8
- package/self-host.js +307 -34
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +334 -24
- package/store.js +2029 -290
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +213 -9
package/agent-store-backend.js
CHANGED
|
@@ -5,18 +5,76 @@ import { expandRules } from './rules.js';
|
|
|
5
5
|
import { calendarEventRollingHorizon } from './store.js';
|
|
6
6
|
import { maintainSlotlockTenant } from './maintenance.js';
|
|
7
7
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
8
|
+
/**
|
|
9
|
+
* A write runs under READ COMMITTED whatever the role's default: a store given tenant limits (as
|
|
10
|
+
* `slotlock serve`'s is) counts it against them under the tenant's lock, which needs that level,
|
|
11
|
+
* and refuses it otherwise.
|
|
12
|
+
*/
|
|
13
|
+
const WRITE = { isolation: 'read committed' };
|
|
8
14
|
function assertActive(context) {
|
|
9
15
|
if (context.signal.aborted)
|
|
10
16
|
throw new SlotlockAgentOperationError('request_aborted', 503);
|
|
11
17
|
}
|
|
12
18
|
async function withTenant(store, context, callback, options) {
|
|
13
19
|
assertActive(context);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
let value;
|
|
21
|
+
try {
|
|
22
|
+
value = await store.withTenant(context.principal.tenantRef, callback, {
|
|
23
|
+
...options,
|
|
24
|
+
signal: context.signal,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const code = error?.code;
|
|
29
|
+
// A suspended tenant may read; a write it asks for is forbidden, not a server failure.
|
|
30
|
+
if (code === 'tenant_suspended')
|
|
31
|
+
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
32
|
+
// Waited past the store's lock timeout behind the tenant's own writes: retry later.
|
|
33
|
+
if (code === 'lock_timeout')
|
|
34
|
+
throw new SlotlockAgentOperationError('server_busy', 503);
|
|
35
|
+
assertActive(context);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
17
38
|
assertActive(context);
|
|
18
39
|
return value;
|
|
19
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Busy intervals one free/busy read or search may gather across all the resources it names: as many
|
|
43
|
+
* as the store reads for one resource. Without it, a request naming 100 resources at that limit, or
|
|
44
|
+
* one resource 100 times, did 100 times the work of one.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_REQUEST_BUSY_INTERVALS = 10_000;
|
|
47
|
+
/**
|
|
48
|
+
* Bookable windows one search may expand from its resources' availability rules in all: five
|
|
49
|
+
* resources of 50 daily rules over a year, or a hundred of two. Without it, a search of 100 such
|
|
50
|
+
* resources over a year expanded 1.8 million windows and held the process for seven seconds.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_REQUEST_RULE_WINDOWS = 100_000;
|
|
53
|
+
/** The resources a request names, each once, in the order first named. */
|
|
54
|
+
function distinctResourceIds(input) {
|
|
55
|
+
return [...new Set(input.resource_ids)];
|
|
56
|
+
}
|
|
57
|
+
/** Counts a request's busy intervals against MAX_REQUEST_BUSY_INTERVALS, refusing past it. */
|
|
58
|
+
function busyBudget() {
|
|
59
|
+
let left = MAX_REQUEST_BUSY_INTERVALS;
|
|
60
|
+
return (intervals) => {
|
|
61
|
+
left -= intervals;
|
|
62
|
+
if (left < 0)
|
|
63
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** The store's free/busy read, its refusal to read past its limit answered as the caller's to narrow. */
|
|
67
|
+
async function readFreeBusy(tenantStore, params) {
|
|
68
|
+
try {
|
|
69
|
+
return await tenantStore.getFreeBusy(params);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error?.code === 'calendar_work_limit') {
|
|
73
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
20
78
|
function interval(input) {
|
|
21
79
|
return { start: new Date(input.start), end: new Date(input.end) };
|
|
22
80
|
}
|
|
@@ -181,25 +239,41 @@ function parseEventCursor(value) {
|
|
|
181
239
|
throw new SlotlockAgentOperationError('invalid_cursor', 400);
|
|
182
240
|
}
|
|
183
241
|
}
|
|
184
|
-
|
|
185
|
-
|
|
242
|
+
/** The cursor after one row of a page ordered by start then id: an event, a hold or a booking. */
|
|
243
|
+
function encodeEventCursor(row) {
|
|
244
|
+
return Buffer.from(JSON.stringify({ start: row.start.toISOString(), id: row.id }), 'utf8').toString('base64url');
|
|
245
|
+
}
|
|
246
|
+
/** One page of rows read with one more than `limit`, and the cursor to the next when there is one. */
|
|
247
|
+
function page(rows, limit) {
|
|
248
|
+
const shown = rows.slice(0, limit);
|
|
249
|
+
const last = shown.at(-1);
|
|
250
|
+
return { shown, next: rows.length > limit && last ? encodeEventCursor(last) : null };
|
|
186
251
|
}
|
|
187
252
|
/**
|
|
188
253
|
* The caller's resource with this id, or `resource_not_found`. The tools accept any id an agent may
|
|
189
254
|
* send (an external reference such as `vehicle-42`, the nil UUID, a version 7 UUID). The store
|
|
190
255
|
* issues version 4 UUIDs (gen_random_uuid) and refuses an id that is not a version 1 to 5 UUID as an
|
|
191
256
|
* invalid identity, so such an id names no resource: it is not found, like a well-formed id that
|
|
192
|
-
* names nothing or another tenant's resource.
|
|
257
|
+
* names nothing or another tenant's resource. With `visibility`, a resource of the other is not
|
|
258
|
+
* found either: agents ask for `shared`, so a private resource is invisible to them.
|
|
193
259
|
*/
|
|
194
|
-
async function requireResource(tenantStore, tenantRef, resourceId) {
|
|
260
|
+
async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
|
|
195
261
|
if (!STORE_ID_PATTERN.test(resourceId)) {
|
|
196
262
|
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
197
263
|
}
|
|
198
|
-
const resource = await tenantStore.getResource({
|
|
264
|
+
const resource = await tenantStore.getResource({
|
|
265
|
+
tenantRef,
|
|
266
|
+
id: resourceId,
|
|
267
|
+
...(visibility !== undefined ? { visibility } : {}),
|
|
268
|
+
});
|
|
199
269
|
if (!resource)
|
|
200
270
|
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
201
271
|
return resource;
|
|
202
272
|
}
|
|
273
|
+
/** The caller's shared resource with this id: what an agent may see and book. */
|
|
274
|
+
function requireAgentResource(tenantStore, tenantRef, resourceId) {
|
|
275
|
+
return requireResource(tenantStore, tenantRef, resourceId, 'shared');
|
|
276
|
+
}
|
|
203
277
|
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
204
278
|
export function createSlotlockStoreAgentBackend(store, options) {
|
|
205
279
|
return {
|
|
@@ -210,6 +284,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
210
284
|
const resource = await tenantStore.getResource({
|
|
211
285
|
tenantRef: context.principal.tenantRef,
|
|
212
286
|
id: resourceId,
|
|
287
|
+
visibility: 'shared',
|
|
213
288
|
});
|
|
214
289
|
return resource
|
|
215
290
|
? { name: resource.externalRef ?? null, timezone: resource.timezone }
|
|
@@ -222,6 +297,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
222
297
|
const limit = input.limit;
|
|
223
298
|
const resources = await tenantStore.listResources({
|
|
224
299
|
tenantRef: context.principal.tenantRef,
|
|
300
|
+
visibility: 'shared',
|
|
225
301
|
limit: limit + 1,
|
|
226
302
|
...(cursor !== undefined ? { after: cursor } : {}),
|
|
227
303
|
});
|
|
@@ -238,13 +314,14 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
238
314
|
},
|
|
239
315
|
async getFreeBusy(context, input) {
|
|
240
316
|
const window = interval(input);
|
|
241
|
-
const resourceIds = input
|
|
317
|
+
const resourceIds = distinctResourceIds(input);
|
|
318
|
+
const charge = busyBudget();
|
|
242
319
|
return withTenant(store, context, async (tenantStore) => {
|
|
243
320
|
const resources = [];
|
|
244
321
|
for (const resourceId of resourceIds) {
|
|
245
|
-
const resource = await
|
|
322
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
246
323
|
const sources = await requiredSources(options, context, resource);
|
|
247
|
-
const result = await tenantStore
|
|
324
|
+
const result = await readFreeBusy(tenantStore, {
|
|
248
325
|
tenantRef: context.principal.tenantRef,
|
|
249
326
|
resourceId,
|
|
250
327
|
window,
|
|
@@ -253,6 +330,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
253
330
|
? { coverageMaxAgeMs: options.coverageMaxAgeMs }
|
|
254
331
|
: {}),
|
|
255
332
|
});
|
|
333
|
+
charge(result.busy.length);
|
|
256
334
|
resources.push({
|
|
257
335
|
resource_id: resourceId,
|
|
258
336
|
busy: result.busy.map((busy) => ({
|
|
@@ -267,15 +345,17 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
267
345
|
},
|
|
268
346
|
async findNextAvailable(context, input) {
|
|
269
347
|
const window = interval(input);
|
|
270
|
-
const resourceIds = input
|
|
348
|
+
const resourceIds = distinctResourceIds(input);
|
|
271
349
|
const durationMs = input.duration_minutes * 60_000;
|
|
350
|
+
const charge = busyBudget();
|
|
351
|
+
let windowsLeft = MAX_REQUEST_RULE_WINDOWS;
|
|
272
352
|
return withTenant(store, context, async (tenantStore) => {
|
|
273
353
|
let best = null;
|
|
274
354
|
let incomplete = false;
|
|
275
355
|
for (const resourceId of resourceIds) {
|
|
276
|
-
const resource = await
|
|
356
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
277
357
|
const sources = await requiredSources(options, context, resource);
|
|
278
|
-
const freeBusy = await tenantStore
|
|
358
|
+
const freeBusy = await readFreeBusy(tenantStore, {
|
|
279
359
|
tenantRef: context.principal.tenantRef,
|
|
280
360
|
resourceId,
|
|
281
361
|
window,
|
|
@@ -284,16 +364,17 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
284
364
|
? { coverageMaxAgeMs: options.coverageMaxAgeMs }
|
|
285
365
|
: {}),
|
|
286
366
|
});
|
|
367
|
+
charge(freeBusy.busy.length);
|
|
287
368
|
if (freeBusy.coverage.state !== 'complete') {
|
|
288
369
|
incomplete = true;
|
|
289
370
|
continue;
|
|
290
371
|
}
|
|
291
372
|
const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
});
|
|
373
|
+
const windows = expandRules(rules, window, resource.timezone);
|
|
374
|
+
windowsLeft -= windows.length;
|
|
375
|
+
if (windowsLeft < 0)
|
|
376
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
377
|
+
const candidate = findNextAvailable({ busy: freeBusy.busy, windows, durationMs });
|
|
297
378
|
if (candidate &&
|
|
298
379
|
(!best ||
|
|
299
380
|
candidate.start.getTime() < best.interval.start.getTime() ||
|
|
@@ -317,7 +398,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
317
398
|
async createEvent(context, rawInput) {
|
|
318
399
|
const input = rawInput;
|
|
319
400
|
return withTenant(store, context, async (tenantStore) => {
|
|
320
|
-
await
|
|
401
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, input.resource_id);
|
|
321
402
|
const recurrence = recurrenceFromInput(input.recurrence_rule, input.recurrence_exceptions);
|
|
322
403
|
const content = {
|
|
323
404
|
start: new Date(input.starts_at),
|
|
@@ -361,7 +442,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
361
442
|
if (!created)
|
|
362
443
|
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
363
444
|
return { event: projectSlotlockCalendarEvent(created), replayed: result.idempotent };
|
|
364
|
-
});
|
|
445
|
+
}, WRITE);
|
|
365
446
|
},
|
|
366
447
|
async getEvent(context, input) {
|
|
367
448
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -377,13 +458,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
377
458
|
},
|
|
378
459
|
async listEvents(context, input) {
|
|
379
460
|
const window = interval(input);
|
|
380
|
-
const resourceIds = input
|
|
461
|
+
const resourceIds = distinctResourceIds(input);
|
|
381
462
|
const limit = input.limit;
|
|
382
463
|
const after = parseEventCursor(input.cursor);
|
|
383
464
|
return withTenant(store, context, async (tenantStore) => {
|
|
384
465
|
const combined = [];
|
|
385
466
|
for (const resourceId of resourceIds) {
|
|
386
|
-
await
|
|
467
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
387
468
|
combined.push(...(await tenantStore.listCalendarEvents({
|
|
388
469
|
tenantRef: context.principal.tenantRef,
|
|
389
470
|
ownerRef: agentOwnerRef(context),
|
|
@@ -404,6 +485,11 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
404
485
|
},
|
|
405
486
|
async updateEvent(context, rawInput) {
|
|
406
487
|
const input = rawInput;
|
|
488
|
+
// No event is on a resource the store never issued (an external reference, say).
|
|
489
|
+
if (input.expected_resource_id !== undefined &&
|
|
490
|
+
!STORE_ID_PATTERN.test(input.expected_resource_id)) {
|
|
491
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
492
|
+
}
|
|
407
493
|
return withTenant(store, context, async (tenantStore) => {
|
|
408
494
|
const current = await tenantStore.getCalendarEvent({
|
|
409
495
|
tenantRef: context.principal.tenantRef,
|
|
@@ -414,7 +500,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
414
500
|
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
415
501
|
const resourceId = input.resource_id ?? current.resourceId;
|
|
416
502
|
if (resourceId !== current.resourceId) {
|
|
417
|
-
await
|
|
503
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
418
504
|
}
|
|
419
505
|
const recurrence = input.recurrence_rule === null
|
|
420
506
|
? undefined
|
|
@@ -473,6 +559,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
473
559
|
tenantRef: context.principal.tenantRef,
|
|
474
560
|
ownerRef: agentOwnerRef(context),
|
|
475
561
|
externalRef: current.externalRef,
|
|
562
|
+
// The caller's fence when it sent one, checked under the event's lock; otherwise the
|
|
563
|
+
// resource just read, so a move between that read and the write is not overwritten.
|
|
564
|
+
expectedResourceId: input.expected_resource_id ?? current.resourceId,
|
|
476
565
|
resourceId,
|
|
477
566
|
expectedRevision: input.expected_revision,
|
|
478
567
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
@@ -490,7 +579,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
490
579
|
if (!updated)
|
|
491
580
|
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
492
581
|
return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
|
|
493
|
-
});
|
|
582
|
+
}, WRITE);
|
|
494
583
|
},
|
|
495
584
|
async deleteEvent(context, input) {
|
|
496
585
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -506,6 +595,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
506
595
|
tenantRef: context.principal.tenantRef,
|
|
507
596
|
ownerRef: agentOwnerRef(context),
|
|
508
597
|
externalRef,
|
|
598
|
+
expectedResourceId: current.resourceId,
|
|
509
599
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
510
600
|
expectedRevision: input.expected_revision,
|
|
511
601
|
});
|
|
@@ -517,7 +607,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
517
607
|
deleted: true,
|
|
518
608
|
replayed: result.idempotent,
|
|
519
609
|
};
|
|
520
|
-
});
|
|
610
|
+
}, WRITE);
|
|
521
611
|
},
|
|
522
612
|
};
|
|
523
613
|
}
|
|
@@ -526,8 +616,9 @@ function storeRefusal(error) {
|
|
|
526
616
|
const code = error?.code;
|
|
527
617
|
if (typeof code !== 'string')
|
|
528
618
|
return error;
|
|
529
|
-
if (code === 'resource_limit_reached')
|
|
619
|
+
if (code === 'resource_limit_reached' || code === 'resource_visibility_conflict') {
|
|
530
620
|
return new SlotlockAgentOperationError(code, 409);
|
|
621
|
+
}
|
|
531
622
|
if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
|
|
532
623
|
return new SlotlockAgentOperationError(code, 400);
|
|
533
624
|
}
|
|
@@ -574,6 +665,17 @@ function resourceOutput(resource) {
|
|
|
574
665
|
external_ref: resource.externalRef,
|
|
575
666
|
timezone: resource.timezone,
|
|
576
667
|
availability: rulesOutput(resource.availabilityRules),
|
|
668
|
+
visibility: resource.visibility ?? 'shared',
|
|
669
|
+
archived_at: resource.archivedAt?.toISOString() ?? null,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
|
|
673
|
+
function tenantEventOutput(event) {
|
|
674
|
+
return {
|
|
675
|
+
...projectSlotlockCalendarEvent(event),
|
|
676
|
+
id: event.id,
|
|
677
|
+
event_ref: event.externalRef,
|
|
678
|
+
owner_ref: event.ownerRef,
|
|
577
679
|
};
|
|
578
680
|
}
|
|
579
681
|
function holdOutput(hold) {
|
|
@@ -599,6 +701,26 @@ function reservationOutput(reservation) {
|
|
|
599
701
|
source: reservation.source,
|
|
600
702
|
};
|
|
601
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* The namespace an application caller's idempotency keys live in: a digest of its tenant and
|
|
706
|
+
* subject, so the authenticated subject never lands in a durable row.
|
|
707
|
+
*/
|
|
708
|
+
function applicationPrincipalRef(context) {
|
|
709
|
+
return `application:${createHash('sha256')
|
|
710
|
+
.update(`${context.principal.tenantRef}\0${context.principal.subject}`)
|
|
711
|
+
.digest('hex')}`;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* An application caller's event command key: its tenant, subject and own key, digested, so one
|
|
715
|
+
* caller's key never replays another's command and neither the subject nor the raw key is stored.
|
|
716
|
+
*/
|
|
717
|
+
function applicationCommandKey(context, idempotencyKey) {
|
|
718
|
+
return `application-command:${createHash('sha256')
|
|
719
|
+
.update(`${context.principal.tenantRef}\0${context.principal.subject}\0${idempotencyKey}`)
|
|
720
|
+
.digest('hex')}`;
|
|
721
|
+
}
|
|
722
|
+
/** The most busy intervals one slotlock_list_busy answers, as one free/busy request reads. */
|
|
723
|
+
const MAX_LISTED_BUSY = MAX_REQUEST_BUSY_INTERVALS;
|
|
602
724
|
/**
|
|
603
725
|
* Serve the application API (application-api.ts) from the production store, in the principal's
|
|
604
726
|
* tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
|
|
@@ -613,6 +735,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
613
735
|
if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
|
|
614
736
|
throw new RangeError('maintenanceBatches must be a whole number from 1 to 100');
|
|
615
737
|
}
|
|
738
|
+
const tenantErasure = options.tenantErasure === true;
|
|
616
739
|
const tenantOf = (context) => context.principal.tenantRef;
|
|
617
740
|
return {
|
|
618
741
|
async createResource(context, input) {
|
|
@@ -623,6 +746,9 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
623
746
|
tenantRef: tenantOf(context),
|
|
624
747
|
externalRef: input.external_ref,
|
|
625
748
|
timezone: input.timezone,
|
|
749
|
+
...(input.visibility !== undefined
|
|
750
|
+
? { visibility: input.visibility }
|
|
751
|
+
: {}),
|
|
626
752
|
...(max !== undefined ? { maxTenantResources: max } : {}),
|
|
627
753
|
});
|
|
628
754
|
return { resource: resourceOutput(resource) };
|
|
@@ -637,6 +763,59 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
637
763
|
resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
|
|
638
764
|
}));
|
|
639
765
|
},
|
|
766
|
+
async archiveResource(context, input) {
|
|
767
|
+
const id = input.resource_id;
|
|
768
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
769
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
770
|
+
return withTenant(store, context, async (tenant) => {
|
|
771
|
+
const resource = await tenant.archiveResource({ tenantRef: tenantOf(context), id });
|
|
772
|
+
if (!resource)
|
|
773
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
774
|
+
return { resource: resourceOutput(resource) };
|
|
775
|
+
}, WRITE);
|
|
776
|
+
},
|
|
777
|
+
async restoreResource(context, input) {
|
|
778
|
+
const id = input.resource_id;
|
|
779
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
780
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
781
|
+
return withTenant(store, context, async (tenant) => {
|
|
782
|
+
const resource = await tenant.restoreResource({ tenantRef: tenantOf(context), id });
|
|
783
|
+
if (!resource)
|
|
784
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
785
|
+
return { resource: resourceOutput(resource) };
|
|
786
|
+
}, WRITE);
|
|
787
|
+
},
|
|
788
|
+
async deleteResource(context, input) {
|
|
789
|
+
const id = input.resource_id;
|
|
790
|
+
// An id the store never issues names no resource: there is nothing to delete.
|
|
791
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
792
|
+
return { deleted: false };
|
|
793
|
+
return withTenant(store, context, async (tenant) => {
|
|
794
|
+
const result = await tenant.deleteResource({ tenantRef: tenantOf(context), id });
|
|
795
|
+
if (!result.ok)
|
|
796
|
+
throw applicationRefusal(result.code);
|
|
797
|
+
return { deleted: result.deleted };
|
|
798
|
+
}, WRITE);
|
|
799
|
+
},
|
|
800
|
+
async listBusy(context, input) {
|
|
801
|
+
return withTenant(store, context, async (tenant) => {
|
|
802
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
803
|
+
// One row past the cap is enough to refuse: the rest is never read.
|
|
804
|
+
const busy = await tenant.listBusy(resource.id, interval(input), {
|
|
805
|
+
tenantRef: tenantOf(context),
|
|
806
|
+
limit: MAX_LISTED_BUSY + 1,
|
|
807
|
+
});
|
|
808
|
+
if (busy.length > MAX_LISTED_BUSY) {
|
|
809
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
busy: busy.map((occupied) => ({
|
|
813
|
+
start: occupied.start.toISOString(),
|
|
814
|
+
end: occupied.end.toISOString(),
|
|
815
|
+
})),
|
|
816
|
+
};
|
|
817
|
+
});
|
|
818
|
+
},
|
|
640
819
|
async setResourceAvailability(context, input) {
|
|
641
820
|
const rules = rulesInput(input.rules);
|
|
642
821
|
const id = input.resource_id;
|
|
@@ -675,15 +854,51 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
675
854
|
return withTenant(store, context, async (tenant) => {
|
|
676
855
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
677
856
|
const result = await tenant.acquireHold({
|
|
857
|
+
tenantRef: tenantOf(context),
|
|
678
858
|
resourceId: resource.id,
|
|
679
859
|
start: new Date(input.start),
|
|
680
860
|
end: new Date(input.end),
|
|
681
861
|
ttlMs: input.ttl_seconds * 1_000,
|
|
682
862
|
...(input.source !== undefined ? { source: input.source } : {}),
|
|
863
|
+
...(input.buffer_after_minutes !== undefined
|
|
864
|
+
? { bufferAfterMs: input.buffer_after_minutes * 60_000 }
|
|
865
|
+
: {}),
|
|
866
|
+
// A retried request with the caller's key answers the hold the first one made.
|
|
867
|
+
...(input.idempotency_key !== undefined
|
|
868
|
+
? {
|
|
869
|
+
idempotencyKey: input.idempotency_key,
|
|
870
|
+
principalRef: applicationPrincipalRef(context),
|
|
871
|
+
}
|
|
872
|
+
: {}),
|
|
683
873
|
});
|
|
684
874
|
if (!result.ok)
|
|
685
875
|
throw applicationRefusal(result.code);
|
|
686
876
|
return { hold: holdOutput(result.hold) };
|
|
877
|
+
}, WRITE);
|
|
878
|
+
},
|
|
879
|
+
async getHold(context, input) {
|
|
880
|
+
const id = input.hold_id;
|
|
881
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
882
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
883
|
+
return withTenant(store, context, async (tenant) => {
|
|
884
|
+
const hold = await tenant.getHold({ tenantRef: tenantOf(context), holdId: id });
|
|
885
|
+
if (!hold)
|
|
886
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
887
|
+
return { hold: holdOutput(hold) };
|
|
888
|
+
});
|
|
889
|
+
},
|
|
890
|
+
async listHolds(context, input) {
|
|
891
|
+
const limit = input.limit;
|
|
892
|
+
const after = parseEventCursor(input.cursor);
|
|
893
|
+
return withTenant(store, context, async (tenant) => {
|
|
894
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
895
|
+
const { shown, next } = page(await tenant.listHolds({
|
|
896
|
+
tenantRef: tenantOf(context),
|
|
897
|
+
resourceId: resource.id,
|
|
898
|
+
limit: limit + 1,
|
|
899
|
+
...(after ? { after } : {}),
|
|
900
|
+
}), limit);
|
|
901
|
+
return { holds: shown.map(holdOutput), next_cursor: next };
|
|
687
902
|
});
|
|
688
903
|
},
|
|
689
904
|
async confirmHold(context, input) {
|
|
@@ -695,7 +910,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
695
910
|
if (!result.ok)
|
|
696
911
|
throw applicationRefusal(result.code);
|
|
697
912
|
return { reservation: reservationOutput(result.reservation) };
|
|
698
|
-
});
|
|
913
|
+
}, WRITE);
|
|
699
914
|
},
|
|
700
915
|
async releaseHold(context, input) {
|
|
701
916
|
const id = input.hold_id;
|
|
@@ -706,6 +921,32 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
706
921
|
released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
|
|
707
922
|
}));
|
|
708
923
|
},
|
|
924
|
+
async getReservation(context, input) {
|
|
925
|
+
return withTenant(store, context, async (tenant) => {
|
|
926
|
+
const reservation = await tenant.getReservation({
|
|
927
|
+
tenantRef: tenantOf(context),
|
|
928
|
+
externalRef: input.reservation_ref,
|
|
929
|
+
});
|
|
930
|
+
if (!reservation)
|
|
931
|
+
throw new SlotlockAgentOperationError('reservation_not_found', 404);
|
|
932
|
+
return { reservation: reservationOutput(reservation) };
|
|
933
|
+
});
|
|
934
|
+
},
|
|
935
|
+
async listReservations(context, input) {
|
|
936
|
+
const limit = input.limit;
|
|
937
|
+
const after = parseEventCursor(input.cursor);
|
|
938
|
+
return withTenant(store, context, async (tenant) => {
|
|
939
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
940
|
+
const { shown, next } = page(await tenant.listReservations({
|
|
941
|
+
tenantRef: tenantOf(context),
|
|
942
|
+
resourceId: resource.id,
|
|
943
|
+
window: interval(input),
|
|
944
|
+
limit: limit + 1,
|
|
945
|
+
...(after ? { after } : {}),
|
|
946
|
+
}), limit);
|
|
947
|
+
return { reservations: shown.map(reservationOutput), next_cursor: next };
|
|
948
|
+
});
|
|
949
|
+
},
|
|
709
950
|
async putReservation(context, input) {
|
|
710
951
|
return withTenant(store, context, async (tenant) => {
|
|
711
952
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
@@ -725,7 +966,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
725
966
|
if (!result.ok)
|
|
726
967
|
throw applicationRefusal(result.code);
|
|
727
968
|
return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
|
|
728
|
-
});
|
|
969
|
+
}, WRITE);
|
|
729
970
|
},
|
|
730
971
|
async cancelReservation(context, input) {
|
|
731
972
|
return withTenant(store, context, async (tenant) => {
|
|
@@ -737,7 +978,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
737
978
|
if (!result.ok)
|
|
738
979
|
throw applicationRefusal(result.code);
|
|
739
980
|
return { cancelled: result.cancelled, revision: result.revision };
|
|
740
|
-
});
|
|
981
|
+
}, WRITE);
|
|
741
982
|
},
|
|
742
983
|
async recordCoverage(context, input) {
|
|
743
984
|
return withTenant(store, context, async (tenant) => {
|
|
@@ -752,17 +993,95 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
752
993
|
if (!result.ok)
|
|
753
994
|
throw applicationRefusal(result.code);
|
|
754
995
|
return { revision: result.revision, replayed: result.idempotent };
|
|
996
|
+
}, WRITE);
|
|
997
|
+
},
|
|
998
|
+
async listTenantEvents(context, input) {
|
|
999
|
+
const limit = input.limit;
|
|
1000
|
+
const after = parseEventCursor(input.cursor);
|
|
1001
|
+
return withTenant(store, context, async (tenant) => {
|
|
1002
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1003
|
+
const { shown, next } = page(await tenant.listCalendarEvents({
|
|
1004
|
+
tenantRef: tenantOf(context),
|
|
1005
|
+
allOwners: true,
|
|
1006
|
+
resourceId: resource.id,
|
|
1007
|
+
window: interval(input),
|
|
1008
|
+
limit: limit + 1,
|
|
1009
|
+
...(after ? { after } : {}),
|
|
1010
|
+
}), limit);
|
|
1011
|
+
return { events: shown.map(tenantEventOutput), next_cursor: next };
|
|
755
1012
|
});
|
|
756
1013
|
},
|
|
1014
|
+
async getTenantEvent(context, input) {
|
|
1015
|
+
const id = input.event_id;
|
|
1016
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1017
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1018
|
+
return withTenant(store, context, async (tenant) => {
|
|
1019
|
+
const event = await tenant.getCalendarEventById({ tenantRef: tenantOf(context), eventId: id });
|
|
1020
|
+
if (!event)
|
|
1021
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1022
|
+
return { event: tenantEventOutput(event) };
|
|
1023
|
+
});
|
|
1024
|
+
},
|
|
1025
|
+
async cancelTenantEvent(context, input) {
|
|
1026
|
+
const id = input.event_id;
|
|
1027
|
+
const expectedResourceId = input.expected_resource_id;
|
|
1028
|
+
// An id the store never issues names no event, and no event is on a resource it never issued.
|
|
1029
|
+
if (!STORE_ID_PATTERN.test(id) ||
|
|
1030
|
+
(expectedResourceId !== undefined && !STORE_ID_PATTERN.test(expectedResourceId))) {
|
|
1031
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1032
|
+
}
|
|
1033
|
+
return withTenant(store, context, async (tenant) => {
|
|
1034
|
+
const result = await tenant.cancelCalendarEventById({
|
|
1035
|
+
tenantRef: tenantOf(context),
|
|
1036
|
+
eventId: id,
|
|
1037
|
+
idempotencyKey: applicationCommandKey(context, input.idempotency_key),
|
|
1038
|
+
expectedRevision: input.expected_revision,
|
|
1039
|
+
...(expectedResourceId !== undefined ? { expectedResourceId } : {}),
|
|
1040
|
+
});
|
|
1041
|
+
if (!result.ok)
|
|
1042
|
+
throw applicationRefusal(result.code);
|
|
1043
|
+
return {
|
|
1044
|
+
event_id: result.eventId,
|
|
1045
|
+
revision: result.revision,
|
|
1046
|
+
cancelled: true,
|
|
1047
|
+
replayed: result.idempotent,
|
|
1048
|
+
};
|
|
1049
|
+
}, WRITE);
|
|
1050
|
+
},
|
|
1051
|
+
async getTenant(context) {
|
|
1052
|
+
return withTenant(store, context, async (tenant) => ({
|
|
1053
|
+
tenant: {
|
|
1054
|
+
ref: tenantOf(context),
|
|
1055
|
+
suspended: await tenant.isTenantSuspended({ tenantRef: tenantOf(context) }),
|
|
1056
|
+
},
|
|
1057
|
+
}));
|
|
1058
|
+
},
|
|
1059
|
+
async eraseTenant(context, input) {
|
|
1060
|
+
if (!tenantErasure)
|
|
1061
|
+
throw new SlotlockAgentOperationError('tenant_erasure_disabled', 403);
|
|
1062
|
+
// The reference, not a yes: a key of another tenant than the caller believes cannot erase it.
|
|
1063
|
+
if (input.confirm_tenant_ref !== tenantOf(context)) {
|
|
1064
|
+
throw new SlotlockAgentOperationError('invalid_confirmation', 400);
|
|
1065
|
+
}
|
|
1066
|
+
return withTenant(store, context, async (tenant) => {
|
|
1067
|
+
// Read under the tenant's lock, which a suspension waits for: a suspended tenant's data is
|
|
1068
|
+
// kept for its operator, who erases it (slotlock tenant erase) when they decide to.
|
|
1069
|
+
if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
|
|
1070
|
+
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
1071
|
+
}
|
|
1072
|
+
const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) });
|
|
1073
|
+
return { total: erased.total, rows: erased.rows };
|
|
1074
|
+
}, WRITE);
|
|
1075
|
+
},
|
|
757
1076
|
async runMaintenance(context) {
|
|
758
1077
|
assertActive(context);
|
|
759
1078
|
try {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1079
|
+
// A caller goes round again while `capped`; when to do so at once is the scheduler's.
|
|
1080
|
+
const { rollPending: _, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
1081
|
+
batches,
|
|
1082
|
+
signal: context.signal,
|
|
1083
|
+
});
|
|
1084
|
+
return result;
|
|
766
1085
|
}
|
|
767
1086
|
catch (error) {
|
|
768
1087
|
if (context.signal.aborted)
|
package/api-keys.d.ts
CHANGED
|
@@ -117,7 +117,10 @@ export declare function createSlotlockApiKeyStore(sql: SlotlockSql): SlotlockApi
|
|
|
117
117
|
export interface SlotlockApiKeyAuthenticatorOptions {
|
|
118
118
|
/** Key lookups that may run at once, default 2: at most this many connections serve them. */
|
|
119
119
|
maxConcurrentLookups?: number;
|
|
120
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Lookups that may wait for one, default 256, and as many again for keys that recently
|
|
122
|
+
* authenticated. A request beyond that is refused as busy at once.
|
|
123
|
+
*/
|
|
121
124
|
maxWaitingLookups?: number;
|
|
122
125
|
}
|
|
123
126
|
/**
|
|
@@ -126,7 +129,12 @@ export interface SlotlockApiKeyAuthenticatorOptions {
|
|
|
126
129
|
* `authorize`. A value that is not a well-formed key is anonymous without a lookup.
|
|
127
130
|
*
|
|
128
131
|
* Anyone can make well-formed keys, so lookups are bounded: a flood of made-up keys waits for at
|
|
129
|
-
* most `maxConcurrentLookups` connections and
|
|
130
|
-
*
|
|
132
|
+
* most `maxConcurrentLookups` connections, and past `maxWaitingLookups` a request is refused with a
|
|
133
|
+
* `SlotlockAgentOperationError` (`server_busy`, 503) rather than answered anonymous, which would
|
|
134
|
+
* tell a client holding a valid key that its key is bad. A key that authenticated in the last 15
|
|
135
|
+
* minutes waits in a queue of its own that is served first, so made-up keys, which never
|
|
136
|
+
* authenticate, cannot keep it waiting. Every request is still looked up, so a revoked key stops
|
|
137
|
+
* at once. Rate-limit a public server in front of it as well (`slotlock serve` does, per client
|
|
138
|
+
* address, before any lookup).
|
|
131
139
|
*/
|
|
132
140
|
export declare function createSlotlockApiKeyAuthenticator(keys: Pick<SlotlockApiKeyStore, 'authenticate'>, options?: SlotlockApiKeyAuthenticatorOptions): (request: Request) => Promise<SlotlockAgentPrincipal | null>;
|