@aglyn/tenant-runtime 1.0.0-beta.147 → 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.
@@ -452,34 +452,6 @@ export async function scheduledPublishingPermission(hostId) {
452
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);
453
453
  });
454
454
  }
455
- /**
456
- * How many entries are live in the whole collection (AGL-3213).
457
- *
458
- * The aggregation counts `published`, which `isLive` admits unconditionally,
459
- * and the live schedules are added from the docs already in hand — a due
460
- * schedule is still stored as `scheduled` while this runs, because
461
- * `flipDueEntry` writes behind the render, so neither half can count it twice.
462
- *
463
- * Falls back to the size of the read on failure. A pager that overstates by a
464
- * page is a page a reader can see is empty; a pager that throws is a listing
465
- * that does not render.
466
- */ async function countLiveEntries(entriesRef, docs, permission, fallback) {
467
- try {
468
- var _ref;
469
- var _published_data;
470
- const published = await entriesRef.where('status', '==', 'published').count().get();
471
- const counted = Number((_ref = (_published_data = published.data()) == null ? void 0 : _published_data.count) != null ? _ref : Number.NaN);
472
- if (!Number.isFinite(counted)) return fallback;
473
- const liveSchedules = docs.filter((entryDoc)=>{
474
- const value = entryDoc.data();
475
- return value['status'] === 'scheduled' && isLive(value, permission);
476
- }).length;
477
- return counted + liveSchedules;
478
- } catch (error) {
479
- console.error(error);
480
- return fallback;
481
- }
482
- }
483
455
  /**
484
456
  * Fetches a collection's live entries (newest first), shared by the route
485
457
  * loader and the compose-time Collection entries block (AGL-551).
@@ -507,42 +479,82 @@ export async function scheduledPublishingPermission(hostId) {
507
479
  return {
508
480
  entries,
509
481
  reachedBound,
510
- pendingSchedule,
511
- totalLive: reachedBound ? await countLiveEntries(entriesRef, docs, permission, entries.length) : entries.length
482
+ pendingSchedule
512
483
  };
513
484
  }
514
485
  /**
515
- * One page of a listing that starts PAST the cached read (AGL-3213).
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).
516
489
  *
517
490
  * Uncached on purpose, and it is the only read on the collection path that is.
518
- * The cached source exists because `/blog`, every `/blog/page/{n}`, every
519
- * category listing, the feed and every "Latest posts" rail want the same first
520
- * hundred entries (AGL-1302); a page past that bound wants ten entries nobody
521
- * else is asking for, and its own ISR entry is already the cache for them.
522
- *
523
- * `offset` bills the documents it skips, so page 11 of a long collection costs
524
- * its 110 reads per regeneration. That is the price of a listing that can be
525
- * read to the end, it is paid only by collections past the bound, and it is
526
- * paid once per page per revalidate window.
527
- */ async function readCollectionListingWindow(options) {
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) {
528
515
  try {
529
516
  const collectionDoc = await findContentCollection(options.hostId, options.collectionSlug);
530
517
  if (!collectionDoc) return null;
531
518
  const entriesRef = collectionDoc.ref.collection('entries');
532
- const snapshot = await liveEntriesBase(entriesRef).orderBy('publishedAt', 'desc').orderBy('__name__', 'desc').offset(options.offset).limit(options.limit).get();
533
- const due = snapshot.docs.filter((entryDoc)=>isDueScheduled(entryDoc.data()));
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()));
534
543
  const permission = due.length ? await scheduledPublishingPermission(options.hostId) : 'allowed';
535
544
  if (permission !== 'allowed') {
536
545
  for (const entryDoc of due){
537
546
  flipDueEntry(entryDoc.ref, entryDoc.data(), permission);
538
547
  }
539
548
  }
540
- const entries = toLiveEntries(snapshot.docs, permission);
549
+ const entries = toLiveEntries(docs, permission);
541
550
  // The byline reads `authorName`, which a record-backed author only has
542
551
  // once resolved — the cached source does this for the entries it holds,
543
552
  // and a windowed page holds entries it never saw (AGL-2486).
544
553
  await attachEntryAuthors(options.hostId, entries);
545
- return entries;
554
+ return {
555
+ entries,
556
+ hasMore
557
+ };
546
558
  } catch (error) {
547
559
  // Fail open to the cached head rather than to a 500: the caller keeps
548
560
  // whatever it already had, which is a page the reader has seen before
@@ -551,6 +563,37 @@ export async function scheduledPublishingPermission(hostId) {
551
563
  return null;
552
564
  }
553
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
+ }
554
597
  /**
555
598
  * Published entries + category taxonomy for a collection resolved by slug —
556
599
  * the data source of the Collection entries block on arbitrary screens
@@ -597,11 +640,10 @@ async function readPublishedCollectionSource(options) {
597
640
  collection: null,
598
641
  entries: [],
599
642
  categories: [],
600
- reachedBound: false,
601
- totalLive: 0
643
+ reachedBound: false
602
644
  };
603
645
  }
604
- const { entries, reachedBound, pendingSchedule, totalLive } = await listLiveEntries(collectionDoc.ref.collection('entries'), options.hostId);
646
+ const { entries, reachedBound, pendingSchedule } = await listLiveEntries(collectionDoc.ref.collection('entries'), options.hostId);
605
647
  // The compose-time source feeds the Collection entries block, whose byline
606
648
  // reads `authorName` — so a record-backed author has to be resolved here
607
649
  // too, or the block prints nothing for the entries a list page shows
@@ -612,8 +654,7 @@ async function readPublishedCollectionSource(options) {
612
654
  collection: mapCollectionDoc(collectionDoc, options.collectionSlug),
613
655
  entries,
614
656
  categories: mapCollectionCategories(collectionDoc.get('categories')),
615
- reachedBound,
616
- totalLive
657
+ reachedBound
617
658
  }, pendingSchedule ? {
618
659
  pendingSchedule: true
619
660
  } : {});
@@ -623,8 +664,7 @@ async function readPublishedCollectionSource(options) {
623
664
  collection: null,
624
665
  entries: [],
625
666
  categories: [],
626
- reachedBound: false,
627
- totalLive: 0
667
+ reachedBound: false
628
668
  };
629
669
  }
630
670
  }
@@ -672,6 +712,7 @@ async function readPublishedCollectionSource(options) {
672
712
  });
673
713
  }
674
714
  if (perPage && perPage > 0) {
715
+ var _ref1;
675
716
  /*
676
717
  * The total is the COLLECTION's, not the read's (AGL-3213).
677
718
  *
@@ -687,15 +728,49 @@ async function readPublishedCollectionSource(options) {
687
728
  * query and a `(categoryId, status, publishedAt DESC)` index; until then
688
729
  * a category of a very large collection is capped, and says so by
689
730
  * agreeing with what it shows.
690
- */ const known = Number(listing == null ? void 0 : listing.totalLive);
691
- const totalEntries = !routedCategory && Number.isFinite(known) && known > data.entries.length ? known : data.entries.length;
692
- const windowStart = Number(listing == null ? void 0 : listing.windowStart);
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;
693
765
  data.pagination = _extends({
694
766
  page,
695
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 ? {} : {
696
771
  totalEntries,
697
772
  totalPages: collectionTotalPages(totalEntries, perPage)
698
- }, Number.isFinite(windowStart) && windowStart > 0 ? {
773
+ }, windowed ? {
699
774
  windowStart
700
775
  } : {});
701
776
  }
@@ -737,6 +812,7 @@ async function readPublishedCollectionSource(options) {
737
812
  // An ENTRY route stays where it was: it reads ONE document by slug and
738
813
  // has nothing to share.
739
814
  if (!entrySlug) {
815
+ var _ref, _options_after, _options_categorySlug;
740
816
  const source = await getPublishedCollectionSource({
741
817
  hostId,
742
818
  collectionSlug
@@ -769,30 +845,51 @@ async function readPublishedCollectionSource(options) {
769
845
  * the same head, and a window read of the whole collection would hand
770
846
  * it ten entries of which any number may belong to another category.
771
847
  */ const { page = 1, perPage } = options;
848
+ const cursor = ((_ref = (_options_after = options.after) != null ? _options_after : options.before) != null ? _ref : '').trim();
772
849
  let windowStart = 0;
773
- if (perPage && perPage > 0 && source.reachedBound) {
774
- var _options_categorySlug;
775
- const offset = (page - 1) * perPage;
776
- if (!((_options_categorySlug = options.categorySlug) != null ? _options_categorySlug : '').trim() && offset >= source.entries.length) {
777
- const windowed = await readCollectionListingWindow({
778
- hostId,
779
- collectionSlug,
780
- offset,
781
- limit: perPage
782
- });
783
- // Null means the window read failed, and the cached head is the
784
- // better answer than an empty page: the reader sees page 1's
785
- // entries under page N's URL rather than nothing at all.
786
- if (windowed) {
787
- data.entries = windowed;
788
- windowStart = offset;
789
- }
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;
790
885
  }
791
886
  }
792
- applyCategoryAndPagination(data, options, {
793
- totalLive: source.totalLive,
794
- windowStart
795
- });
887
+ applyCategoryAndPagination(data, options, _extends({
888
+ windowStart,
889
+ reachedBound: source.reachedBound
890
+ }, cursored ? {
891
+ cursorHasMore: cursored.hasMore
892
+ } : {}));
796
893
  return data;
797
894
  }
798
895
  const collectionDoc = await findContentCollection(hostId, collectionSlug);