slotlock 0.1.1 → 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.
Files changed (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. package/weekly-hours.js +2 -2
package/types.d.ts CHANGED
@@ -21,14 +21,28 @@ export interface SlotlockResource {
21
21
  * uses the default (`SLOTLOCK_AVAILABILITY`, or the backend's `availabilityRules`).
22
22
  */
23
23
  availabilityRules?: WeeklyAvailabilityRule[];
24
+ /**
25
+ * `shared` resources are listed to agents and booked through them; `private` ones are invisible
26
+ * to agents and reached only by the tenant's own code. Fixed when the resource is created. The
27
+ * store always says which; a resource built elsewhere without it is shared.
28
+ */
29
+ visibility?: 'shared' | 'private';
30
+ /** When the tenant archived it: no longer listed or booked, its history kept. Absent in use. */
31
+ archivedAt?: Date;
24
32
  }
25
33
  /** A confirmed occupation of a resource. The DB EXCLUDE constraint is the overlap arbiter. */
26
34
  export interface SlotlockReservation {
27
35
  id: string;
28
36
  resourceId: string;
29
- /** Null only for legacy, hold, and parity-shadow rows. */
37
+ /**
38
+ * Null only for live holds, rows on a resource that has no tenant (legacy), and parity-shadow rows.
39
+ * A booking made by confirming a hold, or by createReservation, on a tenant's resource has one.
40
+ */
30
41
  tenantRef: string | null;
31
- /** Stable booking identity inside tenantRef; null for legacy, hold, and shadow rows. */
42
+ /**
43
+ * Stable booking identity inside tenantRef; null exactly when tenantRef is. A confirmed hold, and
44
+ * a createReservation booking, use their own id.
45
+ */
32
46
  externalRef: string | null;
33
47
  start: Date;
34
48
  end: Date;
@@ -82,6 +96,25 @@ export type ExternalReservationResult = {
82
96
  } | {
83
97
  ok: false;
84
98
  code: 'idempotency_conflict';
99
+ }
100
+ /** The resource is archived: it takes no new booking (cancel and release still work). */
101
+ | {
102
+ ok: false;
103
+ code: 'resource_archived';
104
+ }
105
+ /**
106
+ * A recurring event on the resource is not stored as far as this write reaches, and cannot be
107
+ * extended that far (past the rolling horizon, or blocked): the time cannot be proven free.
108
+ */
109
+ | {
110
+ ok: false;
111
+ code: 'recurrence_not_materialized';
112
+ }
113
+ /** The tenant's bookings and retired references are at its `reservations` limit. */
114
+ | {
115
+ ok: false;
116
+ code: 'reservation_limit_reached';
117
+ limit: number;
85
118
  } | {
86
119
  ok: false;
87
120
  code: 'overlap';
@@ -106,6 +139,15 @@ export type CancelExternalReservationResult = {
106
139
  } | {
107
140
  ok: false;
108
141
  code: 'revision_conflict';
142
+ }
143
+ /**
144
+ * Retiring a reference no booking holds would add a row past the tenant's `reservations` limit
145
+ * (cancelling a booking it has never does).
146
+ */
147
+ | {
148
+ ok: false;
149
+ code: 'reservation_limit_reached';
150
+ limit: number;
109
151
  };
110
152
  /**
111
153
  * VAVAILABILITY-style bookable window (RFC 7953 semantics, weekly subset): a weekly-cadence RRULE
@@ -127,6 +169,30 @@ export type CreateReservationResult = {
127
169
  } | {
128
170
  ok: false;
129
171
  code: 'invalid_window';
172
+ }
173
+ /** No resource with that id, or (under tenant RLS or with `tenantRef`) not one of the tenant's. */
174
+ | {
175
+ ok: false;
176
+ code: 'resource_not_found';
177
+ }
178
+ /** The resource is archived: it takes no new booking (cancel and release still work). */
179
+ | {
180
+ ok: false;
181
+ code: 'resource_archived';
182
+ }
183
+ /**
184
+ * A recurring event on the resource is not stored as far as this write reaches, and cannot be
185
+ * extended that far (past the rolling horizon, or blocked): the time cannot be proven free.
186
+ */
187
+ | {
188
+ ok: false;
189
+ code: 'recurrence_not_materialized';
190
+ }
191
+ /** The booking's tenant is at its `reservations` limit. */
192
+ | {
193
+ ok: false;
194
+ code: 'reservation_limit_reached';
195
+ limit: number;
130
196
  } | {
131
197
  ok: false;
132
198
  code: 'overlap';
@@ -138,18 +204,60 @@ export interface SlotlockHold {
138
204
  resourceId: string;
139
205
  start: Date;
140
206
  end: Date;
207
+ /** Turnaround kept busy after `end`; the booking the hold is confirmed into keeps it. */
208
+ bufferAfterMs: number;
141
209
  expiresAt: Date;
142
210
  source: string | null;
143
211
  }
144
- export type AcquireHoldResult = {
212
+ export type AcquireHoldResult =
213
+ /** `idempotent`: a retry of a keyed hold, answered with the hold the first request made. */
214
+ {
145
215
  ok: true;
146
216
  hold: SlotlockHold;
217
+ idempotent: boolean;
147
218
  } | {
148
219
  ok: false;
149
220
  code: 'invalid_window';
150
221
  } | {
151
222
  ok: false;
152
223
  code: 'invalid_ttl';
224
+ } | {
225
+ ok: false;
226
+ code: 'invalid_buffer';
227
+ }
228
+ /** A tenant, principal or idempotency key that is malformed, or a key without both of the others. */
229
+ | {
230
+ ok: false;
231
+ code: 'invalid_identity';
232
+ }
233
+ /** No resource with that id, or (under tenant RLS or with `tenantRef`) not one of the tenant's. */
234
+ | {
235
+ ok: false;
236
+ code: 'resource_not_found';
237
+ }
238
+ /** The resource is archived: it takes no new booking (cancel and release still work). */
239
+ | {
240
+ ok: false;
241
+ code: 'resource_archived';
242
+ }
243
+ /** The idempotency key already made a hold with another resource, window, ttl, buffer or source. */
244
+ | {
245
+ ok: false;
246
+ code: 'idempotency_conflict';
247
+ }
248
+ /**
249
+ * A recurring event on the resource is not stored as far as this write reaches, and cannot be
250
+ * extended that far (past the rolling horizon, or blocked): the time cannot be proven free.
251
+ */
252
+ | {
253
+ ok: false;
254
+ code: 'recurrence_not_materialized';
255
+ }
256
+ /** The resource's tenant has as many holds as its `holds` limit, expired ones deleted. */
257
+ | {
258
+ ok: false;
259
+ code: 'hold_limit_reached';
260
+ limit: number;
153
261
  } | {
154
262
  ok: false;
155
263
  code: 'overlap';
@@ -164,6 +272,27 @@ export type ConfirmHoldResult = {
164
272
  } | {
165
273
  ok: false;
166
274
  code: 'hold_expired';
275
+ }
276
+ /** A cancellation of the hold's reference (its id) arrived first and retired it. */
277
+ | {
278
+ ok: false;
279
+ code: 'reservation_cancelled';
280
+ }
281
+ /** Another booking of the tenant already uses the hold's id as its reference. */
282
+ | {
283
+ ok: false;
284
+ code: 'idempotency_conflict';
285
+ }
286
+ /** The hold's resource was archived since: it takes no new booking. */
287
+ | {
288
+ ok: false;
289
+ code: 'resource_archived';
290
+ }
291
+ /** The tenant is at its `reservations` limit: the hold stays a hold. */
292
+ | {
293
+ ok: false;
294
+ code: 'reservation_limit_reached';
295
+ limit: number;
167
296
  };
168
297
  export type ReleaseHoldResult = {
169
298
  ok: true;
@@ -241,6 +370,11 @@ export interface SlotlockCalendarEvent extends CalendarEventContent {
241
370
  updatedAt: Date;
242
371
  }
243
372
  export interface PutCalendarEventCommand extends CalendarEventContent {
373
+ /**
374
+ * The resource the writer read the event on: a revision is refused (`resource_tenant_mismatch`)
375
+ * when the stored event is on another, checked under the event's lock.
376
+ */
377
+ expectedResourceId?: string;
244
378
  tenantRef: string;
245
379
  /** Defaults to the trusted `internal` namespace. Agent callers must supply their owner digest. */
246
380
  ownerRef?: string;
@@ -281,9 +415,20 @@ export type PutCalendarEventResult = {
281
415
  ok: false;
282
416
  code: 'owner_command_quota_exceeded';
283
417
  limit: number;
418
+ }
419
+ /** A new event would pass the tenant's `events` limit (revising one it has never does). */
420
+ | {
421
+ ok: false;
422
+ code: 'event_limit_reached';
423
+ limit: number;
284
424
  } | {
285
425
  ok: false;
286
426
  code: 'resource_tenant_mismatch';
427
+ }
428
+ /** The resource is archived: it takes no new booking (cancel and release still work). */
429
+ | {
430
+ ok: false;
431
+ code: 'resource_archived';
287
432
  } | {
288
433
  ok: false;
289
434
  code: 'event_cancelled';
@@ -292,11 +437,30 @@ export type PutCalendarEventResult = {
292
437
  ok: false;
293
438
  code: 'revision_conflict';
294
439
  currentRevision: number;
440
+ }
441
+ /**
442
+ * A recurring event on the resource is not stored as far as this write reaches, and cannot be
443
+ * extended that far (past the rolling horizon, or blocked): the time cannot be proven free.
444
+ */
445
+ | {
446
+ ok: false;
447
+ code: 'recurrence_not_materialized';
295
448
  } | {
296
449
  ok: false;
297
450
  code: 'overlap';
298
451
  conflictingReservationId: string | null;
299
452
  };
453
+ export type DeleteResourceResult =
454
+ /** `deleted`: false when the tenant has no resource with that id (deleted already, say). */
455
+ {
456
+ ok: true;
457
+ deleted: boolean;
458
+ }
459
+ /** A future booking, a live hold, or a calendar event is still on it. */
460
+ | {
461
+ ok: false;
462
+ code: 'resource_in_use';
463
+ };
300
464
  export type CancelCalendarEventResult = {
301
465
  ok: true;
302
466
  eventId: string;
@@ -324,8 +488,16 @@ export type CancelCalendarEventResult = {
324
488
  code: 'revision_conflict';
325
489
  currentRevision: number;
326
490
  };
327
- /** A place in the roll's order of due masters: the last one a batch examined. */
491
+ /**
492
+ * A place in the roll's order of due masters: the last one a batch examined. The roll takes the
493
+ * masters it has never put back first, then those it has, longest waiting first.
494
+ */
328
495
  export interface CalendarHorizonRollCursor {
496
+ /**
497
+ * When the master was due to be examined again, if the roll had put it back; null (or, from a
498
+ * cursor of an earlier release, missing) when it had not.
499
+ */
500
+ rollRetryAt?: Date | null;
329
501
  materializedEndsAt: Date;
330
502
  eventId: string;
331
503
  }
@@ -333,13 +505,23 @@ export interface CalendarHorizonRollCursor {
333
505
  export interface CalendarHorizonRollResult {
334
506
  /** Due recurring masters examined in deterministic order. */
335
507
  examined: number;
336
- /** Masters atomically rematerialized to the requested window. */
508
+ /**
509
+ * Masters atomically rematerialized from the requested window's start through the later of their
510
+ * stored end and the window's: a roll never moves a series' end back.
511
+ */
337
512
  extended: number;
338
- /** Masters whose new occurrence collided; their previous coverage remains intact. */
513
+ /**
514
+ * Masters whose new occurrence collided; their previous coverage remains intact, and they wait an
515
+ * hour before a roll examines them again, behind every master it has not put back.
516
+ */
339
517
  conflicts: number;
518
+ /** The ids of those masters: what blocks each is a booking or hold on its new occurrences. */
519
+ conflictEventIds: string[];
340
520
  /**
341
521
  * Masters whose rule takes more than the expansion budget to reach the window (one that never
342
- * occurs again, or almost never); their previous coverage remains intact.
522
+ * occurs again, or almost never), or whose roll failed for any other reason; their previous
523
+ * coverage remains intact, and they wait an hour
524
+ * before a roll examines them again, behind every master it has not put back.
343
525
  */
344
526
  refused: number;
345
527
  /** The ids of those masters, to find and fix them. */
@@ -347,9 +529,8 @@ export interface CalendarHorizonRollResult {
347
529
  /** True when more due masters remain beyond this bounded batch. */
348
530
  hasMore: boolean;
349
531
  /**
350
- * Pass as `after` for the next batch of this pass, so the masters this batch left behind (in
351
- * conflict or refused, which keep their place at the front of the order) are not examined again
352
- * by every later batch. `null` when none remain.
532
+ * Pass as `after` for the next batch of this pass, so the masters this batch examined are not
533
+ * examined again by every later batch. `null` when none remain.
353
534
  */
354
535
  next: CalendarHorizonRollCursor | null;
355
536
  }
@@ -361,6 +542,26 @@ export interface CalendarEventRetentionResult {
361
542
  /** True when either class filled its batch, so more rows may already be eligible. */
362
543
  hasMore: boolean;
363
544
  }
545
+ /** What erasing a tenant deleted. */
546
+ export interface SlotlockTenantErasure {
547
+ /** Rows deleted, by table (every table the tenant owns rows in is named, with 0 for none). */
548
+ rows: Record<string, number>;
549
+ /** Rows deleted in all; 0 when the tenant had already been erased. */
550
+ total: number;
551
+ }
552
+ export interface BookingRetentionResult {
553
+ /** Holds past their expiry: they occupy nothing, and only a rival's write would reap them. */
554
+ expiredHoldsDeleted: number;
555
+ /** Keyed hold answers whose hold has expired: a retry with that key makes a new hold. */
556
+ holdCommandsDeleted: number;
557
+ /**
558
+ * References a cancel retired with no booking, older than the window: a create with one makes a
559
+ * new booking. A cancelled booking's reference stays retired.
560
+ */
561
+ tombstonesDeleted: number;
562
+ /** True when any class filled its batch, so more rows may already be eligible. */
563
+ hasMore: boolean;
564
+ }
364
565
  export interface CalendarCoverageCommand {
365
566
  tenantRef: string;
366
567
  resourceId: string;
@@ -382,6 +583,12 @@ export type CalendarCoverageResult = {
382
583
  } | {
383
584
  ok: false;
384
585
  code: 'stale_revision' | 'revision_conflict' | 'idempotency_conflict';
586
+ }
587
+ /** A new source would pass the tenant's `coverage` limit (advancing one it has never does). */
588
+ | {
589
+ ok: false;
590
+ code: 'coverage_limit_reached';
591
+ limit: number;
385
592
  };
386
593
  export interface CalendarCoverageAssessment {
387
594
  state: 'complete' | 'partial' | 'unknown';
package/weekly-hours.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Bookable hours: the one validator for weekly availability rules, and the conversion between rules
2
2
  // and a week of windows, the form people edit them in (two windows a day at most).
3
- import { expandRules } from './rules.js';
3
+ import { SLOTLOCK_MAX_RULE_DURATION_MINUTES, expandRules } from './rules.js';
4
4
  /** Rules one schedule may hold: the server default, or one resource's own hours. */
5
5
  export const SLOTLOCK_MAX_AVAILABILITY_RULES = 50;
6
6
  const RULE_KEYS = new Set(['rrule', 'startMinutes', 'durationMinutes']);
7
7
  const MAX_RRULE_CHARACTERS = 500;
8
- const MAX_DURATION_MINUTES = 10_080;
8
+ const MAX_DURATION_MINUTES = SLOTLOCK_MAX_RULE_DURATION_MINUTES;
9
9
  const DAY_MINUTES = 1_440;
10
10
  /** Two weeks every supported weekly rule produces a window in. */
11
11
  const SAMPLE = { start: new Date('2026-01-05T00:00:00Z'), end: new Date('2026-01-19T00:00:00Z') };