slotlock 0.6.2 → 0.7.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 +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/rest-api.d.ts
CHANGED
|
@@ -6,9 +6,8 @@ export interface SlotlockRestRoute<Operation extends string = SlotlockAgentOpera
|
|
|
6
6
|
path: string;
|
|
7
7
|
operation: Operation;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* stale revision). The OpenAPI document declares them, so a generated client types them.
|
|
9
|
+
* Store refusals the route can answer beyond the common ones: 404 (missing or another tenant's)
|
|
10
|
+
* and 409 (a conflict with what is stored). Declared in OpenAPI so generated clients type them.
|
|
12
11
|
*/
|
|
13
12
|
refusals?: readonly (404 | 409)[];
|
|
14
13
|
}
|
|
@@ -20,15 +19,15 @@ export declare const SLOTLOCK_PROBLEM_DOCS = "https://slotlock.pylota.io/docs/ap
|
|
|
20
19
|
/** A problem type's identifier: a tag URI (RFC 4151), stable, one per code, not a locator. */
|
|
21
20
|
export declare function slotlockProblemType(code: string): string;
|
|
22
21
|
/**
|
|
23
|
-
* A REST refusal as RFC 9457 problem details
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* A REST refusal as RFC 9457 problem details: `type` and `title` name the problem, `code` is
|
|
23
|
+
* Slotlock's code, and `error.code` repeats it for older clients. `extensions` add context, such as
|
|
24
|
+
* the interval an overlap refused.
|
|
26
25
|
*/
|
|
27
26
|
export declare function slotlockProblem(status: number, code: string, extensions?: Record<string, unknown>): Record<string, unknown>;
|
|
28
27
|
/**
|
|
29
|
-
* The Idempotency-Key header's value: a Structured Field String (RFC 9651,
|
|
30
|
-
* draft-ietf-httpapi-idempotency-key-header-08
|
|
31
|
-
*
|
|
28
|
+
* The Idempotency-Key header's value: a Structured Field String (RFC 9651, per
|
|
29
|
+
* draft-ietf-httpapi-idempotency-key-header-08), or a bare token as older clients send. `null` for
|
|
30
|
+
* a quoted string that does not parse.
|
|
32
31
|
*/
|
|
33
32
|
export declare function parseIdempotencyKeyHeader(value: string): string | null;
|
|
34
33
|
/** Where an operation's arguments come from. */
|
|
@@ -44,15 +43,15 @@ export type SlotlockRestMatch<Operation extends string = SlotlockAgentOperation>
|
|
|
44
43
|
invalid: true;
|
|
45
44
|
} | null;
|
|
46
45
|
/**
|
|
47
|
-
* The route for a method and path, the methods the path does have, or nothing.
|
|
48
|
-
*
|
|
46
|
+
* The route for a method and path, the methods the path does have, or nothing. First fit wins; a
|
|
47
|
+
* test checks no two routes fit the same path and method.
|
|
49
48
|
*/
|
|
50
49
|
export declare function matchSlotlockRestRoute<Operation extends string = SlotlockAgentOperation>(method: string, path: string, routes?: readonly SlotlockRestRoute<Operation>[]): SlotlockRestMatch<Operation>;
|
|
51
50
|
type JsonSchema = Record<string, unknown>;
|
|
52
51
|
/**
|
|
53
|
-
* An operation's input from a query string:
|
|
54
|
-
*
|
|
55
|
-
*
|
|
52
|
+
* An operation's input from a query string: array parameters repeat (OpenAPI's default form style),
|
|
53
|
+
* others appear once. Unknown or repeated parameters pass through so the strict schema refuses
|
|
54
|
+
* them.
|
|
56
55
|
*/
|
|
57
56
|
export declare function restQueryInput(search: URLSearchParams, inputSchema: JsonSchema): Record<string, unknown>;
|
|
58
57
|
/** One operation, as the OpenAPI document needs it. */
|
|
@@ -62,6 +61,8 @@ export interface SlotlockOpenApiOperation {
|
|
|
62
61
|
description: string;
|
|
63
62
|
inputSchema: JsonSchema;
|
|
64
63
|
outputSchema: JsonSchema;
|
|
64
|
+
/** An operation's own scope: a tool's, or the partner API's (else the application scope). */
|
|
65
|
+
scope?: string;
|
|
65
66
|
}
|
|
66
67
|
/** The OpenAPI 3.1 document for the REST routes, from the operations' own schemas. */
|
|
67
68
|
export declare function slotlockOpenApiDocument(args: {
|
package/rest-api.js
CHANGED
|
@@ -29,6 +29,12 @@ export const SLOTLOCK_REST_ROUTES = Object.freeze([
|
|
|
29
29
|
operation: 'slotlock_release_slots',
|
|
30
30
|
refusals: [409],
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
method: 'POST',
|
|
34
|
+
path: '/v1/slot-holds/{hold_id}/confirm',
|
|
35
|
+
operation: 'slotlock_confirm_slot',
|
|
36
|
+
refusals: [404, 409],
|
|
37
|
+
},
|
|
32
38
|
{ method: 'GET', path: '/v1/events', operation: 'slotlock_list_events', refusals: [404] },
|
|
33
39
|
{ method: 'POST', path: '/v1/events', operation: 'slotlock_create_event', refusals: [404, 409] },
|
|
34
40
|
{ method: 'GET', path: '/v1/events/{event_id}', operation: 'slotlock_get_event', refusals: [404] },
|
|
@@ -44,6 +50,18 @@ export const SLOTLOCK_REST_ROUTES = Object.freeze([
|
|
|
44
50
|
operation: 'slotlock_delete_event',
|
|
45
51
|
refusals: [404, 409],
|
|
46
52
|
},
|
|
53
|
+
{
|
|
54
|
+
method: 'POST',
|
|
55
|
+
path: '/v1/events/{event_id}/reschedule',
|
|
56
|
+
operation: 'slotlock_reschedule',
|
|
57
|
+
refusals: [404, 409],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
method: 'POST',
|
|
61
|
+
path: '/v1/events/{event_id}/cancel',
|
|
62
|
+
operation: 'slotlock_cancel',
|
|
63
|
+
refusals: [404, 409],
|
|
64
|
+
},
|
|
47
65
|
]);
|
|
48
66
|
/** The application operations' routes, served when the server has an application backend. */
|
|
49
67
|
export const SLOTLOCK_APPLICATION_REST_ROUTES = Object.freeze(SLOTLOCK_APPLICATION_OPERATIONS.map(({ method, path, name }) => ({
|
|
@@ -65,6 +83,7 @@ const PROBLEM_TITLES = Object.freeze({
|
|
|
65
83
|
revision_conflict: 'The revision is stale',
|
|
66
84
|
confirmation_required: 'A person must confirm this write',
|
|
67
85
|
rate_limited: 'Too many requests',
|
|
86
|
+
quota_exceeded: 'The monthly quota is used up',
|
|
68
87
|
authentication_required: 'Authentication required',
|
|
69
88
|
forbidden: 'Not allowed',
|
|
70
89
|
invalid_arguments: 'The arguments are not valid',
|
|
@@ -86,9 +105,9 @@ const STATUS_TITLES = Object.freeze({
|
|
|
86
105
|
503: 'Service Unavailable',
|
|
87
106
|
});
|
|
88
107
|
/**
|
|
89
|
-
* A REST refusal as RFC 9457 problem details
|
|
90
|
-
*
|
|
91
|
-
*
|
|
108
|
+
* A REST refusal as RFC 9457 problem details: `type` and `title` name the problem, `code` is
|
|
109
|
+
* Slotlock's code, and `error.code` repeats it for older clients. `extensions` add context, such as
|
|
110
|
+
* the interval an overlap refused.
|
|
92
111
|
*/
|
|
93
112
|
export function slotlockProblem(status, code, extensions = {}) {
|
|
94
113
|
return {
|
|
@@ -101,9 +120,9 @@ export function slotlockProblem(status, code, extensions = {}) {
|
|
|
101
120
|
};
|
|
102
121
|
}
|
|
103
122
|
/**
|
|
104
|
-
* The Idempotency-Key header's value: a Structured Field String (RFC 9651,
|
|
105
|
-
* draft-ietf-httpapi-idempotency-key-header-08
|
|
106
|
-
*
|
|
123
|
+
* The Idempotency-Key header's value: a Structured Field String (RFC 9651, per
|
|
124
|
+
* draft-ietf-httpapi-idempotency-key-header-08), or a bare token as older clients send. `null` for
|
|
125
|
+
* a quoted string that does not parse.
|
|
107
126
|
*/
|
|
108
127
|
export function parseIdempotencyKeyHeader(value) {
|
|
109
128
|
const trimmed = value.trim();
|
|
@@ -158,8 +177,8 @@ function matchPath(pattern, segments) {
|
|
|
158
177
|
return params;
|
|
159
178
|
}
|
|
160
179
|
/**
|
|
161
|
-
* The route for a method and path, the methods the path does have, or nothing.
|
|
162
|
-
*
|
|
180
|
+
* The route for a method and path, the methods the path does have, or nothing. First fit wins; a
|
|
181
|
+
* test checks no two routes fit the same path and method.
|
|
163
182
|
*/
|
|
164
183
|
export function matchSlotlockRestRoute(method, path, routes = SLOTLOCK_REST_ROUTES) {
|
|
165
184
|
const segments = path.split('/');
|
|
@@ -209,9 +228,9 @@ function queryValue(value, schema) {
|
|
|
209
228
|
return value;
|
|
210
229
|
}
|
|
211
230
|
/**
|
|
212
|
-
* An operation's input from a query string:
|
|
213
|
-
*
|
|
214
|
-
*
|
|
231
|
+
* An operation's input from a query string: array parameters repeat (OpenAPI's default form style),
|
|
232
|
+
* others appear once. Unknown or repeated parameters pass through so the strict schema refuses
|
|
233
|
+
* them.
|
|
215
234
|
*/
|
|
216
235
|
export function restQueryInput(search, inputSchema) {
|
|
217
236
|
const input = {};
|
|
@@ -330,15 +349,14 @@ export function slotlockOpenApiDocument(args) {
|
|
|
330
349
|
if (!operation)
|
|
331
350
|
throw new Error(`Slotlock REST route ${route.path} names no operation`);
|
|
332
351
|
add(route, openApiOperation(route, operation, {
|
|
333
|
-
tag: 'Application',
|
|
334
|
-
scope: SLOTLOCK_APPLICATION_SCOPE,
|
|
352
|
+
tag: operation.scope === 'partner' ? 'Partner' : 'Application',
|
|
353
|
+
scope: operation.scope === 'partner' ? 'partner' : SLOTLOCK_APPLICATION_SCOPE,
|
|
335
354
|
responses: {
|
|
336
355
|
'400': ERROR_RESPONSE,
|
|
337
356
|
'401': ERROR_RESPONSE,
|
|
338
357
|
'403': ERROR_RESPONSE,
|
|
339
358
|
'404': ERROR_RESPONSE,
|
|
340
359
|
...(route.method === 'GET' ? {} : { '409': ERROR_RESPONSE }),
|
|
341
|
-
// A change cursor past the feed's retention.
|
|
342
360
|
...(route.operation === 'slotlock_list_changes' ? { '410': ERROR_RESPONSE } : {}),
|
|
343
361
|
'429': ERROR_RESPONSE,
|
|
344
362
|
default: ERROR_RESPONSE,
|
|
@@ -379,6 +397,10 @@ export function slotlockOpenApiDocument(args) {
|
|
|
379
397
|
name: 'Application',
|
|
380
398
|
description: 'What the application around the agents does. Needs the admin scope; never offered to agents.',
|
|
381
399
|
},
|
|
400
|
+
{
|
|
401
|
+
name: 'Partner',
|
|
402
|
+
description: "What a partner reselling Slotlock does for its own users' accounts. Needs a partner key (`slk_partner_…`), whose `partner` scope reaches nothing else.",
|
|
403
|
+
},
|
|
382
404
|
]
|
|
383
405
|
: []),
|
|
384
406
|
],
|
|
@@ -388,7 +410,7 @@ export function slotlockOpenApiDocument(args) {
|
|
|
388
410
|
bearer: {
|
|
389
411
|
type: 'http',
|
|
390
412
|
scheme: 'bearer',
|
|
391
|
-
description: 'A Slotlock API key (`slk_…`) or the server token. `x-slotlock-scope` on each operation names the scope it needs.',
|
|
413
|
+
description: 'A Slotlock API key (`slk_…`, or `slk_test_…` for a test environment), a partner key (`slk_partner_…`; with a `Slotlock-Account` field naming one of its accounts, where its contract allows, it acts as that account) or the server token. `x-slotlock-scope` on each operation names the scope it needs.',
|
|
392
414
|
},
|
|
393
415
|
},
|
|
394
416
|
schemas: {
|
|
@@ -404,6 +426,48 @@ export function slotlockOpenApiDocument(args) {
|
|
|
404
426
|
resource_id: { type: 'string', description: 'An overlap: the resource it refused.' },
|
|
405
427
|
start: { type: 'string', description: 'An overlap: the start it refused.' },
|
|
406
428
|
end: { type: 'string', description: 'An overlap: the end it refused.' },
|
|
429
|
+
busy_start: {
|
|
430
|
+
type: 'string',
|
|
431
|
+
description: 'An overlap: when the booking in the way makes the time busy, from (buffers included).',
|
|
432
|
+
},
|
|
433
|
+
busy_end: { type: 'string', description: 'An overlap: until when that booking makes it busy.' },
|
|
434
|
+
conflicting_hold_id: {
|
|
435
|
+
type: 'string',
|
|
436
|
+
description: 'An overlap with a hold of your own: its id, to book or release it.',
|
|
437
|
+
},
|
|
438
|
+
conflicting_event_id: {
|
|
439
|
+
type: 'string',
|
|
440
|
+
description: 'An overlap with an event of your own: its id, to move or cancel it.',
|
|
441
|
+
},
|
|
442
|
+
approval_id: {
|
|
443
|
+
type: 'string',
|
|
444
|
+
description: 'approval_pending, approval_declined or approval_expired: the approval a policy made of the booking. Retry with the same idempotency_key to learn how it was decided.',
|
|
445
|
+
},
|
|
446
|
+
policy_ref: { type: 'string', description: 'An approval refusal: the policy that applies.' },
|
|
447
|
+
expires_at: {
|
|
448
|
+
type: 'string',
|
|
449
|
+
description: 'approval_pending or approval_required: until when a person may decide (the time is held until then).',
|
|
450
|
+
},
|
|
451
|
+
reason: { type: 'string', description: 'approval_declined: what the person who declined it said, if anything.' },
|
|
452
|
+
alternatives: {
|
|
453
|
+
type: 'array',
|
|
454
|
+
maxItems: 3,
|
|
455
|
+
description: 'A refusal another time can answer (overlap, pool_full, a booking rule): the earliest free times of the same length, within 7 days of the time asked for, checked as slotlock_find_available_slots checks them. Suggestions, not holds. Empty when none was found.',
|
|
456
|
+
items: {
|
|
457
|
+
type: 'object',
|
|
458
|
+
required: ['resource_id', 'start', 'end'],
|
|
459
|
+
properties: {
|
|
460
|
+
resource_id: { type: 'string' },
|
|
461
|
+
start: { type: 'string', format: 'date-time' },
|
|
462
|
+
end: { type: 'string', format: 'date-time' },
|
|
463
|
+
resource_ids: {
|
|
464
|
+
type: 'array',
|
|
465
|
+
items: { type: 'string' },
|
|
466
|
+
description: 'A bundle or pool booking: the resources free together then.',
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
},
|
|
407
471
|
issues: {
|
|
408
472
|
type: 'array',
|
|
409
473
|
description: '`invalid_arguments` from argument validation: each field it refused (at most 20), by its path in the arguments, outermost first; `[]` is the arguments as a whole.',
|
package/rules.d.ts
CHANGED
|
@@ -13,15 +13,14 @@ export declare function invalidWindowError(message?: string): Error & {
|
|
|
13
13
|
code: 'invalid_window';
|
|
14
14
|
};
|
|
15
15
|
/**
|
|
16
|
-
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS
|
|
17
|
-
* is synchronous
|
|
16
|
+
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS: expansion
|
|
17
|
+
* is synchronous and grows with the window.
|
|
18
18
|
*/
|
|
19
19
|
export declare function assertBoundedSearchWindow(window: Interval): void;
|
|
20
20
|
/**
|
|
21
|
-
* Expand availability rules to
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* A search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
21
|
+
* Expand availability rules to merged, sorted UTC windows clipped to `searchWindow`. Unsupported or
|
|
22
|
+
* unparseable rules contribute nothing rather than throwing: one bad rule must neither blank the
|
|
23
|
+
* calendar nor manufacture availability. Validate at write time; this is the fail-closed backstop.
|
|
24
|
+
* A window over SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
26
25
|
*/
|
|
27
26
|
export declare function expandRules(rules: WeeklyAvailabilityRule[], searchWindow: Interval, timezone?: string): Interval[];
|
package/rules.js
CHANGED
|
@@ -21,15 +21,14 @@ export function invalidWindowError(message = 'Slotlock window must contain two f
|
|
|
21
21
|
return Object.assign(new Error(message), { code: 'invalid_window' });
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS
|
|
25
|
-
* is synchronous
|
|
24
|
+
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS: expansion
|
|
25
|
+
* is synchronous and grows with the window.
|
|
26
26
|
*/
|
|
27
27
|
export function assertBoundedSearchWindow(window) {
|
|
28
28
|
if (window.end.getTime() - window.start.getTime() > MAX_SEARCH_WINDOW_MS) {
|
|
29
29
|
throw invalidWindowError(`Slotlock searches a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days at once`);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
/** UTC midnight of the day containing `d`. */
|
|
33
32
|
function utcDayFloor(d) {
|
|
34
33
|
return new Date(Math.floor(d.getTime() / DAY_MS) * DAY_MS);
|
|
35
34
|
}
|
|
@@ -38,15 +37,15 @@ const WEEKDAY = /^(?:MO|TU|WE|TH|FR|SA|SU)$/;
|
|
|
38
37
|
const ABSOLUTE_UNTIL = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
|
|
39
38
|
/**
|
|
40
39
|
* Whether `until` is an absolute UNTIL naming a real instant. rrule reads it with Date.UTC, which
|
|
41
|
-
* carries
|
|
42
|
-
*
|
|
40
|
+
* carries overflow into the next unit (31 February becomes 3 March). A leap second (60) is real
|
|
41
|
+
* (RFC 5545 3.3.12).
|
|
43
42
|
*/
|
|
44
43
|
function isRealUntil(until) {
|
|
45
44
|
const match = ABSOLUTE_UNTIL.exec(until);
|
|
46
45
|
if (!match)
|
|
47
46
|
return false;
|
|
48
47
|
const [, year, month, day, hour, minute, second] = match;
|
|
49
|
-
// Set without overflow checks
|
|
48
|
+
// Set without overflow checks: the date reads back differently when any part overflowed.
|
|
50
49
|
const date = new Date(0);
|
|
51
50
|
date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
|
|
52
51
|
return (date.toISOString().startsWith(`${year}-${month}-${day}T`) &&
|
|
@@ -55,10 +54,10 @@ function isRealUntil(until) {
|
|
|
55
54
|
Number(second) <= 60);
|
|
56
55
|
}
|
|
57
56
|
/**
|
|
58
|
-
* Whether `rrule` is
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
57
|
+
* Whether `rrule` is in SPEC.md's subset: `FREQ=WEEKLY`, a `BYDAY` of plain weekdays, optionally
|
|
58
|
+
* `INTERVAL=1` and an absolute `UNTIL`, each once, in any order. Checked before rrule parses it:
|
|
59
|
+
* rrule honours a DTSTART's TZID, takes the first of two BYDAYs, and lets BYHOUR/BYMINUTE/BYSECOND
|
|
60
|
+
* multiply occurrences.
|
|
62
61
|
*/
|
|
63
62
|
function isWeeklySubset(rrule) {
|
|
64
63
|
const parts = new Map();
|
|
@@ -76,14 +75,12 @@ function isWeeklySubset(rrule) {
|
|
|
76
75
|
(until === undefined || isRealUntil(until)));
|
|
77
76
|
}
|
|
78
77
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* - freq must be WEEKLY
|
|
82
|
-
*
|
|
83
|
-
* - COUNT
|
|
84
|
-
*
|
|
85
|
-
* - INTERVAL>1 parity depends on the anchor — re-anchoring makes biweekly availability flip with
|
|
86
|
-
* the query date. (UNTIL is absolute and expands correctly, so it stays allowed.)
|
|
78
|
+
* Only the weekly subset that can be answered deterministically; anything else fails closed to not
|
|
79
|
+
* bookable:
|
|
80
|
+
* - freq must be WEEKLY: a freq-less string otherwise becomes a YEARLY rule anchored at the query
|
|
81
|
+
* date;
|
|
82
|
+
* - COUNT needs an authored DTSTART: re-anchoring restarts it on every query;
|
|
83
|
+
* - INTERVAL>1 parity depends on the anchor. UNTIL is absolute and stays allowed.
|
|
87
84
|
*/
|
|
88
85
|
function isSupportedRule(parsed) {
|
|
89
86
|
if (parsed.freq !== RRule.WEEKLY)
|
|
@@ -92,8 +89,8 @@ function isSupportedRule(parsed) {
|
|
|
92
89
|
return false;
|
|
93
90
|
if (parsed.interval !== undefined && parsed.interval !== 1)
|
|
94
91
|
return false;
|
|
95
|
-
// BYDAY-less WEEKLY
|
|
96
|
-
//
|
|
92
|
+
// BYDAY-less WEEKLY would take its weekday from the re-anchored DTSTART, varying with the query
|
|
93
|
+
// date.
|
|
97
94
|
const byweekday = parsed.byweekday;
|
|
98
95
|
if (byweekday === undefined || byweekday === null)
|
|
99
96
|
return false;
|
|
@@ -102,16 +99,14 @@ function isSupportedRule(parsed) {
|
|
|
102
99
|
return true;
|
|
103
100
|
}
|
|
104
101
|
/**
|
|
105
|
-
* Expand availability rules to
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* A search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
102
|
+
* Expand availability rules to merged, sorted UTC windows clipped to `searchWindow`. Unsupported or
|
|
103
|
+
* unparseable rules contribute nothing rather than throwing: one bad rule must neither blank the
|
|
104
|
+
* calendar nor manufacture availability. Validate at write time; this is the fail-closed backstop.
|
|
105
|
+
* A window over SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
110
106
|
*/
|
|
111
107
|
export function expandRules(rules, searchWindow, timezone) {
|
|
112
108
|
assertBoundedSearchWindow(searchWindow);
|
|
113
|
-
// Validate
|
|
114
|
-
// would manufacture availability shifted by the zone offset. 'UTC'/omitted = spike path.
|
|
109
|
+
// Validate once: a typo'd zone must never silently fall back to UTC and shift availability.
|
|
115
110
|
const converter = timezone !== undefined ? createTimeZoneConverter(timezone) : null;
|
|
116
111
|
const windows = [];
|
|
117
112
|
for (const rule of rules) {
|
|
@@ -125,9 +120,8 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
125
120
|
}
|
|
126
121
|
let rrule;
|
|
127
122
|
let absoluteUntil = null;
|
|
128
|
-
// A window can
|
|
129
|
-
//
|
|
130
|
-
// windows like "Mon 00:00 + 3 days" are legal; a fixed one-day margin silently lost them).
|
|
123
|
+
// A window can start days before the search window and still reach into it: scan back as far as
|
|
124
|
+
// the longest window this rule can produce.
|
|
131
125
|
const backScanDays = Math.ceil((rule.startMinutes + rule.durationMinutes) / (24 * 60));
|
|
132
126
|
const scanStart = utcDayFloor(new Date(searchWindow.start.getTime() - backScanDays * DAY_MS));
|
|
133
127
|
if (typeof rule.rrule !== 'string' || !isWeeklySubset(rule.rrule))
|
|
@@ -136,10 +130,8 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
136
130
|
const parsed = RRule.parseString(rule.rrule);
|
|
137
131
|
if (!isSupportedRule(parsed))
|
|
138
132
|
continue;
|
|
139
|
-
// rrule's stamps are days, not window starts
|
|
140
|
-
//
|
|
141
|
-
// and under a zone east of UTC drop one that starts before it. Each window's real start is
|
|
142
|
-
// compared with UNTIL instead (below), with or without a zone, so the two paths agree.
|
|
133
|
+
// rrule's stamps are days, not window starts, so its UNTIL handling is wrong at both ends;
|
|
134
|
+
// each window's real start is compared with UNTIL below instead.
|
|
143
135
|
if (parsed.until) {
|
|
144
136
|
absoluteUntil = parsed.until;
|
|
145
137
|
parsed.until = null;
|
|
@@ -150,19 +142,16 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
150
142
|
catch {
|
|
151
143
|
continue;
|
|
152
144
|
}
|
|
153
|
-
// Under a zone
|
|
154
|
-
//
|
|
155
|
-
// and let clipping discard the overshoot.
|
|
145
|
+
// Under a zone a local day whose UTC stamp is past the window end can still start before it
|
|
146
|
+
// (east zones are up to 14h ahead): scan one day over and let clipping discard the overshoot.
|
|
156
147
|
const scanEnd = converter ? new Date(searchWindow.end.getTime() + DAY_MS) : searchWindow.end;
|
|
157
148
|
const days = rrule.between(scanStart, scanEnd, true);
|
|
158
149
|
for (const day of days) {
|
|
159
150
|
let start;
|
|
160
151
|
let end;
|
|
161
152
|
if (converter) {
|
|
162
|
-
// The stamp's Y/M/D is the
|
|
163
|
-
//
|
|
164
|
-
// reads a time, [02:30, 03:30) gave nothing although 03:00-03:30 exists, and [02:00,
|
|
165
|
-
// 02:30) gave 03:00-03:30, which it never names.
|
|
153
|
+
// The stamp's Y/M/D is the local date; start and end are wall-clock. An edge the clocks
|
|
154
|
+
// skip stands where the skipped hour ends, so the window holds only wall times that exist.
|
|
166
155
|
const wallDay = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
|
|
167
156
|
start = converter.wallFrameToUtc(wallDay + rule.startMinutes * MINUTE_MS, { gap: 'end' });
|
|
168
157
|
end = converter.wallFrameToUtc(wallDay + (rule.startMinutes + rule.durationMinutes) * MINUTE_MS, { gap: 'end' });
|
|
@@ -176,7 +165,6 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
176
165
|
}
|
|
177
166
|
if (absoluteUntil && start.getTime() > absoluteUntil.getTime())
|
|
178
167
|
continue;
|
|
179
|
-
// Clip to the search window; drop windows that miss it entirely.
|
|
180
168
|
const clippedStart = start.getTime() < searchWindow.start.getTime() ? searchWindow.start : start;
|
|
181
169
|
const clippedEnd = end.getTime() > searchWindow.end.getTime() ? searchWindow.end : end;
|
|
182
170
|
if (clippedStart.getTime() < clippedEnd.getTime()) {
|
package/sandbox.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type SlotlockAgentCalendarBackend } from './agent-server.js';
|
|
2
|
+
import { type SlotlockMaintenanceResult } from './maintenance.js';
|
|
3
|
+
import type { SlotlockStore } from './store.js';
|
|
4
|
+
export declare const SLOTLOCK_SANDBOX_SCENARIOS: readonly ["car-rental", "clinic", "desks"];
|
|
5
|
+
export type SlotlockSandboxScenario = (typeof SLOTLOCK_SANDBOX_SCENARIOS)[number];
|
|
6
|
+
/** Agents one race runs at most, so a race stays a test of the server and not a load on it. */
|
|
7
|
+
export declare const SLOTLOCK_SANDBOX_MAX_RACERS = 200;
|
|
8
|
+
/** Refused outside a test environment. */
|
|
9
|
+
export declare class SlotlockSandboxError extends Error {
|
|
10
|
+
readonly code: 'sandbox_test_only' | 'sandbox_not_empty';
|
|
11
|
+
constructor(code: 'sandbox_test_only' | 'sandbox_not_empty', message: string);
|
|
12
|
+
}
|
|
13
|
+
export interface SlotlockSandboxSeedResult {
|
|
14
|
+
scenario: SlotlockSandboxScenario;
|
|
15
|
+
resources: number;
|
|
16
|
+
locations: number;
|
|
17
|
+
groups: number;
|
|
18
|
+
pools: number;
|
|
19
|
+
bookings: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fill an empty test environment with a scenario, in one transaction: its locations, groups,
|
|
23
|
+
* resources (with profiles, pools and a composite where it has one), their hours and booking
|
|
24
|
+
* rules, and a few bookings tomorrow. Refused (`sandbox_not_empty`) where it has resources already:
|
|
25
|
+
* reset it first.
|
|
26
|
+
*/
|
|
27
|
+
export declare function seedSlotlockSandbox(store: SlotlockStore, tenantRef: string, scenario: SlotlockSandboxScenario, options?: {
|
|
28
|
+
now?: Date;
|
|
29
|
+
}): Promise<SlotlockSandboxSeedResult>;
|
|
30
|
+
/** Empty a test environment: what seeding made, and every booking since. Keys, webhooks and plan stay. */
|
|
31
|
+
export declare function resetSlotlockSandbox(store: SlotlockStore, tenantRef: string): Promise<Record<string, number>>;
|
|
32
|
+
export interface SlotlockSandboxFastForwardResult {
|
|
33
|
+
minutes: number;
|
|
34
|
+
/** Holds (a waitlist offer's and an approval's among them) and approvals that lapsed now. */
|
|
35
|
+
holds: number;
|
|
36
|
+
approvals: number;
|
|
37
|
+
/** The maintenance pass that then reaped them, as it would have. */
|
|
38
|
+
maintenance: SlotlockMaintenanceResult;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Move a test environment's waiting forward: what would lapse within `minutes` lapses now, then a
|
|
42
|
+
* maintenance pass reaps it as it would have (holds released, waitlist offers passed on, approvals
|
|
43
|
+
* expired). Bookings and events keep their times; no-shows and series rolls follow the real clock.
|
|
44
|
+
*/
|
|
45
|
+
export declare function fastForwardSlotlockSandbox(store: SlotlockStore, tenantRef: string, minutes: number): Promise<SlotlockSandboxFastForwardResult>;
|
|
46
|
+
export interface SlotlockSandboxRaceResult {
|
|
47
|
+
agents: number;
|
|
48
|
+
booked: number;
|
|
49
|
+
/** Refusals by code. */
|
|
50
|
+
refused: Record<string, number>;
|
|
51
|
+
/** Refusals that came with alternatives. */
|
|
52
|
+
withAlternatives: number;
|
|
53
|
+
serverErrors: number;
|
|
54
|
+
p50Ms: number;
|
|
55
|
+
p99Ms: number;
|
|
56
|
+
maxMs: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Race `agents` agents (2-200), each its own principal, for one time on one resource of a test
|
|
60
|
+
* environment, through the same operation boundary MCP, A2A and REST use: who booked, who was
|
|
61
|
+
* refused and why, and how long each answer took.
|
|
62
|
+
*/
|
|
63
|
+
export declare function raceSlotlockSandbox(params: {
|
|
64
|
+
backend: SlotlockAgentCalendarBackend;
|
|
65
|
+
tenantRef: string;
|
|
66
|
+
resourceId: string;
|
|
67
|
+
start: Date;
|
|
68
|
+
end: Date;
|
|
69
|
+
agents?: number;
|
|
70
|
+
}): Promise<SlotlockSandboxRaceResult>;
|