@growth-labs/cms 0.3.1 → 0.4.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 (41) hide show
  1. package/README.md +58 -5
  2. package/dist/engine/content-metadata.d.ts +13 -0
  3. package/dist/engine/content-metadata.d.ts.map +1 -0
  4. package/dist/engine/content-metadata.js +80 -0
  5. package/dist/engine/content-metadata.js.map +1 -0
  6. package/dist/engine/index.d.ts +3 -1
  7. package/dist/engine/index.d.ts.map +1 -1
  8. package/dist/engine/index.js +5 -1
  9. package/dist/engine/index.js.map +1 -1
  10. package/dist/engine/publication.d.ts +29 -1
  11. package/dist/engine/publication.d.ts.map +1 -1
  12. package/dist/engine/publication.js +197 -15
  13. package/dist/engine/publication.js.map +1 -1
  14. package/dist/engine/published-content.d.ts +40 -0
  15. package/dist/engine/published-content.d.ts.map +1 -0
  16. package/dist/engine/published-content.js +388 -0
  17. package/dist/engine/published-content.js.map +1 -0
  18. package/dist/engine/publisher.d.ts +16 -1
  19. package/dist/engine/publisher.d.ts.map +1 -1
  20. package/dist/engine/publisher.js +74 -11
  21. package/dist/engine/publisher.js.map +1 -1
  22. package/dist/engine/revisions.d.ts.map +1 -1
  23. package/dist/engine/revisions.js +2 -0
  24. package/dist/engine/revisions.js.map +1 -1
  25. package/dist/schema/migrations.d.ts.map +1 -1
  26. package/dist/schema/migrations.js +24 -0
  27. package/dist/schema/migrations.js.map +1 -1
  28. package/dist/schema/types.d.ts +6 -0
  29. package/dist/schema/types.d.ts.map +1 -1
  30. package/dist/schema/types.js.map +1 -1
  31. package/migrations/0020_content_metadata_read_model.sql +19 -0
  32. package/migrations/0021_published_revision_slug_index.sql +5 -0
  33. package/package.json +1 -1
  34. package/src/engine/content-metadata.ts +122 -0
  35. package/src/engine/index.ts +26 -0
  36. package/src/engine/publication.ts +329 -18
  37. package/src/engine/published-content.ts +581 -0
  38. package/src/engine/publisher.ts +106 -10
  39. package/src/engine/revisions.ts +4 -0
  40. package/src/schema/migrations.ts +24 -0
  41. package/src/schema/types.ts +6 -0
@@ -0,0 +1,581 @@
1
+ import type { ContentType } from '../schema/types.js'
2
+ import {
3
+ type ContentMetadata,
4
+ ContentMetadataError,
5
+ parseContentMetadata,
6
+ } from './content-metadata.js'
7
+ import type { D1Database } from './d1.js'
8
+ import type { ContentRevisionPayload } from './revisions.js'
9
+
10
+ const CONTENT_TYPES = new Set<ContentType>(['article', 'video', 'podcast', 'newsletter', 'page'])
11
+ const CONTENT_STATUSES = new Set(['draft', 'scheduled', 'review', 'published', 'archived'])
12
+ const CONTENT_VISIBILITIES = new Set(['free', 'premium'])
13
+ const LIST_OPTION_KEYS = new Set(['type', 'channel', 'limit', 'cursor'])
14
+ const CURSOR_KEYS = new Set(['publishedAt', 'contentId'])
15
+
16
+ export const MAX_PUBLISHED_CONTENT_PAGE_SIZE = 100
17
+ export const MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES = 4 * 1024 * 1024
18
+ export const MAX_PUBLISHED_CONTENT_PAGE_BYTES = 4 * 1024 * 1024
19
+
20
+ export type PublishedContentReadErrorCode =
21
+ | 'published_content_input_invalid'
22
+ | 'published_revision_missing'
23
+ | 'published_revision_mismatch'
24
+ | 'published_payload_invalid'
25
+ | 'published_payload_limit_exceeded'
26
+ | 'published_slug_ambiguous'
27
+ | 'published_read_failed'
28
+
29
+ export class PublishedContentReadError extends Error {
30
+ constructor(
31
+ readonly code: PublishedContentReadErrorCode,
32
+ message: string,
33
+ options?: ErrorOptions,
34
+ ) {
35
+ super(message, options)
36
+ this.name = 'PublishedContentReadError'
37
+ }
38
+ }
39
+
40
+ export interface PublishedContent {
41
+ contentId: string
42
+ revisionId: string
43
+ slug: string
44
+ type: ContentType
45
+ publishedAt: number
46
+ updatedAt: number
47
+ payload: ContentRevisionPayload
48
+ metadata: ContentMetadata
49
+ }
50
+
51
+ export interface PublishedContentCursor {
52
+ publishedAt: number
53
+ contentId: string
54
+ }
55
+
56
+ export interface ListPublishedContentOptions {
57
+ type?: ContentType
58
+ channel?: string
59
+ limit?: number
60
+ cursor?: PublishedContentCursor | null
61
+ }
62
+
63
+ export interface PublishedContentPage {
64
+ items: PublishedContent[]
65
+ nextCursor: PublishedContentCursor | null
66
+ }
67
+
68
+ interface PublishedContentRow {
69
+ content_id: string
70
+ pointer_revision_id: string
71
+ canonical_published_at: number
72
+ revision_id: string | null
73
+ revision_content_id: string | null
74
+ payload_json: string | null
75
+ payload_bytes: number | null
76
+ }
77
+
78
+ interface PublishedContentOutlineRow {
79
+ content_id: string
80
+ pointer_revision_id: string
81
+ canonical_published_at: number
82
+ payload_bytes: number
83
+ }
84
+
85
+ const SELECT_PUBLISHED_CONTENT = `
86
+ SELECT
87
+ ci.id AS content_id,
88
+ ci.published_revision_id AS pointer_revision_id,
89
+ ci.published_at AS canonical_published_at,
90
+ cr.id AS revision_id,
91
+ cr.content_id AS revision_content_id,
92
+ CASE
93
+ WHEN length(CAST(cr.payload_json AS BLOB)) <= ${MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES}
94
+ THEN cr.payload_json
95
+ ELSE NULL
96
+ END AS payload_json,
97
+ length(CAST(cr.payload_json AS BLOB)) AS payload_bytes
98
+ FROM content_items ci
99
+ `
100
+
101
+ const SELECT_PUBLISHED_CONTENT_OUTLINE = `
102
+ SELECT
103
+ ci.id AS content_id,
104
+ ci.published_revision_id AS pointer_revision_id,
105
+ ci.published_at AS canonical_published_at,
106
+ length(CAST(cr.payload_json AS BLOB)) AS payload_bytes
107
+ FROM content_items ci
108
+ `
109
+
110
+ const SELECT_PUBLISHED_CONTENT_BY_SLUG = `
111
+ SELECT
112
+ ci.id AS content_id,
113
+ ci.published_revision_id AS pointer_revision_id,
114
+ ci.published_at AS canonical_published_at,
115
+ cr.id AS revision_id,
116
+ cr.content_id AS revision_content_id,
117
+ CASE
118
+ WHEN length(CAST(cr.payload_json AS BLOB)) <= ${MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES}
119
+ THEN cr.payload_json
120
+ ELSE NULL
121
+ END AS payload_json,
122
+ length(CAST(cr.payload_json AS BLOB)) AS payload_bytes
123
+ FROM content_revisions cr INDEXED BY idx_content_revisions_published_slug
124
+ JOIN content_items ci
125
+ ON ci.published_revision_id = cr.id
126
+ AND cr.content_id = ci.id
127
+ `
128
+
129
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
130
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
131
+ const prototype = Object.getPrototypeOf(value)
132
+ return prototype === Object.prototype || prototype === null
133
+ }
134
+
135
+ function byteLength(value: string): number {
136
+ return new TextEncoder().encode(value).byteLength
137
+ }
138
+
139
+ function invalidPayload(message: string): never {
140
+ throw new PublishedContentReadError('published_payload_invalid', message)
141
+ }
142
+
143
+ function validatePayload(raw: string, row: PublishedContentRow): ContentRevisionPayload {
144
+ if (byteLength(raw) > MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES) {
145
+ throw new PublishedContentReadError(
146
+ 'published_payload_limit_exceeded',
147
+ `published revision payload exceeds ${MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES} bytes`,
148
+ )
149
+ }
150
+
151
+ let decoded: unknown
152
+ try {
153
+ decoded = JSON.parse(raw)
154
+ } catch {
155
+ return invalidPayload('published revision payload is not valid JSON')
156
+ }
157
+ if (!isPlainRecord(decoded)) return invalidPayload('published revision payload must be an object')
158
+ if (!isPlainRecord(decoded.item))
159
+ return invalidPayload('published revision item must be an object')
160
+ if (decoded.content !== null && !isPlainRecord(decoded.content)) {
161
+ return invalidPayload('published revision content must be an object or null')
162
+ }
163
+ if (!Array.isArray(decoded.tags) || !decoded.tags.every((tag) => typeof tag === 'string')) {
164
+ return invalidPayload('published revision tags must be strings')
165
+ }
166
+ if (
167
+ decoded.tags.length > 10 ||
168
+ decoded.tags.some((tag) => tag.length === 0 || tag.length > 256)
169
+ ) {
170
+ return invalidPayload('published revision tags exceed their canonical bounds')
171
+ }
172
+ if (
173
+ !Array.isArray(decoded.related) ||
174
+ decoded.related.length > 100 ||
175
+ !decoded.related.every(
176
+ (relation) =>
177
+ isPlainRecord(relation) &&
178
+ typeof relation.related_id === 'string' &&
179
+ relation.related_id.length > 0 &&
180
+ relation.related_id.length <= 256 &&
181
+ Number.isSafeInteger(relation.rank) &&
182
+ (relation.rank as number) >= 0 &&
183
+ (relation.reason === null || typeof relation.reason === 'string'),
184
+ )
185
+ ) {
186
+ return invalidPayload('published revision relations are malformed')
187
+ }
188
+
189
+ const item = decoded.item
190
+ if (item.id !== row.content_id) {
191
+ throw new PublishedContentReadError(
192
+ 'published_revision_mismatch',
193
+ 'published revision item does not belong to the canonical content item',
194
+ )
195
+ }
196
+ if (typeof item.slug !== 'string' || item.slug.length === 0) {
197
+ return invalidPayload('published revision slug is invalid')
198
+ }
199
+ if (item.slug.length > 512) return invalidPayload('published revision slug is too long')
200
+ if (typeof item.type !== 'string' || !CONTENT_TYPES.has(item.type as ContentType)) {
201
+ return invalidPayload('published revision type is invalid')
202
+ }
203
+ if (typeof item.title !== 'string' || !item.title.trim() || item.title.length > 1_024) {
204
+ return invalidPayload('published revision title is invalid')
205
+ }
206
+ if (typeof item.status !== 'string' || !CONTENT_STATUSES.has(item.status)) {
207
+ return invalidPayload('published revision status is invalid')
208
+ }
209
+ if (typeof item.visibility !== 'string' || !CONTENT_VISIBILITIES.has(item.visibility)) {
210
+ return invalidPayload('published revision visibility is invalid')
211
+ }
212
+ if (!Number.isSafeInteger(item.updated_at) || (item.updated_at as number) < 0) {
213
+ return invalidPayload('published revision updated_at is invalid')
214
+ }
215
+ if (item.metadata_json !== undefined && typeof item.metadata_json !== 'string') {
216
+ return invalidPayload('published revision metadata must be JSON text')
217
+ }
218
+ const content = decoded.content
219
+ if (!content) return invalidPayload('published revision content is missing')
220
+ if (
221
+ ['article', 'newsletter', 'page'].includes(item.type as string) &&
222
+ typeof content.body_markdown !== 'string'
223
+ ) {
224
+ return invalidPayload('published revision body Markdown is invalid')
225
+ }
226
+ if (item.type === 'video' && typeof content.video_id !== 'string') {
227
+ return invalidPayload('published revision video ID is invalid')
228
+ }
229
+ if (item.type === 'podcast' && typeof content.audio_r2_key !== 'string') {
230
+ return invalidPayload('published revision podcast audio key is invalid')
231
+ }
232
+
233
+ return decoded as unknown as ContentRevisionPayload
234
+ }
235
+
236
+ function decodePublishedContent(row: PublishedContentRow): PublishedContent {
237
+ if (!row.revision_id || !row.revision_content_id || row.payload_bytes === null) {
238
+ throw new PublishedContentReadError(
239
+ 'published_revision_missing',
240
+ 'canonical publication pointer does not resolve to a revision',
241
+ )
242
+ }
243
+ if (!Number.isSafeInteger(row.payload_bytes) || row.payload_bytes < 1) {
244
+ throw new PublishedContentReadError(
245
+ 'published_payload_invalid',
246
+ 'published revision payload length is invalid',
247
+ )
248
+ }
249
+ if (row.payload_bytes > MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES) {
250
+ throw new PublishedContentReadError(
251
+ 'published_payload_limit_exceeded',
252
+ `published revision payload exceeds ${MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES} bytes`,
253
+ )
254
+ }
255
+ if (row.payload_json === null || byteLength(row.payload_json) !== row.payload_bytes) {
256
+ throw new PublishedContentReadError(
257
+ 'published_read_failed',
258
+ 'published revision changed during its bounded read',
259
+ )
260
+ }
261
+ if (row.pointer_revision_id !== row.revision_id || row.revision_content_id !== row.content_id) {
262
+ throw new PublishedContentReadError(
263
+ 'published_revision_mismatch',
264
+ 'canonical publication pointer resolves to a revision owned by another item',
265
+ )
266
+ }
267
+ if (!Number.isSafeInteger(row.canonical_published_at) || row.canonical_published_at < 0) {
268
+ throw new PublishedContentReadError(
269
+ 'published_payload_invalid',
270
+ 'canonical published_at is invalid',
271
+ )
272
+ }
273
+
274
+ const payload = validatePayload(row.payload_json, row)
275
+ const item = payload.item
276
+ let metadata: ContentMetadata
277
+ try {
278
+ metadata = parseContentMetadata(
279
+ typeof item.metadata_json === 'undefined' ? '{}' : item.metadata_json,
280
+ )
281
+ } catch (error) {
282
+ if (error instanceof ContentMetadataError) {
283
+ throw new PublishedContentReadError(
284
+ 'published_payload_invalid',
285
+ `published revision metadata is invalid: ${error.message}`,
286
+ )
287
+ }
288
+ throw error
289
+ }
290
+
291
+ return {
292
+ contentId: row.content_id,
293
+ revisionId: row.revision_id,
294
+ slug: item.slug as string,
295
+ type: item.type as ContentType,
296
+ publishedAt: row.canonical_published_at,
297
+ updatedAt: item.updated_at as number,
298
+ payload,
299
+ metadata,
300
+ }
301
+ }
302
+
303
+ function assertLookupValue(
304
+ value: unknown,
305
+ field: string,
306
+ maxLength: number,
307
+ ): asserts value is string {
308
+ if (typeof value !== 'string' || !value || value.length > maxLength) {
309
+ throw new PublishedContentReadError(
310
+ 'published_content_input_invalid',
311
+ `${field} must be between 1 and ${maxLength} characters`,
312
+ )
313
+ }
314
+ }
315
+
316
+ function wrapReadError(error: unknown): never {
317
+ if (error instanceof PublishedContentReadError) throw error
318
+ throw new PublishedContentReadError(
319
+ 'published_read_failed',
320
+ 'published content could not be read safely',
321
+ { cause: error },
322
+ )
323
+ }
324
+
325
+ export async function getPublishedContentById(
326
+ db: D1Database,
327
+ contentId: string,
328
+ ): Promise<PublishedContent | null> {
329
+ assertLookupValue(contentId, 'contentId', 256)
330
+ try {
331
+ const row = await db
332
+ .prepare(
333
+ `${SELECT_PUBLISHED_CONTENT}
334
+ LEFT JOIN content_revisions cr
335
+ ON cr.id = ci.published_revision_id
336
+ WHERE ci.id = ?
337
+ AND ci.status = 'published'
338
+ AND ci.deleted_at IS NULL
339
+ AND ci.published_revision_id IS NOT NULL
340
+ AND ci.published_at IS NOT NULL
341
+ LIMIT 1`,
342
+ )
343
+ .bind(contentId)
344
+ .first<PublishedContentRow>()
345
+ return row ? decodePublishedContent(row) : null
346
+ } catch (error) {
347
+ return wrapReadError(error)
348
+ }
349
+ }
350
+
351
+ export async function getPublishedContentBySlug(
352
+ db: D1Database,
353
+ slug: string,
354
+ ): Promise<PublishedContent | null> {
355
+ assertLookupValue(slug, 'slug', 512)
356
+ try {
357
+ const result = await db
358
+ .prepare(
359
+ `${SELECT_PUBLISHED_CONTENT_BY_SLUG}
360
+ WHERE ci.status = 'published'
361
+ AND ci.deleted_at IS NULL
362
+ AND ci.published_revision_id IS NOT NULL
363
+ AND ci.published_at IS NOT NULL
364
+ AND json_extract(cr.payload_json, '$.item.slug') = ?
365
+ ORDER BY ci.id DESC
366
+ LIMIT 2`,
367
+ )
368
+ .bind(slug)
369
+ .all<PublishedContentRow>()
370
+ const rows = result.results ?? []
371
+ if (rows.length === 0) return null
372
+ if (rows.length > 1) {
373
+ throw new PublishedContentReadError(
374
+ 'published_slug_ambiguous',
375
+ 'published slug resolves to more than one canonical content item',
376
+ )
377
+ }
378
+ return decodePublishedContent(rows[0] as PublishedContentRow)
379
+ } catch (error) {
380
+ return wrapReadError(error)
381
+ }
382
+ }
383
+
384
+ function normalizeListOptions(options: unknown): {
385
+ limit: number
386
+ type?: ContentType
387
+ channel?: string
388
+ cursor?: PublishedContentCursor
389
+ } {
390
+ if (!isPlainRecord(options)) {
391
+ throw new PublishedContentReadError(
392
+ 'published_content_input_invalid',
393
+ 'published content options must be an object',
394
+ )
395
+ }
396
+ if (Object.keys(options).some((key) => !LIST_OPTION_KEYS.has(key))) {
397
+ throw new PublishedContentReadError(
398
+ 'published_content_input_invalid',
399
+ 'published content options contain an unsupported field',
400
+ )
401
+ }
402
+ const limit = options.limit ?? 20
403
+ if (
404
+ typeof limit !== 'number' ||
405
+ !Number.isSafeInteger(limit) ||
406
+ limit < 1 ||
407
+ limit > MAX_PUBLISHED_CONTENT_PAGE_SIZE
408
+ ) {
409
+ throw new PublishedContentReadError(
410
+ 'published_content_input_invalid',
411
+ `limit must be between 1 and ${MAX_PUBLISHED_CONTENT_PAGE_SIZE}`,
412
+ )
413
+ }
414
+ let type: ContentType | undefined
415
+ if (options.type !== undefined) {
416
+ if (typeof options.type !== 'string' || !CONTENT_TYPES.has(options.type as ContentType)) {
417
+ throw new PublishedContentReadError(
418
+ 'published_content_input_invalid',
419
+ 'unknown published content type',
420
+ )
421
+ }
422
+ type = options.type as ContentType
423
+ }
424
+ let channel: string | undefined
425
+ if (options.channel !== undefined) {
426
+ assertLookupValue(options.channel, 'channel', 128)
427
+ channel = options.channel
428
+ }
429
+ let cursor: PublishedContentCursor | undefined
430
+ if (options.cursor !== undefined && options.cursor !== null) {
431
+ if (
432
+ !isPlainRecord(options.cursor) ||
433
+ Object.keys(options.cursor).some((key) => !CURSOR_KEYS.has(key))
434
+ ) {
435
+ throw new PublishedContentReadError(
436
+ 'published_content_input_invalid',
437
+ 'published content cursor is malformed',
438
+ )
439
+ }
440
+ if (
441
+ typeof options.cursor.publishedAt !== 'number' ||
442
+ !Number.isSafeInteger(options.cursor.publishedAt) ||
443
+ options.cursor.publishedAt < 0
444
+ ) {
445
+ throw new PublishedContentReadError(
446
+ 'published_content_input_invalid',
447
+ 'cursor publishedAt must be a non-negative integer',
448
+ )
449
+ }
450
+ assertLookupValue(options.cursor.contentId, 'cursor contentId', 256)
451
+ cursor = {
452
+ publishedAt: options.cursor.publishedAt,
453
+ contentId: options.cursor.contentId,
454
+ }
455
+ }
456
+ return {
457
+ limit,
458
+ type,
459
+ channel,
460
+ cursor,
461
+ }
462
+ }
463
+
464
+ export async function listPublishedContent(
465
+ db: D1Database,
466
+ options: ListPublishedContentOptions = {},
467
+ ): Promise<PublishedContentPage> {
468
+ const normalized = normalizeListOptions(options)
469
+ const predicates = [
470
+ "ci.status = 'published'",
471
+ 'ci.deleted_at IS NULL',
472
+ 'ci.published_revision_id IS NOT NULL',
473
+ 'ci.published_at IS NOT NULL',
474
+ ]
475
+ const values: unknown[] = []
476
+ if (normalized.type) {
477
+ predicates.push("json_extract(cr.payload_json, '$.item.type') = ?")
478
+ values.push(normalized.type)
479
+ }
480
+ if (normalized.channel) {
481
+ predicates.push("json_extract(cr.payload_json, '$.item.channel') = ?")
482
+ values.push(normalized.channel)
483
+ }
484
+ if (normalized.cursor) {
485
+ predicates.push('(ci.published_at < ? OR (ci.published_at = ? AND ci.id < ?))')
486
+ values.push(
487
+ normalized.cursor.publishedAt,
488
+ normalized.cursor.publishedAt,
489
+ normalized.cursor.contentId,
490
+ )
491
+ }
492
+ try {
493
+ const outlineResult = await db
494
+ .prepare(
495
+ `${SELECT_PUBLISHED_CONTENT_OUTLINE}
496
+ JOIN content_revisions cr ON cr.id = ci.published_revision_id
497
+ WHERE ${predicates.join('\n AND ')}
498
+ ORDER BY ci.published_at DESC, ci.id DESC
499
+ LIMIT ?`,
500
+ )
501
+ .bind(...values, normalized.limit + 1)
502
+ .all<PublishedContentOutlineRow>()
503
+ const outlines = outlineResult.results ?? []
504
+ const selected: PublishedContentOutlineRow[] = []
505
+ let selectedBytes = 0
506
+ for (const outline of outlines.slice(0, normalized.limit)) {
507
+ if (
508
+ typeof outline.content_id !== 'string' ||
509
+ typeof outline.pointer_revision_id !== 'string' ||
510
+ !Number.isSafeInteger(outline.canonical_published_at) ||
511
+ outline.canonical_published_at < 0 ||
512
+ !Number.isSafeInteger(outline.payload_bytes) ||
513
+ outline.payload_bytes < 1
514
+ ) {
515
+ throw new PublishedContentReadError(
516
+ 'published_payload_invalid',
517
+ 'published revision outline is malformed',
518
+ )
519
+ }
520
+ if (outline.payload_bytes > MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES) {
521
+ throw new PublishedContentReadError(
522
+ 'published_payload_limit_exceeded',
523
+ `published revision payload exceeds ${MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES} bytes`,
524
+ )
525
+ }
526
+ if (
527
+ selected.length > 0 &&
528
+ selectedBytes + outline.payload_bytes > MAX_PUBLISHED_CONTENT_PAGE_BYTES
529
+ ) {
530
+ break
531
+ }
532
+ selected.push(outline)
533
+ selectedBytes += outline.payload_bytes
534
+ }
535
+ if (selected.length === 0) return { items: [], nextCursor: null }
536
+
537
+ const selectedPredicates = selected.map(() => '(ci.id = ? AND ci.published_revision_id = ?)')
538
+ const selectedValues = selected.flatMap((outline) => [
539
+ outline.content_id,
540
+ outline.pointer_revision_id,
541
+ ])
542
+ const result = await db
543
+ .prepare(
544
+ `${SELECT_PUBLISHED_CONTENT}
545
+ JOIN content_revisions cr ON cr.id = ci.published_revision_id
546
+ WHERE ci.status = 'published'
547
+ AND ci.deleted_at IS NULL
548
+ AND (${selectedPredicates.join(' OR ')})`,
549
+ )
550
+ .bind(...selectedValues)
551
+ .all<PublishedContentRow>()
552
+ const rows = result.results ?? []
553
+ const rowsByPointer = new Map(
554
+ rows.map((row) => [`${row.content_id}\0${row.pointer_revision_id}`, row]),
555
+ )
556
+ const items = selected.map((outline) => {
557
+ const row = rowsByPointer.get(`${outline.content_id}\0${outline.pointer_revision_id}`)
558
+ if (
559
+ !row ||
560
+ row.canonical_published_at !== outline.canonical_published_at ||
561
+ row.payload_json === null ||
562
+ byteLength(row.payload_json) !== outline.payload_bytes
563
+ ) {
564
+ throw new PublishedContentReadError(
565
+ 'published_read_failed',
566
+ 'published revision changed during its bounded read',
567
+ )
568
+ }
569
+ return decodePublishedContent(row)
570
+ })
571
+ const hasMore = outlines.length > selected.length
572
+ const last = items.at(-1)
573
+ return {
574
+ items,
575
+ nextCursor:
576
+ hasMore && last ? { publishedAt: last.publishedAt, contentId: last.contentId } : null,
577
+ }
578
+ } catch (error) {
579
+ return wrapReadError(error)
580
+ }
581
+ }