local-review 0.2.0 → 0.3.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 +14 -0
- package/dist/demo/lib/gitworld.js +224 -0
- package/dist/demo/world/api.js +2436 -0
- package/dist/demo/world/infra.js +189 -0
- package/dist/demo/world/people.js +16 -0
- package/dist/demo/world/review.js +578 -0
- package/dist/demo/world/sdk.js +376 -0
- package/dist/demo/world/web.js +657 -0
- package/dist/server/cli.js +28 -0
- package/dist/server/config.js +3 -0
- package/dist/server/examples.js +204 -0
- package/dist/server/index.js +17 -2
- package/dist/server/projects.js +2 -0
- package/dist/web/assets/{angular-html-DJ4eInGE.js → angular-html-qFVZP7wG.js} +1 -1
- package/dist/web/assets/{angular-ts-DBhgg2a7.js → angular-ts-DzOxtaC9.js} +1 -1
- package/dist/web/assets/{apl-TCQAu201.js → apl-0AThSj8r.js} +1 -1
- package/dist/web/assets/{astro-DVtFuwC2.js → astro-By53bk_I.js} +1 -1
- package/dist/web/assets/{blade-CMp-sZYg.js → blade-Dw_Nglq9.js} +1 -1
- package/dist/web/assets/{c-DgqspIM2.js → c-CQT-cQHo.js} +1 -1
- package/dist/web/assets/{chapel-NTLt7i8E.js → chapel-64o3Hib1.js} +1 -1
- package/dist/web/assets/{cobol-CJiWQrT4.js → cobol-COyB9oJ9.js} +1 -1
- package/dist/web/assets/{coffee-Bv_hE6wf.js → coffee-BGIfDQTv.js} +1 -1
- package/dist/web/assets/{cpp-CuUsyAvc.js → cpp-wi7ixCRa.js} +1 -1
- package/dist/web/assets/{crystal-BeLopiD_.js → crystal-ADRCp2kP.js} +1 -1
- package/dist/web/assets/{css-OngwBV3K.js → css-BH9tz7qt.js} +1 -1
- package/dist/web/assets/{edge-BFLildoE.js → edge-DFzphiQm.js} +1 -1
- package/dist/web/assets/{elixir-1Fs6FjVs.js → elixir-BUj3AoF_.js} +1 -1
- package/dist/web/assets/{elm-piMM67vF.js → elm-Bqgz9ryu.js} +1 -1
- package/dist/web/assets/{erb-JJ0sF8GL.js → erb-DHHWfqsN.js} +1 -1
- package/dist/web/assets/{git-rebase-CtqXywWe.js → git-rebase-C7iLD81B.js} +1 -1
- package/dist/web/assets/{glimmer-js-BPewWxGC.js → glimmer-js-BT_UjuD4.js} +1 -1
- package/dist/web/assets/{glimmer-ts-DyUQWB47.js → glimmer-ts-BpeqXZCf.js} +1 -1
- package/dist/web/assets/{glsl-D6tJoMaT.js → glsl-mPoRKPQX.js} +1 -1
- package/dist/web/assets/{graphql-E6u2hkWE.js → graphql-D5e-luGt.js} +1 -1
- package/dist/web/assets/{hack-Djq191-P.js → hack-Yj_0xAq9.js} +1 -1
- package/dist/web/assets/{haml-C0LqmZG3.js → haml-rCEvyqvs.js} +1 -1
- package/dist/web/assets/{handlebars-DwdxF6uN.js → handlebars-CZRXN7yP.js} +1 -1
- package/dist/web/assets/{html-B5Myt0fy.js → html-XqiqNW99.js} +1 -1
- package/dist/web/assets/{html-derivative-DTVrLAw3.js → html-derivative-nWC0aMV7.js} +1 -1
- package/dist/web/assets/{http-CFoh3cQf.js → http-C4fFBmNU.js} +1 -1
- package/dist/web/assets/{hurl-B_38naAo.js → hurl-DjZ_NyWj.js} +1 -1
- package/dist/web/assets/{index-CfxIojO0.js → index-BPfnOrNh.js} +22 -22
- package/dist/web/assets/{index-QywYYqTw.css → index-BplAAg-1.css} +1 -1
- package/dist/web/assets/{java-odnexk6r.js → java-B0zZG6CJ.js} +1 -1
- package/dist/web/assets/{javascript-DvFCJKws.js → javascript-CFawPmAZ.js} +1 -1
- package/dist/web/assets/{jinja-UeLt2DYu.js → jinja-qu2nA6S7.js} +1 -1
- package/dist/web/assets/{jison-HNaDYeqE.js → jison-DviWniCr.js} +1 -1
- package/dist/web/assets/{json-D57CnrjR.js → json-C0vF7y3r.js} +1 -1
- package/dist/web/assets/{jsx-o_5ZAydi.js → jsx-DQYIrdZV.js} +1 -1
- package/dist/web/assets/{julia-20UebbrD.js → julia-C_b0WNzR.js} +1 -1
- package/dist/web/assets/{just-DqnPCg-Q.js → just-NwM_1ruS.js} +1 -1
- package/dist/web/assets/{latex-CZhIB-LV.js → latex-As2zLFN8.js} +1 -1
- package/dist/web/assets/{liquid-BzouPsN0.js → liquid-By8qNhnm.js} +1 -1
- package/dist/web/assets/{lua-BXclv8Fm.js → lua-Dairqw_c.js} +1 -1
- package/dist/web/assets/{marko-M78_INlG.js → marko-C2UPN-L_.js} +1 -1
- package/dist/web/assets/{mdc-CBluOiY7.js → mdc-Eijzzwck.js} +1 -1
- package/dist/web/assets/{nginx-CZFFQFbT.js → nginx-BF8eLz9n.js} +1 -1
- package/dist/web/assets/{nim-17kKsGqk.js → nim-BlPfC-Lu.js} +1 -1
- package/dist/web/assets/{org-BZABHRQH.js → org-jZgCqYxI.js} +1 -1
- package/dist/web/assets/{perl-CMrAQ9Je.js → perl-BIZHUlMC.js} +1 -1
- package/dist/web/assets/{php-DwA3kETk.js → php-Bu0o-4mH.js} +1 -1
- package/dist/web/assets/{pug-D4fM85kA.js → pug-Benj2kck.js} +1 -1
- package/dist/web/assets/{qml-C3893RHi.js → qml-BHSeoMrW.js} +1 -1
- package/dist/web/assets/{r-DwJhLW77.js → r-CUvEVkuI.js} +1 -1
- package/dist/web/assets/{razor-_KkCNZ8V.js → razor-BwPBMIxs.js} +1 -1
- package/dist/web/assets/{regexp-BZ9NHg3b.js → regexp-_ifpjSOj.js} +1 -1
- package/dist/web/assets/{rst-gdK-1c4t.js → rst-Es4SE5IJ.js} +1 -1
- package/dist/web/assets/{ruby-DsmqYtlH.js → ruby-76YJjt1j.js} +1 -1
- package/dist/web/assets/{sas-BNGv1ScF.js → sas-BEWDsPnW.js} +1 -1
- package/dist/web/assets/{scss-DjYUXT73.js → scss-DgcF5VZK.js} +1 -1
- package/dist/web/assets/{shellscript-DBtV-eli.js → shellscript-Cty74sqM.js} +1 -1
- package/dist/web/assets/{shellsession-BKEV8Fnx.js → shellsession-BCfSy2gp.js} +1 -1
- package/dist/web/assets/{soy-DUapMxos.js → soy-dM_k5IdG.js} +1 -1
- package/dist/web/assets/{sql-B-UyqKkd.js → sql-Dr4UDgPO.js} +1 -1
- package/dist/web/assets/{stata-Xq3iZiMK.js → stata-COCyw4rB.js} +1 -1
- package/dist/web/assets/{surrealql-CzXacK8i.js → surrealql-Cc-YKbr5.js} +1 -1
- package/dist/web/assets/{svelte-CZaBEmPP.js → svelte-BBNgvYF5.js} +1 -1
- package/dist/web/assets/{templ-CxkGuUpr.js → templ-D8WXh8mG.js} +1 -1
- package/dist/web/assets/{tex-DP74JH6g.js → tex-BNsfM6-w.js} +1 -1
- package/dist/web/assets/{ts-tags-BAcrCE2Y.js → ts-tags-C5cp5d5e.js} +1 -1
- package/dist/web/assets/{tsx-Dw11YP6z.js → tsx-D0nBgoOJ.js} +1 -1
- package/dist/web/assets/{twig-BMVjBxD6.js → twig-Clef_vTO.js} +1 -1
- package/dist/web/assets/{typescript-DoBLlVdC.js → typescript-84qFVDd8.js} +1 -1
- package/dist/web/assets/{typst-w2u_vl4_.js → typst-kHrpsAej.js} +1 -1
- package/dist/web/assets/{vue-BmuHTv5p.js → vue-D_k3LQNp.js} +1 -1
- package/dist/web/assets/{vue-html-Dt0zSF4I.js → vue-html-Bclb5q-s.js} +1 -1
- package/dist/web/assets/{vue-vine-wvJ1BTex.js → vue-vine-BvY0ORZK.js} +1 -1
- package/dist/web/assets/{xml-CXftIBCQ.js → xml-BgfQ1YbS.js} +1 -1
- package/dist/web/assets/{xsl-BoHSOMz1.js → xsl-DEqH3rje.js} +1 -1
- package/dist/web/assets/{yaml-dGreldtD.js → yaml-BiWwl5JE.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// orchard-sdk: the TypeScript client for orchard-api, used by orchard-web and the mobile app.
|
|
2
|
+
import { RepoBuilder, after, edits, replace } from '../lib/gitworld.js';
|
|
3
|
+
import { at, june, maya, remote, sam, theo } from './people.js';
|
|
4
|
+
const append = (s) => (old) => old + s;
|
|
5
|
+
export function buildSdk() {
|
|
6
|
+
const r = new RepoBuilder('orchard-sdk', remote('orchard-sdk'));
|
|
7
|
+
const b1 = r.commit(null, 'Initial client with a fetch transport', {
|
|
8
|
+
'package.json': `{
|
|
9
|
+
"name": "@orchard/sdk",
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"description": "TypeScript client for the Orchard API",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"files": ["dist"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"typescript": "^5.5.4",
|
|
22
|
+
"vitest": "^2.0.5"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
`,
|
|
26
|
+
'tsconfig.json': `{
|
|
27
|
+
"compilerOptions": {
|
|
28
|
+
"target": "ES2022",
|
|
29
|
+
"module": "ESNext",
|
|
30
|
+
"moduleResolution": "Bundler",
|
|
31
|
+
"declaration": true,
|
|
32
|
+
"outDir": "dist",
|
|
33
|
+
"strict": true
|
|
34
|
+
},
|
|
35
|
+
"include": ["src"]
|
|
36
|
+
}
|
|
37
|
+
`,
|
|
38
|
+
'README.md': `# @orchard/sdk
|
|
39
|
+
|
|
40
|
+
The TypeScript client for the Orchard API. Works in browsers and in Node 18+ (anything with \`fetch\`).
|
|
41
|
+
|
|
42
|
+
\`\`\`ts
|
|
43
|
+
import { OrchardClient } from '@orchard/sdk';
|
|
44
|
+
|
|
45
|
+
const orchard = new OrchardClient({ baseUrl: 'https://api.orchard.example', token });
|
|
46
|
+
const { classes } = await orchard.classes.schedule(studioId, { from: '2026-06-01' });
|
|
47
|
+
\`\`\`
|
|
48
|
+
`,
|
|
49
|
+
'src/errors.ts': `/** An error answer from the API: \`code\` is stable (switch on it), \`message\` is for people. */
|
|
50
|
+
export class ApiError extends Error {
|
|
51
|
+
constructor(
|
|
52
|
+
readonly status: number,
|
|
53
|
+
readonly code: string,
|
|
54
|
+
message: string,
|
|
55
|
+
) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'ApiError';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static from(status: number, body: unknown): ApiError {
|
|
61
|
+
const e = (body as { error?: { code?: string; message?: string } } | null)?.error;
|
|
62
|
+
return new ApiError(status, e?.code ?? 'unknown', e?.message ?? \`HTTP \${status}\`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
`,
|
|
66
|
+
'src/client.ts': `import { ApiError } from './errors.js';
|
|
67
|
+
|
|
68
|
+
export interface ClientOptions {
|
|
69
|
+
baseUrl: string;
|
|
70
|
+
token: string;
|
|
71
|
+
/** Defaults to the global fetch. Tests pass a fake. */
|
|
72
|
+
fetch?: typeof fetch;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class OrchardClient {
|
|
76
|
+
constructor(private readonly options: ClientOptions) {}
|
|
77
|
+
|
|
78
|
+
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
79
|
+
const f = this.options.fetch ?? fetch;
|
|
80
|
+
const res = await f(new URL(path, this.options.baseUrl), {
|
|
81
|
+
method,
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: \`Bearer \${this.options.token}\`,
|
|
84
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
85
|
+
},
|
|
86
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
if (res.status === 204) return undefined as T;
|
|
89
|
+
const data: unknown = await res.json().catch(() => null);
|
|
90
|
+
if (!res.ok) throw ApiError.from(res.status, data);
|
|
91
|
+
return data as T;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`,
|
|
95
|
+
'src/index.ts': `export { OrchardClient, type ClientOptions } from './client.js';
|
|
96
|
+
`,
|
|
97
|
+
}, { author: theo, at: at('2026-05-20 10:40') });
|
|
98
|
+
const b2 = r.commit(b1, 'Add the classes resource', {
|
|
99
|
+
'src/types.ts': `/** A class on a studio's schedule. Times are ISO 8601 strings, in UTC. */
|
|
100
|
+
export interface Class {
|
|
101
|
+
id: number;
|
|
102
|
+
title: string;
|
|
103
|
+
coach: string;
|
|
104
|
+
starts_at: string;
|
|
105
|
+
minutes: number;
|
|
106
|
+
capacity: number;
|
|
107
|
+
spots_left: number;
|
|
108
|
+
}
|
|
109
|
+
`,
|
|
110
|
+
'src/resources/classes.ts': `import type { OrchardClient } from '../client.js';
|
|
111
|
+
import type { Class } from '../types.js';
|
|
112
|
+
|
|
113
|
+
export class ClassesResource {
|
|
114
|
+
constructor(private readonly client: OrchardClient) {}
|
|
115
|
+
|
|
116
|
+
/** A week of a studio's classes, starting on \`from\` (YYYY-MM-DD; default today). */
|
|
117
|
+
schedule(studioId: number, opts: { from?: string } = {}): Promise<{ classes: Class[] }> {
|
|
118
|
+
const q = opts.from ? \`?from=\${encodeURIComponent(opts.from)}\` : '';
|
|
119
|
+
return this.client.request('GET', \`/studios/\${studioId}/schedule\${q}\`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get(id: number): Promise<Class> {
|
|
123
|
+
return this.client.request('GET', \`/classes/\${id}\`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
`,
|
|
127
|
+
'src/client.ts': edits(["import { ApiError } from './errors.js';\n", "import { ApiError } from './errors.js';\nimport { ClassesResource } from './resources/classes.js';\n"], ['export class OrchardClient {\n constructor(private readonly options: ClientOptions) {}\n', 'export class OrchardClient {\n readonly classes = new ClassesResource(this);\n\n constructor(private readonly options: ClientOptions) {}\n']),
|
|
128
|
+
'src/index.ts': append("export { ApiError } from './errors.js';\nexport type * from './types.js';\n"),
|
|
129
|
+
}, { author: june, at: at('2026-05-28 14:15') });
|
|
130
|
+
const b3 = r.commit(b2, 'Add the bookings resource', {
|
|
131
|
+
'src/types.ts': append(`
|
|
132
|
+
export interface Booking {
|
|
133
|
+
id: number;
|
|
134
|
+
class_id: number;
|
|
135
|
+
status: 'confirmed' | 'cancelled';
|
|
136
|
+
/** Present when listing your bookings. */
|
|
137
|
+
class?: Class;
|
|
138
|
+
}
|
|
139
|
+
`),
|
|
140
|
+
'src/resources/bookings.ts': `import type { OrchardClient } from '../client.js';
|
|
141
|
+
import type { Booking } from '../types.js';
|
|
142
|
+
|
|
143
|
+
export class BookingsResource {
|
|
144
|
+
constructor(private readonly client: OrchardClient) {}
|
|
145
|
+
|
|
146
|
+
/** Book a spot. Throws ApiError \`class_full\` or \`already_booked\`. */
|
|
147
|
+
book(classId: number): Promise<Booking> {
|
|
148
|
+
return this.client.request('POST', \`/classes/\${classId}/bookings\`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Your upcoming bookings, soonest first. */
|
|
152
|
+
listMine(): Promise<{ bookings: Booking[] }> {
|
|
153
|
+
return this.client.request('GET', '/me/bookings');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
`,
|
|
157
|
+
'src/client.ts': edits(["import { ClassesResource } from './resources/classes.js';\n", "import { BookingsResource } from './resources/bookings.js';\nimport { ClassesResource } from './resources/classes.js';\n"], [' readonly classes = new ClassesResource(this);\n', ' readonly classes = new ClassesResource(this);\n readonly bookings = new BookingsResource(this);\n']),
|
|
158
|
+
}, { author: theo, at: at('2026-06-04 11:05') });
|
|
159
|
+
const b4 = r.commit(b3, 'Retry idempotent requests on 502 and 503\n\nDeploys briefly answer 502/503 from the load balancer. GETs and DELETEs are safe to repeat;\nPOSTs are not (a booking could be made twice), so they are never retried.', {
|
|
160
|
+
'src/client.ts': edits([
|
|
161
|
+
"export interface ClientOptions {\n",
|
|
162
|
+
"const RETRYABLE = new Set([502, 503]);\nconst IDEMPOTENT = new Set(['GET', 'DELETE']);\n\nexport interface ClientOptions {\n",
|
|
163
|
+
], [
|
|
164
|
+
' async request<T>(method: string, path: string, body?: unknown): Promise<T> {\n const f = this.options.fetch ?? fetch;\n const res = await f(',
|
|
165
|
+
` async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
166
|
+
const attempts = IDEMPOTENT.has(method) ? 3 : 1;
|
|
167
|
+
for (let i = 1; ; i++) {
|
|
168
|
+
const res = await this.send(method, path, body);
|
|
169
|
+
if (i < attempts && RETRYABLE.has(res.status)) {
|
|
170
|
+
await sleep(200 * 2 ** i);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (res.status === 204) return undefined as T;
|
|
174
|
+
const data: unknown = await res.json().catch(() => null);
|
|
175
|
+
if (!res.ok) throw ApiError.from(res.status, data);
|
|
176
|
+
return data as T;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private send(method: string, path: string, body: unknown): Promise<Response> {
|
|
181
|
+
const f = this.options.fetch ?? fetch;
|
|
182
|
+
return f(`,
|
|
183
|
+
], [
|
|
184
|
+
" body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (res.status === 204) return undefined as T;\n const data: unknown = await res.json().catch(() => null);\n if (!res.ok) throw ApiError.from(res.status, data);\n return data as T;\n }\n}\n",
|
|
185
|
+
' body: body === undefined ? undefined : JSON.stringify(body),\n });\n }\n}\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n',
|
|
186
|
+
]),
|
|
187
|
+
}, { author: sam, at: at('2026-06-12 16:22') });
|
|
188
|
+
const b5 = r.commit(b4, 'Test the bookings resource against a fake transport', {
|
|
189
|
+
'test/fake.ts': `import { OrchardClient } from '../src/client.js';
|
|
190
|
+
|
|
191
|
+
export interface Call {
|
|
192
|
+
method: string;
|
|
193
|
+
path: string;
|
|
194
|
+
body?: unknown;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A client whose fetch answers from \`routes\` ("METHOD /path" → [status, body]) and records every call. */
|
|
198
|
+
export function fakeClient(routes: Record<string, [number, unknown?]>) {
|
|
199
|
+
const calls: Call[] = [];
|
|
200
|
+
const fetch = async (url: URL | RequestInfo, init?: RequestInit) => {
|
|
201
|
+
const u = new URL(String(url));
|
|
202
|
+
const method = init?.method ?? 'GET';
|
|
203
|
+
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
204
|
+
calls.push({ method, path: u.pathname + u.search, body });
|
|
205
|
+
const [status, json] = routes[\`\${method} \${u.pathname}\`] ?? [404, { error: { code: 'not_found', message: 'Not found.' } }];
|
|
206
|
+
return new Response(status === 204 ? null : JSON.stringify(json ?? {}), { status });
|
|
207
|
+
};
|
|
208
|
+
return { client: new OrchardClient({ baseUrl: 'https://api.test', token: 't', fetch: fetch as typeof globalThis.fetch }), calls };
|
|
209
|
+
}
|
|
210
|
+
`,
|
|
211
|
+
'test/bookings.test.ts': `import { describe, expect, it } from 'vitest';
|
|
212
|
+
import { ApiError } from '../src/errors.js';
|
|
213
|
+
import { fakeClient } from './fake.js';
|
|
214
|
+
|
|
215
|
+
describe('bookings', () => {
|
|
216
|
+
it('books a class', async () => {
|
|
217
|
+
const { client, calls } = fakeClient({ 'POST /classes/7/bookings': [201, { id: 1, class_id: 7, status: 'confirmed' }] });
|
|
218
|
+
await expect(client.bookings.book(7)).resolves.toMatchObject({ id: 1, status: 'confirmed' });
|
|
219
|
+
expect(calls).toEqual([{ method: 'POST', path: '/classes/7/bookings' }]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('turns an error answer into an ApiError with its code', async () => {
|
|
223
|
+
const { client } = fakeClient({ 'POST /classes/7/bookings': [409, { error: { code: 'class_full', message: 'This class is full.' } }] });
|
|
224
|
+
const err = await client.bookings.book(7).catch((e) => e);
|
|
225
|
+
expect(err).toBeInstanceOf(ApiError);
|
|
226
|
+
expect(err).toMatchObject({ status: 409, code: 'class_full' });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('never retries a POST', async () => {
|
|
230
|
+
const { client, calls } = fakeClient({ 'POST /classes/7/bookings': [503] });
|
|
231
|
+
await expect(client.bookings.book(7)).rejects.toThrow(ApiError);
|
|
232
|
+
expect(calls).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
`,
|
|
236
|
+
}, { author: sam, at: at('2026-06-13 10:30') });
|
|
237
|
+
const b6 = r.commit(b5, 'Support cancelling bookings', {
|
|
238
|
+
'src/resources/bookings.ts': edits(["import type { OrchardClient } from '../client.js';\n", "import type { OrchardClient } from '../client.js';\nimport { ApiError } from '../errors.js';\n"], [
|
|
239
|
+
' /** Your upcoming bookings, soonest first. */\n',
|
|
240
|
+
` /** Cancel one of your bookings. */
|
|
241
|
+
async cancel(bookingId: number, opts: CancelOptions = {}): Promise<void> {
|
|
242
|
+
try {
|
|
243
|
+
await this.client.request('DELETE', \`/me/bookings/\${bookingId}\`);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if (!(opts.ignoreMissing && e instanceof ApiError && e.code === 'not_found')) throw e;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Your upcoming bookings, soonest first. */
|
|
250
|
+
`,
|
|
251
|
+
], [
|
|
252
|
+
'export class BookingsResource {',
|
|
253
|
+
`export interface CancelOptions {
|
|
254
|
+
/** Ignore a booking that's already gone (e.g. a retry after a timeout). */
|
|
255
|
+
ignoreMissing?: boolean;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export class BookingsResource {`,
|
|
259
|
+
]),
|
|
260
|
+
'src/types.ts': replace(" status: 'confirmed' | 'cancelled';\n", " status: 'confirmed' | 'cancelled';\n /** ISO 8601. */\n created_at?: string;\n"),
|
|
261
|
+
}, { author: maya, at: at('2026-06-24 15:50') });
|
|
262
|
+
const b7 = r.commit(b6, 'Release 0.7.0', {
|
|
263
|
+
'package.json': replace('"version": "0.1.0"', '"version": "0.7.0"'),
|
|
264
|
+
'CHANGELOG.md': `# Changelog
|
|
265
|
+
|
|
266
|
+
## 0.7.0
|
|
267
|
+
|
|
268
|
+
- \`bookings.cancel()\`, with \`ignoreMissing\` for retries.
|
|
269
|
+
- GETs and DELETEs are retried on 502/503 (POSTs never are).
|
|
270
|
+
- Errors are \`ApiError\`s with the API's stable \`code\`.
|
|
271
|
+
|
|
272
|
+
## 0.6.0
|
|
273
|
+
|
|
274
|
+
- \`classes.schedule()\` and \`classes.get()\`, \`bookings.book()\` and \`bookings.listMine()\`.
|
|
275
|
+
`,
|
|
276
|
+
}, { author: june, at: at('2026-07-02 09:15') });
|
|
277
|
+
const b8 = r.commit(b7, 'Send a request id and surface it on errors\n\nThe API logs X-Request-Id on every request; quoting it in a bug report finds the log line.', {
|
|
278
|
+
'src/errors.ts': edits([' readonly code: string,\n message: string,\n ) {', " readonly code: string,\n message: string,\n /** The X-Request-Id of the failed request: quote it in bug reports. */\n readonly requestId?: string,\n ) {"], [' static from(status: number, body: unknown): ApiError {', ' static from(status: number, body: unknown, requestId?: string): ApiError {'], ["return new ApiError(status, e?.code ?? 'unknown', e?.message ?? `HTTP ${status}`);", "return new ApiError(status, e?.code ?? 'unknown', e?.message ?? `HTTP ${status}`, requestId);"]),
|
|
279
|
+
'src/client.ts': edits([' if (!res.ok) throw ApiError.from(res.status, data);', " if (!res.ok) throw ApiError.from(res.status, data, res.headers.get('X-Request-Id') ?? undefined);"], [' Authorization: `Bearer ${this.options.token}`,\n', ' Authorization: `Bearer ${this.options.token}`,\n \'X-Request-Id\': crypto.randomUUID(),\n']),
|
|
280
|
+
}, { author: theo, at: at('2026-08-28 13:40') });
|
|
281
|
+
// ---------------------------------------------------------------- Maya's waitlists branch
|
|
282
|
+
const s1 = r.commit(b8, 'Add waitlist types', {
|
|
283
|
+
'src/types.ts': append(`
|
|
284
|
+
export type WaitlistStatus = 'waiting' | 'offered';
|
|
285
|
+
|
|
286
|
+
/** Your place in a full class's waitlist. */
|
|
287
|
+
export interface WaitlistPlace {
|
|
288
|
+
id: number;
|
|
289
|
+
class_id: number;
|
|
290
|
+
/** 1 means you're next. */
|
|
291
|
+
position: number;
|
|
292
|
+
status: WaitlistStatus;
|
|
293
|
+
/** When status is 'offered': the spot is held for you until then (ISO 8601). */
|
|
294
|
+
hold_until?: string;
|
|
295
|
+
/** Present when listing your bookings. */
|
|
296
|
+
class?: Class;
|
|
297
|
+
}
|
|
298
|
+
`),
|
|
299
|
+
}, { author: maya, at: at('2026-09-09 13:10'), key: 's1' });
|
|
300
|
+
const s2 = r.commit(s1, 'Add joinWaitlist and leaveWaitlist', {
|
|
301
|
+
'src/resources/bookings.ts': edits(["import type { Booking } from '../types.js';", "import type { Booking, WaitlistPlace } from '../types.js';"], [
|
|
302
|
+
' /** Your upcoming bookings, soonest first. */\n',
|
|
303
|
+
` /** Join a full class's waitlist. Throws ApiError \`class_not_full\` if you could just book it. */
|
|
304
|
+
joinWaitlist(classId: number): Promise<WaitlistPlace> {
|
|
305
|
+
return this.client.request('POST', \`/classes/\${classId}/waitlist\`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Leave a class's waitlist. Leaving one you're not on is fine. */
|
|
309
|
+
leaveWaitlist(classId: number): Promise<void> {
|
|
310
|
+
return this.client.request('DELETE', \`/classes/\${classId}/waitlist\`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Your upcoming bookings, soonest first. */
|
|
314
|
+
`,
|
|
315
|
+
]),
|
|
316
|
+
'test/waitlist.test.ts': `import { describe, expect, it } from 'vitest';
|
|
317
|
+
import { fakeClient } from './fake.js';
|
|
318
|
+
|
|
319
|
+
describe('waitlist', () => {
|
|
320
|
+
it('joins and leaves', async () => {
|
|
321
|
+
const { client, calls } = fakeClient({
|
|
322
|
+
'POST /classes/7/waitlist': [201, { id: 3, class_id: 7, position: 4, status: 'waiting' }],
|
|
323
|
+
'DELETE /classes/7/waitlist': [204],
|
|
324
|
+
});
|
|
325
|
+
await expect(client.bookings.joinWaitlist(7)).resolves.toMatchObject({ position: 4 });
|
|
326
|
+
await client.bookings.leaveWaitlist(7);
|
|
327
|
+
expect(calls.map((c) => \`\${c.method} \${c.path}\`)).toEqual(['POST /classes/7/waitlist', 'DELETE /classes/7/waitlist']);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('retries a leave on 503, since leaving twice is harmless', async () => {
|
|
331
|
+
const { client, calls } = fakeClient({ 'DELETE /classes/7/waitlist': [503] });
|
|
332
|
+
await expect(client.bookings.leaveWaitlist(7)).rejects.toMatchObject({ status: 503 });
|
|
333
|
+
expect(calls).toHaveLength(3);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
`,
|
|
337
|
+
}, { author: maya, at: at('2026-09-09 17:25'), key: 's2' });
|
|
338
|
+
const s3 = r.commit(s2, 'Return waitlist places from listMine', {
|
|
339
|
+
'src/resources/bookings.ts': edits([' /** Your upcoming bookings, soonest first. */\n listMine(): Promise<{ bookings: Booking[] }> {', ' /** Your upcoming bookings, and the waitlists you\'re on, soonest first. */\n listMine(): Promise<{ bookings: Booking[]; waitlist: WaitlistPlace[] }> {']),
|
|
340
|
+
'CHANGELOG.md': replace('# Changelog\n', '# Changelog\n\n## Unreleased\n\n- `bookings.listMine()` also returns `waitlist`: the waitlists you\'re on, with your position.\n'),
|
|
341
|
+
}, { author: maya, at: at('2026-09-10 11:00'), key: 's3' });
|
|
342
|
+
const s4 = r.commit(s3, 'Add acceptOffer for spots offered from the waitlist', {
|
|
343
|
+
'src/resources/bookings.ts': replace(' /** Your upcoming bookings', ` /**
|
|
344
|
+
* Take a spot you were offered from a waitlist. Throws ApiError \`offer_gone\` (410) if the hold
|
|
345
|
+
* ran out first: the spot went to the next person.
|
|
346
|
+
*/
|
|
347
|
+
acceptOffer(placeId: number): Promise<Booking> {
|
|
348
|
+
return this.client.request('POST', \`/me/offers/\${placeId}/accept\`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Your upcoming bookings`),
|
|
352
|
+
'CHANGELOG.md': after("with your position.\n", '- `bookings.acceptOffer()` takes a spot offered from a waitlist.\n'),
|
|
353
|
+
'README.md': append(`
|
|
354
|
+
## Waitlists
|
|
355
|
+
|
|
356
|
+
When a class is full, join its waitlist. If a spot opens up you're offered it, and it's held for you
|
|
357
|
+
for two hours:
|
|
358
|
+
|
|
359
|
+
\`\`\`ts
|
|
360
|
+
await orchard.bookings.joinWaitlist(classId);
|
|
361
|
+
const { waitlist } = await orchard.bookings.listMine();
|
|
362
|
+
const offer = waitlist.find((p) => p.status === 'offered');
|
|
363
|
+
if (offer) await orchard.bookings.acceptOffer(offer.id);
|
|
364
|
+
\`\`\`
|
|
365
|
+
`),
|
|
366
|
+
}, { author: maya, at: at('2026-09-11 09:35'), key: 's4' });
|
|
367
|
+
r.ref('main', b8);
|
|
368
|
+
r.ref('refs/remotes/origin/main', b8);
|
|
369
|
+
r.ref('refs/remotes/origin/HEAD', b8);
|
|
370
|
+
r.ref('maya/waitlists', s4);
|
|
371
|
+
r.ref('maya/sdk-waitlist-types', s1);
|
|
372
|
+
r.ref('maya/sdk-join-leave', s2);
|
|
373
|
+
r.ref('maya/sdk-list-waitlist', s3);
|
|
374
|
+
r.ref('refs/remotes/origin/maya/sdk-waitlist-types', s1);
|
|
375
|
+
return r;
|
|
376
|
+
}
|