slotlock 0.3.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.
- package/CHANGELOG.md +252 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +590 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- 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
|
@@ -48,6 +48,7 @@ export function findNextAvailable(params) {
|
|
|
48
48
|
if (!Number.isFinite(durationMs) || durationMs <= 0)
|
|
49
49
|
return null;
|
|
50
50
|
const slotAt = (start) => ({ start, end: new Date(start.getTime() + durationMs) });
|
|
51
|
+
const align = params.align ?? ((start) => start);
|
|
51
52
|
// One walk of the merged busy list alongside the merged windows, both by start: a busy interval
|
|
52
53
|
// ending by a window's start ends before every later one too.
|
|
53
54
|
const merged = mergeIntervals(busy);
|
|
@@ -61,13 +62,15 @@ export function findNextAvailable(params) {
|
|
|
61
62
|
const taken = merged[index];
|
|
62
63
|
if (taken.start.getTime() >= window.end.getTime())
|
|
63
64
|
break;
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
const start = align(cursor);
|
|
66
|
+
if (taken.start.getTime() - start.getTime() >= durationMs)
|
|
67
|
+
return slotAt(start);
|
|
66
68
|
if (taken.end.getTime() > cursor.getTime())
|
|
67
69
|
cursor = taken.end;
|
|
68
70
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
const start = align(cursor);
|
|
72
|
+
if (window.end.getTime() - start.getTime() >= durationMs)
|
|
73
|
+
return slotAt(start);
|
|
71
74
|
}
|
|
72
75
|
return null;
|
|
73
76
|
}
|
package/event-feed.d.ts
ADDED
|
@@ -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
|
+
}
|