@superbuilders/primer-tives 0.5.0 → 0.8.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 +294 -481
- package/dist/client/choice-state.d.ts.map +1 -0
- package/dist/client/consumed.d.ts.map +1 -0
- package/dist/client/content.d.ts.map +1 -0
- package/dist/{client.d.ts → client/create.d.ts} +7 -5
- package/dist/client/create.d.ts.map +1 -0
- package/dist/client/extended-text-state.d.ts.map +1 -0
- package/dist/client/feedback-state.d.ts.map +1 -0
- package/dist/{index.d.ts → client/index.d.ts} +7 -5
- package/dist/client/index.d.ts.map +1 -0
- package/dist/{index.js → client/index.js} +81 -47
- package/dist/client/index.js.map +24 -0
- package/dist/client/match-state.d.ts.map +1 -0
- package/dist/client/observation-state.d.ts.map +1 -0
- package/dist/client/order-state.d.ts.map +1 -0
- package/dist/client/pci-state.d.ts.map +1 -0
- package/dist/client/pci.d.ts.map +1 -0
- package/dist/{session-context.d.ts → client/session-context.d.ts} +4 -7
- package/dist/client/session-context.d.ts.map +1 -0
- package/dist/{session.d.ts → client/session.d.ts} +4 -2
- package/dist/client/session.d.ts.map +1 -0
- package/dist/client/text-entry-state.d.ts.map +1 -0
- package/dist/{transport.d.ts → client/transport.d.ts} +5 -4
- package/dist/client/transport.d.ts.map +1 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/errors.d.ts +8 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/grade-level.d.ts +5 -0
- package/dist/grade-level.d.ts.map +1 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/server/create-server.d.ts +42 -0
- package/dist/server/create-server.d.ts.map +1 -0
- package/dist/server/exchange.d.ts +17 -0
- package/dist/server/exchange.d.ts.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +259 -0
- package/dist/server/index.js.map +14 -0
- package/dist/server/students.d.ts +14 -0
- package/dist/server/students.d.ts.map +1 -0
- package/dist/subject.d.ts +6 -0
- package/dist/subject.d.ts.map +1 -0
- package/package.json +8 -4
- package/dist/choice-state.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/consumed.d.ts.map +0 -1
- package/dist/content.d.ts.map +0 -1
- package/dist/extended-text-state.d.ts.map +0 -1
- package/dist/feedback-state.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -23
- package/dist/match-state.d.ts.map +0 -1
- package/dist/observation-state.d.ts.map +0 -1
- package/dist/order-state.d.ts.map +0 -1
- package/dist/pci-state.d.ts.map +0 -1
- package/dist/pci.d.ts.map +0 -1
- package/dist/session-context.d.ts.map +0 -1
- package/dist/session.d.ts.map +0 -1
- package/dist/text-entry-state.d.ts.map +0 -1
- package/dist/transport.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- /package/dist/{choice-state.d.ts → client/choice-state.d.ts} +0 -0
- /package/dist/{consumed.d.ts → client/consumed.d.ts} +0 -0
- /package/dist/{content.d.ts → client/content.d.ts} +0 -0
- /package/dist/{extended-text-state.d.ts → client/extended-text-state.d.ts} +0 -0
- /package/dist/{feedback-state.d.ts → client/feedback-state.d.ts} +0 -0
- /package/dist/{match-state.d.ts → client/match-state.d.ts} +0 -0
- /package/dist/{observation-state.d.ts → client/observation-state.d.ts} +0 -0
- /package/dist/{order-state.d.ts → client/order-state.d.ts} +0 -0
- /package/dist/{pci-state.d.ts → client/pci-state.d.ts} +0 -0
- /package/dist/{pci.d.ts → client/pci.d.ts} +0 -0
- /package/dist/{text-entry-state.d.ts → client/text-entry-state.d.ts} +0 -0
- /package/dist/{types.d.ts → client/types.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
# @superbuilders/primer-tives
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
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.
|
|
6
|
-
|
|
7
|
-
The SDK does **not** know about routines, curricula, storage schemas, or routing rules. It only knows how to:
|
|
8
|
-
|
|
9
|
-
- start a student session
|
|
10
|
-
- submit answers or timeouts
|
|
11
|
-
- normalize transport failures into sentinel errors plus `retriable` semantics
|
|
12
|
-
- expose the current step as an ergonomic TypeScript union
|
|
13
|
-
- expose interaction-native review data for feedback rendering
|
|
14
|
-
- preserve full type safety for portable custom interactions (PCIs)
|
|
15
|
-
|
|
16
|
-
## Install
|
|
3
|
+
TypeScript SDK for the Primer adaptive learning engine. Wraps the raw HTTP protocol in two tiny, fully typed surfaces — one for your backend, one for the browser — so it's mechanically impossible to misuse.
|
|
17
4
|
|
|
18
5
|
```sh
|
|
19
6
|
bun add @superbuilders/primer-tives
|
|
@@ -21,37 +8,66 @@ bun add @superbuilders/primer-tives
|
|
|
21
8
|
|
|
22
9
|
Dependency: `@superbuilders/errors` is installed automatically.
|
|
23
10
|
|
|
24
|
-
##
|
|
11
|
+
## Two entrypoints
|
|
12
|
+
|
|
13
|
+
The package ships two subpaths. There is no root export — you must pick the side of the wire you're on:
|
|
25
14
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
15
|
+
| Import | Runs on | Wraps |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `@superbuilders/primer-tives/server` | your backend | `POST /api/v0/auth/exchange`, `POST /api/v0/students`, `PATCH /api/v0/students/{id}` |
|
|
18
|
+
| `@superbuilders/primer-tives/client` | the browser | `POST /api/v0/advance` (the lesson state machine) |
|
|
19
|
+
|
|
20
|
+
No route strings, no `fetch()` calls, no snake_case wire bodies on your side. Both surfaces normalize transport failures into sentinel errors from `@superbuilders/errors`.
|
|
21
|
+
|
|
22
|
+
## Round trip
|
|
34
23
|
|
|
35
|
-
|
|
24
|
+
Your backend provisions a student (once), exchanges a `sk_` for a short-lived access token (each session), and hands the token to your frontend. Your frontend passes the token to `create()` and drives the returned state machine.
|
|
36
25
|
|
|
37
26
|
```ts
|
|
27
|
+
// ── your backend ─────────────────────────────────────────────────────
|
|
38
28
|
import * as errors from "@superbuilders/errors"
|
|
29
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
30
|
+
|
|
31
|
+
const primer = createPrimerServer({
|
|
32
|
+
origin: "https://primer.example.com",
|
|
33
|
+
secretKey: process.env.PRIMER_CLIENT_SECRET_KEY_DEV
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// One-time per user: provision a Primer-owned student.
|
|
37
|
+
const studentId = await primer.createNativeStudent("4")
|
|
38
|
+
// persist `studentId` alongside your user record
|
|
39
|
+
|
|
40
|
+
// Every session: mint a short-lived access token.
|
|
41
|
+
const result = await errors.try(
|
|
42
|
+
primer.exchangeNativeStudentForAccessToken(studentId)
|
|
43
|
+
)
|
|
44
|
+
if (result.error) {
|
|
45
|
+
// map ErrInvalidSecretKey / ErrStudentNotFound / ErrServerError etc.
|
|
46
|
+
throw result.error
|
|
47
|
+
}
|
|
48
|
+
const { accessToken, expiresInSeconds } = result.data
|
|
49
|
+
// ship `accessToken` to the browser
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// ── your frontend ────────────────────────────────────────────────────
|
|
39
54
|
import {
|
|
40
55
|
create,
|
|
41
56
|
ErrRateLimited,
|
|
42
|
-
type PrimerState
|
|
43
|
-
} from "@superbuilders/primer-tives"
|
|
57
|
+
type PrimerState
|
|
58
|
+
} from "@superbuilders/primer-tives/client"
|
|
44
59
|
|
|
45
60
|
const client = create({
|
|
46
|
-
|
|
47
|
-
origin: "https://
|
|
61
|
+
accessToken, // from your backend
|
|
62
|
+
origin: "https://primer.example.com",
|
|
63
|
+
subject: "math",
|
|
48
64
|
supportedPcis: [
|
|
49
65
|
"urn:primer:pci:division-remainder",
|
|
50
|
-
"urn:primer:pci:fraction-addition"
|
|
51
|
-
]
|
|
66
|
+
"urn:primer:pci:fraction-addition"
|
|
67
|
+
]
|
|
52
68
|
})
|
|
53
69
|
|
|
54
|
-
let state: PrimerState = await client.start(
|
|
70
|
+
let state: PrimerState = await client.start()
|
|
55
71
|
|
|
56
72
|
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
57
73
|
switch (state.phase) {
|
|
@@ -59,418 +75,280 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
59
75
|
renderStimulus(state.stimulus)
|
|
60
76
|
state = await state.advance()
|
|
61
77
|
break
|
|
62
|
-
|
|
63
78
|
case "interaction":
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
switch (state.kind) {
|
|
67
|
-
case "choice":
|
|
68
|
-
state = await state.submitChoice(["option-a"])
|
|
69
|
-
break
|
|
70
|
-
|
|
71
|
-
case "text-entry":
|
|
72
|
-
state = await state.submitText("42")
|
|
73
|
-
break
|
|
74
|
-
|
|
75
|
-
case "extended-text":
|
|
76
|
-
if (state.cardinality === "single") {
|
|
77
|
-
state = await state.submitText("answer")
|
|
78
|
-
} else {
|
|
79
|
-
state = await state.submitTexts(["first", "second"])
|
|
80
|
-
}
|
|
81
|
-
break
|
|
82
|
-
|
|
83
|
-
case "order":
|
|
84
|
-
state = await state.submitOrder(["choice-1", "choice-2", "choice-3"])
|
|
85
|
-
break
|
|
86
|
-
|
|
87
|
-
case "match":
|
|
88
|
-
state = await state.submitMatch([
|
|
89
|
-
{ source: "left-1", target: "right-2" },
|
|
90
|
-
{ source: "left-2", target: "right-1" },
|
|
91
|
-
])
|
|
92
|
-
break
|
|
93
|
-
|
|
94
|
-
case "portable-custom":
|
|
95
|
-
switch (state.pciId) {
|
|
96
|
-
case "urn:primer:pci:division-remainder":
|
|
97
|
-
state = await state.submit({ quotient: "3", remainder: "1" })
|
|
98
|
-
break
|
|
99
|
-
case "urn:primer:pci:fraction-addition":
|
|
100
|
-
state = await state.submit({ numerator: "5", denominator: "6" })
|
|
101
|
-
break
|
|
102
|
-
}
|
|
103
|
-
break
|
|
104
|
-
}
|
|
79
|
+
state = await runInteraction(state)
|
|
105
80
|
break
|
|
106
|
-
|
|
107
81
|
case "feedback":
|
|
108
|
-
renderFeedback(
|
|
109
|
-
correct: state.isCorrect,
|
|
110
|
-
content: state.feedbackContent,
|
|
111
|
-
review: state.review,
|
|
112
|
-
interaction: state.interaction,
|
|
113
|
-
submission: state.submission,
|
|
114
|
-
})
|
|
82
|
+
renderFeedback(state.feedbackContent, state.isCorrect)
|
|
115
83
|
state = await state.advance()
|
|
116
84
|
break
|
|
117
|
-
|
|
118
85
|
case "errored":
|
|
119
|
-
if (
|
|
86
|
+
if (state.retriable) {
|
|
87
|
+
state = await state.retry()
|
|
88
|
+
} else {
|
|
120
89
|
throw state.error
|
|
121
90
|
}
|
|
122
|
-
if (errors.is(state.error, ErrRateLimited)) {
|
|
123
|
-
await delay(1000)
|
|
124
|
-
}
|
|
125
|
-
state = await state.retry()
|
|
126
91
|
break
|
|
127
92
|
}
|
|
128
93
|
}
|
|
129
94
|
```
|
|
130
95
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
## Mental model
|
|
134
|
-
|
|
135
|
-
Primer is **server-authored**.
|
|
96
|
+
---
|
|
136
97
|
|
|
137
|
-
|
|
138
|
-
- The server evaluates that intent and returns the next frame.
|
|
139
|
-
- The SDK wraps that frame in a typed state object.
|
|
140
|
-
- Your UI renders the state and calls the next valid method.
|
|
141
|
-
|
|
142
|
-
In other words, your code does not calculate progression locally. The server owns progression; the SDK owns transport, typing, and ergonomics.
|
|
143
|
-
|
|
144
|
-
## Configuration
|
|
98
|
+
# `/server`
|
|
145
99
|
|
|
146
100
|
```ts
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
101
|
+
import {
|
|
102
|
+
createPrimerServer,
|
|
103
|
+
type PrimerServer,
|
|
104
|
+
type PrimerServerConfig,
|
|
105
|
+
type SessionToken,
|
|
106
|
+
type GradeLevel,
|
|
107
|
+
GRADE_LEVELS
|
|
108
|
+
} from "@superbuilders/primer-tives/server"
|
|
155
109
|
```
|
|
156
110
|
|
|
157
|
-
|
|
158
|
-
|---|---|---|
|
|
159
|
-
| `publishableKey` | yes | Must start with `pk_`. Sent as `Authorization: Bearer pk_...` |
|
|
160
|
-
| `supportedPcis` | yes | PCI URNs the renderer can handle. Use `[]` if none |
|
|
161
|
-
| `origin` | yes | Full Primer API base URL, for example `https://sb-primer.vercel.app` |
|
|
162
|
-
| `fetch` | no | Custom fetch implementation. Defaults to `globalThis.fetch` |
|
|
163
|
-
| `abort` | no | `AbortController` whose `signal` is passed to every request |
|
|
164
|
-
| `logger` | no | Structured logger with `debug`, `info`, `warn`, and `error` methods |
|
|
165
|
-
|
|
166
|
-
`create()` validates the publishable key prefix immediately and throws `ErrMalformedPublishableKey` if it does not start with `pk_`.
|
|
167
|
-
|
|
168
|
-
### Logger interface
|
|
111
|
+
## `createPrimerServer(config)`
|
|
169
112
|
|
|
170
113
|
```ts
|
|
171
|
-
interface
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
114
|
+
interface PrimerServerConfig {
|
|
115
|
+
readonly origin: string // e.g. https://primer.example.com (no trailing slash)
|
|
116
|
+
readonly secretKey: string // your sk_… key
|
|
117
|
+
readonly fetch?: typeof globalThis.fetch // override (defaults to globalThis.fetch)
|
|
118
|
+
readonly abort?: AbortController // wired into every request signal
|
|
119
|
+
readonly logger?: PrimerLogger // optional structured logger
|
|
176
120
|
}
|
|
121
|
+
|
|
122
|
+
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
177
123
|
```
|
|
178
124
|
|
|
179
|
-
|
|
125
|
+
Returns a `PrimerServer` with four methods. Each method throws a sentinel-wrapped `Error` — use `errors.try()` from `@superbuilders/errors` at the call site.
|
|
180
126
|
|
|
181
|
-
|
|
127
|
+
### `createNativeStudent(gradeLevel): Promise<string>`
|
|
182
128
|
|
|
183
|
-
|
|
184
|
-
${origin}/api/v0/advance
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
The path constant is exported as:
|
|
129
|
+
Provision a new Primer-owned student. Returns the `studentId` string — persist it in your own database keyed by your user. Call this **once per user**.
|
|
188
130
|
|
|
189
131
|
```ts
|
|
190
|
-
const
|
|
132
|
+
const studentId = await primer.createNativeStudent("5")
|
|
191
133
|
```
|
|
192
134
|
|
|
193
|
-
|
|
135
|
+
**Do not call this for Timeback integrations.** Timeback students are provisioned automatically on first `exchangeTimebackStudentForAccessToken`.
|
|
194
136
|
|
|
195
|
-
|
|
196
|
-
Authorization: Bearer pk_...
|
|
197
|
-
Content-Type: application/json
|
|
198
|
-
```
|
|
137
|
+
### `updateNativeStudentGradeLevel(studentId, gradeLevel): Promise<void>`
|
|
199
138
|
|
|
200
|
-
|
|
139
|
+
Change a Primer student's grade level.
|
|
201
140
|
|
|
202
141
|
```ts
|
|
203
|
-
|
|
204
|
-
studentId: string
|
|
205
|
-
supportedPcis: readonly PciId[]
|
|
206
|
-
intent: WireIntent<Pcis>
|
|
207
|
-
}
|
|
142
|
+
await primer.updateNativeStudentGradeLevel(studentId, "6")
|
|
208
143
|
```
|
|
209
144
|
|
|
210
|
-
|
|
211
|
-
type WireIntent<Pcis extends PciId = PciId> =
|
|
212
|
-
| { kind: "observation" }
|
|
213
|
-
| { kind: "interaction"; submission: RendererSubmission<Pcis> }
|
|
214
|
-
| { kind: "timeout" }
|
|
215
|
-
```
|
|
145
|
+
**Do not call this for Timeback students.** Grade-level changes flow from the SIS.
|
|
216
146
|
|
|
217
|
-
###
|
|
147
|
+
### `exchangeNativeStudentForAccessToken(studentId): Promise<SessionToken>`
|
|
218
148
|
|
|
219
|
-
|
|
149
|
+
Mint a short-lived access token for an existing Primer-owned student. Call this **every session start**; tokens expire in 15 minutes by default.
|
|
220
150
|
|
|
221
151
|
```ts
|
|
222
|
-
|
|
223
|
-
| {
|
|
224
|
-
outcome: "advanced"
|
|
225
|
-
stimulus: RendererStimulus | null
|
|
226
|
-
interaction: RendererInteraction<Pcis> | null
|
|
227
|
-
}
|
|
228
|
-
| {
|
|
229
|
-
outcome: "submitted"
|
|
230
|
-
stimulus: RendererStimulus | null
|
|
231
|
-
interaction: RendererInteraction<Pcis>
|
|
232
|
-
submission: RendererSubmission<Pcis>
|
|
233
|
-
isCorrect: boolean
|
|
234
|
-
feedbackContent: ContentInline[]
|
|
235
|
-
review: InteractionReview<Pcis> | null
|
|
236
|
-
}
|
|
237
|
-
| { outcome: "completed" }
|
|
152
|
+
const { accessToken, expiresInSeconds } = await primer.exchangeNativeStudentForAccessToken(studentId)
|
|
238
153
|
```
|
|
239
154
|
|
|
240
|
-
###
|
|
155
|
+
### `exchangeTimebackStudentForAccessToken(sourcedId): Promise<SessionToken>`
|
|
241
156
|
|
|
242
|
-
- `
|
|
243
|
-
- `supportedPcis` is sent on every request so the server knows which portable custom interactions the client can render.
|
|
244
|
-
- 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.
|
|
157
|
+
Mint a short-lived access token by OneRoster `sourcedId`. The server contacts the Timeback API to verify identity and grade, auto-provisions the Primer student row on first use, and returns the token. Subsequent exchanges for the same `sourcedId` reuse the existing row.
|
|
245
158
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
159
|
+
```ts
|
|
160
|
+
const { accessToken, expiresInSeconds } = await primer.exchangeTimebackStudentForAccessToken(sourcedId)
|
|
161
|
+
```
|
|
249
162
|
|
|
250
|
-
|
|
251
|
-
observation -> interaction -> feedback -> observation -> ... -> completed
|
|
252
|
-
| \ | \ | \
|
|
253
|
-
v \ v \ v \
|
|
254
|
-
errored fatal errored fatal errored fatal
|
|
163
|
+
## `SessionToken`
|
|
255
164
|
|
|
256
|
-
|
|
257
|
-
|
|
165
|
+
```ts
|
|
166
|
+
interface SessionToken {
|
|
167
|
+
readonly accessToken: string // HS256 JWS — pass to client SDK as Config.accessToken
|
|
168
|
+
readonly expiresInSeconds: number // typically 900 (15 min)
|
|
169
|
+
}
|
|
258
170
|
```
|
|
259
171
|
|
|
260
|
-
|
|
172
|
+
Hand `accessToken` to your frontend over whatever channel you already use (cookie, HTML response, WebSocket, API response). The browser passes it to `create()`.
|
|
261
173
|
|
|
262
|
-
|
|
174
|
+
## `GradeLevel`
|
|
263
175
|
|
|
264
176
|
```ts
|
|
265
|
-
|
|
266
|
-
state.stimulus // RendererStimulus | null
|
|
267
|
-
state.advance() // Promise<PrimerState>
|
|
177
|
+
type GradeLevel = "K" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12"
|
|
268
178
|
```
|
|
269
179
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
Student must respond. Discriminate on `state.kind`.
|
|
273
|
-
|
|
274
|
-
All interaction states also expose:
|
|
180
|
+
Exported as both `GradeLevel` and the `GRADE_LEVELS` readonly tuple.
|
|
275
181
|
|
|
276
|
-
|
|
277
|
-
- `state.interaction`
|
|
278
|
-
- `timeout()`
|
|
182
|
+
## Error sentinels (`/server`)
|
|
279
183
|
|
|
280
|
-
|
|
184
|
+
| Sentinel | Raised when |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
|
|
187
|
+
| `ErrStudentNotFound` | HTTP 404 — student doesn't exist (native) or sourced ID unknown (timeback) |
|
|
188
|
+
| `ErrUnsupportedGrade` | HTTP 400 — Timeback user's grade is outside K–12 |
|
|
189
|
+
| `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed |
|
|
190
|
+
| `ErrBadRequest` | HTTP 400 (non-grade) — validation failure |
|
|
191
|
+
| `ErrServerError` | HTTP 5xx |
|
|
192
|
+
| `ErrJsonParse` | Success response body wasn't valid JSON |
|
|
193
|
+
| `ErrNetwork` | fetch() rejected (DNS, connection, TLS) |
|
|
194
|
+
| `ErrTimeout` | fetch() aborted (your `AbortController` or `TimeoutError`) |
|
|
195
|
+
|
|
196
|
+
All server methods follow the same pattern:
|
|
281
197
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
198
|
+
```ts
|
|
199
|
+
const result = await errors.try(primer.createNativeStudent("5"))
|
|
200
|
+
if (result.error) {
|
|
201
|
+
if (errors.is(result.error, ErrInvalidSecretKey)) {
|
|
202
|
+
// rotate the sk_ and retry
|
|
203
|
+
}
|
|
204
|
+
throw result.error
|
|
205
|
+
}
|
|
206
|
+
const studentId = result.data
|
|
207
|
+
```
|
|
291
208
|
|
|
292
|
-
|
|
209
|
+
---
|
|
293
210
|
|
|
294
|
-
|
|
211
|
+
# `/client`
|
|
295
212
|
|
|
296
213
|
```ts
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
214
|
+
import {
|
|
215
|
+
create,
|
|
216
|
+
type Client,
|
|
217
|
+
type Config,
|
|
218
|
+
type PrimerState,
|
|
219
|
+
type PrimerLogger,
|
|
220
|
+
type Subject,
|
|
221
|
+
type SubjectScope,
|
|
222
|
+
SUBJECTS,
|
|
223
|
+
ErrRateLimited,
|
|
224
|
+
// …sentinels + every state/interaction/content/PCI type
|
|
225
|
+
} from "@superbuilders/primer-tives/client"
|
|
305
226
|
```
|
|
306
227
|
|
|
307
|
-
`
|
|
228
|
+
## `create(config)`
|
|
308
229
|
|
|
309
230
|
```ts
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
type ReviewScalarValue =
|
|
327
|
-
| { kind: "identifier"; value: string }
|
|
328
|
-
| { kind: "string"; value: string }
|
|
329
|
-
| { kind: "integer"; value: number }
|
|
330
|
-
| { kind: "float"; value: number }
|
|
331
|
-
| { kind: "pair"; source: string; target: string }
|
|
231
|
+
function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
|
|
232
|
+
|
|
233
|
+
interface Config<Pcis extends PciId = PciId> {
|
|
234
|
+
readonly accessToken: string // JWS from /server
|
|
235
|
+
readonly supportedPcis: readonly Pcis[] // PCI URNs this renderer handles ([] if none)
|
|
236
|
+
readonly origin: string // same origin you gave /server
|
|
237
|
+
readonly subject: SubjectScope // "math" | "vocabulary" | "science" | "all"
|
|
238
|
+
readonly fetch?: typeof globalThis.fetch
|
|
239
|
+
readonly abort?: AbortController
|
|
240
|
+
readonly logger?: PrimerLogger
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
interface Client<Pcis extends PciId = PciId> {
|
|
244
|
+
start(): Promise<PrimerState<Pcis>> // idempotent
|
|
245
|
+
}
|
|
332
246
|
```
|
|
333
247
|
|
|
334
|
-
|
|
248
|
+
`create()` does a cheap structural check on the token (must start with `eyJ` and contain two dots) and throws `ErrMalformedAccessToken` if the shape is wrong. Signature verification happens on the server.
|
|
335
249
|
|
|
336
|
-
|
|
337
|
-
- order review preserves ordering directly.
|
|
338
|
-
- match review preserves directional `{ source, target }` pairs directly.
|
|
339
|
-
- PCI review uses record fields and can carry `pair` values without flattening them into strings.
|
|
340
|
-
- `feedback.advance()` uses the same observation intent as `observation.advance()`.
|
|
250
|
+
### `subject` scope
|
|
341
251
|
|
|
342
|
-
|
|
252
|
+
| Value | Behavior |
|
|
253
|
+
|---|---|
|
|
254
|
+
| `"math"` / `"vocabulary"` / `"science"` | Restrict drill selection to courses of that subject |
|
|
255
|
+
| `"all"` | No filter; drills from any subject the frontend is bound to |
|
|
343
256
|
|
|
344
|
-
|
|
257
|
+
Per-session. To switch subjects, construct a new client. Student placements are isolated per subject — a student with an in-progress math placement resumes it on a math-scoped reconnect, a new vocabulary-scoped client bootstraps a fresh vocabulary placement, and neither disturbs the other. Curriculum routing is not affected by `subject`.
|
|
345
258
|
|
|
346
|
-
|
|
259
|
+
## `PrimerState` — the state machine
|
|
347
260
|
|
|
348
|
-
|
|
261
|
+
`start()` returns a `PrimerState` union. Each phase has its own shape and action methods.
|
|
349
262
|
|
|
350
263
|
```ts
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
264
|
+
type PrimerState =
|
|
265
|
+
| ObservationState // advance()
|
|
266
|
+
| InteractionState // submit…() / timeout()
|
|
267
|
+
| FeedbackState // advance()
|
|
268
|
+
| CompletedState // terminal
|
|
269
|
+
| ErroredState // retry() — retriable:boolean
|
|
270
|
+
| FatalState // terminal
|
|
355
271
|
```
|
|
356
272
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
- switch on sentinel identity with `errors.is(...)`
|
|
360
|
-
- check `state.retriable` before calling `retry()`
|
|
361
|
-
- non-retriable errored states keep the original sentinel but do not replay the failed action
|
|
362
|
-
|
|
363
|
-
### `fatal`
|
|
273
|
+
### `observation`
|
|
364
274
|
|
|
365
|
-
|
|
275
|
+
Server wants you to show a stimulus and the student to indicate they're done reading.
|
|
366
276
|
|
|
367
277
|
```ts
|
|
368
|
-
|
|
369
|
-
|
|
278
|
+
interface ObservationState {
|
|
279
|
+
readonly phase: "observation"
|
|
280
|
+
readonly stimulus: RendererStimulus | null
|
|
281
|
+
advance(): Promise<PrimerState>
|
|
282
|
+
}
|
|
370
283
|
```
|
|
371
284
|
|
|
372
|
-
|
|
285
|
+
### `interaction`
|
|
373
286
|
|
|
374
|
-
|
|
375
|
-
- invalid publishable key (`401`)
|
|
376
|
-
- forbidden origin (`403`)
|
|
377
|
-
- missing resource (`404`)
|
|
378
|
-
- unsupported PCI (`422`)
|
|
379
|
-
- a successful response that still contains a `portable-custom` frame whose `pciId` is not in `supportedPcis`
|
|
287
|
+
Server wants an answer. `InteractionState` is a discriminated union over `kind`:
|
|
380
288
|
|
|
381
|
-
|
|
289
|
+
| `kind` | method |
|
|
290
|
+
|---|---|
|
|
291
|
+
| `choice` | `submitChoice(selectedKeys: string[])` |
|
|
292
|
+
| `text-entry` | `submitText(value: string)` |
|
|
293
|
+
| `extended-text` (single) | `submitText(value: string)` |
|
|
294
|
+
| `extended-text` (multiple) | `submitTexts(values: string[])` |
|
|
295
|
+
| `order` | `submitOrder(orderedKeys: string[])` |
|
|
296
|
+
| `match` | `submitMatch(pairs: MatchPair[])` |
|
|
297
|
+
| `portable-custom` | `submit(value: PciValue<K>)` |
|
|
382
298
|
|
|
383
|
-
|
|
384
|
-
type PciSubmission<Pcis extends PciId = PciId> = {
|
|
385
|
-
[K in Pcis]: {
|
|
386
|
-
type: "portable-custom"
|
|
387
|
-
pciId: K
|
|
388
|
-
value: PciValue<K>
|
|
389
|
-
}
|
|
390
|
-
}[Pcis]
|
|
391
|
-
|
|
392
|
-
type RendererSubmission<Pcis extends PciId = PciId> =
|
|
393
|
-
| { type: "choice"; selectedKeys: string[] }
|
|
394
|
-
| { type: "text-entry"; value: string }
|
|
395
|
-
| { type: "extended-text"; values: string[] }
|
|
396
|
-
| { type: "order"; orderedKeys: string[] }
|
|
397
|
-
| { type: "match"; pairs: Array<{ source: string; target: string }> }
|
|
398
|
-
| PciSubmission<Pcis>
|
|
399
|
-
```
|
|
299
|
+
All six shapes also expose `timeout(): Promise<PrimerState>`.
|
|
400
300
|
|
|
401
|
-
|
|
301
|
+
### `feedback`
|
|
402
302
|
|
|
403
|
-
|
|
404
|
-
- match submissions are directional `{ source, target }` pairs
|
|
405
|
-
- portable custom submissions carry both `pciId` and a typed PCI `value`
|
|
303
|
+
Server has graded the submission and returned feedback content.
|
|
406
304
|
|
|
407
|
-
|
|
305
|
+
```ts
|
|
306
|
+
interface FeedbackState {
|
|
307
|
+
readonly phase: "feedback"
|
|
308
|
+
readonly stimulus: RendererStimulus | null
|
|
309
|
+
readonly interaction: RendererInteraction
|
|
310
|
+
readonly submission: RendererSubmission
|
|
311
|
+
readonly isCorrect: boolean
|
|
312
|
+
readonly feedbackContent: ContentInline[] // server-provided, localized feedback
|
|
313
|
+
readonly review: InteractionReview | null // correct answers etc., if available
|
|
314
|
+
advance(): Promise<PrimerState>
|
|
315
|
+
}
|
|
316
|
+
```
|
|
408
317
|
|
|
409
|
-
|
|
318
|
+
### `errored`
|
|
410
319
|
|
|
411
|
-
|
|
412
|
-
|---|---|
|
|
413
|
-
| `choice` | min selections, max selections, duplicate keys, unknown option identifiers |
|
|
414
|
-
| `text-entry` | none |
|
|
415
|
-
| `extended-text` + `single` | none |
|
|
416
|
-
| `extended-text` + `multiple` | `minStrings`, `maxStrings` |
|
|
417
|
-
| `order` | min selections, max selections, duplicate keys, unknown choice identifiers |
|
|
418
|
-
| `match` | `minAssociations`, `maxAssociations`, unknown source/target identifiers, and per-choice `matchMin` / `matchMax` caps on both sides |
|
|
419
|
-
| `portable-custom` | no outbound value validation |
|
|
320
|
+
Transport or validation failure that might be recoverable.
|
|
420
321
|
|
|
421
|
-
|
|
322
|
+
```ts
|
|
323
|
+
interface ErroredState {
|
|
324
|
+
readonly phase: "errored"
|
|
325
|
+
readonly error: Error // sentinel-wrapped
|
|
326
|
+
readonly retriable: boolean
|
|
327
|
+
retry(): Promise<PrimerState> // no-op on non-retriable
|
|
328
|
+
}
|
|
329
|
+
```
|
|
422
330
|
|
|
423
|
-
|
|
424
|
-
- `state.error` will match `ErrInvalidSubmission`
|
|
425
|
-
- `state.retriable` will be `false`
|
|
426
|
-
- the original interaction object is still usable for a corrected resubmission
|
|
427
|
-
- `retry()` returns the same errored state instead of replaying the invalid payload
|
|
331
|
+
### `fatal`
|
|
428
332
|
|
|
429
|
-
|
|
333
|
+
Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden).
|
|
430
334
|
|
|
431
335
|
```ts
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
ErrRateLimited,
|
|
437
|
-
ErrUnsupportedPci,
|
|
438
|
-
} from "@superbuilders/primer-tives"
|
|
439
|
-
|
|
440
|
-
if (errors.is(state.error, ErrNetwork)) {
|
|
441
|
-
// handle offline / DNS / CORS / fetch failure
|
|
336
|
+
interface FatalState {
|
|
337
|
+
readonly phase: "fatal"
|
|
338
|
+
readonly error: Error
|
|
339
|
+
readonly retriable: false
|
|
442
340
|
}
|
|
443
341
|
```
|
|
444
342
|
|
|
445
|
-
###
|
|
446
|
-
|
|
447
|
-
| Sentinel | Meaning | `state.retriable` |
|
|
448
|
-
|---|---|---|
|
|
449
|
-
| `ErrNetwork` | fetch failed before a response was received | `true` |
|
|
450
|
-
| `ErrTimeout` | request was aborted or timed out | `true` |
|
|
451
|
-
| `ErrRateLimited` | `429` | `true` |
|
|
452
|
-
| `ErrServiceUnavailable` | `502`, `503`, or `504` | `true` |
|
|
453
|
-
| `ErrServerError` | any other unhandled HTTP error status | `true` |
|
|
454
|
-
| `ErrJsonParse` | response body was not valid JSON | `true` |
|
|
455
|
-
| `ErrConflict` | `409`, or a local conflicting in-flight action | `true` |
|
|
456
|
-
| `ErrInvalidSubmission` | client-side validation failed | `false` |
|
|
457
|
-
|
|
458
|
-
### Permanent: surfaced as `fatal`
|
|
459
|
-
|
|
460
|
-
| Sentinel | Meaning |
|
|
461
|
-
|---|---|
|
|
462
|
-
| `ErrBadRequest` | `400` |
|
|
463
|
-
| `ErrInvalidPublishableKey` | `401` |
|
|
464
|
-
| `ErrForbidden` | `403` |
|
|
465
|
-
| `ErrNotFound` | `404` |
|
|
466
|
-
| `ErrUnsupportedPci` | server rejected the request with `422`, or the server returned a PCI frame the client did not advertise in `supportedPcis` |
|
|
343
|
+
### `completed`
|
|
467
344
|
|
|
468
|
-
|
|
345
|
+
Terminal. Session is done.
|
|
469
346
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
347
|
+
```ts
|
|
348
|
+
interface CompletedState {
|
|
349
|
+
readonly phase: "completed"
|
|
350
|
+
}
|
|
351
|
+
```
|
|
474
352
|
|
|
475
353
|
## Content format
|
|
476
354
|
|
|
@@ -486,172 +364,107 @@ type ContentInline =
|
|
|
486
364
|
type ContentBlock = { type: "paragraph"; children: ContentInline[] }
|
|
487
365
|
```
|
|
488
366
|
|
|
489
|
-
|
|
367
|
+
All three inline variants share the uniform `{ type, value: string }` shape. `ContentSpan` covers HTML-ish rich-text formatting. `latex` is for inline math expressions (render via Temml).
|
|
368
|
+
|
|
369
|
+
Helpers:
|
|
490
370
|
|
|
491
371
|
```ts
|
|
492
372
|
inlinesToPlainText(nodes: ContentInline[]): string
|
|
493
373
|
blocksToPlainText(blocks: ContentBlock[]): string
|
|
494
374
|
```
|
|
495
375
|
|
|
496
|
-
|
|
497
|
-
- `blocksToPlainText()` flattens block content with newlines between paragraphs
|
|
498
|
-
|
|
499
|
-
Typical use cases:
|
|
500
|
-
|
|
501
|
-
- accessibility labels
|
|
502
|
-
- plain-text fallbacks
|
|
503
|
-
- analytics or logging output that should ignore inline formatting
|
|
504
|
-
|
|
505
|
-
## Stimulus model
|
|
376
|
+
## PCI system (Portable Custom Interactions)
|
|
506
377
|
|
|
507
378
|
```ts
|
|
508
|
-
|
|
509
|
-
type: "body"
|
|
510
|
-
body: ContentBlock[]
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
interface ImageStimulus {
|
|
514
|
-
type: "image"
|
|
515
|
-
description: ContentInline[]
|
|
516
|
-
src: string
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
type RendererStimulus = BodyStimulus | ImageStimulus
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
A frame can also have `stimulus: null`.
|
|
523
|
-
|
|
524
|
-
## PCI system
|
|
525
|
-
|
|
526
|
-
Portable Custom Interactions let Primer serve domain-specific input types while preserving compile-time type safety.
|
|
527
|
-
|
|
528
|
-
### Registry
|
|
379
|
+
type PciUrn = "urn:primer:pci:division-remainder" | "urn:primer:pci:fraction-addition"
|
|
529
380
|
|
|
530
|
-
|
|
531
|
-
interface PciRegistry {
|
|
381
|
+
type PciRegistry = {
|
|
532
382
|
"urn:primer:pci:division-remainder": {
|
|
533
|
-
props:
|
|
534
|
-
value:
|
|
383
|
+
props: DivisionRemainderProps
|
|
384
|
+
value: DivisionRemainderSubmission
|
|
535
385
|
}
|
|
536
386
|
"urn:primer:pci:fraction-addition": {
|
|
537
|
-
props:
|
|
538
|
-
|
|
539
|
-
right: { numerator: number; denominator: number }
|
|
540
|
-
}
|
|
541
|
-
value: { numerator: string; denominator: string }
|
|
387
|
+
props: FractionAdditionProps
|
|
388
|
+
value: FractionAdditionSubmission
|
|
542
389
|
}
|
|
543
390
|
}
|
|
544
|
-
```
|
|
545
|
-
|
|
546
|
-
### Type helpers
|
|
547
391
|
|
|
548
|
-
|
|
549
|
-
type PciUrn = `urn:primer:pci:${string}`
|
|
550
|
-
type PciId = keyof PciRegistry & string
|
|
392
|
+
type PciId = keyof PciRegistry
|
|
551
393
|
type PciProps<K extends PciId> = PciRegistry[K]["props"]
|
|
552
394
|
type PciValue<K extends PciId> = PciRegistry[K]["value"]
|
|
553
395
|
```
|
|
554
396
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
```ts
|
|
558
|
-
type PciRenderProps<K extends PciId> =
|
|
559
|
-
| {
|
|
560
|
-
mode: "pending"
|
|
561
|
-
properties: PciProps<K>
|
|
562
|
-
onValueChange: (value: PciValue<K> | null) => void
|
|
563
|
-
}
|
|
564
|
-
| {
|
|
565
|
-
mode: "submitted"
|
|
566
|
-
properties: PciProps<K>
|
|
567
|
-
submission: PciValue<K>
|
|
568
|
-
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
569
|
-
}
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
### Adding a PCI
|
|
573
|
-
|
|
574
|
-
1. Add a new entry to `PciRegistry` in `src/pci.ts`
|
|
575
|
-
2. Use the URN format `urn:primer:pci:<name>`
|
|
576
|
-
3. Add that PCI URN to `supportedPcis` when creating the client
|
|
577
|
-
4. Make sure your renderer knows how to render and collect a `PciValue` for that PCI
|
|
578
|
-
|
|
579
|
-
## Import guide
|
|
397
|
+
Declare the PCI URNs your renderer can handle via `Config.supportedPcis`. Frames requiring an unsupported PCI resolve to `fatal` with `ErrUnsupportedPci`.
|
|
580
398
|
|
|
581
|
-
|
|
399
|
+
### Renderer props
|
|
582
400
|
|
|
583
401
|
```ts
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
ErrNetwork,
|
|
590
|
-
ErrTimeout,
|
|
591
|
-
ErrUnsupportedPci,
|
|
592
|
-
blocksToPlainText,
|
|
593
|
-
create,
|
|
594
|
-
inlinesToPlainText,
|
|
595
|
-
} from "@superbuilders/primer-tives"
|
|
596
|
-
|
|
597
|
-
import type {
|
|
598
|
-
ChoiceState,
|
|
599
|
-
Client,
|
|
600
|
-
Config,
|
|
601
|
-
ContentBlock,
|
|
602
|
-
ContentInline,
|
|
603
|
-
FeedbackState,
|
|
604
|
-
MatchState,
|
|
605
|
-
ObservationState,
|
|
606
|
-
OrderState,
|
|
607
|
-
PciId,
|
|
608
|
-
PciProps,
|
|
609
|
-
PciRenderProps,
|
|
610
|
-
PciValue,
|
|
611
|
-
PrimerLogger,
|
|
612
|
-
PrimerState,
|
|
613
|
-
RendererInteraction,
|
|
614
|
-
RendererStimulus,
|
|
615
|
-
RendererSubmission,
|
|
616
|
-
} from "@superbuilders/primer-tives"
|
|
617
|
-
```
|
|
618
|
-
|
|
619
|
-
## Behavioral notes
|
|
620
|
-
|
|
621
|
-
### States are not serializable
|
|
622
|
-
|
|
623
|
-
`PrimerState` is live in-memory state that contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`.
|
|
402
|
+
type PciPendingRenderProps<K extends PciId> = {
|
|
403
|
+
mode: "pending"
|
|
404
|
+
properties: PciProps<K>
|
|
405
|
+
onValueChange: (value: PciValue<K> | null) => void
|
|
406
|
+
}
|
|
624
407
|
|
|
625
|
-
|
|
408
|
+
type PciSubmittedRenderProps<K extends PciId> = {
|
|
409
|
+
mode: "submitted"
|
|
410
|
+
properties: PciProps<K>
|
|
411
|
+
submission: PciValue<K>
|
|
412
|
+
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
413
|
+
}
|
|
626
414
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
- send a state object over the network
|
|
630
|
-
- treat a state object as a durable snapshot
|
|
415
|
+
type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
|
|
416
|
+
```
|
|
631
417
|
|
|
632
|
-
|
|
418
|
+
## Error sentinels (`/client`)
|
|
633
419
|
|
|
634
|
-
###
|
|
420
|
+
### Surfaced as `errored`
|
|
635
421
|
|
|
636
|
-
|
|
422
|
+
| Sentinel | Raised when |
|
|
423
|
+
|---|---|
|
|
424
|
+
| `ErrNetwork` | fetch() rejected |
|
|
425
|
+
| `ErrTimeout` | fetch() aborted |
|
|
426
|
+
| `ErrServerError` | HTTP 5xx |
|
|
427
|
+
| `ErrServiceUnavailable` | HTTP 502/503/504 |
|
|
428
|
+
| `ErrRateLimited` | HTTP 429 |
|
|
429
|
+
| `ErrConflict` | HTTP 409 |
|
|
430
|
+
| `ErrJsonParse` | Success response body wasn't valid JSON |
|
|
431
|
+
| `ErrInvalidSubmission` | client-side validation rejected the submission |
|
|
432
|
+
|
|
433
|
+
### Surfaced as `fatal`
|
|
434
|
+
|
|
435
|
+
| Sentinel | Raised when |
|
|
436
|
+
|---|---|
|
|
437
|
+
| `ErrBadRequest` | HTTP 400 |
|
|
438
|
+
| `ErrInvalidAccessToken` | HTTP 401 |
|
|
439
|
+
| `ErrTokenExpired` | HTTP 401 with token-expired detail |
|
|
440
|
+
| `ErrForbidden` | HTTP 403 |
|
|
441
|
+
| `ErrNotFound` | HTTP 404 |
|
|
442
|
+
| `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
|
|
637
443
|
|
|
638
|
-
|
|
639
|
-
- calling the same submit method twice with the same payload returns the same promise
|
|
640
|
-
- calling `retry()` twice on the same errored state returns the same promise
|
|
641
|
-
- submit and timeout do **not** alias to each other anymore
|
|
642
|
-
- conflicting in-flight actions surface `ErrConflict` instead of silently reusing the wrong promise
|
|
444
|
+
### Thrown directly by `create()`
|
|
643
445
|
|
|
644
|
-
|
|
446
|
+
| Sentinel | Raised when |
|
|
447
|
+
|---|---|
|
|
448
|
+
| `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
|
|
449
|
+
| `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
|
|
645
450
|
|
|
646
|
-
|
|
451
|
+
## Logger interface
|
|
647
452
|
|
|
648
|
-
|
|
453
|
+
```ts
|
|
454
|
+
interface PrimerLogger {
|
|
455
|
+
debug(message: string, attributes?: Record<string, unknown>): void
|
|
456
|
+
info(message: string, attributes?: Record<string, unknown>): void
|
|
457
|
+
warn(message: string, attributes?: Record<string, unknown>): void
|
|
458
|
+
error(message: string, attributes?: Record<string, unknown>): void
|
|
459
|
+
}
|
|
460
|
+
```
|
|
649
461
|
|
|
650
|
-
|
|
462
|
+
Same shape on both sides. Plug in your slog/pino/console adapter.
|
|
651
463
|
|
|
652
|
-
|
|
464
|
+
## Behavioral notes
|
|
653
465
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
466
|
+
- **`start()` is idempotent.** Calling it twice returns the same promise.
|
|
467
|
+
- **Retry semantics.** `errored.retry()` re-runs the same intent. `errored.retriable === false` for client-validation errors (e.g. `ErrInvalidSubmission`) — those must be fixed, not retried.
|
|
468
|
+
- **Session resumption.** A returning student resumes wherever the server last placed them. No client-side cursor management.
|
|
469
|
+
- **Live state.** `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
|
|
470
|
+
- **PCI type safety.** `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState`/`PciSubmission` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
|