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/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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
27
|
-
*
|
|
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:
|
|
37
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
|
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 (
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
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
|
|
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
|
|
591
|
-
* cursor
|
|
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
|
|
603
|
-
*
|
|
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
|
|
615
|
-
*
|
|
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
|
|
626
|
-
*
|
|
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,
|
|
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
|
|
659
|
-
*
|
|
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
|
+
}
|