@growth-labs/cms 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/reader-state.d.ts +127 -0
- package/dist/engine/reader-state.d.ts.map +1 -0
- package/dist/engine/reader-state.js +493 -0
- package/dist/engine/reader-state.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +37 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +5 -2
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +27 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/migrations/0019_reader_state.sql +34 -0
- package/package.json +1 -1
- package/src/engine/index.ts +38 -0
- package/src/engine/reader-state.ts +918 -0
- package/src/index.ts +4 -2
- package/src/schema/index.ts +2 -0
- package/src/schema/migrations.ts +37 -0
- package/src/schema/tables.ts +5 -2
- package/src/schema/types.ts +29 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import type { CmsReaderStateRow, ContentType, ReaderManualReadState } from '../schema/types.js'
|
|
2
|
+
import type { D1Database, D1Result } from './d1.js'
|
|
3
|
+
|
|
4
|
+
export const READER_AUTOMATIC_COMPLETION_BPS = 9_000
|
|
5
|
+
export const DEFAULT_READER_STATE_BATCH_SIZE = 50
|
|
6
|
+
export const MAX_READER_STATE_BATCH_SIZE = 100
|
|
7
|
+
|
|
8
|
+
const UUIDV4_PATTERN =
|
|
9
|
+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/
|
|
10
|
+
const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/
|
|
11
|
+
const SITE_ID_PATTERN = /^[a-z][a-z0-9_-]{1,63}$/
|
|
12
|
+
const CONTENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
|
|
13
|
+
const MUTATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
|
|
14
|
+
const PREFERENCE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/
|
|
15
|
+
const CONTENT_TYPES = new Set<ContentType>(['article', 'video', 'podcast', 'newsletter', 'page'])
|
|
16
|
+
const MAX_PREFERENCE_KEYS = 50
|
|
17
|
+
const MAX_PREFERENCES_JSON_BYTES = 8_192
|
|
18
|
+
const MAX_EVENT_FUTURE_SKEW_MS = 5 * 60 * 1_000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Verified request scope supplied by the consuming site after issuer and site
|
|
22
|
+
* token validation. The package validates the canonical raw identifier shape;
|
|
23
|
+
* callers must never construct this from analytics, email, local-user, URL, or
|
|
24
|
+
* request-body identifiers.
|
|
25
|
+
*/
|
|
26
|
+
export interface ReaderStateScope {
|
|
27
|
+
identityUserId: string
|
|
28
|
+
siteId: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ContentStateKey {
|
|
32
|
+
contentType: ContentType
|
|
33
|
+
contentId: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ReaderBehavioralConsent = 'granted' | 'denied'
|
|
37
|
+
export type ReaderPreferenceValue = string | number | boolean | null
|
|
38
|
+
export type ReaderPreferences = Record<string, ReaderPreferenceValue>
|
|
39
|
+
|
|
40
|
+
export interface ReaderStateRecord {
|
|
41
|
+
identityUserId: string
|
|
42
|
+
siteId: string
|
|
43
|
+
contentType: ContentType
|
|
44
|
+
contentId: string
|
|
45
|
+
firstOpenedAt: number | null
|
|
46
|
+
lastOpenedAt: number | null
|
|
47
|
+
maxProgressBasisPoints: number
|
|
48
|
+
mediaPositionMs: number | null
|
|
49
|
+
mediaDurationMs: number | null
|
|
50
|
+
progressObservedAt: number | null
|
|
51
|
+
automaticCompletedAt: number | null
|
|
52
|
+
manualReadState: ReaderManualReadState | null
|
|
53
|
+
manualStateAt: number | null
|
|
54
|
+
saved: boolean
|
|
55
|
+
savedAt: number | null
|
|
56
|
+
preferences: ReaderPreferences
|
|
57
|
+
preferencesAt: number | null
|
|
58
|
+
version: number
|
|
59
|
+
effectiveRead: boolean
|
|
60
|
+
createdAt: number
|
|
61
|
+
updatedAt: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ReaderStateMutationResult {
|
|
65
|
+
applied: boolean
|
|
66
|
+
reason: 'applied' | 'stale'
|
|
67
|
+
state: ReaderStateRecord
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ReaderStateBehaviorResult =
|
|
71
|
+
| ReaderStateMutationResult
|
|
72
|
+
| { applied: false; reason: 'consent_denied'; state: ReaderStateRecord | null }
|
|
73
|
+
|
|
74
|
+
export interface ReaderStateListOptions {
|
|
75
|
+
limit?: number
|
|
76
|
+
cursor?: string | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ReaderStatePage {
|
|
80
|
+
items: ReaderStateRecord[]
|
|
81
|
+
nextCursor: string | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ReaderStateExportPage extends ReaderStatePage {
|
|
85
|
+
scope: ReaderStateScope
|
|
86
|
+
exportedAt: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface BehavioralWriteOptions {
|
|
90
|
+
behavioralConsent: ReaderBehavioralConsent
|
|
91
|
+
occurredAt?: number
|
|
92
|
+
expectedVersion?: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface RecordReaderOpenOptions extends BehavioralWriteOptions {}
|
|
96
|
+
|
|
97
|
+
export interface RecordReaderProgressOptions extends BehavioralWriteOptions {
|
|
98
|
+
progressBasisPoints: number
|
|
99
|
+
mediaPositionMs?: number
|
|
100
|
+
mediaDurationMs?: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface ExplicitMutationOptions {
|
|
104
|
+
occurredAt?: number
|
|
105
|
+
mutationId: string
|
|
106
|
+
expectedVersion?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SetManualReadStateOptions extends ExplicitMutationOptions {
|
|
110
|
+
state: ReaderManualReadState
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ClearManualReadStateOptions extends ExplicitMutationOptions {}
|
|
114
|
+
|
|
115
|
+
export interface SetSavedStateOptions extends ExplicitMutationOptions {
|
|
116
|
+
saved: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SetReaderPreferencesOptions extends ExplicitMutationOptions {
|
|
120
|
+
preferences: ReaderPreferences
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ResolvedScope {
|
|
124
|
+
identityUserId: string
|
|
125
|
+
siteId: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface ResolvedMutation {
|
|
129
|
+
occurredAt: number
|
|
130
|
+
expectedVersion: number | undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class ReaderStateInputError extends Error {
|
|
134
|
+
constructor(message: string) {
|
|
135
|
+
super(message)
|
|
136
|
+
this.name = 'ReaderStateInputError'
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class ReaderStateConflictError extends Error {
|
|
141
|
+
constructor(
|
|
142
|
+
readonly expectedVersion: number,
|
|
143
|
+
readonly actualVersion: number | null,
|
|
144
|
+
) {
|
|
145
|
+
super(
|
|
146
|
+
`@growth-labs/cms reader state version conflict: expected ${expectedVersion}, actual ${actualVersion ?? 'missing'}`,
|
|
147
|
+
)
|
|
148
|
+
this.name = 'ReaderStateConflictError'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class ReaderStateDurableStateError extends Error {
|
|
153
|
+
constructor(message: string) {
|
|
154
|
+
super(message)
|
|
155
|
+
this.name = 'ReaderStateDurableStateError'
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function getReaderState(
|
|
160
|
+
db: D1Database,
|
|
161
|
+
scope: ReaderStateScope,
|
|
162
|
+
key: ContentStateKey,
|
|
163
|
+
): Promise<ReaderStateRecord | null> {
|
|
164
|
+
const resolvedScope = resolveScope(scope)
|
|
165
|
+
const resolvedKey = resolveContentKey(key)
|
|
166
|
+
return loadReaderState(db, resolvedScope, resolvedKey)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Unlock is intentionally a read-only observation: it never creates or completes state. */
|
|
170
|
+
export async function recordReaderUnlock(
|
|
171
|
+
db: D1Database,
|
|
172
|
+
scope: ReaderStateScope,
|
|
173
|
+
key: ContentStateKey,
|
|
174
|
+
): Promise<ReaderStateRecord | null> {
|
|
175
|
+
return getReaderState(db, scope, key)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function recordReaderOpen(
|
|
179
|
+
db: D1Database,
|
|
180
|
+
scope: ReaderStateScope,
|
|
181
|
+
key: ContentStateKey,
|
|
182
|
+
options: RecordReaderOpenOptions,
|
|
183
|
+
): Promise<ReaderStateBehaviorResult> {
|
|
184
|
+
const resolvedScope = resolveScope(scope)
|
|
185
|
+
const resolvedKey = resolveContentKey(key)
|
|
186
|
+
if (resolveBehavioralConsent(options) === 'denied') {
|
|
187
|
+
return {
|
|
188
|
+
applied: false,
|
|
189
|
+
reason: 'consent_denied',
|
|
190
|
+
state: await loadReaderState(db, resolvedScope, resolvedKey),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const mutation = resolveMutation(options)
|
|
195
|
+
const { occurredAt, expectedVersion } = mutation
|
|
196
|
+
return executeMutation(
|
|
197
|
+
db,
|
|
198
|
+
resolvedScope,
|
|
199
|
+
resolvedKey,
|
|
200
|
+
occurredAt,
|
|
201
|
+
expectedVersion,
|
|
202
|
+
db
|
|
203
|
+
.prepare(
|
|
204
|
+
`UPDATE cms_reader_state
|
|
205
|
+
SET first_opened_at = CASE
|
|
206
|
+
WHEN first_opened_at IS NULL OR first_opened_at > ? THEN ?
|
|
207
|
+
ELSE first_opened_at END,
|
|
208
|
+
last_opened_at = CASE
|
|
209
|
+
WHEN last_opened_at IS NULL OR last_opened_at < ? THEN ?
|
|
210
|
+
ELSE last_opened_at END,
|
|
211
|
+
version = version + 1,
|
|
212
|
+
updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END
|
|
213
|
+
WHERE identity_user_id = ? AND site_id = ? AND content_type = ? AND content_id = ?
|
|
214
|
+
AND (? IS NULL OR version = ?)
|
|
215
|
+
AND (first_opened_at IS NULL OR first_opened_at > ?
|
|
216
|
+
OR last_opened_at IS NULL OR last_opened_at < ?)
|
|
217
|
+
RETURNING *`,
|
|
218
|
+
)
|
|
219
|
+
.bind(
|
|
220
|
+
occurredAt,
|
|
221
|
+
occurredAt,
|
|
222
|
+
occurredAt,
|
|
223
|
+
occurredAt,
|
|
224
|
+
occurredAt,
|
|
225
|
+
occurredAt,
|
|
226
|
+
resolvedScope.identityUserId,
|
|
227
|
+
resolvedScope.siteId,
|
|
228
|
+
resolvedKey.contentType,
|
|
229
|
+
resolvedKey.contentId,
|
|
230
|
+
expectedVersion ?? null,
|
|
231
|
+
expectedVersion ?? null,
|
|
232
|
+
occurredAt,
|
|
233
|
+
occurredAt,
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function recordReaderProgress(
|
|
239
|
+
db: D1Database,
|
|
240
|
+
scope: ReaderStateScope,
|
|
241
|
+
key: ContentStateKey,
|
|
242
|
+
options: RecordReaderProgressOptions,
|
|
243
|
+
): Promise<ReaderStateBehaviorResult> {
|
|
244
|
+
const resolvedScope = resolveScope(scope)
|
|
245
|
+
const resolvedKey = resolveContentKey(key)
|
|
246
|
+
if (resolveBehavioralConsent(options) === 'denied') {
|
|
247
|
+
return {
|
|
248
|
+
applied: false,
|
|
249
|
+
reason: 'consent_denied',
|
|
250
|
+
state: await loadReaderState(db, resolvedScope, resolvedKey),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const mutation = resolveMutation(options)
|
|
255
|
+
const progress = assertBasisPoints(options.progressBasisPoints)
|
|
256
|
+
const mediaPositionMs = optionalNonNegativeInteger(options.mediaPositionMs, 'mediaPositionMs')
|
|
257
|
+
const mediaDurationMs = optionalNonNegativeInteger(options.mediaDurationMs, 'mediaDurationMs')
|
|
258
|
+
if (
|
|
259
|
+
mediaPositionMs !== undefined &&
|
|
260
|
+
mediaDurationMs !== undefined &&
|
|
261
|
+
mediaPositionMs > mediaDurationMs
|
|
262
|
+
) {
|
|
263
|
+
throw new ReaderStateInputError('reader mediaPositionMs cannot exceed mediaDurationMs')
|
|
264
|
+
}
|
|
265
|
+
const { occurredAt, expectedVersion } = mutation
|
|
266
|
+
return executeMutation(
|
|
267
|
+
db,
|
|
268
|
+
resolvedScope,
|
|
269
|
+
resolvedKey,
|
|
270
|
+
occurredAt,
|
|
271
|
+
expectedVersion,
|
|
272
|
+
db
|
|
273
|
+
.prepare(
|
|
274
|
+
`UPDATE cms_reader_state
|
|
275
|
+
SET max_progress_bps = CASE
|
|
276
|
+
WHEN max_progress_bps < ? THEN ? ELSE max_progress_bps END,
|
|
277
|
+
media_position_ms = CASE
|
|
278
|
+
WHEN ? IS NOT NULL AND (progress_observed_at IS NULL OR progress_observed_at < ?)
|
|
279
|
+
THEN ? ELSE media_position_ms END,
|
|
280
|
+
media_duration_ms = CASE
|
|
281
|
+
WHEN ? IS NOT NULL AND (progress_observed_at IS NULL OR progress_observed_at < ?)
|
|
282
|
+
THEN ? ELSE media_duration_ms END,
|
|
283
|
+
progress_observed_at = CASE
|
|
284
|
+
WHEN progress_observed_at IS NULL OR progress_observed_at < ? THEN ?
|
|
285
|
+
ELSE progress_observed_at END,
|
|
286
|
+
automatic_completed_at = CASE
|
|
287
|
+
WHEN automatic_completed_at IS NULL
|
|
288
|
+
AND MAX(max_progress_bps, ?) >= ${READER_AUTOMATIC_COMPLETION_BPS}
|
|
289
|
+
THEN ? ELSE automatic_completed_at END,
|
|
290
|
+
version = version + 1,
|
|
291
|
+
updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END
|
|
292
|
+
WHERE identity_user_id = ? AND site_id = ? AND content_type = ? AND content_id = ?
|
|
293
|
+
AND (? IS NULL OR version = ?)
|
|
294
|
+
AND (max_progress_bps < ?
|
|
295
|
+
OR progress_observed_at IS NULL OR progress_observed_at < ?
|
|
296
|
+
OR (automatic_completed_at IS NULL AND ? >= ${READER_AUTOMATIC_COMPLETION_BPS}))
|
|
297
|
+
RETURNING *`,
|
|
298
|
+
)
|
|
299
|
+
.bind(
|
|
300
|
+
progress,
|
|
301
|
+
progress,
|
|
302
|
+
mediaPositionMs ?? null,
|
|
303
|
+
occurredAt,
|
|
304
|
+
mediaPositionMs ?? null,
|
|
305
|
+
mediaDurationMs ?? null,
|
|
306
|
+
occurredAt,
|
|
307
|
+
mediaDurationMs ?? null,
|
|
308
|
+
occurredAt,
|
|
309
|
+
occurredAt,
|
|
310
|
+
progress,
|
|
311
|
+
occurredAt,
|
|
312
|
+
occurredAt,
|
|
313
|
+
occurredAt,
|
|
314
|
+
resolvedScope.identityUserId,
|
|
315
|
+
resolvedScope.siteId,
|
|
316
|
+
resolvedKey.contentType,
|
|
317
|
+
resolvedKey.contentId,
|
|
318
|
+
expectedVersion ?? null,
|
|
319
|
+
expectedVersion ?? null,
|
|
320
|
+
progress,
|
|
321
|
+
occurredAt,
|
|
322
|
+
progress,
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function setManualReadState(
|
|
328
|
+
db: D1Database,
|
|
329
|
+
scope: ReaderStateScope,
|
|
330
|
+
key: ContentStateKey,
|
|
331
|
+
options: SetManualReadStateOptions,
|
|
332
|
+
): Promise<ReaderStateMutationResult> {
|
|
333
|
+
if (options?.state !== 'read' && options?.state !== 'unread') {
|
|
334
|
+
throw new ReaderStateInputError('manual reader state must be "read" or "unread"')
|
|
335
|
+
}
|
|
336
|
+
return writeManualState(db, scope, key, options.state, options)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function clearManualReadState(
|
|
340
|
+
db: D1Database,
|
|
341
|
+
scope: ReaderStateScope,
|
|
342
|
+
key: ContentStateKey,
|
|
343
|
+
options: ClearManualReadStateOptions,
|
|
344
|
+
): Promise<ReaderStateMutationResult> {
|
|
345
|
+
return writeManualState(db, scope, key, null, options)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function writeManualState(
|
|
349
|
+
db: D1Database,
|
|
350
|
+
scope: ReaderStateScope,
|
|
351
|
+
key: ContentStateKey,
|
|
352
|
+
state: ReaderManualReadState | null,
|
|
353
|
+
options: ExplicitMutationOptions,
|
|
354
|
+
): Promise<ReaderStateMutationResult> {
|
|
355
|
+
const resolvedScope = resolveScope(scope)
|
|
356
|
+
const resolvedKey = resolveContentKey(key)
|
|
357
|
+
const mutation = resolveExplicitMutation(options)
|
|
358
|
+
return executeMutation(
|
|
359
|
+
db,
|
|
360
|
+
resolvedScope,
|
|
361
|
+
resolvedKey,
|
|
362
|
+
mutation.occurredAt,
|
|
363
|
+
mutation.expectedVersion,
|
|
364
|
+
db
|
|
365
|
+
.prepare(
|
|
366
|
+
`UPDATE cms_reader_state
|
|
367
|
+
SET manual_read_state = ?, manual_state_at = ?, manual_mutation_id = ?,
|
|
368
|
+
version = version + 1,
|
|
369
|
+
updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END
|
|
370
|
+
WHERE identity_user_id = ? AND site_id = ? AND content_type = ? AND content_id = ?
|
|
371
|
+
AND (? IS NULL OR version = ?)
|
|
372
|
+
AND (manual_state_at IS NULL OR manual_state_at < ?
|
|
373
|
+
OR (manual_state_at = ? AND COALESCE(manual_mutation_id, '') < ?))
|
|
374
|
+
RETURNING *`,
|
|
375
|
+
)
|
|
376
|
+
.bind(
|
|
377
|
+
state,
|
|
378
|
+
mutation.occurredAt,
|
|
379
|
+
options.mutationId,
|
|
380
|
+
mutation.occurredAt,
|
|
381
|
+
mutation.occurredAt,
|
|
382
|
+
resolvedScope.identityUserId,
|
|
383
|
+
resolvedScope.siteId,
|
|
384
|
+
resolvedKey.contentType,
|
|
385
|
+
resolvedKey.contentId,
|
|
386
|
+
mutation.expectedVersion ?? null,
|
|
387
|
+
mutation.expectedVersion ?? null,
|
|
388
|
+
mutation.occurredAt,
|
|
389
|
+
mutation.occurredAt,
|
|
390
|
+
options.mutationId,
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export async function setSavedState(
|
|
396
|
+
db: D1Database,
|
|
397
|
+
scope: ReaderStateScope,
|
|
398
|
+
key: ContentStateKey,
|
|
399
|
+
options: SetSavedStateOptions,
|
|
400
|
+
): Promise<ReaderStateMutationResult> {
|
|
401
|
+
if (typeof options?.saved !== 'boolean') {
|
|
402
|
+
throw new ReaderStateInputError('saved reader state must be a boolean')
|
|
403
|
+
}
|
|
404
|
+
const resolvedScope = resolveScope(scope)
|
|
405
|
+
const resolvedKey = resolveContentKey(key)
|
|
406
|
+
const mutation = resolveExplicitMutation(options)
|
|
407
|
+
return executeMutation(
|
|
408
|
+
db,
|
|
409
|
+
resolvedScope,
|
|
410
|
+
resolvedKey,
|
|
411
|
+
mutation.occurredAt,
|
|
412
|
+
mutation.expectedVersion,
|
|
413
|
+
db
|
|
414
|
+
.prepare(
|
|
415
|
+
`UPDATE cms_reader_state
|
|
416
|
+
SET saved = ?, saved_at = ?, saved_mutation_id = ?,
|
|
417
|
+
version = version + 1,
|
|
418
|
+
updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END
|
|
419
|
+
WHERE identity_user_id = ? AND site_id = ? AND content_type = ? AND content_id = ?
|
|
420
|
+
AND (? IS NULL OR version = ?)
|
|
421
|
+
AND (saved_at IS NULL OR saved_at < ?
|
|
422
|
+
OR (saved_at = ? AND COALESCE(saved_mutation_id, '') < ?))
|
|
423
|
+
RETURNING *`,
|
|
424
|
+
)
|
|
425
|
+
.bind(
|
|
426
|
+
options.saved ? 1 : 0,
|
|
427
|
+
mutation.occurredAt,
|
|
428
|
+
options.mutationId,
|
|
429
|
+
mutation.occurredAt,
|
|
430
|
+
mutation.occurredAt,
|
|
431
|
+
resolvedScope.identityUserId,
|
|
432
|
+
resolvedScope.siteId,
|
|
433
|
+
resolvedKey.contentType,
|
|
434
|
+
resolvedKey.contentId,
|
|
435
|
+
mutation.expectedVersion ?? null,
|
|
436
|
+
mutation.expectedVersion ?? null,
|
|
437
|
+
mutation.occurredAt,
|
|
438
|
+
mutation.occurredAt,
|
|
439
|
+
options.mutationId,
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function setReaderPreferences(
|
|
445
|
+
db: D1Database,
|
|
446
|
+
scope: ReaderStateScope,
|
|
447
|
+
key: ContentStateKey,
|
|
448
|
+
options: SetReaderPreferencesOptions,
|
|
449
|
+
): Promise<ReaderStateMutationResult> {
|
|
450
|
+
const preferences = normalizePreferences(options?.preferences)
|
|
451
|
+
const resolvedScope = resolveScope(scope)
|
|
452
|
+
const resolvedKey = resolveContentKey(key)
|
|
453
|
+
const mutation = resolveExplicitMutation(options)
|
|
454
|
+
const preferencesJson = JSON.stringify(preferences)
|
|
455
|
+
return executeMutation(
|
|
456
|
+
db,
|
|
457
|
+
resolvedScope,
|
|
458
|
+
resolvedKey,
|
|
459
|
+
mutation.occurredAt,
|
|
460
|
+
mutation.expectedVersion,
|
|
461
|
+
db
|
|
462
|
+
.prepare(
|
|
463
|
+
`UPDATE cms_reader_state
|
|
464
|
+
SET preferences_json = ?, preferences_at = ?, preferences_mutation_id = ?,
|
|
465
|
+
version = version + 1,
|
|
466
|
+
updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END
|
|
467
|
+
WHERE identity_user_id = ? AND site_id = ? AND content_type = ? AND content_id = ?
|
|
468
|
+
AND (? IS NULL OR version = ?)
|
|
469
|
+
AND (preferences_at IS NULL OR preferences_at < ?
|
|
470
|
+
OR (preferences_at = ? AND COALESCE(preferences_mutation_id, '') < ?))
|
|
471
|
+
RETURNING *`,
|
|
472
|
+
)
|
|
473
|
+
.bind(
|
|
474
|
+
preferencesJson,
|
|
475
|
+
mutation.occurredAt,
|
|
476
|
+
options.mutationId,
|
|
477
|
+
mutation.occurredAt,
|
|
478
|
+
mutation.occurredAt,
|
|
479
|
+
resolvedScope.identityUserId,
|
|
480
|
+
resolvedScope.siteId,
|
|
481
|
+
resolvedKey.contentType,
|
|
482
|
+
resolvedKey.contentId,
|
|
483
|
+
mutation.expectedVersion ?? null,
|
|
484
|
+
mutation.expectedVersion ?? null,
|
|
485
|
+
mutation.occurredAt,
|
|
486
|
+
mutation.occurredAt,
|
|
487
|
+
options.mutationId,
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function getReaderStateBatch(
|
|
493
|
+
db: D1Database,
|
|
494
|
+
scope: ReaderStateScope,
|
|
495
|
+
keys: readonly ContentStateKey[],
|
|
496
|
+
): Promise<ReaderStateRecord[]> {
|
|
497
|
+
const resolvedScope = resolveScope(scope)
|
|
498
|
+
if (!Array.isArray(keys)) {
|
|
499
|
+
throw new ReaderStateInputError('reader state batch keys must be an array')
|
|
500
|
+
}
|
|
501
|
+
if (keys.length > MAX_READER_STATE_BATCH_SIZE) {
|
|
502
|
+
throw new ReaderStateInputError(
|
|
503
|
+
`reader state batches are limited to ${MAX_READER_STATE_BATCH_SIZE} declared content IDs`,
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
if (keys.length === 0) return []
|
|
507
|
+
const resolvedKeys = keys.map(resolveContentKey)
|
|
508
|
+
const declared = resolvedKeys.map(() => '(content_type = ? AND content_id = ?)').join(' OR ')
|
|
509
|
+
const statement = db.prepare(
|
|
510
|
+
`SELECT * FROM cms_reader_state
|
|
511
|
+
WHERE identity_user_id = ? AND site_id = ? AND (${declared})
|
|
512
|
+
ORDER BY content_type, content_id`,
|
|
513
|
+
)
|
|
514
|
+
const bindings: unknown[] = [resolvedScope.identityUserId, resolvedScope.siteId]
|
|
515
|
+
for (const key of resolvedKeys) bindings.push(key.contentType, key.contentId)
|
|
516
|
+
const result = await statement.bind(...bindings).all<CmsReaderStateRow>()
|
|
517
|
+
return (result.results ?? []).map(decodeReaderStateRow)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function listReaderState(
|
|
521
|
+
db: D1Database,
|
|
522
|
+
scope: ReaderStateScope,
|
|
523
|
+
options: ReaderStateListOptions = {},
|
|
524
|
+
): Promise<ReaderStatePage> {
|
|
525
|
+
const resolvedScope = resolveScope(scope)
|
|
526
|
+
return readReaderStatePage(db, resolvedScope, options, 'updated')
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function readReaderStatePage(
|
|
530
|
+
db: D1Database,
|
|
531
|
+
scope: ResolvedScope,
|
|
532
|
+
options: ReaderStateListOptions,
|
|
533
|
+
order: ReaderStateOrder,
|
|
534
|
+
): Promise<ReaderStatePage> {
|
|
535
|
+
const limit = resolveLimit(options.limit)
|
|
536
|
+
const cursor = options.cursor ? decodeCursor(options.cursor, order) : null
|
|
537
|
+
const orderColumn = order === 'updated' ? 'updated_at' : 'created_at'
|
|
538
|
+
let statement: ReturnType<D1Database['prepare']>
|
|
539
|
+
if (cursor) {
|
|
540
|
+
statement = db
|
|
541
|
+
.prepare(
|
|
542
|
+
`SELECT * FROM cms_reader_state
|
|
543
|
+
WHERE identity_user_id = ? AND site_id = ?
|
|
544
|
+
AND (${orderColumn} < ?
|
|
545
|
+
OR (${orderColumn} = ? AND content_type > ?)
|
|
546
|
+
OR (${orderColumn} = ? AND content_type = ? AND content_id > ?))
|
|
547
|
+
ORDER BY ${orderColumn} DESC, content_type, content_id
|
|
548
|
+
LIMIT ?`,
|
|
549
|
+
)
|
|
550
|
+
.bind(
|
|
551
|
+
scope.identityUserId,
|
|
552
|
+
scope.siteId,
|
|
553
|
+
cursor.orderedAt,
|
|
554
|
+
cursor.orderedAt,
|
|
555
|
+
cursor.contentType,
|
|
556
|
+
cursor.orderedAt,
|
|
557
|
+
cursor.contentType,
|
|
558
|
+
cursor.contentId,
|
|
559
|
+
limit + 1,
|
|
560
|
+
)
|
|
561
|
+
} else {
|
|
562
|
+
statement = db
|
|
563
|
+
.prepare(
|
|
564
|
+
`SELECT * FROM cms_reader_state
|
|
565
|
+
WHERE identity_user_id = ? AND site_id = ?
|
|
566
|
+
ORDER BY ${orderColumn} DESC, content_type, content_id
|
|
567
|
+
LIMIT ?`,
|
|
568
|
+
)
|
|
569
|
+
.bind(scope.identityUserId, scope.siteId, limit + 1)
|
|
570
|
+
}
|
|
571
|
+
const result = await statement.all<CmsReaderStateRow>()
|
|
572
|
+
const decoded = (result.results ?? []).map(decodeReaderStateRow)
|
|
573
|
+
const hasMore = decoded.length > limit
|
|
574
|
+
const items = hasMore ? decoded.slice(0, limit) : decoded
|
|
575
|
+
const last = items.at(-1)
|
|
576
|
+
return {
|
|
577
|
+
items,
|
|
578
|
+
nextCursor: hasMore && last ? encodeCursor(last, order) : null,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export async function exportReaderStatePage(
|
|
583
|
+
db: D1Database,
|
|
584
|
+
scope: ReaderStateScope,
|
|
585
|
+
options: ReaderStateListOptions = {},
|
|
586
|
+
): Promise<ReaderStateExportPage> {
|
|
587
|
+
const resolvedScope = resolveScope(scope)
|
|
588
|
+
const page = await readReaderStatePage(db, resolvedScope, options, 'created')
|
|
589
|
+
return {
|
|
590
|
+
...page,
|
|
591
|
+
scope: resolvedScope,
|
|
592
|
+
exportedAt: Date.now(),
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Consent-withdrawal/privacy clear: remove behavioral history while retaining
|
|
598
|
+
* explicit read/unread, save, and stated preference choices.
|
|
599
|
+
*/
|
|
600
|
+
export async function clearReaderHistory(db: D1Database, scope: ReaderStateScope): Promise<number> {
|
|
601
|
+
const resolvedScope = resolveScope(scope)
|
|
602
|
+
const now = Date.now()
|
|
603
|
+
const result = await db
|
|
604
|
+
.prepare(
|
|
605
|
+
`UPDATE cms_reader_state
|
|
606
|
+
SET first_opened_at = NULL, last_opened_at = NULL,
|
|
607
|
+
max_progress_bps = 0, media_position_ms = NULL, media_duration_ms = NULL,
|
|
608
|
+
progress_observed_at = NULL, automatic_completed_at = NULL,
|
|
609
|
+
version = version + 1, updated_at = ?
|
|
610
|
+
WHERE identity_user_id = ? AND site_id = ?
|
|
611
|
+
AND (first_opened_at IS NOT NULL OR last_opened_at IS NOT NULL
|
|
612
|
+
OR max_progress_bps <> 0 OR media_position_ms IS NOT NULL
|
|
613
|
+
OR media_duration_ms IS NOT NULL OR progress_observed_at IS NOT NULL
|
|
614
|
+
OR automatic_completed_at IS NOT NULL)`,
|
|
615
|
+
)
|
|
616
|
+
.bind(now, resolvedScope.identityUserId, resolvedScope.siteId)
|
|
617
|
+
.run()
|
|
618
|
+
return resultChanges(result)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Delete every row for exactly one verified user/site scope. */
|
|
622
|
+
export async function deleteReaderState(db: D1Database, scope: ReaderStateScope): Promise<number> {
|
|
623
|
+
const resolvedScope = resolveScope(scope)
|
|
624
|
+
const result = await db
|
|
625
|
+
.prepare('DELETE FROM cms_reader_state WHERE identity_user_id = ? AND site_id = ?')
|
|
626
|
+
.bind(resolvedScope.identityUserId, resolvedScope.siteId)
|
|
627
|
+
.run()
|
|
628
|
+
return resultChanges(result)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function executeMutation(
|
|
632
|
+
db: D1Database,
|
|
633
|
+
scope: ResolvedScope,
|
|
634
|
+
key: ContentStateKey,
|
|
635
|
+
occurredAt: number,
|
|
636
|
+
expectedVersion: number | undefined,
|
|
637
|
+
update: ReturnType<D1Database['prepare']>,
|
|
638
|
+
): Promise<ReaderStateMutationResult> {
|
|
639
|
+
const expected = expectedVersion ?? null
|
|
640
|
+
const ensure = db
|
|
641
|
+
.prepare(
|
|
642
|
+
`INSERT OR IGNORE INTO cms_reader_state
|
|
643
|
+
(identity_user_id, site_id, content_type, content_id, created_at, updated_at)
|
|
644
|
+
SELECT ?, ?, ?, ?, ?, ?
|
|
645
|
+
WHERE ? IS NULL OR ? = 0`,
|
|
646
|
+
)
|
|
647
|
+
.bind(
|
|
648
|
+
scope.identityUserId,
|
|
649
|
+
scope.siteId,
|
|
650
|
+
key.contentType,
|
|
651
|
+
key.contentId,
|
|
652
|
+
occurredAt,
|
|
653
|
+
occurredAt,
|
|
654
|
+
expected,
|
|
655
|
+
expected,
|
|
656
|
+
)
|
|
657
|
+
const [, updateResult] = await db.batch([ensure, update])
|
|
658
|
+
const returnedRow = updateResult?.results?.[0] as CmsReaderStateRow | undefined
|
|
659
|
+
if (returnedRow) {
|
|
660
|
+
return { applied: true, reason: 'applied', state: decodeReaderStateRow(returnedRow) }
|
|
661
|
+
}
|
|
662
|
+
if (resultChanges(updateResult) > 0) {
|
|
663
|
+
throw new ReaderStateDurableStateError(
|
|
664
|
+
'reader state mutation changed a row without returning its atomic state',
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
const state = await loadReaderState(db, scope, key)
|
|
668
|
+
if (!state) {
|
|
669
|
+
if (expectedVersion !== undefined) {
|
|
670
|
+
throw new ReaderStateConflictError(expectedVersion, null)
|
|
671
|
+
}
|
|
672
|
+
throw new ReaderStateDurableStateError('reader state mutation did not produce a durable row')
|
|
673
|
+
}
|
|
674
|
+
if (expectedVersion !== undefined && state.version !== expectedVersion) {
|
|
675
|
+
throw new ReaderStateConflictError(expectedVersion, state.version)
|
|
676
|
+
}
|
|
677
|
+
return { applied: false, reason: 'stale', state }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function loadReaderState(
|
|
681
|
+
db: D1Database,
|
|
682
|
+
scope: ResolvedScope,
|
|
683
|
+
key: ContentStateKey,
|
|
684
|
+
): Promise<ReaderStateRecord | null> {
|
|
685
|
+
const row = await db
|
|
686
|
+
.prepare(
|
|
687
|
+
`SELECT * FROM cms_reader_state
|
|
688
|
+
WHERE identity_user_id = ? AND site_id = ? AND content_type = ? AND content_id = ?`,
|
|
689
|
+
)
|
|
690
|
+
.bind(scope.identityUserId, scope.siteId, key.contentType, key.contentId)
|
|
691
|
+
.first<CmsReaderStateRow>()
|
|
692
|
+
return row ? decodeReaderStateRow(row) : null
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function decodeReaderStateRow(row: CmsReaderStateRow): ReaderStateRecord {
|
|
696
|
+
let preferences: ReaderPreferences
|
|
697
|
+
try {
|
|
698
|
+
preferences = normalizePreferences(JSON.parse(row.preferences_json))
|
|
699
|
+
} catch (error) {
|
|
700
|
+
const detail = error instanceof Error ? `: ${error.message}` : ''
|
|
701
|
+
throw new ReaderStateDurableStateError(`reader state contains unreadable preferences${detail}`)
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
identityUserId: row.identity_user_id,
|
|
705
|
+
siteId: row.site_id,
|
|
706
|
+
contentType: row.content_type,
|
|
707
|
+
contentId: row.content_id,
|
|
708
|
+
firstOpenedAt: row.first_opened_at,
|
|
709
|
+
lastOpenedAt: row.last_opened_at,
|
|
710
|
+
maxProgressBasisPoints: row.max_progress_bps,
|
|
711
|
+
mediaPositionMs: row.media_position_ms,
|
|
712
|
+
mediaDurationMs: row.media_duration_ms,
|
|
713
|
+
progressObservedAt: row.progress_observed_at,
|
|
714
|
+
automaticCompletedAt: row.automatic_completed_at,
|
|
715
|
+
manualReadState: row.manual_read_state,
|
|
716
|
+
manualStateAt: row.manual_state_at,
|
|
717
|
+
saved: row.saved === 1,
|
|
718
|
+
savedAt: row.saved_at,
|
|
719
|
+
preferences,
|
|
720
|
+
preferencesAt: row.preferences_at,
|
|
721
|
+
version: row.version,
|
|
722
|
+
effectiveRead:
|
|
723
|
+
row.manual_read_state === 'read' ||
|
|
724
|
+
(row.manual_read_state === null && row.automatic_completed_at !== null),
|
|
725
|
+
createdAt: row.created_at,
|
|
726
|
+
updatedAt: row.updated_at,
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function resolveScope(scope: ReaderStateScope): ResolvedScope {
|
|
731
|
+
if (!scope || typeof scope !== 'object') {
|
|
732
|
+
throw new ReaderStateInputError('reader state requires a verified scope')
|
|
733
|
+
}
|
|
734
|
+
const identityUserId = scope.identityUserId
|
|
735
|
+
if (
|
|
736
|
+
typeof identityUserId !== 'string' ||
|
|
737
|
+
!(UUIDV4_PATTERN.test(identityUserId) || ULID_PATTERN.test(identityUserId))
|
|
738
|
+
) {
|
|
739
|
+
throw new ReaderStateInputError(
|
|
740
|
+
'reader state requires a verified raw realm user id, never an analytics, local, email, or prefixed subject identifier',
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
if (typeof scope.siteId !== 'string' || !SITE_ID_PATTERN.test(scope.siteId)) {
|
|
744
|
+
throw new ReaderStateInputError(
|
|
745
|
+
'reader state requires a verified stable lowercase site id such as "fronts"',
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
identityUserId: UUIDV4_PATTERN.test(identityUserId)
|
|
750
|
+
? identityUserId.toLowerCase()
|
|
751
|
+
: identityUserId,
|
|
752
|
+
siteId: scope.siteId,
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function resolveContentKey(key: ContentStateKey): ContentStateKey {
|
|
757
|
+
if (!key || typeof key !== 'object' || !CONTENT_TYPES.has(key.contentType)) {
|
|
758
|
+
throw new ReaderStateInputError('reader state requires a supported CMS content type')
|
|
759
|
+
}
|
|
760
|
+
if (typeof key.contentId !== 'string' || !CONTENT_ID_PATTERN.test(key.contentId)) {
|
|
761
|
+
throw new ReaderStateInputError(
|
|
762
|
+
'reader state requires a stable CMS content id using URL-safe identifier characters',
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
return { contentType: key.contentType, contentId: key.contentId }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function resolveBehavioralConsent(options: BehavioralWriteOptions): ReaderBehavioralConsent {
|
|
769
|
+
if (!options || typeof options !== 'object') {
|
|
770
|
+
throw new ReaderStateInputError('reader behavioral writes require explicit consent state')
|
|
771
|
+
}
|
|
772
|
+
if (options.behavioralConsent !== 'granted' && options.behavioralConsent !== 'denied') {
|
|
773
|
+
throw new ReaderStateInputError('behavioralConsent must be "granted" or "denied"')
|
|
774
|
+
}
|
|
775
|
+
return options.behavioralConsent
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function resolveExplicitMutation(options: ExplicitMutationOptions): ResolvedMutation {
|
|
779
|
+
if (!options || typeof options !== 'object') {
|
|
780
|
+
throw new ReaderStateInputError('explicit reader state writes require mutation options')
|
|
781
|
+
}
|
|
782
|
+
if (typeof options.mutationId !== 'string' || !MUTATION_ID_PATTERN.test(options.mutationId)) {
|
|
783
|
+
throw new ReaderStateInputError('explicit reader state writes require a stable mutationId')
|
|
784
|
+
}
|
|
785
|
+
return resolveMutation(options)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function resolveMutation(options: {
|
|
789
|
+
occurredAt?: number
|
|
790
|
+
expectedVersion?: number
|
|
791
|
+
}): ResolvedMutation {
|
|
792
|
+
const occurredAt = options.occurredAt ?? Date.now()
|
|
793
|
+
if (!Number.isSafeInteger(occurredAt) || occurredAt < 0) {
|
|
794
|
+
throw new ReaderStateInputError(
|
|
795
|
+
'reader state occurredAt must be a non-negative epoch millisecond',
|
|
796
|
+
)
|
|
797
|
+
}
|
|
798
|
+
if (occurredAt > Date.now() + MAX_EVENT_FUTURE_SKEW_MS) {
|
|
799
|
+
throw new ReaderStateInputError(
|
|
800
|
+
'reader state occurredAt exceeds the allowed five-minute clock skew',
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
if (
|
|
804
|
+
options.expectedVersion !== undefined &&
|
|
805
|
+
(!Number.isSafeInteger(options.expectedVersion) || options.expectedVersion < 0)
|
|
806
|
+
) {
|
|
807
|
+
throw new ReaderStateInputError('reader state expectedVersion must be a non-negative integer')
|
|
808
|
+
}
|
|
809
|
+
return { occurredAt, expectedVersion: options.expectedVersion }
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function assertBasisPoints(value: number): number {
|
|
813
|
+
if (!Number.isInteger(value) || value < 0 || value > 10_000) {
|
|
814
|
+
throw new ReaderStateInputError('reader progress must be an integer from 0 through 10000')
|
|
815
|
+
}
|
|
816
|
+
return value
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function optionalNonNegativeInteger(value: number | undefined, name: string): number | undefined {
|
|
820
|
+
if (value === undefined) return undefined
|
|
821
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
822
|
+
throw new ReaderStateInputError(`reader ${name} must be a non-negative integer`)
|
|
823
|
+
}
|
|
824
|
+
return value
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function normalizePreferences(value: unknown): ReaderPreferences {
|
|
828
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
829
|
+
throw new ReaderStateInputError('reader preferences must be a plain object of stated values')
|
|
830
|
+
}
|
|
831
|
+
const prototype = Object.getPrototypeOf(value)
|
|
832
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
833
|
+
throw new ReaderStateInputError('reader preferences must be a plain object')
|
|
834
|
+
}
|
|
835
|
+
const entries = Object.entries(value)
|
|
836
|
+
if (entries.length > MAX_PREFERENCE_KEYS) {
|
|
837
|
+
throw new ReaderStateInputError(`reader preferences are limited to ${MAX_PREFERENCE_KEYS} keys`)
|
|
838
|
+
}
|
|
839
|
+
const normalized: ReaderPreferences = Object.create(null)
|
|
840
|
+
for (const [key, preference] of entries.sort(([a], [b]) => a.localeCompare(b))) {
|
|
841
|
+
if (
|
|
842
|
+
!PREFERENCE_KEY_PATTERN.test(key) ||
|
|
843
|
+
['__proto__', 'constructor', 'prototype'].includes(key)
|
|
844
|
+
) {
|
|
845
|
+
throw new ReaderStateInputError('reader preference keys must use safe stable identifiers')
|
|
846
|
+
}
|
|
847
|
+
if (
|
|
848
|
+
preference !== null &&
|
|
849
|
+
typeof preference !== 'string' &&
|
|
850
|
+
typeof preference !== 'boolean' &&
|
|
851
|
+
(typeof preference !== 'number' || !Number.isFinite(preference))
|
|
852
|
+
) {
|
|
853
|
+
throw new ReaderStateInputError('reader preference values must be finite scalar values')
|
|
854
|
+
}
|
|
855
|
+
normalized[key] = preference as ReaderPreferenceValue
|
|
856
|
+
}
|
|
857
|
+
if (
|
|
858
|
+
new TextEncoder().encode(JSON.stringify(normalized)).byteLength > MAX_PREFERENCES_JSON_BYTES
|
|
859
|
+
) {
|
|
860
|
+
throw new ReaderStateInputError(
|
|
861
|
+
`reader preferences are limited to ${MAX_PREFERENCES_JSON_BYTES} encoded bytes`,
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
return normalized
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function resolveLimit(value: number | undefined): number {
|
|
868
|
+
const limit = value ?? DEFAULT_READER_STATE_BATCH_SIZE
|
|
869
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_READER_STATE_BATCH_SIZE) {
|
|
870
|
+
throw new ReaderStateInputError(
|
|
871
|
+
`reader state list limits must be from 1 through ${MAX_READER_STATE_BATCH_SIZE}`,
|
|
872
|
+
)
|
|
873
|
+
}
|
|
874
|
+
return limit
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
type ReaderStateOrder = 'updated' | 'created'
|
|
878
|
+
|
|
879
|
+
interface ReaderStateCursor {
|
|
880
|
+
order: ReaderStateOrder
|
|
881
|
+
orderedAt: number
|
|
882
|
+
contentType: ContentType
|
|
883
|
+
contentId: string
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function encodeCursor(record: ReaderStateRecord, order: ReaderStateOrder): string {
|
|
887
|
+
const orderedAt = order === 'updated' ? record.updatedAt : record.createdAt
|
|
888
|
+
return encodeURIComponent(
|
|
889
|
+
JSON.stringify([order, orderedAt, record.contentType, record.contentId]),
|
|
890
|
+
)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function decodeCursor(cursor: string, expectedOrder: ReaderStateOrder): ReaderStateCursor {
|
|
894
|
+
try {
|
|
895
|
+
const parsed = JSON.parse(decodeURIComponent(cursor))
|
|
896
|
+
if (
|
|
897
|
+
!Array.isArray(parsed) ||
|
|
898
|
+
parsed.length !== 4 ||
|
|
899
|
+
parsed[0] !== expectedOrder ||
|
|
900
|
+
!Number.isSafeInteger(parsed[1])
|
|
901
|
+
) {
|
|
902
|
+
throw new Error('shape')
|
|
903
|
+
}
|
|
904
|
+
const key = resolveContentKey({ contentType: parsed[2], contentId: parsed[3] })
|
|
905
|
+
return { order: expectedOrder, orderedAt: parsed[1], ...key }
|
|
906
|
+
} catch (error) {
|
|
907
|
+
if (error instanceof ReaderStateInputError) throw error
|
|
908
|
+
throw new ReaderStateInputError('reader state cursor is invalid')
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function resultChanges(result: D1Result | undefined): number {
|
|
913
|
+
const changes = result?.meta?.changes
|
|
914
|
+
if (typeof changes !== 'number' || !Number.isFinite(changes)) {
|
|
915
|
+
throw new ReaderStateDurableStateError('reader state mutation returned no finite change count')
|
|
916
|
+
}
|
|
917
|
+
return changes
|
|
918
|
+
}
|