@superbuilders/primer-tives 0.4.0 → 0.6.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/README.md +605 -153
- package/dist/choice-state.d.ts.map +1 -1
- package/dist/client.d.ts +4 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/errors.d.ts +4 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/extended-text-state.d.ts.map +1 -1
- package/dist/feedback-state.d.ts +2 -2
- package/dist/feedback-state.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +327 -168
- package/dist/index.js.map +15 -14
- package/dist/match-state.d.ts.map +1 -1
- package/dist/order-state.d.ts +3 -10
- package/dist/order-state.d.ts.map +1 -1
- package/dist/pci-state.d.ts.map +1 -1
- package/dist/session.d.ts +2 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/subject.d.ts +6 -0
- package/dist/subject.d.ts.map +1 -0
- package/dist/text-entry-state.d.ts.map +1 -1
- package/dist/transport.d.ts +5 -4
- package/dist/transport.d.ts.map +1 -1
- package/dist/types.d.ts +61 -28
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
# @superbuilders/primer-tives
|
|
2
2
|
|
|
3
|
-
Client SDK for the Primer adaptive learning engine.
|
|
3
|
+
Client SDK for the Primer adaptive learning engine.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **Breaking change**: Auth is a server-minted Primer JWT access token. `create()` takes `accessToken` (not `publishableKey`), and `client.start()` is parameterless — the student identity lives in the token's `sub` claim. Mint tokens on your backend via `POST /api/v0/auth/exchange` with your `sk_` and either a Primer-minted `student_id` (provider `native`) or a Timeback OneRoster `sourcedId` (provider `timeback`). The exchange body now uses `student_id` in place of the old `sourced_id`.
|
|
6
|
+
|
|
7
|
+
This package turns the Primer HTTP protocol into a strongly typed, server-driven state machine. Your app renders the current frame, calls the method that is valid for that frame, and the server decides what comes next.
|
|
8
|
+
|
|
9
|
+
The SDK does **not** know about routines, curricula, storage schemas, or routing rules. It only knows how to:
|
|
10
|
+
|
|
11
|
+
- start a student session
|
|
12
|
+
- submit answers or timeouts
|
|
13
|
+
- normalize transport failures into sentinel errors plus `retriable` semantics
|
|
14
|
+
- expose the current step as an ergonomic TypeScript union
|
|
15
|
+
- expose interaction-native review data for feedback rendering
|
|
16
|
+
- preserve full type safety for portable custom interactions (PCIs)
|
|
6
17
|
|
|
7
18
|
## Install
|
|
8
19
|
|
|
@@ -12,19 +23,41 @@ bun add @superbuilders/primer-tives
|
|
|
12
23
|
|
|
13
24
|
Dependency: `@superbuilders/errors` is installed automatically.
|
|
14
25
|
|
|
26
|
+
## What you get
|
|
27
|
+
|
|
28
|
+
- **Single entry point**: `create(config)`
|
|
29
|
+
- **Single wire endpoint**: `POST ${origin}/api/v0/advance`
|
|
30
|
+
- **Single runtime model**: `PrimerState`
|
|
31
|
+
- **Six phases/kinds to handle**:
|
|
32
|
+
- phases: `observation`, `interaction`, `feedback`, `completed`, `errored`, `fatal`
|
|
33
|
+
- interaction kinds: `choice`, `text-entry`, `extended-text`, `order`, `match`, `portable-custom`
|
|
34
|
+
- **Typed PCI submissions** driven by the `PciRegistry`
|
|
35
|
+
- **Live in-memory state objects** with action methods like `advance()`, `submitChoice()`, `submitOrder()`, `submitMatch()`, `submit()`, `timeout()`, and `retry()`
|
|
36
|
+
|
|
15
37
|
## Quick start
|
|
16
38
|
|
|
17
39
|
```ts
|
|
18
|
-
import { create, ErrRateLimited } from "@superbuilders/primer-tives"
|
|
19
40
|
import * as errors from "@superbuilders/errors"
|
|
20
|
-
|
|
41
|
+
import {
|
|
42
|
+
create,
|
|
43
|
+
ErrRateLimited,
|
|
44
|
+
type PrimerState,
|
|
45
|
+
} from "@superbuilders/primer-tives"
|
|
46
|
+
|
|
47
|
+
// Mint accessToken on your backend via POST /api/v0/auth/exchange
|
|
48
|
+
// (sk_ + { provider, student_id }) then pass it here. Tokens are short-lived
|
|
49
|
+
// (15 min default).
|
|
21
50
|
const client = create({
|
|
22
|
-
|
|
51
|
+
accessToken: "<primer JWS minted by /auth/exchange>",
|
|
23
52
|
origin: "https://sb-primer.vercel.app",
|
|
24
|
-
supportedPcis: [
|
|
53
|
+
supportedPcis: [
|
|
54
|
+
"urn:primer:pci:division-remainder",
|
|
55
|
+
"urn:primer:pci:fraction-addition",
|
|
56
|
+
],
|
|
57
|
+
subject: "math",
|
|
25
58
|
})
|
|
26
59
|
|
|
27
|
-
let state = await client.start(
|
|
60
|
+
let state: PrimerState = await client.start()
|
|
28
61
|
|
|
29
62
|
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
30
63
|
switch (state.phase) {
|
|
@@ -34,32 +67,64 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
34
67
|
break
|
|
35
68
|
|
|
36
69
|
case "interaction":
|
|
70
|
+
renderStimulus(state.stimulus)
|
|
71
|
+
|
|
37
72
|
switch (state.kind) {
|
|
38
73
|
case "choice":
|
|
39
74
|
state = await state.submitChoice(["option-a"])
|
|
40
75
|
break
|
|
76
|
+
|
|
41
77
|
case "text-entry":
|
|
42
78
|
state = await state.submitText("42")
|
|
43
79
|
break
|
|
80
|
+
|
|
44
81
|
case "extended-text":
|
|
45
82
|
if (state.cardinality === "single") {
|
|
46
83
|
state = await state.submitText("answer")
|
|
47
84
|
} else {
|
|
48
|
-
state = await state.submitTexts(["
|
|
85
|
+
state = await state.submitTexts(["first", "second"])
|
|
49
86
|
}
|
|
50
87
|
break
|
|
88
|
+
|
|
89
|
+
case "order":
|
|
90
|
+
state = await state.submitOrder(["choice-1", "choice-2", "choice-3"])
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
case "match":
|
|
94
|
+
state = await state.submitMatch([
|
|
95
|
+
{ source: "left-1", target: "right-2" },
|
|
96
|
+
{ source: "left-2", target: "right-1" },
|
|
97
|
+
])
|
|
98
|
+
break
|
|
99
|
+
|
|
51
100
|
case "portable-custom":
|
|
52
|
-
|
|
101
|
+
switch (state.pciId) {
|
|
102
|
+
case "urn:primer:pci:division-remainder":
|
|
103
|
+
state = await state.submit({ quotient: "3", remainder: "1" })
|
|
104
|
+
break
|
|
105
|
+
case "urn:primer:pci:fraction-addition":
|
|
106
|
+
state = await state.submit({ numerator: "5", denominator: "6" })
|
|
107
|
+
break
|
|
108
|
+
}
|
|
53
109
|
break
|
|
54
110
|
}
|
|
55
111
|
break
|
|
56
112
|
|
|
57
113
|
case "feedback":
|
|
58
|
-
renderFeedback(
|
|
114
|
+
renderFeedback({
|
|
115
|
+
correct: state.isCorrect,
|
|
116
|
+
content: state.feedbackContent,
|
|
117
|
+
review: state.review,
|
|
118
|
+
interaction: state.interaction,
|
|
119
|
+
submission: state.submission,
|
|
120
|
+
})
|
|
59
121
|
state = await state.advance()
|
|
60
122
|
break
|
|
61
123
|
|
|
62
124
|
case "errored":
|
|
125
|
+
if (!state.retriable) {
|
|
126
|
+
throw state.error
|
|
127
|
+
}
|
|
63
128
|
if (errors.is(state.error, ErrRateLimited)) {
|
|
64
129
|
await delay(1000)
|
|
65
130
|
}
|
|
@@ -69,229 +134,499 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
69
134
|
}
|
|
70
135
|
```
|
|
71
136
|
|
|
72
|
-
Both `state.phase` and `state.kind` are discriminated unions
|
|
137
|
+
Both `state.phase` and `state.kind` are discriminated unions, so TypeScript narrows automatically inside each branch.
|
|
73
138
|
|
|
74
|
-
##
|
|
139
|
+
## Mental model
|
|
75
140
|
|
|
76
|
-
|
|
141
|
+
Primer is **server-authored**.
|
|
77
142
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
```
|
|
143
|
+
- The client sends an **intent**: advance, submit, or timeout.
|
|
144
|
+
- The server evaluates that intent and returns the next frame.
|
|
145
|
+
- The SDK wraps that frame in a typed state object.
|
|
146
|
+
- Your UI renders the state and calls the next valid method.
|
|
83
147
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
The server identifies the student, determines which content to serve based on the frontend's assigned courses, evaluates submissions, and returns the next frame. The client sends:
|
|
87
|
-
|
|
88
|
-
- `studentId` — who is being advanced
|
|
89
|
-
- `supportedPcis` — which custom interaction types the renderer can handle
|
|
90
|
-
- `intent` — `{ kind: "observation" }`, `{ kind: "interaction", submission: ... }`, or `{ kind: "timeout" }`
|
|
91
|
-
|
|
92
|
-
Course resolution is entirely server-side. The publishable key identifies the frontend, and the server looks up which courses are assigned to that frontend. The client has no knowledge of courses, subjects, or form factors.
|
|
148
|
+
In other words, your code does not calculate progression locally. The server owns progression; the SDK owns transport, typing, and ergonomics.
|
|
93
149
|
|
|
94
150
|
## Configuration
|
|
95
151
|
|
|
96
152
|
```ts
|
|
97
153
|
interface Config<Pcis extends PciId = PciId> {
|
|
98
|
-
readonly
|
|
154
|
+
readonly accessToken: string
|
|
99
155
|
readonly supportedPcis: readonly Pcis[]
|
|
100
156
|
readonly origin: string
|
|
157
|
+
readonly subject: Subject | "all"
|
|
101
158
|
readonly fetch?: typeof globalThis.fetch
|
|
102
159
|
readonly abort?: AbortController
|
|
103
160
|
readonly logger?: PrimerLogger
|
|
104
161
|
}
|
|
162
|
+
|
|
163
|
+
type Subject = "math" | "vocabulary"
|
|
105
164
|
```
|
|
106
165
|
|
|
107
166
|
| Field | Required | Description |
|
|
108
167
|
|---|---|---|
|
|
109
|
-
| `
|
|
110
|
-
| `supportedPcis` | yes | PCI URNs the renderer
|
|
111
|
-
| `origin` | yes |
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
168
|
+
| `accessToken` | yes | Primer JWT minted by your backend via `POST /api/v0/auth/exchange`. Sent as `Authorization: Bearer <jwt>`. Short-lived (15 min default). |
|
|
169
|
+
| `supportedPcis` | yes | PCI URNs the renderer can handle. Use `[]` if none |
|
|
170
|
+
| `origin` | yes | Full Primer API base URL, for example `https://sb-primer.vercel.app` |
|
|
171
|
+
| `subject` | yes | Subject scope for this session. `"math"` or `"vocabulary"` restricts drill selection to courses of that subject; `"all"` disables the filter |
|
|
172
|
+
| `fetch` | no | Custom fetch implementation. Defaults to `globalThis.fetch` |
|
|
173
|
+
| `abort` | no | `AbortController` whose `signal` is passed to every request |
|
|
174
|
+
| `logger` | no | Structured logger with `debug`, `info`, `warn`, and `error` methods |
|
|
175
|
+
|
|
176
|
+
`create()` does a cheap structural check on the token (must start with `eyJ` and contain exactly two dots) and throws `ErrMalformedAccessToken` if the shape is wrong. Signature verification happens on the server.
|
|
177
|
+
|
|
178
|
+
### What `subject` controls
|
|
115
179
|
|
|
116
|
-
`
|
|
180
|
+
`subject` is sent on every `/advance` request and narrows the set of drill courses the server can serve:
|
|
181
|
+
|
|
182
|
+
- `"math"` / `"vocabulary"` — only drills whose `drill_courses.subject` matches are eligible. The bootstrap router, trigger-driven re-routes, and active-frame lookups all respect the scope.
|
|
183
|
+
- `"all"` — no filter; the server treats the session as federated and may serve drills from any subject the frontend is bound to.
|
|
184
|
+
|
|
185
|
+
The scope is **per-session**. To switch subjects, construct a new client with a different `subject`; you cannot mutate it on an existing client. Student placements are isolated per subject: a student with an in-progress math placement resumes it when a math-scoped client reconnects, and a vocabulary-scoped client will bootstrap a new placement in that subject without disturbing the math one. If a student submits or times out under a scope that does not match their current placement, the server reports no active frame and the client's next observation bootstraps a fresh placement in the declared subject.
|
|
186
|
+
|
|
187
|
+
Curriculum routing is not affected by `subject` in this release — only drill selection is scoped.
|
|
188
|
+
|
|
189
|
+
### Credentials
|
|
190
|
+
|
|
191
|
+
Each integration has one credential pair:
|
|
192
|
+
|
|
193
|
+
- **`client_id`** — your Primer frontend ID (a UUIDv7). Not currently required in request bodies; it's embedded in the `sk_` record server-side and surfaced as the `tenant_id` claim on minted JWTs.
|
|
194
|
+
- **`client_secret`** — your `sk_` secret key. Sent as `Authorization: Bearer sk_…` on both `/api/v0/students` and `/api/v0/auth/exchange`. Stored only as a SHA-256 hash at rest — the raw value is returned exactly once at generation time.
|
|
195
|
+
|
|
196
|
+
Never expose `sk_` keys to the browser. Use them only from your backend.
|
|
197
|
+
|
|
198
|
+
### Minting a student (native provider)
|
|
199
|
+
|
|
200
|
+
If your integration does not front a Timeback OneRoster tenant, mint a Primer-native student for each of your users and persist the returned `student_id`. Your users exist in your system; Primer just needs a stable UUID to attach session state to.
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
curl -X POST "$ORIGIN/api/v0/students" \
|
|
204
|
+
-H "Authorization: Bearer $PRIMER_SECRET_KEY" \
|
|
205
|
+
-H "Content-Type: application/json" \
|
|
206
|
+
-d '{"grade_level":"9"}'
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Response:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{ "student_id": "019d3e6f-5f6d-7000-8000-..." }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- `grade_level` is required. Valid values: `"K"`, `"1"`, `"2"`, …, `"12"`. Primer routes lessons by grade, so it must be set at mint time.
|
|
216
|
+
- The returned `student_id` is owned by the frontend the `sk_` belongs to. Another frontend's `sk_` cannot exchange for it.
|
|
217
|
+
- Store the `student_id` in your own database keyed by your user. It does not change.
|
|
218
|
+
|
|
219
|
+
### Minting an access token
|
|
220
|
+
|
|
221
|
+
`POST /api/v0/auth/exchange` takes your `sk_` bearer and a body describing which student you want a token for.
|
|
222
|
+
|
|
223
|
+
**Native** (you minted the student via `POST /api/v0/students` above):
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
curl -X POST "$ORIGIN/api/v0/auth/exchange" \
|
|
227
|
+
-H "Authorization: Bearer $PRIMER_SECRET_KEY" \
|
|
228
|
+
-H "Content-Type: application/json" \
|
|
229
|
+
-d '{"provider":"native","student_id":"<primer-uuid-you-stored>"}'
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Timeback** (`student_id` carries a OneRoster `sourcedId`):
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
curl -X POST "$ORIGIN/api/v0/auth/exchange" \
|
|
236
|
+
-H "Authorization: Bearer $PRIMER_SECRET_KEY" \
|
|
237
|
+
-H "Content-Type: application/json" \
|
|
238
|
+
-d '{"provider":"timeback","student_id":"<oneroster-sourced-id>"}'
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The request body is `.strict()` — both `provider` and `student_id` are required and the field names are snake_case. A successful response is:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
{
|
|
245
|
+
access_token: string, // HS256 JWS, use as Bearer for /api/v0/advance
|
|
246
|
+
token_type: "Bearer",
|
|
247
|
+
expires_in: 900, // seconds (15 min default)
|
|
248
|
+
scope: "frame:advance",
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Server-side flow:
|
|
253
|
+
|
|
254
|
+
1. **Authenticate the `sk_`.** SHA-256 the bearer, look up `iam_frontend_secret_keys.key_hash` with `status = 'active'`. The matching `frontend_id` + `key_id` are captured for the token claims.
|
|
255
|
+
2. **Resolve the student.**
|
|
256
|
+
- `provider: "native"` — join `iam_frontend_students` on `(frontend_id, student_id)`. If no row exists, return `404 student_not_found`. This stops one frontend from minting tokens against another frontend's UUIDs.
|
|
257
|
+
- `provider: "timeback"` — look up `iam_student_timeback_identities` by `sourced_id`. If a row exists, reuse the linked `iam_students.id`. Otherwise call Timeback OneRoster, map the grade, and insert `iam_students` + `iam_student_timeback_identities` rows in a single transaction. If OneRoster returns 404, return `404 student_not_found`.
|
|
258
|
+
3. **Mint the JWS.** HS256 with claims `{ sub: <iam_students.id>, tenant_id: <frontend_id>, client_id: <key_id>, scope: "frame:advance", iss: "primer", aud: "primer-api", iat, exp, jti }`. Signed but not encrypted — tamper-proof, not opaque.
|
|
259
|
+
|
|
260
|
+
Error responses:
|
|
261
|
+
|
|
262
|
+
| Status | Body | Cause |
|
|
263
|
+
| ------ | ---------------------------- | --------------------------------------------------------------------- |
|
|
264
|
+
| 401 | `invalid_request` | Missing/malformed `sk_` bearer |
|
|
265
|
+
| 400 | `invalid_request` | Body failed schema validation |
|
|
266
|
+
| 404 | `student_not_found` | Native: no junction row. Timeback: OneRoster returned 404. |
|
|
267
|
+
| 400 | `unsupported_grade` | Timeback: OneRoster returned a grade we don't route. |
|
|
268
|
+
| 502 | `timeback_unavailable` | Timeback OneRoster / Cognito failure. |
|
|
269
|
+
|
|
270
|
+
### Local dev (seeded fixtures)
|
|
271
|
+
|
|
272
|
+
The Primer repo ships a seed script that populates a self-contained dev tenant so SDK integrators can develop against a real `/api/v0/auth/exchange` without any Timeback/OneRoster credentials. The seed is idempotent: every run tears down and re-creates the `Primer Dev` frontend, re-hashes the current `env.PRIMER_SECRET_KEY`, deletes any previous dev students owned by this frontend, and mints a fresh one with a random UUIDv7.
|
|
273
|
+
|
|
274
|
+
Run the seed:
|
|
275
|
+
|
|
276
|
+
```sh
|
|
277
|
+
bun db:seed
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
This invokes `src/db/scripts/seed/index.ts`, which chains:
|
|
281
|
+
|
|
282
|
+
- `seedFrontend()` — inserts `iam_frontends { name: "Primer Dev" }` and `iam_frontend_secret_keys { key_hash: sha256(env.PRIMER_SECRET_KEY), key_preview, name: "dev-seed", status: "active" }`.
|
|
283
|
+
- `seedTestCourse(frontendId)` — re-inserts `iam_students { id: HARDCODED_USER.studentId }` (stable dev UUID `019d0000-0000-7000-8000-000000000001`) and an `iam_frontend_students { frontend_id, student_id }` native-ownership row, then binds the frontend to the dev curriculum course.
|
|
284
|
+
|
|
285
|
+
The dev environment is native-first; the seed does **not** create a `iam_student_timeback_identities` row. If you want to exercise the timeback exchange path against the sandbox, hit it with a real OneRoster `sourcedId` (the first call will provision the junction).
|
|
286
|
+
|
|
287
|
+
Three fixture values a client needs:
|
|
288
|
+
|
|
289
|
+
| Fixture | Value | Source |
|
|
290
|
+
| ------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------- |
|
|
291
|
+
| Frontend name | `Primer Dev` | `DEV_FRONTEND_NAME` in `src/db/scripts/seed/constants.ts` |
|
|
292
|
+
| Secret key (`sk_`) | whatever `env.PRIMER_SECRET_KEY` is at seed time | hashed at rest; the raw value is unrecoverable after seeding, so keep it in `.env` |
|
|
293
|
+
| Dev student id | `019d0000-0000-7000-8000-000000000001` | `HARDCODED_USER.studentId` in `src/lib/auth/hardcoded-user.ts` |
|
|
294
|
+
|
|
295
|
+
Mint a native access token off the seeded student:
|
|
296
|
+
|
|
297
|
+
```sh
|
|
298
|
+
curl -X POST "$ORIGIN/api/v0/auth/exchange" \
|
|
299
|
+
-H "Authorization: Bearer $PRIMER_SECRET_KEY" \
|
|
300
|
+
-H "Content-Type: application/json" \
|
|
301
|
+
-d '{"provider":"native","student_id":"019d0000-0000-7000-8000-000000000001"}'
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Expected response:
|
|
305
|
+
|
|
306
|
+
```json
|
|
307
|
+
{
|
|
308
|
+
"access_token": "eyJ...",
|
|
309
|
+
"token_type": "Bearer",
|
|
310
|
+
"expires_in": 900,
|
|
311
|
+
"scope": "frame:advance"
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Hand `access_token` to `create({ accessToken, origin, supportedPcis })` and you're up. The repo's root `/` page does this automatically — opening it in the browser after `bun db:seed` renders the SDK against the seeded student.
|
|
316
|
+
|
|
317
|
+
**Integration contract.** The seed script is the contract. As long as it runs cleanly, nothing on the client side has to change when the Primer auth model evolves — drop the DB, re-run `bun db:seed`, and the same `sk_` keeps working. If a future schema change breaks this guarantee, it breaks the seed first; fix the seed and the contract holds.
|
|
318
|
+
|
|
319
|
+
### Logger interface
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
interface PrimerLogger {
|
|
323
|
+
debug(message: string, attributes?: Record<string, unknown>): void
|
|
324
|
+
info(message: string, attributes?: Record<string, unknown>): void
|
|
325
|
+
warn(message: string, attributes?: Record<string, unknown>): void
|
|
326
|
+
error(message: string, attributes?: Record<string, unknown>): void
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Wire protocol
|
|
331
|
+
|
|
332
|
+
Every request is a `POST` to:
|
|
333
|
+
|
|
334
|
+
```txt
|
|
335
|
+
${origin}/api/v0/advance
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The path constant is exported as:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
const ADVANCE_PATH = "/api/v0/advance"
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Headers
|
|
345
|
+
|
|
346
|
+
```txt
|
|
347
|
+
Authorization: Bearer <primer jwt>
|
|
348
|
+
Content-Type: application/json
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Request body
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
interface WireRequestBody<Pcis extends PciId = PciId> {
|
|
355
|
+
supportedPcis: readonly PciId[]
|
|
356
|
+
intent: WireIntent<Pcis>
|
|
357
|
+
subject: Subject | "all"
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The student identity is **not** in the body. The server reads it from the verified JWT's `sub` claim.
|
|
362
|
+
|
|
363
|
+
The `subject` field is required on every request and validated server-side. A missing or invalid value is rejected with `400 invalid_request`.
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
type WireIntent<Pcis extends PciId = PciId> =
|
|
367
|
+
| { kind: "observation" }
|
|
368
|
+
| { kind: "interaction"; submission: RendererSubmission<Pcis> }
|
|
369
|
+
| { kind: "timeout" }
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Response outcomes
|
|
373
|
+
|
|
374
|
+
Conceptually, the server returns one of three outcomes:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
type WireResult<Pcis extends PciId = PciId> =
|
|
378
|
+
| {
|
|
379
|
+
outcome: "advanced"
|
|
380
|
+
stimulus: RendererStimulus | null
|
|
381
|
+
interaction: RendererInteraction<Pcis> | null
|
|
382
|
+
}
|
|
383
|
+
| {
|
|
384
|
+
outcome: "submitted"
|
|
385
|
+
stimulus: RendererStimulus | null
|
|
386
|
+
interaction: RendererInteraction<Pcis>
|
|
387
|
+
submission: RendererSubmission<Pcis>
|
|
388
|
+
isCorrect: boolean
|
|
389
|
+
feedbackContent: ContentInline[]
|
|
390
|
+
review: InteractionReview<Pcis> | null
|
|
391
|
+
}
|
|
392
|
+
| { outcome: "completed" }
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Important notes
|
|
396
|
+
|
|
397
|
+
- `origin` must be the **full base URL**. The SDK constructs requests as `${origin}${ADVANCE_PATH}`.
|
|
398
|
+
- `supportedPcis` is sent on every request so the server knows which portable custom interactions the client can render.
|
|
399
|
+
- The client also uses `supportedPcis` as an inbound safety check: if the server returns a PCI frame the client did not advertise support for, the SDK returns a fatal `ErrUnsupportedPci` state.
|
|
117
400
|
|
|
118
401
|
## State machine
|
|
119
402
|
|
|
120
403
|
`PrimerState` is a discriminated union on `phase`:
|
|
121
404
|
|
|
122
|
-
```
|
|
123
|
-
observation
|
|
405
|
+
```txt
|
|
406
|
+
observation -> interaction -> feedback -> observation -> ... -> completed
|
|
124
407
|
| \ | \ | \
|
|
125
|
-
|
|
408
|
+
v \ v \ v \
|
|
126
409
|
errored fatal errored fatal errored fatal
|
|
127
410
|
|
|
128
|
-
errored
|
|
129
|
-
interaction timeout
|
|
411
|
+
errored -> retry() -> replays exact failed action
|
|
412
|
+
interaction timeout -> same transport path, intent kind "timeout"
|
|
130
413
|
```
|
|
131
414
|
|
|
132
|
-
Any action (`advance()`, `submitChoice()`, `timeout()`, etc.) can fail. Transient failures (network, timeout, 5xx) become `errored` with `retry()`. Permanent failures (401, 403, 404) become `fatal` — session is dead, no recovery.
|
|
133
|
-
|
|
134
|
-
Calling an action twice (e.g., double-clicking `advance()`) returns the same promise — the SDK memoizes in-flight requests internally. No special handling needed.
|
|
135
|
-
|
|
136
415
|
### `observation`
|
|
137
416
|
|
|
138
|
-
Display-only frame.
|
|
417
|
+
Display-only frame. Render `state.stimulus`, then call `advance()`.
|
|
139
418
|
|
|
140
419
|
```ts
|
|
420
|
+
state.phase === "observation"
|
|
141
421
|
state.stimulus // RendererStimulus | null
|
|
142
422
|
state.advance() // Promise<PrimerState>
|
|
143
423
|
```
|
|
144
424
|
|
|
145
|
-
Stimulus is a discriminated union on `type`:
|
|
146
|
-
- `BodyStimulus` — `{ type: "body", body: ContentBlock[] }` — text-only
|
|
147
|
-
- `ImageStimulus` — `{ type: "image", description: ContentInline[], src: string }` — image-backed
|
|
148
|
-
- `null` — no stimulus
|
|
149
|
-
|
|
150
425
|
### `interaction`
|
|
151
426
|
|
|
152
|
-
Student must respond. Discriminate on `state.kind
|
|
427
|
+
Student must respond. Discriminate on `state.kind`.
|
|
153
428
|
|
|
154
|
-
|
|
155
|
-
|---|---|---|
|
|
156
|
-
| `"choice"` | `submitChoice(keys: string[])` | `options`, `maxChoices`, `minChoices` |
|
|
157
|
-
| `"text-entry"` | `submitText(value: string)` | — |
|
|
158
|
-
| `"extended-text"` (single) | `submitText(value: string)` | `cardinality: "single"` |
|
|
159
|
-
| `"extended-text"` (multiple) | `submitTexts(values: string[])` | `cardinality: "multiple"`, `maxStrings`, `minStrings` |
|
|
160
|
-
| `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
|
|
429
|
+
All interaction states also expose:
|
|
161
430
|
|
|
162
|
-
|
|
431
|
+
- `state.stimulus`
|
|
432
|
+
- `state.interaction`
|
|
433
|
+
- `timeout()`
|
|
163
434
|
|
|
164
|
-
|
|
435
|
+
#### Interaction kinds
|
|
165
436
|
|
|
166
|
-
|
|
437
|
+
| `state.kind` | Submit method | Key fields |
|
|
438
|
+
|---|---|---|
|
|
439
|
+
| `"choice"` | `submitChoice(selectedKeys: string[])` | `options`, `minChoices`, `maxChoices` |
|
|
440
|
+
| `"text-entry"` | `submitText(value: string)` | `interaction.base`, `interaction.expectedLength?`, `interaction.patternMask?`, `interaction.placeholderText?` |
|
|
441
|
+
| `"extended-text"` + `cardinality: "single"` | `submitText(value: string)` | `interaction.format`, `interaction.expectedLength?`, `interaction.expectedLines?`, `interaction.patternMask?`, `interaction.placeholderText?` |
|
|
442
|
+
| `"extended-text"` + `cardinality: "multiple"` | `submitTexts(values: string[])` | `minStrings`, `maxStrings`, plus the same metadata as single-cardinality extended text |
|
|
443
|
+
| `"order"` | `submitOrder(orderedKeys: string[])` | `choices`, `minChoices`, `maxChoices`, `interaction.shuffle` |
|
|
444
|
+
| `"match"` | `submitMatch(pairs: Array<{ source: string; target: string }>)` | `sourceChoices`, `targetChoices`, `minAssociations`, `maxAssociations`, `interaction.shuffle` |
|
|
445
|
+
| `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
|
|
167
446
|
|
|
168
447
|
### `feedback`
|
|
169
448
|
|
|
170
|
-
|
|
449
|
+
The server has evaluated the submission. Render feedback, then call `advance()`.
|
|
171
450
|
|
|
172
451
|
```ts
|
|
452
|
+
state.phase === "feedback"
|
|
173
453
|
state.stimulus // RendererStimulus | null
|
|
174
|
-
state.interaction //
|
|
175
|
-
state.submission //
|
|
454
|
+
state.interaction // RendererInteraction<Pcis>
|
|
455
|
+
state.submission // RendererSubmission<Pcis>
|
|
176
456
|
state.isCorrect // boolean
|
|
177
457
|
state.feedbackContent // ContentInline[]
|
|
178
|
-
state.
|
|
458
|
+
state.review // InteractionReview<Pcis> | null
|
|
179
459
|
state.advance() // Promise<PrimerState>
|
|
180
460
|
```
|
|
181
461
|
|
|
182
|
-
`
|
|
462
|
+
`review` is interaction-native feedback data when available:
|
|
183
463
|
|
|
184
464
|
```ts
|
|
185
|
-
type
|
|
186
|
-
| {
|
|
187
|
-
| {
|
|
188
|
-
| {
|
|
189
|
-
| {
|
|
190
|
-
|
|
191
|
-
type RendererCorrectAnswer =
|
|
192
|
-
| { kind: "single"; value: RendererCorrectScalarValue | null }
|
|
193
|
-
| { kind: "multiple"; values: RendererCorrectScalarValue[] }
|
|
465
|
+
type InteractionReview<Pcis extends PciId = PciId> =
|
|
466
|
+
| { type: "choice"; correctKeys: string[] }
|
|
467
|
+
| { type: "text-entry"; correctValue: ReviewScalarValue | null }
|
|
468
|
+
| { type: "extended-text"; correctValues: ReviewScalarValue[] }
|
|
469
|
+
| { type: "order"; correctOrder: string[] }
|
|
470
|
+
| { type: "match"; correctPairs: Array<{ source: string; target: string }> }
|
|
194
471
|
| {
|
|
195
|
-
|
|
472
|
+
type: "portable-custom"
|
|
473
|
+
pciId: Pcis
|
|
196
474
|
fields: Array<{
|
|
197
475
|
fieldIdentifier: string
|
|
198
|
-
baseType: "identifier" | "string" | "integer" | "float"
|
|
199
|
-
value:
|
|
476
|
+
baseType: "identifier" | "string" | "integer" | "float" | "pair"
|
|
477
|
+
value: ReviewScalarValue | null
|
|
200
478
|
}>
|
|
201
479
|
}
|
|
480
|
+
|
|
481
|
+
type ReviewScalarValue =
|
|
482
|
+
| { kind: "identifier"; value: string }
|
|
483
|
+
| { kind: "string"; value: string }
|
|
484
|
+
| { kind: "integer"; value: number }
|
|
485
|
+
| { kind: "float"; value: number }
|
|
486
|
+
| { kind: "pair"; source: string; target: string }
|
|
202
487
|
```
|
|
203
488
|
|
|
204
|
-
|
|
489
|
+
Notes:
|
|
490
|
+
|
|
491
|
+
- `null` means the server did not provide review data.
|
|
492
|
+
- order review preserves ordering directly.
|
|
493
|
+
- match review preserves directional `{ source, target }` pairs directly.
|
|
494
|
+
- PCI review uses record fields and can carry `pair` values without flattening them into strings.
|
|
495
|
+
- `feedback.advance()` uses the same observation intent as `observation.advance()`.
|
|
205
496
|
|
|
206
497
|
### `completed`
|
|
207
498
|
|
|
208
|
-
Session finished. No further actions.
|
|
499
|
+
Session finished successfully. No further actions.
|
|
209
500
|
|
|
210
501
|
### `errored`
|
|
211
502
|
|
|
212
|
-
|
|
503
|
+
Recoverable or user-correctable failure.
|
|
213
504
|
|
|
214
505
|
```ts
|
|
215
|
-
state.
|
|
216
|
-
state.
|
|
217
|
-
state.
|
|
506
|
+
state.phase === "errored"
|
|
507
|
+
state.error // Error sentinel
|
|
508
|
+
state.retriable // boolean
|
|
509
|
+
state.retry() // Promise<PrimerState>
|
|
218
510
|
```
|
|
219
511
|
|
|
220
|
-
|
|
512
|
+
Caller guidance:
|
|
513
|
+
|
|
514
|
+
- switch on sentinel identity with `errors.is(...)`
|
|
515
|
+
- check `state.retriable` before calling `retry()`
|
|
516
|
+
- non-retriable errored states keep the original sentinel but do not replay the failed action
|
|
221
517
|
|
|
222
518
|
### `fatal`
|
|
223
519
|
|
|
224
|
-
Permanent failure. Session cannot recover.
|
|
520
|
+
Permanent failure. Session cannot recover.
|
|
225
521
|
|
|
226
522
|
```ts
|
|
227
|
-
state.
|
|
523
|
+
state.phase === "fatal"
|
|
524
|
+
state.error // Error sentinel
|
|
228
525
|
```
|
|
229
526
|
|
|
230
|
-
|
|
231
|
-
- Invalid publishable key (401 — `ErrInvalidPublishableKey`)
|
|
232
|
-
- Origin not allowed (403 — `ErrForbidden`)
|
|
233
|
-
- Resource not found (404 — `ErrNotFound`)
|
|
234
|
-
- Malformed request (400 — `ErrBadRequest`)
|
|
235
|
-
- Unsupported PCI (422 — `ErrUnsupportedPci`)
|
|
527
|
+
Fatal states can come from:
|
|
236
528
|
|
|
237
|
-
|
|
529
|
+
- malformed request semantics on the server (`400`)
|
|
530
|
+
- missing, invalid, or expired access token (`401`)
|
|
531
|
+
- forbidden (`403`)
|
|
532
|
+
- missing resource (`404`)
|
|
533
|
+
- unsupported PCI (`422`)
|
|
534
|
+
- a successful response that still contains a `portable-custom` frame whose `pciId` is not in `supportedPcis`
|
|
535
|
+
|
|
536
|
+
## Interaction payloads
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
type PciSubmission<Pcis extends PciId = PciId> = {
|
|
540
|
+
[K in Pcis]: {
|
|
541
|
+
type: "portable-custom"
|
|
542
|
+
pciId: K
|
|
543
|
+
value: PciValue<K>
|
|
544
|
+
}
|
|
545
|
+
}[Pcis]
|
|
546
|
+
|
|
547
|
+
type RendererSubmission<Pcis extends PciId = PciId> =
|
|
548
|
+
| { type: "choice"; selectedKeys: string[] }
|
|
549
|
+
| { type: "text-entry"; value: string }
|
|
550
|
+
| { type: "extended-text"; values: string[] }
|
|
551
|
+
| { type: "order"; orderedKeys: string[] }
|
|
552
|
+
| { type: "match"; pairs: Array<{ source: string; target: string }> }
|
|
553
|
+
| PciSubmission<Pcis>
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Notes:
|
|
238
557
|
|
|
239
|
-
|
|
558
|
+
- single-cardinality extended text is still submitted as `type: "extended-text"` with a one-element `values` array
|
|
559
|
+
- match submissions are directional `{ source, target }` pairs
|
|
560
|
+
- portable custom submissions carry both `pciId` and a typed PCI `value`
|
|
240
561
|
|
|
241
|
-
|
|
562
|
+
## Client-side validation
|
|
242
563
|
|
|
243
|
-
|
|
564
|
+
The SDK performs **selective** client-side validation before sending some submissions.
|
|
244
565
|
|
|
245
|
-
|
|
566
|
+
| Interaction kind | Validation performed |
|
|
567
|
+
|---|---|
|
|
568
|
+
| `choice` | min selections, max selections, duplicate keys, unknown option identifiers |
|
|
569
|
+
| `text-entry` | none |
|
|
570
|
+
| `extended-text` + `single` | none |
|
|
571
|
+
| `extended-text` + `multiple` | `minStrings`, `maxStrings` |
|
|
572
|
+
| `order` | min selections, max selections, duplicate keys, unknown choice identifiers |
|
|
573
|
+
| `match` | `minAssociations`, `maxAssociations`, unknown source/target identifiers, and per-choice `matchMin` / `matchMax` caps on both sides |
|
|
574
|
+
| `portable-custom` | no outbound value validation |
|
|
575
|
+
|
|
576
|
+
If validation fails:
|
|
577
|
+
|
|
578
|
+
- the submit call resolves to an `errored` state
|
|
579
|
+
- `state.error` will match `ErrInvalidSubmission`
|
|
580
|
+
- `state.retriable` will be `false`
|
|
581
|
+
- the original interaction object is still usable for a corrected resubmission
|
|
582
|
+
- `retry()` returns the same errored state instead of replaying the invalid payload
|
|
246
583
|
|
|
247
584
|
## Error sentinels
|
|
248
585
|
|
|
249
586
|
```ts
|
|
250
587
|
import * as errors from "@superbuilders/errors"
|
|
251
|
-
import {
|
|
252
|
-
|
|
253
|
-
|
|
588
|
+
import {
|
|
589
|
+
ErrInvalidSubmission,
|
|
590
|
+
ErrNetwork,
|
|
591
|
+
ErrRateLimited,
|
|
592
|
+
ErrUnsupportedPci,
|
|
593
|
+
} from "@superbuilders/primer-tives"
|
|
594
|
+
|
|
595
|
+
if (errors.is(state.error, ErrNetwork)) {
|
|
596
|
+
// handle offline / DNS / CORS / fetch failure
|
|
597
|
+
}
|
|
254
598
|
```
|
|
255
599
|
|
|
256
|
-
|
|
600
|
+
### Surfaced as `errored`
|
|
257
601
|
|
|
258
|
-
| Sentinel |
|
|
259
|
-
|
|
260
|
-
| `ErrNetwork` |
|
|
261
|
-
| `ErrTimeout` |
|
|
262
|
-
| `ErrRateLimited` | 429 |
|
|
263
|
-
| `
|
|
264
|
-
| `
|
|
265
|
-
| `ErrJsonParse` |
|
|
266
|
-
| `ErrConflict` | 409
|
|
267
|
-
| `ErrInvalidSubmission` |
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
| Sentinel |
|
|
602
|
+
| Sentinel | Meaning | `state.retriable` |
|
|
603
|
+
|---|---|---|
|
|
604
|
+
| `ErrNetwork` | fetch failed before a response was received | `true` |
|
|
605
|
+
| `ErrTimeout` | request was aborted or timed out | `true` |
|
|
606
|
+
| `ErrRateLimited` | `429` | `true` |
|
|
607
|
+
| `ErrServiceUnavailable` | `502`, `503`, or `504` | `true` |
|
|
608
|
+
| `ErrServerError` | any other unhandled HTTP error status | `true` |
|
|
609
|
+
| `ErrJsonParse` | response body was not valid JSON | `true` |
|
|
610
|
+
| `ErrConflict` | `409`, or a local conflicting in-flight action | `true` |
|
|
611
|
+
| `ErrInvalidSubmission` | client-side validation failed | `false` |
|
|
612
|
+
|
|
613
|
+
### Permanent: surfaced as `fatal`
|
|
614
|
+
|
|
615
|
+
| Sentinel | Meaning |
|
|
272
616
|
|---|---|
|
|
273
|
-
| `ErrBadRequest` | 400
|
|
274
|
-
| `
|
|
275
|
-
| `
|
|
276
|
-
| `
|
|
277
|
-
| `
|
|
617
|
+
| `ErrBadRequest` | `400` |
|
|
618
|
+
| `ErrInvalidAccessToken` | `401` with an invalid signature / unknown key / malformed claims |
|
|
619
|
+
| `ErrTokenExpired` | `401` with an `exp` claim in the past |
|
|
620
|
+
| `ErrForbidden` | `403` |
|
|
621
|
+
| `ErrNotFound` | `404` |
|
|
622
|
+
| `ErrUnsupportedPci` | server rejected the request with `422`, or the server returned a PCI frame the client did not advertise in `supportedPcis` |
|
|
278
623
|
|
|
279
|
-
|
|
624
|
+
### Thrown directly
|
|
280
625
|
|
|
281
|
-
| Sentinel |
|
|
626
|
+
| Sentinel | Meaning |
|
|
282
627
|
|---|---|
|
|
283
|
-
| `
|
|
284
|
-
| `ErrNotSerializable` |
|
|
285
|
-
|
|
286
|
-
## Submission payloads
|
|
287
|
-
|
|
288
|
-
```ts
|
|
289
|
-
type RendererSubmission =
|
|
290
|
-
| { type: "choice"; selectedKeys: string[] }
|
|
291
|
-
| { type: "text-entry"; value: string }
|
|
292
|
-
| { type: "extended-text"; values: string[] }
|
|
293
|
-
| { type: "portable-custom"; pciId: PciId; value: PciValue<PciId> }
|
|
294
|
-
```
|
|
628
|
+
| `ErrMalformedAccessToken` | `create()` received a token whose shape is not a JWS (must start with `eyJ` and contain two dots) |
|
|
629
|
+
| `ErrNotSerializable` | code attempted to serialize a live `PrimerState` |
|
|
295
630
|
|
|
296
631
|
## Content format
|
|
297
632
|
|
|
@@ -307,13 +642,44 @@ type ContentInline =
|
|
|
307
642
|
type ContentBlock = { type: "paragraph"; children: ContentInline[] }
|
|
308
643
|
```
|
|
309
644
|
|
|
310
|
-
|
|
645
|
+
### Helpers
|
|
646
|
+
|
|
647
|
+
```ts
|
|
648
|
+
inlinesToPlainText(nodes: ContentInline[]): string
|
|
649
|
+
blocksToPlainText(blocks: ContentBlock[]): string
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
- `inlinesToPlainText()` strips formatting and concatenates inline values
|
|
653
|
+
- `blocksToPlainText()` flattens block content with newlines between paragraphs
|
|
654
|
+
|
|
655
|
+
Typical use cases:
|
|
656
|
+
|
|
657
|
+
- accessibility labels
|
|
658
|
+
- plain-text fallbacks
|
|
659
|
+
- analytics or logging output that should ignore inline formatting
|
|
660
|
+
|
|
661
|
+
## Stimulus model
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
interface BodyStimulus {
|
|
665
|
+
type: "body"
|
|
666
|
+
body: ContentBlock[]
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
interface ImageStimulus {
|
|
670
|
+
type: "image"
|
|
671
|
+
description: ContentInline[]
|
|
672
|
+
src: string
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
type RendererStimulus = BodyStimulus | ImageStimulus
|
|
676
|
+
```
|
|
311
677
|
|
|
312
|
-
|
|
678
|
+
A frame can also have `stimulus: null`.
|
|
313
679
|
|
|
314
680
|
## PCI system
|
|
315
681
|
|
|
316
|
-
Portable Custom Interactions
|
|
682
|
+
Portable Custom Interactions let Primer serve domain-specific input types while preserving compile-time type safety.
|
|
317
683
|
|
|
318
684
|
### Registry
|
|
319
685
|
|
|
@@ -336,31 +702,117 @@ interface PciRegistry {
|
|
|
336
702
|
### Type helpers
|
|
337
703
|
|
|
338
704
|
```ts
|
|
705
|
+
type PciUrn = `urn:primer:pci:${string}`
|
|
339
706
|
type PciId = keyof PciRegistry & string
|
|
340
|
-
type PciProps<K extends PciId>
|
|
341
|
-
type PciValue<K extends PciId>
|
|
707
|
+
type PciProps<K extends PciId> = PciRegistry[K]["props"]
|
|
708
|
+
type PciValue<K extends PciId> = PciRegistry[K]["value"]
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
### PCI renderer props
|
|
712
|
+
|
|
713
|
+
```ts
|
|
714
|
+
type PciRenderProps<K extends PciId> =
|
|
715
|
+
| {
|
|
716
|
+
mode: "pending"
|
|
717
|
+
properties: PciProps<K>
|
|
718
|
+
onValueChange: (value: PciValue<K> | null) => void
|
|
719
|
+
}
|
|
720
|
+
| {
|
|
721
|
+
mode: "submitted"
|
|
722
|
+
properties: PciProps<K>
|
|
723
|
+
submission: PciValue<K>
|
|
724
|
+
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
725
|
+
}
|
|
342
726
|
```
|
|
343
727
|
|
|
344
728
|
### Adding a PCI
|
|
345
729
|
|
|
346
|
-
1. Add entry to `PciRegistry` in `src/pci.ts`
|
|
347
|
-
2. URN format
|
|
348
|
-
3.
|
|
349
|
-
4.
|
|
730
|
+
1. Add a new entry to `PciRegistry` in `src/pci.ts`
|
|
731
|
+
2. Use the URN format `urn:primer:pci:<name>`
|
|
732
|
+
3. Add that PCI URN to `supportedPcis` when creating the client
|
|
733
|
+
4. Make sure your renderer knows how to render and collect a `PciValue` for that PCI
|
|
350
734
|
|
|
351
|
-
## Import
|
|
735
|
+
## Import guide
|
|
352
736
|
|
|
353
737
|
Everything is exported from the package root:
|
|
354
738
|
|
|
355
739
|
```ts
|
|
356
|
-
import {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
740
|
+
import {
|
|
741
|
+
ADVANCE_PATH,
|
|
742
|
+
ErrConflict,
|
|
743
|
+
ErrInvalidAccessToken,
|
|
744
|
+
ErrInvalidSubmission,
|
|
745
|
+
ErrMalformedAccessToken,
|
|
746
|
+
ErrNetwork,
|
|
747
|
+
ErrTimeout,
|
|
748
|
+
ErrTokenExpired,
|
|
749
|
+
ErrUnsupportedPci,
|
|
750
|
+
SUBJECTS,
|
|
751
|
+
blocksToPlainText,
|
|
752
|
+
create,
|
|
753
|
+
inlinesToPlainText,
|
|
754
|
+
} from "@superbuilders/primer-tives"
|
|
755
|
+
|
|
756
|
+
import type {
|
|
757
|
+
ChoiceState,
|
|
758
|
+
Client,
|
|
759
|
+
Config,
|
|
760
|
+
ContentBlock,
|
|
761
|
+
ContentInline,
|
|
762
|
+
FeedbackState,
|
|
763
|
+
MatchState,
|
|
764
|
+
ObservationState,
|
|
765
|
+
OrderState,
|
|
766
|
+
PciId,
|
|
767
|
+
PciProps,
|
|
768
|
+
PciRenderProps,
|
|
769
|
+
PciValue,
|
|
770
|
+
PrimerLogger,
|
|
771
|
+
PrimerState,
|
|
772
|
+
RendererInteraction,
|
|
773
|
+
RendererStimulus,
|
|
774
|
+
RendererSubmission,
|
|
775
|
+
Subject,
|
|
776
|
+
SubjectScope,
|
|
777
|
+
} from "@superbuilders/primer-tives"
|
|
362
778
|
```
|
|
363
779
|
|
|
364
|
-
##
|
|
780
|
+
## Behavioral notes
|
|
781
|
+
|
|
782
|
+
### States are not serializable
|
|
783
|
+
|
|
784
|
+
`PrimerState` is live in-memory state that contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`.
|
|
785
|
+
|
|
786
|
+
Do **not**:
|
|
365
787
|
|
|
366
|
-
`
|
|
788
|
+
- `JSON.stringify(state)`
|
|
789
|
+
- persist a state object to storage
|
|
790
|
+
- send a state object over the network
|
|
791
|
+
- treat a state object as a durable snapshot
|
|
792
|
+
|
|
793
|
+
Instead, keep it in memory and render it immediately.
|
|
794
|
+
|
|
795
|
+
### Repeated actions are deduplicated narrowly
|
|
796
|
+
|
|
797
|
+
State objects memoize only true re-entry of the same in-flight action.
|
|
798
|
+
|
|
799
|
+
- calling `advance()` twice on the same observation state returns the same promise
|
|
800
|
+
- calling the same submit method twice with the same payload returns the same promise
|
|
801
|
+
- calling `retry()` twice on the same errored state returns the same promise
|
|
802
|
+
- submit and timeout do **not** alias to each other anymore
|
|
803
|
+
- conflicting in-flight actions surface `ErrConflict` instead of silently reusing the wrong promise
|
|
804
|
+
|
|
805
|
+
### `start()` is memoized per client instance
|
|
806
|
+
|
|
807
|
+
A `Client` instance memoizes the first `start()` call and returns the same promise on later calls. In practice, create a fresh client instance per independently managed session.
|
|
808
|
+
|
|
809
|
+
## Build and publish
|
|
810
|
+
|
|
811
|
+
The package is built as browser-targeted ESM from `src/index.ts`, emits declaration files, and publishes `dist/` plus this README.
|
|
812
|
+
|
|
813
|
+
Useful package scripts:
|
|
814
|
+
|
|
815
|
+
```sh
|
|
816
|
+
bun run build
|
|
817
|
+
bun run typecheck
|
|
818
|
+
```
|