bolbol-sdk 1.0.1 → 1.0.2

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 (2) hide show
  1. package/README.md +393 -393
  2. package/package.json +28 -28
package/README.md CHANGED
@@ -1,393 +1,393 @@
1
- # bolbol-sdk
2
-
3
- The typed client for the Bolbol API. Every app that talks to the backend - the
4
- Console (Next.js) and the Academy CLI (Node) today - goes through this package
5
- instead of calling `fetch` itself, so the shape of every request and answer is
6
- declared exactly once.
7
-
8
- ```ts
9
- import { BolbolServer, HTTPError } from "bolbol-sdk";
10
-
11
- const bolbol = new BolbolServer({
12
- baseUrl: "https://bolbol.io/api",
13
- getToken: () => readSessionToken(),
14
- });
15
-
16
- const response = await bolbol.auth.user.login({ email, password });
17
- console.log(response.result.user.name);
18
- ```
19
-
20
- - [Layout](#layout)
21
- - [Using it](#using-it)
22
- - [The rules](#the-rules)
23
- - [1. One scope per backend controller folder](#1-one-scope-per-backend-controller-folder)
24
- - [2. One method per controller](#2-one-method-per-controller)
25
- - [3. Types: one per file, named by what they are](#3-types-one-per-file-named-by-what-they-are)
26
- - [Translatable columns: `TranslationType`](#translatable-columns-translationtype)
27
- - [4. Wire format](#4-wire-format)
28
- - [Adding or changing an endpoint](#adding-or-changing-an-endpoint)
29
- - [Checking the SDK against the API](#checking-the-sdk-against-the-api)
30
- - [Scripts](#scripts)
31
-
32
- ## Layout
33
-
34
- ```
35
- src/
36
- ├── BolbolServer.ts the entry point: builds the HTTPClient and one instance of every scope
37
- ├── HTTPClient.ts the only place that calls fetch
38
- ├── HTTPError.ts what every failed call throws
39
- ├── index.ts generated - re-exports everything below
40
- ├── scope/
41
- │ ├── BolbolAuthScope.ts mirrors Bolbol API src/controller/auth
42
- │ ├── BolbolAcademyScope.ts mirrors Bolbol API src/controller/academy
43
- │ └── ... one file per backend controller folder
44
- └── type/
45
- ├── UserRowType.ts a table row
46
- ├── AuthLoginRequestType.ts what one endpoint takes
47
- ├── AuthLoginResponseType.ts what one endpoint answers
48
- ├── RequestStatusEnum.ts a database enum
49
- ├── BolbolResponseType.ts a shared shape
50
- └── ...
51
- scripts/
52
- ├── generate-index.mjs writes src/index.ts
53
- test/
54
- └── api-contract.test.mjs checks every backend controller against its SDK method
55
- ```
56
-
57
- ## Using it
58
-
59
- ### Creating the server
60
-
61
- ```ts
62
- new BolbolServer({
63
- baseUrl: "https://bolbol.io/api", // API base, mount path included, no trailing slash
64
- getToken: () => token, // sync or async; read per request
65
- timeoutMs: 30000, // optional
66
- init: { credentials: "include", cache: "no-store" }, // optional extra fetch options
67
- });
68
- ```
69
-
70
- `getToken` is asked for the session token on every call, so a
71
- browser app can read its cookie and a server can read its request's cookie with
72
- the same class. Create one `BolbolServer` per token source and reuse it.
73
-
74
- ### Calling an endpoint
75
-
76
- Scopes hang off the server, one per backend controller folder, and group their
77
- methods by noun:
78
-
79
- ```ts
80
- await bolbol.teacher.certificate.list(teacherUid);
81
- await bolbol.teacher.certificate.create(teacherUid, { name, issuer, issue_date });
82
- await bolbol.academy.asset.get(academyUid, assetUid);
83
- ```
84
-
85
- Every method resolves to the API's envelope, `BolbolResponseType<T>`:
86
-
87
- ```ts
88
- const response = await bolbol.auth.user.me();
89
- response.result.user; // the controller's own output
90
- response.duration; // plus the envelope metadata: id, code, status, started_at, duration
91
- ```
92
-
93
- ### Errors
94
-
95
- Every call that does not get a 2xx answer throws `HTTPError`:
96
-
97
- | Field | Meaning |
98
- | --- | --- |
99
- | `code` | The HTTP status, or `0` (`HTTPError.UNREACHABLE`) when the API could not be reached at all |
100
- | `message` | The API's own explanation, already localized, or an English fallback |
101
- | `lang` | The language of `message`, when the API said |
102
- | `errors` | For a validation 400: one `{ path, message }` per failed rule |
103
-
104
- ```ts
105
- try
106
- {
107
- await bolbol.auth.user.login(input);
108
- }
109
- catch (error)
110
- {
111
- if (error instanceof HTTPError && error.code === 401)
112
- showWrongPassword(error.message);
113
- else
114
- throw error;
115
- }
116
- ```
117
-
118
- ## The rules
119
-
120
- These keep the SDK a faithful, predictable mirror of the backend. Follow them
121
- for every change; `npm test` enforces them.
122
-
123
- ### 1. One scope per backend controller folder
124
-
125
- - Every folder under `Bolbol API/src/controller/<folder>` has exactly one scope
126
- file, `src/scope/Bolbol{Folder}Scope.ts`, where `{Folder}` is the folder name
127
- in PascalCase: `auth` → `BolbolAuthScope`, `user_file` → `BolbolUserFileScope`.
128
- - `BolbolServer` creates **one instance of each scope** and exposes it as a
129
- public readonly property named after the folder in camelCase:
130
- `bolbol.auth`, `bolbol.userFile`, `bolbol.academy`.
131
- - A scope takes the shared `HTTPClient` in its constructor and nothing else. It
132
- never calls `fetch` and never reads configuration of its own.
133
- - There are no scopes for anything that is not a controller folder. Helpers
134
- that are not endpoints (resolving a static asset path) live on `BolbolServer`.
135
-
136
- ### 2. One method per controller
137
-
138
- Every controller file in the folder gets exactly one method on its scope - no
139
- more, no fewer.
140
-
141
- **Name and grouping.** Methods are grouped into one object per noun, with a
142
- short verb as the key, so call sites read as `scope.noun.verb(...)`:
143
-
144
- ```ts
145
- public readonly certificate = {
146
- list: (teacher_uid: string) => ...,
147
- create: (teacher_uid: string, request: TeacherCertificateCreateRequestType) => ...,
148
- update: (teacher_uid: string, certificate_id: number, request: TeacherCertificateUpdateRequestType) => ...,
149
- delete: (teacher_uid: string, certificate_id: number) => ...,
150
- };
151
- ```
152
-
153
- Nest a noun inside another when the URL nests it (`academy.asset.version.publish`).
154
-
155
- **The marker comment.** The line right above every method is exactly:
156
-
157
- ```ts
158
- // {METHOD} {path exactly as in the controller's info()} - {API name}
159
- // POST /auth/cli-login/:request_token/approve - AuthCLILoginApprove
160
- ```
161
-
162
- `npm test` reads these markers to find the method for each controller.
163
-
164
- **The API name** identifies an endpoint everywhere in the SDK. It is every
165
- folder between `src/controller/` and the file, each in PascalCase, followed by
166
- the file name without `Controller`. The method lives on the scope of the
167
- top-level folder:
168
-
169
- | Controller file | API name |
170
- | --- | --- |
171
- | `auth/LoginController.ts` | `AuthLogin` |
172
- | `academy/AssetCreateController.ts` | `AcademyAssetCreate` |
173
- | `user_file/UploadController.ts` | `UserFileUpload` |
174
- | `teacher/UpdatePhotoController.ts` | `TeacherUpdatePhoto` |
175
- | `bolbol/teacher_exam/CountPendingController.ts` | `BolbolTeacherExamCountPending` (on `BolbolBolbolScope`) |
176
-
177
- **The signature mirrors the controller:**
178
-
179
- - **Path parameters** come first, as positional arguments in the order they
180
- appear in the path, named exactly like the path segment (`teacher_uid`,
181
- `certificate_id`) and typed like the controller's parameter schema
182
- (`string` for a uid or token, `number` for a numeric id).
183
- - **Then one `request` argument** of type `{API name}RequestType`, when the
184
- controller takes input beyond the path: its body schema for POST / PUT /
185
- PATCH / DELETE, its query schema for GET. When every field of a **query** is
186
- optional the argument defaults to `{}`. A body never defaults, even when all
187
- its fields are optional: some updates clear every field they are not sent, so
188
- sending one must be deliberate.
189
- - **The return type** is `Promise<BolbolResponseType<{API name}ResponseType>>`,
190
- where the ResponseType is exactly what the controller's `handle()` returns.
191
- - The HTTP method and path are the controller's own. Interpolate path
192
- parameters with `encodeURIComponent` for strings.
193
-
194
- ```ts
195
- // PUT /teacher/:teacher_uid/certificate/:certificate_id - TeacherCertificateUpdate
196
- update: (teacher_uid: string, certificate_id: number, request: TeacherCertificateUpdateRequestType): Promise<BolbolResponseType<TeacherCertificateUpdateResponseType>> =>
197
- this.http.put(`/teacher/${encodeURIComponent(teacher_uid)}/certificate/${certificate_id}`, { body: request }),
198
- ```
199
-
200
- Convenience that the backend does not have - reading a browser `File` into
201
- base64 before an upload, turning a path into a URL - may wrap the call, but the
202
- arguments that reach the wire must still be exactly the controller's.
203
-
204
- ### 3. Types: one per file, named by what they are
205
-
206
- Every type the SDK exposes lives in `src/type/`, **one exported type per file,
207
- file named exactly after the type**. No barrels, no `types.ts`, no type declared
208
- inside a scope file. The suffix says what kind of type it is, and there are only
209
- five:
210
-
211
- | Suffix | What it is | Named after | Example |
212
- | --- | --- | --- | --- |
213
- | `RowType` | One row of a database table, as the API sends it (the backend's `Public*` projection when it has one - fields that never leave the backend, like a numeric `id` behind a `uid`, are left out) | The table, in PascalCase | `UserRowType`, `TeacherCertificateRowType`, `ItemVersionRowType` |
214
- | `RequestType` | The input of one endpoint - its body (or query, for a GET) | The API name | `AuthLoginRequestType`, `AcademyAssetCreateRequestType` |
215
- | `ResponseType` | The `result` of one endpoint | The API name | `AuthLoginResponseType`, `AcademyAssetListResponseType` |
216
- | `Enum` | A database or API enum, as a TypeScript `enum`. Every member is PascalCase and its value is exactly its name (`PastDue = "PastDue"`) | The backend enum without its `Database_` prefix, in PascalCase | `RequestStatusEnum` (`Database_Request_Status`), `CLILoginRequestStatusEnum` |
217
- | `Type` | Anything else shared by several of the above: a JSON column's shape, a generic wrapper | What it describes | `BolbolResponseType<T>`, `ListPageType<T>`, `UserSettingGeneralType` |
218
-
219
- Rules that make the table work:
220
-
221
- - **Every endpoint has its own RequestType (if it takes input) and its own
222
- ResponseType** - even when two endpoints answer the same shape. Then the
223
- second one is an alias, not a copy:
224
-
225
- ```ts
226
- // AcademyAssetGetResponseType.ts
227
- import { AssetRowType } from "./AssetRowType";
228
-
229
- export interface AcademyAssetGetResponseType
230
- {
231
- item: AssetRowType;
232
- }
233
- ```
234
-
235
- ```ts
236
- // AcademyAssetUpdateResponseType.ts
237
- import { AcademyAssetGetResponseType } from "./AcademyAssetGetResponseType";
238
-
239
- export type AcademyAssetUpdateResponseType = AcademyAssetGetResponseType;
240
- ```
241
-
242
- This keeps "what does endpoint X take and answer" a single lookup, and lets
243
- one endpoint change shape without dragging the others along.
244
- - **Paged list requests extend `ListPagingType`**; paged list answers use
245
- `ListPageType<Row>`, unpaged ones `ListType<Row>`.
246
- - **Field names are the wire names** - snake_case, exactly as the backend's Zod
247
- schema or projection spells them. Never camelCase a field.
248
- - **Optional vs nullable follow the backend**: `.optional()` → `field?: T`,
249
- `.nullable()` → `field: T | null`, `.nullish()` → `field?: T | null`.
250
- - Dates travel as ISO strings, so they are `string` here, never `Date`.
251
- - Each type file starts with a comment naming its source in the backend
252
- (`// Mirrors PublicTeacher in Bolbol API src/service/teacher/TeacherPublic.ts`).
253
- - **The Bolbol API imports these types too.** Its `toPublic*()` projections
254
- return the SDK's RowTypes / Types, and the enums whose values go over the wire
255
- (`UserPolicyEnum`, `RequestStatusEnum`, `ItemTypeEnum`, ...) exist ONLY here -
256
- the backend's database columns are declared from them. So for those, this
257
- package is the source of truth, not a mirror: a change here changes the
258
- backend's database enum and its compile-time contract. Keep every enum's keys
259
- equal to its values - the backend builds columns with `Object.keys(...)`.
260
-
261
- ### Translatable columns: `TranslationType`
262
-
263
- A database column whose text is read in several languages travels with a
264
- sibling `<column>_translations` field (the backend rule is
265
- `Bolbol API/rules/Translation.md`). Its shape is one shared type:
266
-
267
- ```ts
268
- // TranslationType.ts - keyed by LanguageCodeType, the 32 hard-coded language
269
- // codes ("en", "fr", "fa", ...); a language with no translation is an absent key.
270
- export type TranslationType = { [language_code in LanguageCodeType]?: string };
271
- ```
272
-
273
- - **RowType:** the field sits directly below its source field and is always
274
- present - the backend stores `{}` for "no translations", never `null`, even
275
- when the source field is nullable:
276
-
277
- ```ts
278
- name: string;
279
- name_translations: TranslationType;
280
- content: string | null;
281
- content_translations: TranslationType;
282
- ```
283
-
284
- The map only ever holds languages that have a value - never all languages
285
- with blanks.
286
-
287
- - **RequestType:** every Create / Update that takes the source field takes its
288
- `<column>_translations` too, as a **patch of only the changed languages**, not
289
- the whole map. Its type is the second shared type:
290
-
291
- ```ts
292
- // TranslationPatchType.ts
293
- // Mirrors translationSchema() in Bolbol API src/controller/TranslationSchema.ts.
294
- // Only the languages that changed: a string sets that language, null removes
295
- // it, a language not listed is left as stored.
296
- export type TranslationPatchType = { [language_code in LanguageCodeType]?: string | null };
297
- ```
298
-
299
- Optionality follows the Zod schema (`translationSchema(...).optional()` →
300
- `name_translations?: TranslationPatchType`). Leave the field out when no
301
- language changed.
302
- - **Flattened detail columns (`academy.item_version.details_*`)** each get their
303
- own sibling `_translations` column, same convention as every other
304
- translation column. Plain translations use `TranslationType` /
305
- `TranslationPatchType`; choice-keyed translations (e.g.
306
- `details_multiplechoice_choices_translations`) use
307
- `ChoiceTranslationType<TranslationType>` on the RowType and
308
- `ChoiceTranslationType<TranslationPatchType | null>` on the RequestType
309
- (`null` on a choice removes all its translations). Shape and rules:
310
- `Bolbol API/rules/Translation.md`, "Translating `academy.item_version` detail
311
- columns".
312
- - Use `TranslationType` / `TranslationPatchType` by name. Never inline
313
- `Record<string, string>` or `{ [k: string]: string }` in a RowType or
314
- RequestType - one name is what lets every app find (and type-check) every
315
- translatable field.
316
- - Reading one: `row.name_translations[code] ?? row.name`. The SDK has no helper
317
- for this on purpose (rule 1: no non-endpoint code outside `BolbolServer`); each
318
- app wraps that one expression in its own picker.
319
-
320
- ### 4. Wire format
321
-
322
- - The API reads the session token from the `b-token` header. `HTTPClient` adds
323
- it to **every** request whenever `getToken` returns one - public endpoints
324
- included - and scopes never set it by hand. There is no per-call auth flag on
325
- purpose: a public endpoint treats a missing or stale token as a guest, while a
326
- valid one personalises the answer (member-only items, the owner's private
327
- files, the roster of an academy you own, errors in the user's language).
328
- Sending the token only to endpoints that require sign-in would silently lose
329
- all of that.
330
- - Bodies are JSON. Query values are strings on the wire; `HTTPClient`
331
- serializes numbers and booleans, repeats a key for an array, and drops
332
- `null` / `undefined`.
333
- - Endpoints that stream bytes instead of an envelope use `this.http.blob(...)`.
334
- - Endpoints that take raw bytes instead of a JSON body (`UserFileContentUpload`)
335
- have no body schema; their method takes a trailing `content: HTTPRawBody` (a
336
- Blob, or a ReadableStream to count the bytes as they go) and sends
337
- it as `{ raw: content }` (`Content-Type: application/octet-stream`, no
338
- per-request timeout). In Node, pass `await fs.openAsBlob(path)` so a large
339
- file streams from disk.
340
-
341
- ## Adding or changing an endpoint
342
-
343
- 1. Add or update the RowTypes / Enums the change needs in `src/type/`, and the
344
- endpoint's RequestType / ResponseType, following rule 3.
345
- 2. Add or update its method on the folder's scope, with the marker comment,
346
- following rule 2. A new controller folder means a new
347
- `Bolbol{Folder}Scope.ts` and one more property on `BolbolServer`.
348
- 3. Bump the version and publish.
349
- 4. In the Bolbol API: bump `bolbol-sdk`, change the controller, and make its
350
- projection return the new type - the compiler shows everything that no
351
- longer matches.
352
- 5. `npm test` here (against the updated API) and `npm run build`.
353
- 6. Bump `bolbol-sdk` in the Console and the CLI and fix their callers.
354
-
355
- ## Checking the SDK against the API
356
-
357
- ```
358
- npm test # expects the API at ../Bolbol API
359
- BOLBOL_API_PATH=<path to Bolbol API> npm test
360
- ```
361
-
362
- `test/api-contract.test.mjs` reads both code bases with the TypeScript compiler
363
- (nothing runs, no server needed) and, for **every** backend controller, checks:
364
-
365
- | # | Check |
366
- | --- | --- |
367
- | 1 | a scope method carries the controller's marker comment |
368
- | 2 | it lives on `Bolbol{TopFolder}Scope` |
369
- | 3 | the marker's path and the URL the method actually builds equal the controller's path |
370
- | 4 | it calls `this.http.<method>` as the controller's `info()` says (`blob` for a streamed GET) |
371
- | 5 | it takes `request: {API name}RequestType` exactly when the controller has a body (or, for a GET, a query) schema, sends it as `{ body: request }` / `{ query: request }`, and the RequestType's field names and optionality equal the Zod schema's |
372
- | 6 | it returns `Promise<BolbolResponseType<{API name}ResponseType>>` and that type exists |
373
- | 7 | its leading arguments are the path parameters - same names, path order, `number` / `string` as the parameter schema says |
374
-
375
- plus: no SDK method without a controller, no duplicates, and every type file
376
- exporting exactly one type named after the file, with one of the five suffixes.
377
-
378
- What it cannot read is the shape of a controller's *output* (`handle()` builds
379
- it in code), so a ResponseType's fields are still a review question - the
380
- marker comment and the one-file-per-type rule make that quick to answer.
381
-
382
- The parser is TypeScript 5's compiler API (dev dependency `typescript5`),
383
- because TypeScript 7, which builds the SDK, has no stable JavaScript API.
384
-
385
- ## Scripts
386
-
387
- | Script | What it does |
388
- | --- | --- |
389
- | `npm run build` | Regenerate `src/index.ts`, then compile to `dist/` |
390
- | `npm run typecheck` | Regenerate `src/index.ts`, then type check without emitting |
391
- | `npm test` | Check every backend controller against the SDK (see above) |
392
- | `npm run index` | Regenerate `src/index.ts` only |
393
- | `npm run clean` | Remove `dist/` |
1
+ # bolbol-sdk
2
+
3
+ The typed client for the Bolbol API. Every app that talks to the backend - the
4
+ Console (Next.js) and the Academy CLI (Node) today - goes through this package
5
+ instead of calling `fetch` itself, so the shape of every request and answer is
6
+ declared exactly once.
7
+
8
+ ```ts
9
+ import { BolbolServer, HTTPError } from "bolbol-sdk";
10
+
11
+ const bolbol = new BolbolServer({
12
+ baseUrl: "https://bolbol.io/api",
13
+ getToken: () => readSessionToken(),
14
+ });
15
+
16
+ const response = await bolbol.auth.user.login({ email, password });
17
+ console.log(response.result.user.name);
18
+ ```
19
+
20
+ - [Layout](#layout)
21
+ - [Using it](#using-it)
22
+ - [The rules](#the-rules)
23
+ - [1. One scope per backend controller folder](#1-one-scope-per-backend-controller-folder)
24
+ - [2. One method per controller](#2-one-method-per-controller)
25
+ - [3. Types: one per file, named by what they are](#3-types-one-per-file-named-by-what-they-are)
26
+ - [Translatable columns: `TranslationType`](#translatable-columns-translationtype)
27
+ - [4. Wire format](#4-wire-format)
28
+ - [Adding or changing an endpoint](#adding-or-changing-an-endpoint)
29
+ - [Checking the SDK against the API](#checking-the-sdk-against-the-api)
30
+ - [Scripts](#scripts)
31
+
32
+ ## Layout
33
+
34
+ ```
35
+ src/
36
+ ├── BolbolServer.ts the entry point: builds the HTTPClient and one instance of every scope
37
+ ├── HTTPClient.ts the only place that calls fetch
38
+ ├── HTTPError.ts what every failed call throws
39
+ ├── index.ts generated - re-exports everything below
40
+ ├── scope/
41
+ │ ├── BolbolAuthScope.ts mirrors Bolbol API src/controller/auth
42
+ │ ├── BolbolAcademyScope.ts mirrors Bolbol API src/controller/academy
43
+ │ └── ... one file per backend controller folder
44
+ └── type/
45
+ ├── UserRowType.ts a table row
46
+ ├── AuthLoginRequestType.ts what one endpoint takes
47
+ ├── AuthLoginResponseType.ts what one endpoint answers
48
+ ├── RequestStatusEnum.ts a database enum
49
+ ├── BolbolResponseType.ts a shared shape
50
+ └── ...
51
+ scripts/
52
+ ├── generate-index.mjs writes src/index.ts
53
+ test/
54
+ └── api-contract.test.mjs checks every backend controller against its SDK method
55
+ ```
56
+
57
+ ## Using it
58
+
59
+ ### Creating the server
60
+
61
+ ```ts
62
+ new BolbolServer({
63
+ baseUrl: "https://bolbol.io/api", // API base, mount path included, no trailing slash
64
+ getToken: () => token, // sync or async; read per request
65
+ timeoutMs: 30000, // optional
66
+ init: { credentials: "include", cache: "no-store" }, // optional extra fetch options
67
+ });
68
+ ```
69
+
70
+ `getToken` is asked for the session token on every call, so a
71
+ browser app can read its cookie and a server can read its request's cookie with
72
+ the same class. Create one `BolbolServer` per token source and reuse it.
73
+
74
+ ### Calling an endpoint
75
+
76
+ Scopes hang off the server, one per backend controller folder, and group their
77
+ methods by noun:
78
+
79
+ ```ts
80
+ await bolbol.teacher.certificate.list(teacherUid);
81
+ await bolbol.teacher.certificate.create(teacherUid, { name, issuer, issue_date });
82
+ await bolbol.academy.asset.get(academyUid, assetUid);
83
+ ```
84
+
85
+ Every method resolves to the API's envelope, `BolbolResponseType<T>`:
86
+
87
+ ```ts
88
+ const response = await bolbol.auth.user.me();
89
+ response.result.user; // the controller's own output
90
+ response.duration; // plus the envelope metadata: id, code, status, started_at, duration
91
+ ```
92
+
93
+ ### Errors
94
+
95
+ Every call that does not get a 2xx answer throws `HTTPError`:
96
+
97
+ | Field | Meaning |
98
+ | --- | --- |
99
+ | `code` | The HTTP status, or `0` (`HTTPError.UNREACHABLE`) when the API could not be reached at all |
100
+ | `message` | The API's own explanation, already localized, or an English fallback |
101
+ | `lang` | The language of `message`, when the API said |
102
+ | `errors` | For a validation 400: one `{ path, message }` per failed rule |
103
+
104
+ ```ts
105
+ try
106
+ {
107
+ await bolbol.auth.user.login(input);
108
+ }
109
+ catch (error)
110
+ {
111
+ if (error instanceof HTTPError && error.code === 401)
112
+ showWrongPassword(error.message);
113
+ else
114
+ throw error;
115
+ }
116
+ ```
117
+
118
+ ## The rules
119
+
120
+ These keep the SDK a faithful, predictable mirror of the backend. Follow them
121
+ for every change; `npm test` enforces them.
122
+
123
+ ### 1. One scope per backend controller folder
124
+
125
+ - Every folder under `Bolbol API/src/controller/<folder>` has exactly one scope
126
+ file, `src/scope/Bolbol{Folder}Scope.ts`, where `{Folder}` is the folder name
127
+ in PascalCase: `auth` → `BolbolAuthScope`, `user_file` → `BolbolUserFileScope`.
128
+ - `BolbolServer` creates **one instance of each scope** and exposes it as a
129
+ public readonly property named after the folder in camelCase:
130
+ `bolbol.auth`, `bolbol.userFile`, `bolbol.academy`.
131
+ - A scope takes the shared `HTTPClient` in its constructor and nothing else. It
132
+ never calls `fetch` and never reads configuration of its own.
133
+ - There are no scopes for anything that is not a controller folder. Helpers
134
+ that are not endpoints (resolving a static asset path) live on `BolbolServer`.
135
+
136
+ ### 2. One method per controller
137
+
138
+ Every controller file in the folder gets exactly one method on its scope - no
139
+ more, no fewer.
140
+
141
+ **Name and grouping.** Methods are grouped into one object per noun, with a
142
+ short verb as the key, so call sites read as `scope.noun.verb(...)`:
143
+
144
+ ```ts
145
+ public readonly certificate = {
146
+ list: (teacher_uid: string) => ...,
147
+ create: (teacher_uid: string, request: TeacherCertificateCreateRequestType) => ...,
148
+ update: (teacher_uid: string, certificate_id: number, request: TeacherCertificateUpdateRequestType) => ...,
149
+ delete: (teacher_uid: string, certificate_id: number) => ...,
150
+ };
151
+ ```
152
+
153
+ Nest a noun inside another when the URL nests it (`academy.asset.version.publish`).
154
+
155
+ **The marker comment.** The line right above every method is exactly:
156
+
157
+ ```ts
158
+ // {METHOD} {path exactly as in the controller's info()} - {API name}
159
+ // POST /auth/cli-login/:request_token/approve - AuthCLILoginApprove
160
+ ```
161
+
162
+ `npm test` reads these markers to find the method for each controller.
163
+
164
+ **The API name** identifies an endpoint everywhere in the SDK. It is every
165
+ folder between `src/controller/` and the file, each in PascalCase, followed by
166
+ the file name without `Controller`. The method lives on the scope of the
167
+ top-level folder:
168
+
169
+ | Controller file | API name |
170
+ | --- | --- |
171
+ | `auth/LoginController.ts` | `AuthLogin` |
172
+ | `academy/AssetCreateController.ts` | `AcademyAssetCreate` |
173
+ | `user_file/UploadController.ts` | `UserFileUpload` |
174
+ | `teacher/UpdatePhotoController.ts` | `TeacherUpdatePhoto` |
175
+ | `bolbol/teacher_exam/CountPendingController.ts` | `BolbolTeacherExamCountPending` (on `BolbolBolbolScope`) |
176
+
177
+ **The signature mirrors the controller:**
178
+
179
+ - **Path parameters** come first, as positional arguments in the order they
180
+ appear in the path, named exactly like the path segment (`teacher_uid`,
181
+ `certificate_id`) and typed like the controller's parameter schema
182
+ (`string` for a uid or token, `number` for a numeric id).
183
+ - **Then one `request` argument** of type `{API name}RequestType`, when the
184
+ controller takes input beyond the path: its body schema for POST / PUT /
185
+ PATCH / DELETE, its query schema for GET. When every field of a **query** is
186
+ optional the argument defaults to `{}`. A body never defaults, even when all
187
+ its fields are optional: some updates clear every field they are not sent, so
188
+ sending one must be deliberate.
189
+ - **The return type** is `Promise<BolbolResponseType<{API name}ResponseType>>`,
190
+ where the ResponseType is exactly what the controller's `handle()` returns.
191
+ - The HTTP method and path are the controller's own. Interpolate path
192
+ parameters with `encodeURIComponent` for strings.
193
+
194
+ ```ts
195
+ // PUT /teacher/:teacher_uid/certificate/:certificate_id - TeacherCertificateUpdate
196
+ update: (teacher_uid: string, certificate_id: number, request: TeacherCertificateUpdateRequestType): Promise<BolbolResponseType<TeacherCertificateUpdateResponseType>> =>
197
+ this.http.put(`/teacher/${encodeURIComponent(teacher_uid)}/certificate/${certificate_id}`, { body: request }),
198
+ ```
199
+
200
+ Convenience that the backend does not have - reading a browser `File` into
201
+ base64 before an upload, turning a path into a URL - may wrap the call, but the
202
+ arguments that reach the wire must still be exactly the controller's.
203
+
204
+ ### 3. Types: one per file, named by what they are
205
+
206
+ Every type the SDK exposes lives in `src/type/`, **one exported type per file,
207
+ file named exactly after the type**. No barrels, no `types.ts`, no type declared
208
+ inside a scope file. The suffix says what kind of type it is, and there are only
209
+ five:
210
+
211
+ | Suffix | What it is | Named after | Example |
212
+ | --- | --- | --- | --- |
213
+ | `RowType` | One row of a database table, as the API sends it (the backend's `Public*` projection when it has one - fields that never leave the backend, like a numeric `id` behind a `uid`, are left out) | The table, in PascalCase | `UserRowType`, `TeacherCertificateRowType`, `ItemVersionRowType` |
214
+ | `RequestType` | The input of one endpoint - its body (or query, for a GET) | The API name | `AuthLoginRequestType`, `AcademyAssetCreateRequestType` |
215
+ | `ResponseType` | The `result` of one endpoint | The API name | `AuthLoginResponseType`, `AcademyAssetListResponseType` |
216
+ | `Enum` | A database or API enum, as a TypeScript `enum`. Every member is PascalCase and its value is exactly its name (`PastDue = "PastDue"`) | The backend enum without its `Database_` prefix, in PascalCase | `RequestStatusEnum` (`Database_Request_Status`), `CLILoginRequestStatusEnum` |
217
+ | `Type` | Anything else shared by several of the above: a JSON column's shape, a generic wrapper | What it describes | `BolbolResponseType<T>`, `ListPageType<T>`, `UserSettingGeneralType` |
218
+
219
+ Rules that make the table work:
220
+
221
+ - **Every endpoint has its own RequestType (if it takes input) and its own
222
+ ResponseType** - even when two endpoints answer the same shape. Then the
223
+ second one is an alias, not a copy:
224
+
225
+ ```ts
226
+ // AcademyAssetGetResponseType.ts
227
+ import { AssetRowType } from "./AssetRowType";
228
+
229
+ export interface AcademyAssetGetResponseType
230
+ {
231
+ item: AssetRowType;
232
+ }
233
+ ```
234
+
235
+ ```ts
236
+ // AcademyAssetUpdateResponseType.ts
237
+ import { AcademyAssetGetResponseType } from "./AcademyAssetGetResponseType";
238
+
239
+ export type AcademyAssetUpdateResponseType = AcademyAssetGetResponseType;
240
+ ```
241
+
242
+ This keeps "what does endpoint X take and answer" a single lookup, and lets
243
+ one endpoint change shape without dragging the others along.
244
+ - **Paged list requests extend `ListPagingType`**; paged list answers use
245
+ `ListPageType<Row>`, unpaged ones `ListType<Row>`.
246
+ - **Field names are the wire names** - snake_case, exactly as the backend's Zod
247
+ schema or projection spells them. Never camelCase a field.
248
+ - **Optional vs nullable follow the backend**: `.optional()` → `field?: T`,
249
+ `.nullable()` → `field: T | null`, `.nullish()` → `field?: T | null`.
250
+ - Dates travel as ISO strings, so they are `string` here, never `Date`.
251
+ - Each type file starts with a comment naming its source in the backend
252
+ (`// Mirrors PublicTeacher in Bolbol API src/service/teacher/TeacherPublic.ts`).
253
+ - **The Bolbol API imports these types too.** Its `toPublic*()` projections
254
+ return the SDK's RowTypes / Types, and the enums whose values go over the wire
255
+ (`UserPolicyEnum`, `RequestStatusEnum`, `ItemTypeEnum`, ...) exist ONLY here -
256
+ the backend's database columns are declared from them. So for those, this
257
+ package is the source of truth, not a mirror: a change here changes the
258
+ backend's database enum and its compile-time contract. Keep every enum's keys
259
+ equal to its values - the backend builds columns with `Object.keys(...)`.
260
+
261
+ ### Translatable columns: `TranslationType`
262
+
263
+ A database column whose text is read in several languages travels with a
264
+ sibling `<column>_translations` field (the backend rule is
265
+ `Bolbol API/rules/Translation.md`). Its shape is one shared type:
266
+
267
+ ```ts
268
+ // TranslationType.ts - keyed by LanguageCodeType, the 32 hard-coded language
269
+ // codes ("en", "fr", "fa", ...); a language with no translation is an absent key.
270
+ export type TranslationType = { [language_code in LanguageCodeType]?: string };
271
+ ```
272
+
273
+ - **RowType:** the field sits directly below its source field and is always
274
+ present - the backend stores `{}` for "no translations", never `null`, even
275
+ when the source field is nullable:
276
+
277
+ ```ts
278
+ name: string;
279
+ name_translations: TranslationType;
280
+ content: string | null;
281
+ content_translations: TranslationType;
282
+ ```
283
+
284
+ The map only ever holds languages that have a value - never all languages
285
+ with blanks.
286
+
287
+ - **RequestType:** every Create / Update that takes the source field takes its
288
+ `<column>_translations` too, as a **patch of only the changed languages**, not
289
+ the whole map. Its type is the second shared type:
290
+
291
+ ```ts
292
+ // TranslationPatchType.ts
293
+ // Mirrors translationSchema() in Bolbol API src/controller/TranslationSchema.ts.
294
+ // Only the languages that changed: a string sets that language, null removes
295
+ // it, a language not listed is left as stored.
296
+ export type TranslationPatchType = { [language_code in LanguageCodeType]?: string | null };
297
+ ```
298
+
299
+ Optionality follows the Zod schema (`translationSchema(...).optional()` →
300
+ `name_translations?: TranslationPatchType`). Leave the field out when no
301
+ language changed.
302
+ - **Flattened detail columns (`academy.item_version.details_*`)** each get their
303
+ own sibling `_translations` column, same convention as every other
304
+ translation column. Plain translations use `TranslationType` /
305
+ `TranslationPatchType`; choice-keyed translations (e.g.
306
+ `details_multiplechoice_choices_translations`) use
307
+ `ChoiceTranslationType<TranslationType>` on the RowType and
308
+ `ChoiceTranslationType<TranslationPatchType | null>` on the RequestType
309
+ (`null` on a choice removes all its translations). Shape and rules:
310
+ `Bolbol API/rules/Translation.md`, "Translating `academy.item_version` detail
311
+ columns".
312
+ - Use `TranslationType` / `TranslationPatchType` by name. Never inline
313
+ `Record<string, string>` or `{ [k: string]: string }` in a RowType or
314
+ RequestType - one name is what lets every app find (and type-check) every
315
+ translatable field.
316
+ - Reading one: `row.name_translations[code] ?? row.name`. The SDK has no helper
317
+ for this on purpose (rule 1: no non-endpoint code outside `BolbolServer`); each
318
+ app wraps that one expression in its own picker.
319
+
320
+ ### 4. Wire format
321
+
322
+ - The API reads the session token from the `b-token` header. `HTTPClient` adds
323
+ it to **every** request whenever `getToken` returns one - public endpoints
324
+ included - and scopes never set it by hand. There is no per-call auth flag on
325
+ purpose: a public endpoint treats a missing or stale token as a guest, while a
326
+ valid one personalises the answer (member-only items, the owner's private
327
+ files, the roster of an academy you own, errors in the user's language).
328
+ Sending the token only to endpoints that require sign-in would silently lose
329
+ all of that.
330
+ - Bodies are JSON. Query values are strings on the wire; `HTTPClient`
331
+ serializes numbers and booleans, repeats a key for an array, and drops
332
+ `null` / `undefined`.
333
+ - Endpoints that stream bytes instead of an envelope use `this.http.blob(...)`.
334
+ - Endpoints that take raw bytes instead of a JSON body (`UserFileContentUpload`)
335
+ have no body schema; their method takes a trailing `content: HTTPRawBody` (a
336
+ Blob, or a ReadableStream to count the bytes as they go) and sends
337
+ it as `{ raw: content }` (`Content-Type: application/octet-stream`, no
338
+ per-request timeout). In Node, pass `await fs.openAsBlob(path)` so a large
339
+ file streams from disk.
340
+
341
+ ## Adding or changing an endpoint
342
+
343
+ 1. Add or update the RowTypes / Enums the change needs in `src/type/`, and the
344
+ endpoint's RequestType / ResponseType, following rule 3.
345
+ 2. Add or update its method on the folder's scope, with the marker comment,
346
+ following rule 2. A new controller folder means a new
347
+ `Bolbol{Folder}Scope.ts` and one more property on `BolbolServer`.
348
+ 3. Bump the version and publish.
349
+ 4. In the Bolbol API: bump `bolbol-sdk`, change the controller, and make its
350
+ projection return the new type - the compiler shows everything that no
351
+ longer matches.
352
+ 5. `npm test` here (against the updated API) and `npm run build`.
353
+ 6. Bump `bolbol-sdk` in the Console and the CLI and fix their callers.
354
+
355
+ ## Checking the SDK against the API
356
+
357
+ ```
358
+ npm test # expects the API at ../Bolbol API
359
+ BOLBOL_API_PATH=<path to Bolbol API> npm test
360
+ ```
361
+
362
+ `test/api-contract.test.mjs` reads both code bases with the TypeScript compiler
363
+ (nothing runs, no server needed) and, for **every** backend controller, checks:
364
+
365
+ | # | Check |
366
+ | --- | --- |
367
+ | 1 | a scope method carries the controller's marker comment |
368
+ | 2 | it lives on `Bolbol{TopFolder}Scope` |
369
+ | 3 | the marker's path and the URL the method actually builds equal the controller's path |
370
+ | 4 | it calls `this.http.<method>` as the controller's `info()` says (`blob` for a streamed GET) |
371
+ | 5 | it takes `request: {API name}RequestType` exactly when the controller has a body (or, for a GET, a query) schema, sends it as `{ body: request }` / `{ query: request }`, and the RequestType's field names and optionality equal the Zod schema's |
372
+ | 6 | it returns `Promise<BolbolResponseType<{API name}ResponseType>>` and that type exists |
373
+ | 7 | its leading arguments are the path parameters - same names, path order, `number` / `string` as the parameter schema says |
374
+
375
+ plus: no SDK method without a controller, no duplicates, and every type file
376
+ exporting exactly one type named after the file, with one of the five suffixes.
377
+
378
+ What it cannot read is the shape of a controller's *output* (`handle()` builds
379
+ it in code), so a ResponseType's fields are still a review question - the
380
+ marker comment and the one-file-per-type rule make that quick to answer.
381
+
382
+ The parser is TypeScript 5's compiler API (dev dependency `typescript5`),
383
+ because TypeScript 7, which builds the SDK, has no stable JavaScript API.
384
+
385
+ ## Scripts
386
+
387
+ | Script | What it does |
388
+ | --- | --- |
389
+ | `npm run build` | Regenerate `src/index.ts`, then compile to `dist/` |
390
+ | `npm run typecheck` | Regenerate `src/index.ts`, then type check without emitting |
391
+ | `npm test` | Check every backend controller against the SDK (see above) |
392
+ | `npm run index` | Regenerate `src/index.ts` only |
393
+ | `npm run clean` | Remove `dist/` |
package/package.json CHANGED
@@ -1,29 +1,29 @@
1
- {
2
- "name": "bolbol-sdk",
3
- "version": "1.0.1",
4
- "description": "Typed client for the Bolbol API: one scope per backend controller folder, one type per file.",
5
- "license": "ISC",
6
- "author": "",
7
- "type": "commonjs",
8
- "main": "dist/index.js",
9
- "types": "dist/index.d.ts",
10
- "files": [
11
- "dist",
12
- "README.md"
13
- ],
14
- "engines": {
15
- "node": ">=20"
16
- },
17
- "scripts": {
18
- "index": "node scripts/generate-index.mjs",
19
- "build": "npm run index && tsc",
20
- "typecheck": "npm run index && tsc --noEmit",
21
- "test": "node --test \"test/**/*.test.mjs\"",
22
- "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
23
- "prepublishOnly": "npm run clean && npm run build"
24
- },
25
- "devDependencies": {
26
- "typescript": "^7.0.2",
27
- "typescript5": "npm:typescript@^7.0.2"
28
- }
1
+ {
2
+ "name": "bolbol-sdk",
3
+ "version": "1.0.2",
4
+ "description": "Typed client for the Bolbol API: one scope per backend controller folder, one type per file.",
5
+ "license": "ISC",
6
+ "author": "Ilia Abolhasani, Amir Abolhasani",
7
+ "type": "commonjs",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "index": "node scripts/generate-index.mjs",
19
+ "build": "npm run index && tsc",
20
+ "typecheck": "npm run index && tsc --noEmit",
21
+ "test": "node --test \"test/**/*.test.mjs\"",
22
+ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
23
+ "prepublishOnly": "npm run clean && npm run build"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^7.0.2",
27
+ "typescript5": "npm:typescript@^7.0.2"
28
+ }
29
29
  }