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/event-feed.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Sql, TransactionSql } from 'postgres';
2
2
  type SlotlockSql = Sql | TransactionSql;
3
3
  /** What a change can be, as the feed and a webhook's `event_types` name it. */
4
- export declare const SLOTLOCK_CHANGE_TYPES: readonly ["hold.created", "hold.updated", "hold.confirmed", "hold.released", "hold.expired", "reservation.created", "reservation.updated", "reservation.cancelled", "reservation.checked_in", "reservation.no_show", "event.created", "event.updated", "event.cancelled", "event.timezone_shifted", "event.timezone_conflict", "coverage.stale", "waitlist.offered", "waitlist.expired", "webhook.disabled", "resource.created", "resource.updated", "resource.archived", "resource.restored", "resource.deleted", "coverage.recorded", "coverage.deleted", "webhook.created", "webhook.enabled", "webhook.deleted", "key.created", "key.rotated", "key.revoked"];
4
+ export declare const SLOTLOCK_CHANGE_TYPES: readonly ["hold.created", "hold.updated", "hold.confirmed", "hold.released", "hold.expired", "reservation.created", "reservation.updated", "reservation.cancelled", "reservation.checked_in", "reservation.no_show", "event.created", "event.updated", "event.cancelled", "event.timezone_shifted", "event.timezone_conflict", "coverage.stale", "waitlist.offered", "waitlist.expired", "approval.requested", "approval.granted", "approval.declined", "approval.expired", "webhook.disabled", "usage.threshold_reached", "resource.created", "resource.updated", "resource.archived", "resource.restored", "resource.deleted", "coverage.recorded", "coverage.deleted", "webhook.created", "webhook.enabled", "webhook.deleted", "key.created", "key.rotated", "key.revoked"];
5
5
  export type SlotlockChangeType = (typeof SLOTLOCK_CHANGE_TYPES)[number];
6
6
  /**
7
7
  * Where a change came from (withTenant's `origin`): an agent protocol (`mcp`, `a2a`), the REST
@@ -39,7 +39,7 @@ export declare function slotlockServedChange(type: string, subject: string, data
39
39
  /** How long the feed keeps a change, and so how far back a cursor may reach. */
40
40
  export declare const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
41
41
  /** Webhook endpoints one tenant may have. */
42
- export declare const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 10;
42
+ export declare const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 25;
43
43
  /** After this long failing, an endpoint is disabled and the feed says so (`webhook.disabled`). */
44
44
  export declare const SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS: number;
45
45
  export interface SlotlockChange {
@@ -60,11 +60,29 @@ export interface SlotlockChange {
60
60
  /** Pass as `after` to read what follows this change. */
61
61
  position: string;
62
62
  }
63
+ /** Resources one webhook endpoint's filter may name. */
64
+ export declare const SLOTLOCK_WEBHOOK_FILTER_RESOURCES = 100;
65
+ /** Pools, locations or groups one webhook endpoint's filter may name, of each. */
66
+ export declare const SLOTLOCK_WEBHOOK_FILTER_REFS = 20;
67
+ /**
68
+ * Which resources' changes an endpoint receives (RFC 0003 G5): only changes about a resource that
69
+ * matches one of these, as it is when the change is delivered, so a change about no resource (a key,
70
+ * a webhook, a usage notice) is not sent. A location includes every location in it. At most
71
+ * SLOTLOCK_WEBHOOK_FILTER_RESOURCES ids, and SLOTLOCK_WEBHOOK_FILTER_REFS of each of the others.
72
+ */
73
+ export interface SlotlockWebhookFilter {
74
+ resourceIds?: string[];
75
+ pools?: string[];
76
+ locations?: string[];
77
+ groups?: string[];
78
+ }
63
79
  export interface SlotlockWebhookEndpoint {
64
80
  id: string;
65
81
  url: string;
66
82
  /** Null: every type. */
67
83
  eventTypes: SlotlockChangeType[] | null;
84
+ /** Only changes about these resources; absent, changes about any resource or none. */
85
+ filter?: SlotlockWebhookFilter;
68
86
  createdAt: Date;
69
87
  /** Failed attempts since the last delivery that succeeded. */
70
88
  attempts: number;
@@ -90,10 +108,11 @@ export interface SlotlockWebhookClaim {
90
108
  export type CreateWebhookEndpointResult = {
91
109
  ok: true;
92
110
  endpoint: SlotlockWebhookEndpoint;
93
- secret: string;
111
+ secret: string; /** True when an idempotency key answered what an earlier create made. */
112
+ replayed?: boolean;
94
113
  } | {
95
114
  ok: false;
96
- code: 'invalid_webhook_url' | 'invalid_event_types';
115
+ code: 'invalid_webhook_url' | 'invalid_event_types' | 'invalid_webhook_filter' | 'idempotency_conflict' | 'invalid_identity';
97
116
  } | {
98
117
  ok: false;
99
118
  code: 'webhook_limit_reached';
@@ -136,6 +155,13 @@ export interface SlotlockEventFeed {
136
155
  tenantRef: string;
137
156
  url: string;
138
157
  eventTypes?: readonly string[];
158
+ /** Only changes about these resources (`invalid_webhook_filter` past its bounds). */
159
+ filter?: SlotlockWebhookFilter;
160
+ /**
161
+ * A retry identity, kept as long as the endpoint: the same key and request answer the endpoint
162
+ * and secret the first made (`replayed`), another request `idempotency_conflict`.
163
+ */
164
+ idempotencyKey?: string;
139
165
  }): Promise<CreateWebhookEndpointResult>;
140
166
  listWebhookEndpoints(params: {
141
167
  tenantRef: string;
package/event-feed.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // The event feed: what changed for a tenant, read by position, and the webhook endpoints it is
2
2
  // delivered to. Rows are appended by triggers in the changing transaction (ddl.ts,
3
3
  // SLOTLOCK_OUTBOX_DDL); webhooks.ts signs and sends.
4
- import { randomBytes } from 'node:crypto';
4
+ import { slotlockPlanReadRefusal } from './plans.js';
5
+ import { createHash, randomBytes } from 'node:crypto';
5
6
  /** A transaction on a client, or a savepoint inside the caller's (a tenant's, say). */
6
7
  async function inTransaction(sql, callback) {
7
8
  return ('begin' in sql ? sql.begin(callback) : sql.savepoint(callback));
@@ -26,7 +27,14 @@ export const SLOTLOCK_CHANGE_TYPES = Object.freeze([
26
27
  'coverage.stale',
27
28
  'waitlist.offered',
28
29
  'waitlist.expired',
30
+ // A write waiting for a person (approval policies): requested, then granted, declined or expired.
31
+ 'approval.requested',
32
+ 'approval.granted',
33
+ 'approval.declined',
34
+ 'approval.expired',
29
35
  'webhook.disabled',
36
+ // A plan's monthly quota 80% or 100% used (quotas.ts): the metric, the share and the counts.
37
+ 'usage.threshold_reached',
30
38
  // Configuration changes: ids and states, never a secret or a URL.
31
39
  'resource.created',
32
40
  'resource.updated',
@@ -102,9 +110,13 @@ export function slotlockServedChange(type, subject, data) {
102
110
  /** How long the feed keeps a change, and so how far back a cursor may reach. */
103
111
  export const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
104
112
  /** Webhook endpoints one tenant may have. */
105
- export const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 10;
113
+ export const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 25;
106
114
  /** After this long failing, an endpoint is disabled and the feed says so (`webhook.disabled`). */
107
115
  export const SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS = 24 * 60 * 60 * 1_000;
116
+ /** Resources one webhook endpoint's filter may name. */
117
+ export const SLOTLOCK_WEBHOOK_FILTER_RESOURCES = 100;
118
+ /** Pools, locations or groups one webhook endpoint's filter may name, of each. */
119
+ export const SLOTLOCK_WEBHOOK_FILTER_REFS = 20;
108
120
  function encodePosition(position) {
109
121
  return Buffer.from(`2:${position.txid}:${position.id}:${position.at}`).toString('base64url');
110
122
  }
@@ -134,7 +146,7 @@ function toChange(row) {
134
146
  };
135
147
  }
136
148
  function toEndpoint(row) {
137
- return {
149
+ const endpoint = {
138
150
  id: row.id,
139
151
  url: row.url,
140
152
  eventTypes: row.event_types,
@@ -146,9 +158,85 @@ function toEndpoint(row) {
146
158
  disabledAt: row.disabled_at,
147
159
  disabledReason: row.disabled_reason,
148
160
  };
161
+ const filter = {};
162
+ if (row.filter_resource_ids)
163
+ filter.resourceIds = row.filter_resource_ids;
164
+ if (row.filter_pools)
165
+ filter.pools = row.filter_pools;
166
+ if (row.filter_locations)
167
+ filter.locations = row.filter_locations;
168
+ if (row.filter_groups)
169
+ filter.groups = row.filter_groups;
170
+ if (Object.keys(filter).length > 0)
171
+ endpoint.filter = filter;
172
+ return endpoint;
149
173
  }
150
174
  const ENDPOINT_COLUMNS = (sql) => sql `id, url, event_types, created_at, attempts, failing_since, last_status, last_error,
151
- disabled_at, disabled_reason`;
175
+ disabled_at, disabled_reason, filter_resource_ids::text[], filter_pools, filter_locations,
176
+ filter_groups`;
177
+ /** A pool's, location's or group's reference. */
178
+ const GROUPING_REF = /^[a-z0-9][a-z0-9._-]{0,99}$/;
179
+ /**
180
+ * A webhook filter as it is stored, each list without repeats, or null when one is out of bounds.
181
+ * An empty filter, or empty lists, filter nothing.
182
+ */
183
+ function parseWebhookFilter(filter) {
184
+ const list = (values, max, valid) => {
185
+ if (values === undefined)
186
+ return null;
187
+ if (!Array.isArray(values))
188
+ return false;
189
+ const kept = [...new Set(values)];
190
+ if (kept.length === 0)
191
+ return null;
192
+ if (kept.length > max || !kept.every((value) => typeof value === 'string' && valid(value)))
193
+ return false;
194
+ return kept.sort();
195
+ };
196
+ const resourceIds = list(filter?.resourceIds, SLOTLOCK_WEBHOOK_FILTER_RESOURCES, (value) => UUID.test(value));
197
+ const pools = list(filter?.pools, SLOTLOCK_WEBHOOK_FILTER_REFS, (value) => GROUPING_REF.test(value));
198
+ const locations = list(filter?.locations, SLOTLOCK_WEBHOOK_FILTER_REFS, (value) => GROUPING_REF.test(value));
199
+ const groups = list(filter?.groups, SLOTLOCK_WEBHOOK_FILTER_REFS, (value) => GROUPING_REF.test(value));
200
+ if (resourceIds === false || pools === false || locations === false || groups === false)
201
+ return null;
202
+ return {
203
+ resourceIds: resourceIds?.map((id) => id.toLowerCase()) ?? null,
204
+ pools,
205
+ locations,
206
+ groups,
207
+ };
208
+ }
209
+ /**
210
+ * Whether a change (`change`, a row of slotlock.outbox) passes an endpoint's filter (`due`, a row of
211
+ * slotlock.webhook_endpoints): no filter, or a resource it names, or one in a pool, at a location
212
+ * (or a location in it) or in a group it names, as the resource is now; or a change to a resource
213
+ * that says it was in one (resource.created, resource.deleted, or resource.updated's `before`),
214
+ * so an endpoint also learns of a resource deleted, or moved out of what it follows.
215
+ */
216
+ const FILTER_MATCH = `(
217
+ (due.filter_resource_ids IS NULL AND due.filter_pools IS NULL
218
+ AND due.filter_locations IS NULL AND due.filter_groups IS NULL)
219
+ OR change.resource_id = ANY (due.filter_resource_ids)
220
+ OR EXISTS (
221
+ SELECT 1 FROM slotlock.resources held
222
+ WHERE held.tenant_ref = change.tenant_ref AND held.id = change.resource_id
223
+ AND (held.pool_ref = ANY (due.filter_pools)
224
+ OR (held.location_ref IS NOT NULL
225
+ AND slotlock.location_path(held.tenant_ref, held.location_ref) && due.filter_locations)
226
+ OR EXISTS (SELECT 1 FROM slotlock.resource_group_members member
227
+ WHERE member.tenant_ref = held.tenant_ref AND member.resource_id = held.id
228
+ AND member.group_ref = ANY (due.filter_groups))))
229
+ OR (change.type LIKE 'resource.%' AND (
230
+ (change.data ->> 'pool') = ANY (due.filter_pools)
231
+ OR (change.data #>> '{before,pool}') = ANY (due.filter_pools)
232
+ OR EXISTS (
233
+ SELECT 1 FROM unnest(ARRAY[change.data ->> 'location', change.data #>> '{before,location}']) was(ref)
234
+ WHERE was.ref IS NOT NULL
235
+ AND (was.ref = ANY (due.filter_locations)
236
+ OR slotlock.location_path(change.tenant_ref, was.ref) && due.filter_locations))
237
+ OR (jsonb_typeof(change.data -> 'groups') = 'array' AND (change.data -> 'groups') ?| due.filter_groups)
238
+ OR (jsonb_typeof(change.data #> '{before,groups}') = 'array'
239
+ AND (change.data #> '{before,groups}') ?| due.filter_groups))))`;
152
240
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
153
241
  function isIdentity(value) {
154
242
  return typeof value === 'string' && Buffer.byteLength(value) >= 1 && Buffer.byteLength(value) <= 500;
@@ -268,25 +356,60 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
268
356
  !types.every((type) => SLOTLOCK_CHANGE_TYPES.includes(type)))) {
269
357
  return { ok: false, code: 'invalid_event_types' };
270
358
  }
359
+ const filter = parseWebhookFilter(params.filter);
360
+ if (filter === null)
361
+ return { ok: false, code: 'invalid_webhook_filter' };
362
+ const key = params.idempotencyKey;
363
+ if (key !== undefined && !isIdentity(key))
364
+ return { ok: false, code: 'invalid_identity' };
365
+ const payloadHash = key === undefined
366
+ ? null
367
+ : createHash('sha256')
368
+ .update(JSON.stringify([params.url, types === null ? null : [...types].sort(), filter]))
369
+ .digest('hex');
271
370
  const secret = `whsec_${randomBytes(32).toString('base64')}`;
272
371
  return inTransaction(sql, async (tx) => {
273
372
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:webhooks:${params.tenantRef}`}, 0))`;
373
+ if (key !== undefined) {
374
+ // Under the tenant's webhook lock: a retry racing its first finds what the first made.
375
+ const [earlier] = await tx `
376
+ SELECT ${ENDPOINT_COLUMNS(tx)}, secret, payload_hash FROM slotlock.webhook_endpoints
377
+ WHERE tenant_ref = ${params.tenantRef} AND idempotency_key = ${key}`;
378
+ if (earlier) {
379
+ if (earlier.payload_hash !== payloadHash)
380
+ return { ok: false, code: 'idempotency_conflict' };
381
+ return { ok: true, endpoint: toEndpoint(earlier), secret: earlier.secret, replayed: true };
382
+ }
383
+ }
274
384
  const [count] = await tx `
275
- SELECT count(*)::int AS count FROM slotlock.webhook_endpoints
276
- WHERE tenant_ref = ${params.tenantRef}`;
277
- if ((count?.count ?? 0) >= SLOTLOCK_MAX_WEBHOOK_ENDPOINTS) {
278
- return { ok: false, code: 'webhook_limit_reached', limit: SLOTLOCK_MAX_WEBHOOK_ENDPOINTS };
385
+ SELECT count(*)::int AS count,
386
+ (SELECT max_webhook_endpoints FROM slotlock.tenant_plan(${params.tenantRef}::text))
387
+ AS planned
388
+ FROM slotlock.webhook_endpoints
389
+ WHERE tenant_ref = ${params.tenantRef}`.catch((error) => {
390
+ throw slotlockPlanReadRefusal(error);
391
+ });
392
+ // The lower of the server's limit and the tenant's plan (plans.ts), when it has one.
393
+ const limit = Math.min(SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, count?.planned ?? SLOTLOCK_MAX_WEBHOOK_ENDPOINTS);
394
+ if ((count?.count ?? 0) >= limit) {
395
+ return { ok: false, code: 'webhook_limit_reached', limit };
279
396
  }
280
397
  // From now on: every transaction not yet ended is at or after this position.
281
398
  const [row] = await tx `
282
- INSERT INTO slotlock.webhook_endpoints (tenant_ref, url, secret, event_types, cursor_txid)
399
+ INSERT INTO slotlock.webhook_endpoints (
400
+ tenant_ref, url, secret, event_types, cursor_txid,
401
+ filter_resource_ids, filter_pools, filter_locations, filter_groups,
402
+ idempotency_key, payload_hash
403
+ )
283
404
  VALUES (${params.tenantRef}, ${params.url}, ${secret}, ${types}::text[],
284
- pg_snapshot_xmin(pg_current_snapshot()))
405
+ pg_snapshot_xmin(pg_current_snapshot()),
406
+ ${filter.resourceIds}::uuid[], ${filter.pools}::text[], ${filter.locations}::text[],
407
+ ${filter.groups}::text[], ${key ?? null}, ${payloadHash})
285
408
  RETURNING ${ENDPOINT_COLUMNS(tx)}`;
286
409
  if (!row)
287
410
  throw new Error('slotlock: webhook endpoint insert returned no row');
288
411
  await tx `SELECT slotlock.register_webhook_tenant(${params.tenantRef}::text)`;
289
- return { ok: true, endpoint: toEndpoint(row), secret };
412
+ return { ok: true, endpoint: toEndpoint(row), secret, replayed: false };
290
413
  });
291
414
  },
292
415
  async listWebhookEndpoints(params) {
@@ -344,6 +467,32 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
344
467
  if (!(Number.isSafeInteger(params.batch) && params.batch >= 1 && params.batch <= 100)) {
345
468
  throw new RangeError('Slotlock webhook batch must be 1-100 changes');
346
469
  }
470
+ // An endpoint that takes only some changes (by type or filter) moves past the ones it does
471
+ // not take, up to the last change that has ended, when none before it is for the endpoint:
472
+ // else its cursor would stay where it was made, keeping the whole feed from being pruned and
473
+ // every claim reading it all again. Not one being delivered (leased), nor one waiting to try
474
+ // again (it has a change for it).
475
+ await sql `
476
+ UPDATE slotlock.webhook_endpoints due
477
+ SET cursor_txid = head.txid, cursor_id = head.id
478
+ FROM (SELECT change.txid, change.id FROM slotlock.outbox change
479
+ WHERE change.tenant_ref = ${params.tenantRef}
480
+ AND change.txid < pg_snapshot_xmin(pg_current_snapshot())
481
+ ORDER BY change.txid DESC, change.id DESC
482
+ LIMIT 1) head
483
+ WHERE due.tenant_ref = ${params.tenantRef}
484
+ AND due.disabled_at IS NULL
485
+ AND (due.lease_until IS NULL OR due.lease_until <= now())
486
+ AND (due.event_types IS NOT NULL OR due.filter_resource_ids IS NOT NULL OR due.filter_pools IS NOT NULL
487
+ OR due.filter_locations IS NOT NULL OR due.filter_groups IS NOT NULL)
488
+ AND (head.txid, head.id) > (due.cursor_txid, due.cursor_id)
489
+ AND NOT EXISTS (
490
+ SELECT 1 FROM slotlock.outbox change
491
+ WHERE change.tenant_ref = due.tenant_ref
492
+ AND (change.txid, change.id) > (due.cursor_txid, due.cursor_id)
493
+ AND (change.txid, change.id) <= (head.txid, head.id)
494
+ AND (due.event_types IS NULL OR change.type = ANY (due.event_types))
495
+ AND ${sql.unsafe(FILTER_MATCH)})`;
347
496
  const endpoints = await sql `
348
497
  UPDATE slotlock.webhook_endpoints endpoint
349
498
  SET lease_until = now() + make_interval(secs => ${params.leaseMs / 1_000}),
@@ -360,6 +509,7 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
360
509
  AND change.txid < pg_snapshot_xmin(pg_current_snapshot())
361
510
  AND (change.txid, change.id) > (due.cursor_txid, due.cursor_id)
362
511
  AND (due.event_types IS NULL OR change.type = ANY (due.event_types))
512
+ AND ${sql.unsafe(FILTER_MATCH)}
363
513
  )
364
514
  FOR UPDATE SKIP LOCKED
365
515
  )
@@ -368,14 +518,16 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
368
518
  const claims = [];
369
519
  for (const endpoint of endpoints) {
370
520
  const rows = await sql `
371
- SELECT id::text, txid::text, type, resource_id, subject, data, occurred_at, actor, origin,
372
- key_id::text
373
- FROM slotlock.outbox
374
- WHERE tenant_ref = ${params.tenantRef}
375
- AND txid < pg_snapshot_xmin(pg_current_snapshot())
376
- AND (txid, id) > (${endpoint.cursor_txid}::xid8, ${endpoint.cursor_id}::bigint)
377
- AND (${endpoint.event_types}::text[] IS NULL OR type = ANY (${endpoint.event_types}::text[]))
378
- ORDER BY txid, id
521
+ SELECT change.id::text, change.txid::text, change.type, change.resource_id, change.subject,
522
+ change.data, change.occurred_at, change.actor, change.origin, change.key_id::text
523
+ FROM slotlock.outbox change, slotlock.webhook_endpoints due
524
+ WHERE due.id = ${endpoint.id}::uuid
525
+ AND change.tenant_ref = ${params.tenantRef}
526
+ AND change.txid < pg_snapshot_xmin(pg_current_snapshot())
527
+ AND (change.txid, change.id) > (${endpoint.cursor_txid}::xid8, ${endpoint.cursor_id}::bigint)
528
+ AND (${endpoint.event_types}::text[] IS NULL OR change.type = ANY (${endpoint.event_types}::text[]))
529
+ AND ${sql.unsafe(FILTER_MATCH)}
530
+ ORDER BY change.txid, change.id
379
531
  LIMIT ${params.batch}`;
380
532
  claims.push({
381
533
  endpoint: { id: endpoint.id, url: endpoint.url, secret: endpoint.secret, attempts: endpoint.attempts },
package/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference types="node" preserve="true" />
2
2
  export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
3
3
  export { type SlotlockBookingRule, type SlotlockBookingRules, type SlotlockBookingSearch, bookingRuleBroken, bookingSearch, bookingSearchBlocked, bookingSearchReach, nextStepStart, parseBookingRuleDefaults, parseBookingRules, } from './booking-rules.js';
4
+ export { SLOTLOCK_GROUPING_REF, SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_LABEL, SLOTLOCK_RESOURCE_PROFILE_FIELDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, parseSlotlockLocation, parseSlotlockResourceGroup, parseSlotlockResourceProfile, type SlotlockLocation, type SlotlockResourceGroup, type SlotlockResourceAttributes, type SlotlockResourceKind, type SlotlockResourceProfileChange, type SlotlockResourceProfileInput, } from './resource-profile.js';
4
5
  export { type CreateWebhookEndpointResult, type ListChangesResult, SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, SLOTLOCK_CHANGE_TYPES, SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS, slotlockChangeFeedHead, type SlotlockChange, type SlotlockChangeOrigin, type SlotlockChangeType, type SlotlockEventFeed, type SlotlockWebhookClaim, type SlotlockWebhookEndpoint, slotlockCoverageSourceId, } from './event-feed.js';
5
6
  export { type SlotlockCloudEvent, type SlotlockWebhookPassResult, type SlotlockWebhookResponse, type SlotlockWebhookScheduler, type SlotlockWebhookSender, createSlotlockGuardedFetch, createSlotlockWebhookSender, deliverSlotlockWebhooks, isPrivateAddress, signSlotlockWebhook, slotlockCloudEvent, slotlockWebhookRetryDelayMs, startSlotlockWebhookDelivery, } from './webhooks.js';
6
7
  export { type SlotlockMetrics, createSlotlockMetrics } from './metrics.js';
@@ -11,8 +12,8 @@ export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOC
11
12
  export type { EmitVAvailabilityOptions, JSCalendarAlert, JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule, ParsedVAvailability, } from './interop.js';
12
13
  export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
13
14
  export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
14
- export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_WATCH_SKILL, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
15
- export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockArgumentIssue, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
15
+ export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_WATCH_SKILL, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MAX_ALTERNATIVES, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
16
+ export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockAlternative, SlotlockArgumentIssue, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
16
17
  export { createSlotlockNodeServer } from './node-server.js';
17
18
  export type { SlotlockNodeListenOptions, SlotlockNodeServer, SlotlockNodeServerAddress, SlotlockNodeServerCloseResult, SlotlockNodeServerErrorPhase, SlotlockNodeServerFetchTarget, SlotlockNodeServerOptions, } from './node-server.js';
18
19
  export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, createSlotlockStoreChangeSource, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
@@ -22,11 +23,21 @@ export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlock
22
23
  export type { SlotlockApplicationBackend, SlotlockApplicationInvocationContext, SlotlockApplicationOperation, SlotlockApplicationOperationDefinition, } from './application-api.js';
23
24
  export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
24
25
  export type { SlotlockMaintenanceResult, SlotlockMaintenanceScheduler, SlotlockMaintenanceSchedulerOptions, } from './maintenance.js';
25
- export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
26
+ export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, isSlotlockTestTenant, slotlockLiveTenant, SLOTLOCK_TEST_API_KEY_PREFIX, SLOTLOCK_TEST_TENANT_SUFFIX, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
26
27
  export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
27
28
  export type { SlotlockDashboard, SlotlockDashboardAssistantOptions, SlotlockDashboardOptions, SlotlockDashboardResources, SlotlockDashboardState, } from './dashboard.js';
28
29
  export { createSlotlockAssistantStore, createSlotlockMemoryAssistantStore, SLOTLOCK_ASSISTANT_TOOL_USE, } from './assistant.js';
29
30
  export type { SlotlockAssistantStore } from './assistant.js';
31
+ export { createSlotlockPartnerKeyAuthenticator, createSlotlockPartnerStore, parseSlotlockPartnerContract, slotlockPartnerTenantRef, SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_ID, SLOTLOCK_PARTNER_SCOPE, } from './partners.js';
32
+ export { createSlotlockDashboardTeams, createSlotlockOrganizationCheck } from './teams.js';
33
+ export { reportSlotlockOverage, SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT } from './overage.js';
34
+ export type { SlotlockOverageReport } from './overage.js';
35
+ export { createSlotlockOtlpExporter, readSlotlockOtlpConfig, slotlockCallSpan } from './otel.js';
36
+ export type { SlotlockOtlpConfig, SlotlockTraceExporter } from './otel.js';
37
+ export type { SlotlockDashboardTeams } from './teams.js';
38
+ export { closeSlotlockPartnerMonth, priceSlotlockPartnerMonth, slotlockClientLabel, slotlockPartnerMonthChecksum, } from './partner-billing.js';
39
+ export type { SlotlockPartnerAccountMonth, SlotlockPartnerInvoiceLine, SlotlockPartnerMonthClose, } from './partner-billing.js';
40
+ export type { SlotlockPartnerAccount, SlotlockPartnerContract, SlotlockPartnerPlan, SlotlockPartnerPricing, SlotlockPartnerStore, } from './partners.js';
30
41
  export type { CreateSlotlockApiKeyInput, SlotlockApiKey, SlotlockApiKeyAuthenticatorOptions, SlotlockApiKeyChange, SlotlockApiKeyIdentity, SlotlockApiKeyScope, SlotlockApiKeyStore, } from './api-keys.js';
31
42
  export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
32
43
  export type { ICalendarWorkerPool, ICalendarWorkerPoolOptions } from './ical-worker.js';
@@ -34,4 +45,4 @@ export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emit
34
45
  export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, ICalendarFeedRead, ICalendarSeriesRefusal, ITipMethod, } from './sync.js';
35
46
  export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
36
47
  export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_TENANT_WRITE_METHODS, type SlotlockHoldTenantOptions, type SlotlockMigrationOptions, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantLimits, type SlotlockTenantStore, } from './store.js';
37
- export type { AcquireHoldResult, BookingRetentionResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockAgendaEntry, SlotlockHold, SlotlockCalendarEvent, SlotlockReservation, SlotlockResource, SlotlockTenantErasure, SlotlockBookingRuleDefaults, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, WeeklyAvailabilityRule, } from './types.js';
48
+ export type { AcquireHoldResult, BookingRetentionResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockAgendaEntry, SlotlockHold, SlotlockCalendarEvent, SlotlockBookingDescription, SlotlockReservation, SlotlockResource, SlotlockTenantErasure, SlotlockBookingRuleDefaults, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, WeeklyAvailabilityRule, } from './types.js';
package/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  /// <reference types="node" preserve="true" />
6
6
  export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
7
7
  export { bookingRuleBroken, bookingSearch, bookingSearchBlocked, bookingSearchReach, nextStepStart, parseBookingRuleDefaults, parseBookingRules, } from './booking-rules.js';
8
+ export { SLOTLOCK_GROUPING_REF, SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_LABEL, SLOTLOCK_RESOURCE_PROFILE_FIELDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, parseSlotlockLocation, parseSlotlockResourceGroup, parseSlotlockResourceProfile, } from './resource-profile.js';
8
9
  export { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, SLOTLOCK_CHANGE_TYPES, SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS, slotlockChangeFeedHead, slotlockCoverageSourceId, } from './event-feed.js';
9
10
  export { createSlotlockGuardedFetch, createSlotlockWebhookSender, deliverSlotlockWebhooks, isPrivateAddress, signSlotlockWebhook, slotlockCloudEvent, slotlockWebhookRetryDelayMs, startSlotlockWebhookDelivery, } from './webhooks.js';
10
11
  export { createSlotlockMetrics } from './metrics.js';
@@ -12,15 +13,20 @@ export { expandRules } from './rules.js';
12
13
  export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
13
14
  export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, toJSCalendarEvent, } from './interop.js';
14
15
  export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
15
- export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_WATCH_SKILL, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
16
+ export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_WATCH_SKILL, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MAX_ALTERNATIVES, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
16
17
  export { createSlotlockNodeServer } from './node-server.js';
17
18
  export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, createSlotlockStoreChangeSource, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
18
19
  export { SLOTLOCK_FEED_BUSY_MS, SLOTLOCK_FEED_IDLE_MS, } from './change-watch.js';
19
20
  export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, } from './application-api.js';
20
21
  export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
21
- export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
22
+ export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, isSlotlockTestTenant, slotlockLiveTenant, SLOTLOCK_TEST_API_KEY_PREFIX, SLOTLOCK_TEST_TENANT_SUFFIX, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
22
23
  export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
23
24
  export { createSlotlockAssistantStore, createSlotlockMemoryAssistantStore, SLOTLOCK_ASSISTANT_TOOL_USE, } from './assistant.js';
25
+ export { createSlotlockPartnerKeyAuthenticator, createSlotlockPartnerStore, parseSlotlockPartnerContract, slotlockPartnerTenantRef, SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_ID, SLOTLOCK_PARTNER_SCOPE, } from './partners.js';
26
+ export { createSlotlockDashboardTeams, createSlotlockOrganizationCheck } from './teams.js';
27
+ export { reportSlotlockOverage, SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT } from './overage.js';
28
+ export { createSlotlockOtlpExporter, readSlotlockOtlpConfig, slotlockCallSpan } from './otel.js';
29
+ export { closeSlotlockPartnerMonth, priceSlotlockPartnerMonth, slotlockClientLabel, slotlockPartnerMonthChecksum, } from './partner-billing.js';
24
30
  export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
25
31
  export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
26
32
  export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
package/maintenance.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface SlotlockMaintenanceResult {
24
24
  noShows: number;
25
25
  /** Waitlist entries whose window came free, each now holding it (`waitlist.offered`). */
26
26
  waitlistOffers: number;
27
+ /** Approvals no one decided in time, their held time freed (`approval.expired`). */
28
+ approvalsExpired: number;
27
29
  /** More was left than one pass does: run another. */
28
30
  capped: boolean;
29
31
  /**
@@ -41,6 +43,12 @@ export declare function maintainSlotlockTenant(store: SlotlockStore, tenantRef:
41
43
  batches?: number;
42
44
  signal?: AbortSignal;
43
45
  followZoneRules?: boolean;
46
+ /**
47
+ * Roll the tenant's committed bookings and holds up into its usage first (usage.ts), before
48
+ * pruning can delete feed changes it has not counted. For a deployment that meters
49
+ * (SLOTLOCK_USAGE=on).
50
+ */
51
+ usage?: boolean;
44
52
  }): Promise<SlotlockMaintenanceResult>;
45
53
  /**
46
54
  * Run `work` holding the tenant's maintenance lock on a connection of its own, or return
@@ -73,6 +81,8 @@ export interface SlotlockMaintenanceSchedulerOptions {
73
81
  maxTenants?: number;
74
82
  /** Batches of each task per pass (maintainSlotlockTenant). */
75
83
  batches?: number;
84
+ /** Roll each tenant's bookings and holds up into its usage, first (maintainSlotlockTenant `usage`). */
85
+ usage?: boolean;
76
86
  /** A pass that ran: counts only, never the tenant. Not awaited; what it throws is ignored. */
77
87
  onPass?: (result: SlotlockMaintenanceResult) => void;
78
88
  /**
package/maintenance.js CHANGED
@@ -11,6 +11,14 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
11
11
  throw new RangeError('batches must be a whole number from 1 to 100');
12
12
  }
13
13
  const withTenant = (callback) => store.withTenant(tenantRef, callback, { origin: 'maintenance' });
14
+ if (options.usage) {
15
+ for (let batch = 0; batch < batches; batch++) {
16
+ options.signal?.throwIfAborted();
17
+ const read = await withTenant((tenant) => tenant.rollUpUsage({ tenantRef, limit: USAGE_ROLL_UP_BATCH }));
18
+ if (read < USAGE_ROLL_UP_BATCH)
19
+ break;
20
+ }
21
+ }
14
22
  const window = calendarEventRollingHorizon();
15
23
  let extended = 0;
16
24
  let conflicts = 0;
@@ -66,7 +74,9 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
66
74
  pruned += await withTenant((tenant) => tenant.pruneAssistantConversations({ tenantRef }));
67
75
  const zones = await withTenant((tenant) => tenant.followZoneRules({ tenantRef, move: options.followZoneRules === true }));
68
76
  const staleSources = await withTenant((tenant) => tenant.reportStaleCoverage({ tenantRef }));
69
- // No-shows first: the time they free is offered to the waitlist in the same pass.
77
+ // Approvals past their wait, and no-shows, first: the time they free is offered to the waitlist
78
+ // in the same pass.
79
+ const approvals = await withTenant((tenant) => tenant.expireApprovals({ tenantRef, limit: 100 }));
70
80
  const noShows = await withTenant((tenant) => tenant.releaseNoShows({ tenantRef }));
71
81
  // The whole waitlist in join order, a page per transaction, so a later free entry is offered
72
82
  // however many wait before it.
@@ -92,7 +102,8 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
92
102
  staleSources,
93
103
  noShows,
94
104
  waitlistOffers,
95
- capped: rollCapped || eventsLeft || bookingsLeft || changesLeft,
105
+ approvalsExpired: approvals.expired,
106
+ capped: rollCapped || eventsLeft || bookingsLeft || changesLeft || approvals.expired === 100,
96
107
  rollPending: rollCapped && after !== null && (after.rollRetryAt ?? null) === null,
97
108
  };
98
109
  }
@@ -102,6 +113,8 @@ const WAITLIST_PAGES = Math.ceil(SLOTLOCK_MAX_WAITLIST_PER_TENANT / 50);
102
113
  const SWEEP_INTERVAL_MS = 15 * 60_000;
103
114
  /** Feed changes past the retention deleted per batch. */
104
115
  const CHANGE_PRUNE_BATCH = 1_000;
116
+ /** Feed changes rolled up into usage per batch. */
117
+ const USAGE_ROLL_UP_BATCH = 5_000;
105
118
  /** The advisory lock key for one tenant's maintenance (64-bit, from the tenant reference). */
106
119
  const LOCK_NAMESPACE = 'slotlock.maintenance:';
107
120
  /**
@@ -169,6 +182,7 @@ export function startSlotlockMaintenance(options) {
169
182
  try {
170
183
  const result = await withSlotlockMaintenanceLock(options.sql, tenantRef, () => maintainSlotlockTenant(options.store, tenantRef, {
171
184
  ...(options.batches !== undefined ? { batches: options.batches } : {}),
185
+ ...(options.usage ? { usage: true } : {}),
172
186
  signal: controller.signal,
173
187
  }));
174
188
  recordPass(tenantRef, Date.now());
package/metrics.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { SlotlockAgentServerEvent } from './agent-server.js';
2
2
  import type { SlotlockMaintenanceResult } from './maintenance.js';
3
3
  import type { SlotlockRefusalCounts } from './rate-limit.js';
4
4
  import type { SlotlockWebhookPassResult } from './webhooks.js';
5
+ /** A metric's labels, by name. */
6
+ export type Labels = Readonly<Record<string, string>>;
5
7
  export interface SlotlockMetrics {
6
8
  /** Count what the agent server reports (its `onEvent`). */
7
9
  event(event: SlotlockAgentServerEvent): void;
@@ -13,8 +15,47 @@ export interface SlotlockMetrics {
13
15
  webhooks(result: SlotlockWebhookPassResult): void;
14
16
  /** Count a scrape refused for its credential. */
15
17
  refusedScrape(): void;
18
+ /**
19
+ * Count an authenticated request by the integrator its `Slotlock-Client` field names (RFC 0003
20
+ * P5): a name from the deployment's known list, `other` or `none` (slotlockClientLabel).
21
+ */
22
+ client(name: string): void;
16
23
  /** Everything counted so far, as a scrape answers it. */
17
24
  render(): string;
25
+ /** Everything counted so far, as data: for an OpenTelemetry metrics export (otel.ts). */
26
+ collect(): SlotlockMetricsSnapshot;
27
+ }
28
+ /** The counters and histograms of one server since it started (cumulative). */
29
+ export interface SlotlockMetricsSnapshot {
30
+ startedAtMs: number;
31
+ counters: {
32
+ name: string;
33
+ help: string;
34
+ points: {
35
+ labels: Labels;
36
+ value: number;
37
+ }[];
38
+ }[];
39
+ histograms: {
40
+ name: string;
41
+ help: string;
42
+ /** Upper bounds, in seconds; each point's buckets count what fell at or under each one. */
43
+ bounds: readonly number[];
44
+ points: {
45
+ labels: Labels;
46
+ cumulative: number[];
47
+ sum: number;
48
+ count: number;
49
+ }[];
50
+ }[];
51
+ gauges: {
52
+ name: string;
53
+ help: string;
54
+ points: {
55
+ labels: Labels;
56
+ value: number;
57
+ }[];
58
+ }[];
18
59
  }
19
60
  /** Counters for one server process, reset when it restarts (`slotlock_start_time_seconds`). */
20
61
  export declare function createSlotlockMetrics(info: {