@growth-labs/cms 0.1.3 → 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.
Files changed (40) hide show
  1. package/README.md +207 -0
  2. package/dist/engine/index.d.ts +2 -0
  3. package/dist/engine/index.d.ts.map +1 -1
  4. package/dist/engine/index.js +4 -0
  5. package/dist/engine/index.js.map +1 -1
  6. package/dist/engine/publication.d.ts +210 -0
  7. package/dist/engine/publication.d.ts.map +1 -0
  8. package/dist/engine/publication.js +1436 -0
  9. package/dist/engine/publication.js.map +1 -0
  10. package/dist/engine/reader-state.d.ts +127 -0
  11. package/dist/engine/reader-state.d.ts.map +1 -0
  12. package/dist/engine/reader-state.js +493 -0
  13. package/dist/engine/reader-state.js.map +1 -0
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/schema/index.d.ts +1 -1
  19. package/dist/schema/index.d.ts.map +1 -1
  20. package/dist/schema/migrations.d.ts.map +1 -1
  21. package/dist/schema/migrations.js +65 -0
  22. package/dist/schema/migrations.js.map +1 -1
  23. package/dist/schema/tables.d.ts +1 -1
  24. package/dist/schema/tables.d.ts.map +1 -1
  25. package/dist/schema/tables.js +6 -2
  26. package/dist/schema/tables.js.map +1 -1
  27. package/dist/schema/types.d.ts +47 -0
  28. package/dist/schema/types.d.ts.map +1 -1
  29. package/dist/schema/types.js.map +1 -1
  30. package/migrations/0018_content_publication_attempts.sql +23 -0
  31. package/migrations/0019_reader_state.sql +34 -0
  32. package/package.json +1 -1
  33. package/src/engine/index.ts +77 -0
  34. package/src/engine/publication.ts +2152 -0
  35. package/src/engine/reader-state.ts +918 -0
  36. package/src/index.ts +7 -2
  37. package/src/schema/index.ts +5 -0
  38. package/src/schema/migrations.ts +65 -0
  39. package/src/schema/tables.ts +6 -2
  40. package/src/schema/types.ts +60 -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
+ }