@aglyn/tenant-runtime 1.0.0-beta.146 → 1.0.0-beta.149

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.
@@ -277,42 +277,161 @@ export async function scheduledPublishingPermission(hostId) {
277
277
  }).catch((error)=>console.error(error));
278
278
  }
279
279
  /**
280
- * Fetches a collection's live entries (newest first), shared by the route
281
- * loader and the compose-time Collection entries block (AGL-551).
282
- */ async function listLiveEntries(entriesRef, hostId) {
283
- // No orderBy: entries missing publishedAt would be dropped by Firestore;
284
- // sort client-side like the version lists.
285
- const entriesQuery = await entriesRef.where('status', 'in', [
280
+ * The fields a LISTING read of an entry needs — the field mask on the query
281
+ * below (AGL-3213).
282
+ *
283
+ * The point of the mask is the field that is NOT in it. `body` is the whole
284
+ * post, and `mapEntryFields` has never mapped it on this path: a list card
285
+ * binds a title, an excerpt, a cover and a byline, and the routed entry page
286
+ * reads its one document separately. So the markdown of up to
287
+ * {@link COLLECTION_SOURCE_MAX} posts crossed the wire, was parsed out of the
288
+ * response, and was dropped one function later — on every fill of a cache
289
+ * that every listing address, every "Latest posts" rail, the feed and the
290
+ * author page share. A changelog is the worst case and also the common one.
291
+ *
292
+ * Firestore bills the document read either way, so this buys no reads; it
293
+ * buys egress and the JSON parse, which is the part of a collection render
294
+ * that grows with how much people have written.
295
+ *
296
+ * Site search is unaffected and must stay that way: it matches on `body`
297
+ * through its OWN query in `apps/tenant/utils/search-content.ts`, which this
298
+ * mask does not touch.
299
+ *
300
+ * ⛔ A reader added to `mapEntryFields` or to the liveness/schedule helpers
301
+ * must be added HERE in the same edit. A field left out does not error — it
302
+ * arrives `undefined`, which is exactly how `authorName` and `updatedAt` went
303
+ * missing for months (AGL-2486, AGL-2534). The four schedule fields are
304
+ * listed first for that reason: `status`, `publishAt` and `scheduleStatus`
305
+ * decide whether an entry is live at all, and `flipDueEntry` WRITES
306
+ * `publishAt` back as `publishedAt`, so a mask that dropped it would publish
307
+ * a due entry with no date.
308
+ */ const LIVE_ENTRY_FIELDS = [
309
+ 'status',
310
+ 'publishAt',
311
+ 'publishedAt',
312
+ 'scheduleStatus',
313
+ 'title',
314
+ 'slug',
315
+ 'excerpt',
316
+ 'authorName',
317
+ 'authorId',
318
+ 'coverImage',
319
+ 'coverImageAlt',
320
+ 'coverVideo',
321
+ 'seoTitle',
322
+ 'seoDescription',
323
+ 'categoryId',
324
+ 'category',
325
+ 'tags',
326
+ 'updatedAt'
327
+ ];
328
+ /**
329
+ * Most SCHEDULED entries one live read considers (AGL-3213).
330
+ *
331
+ * Read by its own query rather than taken from the dated page below, because
332
+ * a schedule is invisible to that page: scheduling writes `publishAt` and
333
+ * never `publishedAt`, and `flipDueEntry` during a render is the only thing
334
+ * that publishes one. A schedule the read misses is a post that never goes
335
+ * out. The set is small by nature — a hundred pending schedules on one
336
+ * collection is already an unusual editorial calendar.
337
+ */ const SCHEDULED_SOURCE_MAX = 100;
338
+ /** Everything a public listing may show, before any ordering. */ function liveEntriesBase(entriesRef) {
339
+ return entriesRef.where('status', 'in', [
286
340
  'published',
287
341
  'scheduled'
288
- ])// Named rather than literal (AGL-1516): a search index has to be able to
289
- // say "this read reached its bound", and it can only do that against a
290
- // bound it shares with the query. `collectionSourceReachedBound` reads
291
- // the same constant.
292
- .limit(COLLECTION_SOURCE_MAX).get();
293
- // Ask the plan question ONLY if something is actually due (AGL-471). A
294
- // collection with no due schedule — almost every render — never reads the
295
- // org at all.
296
- const due = entriesQuery.docs.filter((entryDoc)=>isDueScheduled(entryDoc.data()));
297
- const permission = due.length ? await scheduledPublishingPermission(hostId) : 'allowed';
298
- // Record the terminal refusal on its own pass, because a refused entry is
299
- // NOT live and so never reaches the `flipDueEntry` inside the map below.
300
- // Without this the entry stays due forever: excluded from every render, and
301
- // re-reading the org on each one.
302
- if (permission !== 'allowed') {
303
- for (const entryDoc of due){
304
- flipDueEntry(entryDoc.ref, entryDoc.data(), permission);
342
+ ])// Everything this path reads, and nothing else (AGL-3213) — see
343
+ // {@link LIVE_ENTRY_FIELDS}.
344
+ .select(...LIVE_ENTRY_FIELDS);
345
+ }
346
+ /**
347
+ * The documents a live read considers, in the order the site shows them
348
+ * (AGL-3213).
349
+ *
350
+ * ## Why this is ordered now, and why ordering alone would have broken it
351
+ *
352
+ * It was `where(status).limit(100)` with NO `orderBy`, sorted in memory
353
+ * afterwards. That is not the newest hundred — it is a hundred documents in
354
+ * NAME order, then sorted. Under the bound the two agree, because a hundred
355
+ * out of a hundred is everything; past it they diverge completely. This site's
356
+ * own changelog reached 166 live entries and its listing showed an arbitrary
357
+ * hundred of them, chosen by document id, with 66 releases reachable only at
358
+ * their own URLs.
359
+ *
360
+ * The comment that used to sit here was right about `orderBy`, though:
361
+ * Firestore returns only documents that HAVE the ordered field, so a dated
362
+ * read does not mis-sort an entry without a date — it hides it. Two of those
363
+ * exist and both matter:
364
+ *
365
+ * A SCHEDULE carries `publishAt` and no `publishedAt` until it goes out.
366
+ * Ordering alone would have stopped scheduled posts publishing
367
+ * at all, silently, because nothing else publishes them.
368
+ * AN IMPORT restores whatever the bundle carried, and
369
+ * `/api/hosts/resources` validates no field for presence
370
+ * either — so a published entry with no `publishedAt` exists.
371
+ *
372
+ * So it is three queries, in the shape the console's sorted window uses
373
+ * (AGL-2853): the DATED page, every SCHEDULE, and — only when the dated page
374
+ * came back SHORT, which is the server's own proof that the collection fits
375
+ * inside the bound — a scan for live entries carrying no date. Past the bound
376
+ * an undated entry sorts after every dated one by definition, so it belongs to
377
+ * the tail pages, which are served by their own window read.
378
+ */ async function readLiveEntryDocs(entriesRef) {
379
+ try {
380
+ const dated = await liveEntriesBase(entriesRef).orderBy('publishedAt', 'desc')// The document name breaks ties, so two entries published in the same
381
+ // second cannot swap places between two reads and move a page boundary
382
+ // under a reader. Descending to match the date: a composite index ends
383
+ // with `__name__` in the last field's direction, which makes this the
384
+ // `(status, publishedAt DESC)` index the console's table already needs.
385
+ .orderBy('__name__', 'desc')// Named rather than literal (AGL-1516): a search index has to be able to
386
+ // say "this read reached its bound", and it can only do that against a
387
+ // bound it shares with the query. `collectionSourceReachedBound` reads
388
+ // the same constant.
389
+ .limit(COLLECTION_SOURCE_MAX).get();
390
+ const scheduled = await entriesRef.where('status', '==', 'scheduled').select(...LIVE_ENTRY_FIELDS).limit(SCHEDULED_SOURCE_MAX).get();
391
+ /*
392
+ * EITHER read stopping at its own limit means entries went unseen.
393
+ *
394
+ * The dated page is the usual one. The schedule page is the case a dated
395
+ * read alone cannot even detect: a collection holding nothing but pending
396
+ * schedules returns ZERO dated documents, so a bound measured only there
397
+ * would report a complete read of an empty collection — and "nothing is
398
+ * live here" is the claim that takes a listing off the site (AGL-3101).
399
+ */ const reachedBound = dated.docs.length >= COLLECTION_SOURCE_MAX || scheduled.docs.length >= SCHEDULED_SOURCE_MAX;
400
+ const undated = reachedBound ? [] : (await liveEntriesBase(entriesRef).orderBy('__name__').limit(COLLECTION_SOURCE_MAX).get()).docs.filter((entryDoc)=>!entryDoc.get('publishedAt'));
401
+ const seen = new Set();
402
+ const docs = [];
403
+ for (const entryDoc of [
404
+ ...dated.docs,
405
+ ...scheduled.docs,
406
+ ...undated
407
+ ]){
408
+ if (seen.has(entryDoc.id)) continue;
409
+ seen.add(entryDoc.id);
410
+ docs.push(entryDoc);
305
411
  }
412
+ return {
413
+ docs,
414
+ reachedBound
415
+ };
416
+ } catch (error) {
417
+ /*
418
+ * FAIL SOFT TO THE UNORDERED READ.
419
+ *
420
+ * The ordered query needs the `(status, publishedAt DESC)` composite
421
+ * index. Indexes do NOT ship with a promotion — RELEASING.md deploys them
422
+ * by hand afterwards — so the window between the code landing and the
423
+ * index existing has to degrade rather than break. An arbitrary hundred
424
+ * is a bad listing; a 500 is a customer's blog down.
425
+ */ console.error(error);
426
+ const unordered = await liveEntriesBase(entriesRef).limit(COLLECTION_SOURCE_MAX).get();
427
+ return {
428
+ docs: unordered.docs,
429
+ reachedBound: unordered.docs.length >= COLLECTION_SOURCE_MAX
430
+ };
306
431
  }
307
- // Measured on the RAW docs, before the liveness filter (AGL-1516). This is
308
- // the only place that can still see how many documents the query returned;
309
- // one line down that number is gone for good.
310
- const reachedBound = entriesQuery.docs.length >= COLLECTION_SOURCE_MAX;
311
- // Also measured on the RAW docs, and for the same reason: a not-yet-due
312
- // entry is filtered out one line down, so this is the last place that can
313
- // see one at all.
314
- const pendingSchedule = entriesQuery.docs.some((entryDoc)=>isPendingScheduled(entryDoc.data()));
315
- const entries = entriesQuery.docs.filter((entryDoc)=>isLive(entryDoc.data(), permission)).map((entryDoc)=>{
432
+ }
433
+ /** The live entries among `docs`, newest first, publishing any that came due. */ function toLiveEntries(docs, permission) {
434
+ return docs.filter((entryDoc)=>isLive(entryDoc.data(), permission)).map((entryDoc)=>{
316
435
  var _value_title, _value_slug, _value_publishedAt, _value_publishedAt1;
317
436
  const value = entryDoc.data();
318
437
  flipDueEntry(entryDoc.ref, value, permission);
@@ -325,17 +444,156 @@ export async function scheduledPublishingPermission(hostId) {
325
444
  seconds: ((_value_publishedAt1 = value['publishedAt']) != null ? _value_publishedAt1 : value['publishAt']).seconds
326
445
  } : null
327
446
  });
328
- }).sort((a, b)=>{
447
+ })// An entry carrying no date at all sorts last rather than to 1970 —
448
+ // the same place the query's own order puts it.
449
+ .sort((a, b)=>{
329
450
  var _ref, _ref1;
330
451
  var _b_publishedAt, _a_publishedAt;
331
452
  return ((_ref = (_b_publishedAt = b.publishedAt) == null ? void 0 : _b_publishedAt.seconds) != null ? _ref : 0) - ((_ref1 = (_a_publishedAt = a.publishedAt) == null ? void 0 : _a_publishedAt.seconds) != null ? _ref1 : 0);
332
453
  });
454
+ }
455
+ /**
456
+ * Fetches a collection's live entries (newest first), shared by the route
457
+ * loader and the compose-time Collection entries block (AGL-551).
458
+ */ async function listLiveEntries(entriesRef, hostId) {
459
+ const { docs, reachedBound } = await readLiveEntryDocs(entriesRef);
460
+ // Ask the plan question ONLY if something is actually due (AGL-471). A
461
+ // collection with no due schedule — almost every render — never reads the
462
+ // org at all.
463
+ const due = docs.filter((entryDoc)=>isDueScheduled(entryDoc.data()));
464
+ const permission = due.length ? await scheduledPublishingPermission(hostId) : 'allowed';
465
+ // Record the terminal refusal on its own pass, because a refused entry is
466
+ // NOT live and so never reaches the `flipDueEntry` inside the map below.
467
+ // Without this the entry stays due forever: excluded from every render, and
468
+ // re-reading the org on each one.
469
+ if (permission !== 'allowed') {
470
+ for (const entryDoc of due){
471
+ flipDueEntry(entryDoc.ref, entryDoc.data(), permission);
472
+ }
473
+ }
474
+ // Measured on the RAW docs, before the liveness filter (AGL-1516): a
475
+ // not-yet-due entry is filtered out one line down, so this is the last
476
+ // place that can see one at all.
477
+ const pendingSchedule = docs.some((entryDoc)=>isPendingScheduled(entryDoc.data()));
478
+ const entries = toLiveEntries(docs, permission);
333
479
  return {
334
480
  entries,
335
481
  reachedBound,
336
482
  pendingSchedule
337
483
  };
338
484
  }
485
+ /**
486
+ * One page of a listing that starts PAST the cached read (AGL-3213),
487
+ * addressed by the document it continues from rather than by a position
488
+ * (AGL-3219).
489
+ *
490
+ * Uncached on purpose, and it is the only read on the collection path that is.
491
+ * The cached source exists because `/blog`, every listing address, the feed
492
+ * and every "Latest posts" rail want the same first hundred entries
493
+ * (AGL-1302); a page past that bound wants ten entries nobody else is asking
494
+ * for, and its own ISR entry is already the cache for them.
495
+ *
496
+ * ## Why a cursor, and not the offset this replaces
497
+ *
498
+ * `.offset(n)` asks for a POSITION, and a listing takes its inserts at the
499
+ * head, so every publish moves every position by one. The head pages and this
500
+ * read are cached under different policies and revalidated by different
501
+ * triggers — the publish fan-out refreshes the head eagerly and deliberately
502
+ * stops there — so the two sides were routinely describing the collection as
503
+ * it stood at two different moments, and the seam between them repeated an
504
+ * entry or hid one for as long as the slower side lagged. `v1.0.0-beta.147`
505
+ * shipped and `/changelog` showed `v1-0-0-beta-36` as both the last entry of
506
+ * page 10 and the first of page 11.
507
+ *
508
+ * `startAfter(doc)` asks a question whose answer does not move: what follows
509
+ * THIS entry. Publish a hundred entries at the head and this page returns the
510
+ * same ten. The seam cannot drift because there is no longer a number on
511
+ * either side of it to disagree about.
512
+ *
513
+ * `offset` also billed every document it skipped; a cursor bills none of them.
514
+ */ async function readCollectionListingPage(options) {
515
+ try {
516
+ const collectionDoc = await findContentCollection(options.hostId, options.collectionSlug);
517
+ if (!collectionDoc) return null;
518
+ const entriesRef = collectionDoc.ref.collection('entries');
519
+ const cursorId = options.before || options.after;
520
+ if (!cursorId) return null;
521
+ // A direct get, not a `where('slug', ...)` query: the cursor IS the
522
+ // document name, which is the second field the read orders on, so the
523
+ // snapshot it needs is one document rather than an index lookup.
524
+ const cursorDoc = await entriesRef.doc(cursorId).get();
525
+ // A cursor naming a document that no longer exists — deleted, or a URL
526
+ // someone kept — cannot be positioned against. Null sends the caller back
527
+ // to the cached head, which is a page the reader can act on.
528
+ if (!cursorDoc.exists) return null;
529
+ // Backwards is the same query read the other way up, so both directions
530
+ // rest on an index the project already deploys: `(status, publishedAt)`
531
+ // exists ascending and descending both.
532
+ const backwards = Boolean(options.before);
533
+ const snapshot = await liveEntriesBase(entriesRef).orderBy('publishedAt', backwards ? 'asc' : 'desc').orderBy('__name__', backwards ? 'asc' : 'desc').startAfter(cursorDoc)// One more than the page: the extra row is the whole of "is there
534
+ // another page", and it replaces a `count()` over the collection.
535
+ .limit(options.limit + 1).get();
536
+ const hasMore = snapshot.docs.length > options.limit;
537
+ const pageDocs = snapshot.docs.slice(0, options.limit);
538
+ // Read backwards, the newest of the page came back last.
539
+ const docs = backwards ? [
540
+ ...pageDocs
541
+ ].reverse() : pageDocs;
542
+ const due = docs.filter((entryDoc)=>isDueScheduled(entryDoc.data()));
543
+ const permission = due.length ? await scheduledPublishingPermission(options.hostId) : 'allowed';
544
+ if (permission !== 'allowed') {
545
+ for (const entryDoc of due){
546
+ flipDueEntry(entryDoc.ref, entryDoc.data(), permission);
547
+ }
548
+ }
549
+ const entries = toLiveEntries(docs, permission);
550
+ // The byline reads `authorName`, which a record-backed author only has
551
+ // once resolved — the cached source does this for the entries it holds,
552
+ // and a windowed page holds entries it never saw (AGL-2486).
553
+ await attachEntryAuthors(options.hostId, entries);
554
+ return {
555
+ entries,
556
+ hasMore
557
+ };
558
+ } catch (error) {
559
+ // Fail open to the cached head rather than to a 500: the caller keeps
560
+ // whatever it already had, which is a page the reader has seen before
561
+ // rather than an error they cannot get past.
562
+ console.error(error);
563
+ return null;
564
+ }
565
+ }
566
+ /**
567
+ * The cursor a retired `/{collection}/page/{n}` address redirects onto
568
+ * (AGL-3219).
569
+ *
570
+ * This is the ONE place a position is still resolved, and it is deliberately
571
+ * the only one: a 301 is served once, the reader lands on an address that
572
+ * cannot drift afterwards, and whatever the position meant at that instant is
573
+ * the page they would have got anyway. Everything downstream is cursors.
574
+ *
575
+ * Keys only — `select()` with no fields asks Firestore for document names and
576
+ * nothing else — so the skipped documents are billed at the cheapest rate the
577
+ * offset can be had for. Returns `''` when the position is past the end,
578
+ * which is a 404 rather than a redirect to nowhere.
579
+ */ export async function resolveCollectionPageCursor(options) {
580
+ const skip = (options.page - 1) * options.perPage - 1;
581
+ if (!Number.isFinite(skip) || skip < 0) return '';
582
+ try {
583
+ var _ref;
584
+ var _snapshot_docs_;
585
+ const collectionDoc = await findContentCollection(options.hostId, options.collectionSlug);
586
+ if (!collectionDoc) return '';
587
+ const snapshot = await collectionDoc.ref.collection('entries').where('status', 'in', [
588
+ 'published',
589
+ 'scheduled'
590
+ ]).select().orderBy('publishedAt', 'desc').orderBy('__name__', 'desc').offset(skip).limit(1).get();
591
+ return (_ref = (_snapshot_docs_ = snapshot.docs[0]) == null ? void 0 : _snapshot_docs_.id) != null ? _ref : '';
592
+ } catch (error) {
593
+ console.error(error);
594
+ return '';
595
+ }
596
+ }
339
597
  /**
340
598
  * Published entries + category taxonomy for a collection resolved by slug —
341
599
  * the data source of the Collection entries block on arbitrary screens
@@ -425,7 +683,7 @@ async function readPublishedCollectionSource(options) {
425
683
  * category route hands its already-narrowed entries to compose and the
426
684
  * entries block's "measure the raw set, not the filtered one" rule then has
427
685
  * nothing raw left to measure (AGL-1516).
428
- */ function applyCategoryAndPagination(data, options) {
686
+ */ function applyCategoryAndPagination(data, options, listing) {
429
687
  var _options_categorySlug;
430
688
  const { page = 1, perPage } = options;
431
689
  const routedCategory = ((_options_categorySlug = options.categorySlug) != null ? _options_categorySlug : '').trim();
@@ -454,13 +712,67 @@ async function readPublishedCollectionSource(options) {
454
712
  });
455
713
  }
456
714
  if (perPage && perPage > 0) {
457
- const totalEntries = data.entries.length;
458
- data.pagination = {
715
+ var _ref1;
716
+ /*
717
+ * The total is the COLLECTION's, not the read's (AGL-3213).
718
+ *
719
+ * `entries.length` was the count, and on a collection inside the bound it
720
+ * still is — the two are the same number there. Past the bound it is the
721
+ * size of the window, which is how `/changelog` advertised "Page 10 of
722
+ * 10" while holding 166 entries and linking to 100 of them.
723
+ *
724
+ * A ROUTED CATEGORY keeps counting its own entries, because that is the
725
+ * only honest number available here: the narrowing happens in memory over
726
+ * the cached head, so both the count and the listing describe the same
727
+ * bounded set. Paging a category past the bound needs its own ordered
728
+ * query and a `(categoryId, status, publishedAt DESC)` index; until then
729
+ * a category of a very large collection is capped, and says so by
730
+ * agreeing with what it shows.
731
+ */ const windowStart = Number(listing == null ? void 0 : listing.windowStart);
732
+ const windowed = Number.isFinite(windowStart) && windowStart > 0;
733
+ /*
734
+ * The cursors, which are the pager's real answer (AGL-3219).
735
+ *
736
+ * `data.entries` is either EXACTLY this page — a cursor read — or the
737
+ * whole cached head, which every listing address shares and each slices
738
+ * its own page out of. So the page's own last entry is at the end of the
739
+ * array in the first case and at `page * perPage - 1` in the second, and
740
+ * the cursor is that entry's document id.
741
+ */ const pageEnd = windowed ? data.entries.length : page * perPage;
742
+ const last = data.entries[pageEnd - 1];
743
+ const first = data.entries[windowed ? 0 : (page - 1) * perPage];
744
+ /*
745
+ * Is there an older page?
746
+ *
747
+ * A cursor page KNOWS, because its read asked for one more entry than it
748
+ * needed and the extra row came back. The head has to reason: either it
749
+ * is holding more entries than this page shows, or it is holding all it
750
+ * could read and the read stopped at its own bound, which is the one
751
+ * thing `entries.length` can never tell you about the collection.
752
+ */ const hasMore = (_ref1 = listing == null ? void 0 : listing.cursorHasMore) != null ? _ref1 : data.entries.length > pageEnd || Boolean(listing == null ? void 0 : listing.reachedBound) && !routedCategory;
753
+ /*
754
+ * The deprecated total, stated ONLY where it can be true.
755
+ *
756
+ * A collection that fits inside one read can be counted, and a template
757
+ * binding `{{pagination.totalPages}}` keeps rendering the number it
758
+ * always did. Past the bound there is no honest total — the count and
759
+ * the listing were reading the collection at two different moments, which
760
+ * is what made the seam repeat an entry — so it is left absent rather
761
+ * than asserted. A routed category counts its own narrowed set, which is
762
+ * bounded by the same head and therefore countable.
763
+ */ const countable = routedCategory || !(listing == null ? void 0 : listing.reachedBound);
764
+ const totalEntries = countable ? data.entries.length : undefined;
765
+ data.pagination = _extends({
459
766
  page,
460
767
  perPage,
768
+ nextCursor: hasMore && (last == null ? void 0 : last.$id) ? last.$id : '',
769
+ prevCursor: page > 1 && (first == null ? void 0 : first.$id) ? first.$id : ''
770
+ }, totalEntries === undefined ? {} : {
461
771
  totalEntries,
462
772
  totalPages: collectionTotalPages(totalEntries, perPage)
463
- };
773
+ }, windowed ? {
774
+ windowStart
775
+ } : {});
464
776
  }
465
777
  }
466
778
  /**
@@ -500,6 +812,7 @@ async function readPublishedCollectionSource(options) {
500
812
  // An ENTRY route stays where it was: it reads ONE document by slug and
501
813
  // has nothing to share.
502
814
  if (!entrySlug) {
815
+ var _ref, _options_after, _options_categorySlug;
503
816
  const source = await getPublishedCollectionSource({
504
817
  hostId,
505
818
  collectionSlug
@@ -523,7 +836,60 @@ async function readPublishedCollectionSource(options) {
523
836
  data.collection = source.collection;
524
837
  data.entries = source.entries;
525
838
  data.entriesReachedBound = source.reachedBound;
526
- applyCategoryAndPagination(data, options);
839
+ /*
840
+ * A page that starts past the cached read is served by its own window
841
+ * (AGL-3213). Everything before that point comes out of the shared
842
+ * source, so the pages a reader actually visits stay free.
843
+ *
844
+ * Only the unfiltered listing: a category route narrows in memory over
845
+ * the same head, and a window read of the whole collection would hand
846
+ * it ten entries of which any number may belong to another category.
847
+ */ const { page = 1, perPage } = options;
848
+ const cursor = ((_ref = (_options_after = options.after) != null ? _options_after : options.before) != null ? _ref : '').trim();
849
+ let windowStart = 0;
850
+ let cursored = null;
851
+ /*
852
+ * A cursor address is served by its own read (AGL-3219). Everything
853
+ * reachable without one comes out of the shared source, so the pages a
854
+ * reader actually visits stay free.
855
+ *
856
+ * Only the unfiltered listing: a category route narrows in memory over
857
+ * the same head, and a cursor read of the whole collection would hand it
858
+ * ten entries of which any number belong to another category.
859
+ */ if (perPage && perPage > 0 && cursor && !((_options_categorySlug = options.categorySlug) != null ? _options_categorySlug : '').trim()) {
860
+ cursored = await readCollectionListingPage(_extends({
861
+ hostId,
862
+ collectionSlug
863
+ }, options.before ? {
864
+ before: options.before
865
+ } : {
866
+ after: cursor
867
+ }, {
868
+ limit: perPage
869
+ }));
870
+ // Null means the cursor read failed, or named a document that is
871
+ // gone. The cached head is the better answer than an empty page: the
872
+ // reader sees the newest entries rather than nothing at all.
873
+ if (cursored) {
874
+ data.entries = cursored.entries;
875
+ /*
876
+ * `entries` is now EXACTLY this page, and both windowing sites
877
+ * slice `[(page - 1) * perPage, …)`. Declaring the window to start
878
+ * at that same offset makes their subtraction come out at zero, so
879
+ * they take the page whole.
880
+ *
881
+ * Both sides of the subtraction are built from the same `page`, so
882
+ * a hand-edited counter in a URL cancels itself out: the label is
883
+ * wrong and the entries are still this cursor's page.
884
+ */ windowStart = (page - 1) * perPage;
885
+ }
886
+ }
887
+ applyCategoryAndPagination(data, options, _extends({
888
+ windowStart,
889
+ reachedBound: source.reachedBound
890
+ }, cursored ? {
891
+ cursorHasMore: cursored.hasMore
892
+ } : {}));
527
893
  return data;
528
894
  }
529
895
  const collectionDoc = await findContentCollection(hostId, collectionSlug);