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/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SlotlockBookingRule, SlotlockBookingRules } from './booking-rules.js';
2
+ import type { SlotlockResourceAttributes, SlotlockResourceKind } from './resource-profile.js';
2
3
  /** Half-open time interval `[start, end)`. */
3
4
  export interface Interval {
4
5
  start: Date;
@@ -11,10 +12,9 @@ export interface SlotlockResource {
11
12
  /** Null only for legacy/parity-shadow resources created before authority primitives existed. */
12
13
  tenantRef: string | null;
13
14
  /**
14
- * IANA timezone the resource's availability rules are authored in. expandRules evaluates
15
- * BYDAY/startMinutes as LOCAL wall-clock in this zone (DST-correct, wall-clock ends);
16
- * findNextAvailableFor reads it from the stored resource. Formerly the documented
17
- * Rules remain stable across daylight-saving changes because wall time is resolved in this zone.
15
+ * IANA timezone the availability rules are authored in: expandRules evaluates BYDAY and
16
+ * startMinutes as local wall-clock time in this zone, so rules stay stable across daylight-saving
17
+ * changes.
18
18
  */
19
19
  timezone: string;
20
20
  /**
@@ -23,9 +23,8 @@ export interface SlotlockResource {
23
23
  */
24
24
  availabilityRules?: WeeklyAvailabilityRule[];
25
25
  /**
26
- * `shared` resources are listed to agents and booked through them; `private` ones are invisible
27
- * to agents and reached only by the tenant's own code. Fixed when the resource is created. The
28
- * store always says which; a resource built elsewhere without it is shared.
26
+ * `shared` resources are listed to and booked by agents; `private` ones are invisible to agents.
27
+ * Fixed at creation; absent means shared.
29
28
  */
30
29
  visibility?: 'shared' | 'private';
31
30
  /** When the tenant archived it: no longer listed or booked, its history kept. Absent in use. */
@@ -33,25 +32,109 @@ export interface SlotlockResource {
33
32
  /** Its own booking rules (`SlotlockBookingRules`). Absent when it has none. */
34
33
  bookingRules?: SlotlockBookingRules;
35
34
  /**
36
- * What a booking on it must satisfy: the tenant's default rules, overlaid by its pool's, overlaid
37
- * by its own (`setBookingRuleDefaults`), a field set at a narrower level winning. Absent when no
38
- * rule applies.
35
+ * What a booking on it must satisfy: tenant defaults, overlaid by its pool's, overlaid by its own
36
+ * (`setBookingRuleDefaults`). Absent when no rule applies.
39
37
  */
40
38
  effectiveBookingRules?: SlotlockBookingRules;
41
39
  /**
42
- * The pool it belongs to: interchangeable resources sharing a name, such as a vehicle class or a
43
- * room type, booked by the class (`slotlock_book_pool`). Absent when it is in none.
40
+ * Its pool: interchangeable resources (a vehicle class, a room type) booked by the class
41
+ * (`slotlock_book_pool`).
44
42
  */
45
43
  pool?: string;
44
+ /**
45
+ * The composite it is a part of (RFC 0003 G8), and its place there: a booking of the whole
46
+ * conflicts with every part. Absent when it is no part.
47
+ */
48
+ parentId?: string;
49
+ partIndex?: number;
50
+ /** Its name, shown instead of `externalRef` (resource-profile.ts). Absent when it has none. */
51
+ name?: string;
52
+ /** What it is, in a sentence or a paragraph. Absent when it has none. */
53
+ description?: string;
54
+ /** What kind of thing it is, a hint for filtering that changes no booking. Absent: `other`. */
55
+ kind?: SlotlockResourceKind;
56
+ /** How many people or seats it holds, said for people to read; never enforced. Absent: not said. */
57
+ capacity?: number;
58
+ /** Labels to find it by, sorted. Absent when it has none. */
59
+ labels?: string[];
60
+ /** What the integrator keeps on it. Absent when it has none. */
61
+ attributes?: SlotlockResourceAttributes;
62
+ /** The reference of the location it is at (resource-profile.ts). Absent when not said. */
63
+ location?: string;
64
+ /** That location's name. */
65
+ locationName?: string;
66
+ /** The references of the groups it is in, sorted. Absent when it is in none. */
67
+ groups?: string[];
68
+ }
69
+ /** Which writes an approval policy covers (RFC 0004 A4); none named is every write it applies to. */
70
+ export type SlotlockApprovalKind = 'single' | 'pool' | 'bundle' | 'move';
71
+ /** A tenant's approval policy (RFC 0004 A4). */
72
+ export interface SlotlockApprovalPolicy {
73
+ ref: string;
74
+ name: string;
75
+ /** The resources it applies to; every one when all are empty. */
76
+ appliesTo: {
77
+ resourceIds?: string[];
78
+ pools?: string[];
79
+ locations?: string[];
80
+ groups?: string[];
81
+ };
82
+ /** What makes a write need it; a write meets it when it meets every condition given. */
83
+ conditions: {
84
+ longerThanMinutes?: number;
85
+ outsideHours?: boolean;
86
+ kinds?: SlotlockApprovalKind[];
87
+ };
88
+ outcome: 'require_approval' | 'refuse';
89
+ /** How long a person has to decide, before the booking's start less `leadMinutes`. */
90
+ approvalTtlMinutes: number;
91
+ leadMinutes: number;
92
+ createdAt: Date;
93
+ updatedAt: Date;
94
+ }
95
+ /** A write waiting for a person, or decided (RFC 0004 A4). */
96
+ export interface SlotlockApproval {
97
+ id: string;
98
+ policyRef: string;
99
+ status: 'pending' | 'granted' | 'declined' | 'expired';
100
+ /** The agent operation it runs when granted, and its arguments. */
101
+ operation: 'slotlock_create_event' | 'slotlock_book_together' | 'slotlock_update_event';
102
+ request: Record<string, unknown>;
103
+ /** Who asked, as the history names an actor (the principal's subject, at most 200 characters). */
104
+ requestedBy: string | null;
105
+ /** The resources and time it holds while it waits. */
106
+ holdIds: string[];
107
+ resourceIds: string[];
108
+ start: Date;
109
+ end: Date;
110
+ expiresAt: Date;
111
+ decidedBy: string | null;
112
+ decidedAt: Date | null;
113
+ reason: string | null;
114
+ createdAt: Date;
115
+ }
116
+ /**
117
+ * A booking, hold or event occurrence as a refusal can name it (`describeBooking`): the time it
118
+ * occupies, buffers included, and whose it is, so a caller is told only what it may see.
119
+ */
120
+ export interface SlotlockBookingDescription {
121
+ id: string;
122
+ resourceId: string;
123
+ status: 'held' | 'confirmed';
124
+ /** The holder of a hold, as the store was given it; null otherwise. */
125
+ holderRef: string | null;
126
+ /** The calendar event this is an occurrence of (its id and reference), and its owner; null for a booking. */
127
+ eventId: string | null;
128
+ eventRef: string | null;
129
+ eventOwnerRef: string | null;
130
+ start: Date;
131
+ end: Date;
46
132
  }
47
133
  /** A confirmed occupation of a resource. The DB EXCLUDE constraint is the overlap arbiter. */
48
134
  export interface SlotlockReservation {
49
135
  id: string;
50
136
  resourceId: string;
51
- /**
52
- * Null only for live holds, rows on a resource that has no tenant (legacy), and parity-shadow rows.
53
- * A booking made by confirming a hold, or by createReservation, on a tenant's resource has one.
54
- */
137
+ /** Null only for live holds, rows on a tenantless (legacy) resource, and parity-shadow rows. */
55
138
  tenantRef: string | null;
56
139
  /**
57
140
  * Stable booking identity inside tenantRef; null exactly when tenantRef is. A confirmed hold, and
@@ -170,17 +253,15 @@ export type CancelExternalReservationResult = {
170
253
  limit: number;
171
254
  };
172
255
  /**
173
- * VAVAILABILITY-style bookable window (RFC 7953 semantics, weekly subset): a weekly-cadence RRULE
174
- * picks the days; the window on each matching day runs `[startMinutes, startMinutes+durationMinutes)`
175
- * after that day's local midnight in the evaluation timezone (`expandRules`' `timezone`, the
176
- * resource's stored zone for `findNextAvailableFor`, UTC when none is given).
256
+ * VAVAILABILITY-style bookable window (RFC 7953, weekly subset): a weekly RRULE picks the days, and
257
+ * each day's window is `[startMinutes, startMinutes+durationMinutes)` after local midnight in the
258
+ * evaluation timezone (UTC when none is given).
177
259
  */
178
260
  export interface WeeklyAvailabilityRule {
179
261
  /** RRULE content line without the `RRULE:` prefix, e.g. `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR`. */
180
262
  rrule: string;
181
- /** Window start, minutes after midnight in the evaluation timezone (UTC when none is given) of each matching day (0..1439). */
263
+ /** Window start, minutes after local midnight (0..1439). */
182
264
  startMinutes: number;
183
- /** Window length in minutes (> 0). */
184
265
  durationMinutes: number;
185
266
  }
186
267
  export type CreateReservationResult = {
@@ -243,7 +324,6 @@ export interface SlotlockAgendaEntry {
243
324
  resourceId: string;
244
325
  start: Date;
245
326
  end: Date;
246
- /** A booking, a calendar event's occurrence, or a live hold. */
247
327
  kind: 'booking' | 'event' | 'hold';
248
328
  /** The booking's reference; null for an event's occurrence and a hold. */
249
329
  reference: string | null;
@@ -447,18 +527,17 @@ export interface PutCalendarEventCommand extends CalendarEventContent {
447
527
  materializationWindow?: Interval;
448
528
  source?: string;
449
529
  /**
450
- * Judge the event as a booking made now against the resource's booking rules (its first
451
- * occurrence for notice and advance, every occurrence for step and hours), and keep the
452
- * resource's turnaround after each occurrence. Off by default: an imported event is what happened
453
- * elsewhere. The agent tools set it.
530
+ * Judge the event as a booking made now against the resource's booking rules (first occurrence
531
+ * for notice and advance, every occurrence for step and hours), and keep the resource's
532
+ * turnaround. Off by default, since an imported event already happened elsewhere; the agent tools
533
+ * set it.
454
534
  */
455
535
  enforceBookingRules?: boolean;
456
536
  /**
457
- * Book the time a live hold keeps: the hold must be `holderRef`'s (`acquireHold`'s
458
- * `principalRef`), on this resource, and cover the event, a single occurrence. In one transaction
459
- * the hold and the rest of its group are released and the event takes the time, so no rival can
460
- * come between. Its notice and advance were judged when it was held; step and hours are judged
461
- * again.
537
+ * Book the time a live hold keeps: the hold must be `holderRef`'s, on this resource, and cover
538
+ * the event (a single occurrence). The hold's group is released and the event takes the time in
539
+ * one transaction, so no rival can come between. Notice and advance were judged at hold time;
540
+ * step and hours are judged again.
462
541
  */
463
542
  fromHold?: {
464
543
  holdId: string;
@@ -515,11 +594,16 @@ export type PutCalendarEventResult = {
515
594
  code: 'revision_conflict';
516
595
  currentRevision: number;
517
596
  }
518
- /** `fromHold` names no live hold of that holder's: released, booked, expired, or another's. */
597
+ /** `fromHold` names no hold of that holder's: released, booked, reaped, or another's. */
519
598
  | {
520
599
  ok: false;
521
600
  code: 'hold_not_found';
522
601
  }
602
+ /** `fromHold`'s hold is the holder's, but it lapsed before it was booked. */
603
+ | {
604
+ ok: false;
605
+ code: 'hold_expired';
606
+ }
523
607
  /** `fromHold`'s hold is on another resource, or does not cover the event. */
524
608
  | {
525
609
  ok: false;
@@ -581,14 +665,11 @@ export type CancelCalendarEventResult = {
581
665
  code: 'revision_conflict';
582
666
  currentRevision: number;
583
667
  };
584
- /**
585
- * A place in the roll's order of due masters: the last one a batch examined. The roll takes the
586
- * masters it has never put back first, then those it has, longest waiting first.
587
- */
668
+ /** A place in the roll's order of due masters: the last one a batch examined. */
588
669
  export interface CalendarHorizonRollCursor {
589
670
  /**
590
- * When the master was due to be examined again, if the roll had put it back; null (or, from a
591
- * cursor of an earlier release, missing) when it had not.
671
+ * When the master was due again, if the roll had deferred it; null (or missing, from an older
672
+ * cursor) when not.
592
673
  */
593
674
  rollRetryAt?: Date | null;
594
675
  materializedEndsAt: Date;
@@ -596,34 +677,27 @@ export interface CalendarHorizonRollCursor {
596
677
  }
597
678
  /** Content-minimized outcome of one bounded recurring-event horizon maintenance batch. */
598
679
  export interface CalendarHorizonRollResult {
599
- /** Due recurring masters examined in deterministic order. */
600
680
  examined: number;
601
681
  /**
602
- * Masters atomically rematerialized from the requested window's start through the later of their
603
- * stored end and the window's: a roll never moves a series' end back.
682
+ * Masters rematerialized through the later of their stored end and the window's: a roll never
683
+ * moves a series' end back.
604
684
  */
605
685
  extended: number;
606
- /**
607
- * Masters whose new occurrence collided; their previous coverage remains intact, and they wait an
608
- * hour before a roll examines them again, behind every master it has not put back.
609
- */
686
+ /** Masters whose new occurrence collided: their coverage stays, and they are deferred an hour. */
610
687
  conflicts: number;
611
688
  /** The ids of those masters: what blocks each is a booking or hold on its new occurrences. */
612
689
  conflictEventIds: string[];
613
690
  /**
614
- * Masters whose rule takes more than the expansion budget to reach the window (one that never
615
- * occurs again, or almost never), or whose roll failed for any other reason; their previous
616
- * coverage remains intact, and they wait an hour
617
- * before a roll examines them again, behind every master it has not put back.
691
+ * Masters over the expansion budget, or whose roll failed otherwise: their coverage stays, and
692
+ * they are deferred an hour.
618
693
  */
619
694
  refused: number;
620
- /** The ids of those masters, to find and fix them. */
621
695
  refusedEventIds: string[];
622
696
  /** True when more due masters remain beyond this bounded batch. */
623
697
  hasMore: boolean;
624
698
  /**
625
- * Pass as `after` for the next batch of this pass, so the masters this batch examined are not
626
- * examined again by every later batch. `null` when none remain.
699
+ * Pass as `after` for the next batch, so this batch's masters are not examined again. `null` when
700
+ * none remain.
627
701
  */
628
702
  next: CalendarHorizonRollCursor | null;
629
703
  }
@@ -635,7 +709,6 @@ export interface CalendarEventRetentionResult {
635
709
  /** True when either class filled its batch, so more rows may already be eligible. */
636
710
  hasMore: boolean;
637
711
  }
638
- /** What erasing a tenant deleted. */
639
712
  /** A tenant's default booking rules (`SlotlockStore.getBookingRuleDefaults`). */
640
713
  export interface SlotlockBookingRuleDefaults {
641
714
  /** The tenant's own defaults, under every pool's; null when it has none. */
@@ -650,13 +723,13 @@ export interface SlotlockTenantErasure {
650
723
  total: number;
651
724
  }
652
725
  export interface BookingRetentionResult {
653
- /** Holds past their expiry: they occupy nothing, and only a rival's write would reap them. */
726
+ /** Holds past their expiry: they occupy nothing, but only a rival's write would reap them. */
654
727
  expiredHoldsDeleted: number;
655
728
  /** Keyed hold answers whose hold has expired: a retry with that key makes a new hold. */
656
729
  holdCommandsDeleted: number;
657
730
  /**
658
- * References a cancel retired with no booking, older than the window: a create with one makes a
659
- * new booking. A cancelled booking's reference stays retired.
731
+ * References a cancel retired with no booking, older than the window. A cancelled booking's
732
+ * reference stays retired.
660
733
  */
661
734
  tombstonesDeleted: number;
662
735
  /** True when any class filled its batch, so more rows may already be eligible. */
package/usage.d.ts ADDED
@@ -0,0 +1,106 @@
1
+ import type { SlotlockAgentUsage } from './agent-server.js';
2
+ import { SLOTLOCK_RECORDED_USAGE_METRICS, SLOTLOCK_USAGE_METRICS, SLOTLOCK_USAGE_RETENTION_DAYS } from './ddl.js';
3
+ import type { SlotlockSql } from './store.js';
4
+ export type SlotlockUsageMetric = (typeof SLOTLOCK_USAGE_METRICS)[number];
5
+ /** What a server counts and records itself; bookings, holds and resources the database counts. */
6
+ export type SlotlockRecordedUsageMetric = (typeof SLOTLOCK_RECORDED_USAGE_METRICS)[number];
7
+ export { SLOTLOCK_RECORDED_USAGE_METRICS, SLOTLOCK_USAGE_METRICS, SLOTLOCK_USAGE_RETENTION_DAYS };
8
+ /** One counted row: a tenant's count of one metric on one UTC day, by key ('' for none) and surface. */
9
+ export interface SlotlockUsageCount {
10
+ tenantRef: string;
11
+ /** The UTC day, `YYYY-MM-DD`. */
12
+ day: string;
13
+ metric: SlotlockRecordedUsageMetric;
14
+ keyId: string;
15
+ origin: string;
16
+ count: number;
17
+ }
18
+ export interface SlotlockUsageMeterOptions {
19
+ /** Records one batch once per `flushId`: true when added, false when it was added before. */
20
+ record(flushId: string, rows: readonly SlotlockUsageCount[]): Promise<boolean>;
21
+ /** How often counts are recorded, in milliseconds (default 15,000). 0: only on `flush`. */
22
+ intervalMs?: number;
23
+ now?: () => number;
24
+ /** A batch the database refused (kept for the next flush), or one dropped after too many. */
25
+ onError?: (error: unknown) => void;
26
+ }
27
+ export interface SlotlockUsageMeter {
28
+ /** Count one operation (the agent server's `onUsage`). Never throws, never waits. */
29
+ count(usage: SlotlockAgentUsage): void;
30
+ /**
31
+ * Count something else a server sees: webhook deliveries an endpoint accepted, messages to the
32
+ * assistant. Never throws, never waits; a count below 1 is ignored.
33
+ */
34
+ add(usage: {
35
+ tenantRef: string;
36
+ metric: Exclude<SlotlockRecordedUsageMetric, 'operations'>;
37
+ origin: string;
38
+ count?: number;
39
+ keyId?: string;
40
+ }): void;
41
+ /**
42
+ * Record what has been counted. A batch the database refuses is kept, with its id, and sent again
43
+ * first next time, so a batch whose answer was lost is never added twice.
44
+ */
45
+ flush(): Promise<void>;
46
+ /** Stop the timer and record what is left. */
47
+ close(): Promise<void>;
48
+ /** Rows counted and not yet recorded, held batches included. */
49
+ pending(): number;
50
+ /**
51
+ * What this meter has counted of a tenant's metric on `fromDay` (`YYYY-MM-DD`) or later and not
52
+ * yet recorded, batches held or being sent included, and how much of it it has counted since it
53
+ * started: quotas.ts adds them to what the database holds.
54
+ */
55
+ local(tenantRef: string, metric: SlotlockRecordedUsageMetric, fromDay: string): {
56
+ unrecorded: number;
57
+ counted: number;
58
+ };
59
+ }
60
+ /** The UTC day of an instant, `YYYY-MM-DD`. */
61
+ export declare function slotlockUsageDay(at: number): string;
62
+ export declare function createSlotlockUsageMeter(options: SlotlockUsageMeterOptions): SlotlockUsageMeter;
63
+ /** `slotlock.record_usage`: one batch of counts, once per flush id. */
64
+ export declare function recordSlotlockUsage(sql: SlotlockSql, flushId: string, rows: readonly SlotlockUsageCount[]): Promise<boolean>;
65
+ /** One stored row of a tenant's usage. */
66
+ export interface SlotlockUsageRow {
67
+ day: string;
68
+ metric: SlotlockUsageMetric;
69
+ keyId: string;
70
+ origin: string;
71
+ count: number;
72
+ }
73
+ /** The tenant's usage from `from` to `to`, UTC days inclusive (at most SLOTLOCK_USAGE_RETENTION_DAYS apart). */
74
+ export declare function readSlotlockTenantUsage(sql: SlotlockSql, tenantRef: string, from: string, to: string): Promise<SlotlockUsageRow[]>;
75
+ /**
76
+ * Each metric's total over some rows: every metric present, 0 when unused. Resources are a count
77
+ * each day, not something used up, so theirs is the latest day's.
78
+ */
79
+ export declare function totalSlotlockUsage(rows: readonly SlotlockUsageRow[]): Record<SlotlockUsageMetric, number>;
80
+ /** Operations, bookings and holds together: one line of a usage summary. */
81
+ export type SlotlockUsageTotals = Record<SlotlockUsageMetric, number>;
82
+ /** A tenant's usage over a range, as `GET /v1/usage` and the Plan page show it. */
83
+ export interface SlotlockUsageSummary {
84
+ totals: SlotlockUsageTotals;
85
+ /** Each UTC day with any use, in order. */
86
+ days: Array<{
87
+ day: string;
88
+ } & SlotlockUsageTotals>;
89
+ /** Each API key with any use (`keyId` null: no key, such as the assistant), most operations first; no resources. */
90
+ keys: Array<{
91
+ keyId: string | null;
92
+ } & SlotlockUsageTotals>;
93
+ /** Each surface with any use (mcp, a2a, rest, application, dashboard-assistant, webhooks), most first; no resources. */
94
+ surfaces: Array<{
95
+ origin: string;
96
+ } & SlotlockUsageTotals>;
97
+ }
98
+ /** Totals, by day, by key and by surface, from stored rows: bounded however many rows there are. */
99
+ export declare function summarizeSlotlockUsage(rows: readonly SlotlockUsageRow[]): SlotlockUsageSummary;
100
+ /** The range `GET /v1/usage` reads when given none: the first of this UTC month to today. */
101
+ export declare function slotlockUsageMonthToDate(at: number): {
102
+ from: string;
103
+ to: string;
104
+ };
105
+ /** Days from `from` to `to`, both `YYYY-MM-DD`: 0 for the same day, negative when `to` is earlier. */
106
+ export declare function slotlockUsageSpanDays(from: string, to: string): number;
package/usage.js ADDED
@@ -0,0 +1,238 @@
1
+ // Usage metering (RFC 0003 part 3): how much each tenant uses the service, by UTC day, API key and
2
+ // surface. Operations are counted by the server that runs them (`createSlotlockUsageMeter`, fed by
3
+ // the agent server's `onUsage`) and recorded in batches; bookings and holds are rolled up from the
4
+ // tenant's feed by maintenance, so a write that rolled back is never counted. Counts only, never
5
+ // arguments or results. Off unless the deployment asks for it (SLOTLOCK_USAGE=on).
6
+ import { randomUUID } from 'node:crypto';
7
+ import { SLOTLOCK_RECORDED_USAGE_METRICS, SLOTLOCK_USAGE_METRICS, SLOTLOCK_USAGE_RETENTION_DAYS, } from './ddl.js';
8
+ export { SLOTLOCK_RECORDED_USAGE_METRICS, SLOTLOCK_USAGE_METRICS, SLOTLOCK_USAGE_RETENTION_DAYS };
9
+ /** Rows one `slotlock.record_usage` call takes. */
10
+ const MAX_BATCH_ROWS = 10_000;
11
+ /** Batches kept for another try while the database refuses them; older ones are dropped, and said so. */
12
+ const MAX_HELD_BATCHES = 100;
13
+ /** PostgreSQL's invalid_parameter_value: record_usage refusing a batch it will never take. */
14
+ const REFUSED_FOR_GOOD = '22023';
15
+ /** Tenants whose running totals one meter keeps (`local`); past this they start again from 0. */
16
+ const MAX_TOTALS = 100_000;
17
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
18
+ /** The UTC day of an instant, `YYYY-MM-DD`. */
19
+ export function slotlockUsageDay(at) {
20
+ return new Date(at).toISOString().slice(0, 10);
21
+ }
22
+ export function createSlotlockUsageMeter(options) {
23
+ const now = options.now ?? Date.now;
24
+ let counted = new Map();
25
+ const held = [];
26
+ // Rows taken from `counted` for the flush under way, until each batch is recorded or held.
27
+ let sending = [];
28
+ // Everything counted since the meter started, by tenant and metric.
29
+ let totals = new Map();
30
+ let running = Promise.resolve();
31
+ const report = (error) => {
32
+ try {
33
+ options.onError?.(error);
34
+ }
35
+ catch {
36
+ // A reporter's failure changes nothing.
37
+ }
38
+ };
39
+ const dropped = (rows, why) => report(Object.assign(new Error(`Slotlock usage: ${String(rows)} counts dropped ${why}`), {
40
+ code: 'usage_dropped',
41
+ }));
42
+ // True when recorded, or when the database refused the batch for good (record_usage's
43
+ // invalid_parameter_value: a day it no longer takes, after an outage past the next day), which is
44
+ // then dropped rather than held in front of every batch after it. False to try again.
45
+ async function send(batch) {
46
+ try {
47
+ await options.record(batch.flushId, batch.rows);
48
+ return true;
49
+ }
50
+ catch (error) {
51
+ report(error);
52
+ if (error?.code === REFUSED_FOR_GOOD) {
53
+ dropped(batch.rows.length, 'that the database will not take');
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ }
59
+ async function flushOnce() {
60
+ // Batches refused before go first, under their own ids.
61
+ while (held.length > 0) {
62
+ if (!(await send(held[0])))
63
+ return;
64
+ held.shift();
65
+ }
66
+ if (counted.size === 0)
67
+ return;
68
+ const rows = [...counted.values()];
69
+ counted = new Map();
70
+ sending = rows;
71
+ try {
72
+ for (let start = 0; start < rows.length; start += MAX_BATCH_ROWS) {
73
+ const batch = { flushId: randomUUID(), rows: rows.slice(start, start + MAX_BATCH_ROWS) };
74
+ if (held.length > 0 || !(await send(batch)))
75
+ held.push(batch);
76
+ sending = rows.slice(start + MAX_BATCH_ROWS);
77
+ }
78
+ }
79
+ finally {
80
+ sending = [];
81
+ }
82
+ while (held.length > MAX_HELD_BATCHES) {
83
+ const oldest = held.shift();
84
+ dropped(oldest?.rows.length ?? 0, `after the database refused them ${String(MAX_HELD_BATCHES)} times`);
85
+ }
86
+ }
87
+ const flush = () => {
88
+ running = running.then(flushOnce, flushOnce);
89
+ return running;
90
+ };
91
+ const interval = options.intervalMs ?? 15_000;
92
+ const timer = interval > 0 ? setInterval(() => void flush(), interval) : null;
93
+ timer?.unref?.();
94
+ function tally(tenantRef, metric, keyIdGiven, origin, count) {
95
+ try {
96
+ if (typeof tenantRef !== 'string' || tenantRef.length === 0)
97
+ return;
98
+ if (!Number.isSafeInteger(count) || count < 1)
99
+ return;
100
+ const keyId = typeof keyIdGiven === 'string' && UUID.test(keyIdGiven) ? keyIdGiven : '';
101
+ const day = slotlockUsageDay(now());
102
+ const key = `${tenantRef}\0${day}\0${metric}\0${keyId}\0${origin}`;
103
+ const found = counted.get(key);
104
+ if (found)
105
+ found.count += count;
106
+ else
107
+ counted.set(key, { tenantRef, day, metric, keyId, origin, count });
108
+ const total = `${tenantRef}\0${metric}`;
109
+ if (!totals.has(total) && totals.size >= MAX_TOTALS)
110
+ totals = new Map();
111
+ totals.set(total, (totals.get(total) ?? 0) + count);
112
+ }
113
+ catch {
114
+ // Metering never changes a call.
115
+ }
116
+ }
117
+ return {
118
+ count({ principal, origin }) {
119
+ tally(principal.tenantRef, 'operations', principal.keyId, origin, 1);
120
+ },
121
+ add({ tenantRef, metric, origin, count = 1, keyId }) {
122
+ tally(tenantRef, metric, keyId, origin, count);
123
+ },
124
+ flush,
125
+ async close() {
126
+ if (timer)
127
+ clearInterval(timer);
128
+ await flush();
129
+ },
130
+ pending() {
131
+ return [...counted.values()].length + held.reduce((sum, batch) => sum + batch.rows.length, 0);
132
+ },
133
+ local(tenantRef, metric, fromDay) {
134
+ let unrecorded = 0;
135
+ const add = (row) => {
136
+ if (row.tenantRef === tenantRef && row.metric === metric && row.day >= fromDay) {
137
+ unrecorded += row.count;
138
+ }
139
+ };
140
+ for (const row of counted.values())
141
+ add(row);
142
+ for (const row of sending)
143
+ add(row);
144
+ for (const batch of held)
145
+ for (const row of batch.rows)
146
+ add(row);
147
+ return { unrecorded, counted: totals.get(`${tenantRef}\0${metric}`) ?? 0 };
148
+ },
149
+ };
150
+ }
151
+ /** `slotlock.record_usage`: one batch of counts, once per flush id. */
152
+ export async function recordSlotlockUsage(sql, flushId, rows) {
153
+ const payload = rows.map((row) => ({
154
+ tenant_ref: row.tenantRef,
155
+ day: row.day,
156
+ metric: row.metric,
157
+ key_id: row.keyId,
158
+ origin: row.origin,
159
+ count: row.count,
160
+ }));
161
+ const [result] = await sql `
162
+ SELECT slotlock.record_usage(${flushId}::uuid, ${JSON.stringify(payload)}::text::jsonb) AS added`;
163
+ return result?.added === true;
164
+ }
165
+ /** The tenant's usage from `from` to `to`, UTC days inclusive (at most SLOTLOCK_USAGE_RETENTION_DAYS apart). */
166
+ export async function readSlotlockTenantUsage(sql, tenantRef, from, to) {
167
+ const rows = await sql `SELECT * FROM slotlock.tenant_usage(${tenantRef}::text, ${from}::date, ${to}::date)`;
168
+ return rows.map((row) => ({
169
+ day: typeof row.day === 'string' ? row.day.slice(0, 10) : slotlockUsageDay(row.day.getTime()),
170
+ metric: row.metric,
171
+ keyId: row.key_id,
172
+ origin: row.origin,
173
+ count: Number(row.count),
174
+ }));
175
+ }
176
+ /**
177
+ * Each metric's total over some rows: every metric present, 0 when unused. Resources are a count
178
+ * each day, not something used up, so theirs is the latest day's.
179
+ */
180
+ export function totalSlotlockUsage(rows) {
181
+ const totals = Object.fromEntries(SLOTLOCK_USAGE_METRICS.map((metric) => [metric, 0]));
182
+ let resourcesDay = '';
183
+ for (const row of rows) {
184
+ if (row.metric !== 'resources')
185
+ totals[row.metric] += row.count;
186
+ else if (row.day > resourcesDay) {
187
+ resourcesDay = row.day;
188
+ totals.resources = row.count;
189
+ }
190
+ else if (row.day === resourcesDay)
191
+ totals.resources += row.count;
192
+ }
193
+ return totals;
194
+ }
195
+ /** Totals, by day, by key and by surface, from stored rows: bounded however many rows there are. */
196
+ export function summarizeSlotlockUsage(rows) {
197
+ const zero = () => totalSlotlockUsage([]);
198
+ const days = new Map();
199
+ const keys = new Map();
200
+ const surfaces = new Map();
201
+ const add = (map, name, row) => {
202
+ const found = map.get(name) ?? zero();
203
+ found[row.metric] += row.count;
204
+ map.set(name, found);
205
+ };
206
+ for (const row of rows) {
207
+ add(days, row.day, row);
208
+ // A day's resource count belongs to no key or surface.
209
+ if (row.metric === 'resources')
210
+ continue;
211
+ add(keys, row.keyId, row);
212
+ add(surfaces, row.origin, row);
213
+ }
214
+ const byUse = (a, b) => b.operations - a.operations ||
215
+ b.bookings - a.bookings ||
216
+ b.holds - a.holds ||
217
+ b.webhook_deliveries - a.webhook_deliveries ||
218
+ b.assistant_messages - a.assistant_messages;
219
+ return {
220
+ totals: totalSlotlockUsage(rows),
221
+ days: [...days].sort(([a], [b]) => a.localeCompare(b)).map(([day, totals]) => ({ day, ...totals })),
222
+ keys: [...keys]
223
+ .sort(([a, x], [b, y]) => byUse(x, y) || a.localeCompare(b))
224
+ .map(([keyId, totals]) => ({ keyId: keyId === '' ? null : keyId, ...totals })),
225
+ surfaces: [...surfaces]
226
+ .sort(([a, x], [b, y]) => byUse(x, y) || a.localeCompare(b))
227
+ .map(([origin, totals]) => ({ origin, ...totals })),
228
+ };
229
+ }
230
+ /** The range `GET /v1/usage` reads when given none: the first of this UTC month to today. */
231
+ export function slotlockUsageMonthToDate(at) {
232
+ const to = slotlockUsageDay(at);
233
+ return { from: `${to.slice(0, 8)}01`, to };
234
+ }
235
+ /** Days from `from` to `to`, both `YYYY-MM-DD`: 0 for the same day, negative when `to` is earlier. */
236
+ export function slotlockUsageSpanDays(from, to) {
237
+ return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000);
238
+ }