Skip to content

Commit 16fcdd6

Browse files
committed
feat: extendHold and cursor pagination
Tracks two new API capabilities. extendHold keeps a server-side hold alive past the checkout window, for invoiced and phone orders. Releasing and re-holding hands the seats to whoever is racing for them in between, so this is the call you want. list() now takes limit/cursor, and listAll() pages transparently as an async iterator — deliberately not an array, since paginating exists to stop holding an unbounded list in memory and returning one would hand that problem straight back. listAll() over events also drops the per-event availability fanout, which is exactly the cost pagination was added to avoid. 35 tests.
1 parent 9f8c841 commit 16fcdd6

6 files changed

Lines changed: 209 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
First release of the SeatLayer Node server SDK.
66

7+
- `inventory.extendHold` — keep a server-side hold alive past the checkout window.
8+
- `charts.list` / `events.list` take `limit` and `cursor`; `listAll()` pages transparently as an
9+
async iterator and skips the per-event availability fanout.
710
- `SeatLayer` client with secret-key auth, per-attempt timeouts, and a typed escape hatch.
811
- Resources: `charts`, `events`, `inventory`, `sessions`, `webhooks`, `workspaces`.
912
- Automatic `Idempotency-Key` on every mutation, reused across retries so a retried

README.md

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,47 @@ await seatlayer.inventory.bookBestAvailable(eventKey, { qty: 2, bookingRef: 'pho
7171
await seatlayer.inventory.boxOfficeBook(eventKey, { labels: ['A-1', 'A-2'], bookingRef: 'comp-14' });
7272
```
7373

74+
## Listing and pagination
75+
76+
`list()` returns one page plus a `nextCursor`. When you want everything, `listAll()` pages for you
77+
and yields as it goes — an async iterator rather than an array, because the point of paginating is
78+
to *not* hold an unbounded list in memory.
79+
80+
```ts
81+
// One page, your own paging.
82+
const page = await seatlayer.events.list({ limit: 50 });
83+
page.events; // EventMeta[]
84+
page.nextCursor; // undefined once exhausted
85+
86+
// Or let the SDK walk it.
87+
for await (const event of seatlayer.events.listAll()) {
88+
await sync(event);
89+
}
90+
```
91+
92+
Listing events includes live availability `counts` by default, which costs the server one
93+
round-trip **per event**. `listAll()` turns them off automatically — walking a whole catalogue is
94+
exactly when you don't want that — and you can control it explicitly:
95+
96+
```ts
97+
await seatlayer.events.list({ limit: 50, counts: false });
98+
```
99+
100+
## Keeping a hold alive
101+
102+
When an order takes longer than the checkout window — an invoice, a phone sale — extend rather than
103+
release and re-hold. Releasing first hands the seats to whoever is racing for them in between.
104+
105+
```ts
106+
try {
107+
await seatlayer.inventory.extendHold(eventKey, { holdId, ttlMs: 10 * 60_000 });
108+
} catch (error) {
109+
if (error instanceof SeatLayerConflictError) {
110+
// Gone, expired, or at its renewal cap — the buyer has to re-pick.
111+
}
112+
}
113+
```
114+
74115
## Embedding the control room
75116

76117
Your secret key never reaches a browser. Mint a scoped token instead and hand that to the widget.
@@ -116,9 +157,9 @@ app.post('/webhooks/seatlayer', express.raw({ type: 'application/json' }), (req,
116157
secret: process.env.SEATLAYER_WEBHOOK_SECRET!,
117158
});
118159

119-
// Deliveries are signed over the body only — there is no timestamp header
120-
// and no tolerance window, so a captured delivery stays valid. Deduplicate
121-
// on occurrenceId; this is your replay protection.
160+
// The signed body carries `at`, but nothing enforces a freshness window,
161+
// so a captured delivery stays valid indefinitely. Deduplicate on
162+
// occurrenceIdthis is your replay protection, not an optimisation.
122163
if (await alreadyProcessed(event.occurrenceId)) return res.sendStatus(200);
123164

124165
await handle(event);
@@ -191,9 +232,9 @@ await seatlayer.request('POST', '/v1/events/ev_1/some-new-route', { body: { …
191232

192233
| Resource | Methods |
193234
| --- | --- |
194-
| `charts` | `list` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` |
195-
| `events` | `list` `create` `retrieve` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `retrieveReport` `retrieveLog` |
196-
| `inventory` | `hold` `holdBestAvailable` `bookBestAvailable` `retrieveHold` `release` `book` `boxOfficeBook` `unbook` `block` `unblock` `unblockAll` `retrieveAvailability` `updateAvailability` |
235+
| `charts` | `list` `listAll` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` |
236+
| `events` | `list` `listAll` `create` `retrieve` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `retrieveReport` `retrieveLog` |
237+
| `inventory` | `hold` `holdBestAvailable` `bookBestAvailable` `extendHold` `retrieveHold` `release` `book` `boxOfficeBook` `unbook` `block` `unblock` `unblockAll` `retrieveAvailability` `updateAvailability` |
197238
| `sessions` | `createManageSession` `revokeManageSession` `createDesignerSession` `revokeDesignerSession` |
198239
| `webhooks` | `list` `create` `update` `delete` `listDeliveries` |
199240
| `workspaces` | `list` `create` `retrieve` `update` |

src/resources/charts.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import type { HttpClient } from '../http.js';
22
import type { Chart, ChartMeta } from '../types.js';
33

4+
export interface ChartListOptions {
5+
workspaceId?: string;
6+
externalRef?: string;
7+
archived?: boolean;
8+
/** Page size. Clamped server-side; asking for more is not an error. */
9+
limit?: number;
10+
cursor?: string;
11+
}
12+
13+
export interface ChartPage {
14+
charts: ChartMeta[];
15+
/** Absent once the list is exhausted. */
16+
nextCursor?: string;
17+
}
18+
419
/**
520
* Charts are the seat-map definitions events are created from.
621
*
@@ -16,16 +31,40 @@ export class Charts {
1631
this.#http = http;
1732
}
1833

19-
list(options: { workspaceId?: string; externalRef?: string; archived?: boolean } = {}): Promise<{ charts: ChartMeta[] }> {
34+
/**
35+
* One page of charts. Pass `cursor` from the previous page's `nextCursor`;
36+
* its absence means the list is exhausted.
37+
*/
38+
list(options: ChartListOptions = {}): Promise<ChartPage> {
2039
return this.#http.get('/v1/charts', {
2140
query: {
2241
workspaceId: options.workspaceId,
2342
externalRef: options.externalRef,
43+
limit: options.limit,
44+
cursor: options.cursor,
2445
...(options.archived ? { archived: '1' } : {}),
2546
},
2647
});
2748
}
2849

50+
/**
51+
* Every chart, paging transparently.
52+
*
53+
* An async iterator rather than an array: the whole point of paginating was
54+
* to stop loading an unbounded list into memory, and returning `ChartMeta[]`
55+
* would hand that problem straight back to the caller.
56+
*
57+
* for await (const chart of seatlayer.charts.listAll()) { … }
58+
*/
59+
async *listAll(options: Omit<ChartListOptions, 'cursor'> = {}): AsyncGenerator<ChartMeta> {
60+
let cursor: string | undefined;
61+
do {
62+
const page = await this.list({ ...options, cursor });
63+
for (const chart of page.charts) yield chart;
64+
cursor = page.nextCursor;
65+
} while (cursor);
66+
}
67+
2968
create(params: {
3069
name: string;
3170
doc?: Record<string, unknown>;

src/resources/events.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,65 @@
11
import type { HttpClient } from '../http.js';
22
import type { EventMeta } from '../types.js';
33

4+
export interface EventListOptions {
5+
workspaceId?: string;
6+
externalRef?: string;
7+
/** Page size. Clamped server-side; asking for more is not an error. */
8+
limit?: number;
9+
cursor?: string;
10+
/** Include live availability counts. One server round-trip per event. */
11+
counts?: boolean;
12+
}
13+
14+
export interface EventPage {
15+
events: EventMeta[];
16+
/** Absent once the list is exhausted. */
17+
nextCursor?: string;
18+
}
19+
420
export class Events {
521
#http: HttpClient;
622

723
constructor(http: HttpClient) {
824
this.#http = http;
925
}
1026

11-
list(options: { workspaceId?: string; externalRef?: string } = {}): Promise<{ events: EventMeta[] }> {
27+
/**
28+
* One page of events. Pass `cursor` from the previous page's `nextCursor`.
29+
*
30+
* Live availability `counts` cost one round-trip per event server-side. They
31+
* are included by default because most callers want them; pass
32+
* `counts: false` when paging a whole catalogue, where you almost certainly
33+
* do not.
34+
*/
35+
list(options: EventListOptions = {}): Promise<EventPage> {
1236
return this.#http.get('/v1/events', {
13-
query: { workspaceId: options.workspaceId, externalRef: options.externalRef },
37+
query: {
38+
workspaceId: options.workspaceId,
39+
externalRef: options.externalRef,
40+
limit: options.limit,
41+
cursor: options.cursor,
42+
...(options.counts === false ? { counts: '0' } : {}),
43+
},
1444
});
1545
}
1646

47+
/**
48+
* Every event, paging transparently. Defaults to `counts: false` — you are
49+
* walking the whole list, so per-event availability is rarely what you want
50+
* and always what it costs.
51+
*
52+
* for await (const event of seatlayer.events.listAll()) { … }
53+
*/
54+
async *listAll(options: Omit<EventListOptions, 'cursor'> = {}): AsyncGenerator<EventMeta> {
55+
let cursor: string | undefined;
56+
do {
57+
const page = await this.list({ counts: false, ...options, cursor });
58+
for (const event of page.events) yield event;
59+
cursor = page.nextCursor;
60+
} while (cursor);
61+
}
62+
1763
create(params: {
1864
chartId: string;
1965
name?: string;

src/resources/inventory.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ export class Inventory {
7474
});
7575
}
7676

77+
/**
78+
* Push an active hold's expiry out by a fresh window before it lapses.
79+
*
80+
* Use this rather than release-and-re-hold when an order is taking longer
81+
* than the checkout window — invoiced sales, a phone order on hold. Releasing
82+
* first hands the seats to whoever is racing for them in between. The server
83+
* clamps the window and the DO caps how many times one hold can be renewed;
84+
* a hold that is gone, expired, or at its cap answers 409 `cannot_extend`.
85+
*/
86+
extendHold(eventKey: string, params: {
87+
holdId: string;
88+
ttlMs?: number;
89+
}): Promise<HoldResult> {
90+
return this.#http.post(this.#path(eventKey, '/extend'), { body: params });
91+
}
92+
7793
/** Authoritative items and prices for a hold. Charge from this, not the browser. */
7894
retrieveHold(eventKey: string, holdId: string): Promise<{ items: HoldLineItem[]; expiresAt: number; currency: string }> {
7995
return this.#http.get(this.#path(eventKey, `/holds/${encodeURIComponent(holdId)}`));

test/client.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,58 @@ describe('charts', () => {
205205
expect(JSON.parse(call(0).body).expectedUpdatedAt).toBe(1234);
206206
});
207207
});
208+
209+
describe('pagination', () => {
210+
it('walks every page with listAll and stops when the cursor runs out', async () => {
211+
const { sdk, calls } = client([
212+
{ status: 200, body: { charts: [{ id: 'c_1' }, { id: 'c_2' }], nextCursor: 'cur_1' } },
213+
{ status: 200, body: { charts: [{ id: 'c_3' }] } },
214+
]);
215+
216+
const seen: string[] = [];
217+
for await (const chart of sdk.charts.listAll({ limit: 2 })) seen.push(chart.id);
218+
219+
expect(seen).toEqual(['c_1', 'c_2', 'c_3']);
220+
expect(calls).toHaveLength(2);
221+
// Absent nextCursor terminates — a caller looping on it cannot spin forever.
222+
expect(String(calls[1]!.url)).toContain('cursor=cur_1');
223+
});
224+
225+
it('does not ask for per-event counts when walking the whole catalogue', async () => {
226+
// Counts cost a server round-trip PER EVENT, which is exactly the cost
227+
// pagination was added to avoid.
228+
const { sdk, call } = client([{ status: 200, body: { events: [] } }]);
229+
for await (const _ of sdk.events.listAll()) { /* drain */ }
230+
expect(call(0).url).toContain('counts=0');
231+
});
232+
233+
it('keeps counts on a single explicit page', async () => {
234+
const { sdk, call } = client([{ status: 200, body: { events: [] } }]);
235+
await sdk.events.list({ limit: 10 });
236+
expect(call(0).url).not.toContain('counts=0');
237+
});
238+
239+
it('passes limit and cursor through verbatim', async () => {
240+
const { sdk, call } = client([{ status: 200, body: { charts: [] } }]);
241+
await sdk.charts.list({ limit: 25, cursor: 'abc' });
242+
expect(call(0).url).toContain('limit=25');
243+
expect(call(0).url).toContain('cursor=abc');
244+
});
245+
});
246+
247+
describe('extendHold', () => {
248+
it('posts the hold id to the extend route', async () => {
249+
const { sdk, call } = client([{ status: 200, body: { ok: true, expiresAt: 123 } }]);
250+
await sdk.inventory.extendHold('ev_1', { holdId: 'h_9', ttlMs: 600_000 });
251+
252+
expect(call(0).url).toBe('https://api.seatlayer.io/v1/events/ev_1/extend');
253+
expect(JSON.parse(call(0).body)).toEqual({ holdId: 'h_9', ttlMs: 600_000 });
254+
});
255+
256+
it('surfaces a spent hold as a conflict, not a generic failure', async () => {
257+
const { sdk } = client([{ status: 409, body: { error: 'cannot_extend', reason: 'expired' } }]);
258+
const error = await sdk.inventory.extendHold('ev_1', { holdId: 'h_9' }).catch((e) => e);
259+
expect(error).toBeInstanceOf(SeatLayerConflictError);
260+
expect(error.code).toBe('cannot_extend');
261+
});
262+
});

0 commit comments

Comments
 (0)