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.
Files changed (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. 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
- * The store refusals the route can answer besides the ones every route can: 404 for a record
10
- * that is missing or another tenant's, 409 for a conflict with what is stored (an overlap, a
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 (`application/problem+json`): `type` and `title` name
24
- * the problem, `code` is Slotlock's code, and `error.code` repeats it, the body every earlier
25
- * client reads. `extensions` add what the refusal is about, such as the interval an overlap refused.
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, as
30
- * draft-ietf-httpapi-idempotency-key-header-08 defines it), or the bare token clients sent before.
31
- * `null` when it is a quoted string that does not parse.
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. The first route in
48
- * `routes` that fits wins; no two of Slotlock's routes fit the same path and method (a test checks).
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: an array parameter repeats (`resource_ids=a&resource_ids=b`,
54
- * OpenAPI's default form style), every other parameter appears once. A parameter the operation does
55
- * not have, or a repeated one, is passed on as it is, so the operation's strict schema refuses it.
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 (`application/problem+json`): `type` and `title` name
90
- * the problem, `code` is Slotlock's code, and `error.code` repeats it, the body every earlier
91
- * client reads. `extensions` add what the refusal is about, such as the interval an overlap refused.
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, as
105
- * draft-ietf-httpapi-idempotency-key-header-08 defines it), or the bare token clients sent before.
106
- * `null` when it is a quoted string that does not parse.
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. The first route in
162
- * `routes` that fits wins; no two of Slotlock's routes fit the same path and method (a test checks).
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: an array parameter repeats (`resource_ids=a&resource_ids=b`,
213
- * OpenAPI's default form style), every other parameter appears once. A parameter the operation does
214
- * not have, or a repeated one, is passed on as it is, so the operation's strict schema refuses it.
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. Expansion
17
- * is synchronous: fifty daily rules over a century are close to two million windows.
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 concrete, merged, sorted UTC windows clipped to `searchWindow`.
22
- * Unsupported or unparseable rules contribute nothing rather than throwing: one bad rule must not
23
- * blank the whole calendar — and must never manufacture availability the rule doesn't mean.
24
- * Callers should validate rules at write time; this read-path skip remains the fail-closed backstop.
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. Expansion
25
- * is synchronous: fifty daily rules over a century are close to two million windows.
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 an overflowing field into the next unit: 31 February became 3 March, and the rule stayed
42
- * bookable after the day it was written to end. A leap second (60) is real (RFC 5545 3.3.12).
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, the date reads back differently when any part overflowed.
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 written in SPEC.md's subset: `FREQ=WEEKLY`, a `BYDAY` of plain weekdays,
59
- * optionally `INTERVAL=1` and an absolute `UNTIL`, each once, in any order, and nothing else.
60
- * Checked before rrule parses it: rrule honours a DTSTART's TZID (Monday came out as Sunday) and
61
- * the first BYDAY of two, and BYHOUR/BYMINUTE/BYSECOND multiply occurrences (one rule took 0.7 s).
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
- * The spike honours exactly the weekly-cadence subset it can answer DETERMINISTICALLY. Anything
80
- * else is refused (skipped = fails closed to "not bookable"), never guessed:
81
- * - freq must be WEEKLY — a freq-less-but-parseable string ('', 'COUNT=5') otherwise becomes a
82
- * default-YEARLY rule anchored at the query date: phantom availability at every search start.
83
- * - COUNT is unanswerable without an authored DTSTART — re-anchoring makes "first N occurrences"
84
- * restart on every query.
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 inherits its weekday from the re-anchored DTSTART — the answer wobbles
96
- // with the query date (and, before this gate, flipped between the UTC and zoned paths).
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 concrete, merged, sorted UTC windows clipped to `searchWindow`.
106
- * Unsupported or unparseable rules contribute nothing rather than throwing: one bad rule must not
107
- * blank the whole calendar — and must never manufacture availability the rule doesn't mean.
108
- * Callers should validate rules at write time; this read-path skip remains the fail-closed backstop.
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 ONCE, loudly: a typo'd zone must never silently fall back to UTC math — that
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 START days before the search window and still reach into it — scan the
129
- // recurrence back far enough for the longest window this rule can produce (multi-day
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: a UTC midnight, or under a zone a LOCAL date
140
- // read as UTC. Left to rrule, UNTIL would keep a window that starts after it on its own day,
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, a LOCAL day whose UTC stamp lies past the window end can still start
154
- // before it (east zones read a stamp's wall date up to 14h earlier) — scan one day over
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 LOCAL calendar date; start and end are both wall-clock. An
163
- // edge the clocks skip stands where the skipped hour ends: moved on by the gap, as RFC 5545
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>;