slotlock 0.6.3 → 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 (70) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  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 +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +40 -0
  17. package/assistant.js +115 -0
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-icon.d.ts +1 -0
  23. package/dashboard-icon.js +5 -0
  24. package/dashboard.d.ts +203 -7
  25. package/dashboard.js +1545 -64
  26. package/ddl.d.ts +51 -12
  27. package/ddl.js +2566 -54
  28. package/event-feed.d.ts +30 -4
  29. package/event-feed.js +171 -19
  30. package/index.d.ts +15 -4
  31. package/index.js +8 -2
  32. package/maintenance.d.ts +10 -0
  33. package/maintenance.js +16 -2
  34. package/metrics.d.ts +41 -0
  35. package/metrics.js +22 -0
  36. package/openapi.json +14675 -5957
  37. package/otel.d.ts +86 -0
  38. package/otel.js +273 -0
  39. package/overage.d.ts +33 -0
  40. package/overage.js +115 -0
  41. package/package.json +1 -1
  42. package/partner-billing.d.ts +81 -0
  43. package/partner-billing.js +333 -0
  44. package/partner-names.d.ts +8 -0
  45. package/partner-names.js +10 -0
  46. package/partners.d.ts +147 -0
  47. package/partners.js +375 -0
  48. package/plans.d.ts +92 -0
  49. package/plans.js +115 -0
  50. package/quotas.d.ts +108 -0
  51. package/quotas.js +258 -0
  52. package/rate-limit.d.ts +21 -1
  53. package/rate-limit.js +82 -15
  54. package/resource-profile.d.ts +135 -0
  55. package/resource-profile.js +263 -0
  56. package/rest-api.d.ts +2 -0
  57. package/rest-api.js +68 -3
  58. package/sandbox.d.ts +70 -0
  59. package/sandbox.js +295 -0
  60. package/self-host.d.ts +33 -5
  61. package/self-host.js +950 -22
  62. package/slotlock.manifest.json +1 -1
  63. package/store.d.ts +373 -4
  64. package/store.js +1212 -118
  65. package/teams.d.ts +25 -0
  66. package/teams.js +63 -0
  67. package/timezone.js +13 -1
  68. package/types.d.ts +95 -1
  69. package/usage.d.ts +106 -0
  70. package/usage.js +238 -0
package/teams.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { SlotlockSql } from './store.js';
2
+ /** What the dashboard reads and changes of the organizations turned on (SLOTLOCK_DASHBOARD_ORGS=enabled). */
3
+ export interface SlotlockDashboardTeams {
4
+ /** Of these organization ids, the ones turned on. */
5
+ enabled(ids: readonly string[]): Promise<ReadonlySet<string>>;
6
+ /** Turn Slotlock on for an organization, by an owner of it (`github:<id>`). True when it was off. */
7
+ enable(org: {
8
+ id: string;
9
+ login: string;
10
+ }, by: string): Promise<boolean>;
11
+ /** Turn it off: its keys stop at once, and its data stay. True when it was on. */
12
+ disable(id: string, by: string): Promise<boolean>;
13
+ }
14
+ /** The organizations turned on, through slotlock.enabled_organizations and its two writers. */
15
+ export declare function createSlotlockDashboardTeams(sql: SlotlockSql): SlotlockDashboardTeams;
16
+ /**
17
+ * Whether an organization is turned on, for `serve`'s keys with SLOTLOCK_DASHBOARD_ORGS=enabled:
18
+ * read through slotlock.enabled_organizations and kept for `ttlMs` (30 seconds), so turning one off
19
+ * stops its keys on every server within that, without a query per request.
20
+ */
21
+ export declare function createSlotlockOrganizationCheck(sql: SlotlockSql, options?: {
22
+ ttlMs?: number;
23
+ now?: () => number;
24
+ maxEntries?: number;
25
+ }): (orgId: string) => Promise<boolean>;
package/teams.js ADDED
@@ -0,0 +1,63 @@
1
+ const ORGANIZATION_ID = /^[1-9]\d{0,19}$/;
2
+ const GITHUB_LOGIN = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/;
3
+ const PERSON = /^github:[1-9]\d{0,19}$/;
4
+ function refuse(message) {
5
+ throw Object.assign(new Error(`Slotlock organizations: ${message}`), { code: 'invalid_organization' });
6
+ }
7
+ /** The organizations turned on, through slotlock.enabled_organizations and its two writers. */
8
+ export function createSlotlockDashboardTeams(sql) {
9
+ return {
10
+ async enabled(ids) {
11
+ const valid = ids.filter((id) => ORGANIZATION_ID.test(id));
12
+ if (valid.length === 0)
13
+ return new Set();
14
+ const rows = await sql `
15
+ SELECT org_id FROM slotlock.enabled_organizations(${valid.join(',')}::text) AS org_id`;
16
+ return new Set(rows.map((row) => row.org_id));
17
+ },
18
+ async enable(org, by) {
19
+ if (!ORGANIZATION_ID.test(org.id) || !GITHUB_LOGIN.test(org.login) || !PERSON.test(by)) {
20
+ refuse('needs an organization id, its login and the person turning it on');
21
+ }
22
+ const [row] = await sql `
23
+ SELECT slotlock.enable_organization(${org.id}::text, ${org.login}::text, ${by}::text) AS changed`;
24
+ return row?.changed === true;
25
+ },
26
+ async disable(id, by) {
27
+ if (!ORGANIZATION_ID.test(id) || !PERSON.test(by))
28
+ refuse('needs an organization id and the person turning it off');
29
+ const [row] = await sql `
30
+ SELECT slotlock.disable_organization(${id}::text, ${by}::text) AS changed`;
31
+ return row?.changed === true;
32
+ },
33
+ };
34
+ }
35
+ /**
36
+ * Whether an organization is turned on, for `serve`'s keys with SLOTLOCK_DASHBOARD_ORGS=enabled:
37
+ * read through slotlock.enabled_organizations and kept for `ttlMs` (30 seconds), so turning one off
38
+ * stops its keys on every server within that, without a query per request.
39
+ */
40
+ export function createSlotlockOrganizationCheck(sql, options = {}) {
41
+ const ttlMs = options.ttlMs ?? 30_000;
42
+ const now = options.now ?? Date.now;
43
+ const maxEntries = options.maxEntries ?? 10_000;
44
+ const known = new Map();
45
+ const teams = createSlotlockDashboardTeams(sql);
46
+ return (orgId) => {
47
+ if (!ORGANIZATION_ID.test(orgId))
48
+ return Promise.resolve(false);
49
+ const cached = known.get(orgId);
50
+ if (cached && now() - cached.at < ttlMs)
51
+ return cached.enabled;
52
+ const enabled = teams.enabled([orgId]).then((found) => found.has(orgId), (error) => {
53
+ // A failed read is not remembered: the next request asks again.
54
+ known.delete(orgId);
55
+ throw error;
56
+ });
57
+ known.delete(orgId);
58
+ known.set(orgId, { at: now(), enabled });
59
+ if (known.size > maxEntries)
60
+ known.delete(known.keys().next().value);
61
+ return enabled;
62
+ };
63
+ }
package/timezone.js CHANGED
@@ -247,6 +247,11 @@ export function canonicalTimeZoneName(timezone) {
247
247
  const invalid = () => Object.assign(new Error(`Invalid timezone: ${timezone}`), { code: 'invalid_timezone' });
248
248
  if (!name || /^[+-]/.test(name))
249
249
  throw invalid();
250
+ // Each name resolves the same for the process's life (its time zone data does not change under
251
+ // it), and building a formatter to ask is the costly part: remembered, valid names only.
252
+ const known = canonicalNames.get(name);
253
+ if (known !== undefined)
254
+ return known;
250
255
  let resolved;
251
256
  try {
252
257
  resolved = new Intl.DateTimeFormat('en-US', { timeZone: name }).resolvedOptions().timeZone;
@@ -256,8 +261,15 @@ export function canonicalTimeZoneName(timezone) {
256
261
  }
257
262
  if (/^[+-]/.test(resolved))
258
263
  throw invalid();
259
- return resolved.toLowerCase() === name.toLowerCase() ? resolved : name;
264
+ const canonical = resolved.toLowerCase() === name.toLowerCase() ? resolved : name;
265
+ if (canonicalNames.size >= MAX_CANONICAL_NAMES)
266
+ canonicalNames.clear();
267
+ canonicalNames.set(name, canonical);
268
+ return canonical;
260
269
  }
270
+ /** Names canonicalTimeZoneName resolved; a few hundred zones exist, so this rarely fills. */
271
+ const canonicalNames = new Map();
272
+ const MAX_CANONICAL_NAMES = 2_000;
261
273
  /**
262
274
  * The civil date and ISO weekday an instant falls on in `timezone` (the inverse of {@link
263
275
  * zonedDateTimeToUtc}): a pickup at 00:30 BST on a Friday is a Friday, whatever UTC says.
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;
@@ -40,6 +41,94 @@ export interface SlotlockResource {
40
41
  * (`slotlock_book_pool`).
41
42
  */
42
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;
43
132
  }
44
133
  /** A confirmed occupation of a resource. The DB EXCLUDE constraint is the overlap arbiter. */
45
134
  export interface SlotlockReservation {
@@ -505,11 +594,16 @@ export type PutCalendarEventResult = {
505
594
  code: 'revision_conflict';
506
595
  currentRevision: number;
507
596
  }
508
- /** `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. */
509
598
  | {
510
599
  ok: false;
511
600
  code: 'hold_not_found';
512
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
+ }
513
607
  /** `fromHold`'s hold is on another resource, or does not cover the event. */
514
608
  | {
515
609
  ok: false;
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
+ }