@superbuilders/primer-tives 2.2.1 → 3.5.1
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 +813 -446
- package/dist/client/auth/access-token.d.ts +10 -0
- package/dist/client/auth/access-token.d.ts.map +1 -0
- package/dist/client/auth/browser.d.ts +20 -0
- package/dist/client/auth/browser.d.ts.map +1 -0
- package/dist/client/auth/callback.d.ts +10 -0
- package/dist/client/auth/callback.d.ts.map +1 -0
- package/dist/client/auth/hosted-popup.d.ts +14 -0
- package/dist/client/auth/hosted-popup.d.ts.map +1 -0
- package/dist/client/auth/provider.d.ts +14 -0
- package/dist/client/auth/provider.d.ts.map +1 -0
- package/dist/client/auth/storage.d.ts +9 -0
- package/dist/client/auth/storage.d.ts.map +1 -0
- package/dist/client/create.d.ts +22 -25
- package/dist/client/create.d.ts.map +1 -1
- package/dist/client/create.type-test.d.ts +2 -0
- package/dist/client/create.type-test.d.ts.map +1 -0
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +336 -74
- package/dist/client/index.js.map +14 -10
- package/dist/client/runtime-subject.d.ts +4 -0
- package/dist/client/runtime-subject.d.ts.map +1 -0
- package/dist/client/session.d.ts +2 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/transport.d.ts +6 -4
- package/dist/client/transport.d.ts.map +1 -1
- package/dist/errors.d.ts +7 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +14 -6
- package/dist/errors.js.map +3 -3
- package/dist/subject-pcis.d.ts +11 -5
- package/dist/subject-pcis.d.ts.map +1 -1
- package/dist/subject-pcis.js +39 -0
- package/dist/subject-pcis.js.map +11 -0
- package/dist/subject.d.ts +1 -2
- package/dist/subject.d.ts.map +1 -1
- package/dist/subject.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +2 -6
- package/dist/server/create-server.d.ts +0 -17
- package/dist/server/create-server.d.ts.map +0 -1
- package/dist/server/exchange.d.ts +0 -16
- package/dist/server/exchange.d.ts.map +0 -1
- package/dist/server/index.d.ts +0 -3
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/index.js +0 -173
- package/dist/server/index.js.map +0 -12
package/README.md
CHANGED
|
@@ -2,378 +2,346 @@
|
|
|
2
2
|
|
|
3
3
|
TypeScript SDK primitives for the Primer adaptive learning runtime.
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The public lifecycle is one async call:
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
```txt
|
|
8
|
+
create(options) -> Promise<PrimerState>
|
|
9
|
+
```
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
`create` resolves learner auth, opens the first Primer learning state, and returns the live state machine object your renderer drives.
|
|
11
12
|
|
|
12
13
|
```sh
|
|
13
14
|
bun add @superbuilders/primer-tives
|
|
14
15
|
```
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
## Package Version
|
|
19
|
-
|
|
20
|
-
The current SDK version is `2.2.1`.
|
|
17
|
+
## Version
|
|
21
18
|
|
|
22
|
-
The
|
|
19
|
+
The current SDK version is `3.5.1`.
|
|
23
20
|
|
|
24
21
|
## Entrypoints
|
|
25
22
|
|
|
26
|
-
There is no package-root export.
|
|
27
|
-
|
|
28
|
-
| Subpath | Runtime | Owns |
|
|
29
|
-
| --- | --- | --- |
|
|
30
|
-
| `@superbuilders/primer-tives/server` | Backend only | `createPrimerServer`, `PrimerServer`, `PrimerServerConfig`, `GetTokenInput` |
|
|
31
|
-
| `@superbuilders/primer-tives/client` | Browser | `create`, `Client`, `Config`, `PrimerState`, every state interface, PCI render props |
|
|
32
|
-
| `@superbuilders/primer-tives/contracts` | Shared | Content, stimulus, interaction, submission, review, PCI types, Zod schemas, validation helpers |
|
|
33
|
-
| `@superbuilders/primer-tives/errors` | Shared | Every SDK error sentinel |
|
|
34
|
-
| `@superbuilders/primer-tives/logger` | Shared | `PrimerLogger` interface |
|
|
35
|
-
| `@superbuilders/primer-tives/subject` | Shared | `Subject`, `SubjectScope`, `SUBJECTS` |
|
|
36
|
-
| `@superbuilders/primer-tives/subject-pcis` | Shared | Required PCI helpers for subject scopes |
|
|
37
|
-
| `@superbuilders/primer-tives/grade-level` | Shared | `GradeLevel`, `GRADE_LEVELS` |
|
|
38
|
-
|
|
39
|
-
## Architecture
|
|
23
|
+
There is no package-root export. Import from the public subpath that owns the surface you need.
|
|
40
24
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
The backend token flow is:
|
|
52
|
-
|
|
53
|
-
1. Your app authenticates a user.
|
|
54
|
-
2. Your app verifies the user's email.
|
|
55
|
-
3. Your backend calls `primer.getToken({ verifiedEmail: user.email })` with a Primer `sk_...` secret key.
|
|
56
|
-
4. Primer normalizes and hashes the email server-side.
|
|
57
|
-
5. Primer resolves or creates the internal frontend-scoped student.
|
|
58
|
-
6. Primer returns a short-lived browser access token.
|
|
59
|
-
7. Your browser UI passes that token to `create({ accessToken, ... })`.
|
|
25
|
+
| Subpath | Owns |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `@superbuilders/primer-tives/client` | `create`, `PrimerOptions`, `PrimerState`, all state interfaces, PCI render props |
|
|
28
|
+
| `@superbuilders/primer-tives/contracts` | Content, stimulus, interaction, submission, review, PCI types, schemas, validation helpers |
|
|
29
|
+
| `@superbuilders/primer-tives/errors` | Every SDK error sentinel |
|
|
30
|
+
| `@superbuilders/primer-tives/logger` | `PrimerLogger` interface |
|
|
31
|
+
| `@superbuilders/primer-tives/subject` | `Subject`, `SUBJECTS` |
|
|
32
|
+
| `@superbuilders/primer-tives/subject-pcis` | Subject-required PCI helpers and type helpers |
|
|
33
|
+
| `@superbuilders/primer-tives/grade-level` | `GradeLevel`, `GRADE_LEVELS` |
|
|
60
34
|
|
|
61
|
-
|
|
35
|
+
## Quick Start
|
|
62
36
|
|
|
63
|
-
|
|
37
|
+
Math content can require the fraction-input PCI capability, so a math renderer must declare it.
|
|
64
38
|
|
|
65
39
|
```ts
|
|
66
|
-
import * as errors from "@superbuilders/errors"
|
|
67
40
|
import * as logger from "@superbuilders/slog"
|
|
68
|
-
import {
|
|
69
|
-
import { ErrBadRequest, ErrInvalidSecretKey, ErrNetwork, ErrTimeout } from "@superbuilders/primer-tives/errors"
|
|
41
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
70
42
|
|
|
71
|
-
|
|
43
|
+
let state = await create({
|
|
72
44
|
origin: "https://primerlearn.dev",
|
|
73
|
-
|
|
45
|
+
publishableKey: "pk_...",
|
|
46
|
+
subject: "math",
|
|
47
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
74
48
|
logger
|
|
75
49
|
})
|
|
76
|
-
|
|
77
|
-
async function getPrimerAccessToken(user: { email: string; emailVerified: boolean }): Promise<string> {
|
|
78
|
-
if (!user.emailVerified) {
|
|
79
|
-
logger.error("user email not verified")
|
|
80
|
-
throw errors.new("email not verified")
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const result = await errors.try(primer.getToken({ verifiedEmail: user.email }))
|
|
84
|
-
if (result.error) {
|
|
85
|
-
if (errors.is(result.error, ErrInvalidSecretKey)) {
|
|
86
|
-
logger.error("primer secret key invalid", { error: result.error })
|
|
87
|
-
throw errors.wrap(result.error, "primer token")
|
|
88
|
-
}
|
|
89
|
-
if (errors.is(result.error, ErrBadRequest)) {
|
|
90
|
-
logger.error("primer token request rejected", { error: result.error })
|
|
91
|
-
throw errors.wrap(result.error, "primer token")
|
|
92
|
-
}
|
|
93
|
-
if (errors.is(result.error, ErrNetwork) || errors.is(result.error, ErrTimeout)) {
|
|
94
|
-
logger.error("primer token transport failed", { error: result.error })
|
|
95
|
-
throw errors.wrap(result.error, "primer token")
|
|
96
|
-
}
|
|
97
|
-
logger.error("primer token failed", { error: result.error })
|
|
98
|
-
throw errors.wrap(result.error, "primer token")
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return result.data
|
|
102
|
-
}
|
|
103
50
|
```
|
|
104
51
|
|
|
105
|
-
|
|
52
|
+
If your application already has a learner access token, pass it directly. The SDK will use that token and skip SDK-managed auth.
|
|
106
53
|
|
|
107
54
|
```ts
|
|
108
|
-
|
|
109
|
-
import { create, type PrimerState } from "@superbuilders/primer-tives/client"
|
|
110
|
-
|
|
111
|
-
const client = create({
|
|
55
|
+
let state = await create({
|
|
112
56
|
origin: "https://primerlearn.dev",
|
|
57
|
+
publishableKey: "pk_...",
|
|
113
58
|
accessToken,
|
|
114
59
|
subject: "math",
|
|
115
60
|
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
116
61
|
logger
|
|
117
62
|
})
|
|
63
|
+
```
|
|
118
64
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
122
|
-
switch (state.phase) {
|
|
123
|
-
case "observation": {
|
|
124
|
-
renderFrame(state.body, state.stimulus)
|
|
125
|
-
state = await state.advance()
|
|
126
|
-
break
|
|
127
|
-
}
|
|
128
|
-
case "interaction": {
|
|
129
|
-
state = await renderAndSubmitInteraction(state)
|
|
130
|
-
break
|
|
131
|
-
}
|
|
132
|
-
case "feedback": {
|
|
133
|
-
renderFeedback(state.feedbackContent, state.isCorrect, state.review)
|
|
134
|
-
state = await state.advance()
|
|
135
|
-
break
|
|
136
|
-
}
|
|
137
|
-
case "errored": {
|
|
138
|
-
if (state.retriable) {
|
|
139
|
-
state = await state.retry()
|
|
140
|
-
break
|
|
141
|
-
}
|
|
142
|
-
throw state.error
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
65
|
+
Vocabulary and science currently have no required PCI capabilities, so `supportedPcis` can be omitted for those subjects.
|
|
146
66
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
67
|
+
```ts
|
|
68
|
+
let state = await create({
|
|
69
|
+
origin: "https://primerlearn.dev",
|
|
70
|
+
publishableKey: "pk_...",
|
|
71
|
+
subject: "vocabulary",
|
|
72
|
+
logger
|
|
73
|
+
})
|
|
150
74
|
```
|
|
151
75
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
The server subpath is backend-only. It wraps the token endpoint and hides raw HTTP response details.
|
|
76
|
+
Omitting `subject` means the SDK asks Primer for the all-subject runtime scope. Because that scope can include math, it requires the union of all subject-required PCIs.
|
|
155
77
|
|
|
156
78
|
```ts
|
|
157
|
-
|
|
158
|
-
|
|
79
|
+
let state = await create({
|
|
80
|
+
origin: "https://primerlearn.dev",
|
|
81
|
+
publishableKey: "pk_...",
|
|
82
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
83
|
+
logger
|
|
84
|
+
})
|
|
159
85
|
```
|
|
160
86
|
|
|
161
|
-
## `
|
|
87
|
+
## `create(options)`
|
|
162
88
|
|
|
163
89
|
```ts
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
readonly
|
|
167
|
-
|
|
168
|
-
readonly abort?: AbortController
|
|
169
|
-
readonly logger: PrimerLogger
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
90
|
+
function create<
|
|
91
|
+
const S extends Subject | undefined = undefined,
|
|
92
|
+
const Supported extends readonly PciId[] = []
|
|
93
|
+
>(options: PrimerOptions<S, Supported>): Promise<PrimerState>
|
|
173
94
|
```
|
|
174
95
|
|
|
175
|
-
|
|
96
|
+
`create` can fail in two different ways:
|
|
176
97
|
|
|
177
|
-
|
|
|
178
|
-
| --- | --- |
|
|
179
|
-
|
|
|
180
|
-
|
|
|
181
|
-
| `fetch` | No | Custom fetch for tests, proxies, tracing, or platform adapters. Defaults to `globalThis.fetch`. |
|
|
182
|
-
| `abort` | No | `AbortController`; its signal is passed to every server SDK request. |
|
|
183
|
-
| `logger` | Yes | Structured logger implementing `debug`, `info`, `warn`, `error`. |
|
|
98
|
+
| Moment | Behavior |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| Auth or local token resolution fails | `create` rejects with an SDK sentinel error. |
|
|
101
|
+
| The learning runtime cannot produce a normal first state | `create` resolves to `ErroredState` or `FatalState`. |
|
|
184
102
|
|
|
185
|
-
|
|
103
|
+
This distinction matters. Use `errors.try(create(...))` for startup failures, then handle `state.phase` for learning-state failures.
|
|
186
104
|
|
|
187
105
|
```ts
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
106
|
+
import * as errors from "@superbuilders/errors"
|
|
107
|
+
import * as logger from "@superbuilders/slog"
|
|
108
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
109
|
+
import { ErrAuthPopupBlocked, ErrMalformedAccessToken } from "@superbuilders/primer-tives/errors"
|
|
110
|
+
|
|
111
|
+
const result = await errors.try(
|
|
112
|
+
create({
|
|
113
|
+
origin,
|
|
114
|
+
publishableKey,
|
|
115
|
+
accessToken,
|
|
116
|
+
subject: "math",
|
|
117
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
118
|
+
logger
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
if (result.error) {
|
|
122
|
+
if (errors.is(result.error, ErrAuthPopupBlocked)) {
|
|
123
|
+
renderStartButtonAgain()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (errors.is(result.error, ErrMalformedAccessToken)) {
|
|
127
|
+
renderSignInAgain()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
logger.error("primer create failed", { error: result.error })
|
|
131
|
+
throw result.error
|
|
194
132
|
}
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
`getToken` returns the browser access token string directly. It does not return a session object because the browser only needs the token.
|
|
198
|
-
|
|
199
|
-
The SDK sends this wire body:
|
|
200
133
|
|
|
201
|
-
|
|
202
|
-
{
|
|
203
|
-
"verified_email": "student@example.com"
|
|
204
|
-
}
|
|
134
|
+
let state = result.data
|
|
205
135
|
```
|
|
206
136
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
## `verifiedEmail`
|
|
210
|
-
|
|
211
|
-
`verifiedEmail` must be server-trusted. The SDK does not validate that the user owns this address. It only validates that Primer accepts the request shape.
|
|
212
|
-
|
|
213
|
-
Correct usage:
|
|
137
|
+
## `PrimerOptions`
|
|
214
138
|
|
|
215
139
|
```ts
|
|
216
|
-
|
|
140
|
+
type PrimerOptions<S extends Subject | undefined = undefined, Supported extends readonly PciId[] = []> = {
|
|
141
|
+
readonly origin: string
|
|
142
|
+
readonly publishableKey: string
|
|
143
|
+
readonly accessToken?: string
|
|
144
|
+
readonly subject?: S
|
|
145
|
+
readonly supportedPcis: subject-dependent
|
|
146
|
+
readonly fetch?: typeof globalThis.fetch
|
|
147
|
+
readonly abort?: AbortController
|
|
148
|
+
readonly logger: PrimerLogger
|
|
149
|
+
}
|
|
217
150
|
```
|
|
218
151
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
152
|
+
| Field | Required | Meaning |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| `origin` | Yes | Primer deployment origin. |
|
|
155
|
+
| `publishableKey` | Yes | Public key identifying the Primer frontend your runtime belongs to. |
|
|
156
|
+
| `accessToken` | No | Learner access token. When present, SDK-managed auth is skipped. |
|
|
157
|
+
| `subject` | No | Public content scope: `"math"`, `"vocabulary"`, or `"science"`. Omitted means all-subject scope. |
|
|
158
|
+
| `supportedPcis` | Subject-dependent | Renderer capabilities for Portable Custom Interactions. Required when the chosen scope can emit required PCIs. |
|
|
159
|
+
| `fetch` | No | Fetch override for tests, instrumentation, or host runtime integration. |
|
|
160
|
+
| `abort` | No | Abort controller for SDK runtime work. |
|
|
161
|
+
| `logger` | Yes | Structured logger implementing `debug`, `info`, `warn`, and `error`. |
|
|
222
162
|
|
|
223
|
-
|
|
224
|
-
const accessToken = await primer.getToken({ verifiedEmail: request.body.email })
|
|
225
|
-
```
|
|
163
|
+
The presence or absence of `accessToken` selects auth semantics.
|
|
226
164
|
|
|
227
|
-
|
|
165
|
+
| Shape | Semantics |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `accessToken` present | The SDK validates the token shape locally and uses it for learning runtime state. |
|
|
168
|
+
| `accessToken` absent | The SDK resolves a learner token through SDK-managed browser auth, then starts learning runtime state. |
|
|
228
169
|
|
|
229
|
-
|
|
170
|
+
The public API does not expose separate auth plumbing, auth result objects, or any auth lifecycle method. Auth is part of `create`.
|
|
230
171
|
|
|
231
|
-
|
|
172
|
+
## Auth Semantics
|
|
232
173
|
|
|
233
|
-
|
|
234
|
-
import { ErrInvalidSecretKey, ErrBadRequest } from "@superbuilders/primer-tives/errors"
|
|
235
|
-
```
|
|
174
|
+
An access token is expected to be JWS-shaped: it starts with `eyJ` and contains exactly two dots. If a provided or SDK-managed token does not match that shape, `create` rejects with `ErrMalformedAccessToken`.
|
|
236
175
|
|
|
237
|
-
|
|
176
|
+
SDK-managed auth may require browser capabilities and learner interaction. These failures reject `create`:
|
|
238
177
|
|
|
239
|
-
| Sentinel |
|
|
178
|
+
| Sentinel | Meaning |
|
|
240
179
|
| --- | --- |
|
|
241
|
-
| `
|
|
242
|
-
| `
|
|
243
|
-
| `
|
|
244
|
-
| `
|
|
245
|
-
| `
|
|
246
|
-
| `
|
|
180
|
+
| `ErrAuthUnavailable` | SDK-managed auth requires browser functionality that is unavailable in the current runtime. |
|
|
181
|
+
| `ErrAuthConfigInvalid` | SDK-managed auth was given invalid public configuration. |
|
|
182
|
+
| `ErrAuthCallbackInvalid` | The auth result could not be accepted as a successful learner auth result. |
|
|
183
|
+
| `ErrAuthStateMismatch` | The auth result did not match the auth attempt that initiated it. |
|
|
184
|
+
| `ErrAuthPopupBlocked` | The browser blocked the learner auth window. |
|
|
185
|
+
| `ErrAuthCancelled` | The learner auth interaction was closed or exceeded its allowed time. |
|
|
186
|
+
| `ErrMalformedAccessToken` | The resolved token was not shaped like a learner access token. |
|
|
247
187
|
|
|
248
|
-
|
|
188
|
+
Applications should handle user-actionable auth failures directly and log unexpected failures before propagating them.
|
|
249
189
|
|
|
250
190
|
```ts
|
|
251
191
|
import * as errors from "@superbuilders/errors"
|
|
252
192
|
import * as logger from "@superbuilders/slog"
|
|
253
|
-
import {
|
|
193
|
+
import {
|
|
194
|
+
ErrAuthCancelled,
|
|
195
|
+
ErrAuthPopupBlocked,
|
|
196
|
+
ErrAuthUnavailable
|
|
197
|
+
} from "@superbuilders/primer-tives/errors"
|
|
254
198
|
|
|
255
|
-
const result = await errors.try(
|
|
199
|
+
const result = await errors.try(create(options))
|
|
256
200
|
if (result.error) {
|
|
257
|
-
if (errors.is(result.error,
|
|
258
|
-
|
|
259
|
-
|
|
201
|
+
if (errors.is(result.error, ErrAuthPopupBlocked)) {
|
|
202
|
+
renderPopupInstructions()
|
|
203
|
+
return
|
|
260
204
|
}
|
|
261
|
-
if (errors.is(result.error,
|
|
262
|
-
|
|
263
|
-
|
|
205
|
+
if (errors.is(result.error, ErrAuthCancelled)) {
|
|
206
|
+
renderTryAgain()
|
|
207
|
+
return
|
|
264
208
|
}
|
|
265
|
-
|
|
266
|
-
|
|
209
|
+
if (errors.is(result.error, ErrAuthUnavailable)) {
|
|
210
|
+
renderUnsupportedBrowserMessage()
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
logger.error("primer auth failed", { error: result.error })
|
|
214
|
+
throw result.error
|
|
267
215
|
}
|
|
268
|
-
|
|
269
|
-
const accessToken = result.data
|
|
270
216
|
```
|
|
271
217
|
|
|
272
|
-
|
|
218
|
+
## Subject And PCI Contract
|
|
273
219
|
|
|
274
|
-
|
|
220
|
+
`subject` selects content scope and determines required renderer capabilities.
|
|
275
221
|
|
|
276
222
|
```ts
|
|
277
|
-
import {
|
|
278
|
-
import type {
|
|
223
|
+
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
|
|
224
|
+
import type { Subject } from "@superbuilders/primer-tives/subject"
|
|
225
|
+
|
|
226
|
+
const subjects = SUBJECTS
|
|
227
|
+
type RuntimeSubject = Subject
|
|
279
228
|
```
|
|
280
229
|
|
|
281
|
-
|
|
230
|
+
Current public subjects:
|
|
282
231
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
232
|
+
| Subject | Required PCI support |
|
|
233
|
+
| --- | --- |
|
|
234
|
+
| `"math"` | `"urn:primer:pci:fraction-input"` |
|
|
235
|
+
| `"vocabulary"` | none |
|
|
236
|
+
| `"science"` | none |
|
|
237
|
+
| omitted subject | union of all subject-required PCIs, currently `"urn:primer:pci:fraction-input"` |
|
|
288
238
|
|
|
289
|
-
|
|
239
|
+
The type-level rule is a subset check:
|
|
290
240
|
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
readonly accessToken: string
|
|
294
|
-
readonly subject: S
|
|
295
|
-
readonly origin: string
|
|
296
|
-
readonly fetch?: typeof globalThis.fetch
|
|
297
|
-
readonly abort?: AbortController
|
|
298
|
-
readonly logger: PrimerLogger
|
|
299
|
-
} & PciConfigForSubject<S, Pcis>
|
|
241
|
+
```txt
|
|
242
|
+
required PCIs for selected scope <= supportedPcis
|
|
300
243
|
```
|
|
301
244
|
|
|
302
|
-
|
|
245
|
+
Order does not matter. Extra supported PCIs are allowed. `supportedPcis` is a renderer capability declaration, not a content request.
|
|
303
246
|
|
|
304
|
-
|
|
305
|
-
| --- | --- | --- |
|
|
306
|
-
| `accessToken` | Yes | Token string returned by `primer.getToken`. |
|
|
307
|
-
| `subject` | Yes | `"math"`, `"vocabulary"`, `"science"`, or `"all"`. |
|
|
308
|
-
| `origin` | Yes | Primer deployment origin. |
|
|
309
|
-
| `supportedPcis` | Conditionally | Literal array of PCI URNs your renderer supports. Required when the chosen subject can emit required PCIs. |
|
|
310
|
-
| `fetch` | No | Custom fetch implementation. |
|
|
311
|
-
| `abort` | No | `AbortController`; signal passed to `/advance`. |
|
|
312
|
-
| `logger` | Yes | Structured logger. |
|
|
313
|
-
|
|
314
|
-
`create` performs local checks before any network request:
|
|
247
|
+
This fails at compile time because math can emit a required PCI:
|
|
315
248
|
|
|
316
|
-
|
|
317
|
-
|
|
249
|
+
```ts
|
|
250
|
+
await create({
|
|
251
|
+
origin,
|
|
252
|
+
publishableKey,
|
|
253
|
+
subject: "math",
|
|
254
|
+
logger
|
|
255
|
+
})
|
|
256
|
+
```
|
|
318
257
|
|
|
319
|
-
|
|
258
|
+
This passes:
|
|
320
259
|
|
|
321
260
|
```ts
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
261
|
+
await create({
|
|
262
|
+
origin,
|
|
263
|
+
publishableKey,
|
|
264
|
+
subject: "math",
|
|
265
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
266
|
+
logger
|
|
267
|
+
})
|
|
325
268
|
```
|
|
326
269
|
|
|
327
|
-
`
|
|
328
|
-
|
|
329
|
-
## Subject Scopes
|
|
270
|
+
If `subject` is a broad dynamic `Subject` value, TypeScript requires support for the union of all PCIs required by the possible subjects.
|
|
330
271
|
|
|
331
272
|
```ts
|
|
332
|
-
|
|
333
|
-
import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
|
|
273
|
+
declare const subject: Subject
|
|
334
274
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
275
|
+
await create({
|
|
276
|
+
origin,
|
|
277
|
+
publishableKey,
|
|
278
|
+
subject,
|
|
279
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
280
|
+
logger
|
|
281
|
+
})
|
|
338
282
|
```
|
|
339
283
|
|
|
340
|
-
|
|
341
|
-
| --- | --- |
|
|
342
|
-
| `"math"` | Runtime is scoped to math content and math-required PCIs. |
|
|
343
|
-
| `"vocabulary"` | Runtime is scoped to vocabulary content. |
|
|
344
|
-
| `"science"` | Runtime is scoped to science content. |
|
|
345
|
-
| `"all"` | Runtime can draw from all subjects available to the Primer frontend. |
|
|
346
|
-
|
|
347
|
-
To change subjects, create a new client. Placement state is server-owned and subject-scoped; the browser does not manage cursors.
|
|
284
|
+
The runtime also protects the trust boundary. If Primer presents a portable custom interaction that is not declared in `supportedPcis`, the SDK returns `FatalState` with `ErrUnsupportedPci`.
|
|
348
285
|
|
|
349
|
-
##
|
|
286
|
+
## Runtime Loop
|
|
350
287
|
|
|
351
|
-
|
|
288
|
+
Every renderer should switch on `state.phase`. Interaction rendering should then switch on `state.kind`.
|
|
352
289
|
|
|
353
290
|
```ts
|
|
354
|
-
import
|
|
291
|
+
import * as logger from "@superbuilders/slog"
|
|
292
|
+
import type { PrimerState } from "@superbuilders/primer-tives/client"
|
|
355
293
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
```
|
|
294
|
+
async function runPrimer(initialState: PrimerState): Promise<void> {
|
|
295
|
+
let state = initialState
|
|
359
296
|
|
|
360
|
-
|
|
297
|
+
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
298
|
+
switch (state.phase) {
|
|
299
|
+
case "observation":
|
|
300
|
+
renderFrame(state.body, state.stimulus)
|
|
301
|
+
state = await state.advance()
|
|
302
|
+
break
|
|
303
|
+
case "interaction":
|
|
304
|
+
state = await renderAndSubmitInteraction(state)
|
|
305
|
+
break
|
|
306
|
+
case "feedback":
|
|
307
|
+
renderFeedback(state.feedbackContent, state.isCorrect, state.review)
|
|
308
|
+
state = await state.advance()
|
|
309
|
+
break
|
|
310
|
+
case "errored":
|
|
311
|
+
if (state.retriable) {
|
|
312
|
+
state = await state.retry()
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
logger.error("primer state error", { error: state.error })
|
|
316
|
+
throw state.error
|
|
317
|
+
}
|
|
318
|
+
}
|
|
361
319
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
368
|
-
logger
|
|
369
|
-
})
|
|
320
|
+
if (state.phase === "fatal") {
|
|
321
|
+
logger.error("primer fatal state", { error: state.error })
|
|
322
|
+
throw state.error
|
|
323
|
+
}
|
|
324
|
+
}
|
|
370
325
|
```
|
|
371
326
|
|
|
372
|
-
|
|
327
|
+
State transitions:
|
|
373
328
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
`
|
|
329
|
+
| Current state | Valid operation | Next result |
|
|
330
|
+
| --- | --- | --- |
|
|
331
|
+
| `ObservationState` | `advance()` | `Promise<PrimerState>` |
|
|
332
|
+
| `ChoiceState` | `submitChoice(selectedKeys)` or `timeout()` | `Promise<PrimerState>` |
|
|
333
|
+
| `TextEntryState` | `submitText(value)` or `timeout()` | `Promise<PrimerState>` |
|
|
334
|
+
| `ExtendedTextSingleState` | `submitText(value)` or `timeout()` | `Promise<PrimerState>` |
|
|
335
|
+
| `ExtendedTextMultipleState` | `submitTexts(values)` or `timeout()` | `Promise<PrimerState>` |
|
|
336
|
+
| `OrderState` | `submitOrder(orderedKeys)` or `timeout()` | `Promise<PrimerState>` |
|
|
337
|
+
| `MatchState` | `submitMatch(pairs)` or `timeout()` | `Promise<PrimerState>` |
|
|
338
|
+
| `PciInteractionState` | `submit(value)` or `timeout()` | `Promise<PrimerState>` |
|
|
339
|
+
| `FeedbackState` | `advance()` | `Promise<PrimerState>` |
|
|
340
|
+
| `CompletedState` | none | terminal |
|
|
341
|
+
| `ErroredState` | `retry()` when `retriable` is `true` | `Promise<PrimerState>` |
|
|
342
|
+
| `FatalState` | none | terminal |
|
|
343
|
+
|
|
344
|
+
## `PrimerState`
|
|
377
345
|
|
|
378
346
|
```ts
|
|
379
347
|
type PrimerState<Pcis extends PciId = PciId> =
|
|
@@ -385,12 +353,27 @@ type PrimerState<Pcis extends PciId = PciId> =
|
|
|
385
353
|
| FatalState
|
|
386
354
|
```
|
|
387
355
|
|
|
388
|
-
|
|
356
|
+
`PrimerState` is live in-memory state. It contains transition closures, pending-operation guards, and retry behavior. Do not serialize it, store it, clone it through JSON, or pass it through host data. Calling `JSON.stringify(state)` throws `ErrNotSerializable`.
|
|
357
|
+
|
|
358
|
+
Recreate state by calling `create` again after a reload, remount, account switch, or subject switch.
|
|
359
|
+
|
|
360
|
+
## Common State Fields
|
|
361
|
+
|
|
362
|
+
Learning states that render content expose `body` and `stimulus`.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
interface RenderableState {
|
|
366
|
+
readonly body: ContentBlock[]
|
|
367
|
+
readonly stimulus: RendererStimulus | null
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
`body` is the main instructional content. `stimulus` is optional supporting material. Current stimulus support is image-only, but it is still a discriminated union so renderers can remain future-safe.
|
|
389
372
|
|
|
390
373
|
## `ObservationState`
|
|
391
374
|
|
|
392
375
|
```ts
|
|
393
|
-
interface ObservationState<Pcis extends PciId = PciId>
|
|
376
|
+
interface ObservationState<Pcis extends PciId = PciId> {
|
|
394
377
|
readonly phase: "observation"
|
|
395
378
|
readonly body: ContentBlock[]
|
|
396
379
|
readonly stimulus: RendererStimulus | null
|
|
@@ -398,56 +381,78 @@ interface ObservationState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
|
398
381
|
}
|
|
399
382
|
```
|
|
400
383
|
|
|
401
|
-
Render
|
|
384
|
+
Render the frame, then call `advance()` when the learner is ready to continue. Observation states have no answer to submit.
|
|
402
385
|
|
|
403
|
-
|
|
386
|
+
Repeated `advance()` calls while the first one is pending return the same pending result.
|
|
404
387
|
|
|
405
|
-
`InteractionState`
|
|
388
|
+
## `InteractionState`
|
|
406
389
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
|
410
|
-
|
|
|
411
|
-
|
|
|
412
|
-
|
|
|
413
|
-
|
|
|
414
|
-
|
|
|
415
|
-
|
|
390
|
+
```ts
|
|
391
|
+
type InteractionState<Pcis extends PciId = PciId> =
|
|
392
|
+
| ChoiceState<Pcis>
|
|
393
|
+
| TextEntryState<Pcis>
|
|
394
|
+
| ExtendedTextState<Pcis>
|
|
395
|
+
| OrderState<Pcis>
|
|
396
|
+
| MatchState<Pcis>
|
|
397
|
+
| PciInteractionState<Pcis>
|
|
398
|
+
```
|
|
416
399
|
|
|
417
400
|
Every interaction state includes:
|
|
418
401
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
-
|
|
423
|
-
|
|
424
|
-
|
|
402
|
+
| Field | Meaning |
|
|
403
|
+
| --- | --- |
|
|
404
|
+
| `phase: "interaction"` | State-machine discriminator. |
|
|
405
|
+
| `kind` | Renderer-facing interaction kind. |
|
|
406
|
+
| `body` | Frame content. |
|
|
407
|
+
| `stimulus` | Optional frame stimulus. |
|
|
408
|
+
| `interaction` | Full interaction contract object. |
|
|
409
|
+
| submit method | Kind-specific learner submission operation. |
|
|
410
|
+
| `timeout()` | Records that the learner timed out or the host chose to end the attempt without a submission. |
|
|
411
|
+
|
|
412
|
+
Submission methods validate standard interaction payloads before runtime submission. Invalid standard submissions return `ErroredState` with `ErrInvalidSubmission`.
|
|
425
413
|
|
|
426
|
-
|
|
414
|
+
Concurrent interaction operations are guarded:
|
|
427
415
|
|
|
428
|
-
|
|
416
|
+
| Situation | SDK behavior |
|
|
417
|
+
| --- | --- |
|
|
418
|
+
| Same submit payload while submit is pending | Returns the same pending result. |
|
|
419
|
+
| Different submit payload while submit is pending | Returns `ErroredState` with `ErrConflict`. |
|
|
420
|
+
| Submit while timeout is pending | Returns `ErroredState` with `ErrConflict`. |
|
|
421
|
+
| Timeout while submit is pending | Returns `ErroredState` with `ErrConflict`. |
|
|
422
|
+
| Repeated timeout while timeout is pending | Returns the same pending result. |
|
|
423
|
+
|
|
424
|
+
## `ChoiceState`
|
|
429
425
|
|
|
430
426
|
```ts
|
|
431
|
-
interface ChoiceState<Pcis extends PciId = PciId>
|
|
427
|
+
interface ChoiceState<Pcis extends PciId = PciId> {
|
|
432
428
|
readonly phase: "interaction"
|
|
433
429
|
readonly kind: "choice"
|
|
434
430
|
readonly body: ContentBlock[]
|
|
435
431
|
readonly stimulus: RendererStimulus | null
|
|
436
432
|
readonly interaction: Extract<StandardRendererInteraction, { type: "choice" }>
|
|
437
433
|
readonly options: RendererChoice[]
|
|
438
|
-
readonly maxChoices: number
|
|
439
434
|
readonly minChoices: number
|
|
435
|
+
readonly maxChoices: number
|
|
440
436
|
submitChoice(selectedKeys: string[]): Promise<PrimerState<Pcis>>
|
|
441
437
|
timeout(): Promise<PrimerState<Pcis>>
|
|
442
438
|
}
|
|
443
439
|
```
|
|
444
440
|
|
|
445
|
-
Use `minChoices` and `maxChoices` to decide whether
|
|
441
|
+
Use `minChoices` and `maxChoices` to decide whether the UI should submit immediately or require an explicit submit action.
|
|
442
|
+
|
|
443
|
+
Valid `submitChoice` payloads:
|
|
446
444
|
|
|
447
|
-
|
|
445
|
+
| Requirement | Error if violated |
|
|
446
|
+
| --- | --- |
|
|
447
|
+
| At least `minChoices` identifiers | `ErrInvalidSubmission` |
|
|
448
|
+
| At most `maxChoices` identifiers | `ErrInvalidSubmission` |
|
|
449
|
+
| Every identifier exists in `options` | `ErrInvalidSubmission` |
|
|
450
|
+
| No duplicate identifiers | `ErrInvalidSubmission` |
|
|
451
|
+
|
|
452
|
+
## `TextEntryState`
|
|
448
453
|
|
|
449
454
|
```ts
|
|
450
|
-
interface TextEntryState<Pcis extends PciId = PciId>
|
|
455
|
+
interface TextEntryState<Pcis extends PciId = PciId> {
|
|
451
456
|
readonly phase: "interaction"
|
|
452
457
|
readonly kind: "text-entry"
|
|
453
458
|
readonly body: ContentBlock[]
|
|
@@ -458,25 +463,37 @@ interface TextEntryState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
|
458
463
|
}
|
|
459
464
|
```
|
|
460
465
|
|
|
461
|
-
|
|
466
|
+
The interaction may include `expectedLength`, `patternMask`, and `placeholderText`. These are renderer hints. The SDK requires the submission to be a text-entry submission with a string value.
|
|
462
467
|
|
|
463
|
-
|
|
468
|
+
## `ExtendedTextState`
|
|
464
469
|
|
|
465
470
|
Extended text has two cardinalities.
|
|
466
471
|
|
|
467
472
|
```ts
|
|
468
|
-
interface ExtendedTextSingleState<Pcis extends PciId = PciId>
|
|
473
|
+
interface ExtendedTextSingleState<Pcis extends PciId = PciId> {
|
|
469
474
|
readonly phase: "interaction"
|
|
470
475
|
readonly kind: "extended-text"
|
|
471
476
|
readonly cardinality: "single"
|
|
477
|
+
readonly body: ContentBlock[]
|
|
478
|
+
readonly stimulus: RendererStimulus | null
|
|
479
|
+
readonly interaction: Extract<
|
|
480
|
+
StandardRendererInteraction,
|
|
481
|
+
{ type: "extended-text"; cardinality: "single" }
|
|
482
|
+
>
|
|
472
483
|
submitText(value: string): Promise<PrimerState<Pcis>>
|
|
473
484
|
timeout(): Promise<PrimerState<Pcis>>
|
|
474
485
|
}
|
|
475
486
|
|
|
476
|
-
interface ExtendedTextMultipleState<Pcis extends PciId = PciId>
|
|
487
|
+
interface ExtendedTextMultipleState<Pcis extends PciId = PciId> {
|
|
477
488
|
readonly phase: "interaction"
|
|
478
489
|
readonly kind: "extended-text"
|
|
479
490
|
readonly cardinality: "multiple"
|
|
491
|
+
readonly body: ContentBlock[]
|
|
492
|
+
readonly stimulus: RendererStimulus | null
|
|
493
|
+
readonly interaction: Extract<
|
|
494
|
+
StandardRendererInteraction,
|
|
495
|
+
{ type: "extended-text"; cardinality: "multiple" }
|
|
496
|
+
>
|
|
480
497
|
readonly minStrings: number
|
|
481
498
|
readonly maxStrings: number
|
|
482
499
|
submitTexts(values: string[]): Promise<PrimerState<Pcis>>
|
|
@@ -484,14 +501,27 @@ interface ExtendedTextMultipleState<Pcis extends PciId = PciId> extends NonSeria
|
|
|
484
501
|
}
|
|
485
502
|
```
|
|
486
503
|
|
|
487
|
-
|
|
504
|
+
For single-cardinality extended text, call `submitText(value)`. For multiple-cardinality extended text, call `submitTexts(values)`.
|
|
505
|
+
|
|
506
|
+
Valid multiple-cardinality payloads:
|
|
507
|
+
|
|
508
|
+
| Requirement | Error if violated |
|
|
509
|
+
| --- | --- |
|
|
510
|
+
| At least `minStrings` values | `ErrInvalidSubmission` |
|
|
511
|
+
| At most `maxStrings` values | `ErrInvalidSubmission` |
|
|
512
|
+
| No duplicate values | `ErrInvalidSubmission` |
|
|
488
513
|
|
|
489
|
-
|
|
514
|
+
`expectedLength`, `expectedLines`, `patternMask`, and `placeholderText` are renderer hints.
|
|
515
|
+
|
|
516
|
+
## `OrderState`
|
|
490
517
|
|
|
491
518
|
```ts
|
|
492
|
-
interface OrderState<Pcis extends PciId = PciId>
|
|
519
|
+
interface OrderState<Pcis extends PciId = PciId> {
|
|
493
520
|
readonly phase: "interaction"
|
|
494
521
|
readonly kind: "order"
|
|
522
|
+
readonly body: ContentBlock[]
|
|
523
|
+
readonly stimulus: RendererStimulus | null
|
|
524
|
+
readonly interaction: Extract<StandardRendererInteraction, { type: "order" }>
|
|
495
525
|
readonly choices: RendererChoice[]
|
|
496
526
|
readonly minChoices: number
|
|
497
527
|
readonly maxChoices: number
|
|
@@ -502,7 +532,16 @@ interface OrderState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
|
502
532
|
|
|
503
533
|
Submit identifiers in learner-selected order.
|
|
504
534
|
|
|
505
|
-
|
|
535
|
+
Valid `submitOrder` payloads:
|
|
536
|
+
|
|
537
|
+
| Requirement | Error if violated |
|
|
538
|
+
| --- | --- |
|
|
539
|
+
| At least `minChoices` identifiers | `ErrInvalidSubmission` |
|
|
540
|
+
| At most `maxChoices` identifiers | `ErrInvalidSubmission` |
|
|
541
|
+
| Every identifier exists in `choices` | `ErrInvalidSubmission` |
|
|
542
|
+
| No duplicate identifiers | `ErrInvalidSubmission` |
|
|
543
|
+
|
|
544
|
+
## `MatchState`
|
|
506
545
|
|
|
507
546
|
```ts
|
|
508
547
|
interface MatchPair {
|
|
@@ -510,9 +549,12 @@ interface MatchPair {
|
|
|
510
549
|
target: string
|
|
511
550
|
}
|
|
512
551
|
|
|
513
|
-
interface MatchState<Pcis extends PciId = PciId>
|
|
552
|
+
interface MatchState<Pcis extends PciId = PciId> {
|
|
514
553
|
readonly phase: "interaction"
|
|
515
554
|
readonly kind: "match"
|
|
555
|
+
readonly body: ContentBlock[]
|
|
556
|
+
readonly stimulus: RendererStimulus | null
|
|
557
|
+
readonly interaction: Extract<StandardRendererInteraction, { type: "match" }>
|
|
516
558
|
readonly sourceChoices: RendererMatchChoice[]
|
|
517
559
|
readonly targetChoices: RendererMatchChoice[]
|
|
518
560
|
readonly minAssociations: number
|
|
@@ -522,13 +564,29 @@ interface MatchState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
|
522
564
|
}
|
|
523
565
|
```
|
|
524
566
|
|
|
525
|
-
Each
|
|
567
|
+
Each pair connects one source identifier to one target identifier.
|
|
568
|
+
|
|
569
|
+
Valid `submitMatch` payloads:
|
|
570
|
+
|
|
571
|
+
| Requirement | Error if violated |
|
|
572
|
+
| --- | --- |
|
|
573
|
+
| At least `minAssociations` pairs | `ErrInvalidSubmission` |
|
|
574
|
+
| At most `maxAssociations` pairs | `ErrInvalidSubmission` |
|
|
575
|
+
| Every source identifier exists in `sourceChoices` | `ErrInvalidSubmission` |
|
|
576
|
+
| Every target identifier exists in `targetChoices` | `ErrInvalidSubmission` |
|
|
577
|
+
| No duplicate source-target pairs | `ErrInvalidSubmission` |
|
|
578
|
+
| Every choice respects its `matchMin` | `ErrInvalidSubmission` |
|
|
579
|
+
| Every choice respects its `matchMax` when `matchMax` is nonzero | `ErrInvalidSubmission` |
|
|
526
580
|
|
|
527
|
-
|
|
581
|
+
`matchMax: 0` means unbounded.
|
|
582
|
+
|
|
583
|
+
## `PciInteractionState`
|
|
584
|
+
|
|
585
|
+
Portable Custom Interaction state is typed by PCI id.
|
|
528
586
|
|
|
529
587
|
```ts
|
|
530
588
|
type PciInteractionState<Pcis extends PciId = PciId> = {
|
|
531
|
-
[K in Pcis]:
|
|
589
|
+
[K in Pcis]: {
|
|
532
590
|
readonly phase: "interaction"
|
|
533
591
|
readonly kind: "portable-custom"
|
|
534
592
|
readonly body: ContentBlock[]
|
|
@@ -542,29 +600,25 @@ type PciInteractionState<Pcis extends PciId = PciId> = {
|
|
|
542
600
|
}[Pcis]
|
|
543
601
|
```
|
|
544
602
|
|
|
545
|
-
|
|
603
|
+
When the state is narrowed to a PCI id, `submit` accepts only that PCI's value type.
|
|
546
604
|
|
|
547
605
|
```ts
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
606
|
+
import type { PciValue } from "@superbuilders/primer-tives/contracts"
|
|
607
|
+
|
|
608
|
+
if (state.phase === "interaction" && state.kind === "portable-custom") {
|
|
609
|
+
if (state.pciId === "urn:primer:pci:fraction-input") {
|
|
610
|
+
const value: PciValue<"urn:primer:pci:fraction-input"> = readFractionInput(
|
|
611
|
+
state.properties
|
|
612
|
+
)
|
|
613
|
+
state = await state.submit(value)
|
|
614
|
+
}
|
|
551
615
|
}
|
|
552
616
|
```
|
|
553
617
|
|
|
554
|
-
The submitted value is one of:
|
|
555
|
-
|
|
556
|
-
```ts
|
|
557
|
-
type FractionInputSubmission =
|
|
558
|
-
| { form: "whole"; whole: string }
|
|
559
|
-
| { form: "proper"; numerator: string; denominator: string }
|
|
560
|
-
| { form: "improper"; numerator: string; denominator: string }
|
|
561
|
-
| { form: "mixed"; whole: string; numerator: string; denominator: string }
|
|
562
|
-
```
|
|
563
|
-
|
|
564
618
|
## `FeedbackState`
|
|
565
619
|
|
|
566
620
|
```ts
|
|
567
|
-
interface FeedbackState<Pcis extends PciId = PciId>
|
|
621
|
+
interface FeedbackState<Pcis extends PciId = PciId> {
|
|
568
622
|
readonly phase: "feedback"
|
|
569
623
|
readonly body: ContentBlock[]
|
|
570
624
|
readonly stimulus: RendererStimulus | null
|
|
@@ -577,22 +631,24 @@ interface FeedbackState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
|
577
631
|
}
|
|
578
632
|
```
|
|
579
633
|
|
|
580
|
-
Feedback state is returned after a successful
|
|
634
|
+
Feedback state is returned after a successful learner submission. Render the submitted frame, submitted value, correctness, feedback content, and optional review data. Call `advance()` when the learner is ready to continue.
|
|
635
|
+
|
|
636
|
+
Repeated `advance()` calls while the first one is pending return the same pending result.
|
|
581
637
|
|
|
582
638
|
## `CompletedState`
|
|
583
639
|
|
|
584
640
|
```ts
|
|
585
|
-
interface CompletedState
|
|
641
|
+
interface CompletedState {
|
|
586
642
|
readonly phase: "completed"
|
|
587
643
|
}
|
|
588
644
|
```
|
|
589
645
|
|
|
590
|
-
Terminal state. There is no
|
|
646
|
+
Terminal state. There is no transition method.
|
|
591
647
|
|
|
592
648
|
## `ErroredState`
|
|
593
649
|
|
|
594
650
|
```ts
|
|
595
|
-
interface ErroredState<Pcis extends PciId = PciId>
|
|
651
|
+
interface ErroredState<Pcis extends PciId = PciId> {
|
|
596
652
|
readonly phase: "errored"
|
|
597
653
|
readonly error: Error
|
|
598
654
|
readonly retriable: boolean
|
|
@@ -600,43 +656,74 @@ interface ErroredState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
|
600
656
|
}
|
|
601
657
|
```
|
|
602
658
|
|
|
603
|
-
`
|
|
659
|
+
`ErroredState` means the current learner intent could not complete, but the learning session itself is not necessarily terminal.
|
|
660
|
+
|
|
661
|
+
If `retriable` is `true`, `retry()` repeats the exact failed intent. If `retriable` is `false`, `retry()` resolves to the same errored state.
|
|
662
|
+
|
|
663
|
+
`ErrInvalidSubmission` is non-retriable because the submitted value was invalid for the active interaction. Renderer code should fix the payload before submitting again from a fresh interaction state.
|
|
604
664
|
|
|
605
665
|
## `FatalState`
|
|
606
666
|
|
|
607
667
|
```ts
|
|
608
|
-
interface FatalState
|
|
668
|
+
interface FatalState {
|
|
609
669
|
readonly phase: "fatal"
|
|
610
670
|
readonly error: Error
|
|
611
671
|
readonly retriable: false
|
|
612
672
|
}
|
|
613
673
|
```
|
|
614
674
|
|
|
615
|
-
Fatal means the
|
|
675
|
+
Fatal state means the SDK cannot recover by retrying the current learner intent. Render a terminal error UI, require a new `create` call, or send the learner through auth again depending on the sentinel.
|
|
676
|
+
|
|
677
|
+
Fatal sentinels:
|
|
678
|
+
|
|
679
|
+
| Sentinel | Meaning |
|
|
680
|
+
| --- | --- |
|
|
681
|
+
| `ErrBadRequest` | Primer rejected the runtime request as invalid for the SDK contract. |
|
|
682
|
+
| `ErrInvalidAccessToken` | The learner token was rejected. |
|
|
683
|
+
| `ErrTokenExpired` | The learner token expired. |
|
|
684
|
+
| `ErrForbidden` | The learner is not allowed to continue in this runtime scope. |
|
|
685
|
+
| `ErrNotFound` | The requested runtime scope or state could not be found. |
|
|
686
|
+
| `ErrSdkUpgradeRequired` | The installed SDK is too old for the current Primer runtime. |
|
|
687
|
+
| `ErrUnsupportedPci` | Primer presented a PCI that the renderer did not declare in `supportedPcis`. |
|
|
616
688
|
|
|
617
|
-
|
|
689
|
+
## `/contracts`
|
|
618
690
|
|
|
619
|
-
The contracts subpath contains renderer
|
|
691
|
+
The contracts subpath contains renderer-facing data types and validation helpers.
|
|
620
692
|
|
|
621
693
|
```ts
|
|
622
694
|
import {
|
|
695
|
+
ChoiceSubmissionSchema,
|
|
696
|
+
ExtendedTextSubmissionSchema,
|
|
697
|
+
FractionInputPciSubmissionSchema,
|
|
698
|
+
MatchPairSchema,
|
|
699
|
+
MatchSubmissionSchema,
|
|
700
|
+
OrderSubmissionSchema,
|
|
701
|
+
PCI_IDS,
|
|
623
702
|
RendererSubmissionSchema,
|
|
703
|
+
TextEntrySubmissionSchema,
|
|
624
704
|
blocksToPlainText,
|
|
625
705
|
inlinesToPlainText,
|
|
706
|
+
isPciId,
|
|
626
707
|
submissionValidationMessage,
|
|
627
708
|
validateSubmissionForInteraction
|
|
628
709
|
} from "@superbuilders/primer-tives/contracts"
|
|
710
|
+
|
|
629
711
|
import type {
|
|
630
712
|
ContentBlock,
|
|
631
713
|
ContentInline,
|
|
632
714
|
ContentSpan,
|
|
715
|
+
FractionInputForm,
|
|
716
|
+
FractionInputProps,
|
|
717
|
+
FractionInputSubmission,
|
|
633
718
|
ImageStimulus,
|
|
634
719
|
InteractionReview,
|
|
635
720
|
MatchPair,
|
|
636
721
|
PciId,
|
|
637
722
|
PciInteraction,
|
|
638
723
|
PciProps,
|
|
724
|
+
PciRegistry,
|
|
639
725
|
PciSubmission,
|
|
726
|
+
PciUrn,
|
|
640
727
|
PciValue,
|
|
641
728
|
RendererChoice,
|
|
642
729
|
RendererInteraction,
|
|
@@ -658,11 +745,11 @@ type ContentBlock = { type: "paragraph"; children: ContentInline[] }
|
|
|
658
745
|
Helpers:
|
|
659
746
|
|
|
660
747
|
```ts
|
|
661
|
-
inlinesToPlainText(nodes: ContentInline[]): string
|
|
662
|
-
blocksToPlainText(blocks: ContentBlock[]): string
|
|
748
|
+
function inlinesToPlainText(nodes: ContentInline[]): string
|
|
749
|
+
function blocksToPlainText(blocks: ContentBlock[]): string
|
|
663
750
|
```
|
|
664
751
|
|
|
665
|
-
Use
|
|
752
|
+
Use the plain-text helpers for accessibility labels, logging summaries, search snippets, and renderer fallbacks. LaTeX inline nodes contribute their raw `value` to plain text.
|
|
666
753
|
|
|
667
754
|
## Stimulus
|
|
668
755
|
|
|
@@ -676,7 +763,7 @@ interface ImageStimulus {
|
|
|
676
763
|
type RendererStimulus = ImageStimulus
|
|
677
764
|
```
|
|
678
765
|
|
|
679
|
-
`RendererStimulus` is currently image-only
|
|
766
|
+
`RendererStimulus` is currently image-only. Always switch on `stimulus.kind` anyway.
|
|
680
767
|
|
|
681
768
|
## Interactions
|
|
682
769
|
|
|
@@ -730,50 +817,86 @@ type RendererSubmission<Pcis extends PciId = PciId> =
|
|
|
730
817
|
| PciSubmission<Pcis>
|
|
731
818
|
```
|
|
732
819
|
|
|
733
|
-
|
|
820
|
+
Public schemas:
|
|
734
821
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
822
|
+
| Schema | Validates |
|
|
823
|
+
| --- | --- |
|
|
824
|
+
| `MatchPairSchema` | `{ source, target }` match pair shape |
|
|
825
|
+
| `ChoiceSubmissionSchema` | choice submission shape |
|
|
826
|
+
| `TextEntrySubmissionSchema` | text-entry submission shape |
|
|
827
|
+
| `ExtendedTextSubmissionSchema` | extended-text submission shape |
|
|
828
|
+
| `OrderSubmissionSchema` | order submission shape |
|
|
829
|
+
| `MatchSubmissionSchema` | match submission shape |
|
|
830
|
+
| `FractionInputPciSubmissionSchema` | fraction-input PCI submission shape |
|
|
831
|
+
| `RendererSubmissionSchema` | union of all supported submission shapes |
|
|
744
832
|
|
|
745
|
-
Always use `safeParse` when parsing arbitrary input
|
|
833
|
+
Always use `safeParse` when parsing arbitrary input.
|
|
746
834
|
|
|
747
835
|
```ts
|
|
836
|
+
import * as errors from "@superbuilders/errors"
|
|
837
|
+
import * as logger from "@superbuilders/slog"
|
|
838
|
+
import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
|
|
839
|
+
|
|
748
840
|
const parsed = RendererSubmissionSchema.safeParse(payload)
|
|
749
841
|
if (!parsed.success) {
|
|
750
|
-
|
|
842
|
+
logger.error("submission payload invalid", { error: parsed.error })
|
|
843
|
+
throw errors.wrap(parsed.error, "submission payload")
|
|
751
844
|
}
|
|
845
|
+
|
|
846
|
+
const submission = parsed.data
|
|
752
847
|
```
|
|
753
848
|
|
|
754
|
-
## Semantic Validation
|
|
849
|
+
## Semantic Submission Validation
|
|
755
850
|
|
|
756
|
-
|
|
851
|
+
Shape validation answers “does this look like a submission?” Semantic validation answers “is this submission valid for this exact interaction?”
|
|
757
852
|
|
|
758
853
|
```ts
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
854
|
+
function validateSubmissionForInteraction(
|
|
855
|
+
interaction: RendererInteraction,
|
|
856
|
+
submission: RendererSubmission
|
|
857
|
+
): SubmissionValidationResult
|
|
858
|
+
|
|
859
|
+
function submissionValidationMessage(result: SubmissionValidationFailure): string
|
|
763
860
|
```
|
|
764
861
|
|
|
765
|
-
|
|
862
|
+
Result shape:
|
|
863
|
+
|
|
864
|
+
```ts
|
|
865
|
+
type SubmissionValidationResult =
|
|
866
|
+
| { ok: true; value: RendererSubmission }
|
|
867
|
+
| { ok: false; issues: readonly string[] }
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
Validation checks:
|
|
871
|
+
|
|
872
|
+
| Interaction | Checks |
|
|
873
|
+
| --- | --- |
|
|
874
|
+
| `choice` | type match, min/max selection count, duplicate identifiers, unknown identifiers |
|
|
875
|
+
| `text-entry` | type match |
|
|
876
|
+
| `extended-text` single | type match, exactly one value |
|
|
877
|
+
| `extended-text` multiple | type match, min/max value count, duplicate values |
|
|
878
|
+
| `order` | type match, min/max selection count, duplicate identifiers, unknown identifiers |
|
|
879
|
+
| `match` | type match, min/max association count, duplicate pairs, unknown sources, unknown targets, `matchMin`, `matchMax` |
|
|
880
|
+
| `portable-custom` | type match, PCI id match, PCI value schema |
|
|
766
881
|
|
|
767
|
-
-
|
|
768
|
-
- choice identifiers exist
|
|
769
|
-
- choice cardinality is within `minChoices` and `maxChoices`
|
|
770
|
-
- duplicate choice/order identifiers are rejected
|
|
771
|
-
- extended-text value count matches cardinality bounds
|
|
772
|
-
- match source/target identifiers exist
|
|
773
|
-
- match min/max usage bounds hold
|
|
774
|
-
- portable-custom `pciId` matches and payload parses against the PCI schema
|
|
882
|
+
The built-in standard interaction state methods call this before submitting. Custom renderer utilities that build `RendererSubmission` values directly should call it too.
|
|
775
883
|
|
|
776
|
-
|
|
884
|
+
```ts
|
|
885
|
+
import * as errors from "@superbuilders/errors"
|
|
886
|
+
import * as logger from "@superbuilders/slog"
|
|
887
|
+
import {
|
|
888
|
+
submissionValidationMessage,
|
|
889
|
+
validateSubmissionForInteraction
|
|
890
|
+
} from "@superbuilders/primer-tives/contracts"
|
|
891
|
+
import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
|
|
892
|
+
|
|
893
|
+
const validation = validateSubmissionForInteraction(interaction, submission)
|
|
894
|
+
if (!validation.ok) {
|
|
895
|
+
const message = submissionValidationMessage(validation)
|
|
896
|
+
logger.error("submission invalid", { issues: validation.issues })
|
|
897
|
+
throw errors.wrap(ErrInvalidSubmission, message)
|
|
898
|
+
}
|
|
899
|
+
```
|
|
777
900
|
|
|
778
901
|
## Review Types
|
|
779
902
|
|
|
@@ -800,23 +923,143 @@ Review variants:
|
|
|
800
923
|
| `match` | `correctPairs: MatchPair[]` |
|
|
801
924
|
| `portable-custom` | `pciId`, `fields: ReviewRecordField[]` |
|
|
802
925
|
|
|
803
|
-
|
|
926
|
+
Review scalar values:
|
|
927
|
+
|
|
928
|
+
```ts
|
|
929
|
+
type ReviewScalarValue =
|
|
930
|
+
| { kind: "identifier"; value: string }
|
|
931
|
+
| { kind: "string"; value: string }
|
|
932
|
+
| { kind: "integer"; value: number }
|
|
933
|
+
| { kind: "float"; value: number }
|
|
934
|
+
| { kind: "pair"; source: string; target: string }
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
`review` is for renderer display and inspection. Correctness already lives on `FeedbackState.isCorrect`.
|
|
938
|
+
|
|
939
|
+
## PCI Registry
|
|
940
|
+
|
|
941
|
+
The current PCI registry contains one PCI.
|
|
942
|
+
|
|
943
|
+
```ts
|
|
944
|
+
const PCI_IDS = ["urn:primer:pci:fraction-input"] as const
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
```ts
|
|
948
|
+
type PciId = "urn:primer:pci:fraction-input"
|
|
949
|
+
type PciProps<K extends PciId> = PciRegistry[K]["props"]
|
|
950
|
+
type PciValue<K extends PciId> = PciRegistry[K]["value"]
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
Use `isPciId(value)` to narrow an arbitrary string to the current `PciId` union.
|
|
954
|
+
|
|
955
|
+
## Fraction Input PCI
|
|
956
|
+
|
|
957
|
+
```ts
|
|
958
|
+
type FractionInputForm = "whole" | "proper" | "improper" | "mixed"
|
|
959
|
+
|
|
960
|
+
interface FractionInputProps {
|
|
961
|
+
form: FractionInputForm
|
|
962
|
+
requireSimplified: boolean
|
|
963
|
+
}
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
Submitted value:
|
|
967
|
+
|
|
968
|
+
```ts
|
|
969
|
+
type FractionInputSubmission =
|
|
970
|
+
| { form: "whole"; whole: string }
|
|
971
|
+
| { form: "proper"; numerator: string; denominator: string }
|
|
972
|
+
| { form: "improper"; numerator: string; denominator: string }
|
|
973
|
+
| { form: "mixed"; whole: string; numerator: string; denominator: string }
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
Example renderer branch:
|
|
804
977
|
|
|
805
|
-
|
|
978
|
+
```ts
|
|
979
|
+
if (state.phase === "interaction" && state.kind === "portable-custom") {
|
|
980
|
+
if (state.pciId === "urn:primer:pci:fraction-input") {
|
|
981
|
+
renderFractionInput({
|
|
982
|
+
mode: "pending",
|
|
983
|
+
properties: state.properties,
|
|
984
|
+
onValueChange: handleFractionValueChange
|
|
985
|
+
})
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
## PCI Render Props
|
|
991
|
+
|
|
992
|
+
PCI render props are convenience types for renderer components.
|
|
993
|
+
|
|
994
|
+
```ts
|
|
995
|
+
type PciPendingRenderProps<K extends PciId> = {
|
|
996
|
+
mode: "pending"
|
|
997
|
+
properties: PciProps<K>
|
|
998
|
+
onValueChange: (value: PciValue<K> | null) => void
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
type PciSubmittedRenderProps<K extends PciId> = {
|
|
1002
|
+
mode: "submitted"
|
|
1003
|
+
properties: PciProps<K>
|
|
1004
|
+
submission: PciValue<K>
|
|
1005
|
+
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
## Subject PCI Helpers
|
|
1012
|
+
|
|
1013
|
+
Use `@superbuilders/primer-tives/subject-pcis` when renderer tooling needs the same subject-to-PCI contract as `create`.
|
|
1014
|
+
|
|
1015
|
+
```ts
|
|
1016
|
+
import {
|
|
1017
|
+
REQUIRED_PCIS_BY_SUBJECT,
|
|
1018
|
+
missingPcisForSubject,
|
|
1019
|
+
requiredPcisForSubject
|
|
1020
|
+
} from "@superbuilders/primer-tives/subject-pcis"
|
|
1021
|
+
import type {
|
|
1022
|
+
HasRequiredPcis,
|
|
1023
|
+
MissingRequiredPcis,
|
|
1024
|
+
RequiredPciForSubject
|
|
1025
|
+
} from "@superbuilders/primer-tives/subject-pcis"
|
|
1026
|
+
|
|
1027
|
+
const requiredForMath = requiredPcisForSubject("math")
|
|
1028
|
+
const missingForMath = missingPcisForSubject("math", [])
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
`requiredPcisForSubject(undefined)` returns the all-subject required PCI union.
|
|
806
1032
|
|
|
807
|
-
|
|
1033
|
+
## Errors
|
|
1034
|
+
|
|
1035
|
+
All SDK sentinels are exported from `@superbuilders/primer-tives/errors` and are compatible with `errors.is()` from `@superbuilders/errors`.
|
|
1036
|
+
|
|
1037
|
+
```ts
|
|
1038
|
+
import * as errors from "@superbuilders/errors"
|
|
1039
|
+
import { ErrTokenExpired } from "@superbuilders/primer-tives/errors"
|
|
1040
|
+
|
|
1041
|
+
if (errors.is(err, ErrTokenExpired)) {
|
|
1042
|
+
renderSignInAgain()
|
|
1043
|
+
}
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
Complete export set:
|
|
808
1047
|
|
|
809
1048
|
```ts
|
|
810
1049
|
import {
|
|
1050
|
+
ErrAuthCallbackInvalid,
|
|
1051
|
+
ErrAuthCancelled,
|
|
1052
|
+
ErrAuthConfigInvalid,
|
|
1053
|
+
ErrAuthPopupBlocked,
|
|
1054
|
+
ErrAuthStateMismatch,
|
|
1055
|
+
ErrAuthUnavailable,
|
|
811
1056
|
ErrBadRequest,
|
|
812
1057
|
ErrConflict,
|
|
813
1058
|
ErrForbidden,
|
|
814
1059
|
ErrInvalidAccessToken,
|
|
815
|
-
ErrInvalidSecretKey,
|
|
816
1060
|
ErrInvalidSubmission,
|
|
817
1061
|
ErrJsonParse,
|
|
818
1062
|
ErrMalformedAccessToken,
|
|
819
|
-
ErrMissingRequiredPci,
|
|
820
1063
|
ErrNetwork,
|
|
821
1064
|
ErrNotFound,
|
|
822
1065
|
ErrNotSerializable,
|
|
@@ -830,55 +1073,101 @@ import {
|
|
|
830
1073
|
} from "@superbuilders/primer-tives/errors"
|
|
831
1074
|
```
|
|
832
1075
|
|
|
833
|
-
##
|
|
1076
|
+
## Create-Time Errors
|
|
834
1077
|
|
|
835
|
-
|
|
836
|
-
| --- | --- |
|
|
837
|
-
| `ErrInvalidSecretKey` | Secret key rejected by Primer. |
|
|
838
|
-
| `ErrBadRequest` | Token request was malformed. |
|
|
839
|
-
| `ErrServerError` | Primer returned an unexpected non-2xx status. |
|
|
840
|
-
| `ErrJsonParse` | Successful response did not parse or lacked token shape. |
|
|
841
|
-
| `ErrNetwork` | Fetch rejected before response. |
|
|
842
|
-
| `ErrTimeout` | Fetch aborted. |
|
|
1078
|
+
These are most often thrown by `create` before a `PrimerState` exists.
|
|
843
1079
|
|
|
844
|
-
|
|
1080
|
+
| Sentinel | Meaning | Typical handling |
|
|
1081
|
+
| --- | --- | --- |
|
|
1082
|
+
| `ErrAuthUnavailable` | SDK-managed auth cannot run in the current host environment. | Show unsupported-runtime or fallback sign-in UI. |
|
|
1083
|
+
| `ErrAuthConfigInvalid` | Public auth configuration is invalid. | Log and treat as integration error. |
|
|
1084
|
+
| `ErrAuthCallbackInvalid` | Learner auth did not complete with an acceptable result. | Offer sign-in retry; log if unexpected. |
|
|
1085
|
+
| `ErrAuthStateMismatch` | Auth result does not match the initiated auth attempt. | Restart auth. |
|
|
1086
|
+
| `ErrAuthPopupBlocked` | Browser blocked learner auth UI. | Ask learner to allow popups or retry from a user gesture. |
|
|
1087
|
+
| `ErrAuthCancelled` | Learner auth was closed or timed out. | Offer retry. |
|
|
1088
|
+
| `ErrMalformedAccessToken` | Provided or resolved token is not shaped like a learner access token. | Re-authenticate learner or fix token source. |
|
|
1089
|
+
|
|
1090
|
+
## Runtime Error States
|
|
1091
|
+
|
|
1092
|
+
Runtime errors are represented as `ErroredState` or `FatalState`.
|
|
1093
|
+
|
|
1094
|
+
| Sentinel | State | Retriable | Meaning |
|
|
1095
|
+
| --- | --- | --- | --- |
|
|
1096
|
+
| `ErrNetwork` | `ErroredState` | yes | Runtime communication failed before a usable Primer result existed. |
|
|
1097
|
+
| `ErrTimeout` | `ErroredState` | yes | Runtime work was aborted or exceeded the host's allowed time. |
|
|
1098
|
+
| `ErrServerError` | `ErroredState` | yes | Primer could not produce a normal runtime result. |
|
|
1099
|
+
| `ErrServiceUnavailable` | `ErroredState` | yes | Primer is temporarily unavailable. |
|
|
1100
|
+
| `ErrRateLimited` | `ErroredState` | yes | Runtime work is temporarily rate limited. |
|
|
1101
|
+
| `ErrConflict` | `ErroredState` | yes | The learner intent conflicts with another in-flight or current runtime action. |
|
|
1102
|
+
| `ErrJsonParse` | `ErroredState` | yes | Runtime data could not be interpreted as the SDK contract. |
|
|
1103
|
+
| `ErrInvalidSubmission` | `ErroredState` | no | Renderer submitted a value that is invalid for the active interaction. |
|
|
1104
|
+
| `ErrBadRequest` | `FatalState` | no | Runtime request violates the SDK contract. |
|
|
1105
|
+
| `ErrInvalidAccessToken` | `FatalState` | no | Learner token is invalid. |
|
|
1106
|
+
| `ErrTokenExpired` | `FatalState` | no | Learner token expired. |
|
|
1107
|
+
| `ErrForbidden` | `FatalState` | no | Learner cannot continue in this runtime scope. |
|
|
1108
|
+
| `ErrNotFound` | `FatalState` | no | Runtime scope or state is unavailable. |
|
|
1109
|
+
| `ErrSdkUpgradeRequired` | `FatalState` | no | Installed SDK version is too old for Primer. |
|
|
1110
|
+
| `ErrUnsupportedPci` | `FatalState` | no | Renderer did not declare support for the presented PCI. |
|
|
1111
|
+
| `ErrNotSerializable` | thrown by `toJSON` | no | A live `PrimerState` was serialized. |
|
|
1112
|
+
|
|
1113
|
+
## Error-Handling Recipes
|
|
1114
|
+
|
|
1115
|
+
Handle create-time errors before using state:
|
|
845
1116
|
|
|
846
|
-
|
|
1117
|
+
```ts
|
|
1118
|
+
const result = await errors.try(create(options))
|
|
1119
|
+
if (result.error) {
|
|
1120
|
+
if (errors.is(result.error, ErrAuthCancelled)) {
|
|
1121
|
+
renderTryAgain()
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
logger.error("primer create failed", { error: result.error })
|
|
1125
|
+
throw result.error
|
|
1126
|
+
}
|
|
847
1127
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
| `ErrNetwork` | Fetch rejected. |
|
|
851
|
-
| `ErrTimeout` | Fetch aborted. |
|
|
852
|
-
| `ErrServerError` | HTTP 5xx not otherwise mapped. |
|
|
853
|
-
| `ErrServiceUnavailable` | HTTP 502, 503, or 504. |
|
|
854
|
-
| `ErrRateLimited` | HTTP 429. |
|
|
855
|
-
| `ErrConflict` | HTTP 409. |
|
|
856
|
-
| `ErrJsonParse` | Successful response body failed to parse. |
|
|
857
|
-
| `ErrInvalidSubmission` | Local semantic validation rejected the submission. |
|
|
1128
|
+
let state = result.data
|
|
1129
|
+
```
|
|
858
1130
|
|
|
859
|
-
|
|
1131
|
+
Handle runtime errors through the state machine:
|
|
860
1132
|
|
|
861
|
-
|
|
1133
|
+
```ts
|
|
1134
|
+
if (state.phase === "errored") {
|
|
1135
|
+
if (state.retriable) {
|
|
1136
|
+
state = await state.retry()
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
logger.error("primer non-retriable state", { error: state.error })
|
|
1140
|
+
throw state.error
|
|
1141
|
+
}
|
|
862
1142
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1143
|
+
if (state.phase === "fatal") {
|
|
1144
|
+
if (errors.is(state.error, ErrTokenExpired)) {
|
|
1145
|
+
renderSignInAgain()
|
|
1146
|
+
return
|
|
1147
|
+
}
|
|
1148
|
+
if (errors.is(state.error, ErrSdkUpgradeRequired)) {
|
|
1149
|
+
renderSdkUpgradeMessage()
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
logger.error("primer fatal state", { error: state.error })
|
|
1153
|
+
throw state.error
|
|
1154
|
+
}
|
|
1155
|
+
```
|
|
872
1156
|
|
|
873
|
-
|
|
1157
|
+
Handle invalid submissions by fixing renderer state, not by retrying the same invalid intent:
|
|
874
1158
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1159
|
+
```ts
|
|
1160
|
+
const next = await state.submitChoice(selectedKeys)
|
|
1161
|
+
if (next.phase === "errored") {
|
|
1162
|
+
if (errors.is(next.error, ErrInvalidSubmission)) {
|
|
1163
|
+
renderSelectionError(next.error.message)
|
|
1164
|
+
return
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
state = next
|
|
1168
|
+
```
|
|
880
1169
|
|
|
881
|
-
|
|
1170
|
+
## Logger
|
|
882
1171
|
|
|
883
1172
|
```ts
|
|
884
1173
|
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
@@ -891,74 +1180,152 @@ interface PrimerLogger {
|
|
|
891
1180
|
}
|
|
892
1181
|
```
|
|
893
1182
|
|
|
894
|
-
|
|
1183
|
+
`@superbuilders/slog` matches this interface directly.
|
|
895
1184
|
|
|
896
1185
|
```ts
|
|
897
1186
|
import * as logger from "@superbuilders/slog"
|
|
898
1187
|
|
|
899
|
-
const
|
|
900
|
-
|
|
1188
|
+
const state = await create({
|
|
1189
|
+
origin,
|
|
1190
|
+
publishableKey,
|
|
1191
|
+
subject: "vocabulary",
|
|
1192
|
+
logger
|
|
1193
|
+
})
|
|
901
1194
|
```
|
|
902
1195
|
|
|
903
|
-
|
|
1196
|
+
## Grade Levels
|
|
904
1197
|
|
|
905
1198
|
```ts
|
|
906
1199
|
import { GRADE_LEVELS } from "@superbuilders/primer-tives/grade-level"
|
|
907
1200
|
import type { GradeLevel } from "@superbuilders/primer-tives/grade-level"
|
|
908
1201
|
|
|
1202
|
+
const gradeLevels = GRADE_LEVELS
|
|
1203
|
+
type RuntimeGradeLevel = GradeLevel
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
```ts
|
|
909
1207
|
const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] as const
|
|
910
1208
|
type GradeLevel = (typeof GRADE_LEVELS)[number]
|
|
911
1209
|
```
|
|
912
1210
|
|
|
913
|
-
|
|
1211
|
+
Grade level is not a `create` option. Treat grade-level identifiers as content/runtime data when Primer presents them, not as an SDK lifecycle input.
|
|
1212
|
+
|
|
1213
|
+
## Testing
|
|
1214
|
+
|
|
1215
|
+
`PrimerOptions.fetch` exists so tests, host runtimes, and instrumentation can provide a fetch-compatible function. The SDK treats it exactly as the runtime communication function for `create`, transitions, submissions, retries, and timeouts.
|
|
914
1216
|
|
|
915
|
-
|
|
1217
|
+
The runtime exchange shape is not public SDK surface. Tests should assert SDK semantics after `create` resolves or rejects:
|
|
916
1218
|
|
|
917
|
-
|
|
1219
|
+
| Scenario | Assert |
|
|
1220
|
+
| --- | --- |
|
|
1221
|
+
| auth fails | `create` rejects with the expected auth sentinel |
|
|
1222
|
+
| first runtime work fails recoverably | `create` resolves to `ErroredState` with `retriable: true` |
|
|
1223
|
+
| first runtime work fails terminally | `create` resolves to `FatalState` |
|
|
1224
|
+
| unsupported PCI is presented | `create` resolves to `FatalState` with `ErrUnsupportedPci` |
|
|
1225
|
+
| standard submission is invalid | submit method resolves to `ErroredState` with `ErrInvalidSubmission` |
|
|
1226
|
+
| concurrent submit/timeout conflict occurs | transition resolves to `ErroredState` with `ErrConflict` |
|
|
1227
|
+
| state is serialized | serialization throws `ErrNotSerializable` |
|
|
1228
|
+
|
|
1229
|
+
Example test shape:
|
|
1230
|
+
|
|
1231
|
+
```ts
|
|
1232
|
+
import * as errors from "@superbuilders/errors"
|
|
1233
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
1234
|
+
import { ErrUnsupportedPci } from "@superbuilders/primer-tives/errors"
|
|
1235
|
+
|
|
1236
|
+
declare const fetchMock: typeof globalThis.fetch
|
|
1237
|
+
|
|
1238
|
+
const state = await create({
|
|
1239
|
+
origin: "https://primer.test",
|
|
1240
|
+
publishableKey: "pk_test",
|
|
1241
|
+
accessToken: "eyJ.test.token",
|
|
1242
|
+
subject: "math",
|
|
1243
|
+
fetch: fetchMock,
|
|
1244
|
+
logger
|
|
1245
|
+
})
|
|
918
1246
|
|
|
919
|
-
|
|
1247
|
+
if (state.phase === "fatal") {
|
|
1248
|
+
if (errors.is(state.error, ErrUnsupportedPci)) {
|
|
1249
|
+
renderRendererCapabilityError()
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
```
|
|
920
1253
|
|
|
921
|
-
|
|
922
|
-
- `kind: "choice"`
|
|
923
|
-
- `minChoices: 1`
|
|
924
|
-
- `maxChoices: 1`
|
|
925
|
-
- options with identifiers from `GRADE_LEVELS`
|
|
1254
|
+
## Security Model
|
|
926
1255
|
|
|
927
|
-
|
|
1256
|
+
The browser may hold:
|
|
928
1257
|
|
|
929
|
-
|
|
1258
|
+
```txt
|
|
1259
|
+
publishable key
|
|
1260
|
+
learner access token
|
|
1261
|
+
```
|
|
930
1262
|
|
|
931
|
-
|
|
932
|
-
- no server-side student creation method
|
|
933
|
-
- no separate onboarding endpoint
|
|
934
|
-
- no browser special case beyond rendering a normal choice interaction
|
|
1263
|
+
The publishable key identifies the Primer frontend. It is not learner auth.
|
|
935
1264
|
|
|
936
|
-
|
|
1265
|
+
The access token authenticates the learner. Primer verifies it before producing learning state.
|
|
937
1266
|
|
|
938
|
-
|
|
1267
|
+
PCI support is a renderer capability declaration. It is not negotiated implicitly. If a renderer cannot handle a required PCI, it must not claim that PCI in `supportedPcis`.
|
|
939
1268
|
|
|
940
|
-
|
|
941
|
-
2. Backend keeps `secretKey` server-only.
|
|
942
|
-
3. Backend calls `getToken` only for an authenticated user with a verified email.
|
|
943
|
-
4. Backend returns the token string to the browser.
|
|
944
|
-
5. Browser imports `create` from `/client`.
|
|
945
|
-
6. Browser passes `accessToken`, `origin`, `subject`, `logger`, and required `supportedPcis`.
|
|
946
|
-
7. Browser calls `start()` once per runtime mount.
|
|
947
|
-
8. Browser renders by switching on `state.phase`.
|
|
948
|
-
9. Browser never serializes `PrimerState`.
|
|
949
|
-
10. Browser recreates the client after reload or subject switch.
|
|
1269
|
+
Primer does not need these as public SDK inputs:
|
|
950
1270
|
|
|
951
|
-
|
|
1271
|
+
```txt
|
|
1272
|
+
learner email
|
|
1273
|
+
verified email
|
|
1274
|
+
frontend secret key
|
|
1275
|
+
student id
|
|
1276
|
+
grade level
|
|
1277
|
+
```
|
|
952
1278
|
|
|
953
|
-
|
|
1279
|
+
## Integration Checklist
|
|
954
1280
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
-
|
|
962
|
-
|
|
1281
|
+
1. Import `create` from `@superbuilders/primer-tives/client`.
|
|
1282
|
+
2. Import shared renderer contracts from `@superbuilders/primer-tives/contracts`.
|
|
1283
|
+
3. Pass `origin`, `publishableKey`, and `logger` to `create`.
|
|
1284
|
+
4. Either pass `accessToken` or let `create` perform SDK-managed auth.
|
|
1285
|
+
5. Choose a public `subject`, or omit `subject` for all-subject runtime scope.
|
|
1286
|
+
6. Declare every required renderer PCI in `supportedPcis`.
|
|
1287
|
+
7. Await `create` and handle create-time errors with `errors.try`.
|
|
1288
|
+
8. Render by switching on `state.phase`.
|
|
1289
|
+
9. For interaction states, render by switching on `state.kind`.
|
|
1290
|
+
10. Use only the transition methods exposed by the current state.
|
|
1291
|
+
11. Handle `ErroredState` through `retriable` and `retry()`.
|
|
1292
|
+
12. Handle `FatalState` as terminal for the current state object.
|
|
1293
|
+
13. Never serialize `PrimerState`.
|
|
1294
|
+
14. Recreate state with `create` after reload, remount, account switch, or subject switch.
|
|
1295
|
+
|
|
1296
|
+
## What This SDK Does Not Expose
|
|
1297
|
+
|
|
1298
|
+
The current SDK intentionally does not expose:
|
|
1299
|
+
|
|
1300
|
+
| Not exposed | Use instead |
|
|
1301
|
+
| --- | --- |
|
|
1302
|
+
| package-root exports | explicit public subpaths |
|
|
1303
|
+
| backend-only SDK surface | browser/client semantic SDK only |
|
|
1304
|
+
| public auth result union | `create` |
|
|
1305
|
+
| separate auth API | `create` |
|
|
1306
|
+
| client wrapper object | `create` returning `Promise<PrimerState>` |
|
|
1307
|
+
| `start()` | `create` already starts the first state |
|
|
1308
|
+
| `snapshot()` | live `PrimerState` only |
|
|
1309
|
+
| serializable state | recreate with `create` |
|
|
1310
|
+
| public `"all"` subject value | omit `subject` |
|
|
1311
|
+
| implicit PCI negotiation | explicit `supportedPcis` |
|
|
1312
|
+
| grade-level lifecycle option | runtime content/state handling |
|
|
1313
|
+
|
|
1314
|
+
## Final Invariants
|
|
1315
|
+
|
|
1316
|
+
```txt
|
|
1317
|
+
create is the only public lifecycle operation
|
|
1318
|
+
create returns Promise<PrimerState>
|
|
1319
|
+
accessToken present skips SDK-managed auth
|
|
1320
|
+
accessToken absent uses SDK-managed auth
|
|
1321
|
+
subject is optional; omitted means all-subject runtime scope
|
|
1322
|
+
subject determines required renderer PCI capabilities
|
|
1323
|
+
supportedPcis declares renderer PCI capabilities
|
|
1324
|
+
PrimerState is the live learning state machine
|
|
1325
|
+
only valid state variants expose learning transitions
|
|
1326
|
+
standard interaction submissions are validated before runtime submission
|
|
1327
|
+
fatal state is terminal for the current state object
|
|
1328
|
+
live state is not serializable
|
|
1329
|
+
```
|
|
963
1330
|
|
|
964
|
-
|
|
1331
|
+
Keep these concepts separate: publishable key is not learner auth; access token is not content authorization; subject selects scope but does not prove renderer capability; PCI support is explicit; `PrimerState` is live behavior, not data.
|