slotlock 0.2.0 → 0.5.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 (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/engine.d.ts CHANGED
@@ -17,4 +17,9 @@ export declare function findNextAvailable(params: {
17
17
  busy: Interval[];
18
18
  windows: Interval[];
19
19
  durationMs: number;
20
+ /**
21
+ * Optional: the first allowed start at or after a candidate (a booking rule's step, say). It
22
+ * must not move a start backwards.
23
+ */
24
+ align?: (start: Date) => Date;
20
25
  }): Interval | null;
package/engine.js CHANGED
@@ -47,13 +47,30 @@ export function findNextAvailable(params) {
47
47
  const { busy, windows, durationMs } = params;
48
48
  if (!Number.isFinite(durationMs) || durationMs <= 0)
49
49
  return null;
50
- const orderedWindows = mergeIntervals(windows);
51
- for (const window of orderedWindows) {
52
- for (const free of subtractBusy(window, busy)) {
53
- if (free.end.getTime() - free.start.getTime() >= durationMs) {
54
- return { start: free.start, end: new Date(free.start.getTime() + durationMs) };
55
- }
50
+ const slotAt = (start) => ({ start, end: new Date(start.getTime() + durationMs) });
51
+ const align = params.align ?? ((start) => start);
52
+ // One walk of the merged busy list alongside the merged windows, both by start: a busy interval
53
+ // ending by a window's start ends before every later one too.
54
+ const merged = mergeIntervals(busy);
55
+ let next = 0;
56
+ for (const window of mergeIntervals(windows)) {
57
+ while (next < merged.length && merged[next].end.getTime() <= window.start.getTime()) {
58
+ next += 1;
56
59
  }
60
+ let cursor = window.start;
61
+ for (let index = next; index < merged.length; index++) {
62
+ const taken = merged[index];
63
+ if (taken.start.getTime() >= window.end.getTime())
64
+ break;
65
+ const start = align(cursor);
66
+ if (taken.start.getTime() - start.getTime() >= durationMs)
67
+ return slotAt(start);
68
+ if (taken.end.getTime() > cursor.getTime())
69
+ cursor = taken.end;
70
+ }
71
+ const start = align(cursor);
72
+ if (window.end.getTime() - start.getTime() >= durationMs)
73
+ return slotAt(start);
57
74
  }
58
75
  return null;
59
76
  }
@@ -0,0 +1,178 @@
1
+ import type { Sql, TransactionSql } from 'postgres';
2
+ type SlotlockSql = Sql | TransactionSql;
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"];
5
+ export type SlotlockChangeType = (typeof SLOTLOCK_CHANGE_TYPES)[number];
6
+ /** How long the feed keeps a change, and so how far back a cursor may reach. */
7
+ export declare const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
8
+ /** Webhook endpoints one tenant may have. */
9
+ export declare const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 10;
10
+ /** After this long failing, an endpoint is disabled and the feed says so (`webhook.disabled`). */
11
+ export declare const SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS: number;
12
+ export interface SlotlockChange {
13
+ /** Stable across every read and every delivery: the webhook's `webhook-id`. */
14
+ id: string;
15
+ type: SlotlockChangeType;
16
+ resourceId: string | null;
17
+ /** The record that changed: a booking's reference, else its id. */
18
+ subject: string;
19
+ data: Record<string, unknown>;
20
+ occurredAt: Date;
21
+ /** Who made it (withTenant's `actor`: an API key, an agent), or null when unrecorded. */
22
+ actor: string | null;
23
+ /** Pass as `after` to read what follows this change. */
24
+ position: string;
25
+ }
26
+ export interface SlotlockWebhookEndpoint {
27
+ id: string;
28
+ url: string;
29
+ /** Null: every type. */
30
+ eventTypes: SlotlockChangeType[] | null;
31
+ createdAt: Date;
32
+ /** Failed attempts since the last delivery that succeeded. */
33
+ attempts: number;
34
+ failingSince: Date | null;
35
+ lastStatus: number | null;
36
+ lastError: string | null;
37
+ disabledAt: Date | null;
38
+ disabledReason: string | null;
39
+ }
40
+ export interface SlotlockWebhookClaim {
41
+ /** `attempts`: deliveries that have failed in a row so far, which sets the next retry's delay. */
42
+ endpoint: {
43
+ id: string;
44
+ url: string;
45
+ secret: string;
46
+ attempts: number;
47
+ };
48
+ /** Settle the claim with it; another worker's claim on the same endpoint has another. */
49
+ leaseToken: string;
50
+ /** In order, at most `batch`. */
51
+ changes: SlotlockChange[];
52
+ }
53
+ export type CreateWebhookEndpointResult = {
54
+ ok: true;
55
+ endpoint: SlotlockWebhookEndpoint;
56
+ secret: string;
57
+ } | {
58
+ ok: false;
59
+ code: 'invalid_webhook_url' | 'invalid_event_types';
60
+ } | {
61
+ ok: false;
62
+ code: 'webhook_limit_reached';
63
+ limit: number;
64
+ };
65
+ export type ListChangesResult = {
66
+ ok: true;
67
+ changes: SlotlockChange[];
68
+ next: string;
69
+ } | {
70
+ ok: false;
71
+ code: 'invalid_cursor' | 'cursor_expired';
72
+ };
73
+ export interface SlotlockEventFeed {
74
+ /**
75
+ * The tenant's changes after `after` (a `position`, or the oldest kept without it), oldest
76
+ * first, up to `limit` (1-500, default 100). Only changes of transactions that have ended are
77
+ * read, so a reader that follows `next` never skips one that commits late. `next` is the last
78
+ * change's position, or `after` when there is none yet: poll with it. A position before a change
79
+ * any store has pruned is `cursor_expired`: read from the start again.
80
+ */
81
+ listChanges(params: {
82
+ tenantRef: string;
83
+ after?: string;
84
+ limit?: number;
85
+ }): Promise<ListChangesResult>;
86
+ /**
87
+ * The tenant's latest changes, newest first, up to `limit` (1-100, default 20): what happened
88
+ * lately, for people to read. To follow the feed, read listChanges.
89
+ */
90
+ listRecentChanges(params: {
91
+ tenantRef: string;
92
+ limit?: number;
93
+ }): Promise<SlotlockChange[]>;
94
+ /**
95
+ * Add a webhook endpoint for the tenant's changes from now on. `url` is https (http only for
96
+ * `localhost`), with no credentials or fragment, at most 2,048 bytes. The secret (`whsec_…`,
97
+ * 32 random bytes) is returned here only. At most SLOTLOCK_MAX_WEBHOOK_ENDPOINTS per tenant.
98
+ */
99
+ createWebhookEndpoint(params: {
100
+ tenantRef: string;
101
+ url: string;
102
+ eventTypes?: readonly string[];
103
+ }): Promise<CreateWebhookEndpointResult>;
104
+ listWebhookEndpoints(params: {
105
+ tenantRef: string;
106
+ }): Promise<SlotlockWebhookEndpoint[]>;
107
+ deleteWebhookEndpoint(params: {
108
+ tenantRef: string;
109
+ id: string;
110
+ }): Promise<{
111
+ deleted: boolean;
112
+ }>;
113
+ /**
114
+ * Deliver to a disabled endpoint again, from where it stopped; null when there is none. When
115
+ * the changes it missed are older than the feed keeps, `changes_pruned`: it would skip them
116
+ * unseen, so delete it, add it again, and read what it missed from listChanges.
117
+ */
118
+ enableWebhookEndpoint(params: {
119
+ tenantRef: string;
120
+ id: string;
121
+ }): Promise<SlotlockWebhookEndpoint | null | {
122
+ code: 'changes_pruned';
123
+ }>;
124
+ /**
125
+ * Lease the tenant's endpoints that have changes due, each with its next `batch` (1-100)
126
+ * changes, for `leaseMs` (1,000-300,000). A lease that runs out is claimed again by any worker,
127
+ * so a worker that dies mid-batch costs a redelivery, never a lost change.
128
+ */
129
+ claimWebhookDeliveries(params: {
130
+ tenantRef: string;
131
+ leaseMs: number;
132
+ batch: number;
133
+ }): Promise<SlotlockWebhookClaim[]>;
134
+ /**
135
+ * Record how a claim went: `delivered`, the position of the last change the endpoint accepted;
136
+ * `failure` for the one it did not, retried after `retryInMs`. An endpoint failing for
137
+ * SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS, or answering 410 (`gone`), is disabled, and the feed records
138
+ * `webhook.disabled`. Nothing changes once the lease has passed to another worker.
139
+ */
140
+ settleWebhookDelivery(params: {
141
+ tenantRef: string;
142
+ endpointId: string;
143
+ leaseToken: string;
144
+ delivered?: string;
145
+ failure?: {
146
+ status: number | null;
147
+ error: string;
148
+ retryInMs: number;
149
+ gone?: boolean;
150
+ };
151
+ }): Promise<void>;
152
+ /**
153
+ * History: the changes to one resource (`resourceId`) or one record (`subject`: a booking's
154
+ * reference, an event's or hold's id), newest first, `limit` (1-100) at a time, each with who
155
+ * made it and, for a change of an existing record, what it was before. Pass the last one's
156
+ * `id` as `before` for the page after. Kept as long as the feed keeps changes.
157
+ */
158
+ listHistory(params: {
159
+ tenantRef: string;
160
+ resourceId?: string;
161
+ subject?: string;
162
+ limit?: number;
163
+ before?: string;
164
+ }): Promise<SlotlockChange[]>;
165
+ /** Delete up to `limit` of the tenant's changes older than the retention; how many went. */
166
+ pruneChanges(params: {
167
+ tenantRef: string;
168
+ limit: number;
169
+ }): Promise<number>;
170
+ }
171
+ /** A webhook URL Slotlock will call: https, or http to localhost; no credentials or fragment. */
172
+ export declare function isWebhookUrl(value: unknown): value is string;
173
+ /**
174
+ * The feed's methods on `sql`: the store's own client, or its tenant transaction. Changes are kept
175
+ * `retentionDays` (default SLOTLOCK_CHANGE_RETENTION_DAYS), and a cursor reaches back as far.
176
+ */
177
+ export declare function slotlockEventFeedMethods(sql: SlotlockSql, retentionDays?: number): SlotlockEventFeed;
178
+ export {};
package/event-feed.js ADDED
@@ -0,0 +1,435 @@
1
+ // The event feed (RFC 0002 P1-5): what changed for a tenant, read by position, and the webhook
2
+ // endpoints it is delivered to. Rows are appended by triggers in the same transaction as the change
3
+ // (ddl.ts, SLOTLOCK_OUTBOX_DDL). These are the store's methods for reading them and for keeping each
4
+ // endpoint's delivery state; webhooks.ts signs and sends.
5
+ import { randomBytes } from 'node:crypto';
6
+ /** A transaction on a client, or a savepoint inside the caller's (a tenant's, say). */
7
+ async function inTransaction(sql, callback) {
8
+ return ('begin' in sql ? sql.begin(callback) : sql.savepoint(callback));
9
+ }
10
+ /** What a change can be, as the feed and a webhook's `event_types` name it. */
11
+ export const SLOTLOCK_CHANGE_TYPES = Object.freeze([
12
+ 'hold.created',
13
+ 'hold.updated',
14
+ 'hold.confirmed',
15
+ 'hold.released',
16
+ 'hold.expired',
17
+ 'reservation.created',
18
+ 'reservation.updated',
19
+ 'reservation.cancelled',
20
+ 'reservation.checked_in',
21
+ 'reservation.no_show',
22
+ 'event.created',
23
+ 'event.updated',
24
+ 'event.cancelled',
25
+ 'event.timezone_shifted',
26
+ 'event.timezone_conflict',
27
+ 'coverage.stale',
28
+ 'waitlist.offered',
29
+ 'waitlist.expired',
30
+ 'webhook.disabled',
31
+ ]);
32
+ /** How long the feed keeps a change, and so how far back a cursor may reach. */
33
+ export const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
34
+ /** Webhook endpoints one tenant may have. */
35
+ export const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 10;
36
+ /** After this long failing, an endpoint is disabled and the feed says so (`webhook.disabled`). */
37
+ export const SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS = 24 * 60 * 60 * 1_000;
38
+ function encodePosition(position) {
39
+ return Buffer.from(`1:${position.txid}:${position.id}:${position.at}`).toString('base64url');
40
+ }
41
+ function decodePosition(value) {
42
+ if (value.length > 200 || !/^[A-Za-z0-9_-]+$/.test(value))
43
+ return null;
44
+ const match = /^1:(\d{1,20}):(\d{1,19}):(\d{1,15})$/.exec(Buffer.from(value, 'base64url').toString());
45
+ return match ? { txid: match[1], id: match[2], at: Number(match[3]) } : null;
46
+ }
47
+ function toChange(row) {
48
+ return {
49
+ id: `msg_${row.id}`,
50
+ type: row.type,
51
+ resourceId: row.resource_id,
52
+ subject: row.subject,
53
+ data: row.data,
54
+ occurredAt: row.occurred_at,
55
+ actor: row.actor,
56
+ position: encodePosition({ txid: row.txid, id: row.id, at: row.occurred_at.getTime() }),
57
+ };
58
+ }
59
+ function toEndpoint(row) {
60
+ return {
61
+ id: row.id,
62
+ url: row.url,
63
+ eventTypes: row.event_types,
64
+ createdAt: row.created_at,
65
+ attempts: row.attempts,
66
+ failingSince: row.failing_since,
67
+ lastStatus: row.last_status,
68
+ lastError: row.last_error,
69
+ disabledAt: row.disabled_at,
70
+ disabledReason: row.disabled_reason,
71
+ };
72
+ }
73
+ const ENDPOINT_COLUMNS = (sql) => sql `id, url, event_types, created_at, attempts, failing_since, last_status, last_error,
74
+ disabled_at, disabled_reason`;
75
+ 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;
76
+ function isIdentity(value) {
77
+ return typeof value === 'string' && Buffer.byteLength(value) >= 1 && Buffer.byteLength(value) <= 500;
78
+ }
79
+ function identityError() {
80
+ return Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
81
+ code: 'invalid_identity',
82
+ });
83
+ }
84
+ /** A webhook URL Slotlock will call: https, or http to localhost; no credentials or fragment. */
85
+ export function isWebhookUrl(value) {
86
+ if (typeof value !== 'string' || Buffer.byteLength(value) > 2048)
87
+ return false;
88
+ let url;
89
+ try {
90
+ url = new URL(value);
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ if (url.username || url.password || url.hash)
96
+ return false;
97
+ if (url.protocol === 'https:')
98
+ return true;
99
+ return url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1');
100
+ }
101
+ /**
102
+ * The feed's methods on `sql`: the store's own client, or its tenant transaction. Changes are kept
103
+ * `retentionDays` (default SLOTLOCK_CHANGE_RETENTION_DAYS), and a cursor reaches back as far.
104
+ */
105
+ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RETENTION_DAYS) {
106
+ return {
107
+ async listChanges(params) {
108
+ if (!isIdentity(params.tenantRef))
109
+ throw identityError();
110
+ const limit = params.limit ?? 100;
111
+ if (!(Number.isSafeInteger(limit) && limit >= 1 && limit <= 500)) {
112
+ throw Object.assign(new RangeError('Slotlock change page limit must be 1-500'), {
113
+ code: 'invalid_limit',
114
+ });
115
+ }
116
+ const after = params.after === undefined ? null : decodePosition(params.after);
117
+ if (params.after !== undefined && !after)
118
+ return { ok: false, code: 'invalid_cursor' };
119
+ // One statement, so the pruned position and the page are read from one snapshot: a prune
120
+ // that commits meanwhile is either wholly seen or not at all.
121
+ const rows = await sql `
122
+ SELECT mark.txid::text AS pruned_txid, mark.id::text AS pruned_id,
123
+ pg_snapshot_xmin(pg_current_snapshot())::text AS horizon,
124
+ page.id::text, page.txid::text, page.type, page.resource_id, page.subject,
125
+ page.data, page.occurred_at, page.actor
126
+ FROM (SELECT 1) here
127
+ LEFT JOIN slotlock.outbox_pruned mark ON mark.tenant_ref = ${params.tenantRef}
128
+ LEFT JOIN LATERAL (
129
+ SELECT * FROM slotlock.outbox
130
+ WHERE tenant_ref = ${params.tenantRef}
131
+ AND txid < pg_snapshot_xmin(pg_current_snapshot())
132
+ AND (${after?.txid ?? null}::xid8 IS NULL
133
+ OR (txid, id) > (${after?.txid ?? null}::xid8, ${after?.id ?? null}::bigint))
134
+ ORDER BY txid, id
135
+ LIMIT ${limit}
136
+ ) page ON true
137
+ ORDER BY page.txid, page.id`;
138
+ const head = rows[0];
139
+ if (!head)
140
+ throw new Error('slotlock: change page returned no row');
141
+ // Whichever server pruned it, and however long it keeps changes: reading on could skip one.
142
+ if (after &&
143
+ head.pruned_txid !== null &&
144
+ (BigInt(head.pruned_txid) > BigInt(after.txid) ||
145
+ (BigInt(head.pruned_txid) === BigInt(after.txid) &&
146
+ BigInt(head.pruned_id ?? 0) > BigInt(after.id)))) {
147
+ return { ok: false, code: 'cursor_expired' };
148
+ }
149
+ const changes = rows.flatMap((row) => (row.id === null ? [] : [toChange(row)]));
150
+ const last = changes.at(-1);
151
+ // With nothing new the position stays, dated now: a reader that is up to date stays current.
152
+ // Before any change, every one still to come is after the oldest transaction still running.
153
+ const here = after ?? { txid: head.horizon, id: '0', at: 0 };
154
+ return {
155
+ ok: true,
156
+ changes,
157
+ next: last?.position ?? encodePosition({ ...here, at: Date.now() }),
158
+ };
159
+ },
160
+ async listRecentChanges(params) {
161
+ if (!isIdentity(params.tenantRef))
162
+ throw identityError();
163
+ const limit = params.limit ?? 20;
164
+ if (!(Number.isSafeInteger(limit) && limit >= 1 && limit <= 100)) {
165
+ throw Object.assign(new RangeError('Slotlock recent change limit must be 1-100'), {
166
+ code: 'invalid_limit',
167
+ });
168
+ }
169
+ const rows = await sql `
170
+ SELECT id::text, txid::text, type, resource_id, subject, data, occurred_at, actor
171
+ FROM slotlock.outbox
172
+ WHERE tenant_ref = ${params.tenantRef}
173
+ ORDER BY txid DESC, id DESC
174
+ LIMIT ${limit}`;
175
+ return rows.map(toChange);
176
+ },
177
+ async createWebhookEndpoint(params) {
178
+ if (!isIdentity(params.tenantRef))
179
+ throw identityError();
180
+ if (!isWebhookUrl(params.url))
181
+ return { ok: false, code: 'invalid_webhook_url' };
182
+ const types = params.eventTypes === undefined ? null : [...new Set(params.eventTypes)];
183
+ if (types !== null &&
184
+ (types.length === 0 ||
185
+ types.length > 20 ||
186
+ !types.every((type) => SLOTLOCK_CHANGE_TYPES.includes(type)))) {
187
+ return { ok: false, code: 'invalid_event_types' };
188
+ }
189
+ const secret = `whsec_${randomBytes(32).toString('base64')}`;
190
+ return inTransaction(sql, async (tx) => {
191
+ // One at a time per tenant, so racing creates stop at the limit.
192
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:webhooks:${params.tenantRef}`}, 0))`;
193
+ const [count] = await tx `
194
+ SELECT count(*)::int AS count FROM slotlock.webhook_endpoints
195
+ WHERE tenant_ref = ${params.tenantRef}`;
196
+ if ((count?.count ?? 0) >= SLOTLOCK_MAX_WEBHOOK_ENDPOINTS) {
197
+ return { ok: false, code: 'webhook_limit_reached', limit: SLOTLOCK_MAX_WEBHOOK_ENDPOINTS };
198
+ }
199
+ // From now on: every transaction that has not ended yet is at or after this position.
200
+ const [row] = await tx `
201
+ INSERT INTO slotlock.webhook_endpoints (tenant_ref, url, secret, event_types, cursor_txid)
202
+ VALUES (${params.tenantRef}, ${params.url}, ${secret}, ${types}::text[],
203
+ pg_snapshot_xmin(pg_current_snapshot()))
204
+ RETURNING ${ENDPOINT_COLUMNS(tx)}`;
205
+ if (!row)
206
+ throw new Error('slotlock: webhook endpoint insert returned no row');
207
+ await tx `SELECT slotlock.register_webhook_tenant(${params.tenantRef}::text)`;
208
+ return { ok: true, endpoint: toEndpoint(row), secret };
209
+ });
210
+ },
211
+ async listWebhookEndpoints(params) {
212
+ if (!isIdentity(params.tenantRef))
213
+ throw identityError();
214
+ const rows = await sql `
215
+ SELECT ${ENDPOINT_COLUMNS(sql)} FROM slotlock.webhook_endpoints
216
+ WHERE tenant_ref = ${params.tenantRef}
217
+ ORDER BY created_at, id`;
218
+ return rows.map(toEndpoint);
219
+ },
220
+ async deleteWebhookEndpoint(params) {
221
+ if (!isIdentity(params.tenantRef))
222
+ throw identityError();
223
+ if (!UUID.test(params.id))
224
+ return { deleted: false };
225
+ return inTransaction(sql, async (tx) => {
226
+ const rows = await tx `
227
+ DELETE FROM slotlock.webhook_endpoints
228
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
229
+ RETURNING id`;
230
+ const [left] = await tx `
231
+ SELECT count(*)::int AS count FROM slotlock.webhook_endpoints
232
+ WHERE tenant_ref = ${params.tenantRef}`;
233
+ // The tenant's last endpoint takes it off the list servers deliver for.
234
+ if ((left?.count ?? 0) === 0) {
235
+ await tx `SELECT slotlock.unregister_webhook_tenant(${params.tenantRef}::text)`;
236
+ }
237
+ return { deleted: rows.length > 0 };
238
+ });
239
+ },
240
+ async enableWebhookEndpoint(params) {
241
+ if (!isIdentity(params.tenantRef))
242
+ throw identityError();
243
+ if (!UUID.test(params.id))
244
+ return null;
245
+ const [behind] = await sql `
246
+ SELECT id FROM slotlock.webhook_endpoints
247
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id} AND changes_pruned`;
248
+ if (behind)
249
+ return { code: 'changes_pruned' };
250
+ const [row] = await sql `
251
+ UPDATE slotlock.webhook_endpoints
252
+ SET disabled_at = NULL, disabled_reason = NULL, attempts = 0, failing_since = NULL,
253
+ next_attempt_at = NULL, lease_until = NULL, lease_token = NULL
254
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
255
+ RETURNING ${ENDPOINT_COLUMNS(sql)}`;
256
+ return row ? toEndpoint(row) : null;
257
+ },
258
+ async claimWebhookDeliveries(params) {
259
+ if (!isIdentity(params.tenantRef))
260
+ throw identityError();
261
+ if (!(Number.isSafeInteger(params.leaseMs) && params.leaseMs >= 1_000 && params.leaseMs <= 300_000)) {
262
+ throw new RangeError('Slotlock webhook lease must be 1,000-300,000 ms');
263
+ }
264
+ if (!(Number.isSafeInteger(params.batch) && params.batch >= 1 && params.batch <= 100)) {
265
+ throw new RangeError('Slotlock webhook batch must be 1-100 changes');
266
+ }
267
+ const endpoints = await sql `
268
+ UPDATE slotlock.webhook_endpoints endpoint
269
+ SET lease_until = now() + make_interval(secs => ${params.leaseMs / 1_000}),
270
+ lease_token = gen_random_uuid()
271
+ WHERE endpoint.id IN (
272
+ SELECT due.id FROM slotlock.webhook_endpoints due
273
+ WHERE due.tenant_ref = ${params.tenantRef}
274
+ AND due.disabled_at IS NULL
275
+ AND (due.next_attempt_at IS NULL OR due.next_attempt_at <= now())
276
+ AND (due.lease_until IS NULL OR due.lease_until <= now())
277
+ AND EXISTS (
278
+ SELECT 1 FROM slotlock.outbox change
279
+ WHERE change.tenant_ref = due.tenant_ref
280
+ AND change.txid < pg_snapshot_xmin(pg_current_snapshot())
281
+ AND (change.txid, change.id) > (due.cursor_txid, due.cursor_id)
282
+ AND (due.event_types IS NULL OR change.type = ANY (due.event_types))
283
+ )
284
+ FOR UPDATE SKIP LOCKED
285
+ )
286
+ RETURNING endpoint.id, endpoint.url, endpoint.secret, endpoint.attempts, endpoint.lease_token::text,
287
+ endpoint.event_types, endpoint.cursor_txid::text, endpoint.cursor_id::text`;
288
+ const claims = [];
289
+ for (const endpoint of endpoints) {
290
+ const rows = await sql `
291
+ SELECT id::text, txid::text, type, resource_id, subject, data, occurred_at, actor
292
+ FROM slotlock.outbox
293
+ WHERE tenant_ref = ${params.tenantRef}
294
+ AND txid < pg_snapshot_xmin(pg_current_snapshot())
295
+ AND (txid, id) > (${endpoint.cursor_txid}::xid8, ${endpoint.cursor_id}::bigint)
296
+ AND (${endpoint.event_types}::text[] IS NULL OR type = ANY (${endpoint.event_types}::text[]))
297
+ ORDER BY txid, id
298
+ LIMIT ${params.batch}`;
299
+ claims.push({
300
+ endpoint: { id: endpoint.id, url: endpoint.url, secret: endpoint.secret, attempts: endpoint.attempts },
301
+ leaseToken: endpoint.lease_token,
302
+ changes: rows.map(toChange),
303
+ });
304
+ }
305
+ return claims;
306
+ },
307
+ async settleWebhookDelivery(params) {
308
+ if (!isIdentity(params.tenantRef))
309
+ throw identityError();
310
+ const delivered = params.delivered === undefined ? null : decodePosition(params.delivered);
311
+ if (params.delivered !== undefined && !delivered) {
312
+ throw new TypeError('Slotlock webhook delivery position is not one the feed gave');
313
+ }
314
+ const failure = params.failure;
315
+ await inTransaction(sql, async (tx) => {
316
+ const [settled] = await tx `
317
+ UPDATE slotlock.webhook_endpoints
318
+ SET cursor_txid = COALESCE(${delivered?.txid ?? null}::xid8, cursor_txid),
319
+ cursor_id = COALESCE(${delivered?.id ?? null}::bigint, cursor_id),
320
+ -- Progress before a failure starts the count again: an endpoint that keeps
321
+ -- advancing is not failing, however often one change needs a retry.
322
+ attempts = CASE WHEN ${failure ? 1 : 0}::int = 0 THEN 0
323
+ WHEN ${delivered ? 1 : 0}::int = 1 THEN 1
324
+ ELSE attempts + 1 END,
325
+ failing_since = CASE WHEN ${failure ? 1 : 0}::int = 0 THEN NULL
326
+ WHEN ${delivered ? 1 : 0}::int = 1 THEN now()
327
+ ELSE COALESCE(failing_since, now()) END,
328
+ next_attempt_at = CASE WHEN ${failure ? 1 : 0}::int = 1
329
+ THEN now() + make_interval(secs => ${(failure?.retryInMs ?? 0) / 1_000})
330
+ END,
331
+ last_status = COALESCE(${failure?.status ?? null}::int, last_status),
332
+ last_error = CASE WHEN ${failure ? 1 : 0}::int = 1
333
+ THEN left(${failure?.error ?? ''}, 500) ELSE last_error END,
334
+ disabled_at = CASE
335
+ WHEN ${failure?.gone === true}::boolean
336
+ OR (${failure ? 1 : 0}::int = 1
337
+ AND now() - COALESCE(failing_since, now())
338
+ >= make_interval(secs => ${SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS / 1_000}))
339
+ THEN now() END,
340
+ disabled_reason = CASE
341
+ WHEN ${failure?.gone === true}::boolean THEN 'gone'
342
+ WHEN ${failure ? 1 : 0}::int = 1
343
+ AND now() - COALESCE(failing_since, now())
344
+ >= make_interval(secs => ${SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS / 1_000})
345
+ THEN 'failing' END,
346
+ lease_until = NULL,
347
+ lease_token = NULL
348
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.endpointId}
349
+ AND lease_token = ${params.leaseToken}::uuid
350
+ RETURNING disabled_at IS NOT NULL AS disabled`;
351
+ if (settled?.disabled) {
352
+ // The tenant hears of it on the feed, which every reader and other endpoint sees.
353
+ await tx `
354
+ INSERT INTO slotlock.outbox (tenant_ref, type, subject, data)
355
+ VALUES (${params.tenantRef}, 'webhook.disabled', ${params.endpointId},
356
+ ${tx.json({
357
+ // The id alone: the feed goes to the tenant's other endpoints, and a URL can
358
+ // carry a token.
359
+ id: params.endpointId,
360
+ reason: failure?.gone === true ? 'gone' : 'failing',
361
+ last_status: failure?.status ?? null,
362
+ })})`;
363
+ }
364
+ });
365
+ },
366
+ async listHistory(params) {
367
+ if (!isIdentity(params.tenantRef))
368
+ throw identityError();
369
+ if ((params.resourceId === undefined) === (params.subject === undefined)) {
370
+ throw new TypeError('Slotlock history is of one resource or one record: give resourceId or subject');
371
+ }
372
+ if (params.resourceId !== undefined && !UUID.test(params.resourceId))
373
+ return [];
374
+ if (params.subject !== undefined && !isIdentity(params.subject))
375
+ return [];
376
+ const limit = params.limit ?? 50;
377
+ if (!(Number.isSafeInteger(limit) && limit >= 1 && limit <= 100)) {
378
+ throw new RangeError('Slotlock history page limit must be 1-100');
379
+ }
380
+ const before = params.before === undefined ? null : /^msg_(\d{1,19})$/.exec(params.before)?.[1];
381
+ if (before === undefined)
382
+ return [];
383
+ const rows = await sql `
384
+ SELECT id::text, txid::text, type, resource_id, subject, data, occurred_at, actor
385
+ FROM slotlock.outbox
386
+ WHERE tenant_ref = ${params.tenantRef}
387
+ AND (${params.resourceId ?? null}::uuid IS NULL OR resource_id = ${params.resourceId ?? null}::uuid)
388
+ AND (${params.subject ?? null}::text IS NULL OR subject = ${params.subject ?? null})
389
+ AND (${before}::bigint IS NULL OR id < ${before}::bigint)
390
+ ORDER BY id DESC
391
+ LIMIT ${limit}`;
392
+ return rows.map(toChange);
393
+ },
394
+ async pruneChanges(params) {
395
+ if (!isIdentity(params.tenantRef))
396
+ throw identityError();
397
+ // Oldest first in feed order, and only up to the first change kept: a change still within the
398
+ // retention, one an enabled webhook has still to receive, or one of a transaction later than
399
+ // the oldest still running. So every change kept is after every change pruned (the trigger
400
+ // on the feed records how far), and a reader starting over reads on past the gap.
401
+ const rows = await sql `
402
+ WITH head AS MATERIALIZED (
403
+ SELECT outbox.id, outbox.txid,
404
+ outbox.occurred_at >= now() - make_interval(days => ${retentionDays})
405
+ OR EXISTS (
406
+ SELECT 1 FROM slotlock.webhook_endpoints endpoint
407
+ WHERE endpoint.tenant_ref = outbox.tenant_ref AND endpoint.disabled_at IS NULL
408
+ AND (outbox.txid, outbox.id) > (endpoint.cursor_txid, endpoint.cursor_id)
409
+ ) AS kept
410
+ FROM slotlock.outbox
411
+ WHERE outbox.tenant_ref = ${params.tenantRef}
412
+ AND outbox.txid < pg_snapshot_xmin(pg_current_snapshot())
413
+ ORDER BY outbox.txid, outbox.id
414
+ LIMIT ${params.limit}
415
+ ), doomed AS MATERIALIZED (
416
+ SELECT change.id, change.txid FROM head change
417
+ WHERE NOT EXISTS (
418
+ SELECT 1 FROM head keeper
419
+ WHERE keeper.kept AND (keeper.txid, keeper.id) <= (change.txid, change.id))
420
+ ), missed AS (
421
+ -- A disabled endpoint that had not received one of them has lost it.
422
+ UPDATE slotlock.webhook_endpoints endpoint SET changes_pruned = true
423
+ WHERE endpoint.tenant_ref = ${params.tenantRef} AND endpoint.disabled_at IS NOT NULL
424
+ AND NOT endpoint.changes_pruned
425
+ AND EXISTS (
426
+ SELECT 1 FROM doomed
427
+ WHERE (doomed.txid, doomed.id) > (endpoint.cursor_txid, endpoint.cursor_id))
428
+ ), deleted AS (
429
+ DELETE FROM slotlock.outbox change USING doomed WHERE change.id = doomed.id RETURNING 1
430
+ )
431
+ SELECT count(*)::int AS deleted FROM deleted`;
432
+ return rows[0]?.deleted ?? 0;
433
+ },
434
+ };
435
+ }