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

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.
@@ -227,6 +227,18 @@ export interface CollectionPagination {
227
227
  perPage: number;
228
228
  totalPages: number;
229
229
  totalEntries: number;
230
+ /**
231
+ * Where `entries` begins in the collection's own order (AGL-3213): 0 for a
232
+ * listing served from the cached read, and the page's own offset for one
233
+ * served by a window read past that read's bound.
234
+ *
235
+ * Both windowing sites subtract it — `collectionEntriesPageWindow` on the
236
+ * way into props, and the Collection entries block on the way into compose.
237
+ * Without it a windowed listing renders empty, because every one of them
238
+ * slices `[(page - 1) * perPage, …)` on the premise that `entries` starts at
239
+ * the beginning of the collection.
240
+ */
241
+ windowStart?: number;
230
242
  }
231
243
  /** Compose-time view of a collection: its live entries and its taxonomy. */
232
244
  export interface PublishedCollectionSource {
@@ -259,6 +271,12 @@ export interface PublishedCollectionSource {
259
271
  * would tell a reader their search covered less than it did.
260
272
  */
261
273
  reachedBound: boolean;
274
+ /**
275
+ * How many entries are live in the whole collection (AGL-3213) — see
276
+ * {@link LiveEntriesRead.totalLive}. Equal to `entries.length` for every
277
+ * collection inside the bound, which is almost all of them.
278
+ */
279
+ totalLive: number;
262
280
  }
263
281
  /**
264
282
  * Published entries + category taxonomy for a collection resolved by slug —
@@ -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,113 @@ 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
+ * 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
+ /**
484
+ * Fetches a collection's live entries (newest first), shared by the route
485
+ * loader and the compose-time Collection entries block (AGL-551).
486
+ */ async function listLiveEntries(entriesRef, hostId) {
487
+ const { docs, reachedBound } = await readLiveEntryDocs(entriesRef);
488
+ // Ask the plan question ONLY if something is actually due (AGL-471). A
489
+ // collection with no due schedule — almost every render — never reads the
490
+ // org at all.
491
+ const due = docs.filter((entryDoc)=>isDueScheduled(entryDoc.data()));
492
+ const permission = due.length ? await scheduledPublishingPermission(hostId) : 'allowed';
493
+ // Record the terminal refusal on its own pass, because a refused entry is
494
+ // NOT live and so never reaches the `flipDueEntry` inside the map below.
495
+ // Without this the entry stays due forever: excluded from every render, and
496
+ // re-reading the org on each one.
497
+ if (permission !== 'allowed') {
498
+ for (const entryDoc of due){
499
+ flipDueEntry(entryDoc.ref, entryDoc.data(), permission);
500
+ }
501
+ }
502
+ // Measured on the RAW docs, before the liveness filter (AGL-1516): a
503
+ // not-yet-due entry is filtered out one line down, so this is the last
504
+ // place that can see one at all.
505
+ const pendingSchedule = docs.some((entryDoc)=>isPendingScheduled(entryDoc.data()));
506
+ const entries = toLiveEntries(docs, permission);
333
507
  return {
334
508
  entries,
335
509
  reachedBound,
336
- pendingSchedule
510
+ pendingSchedule,
511
+ totalLive: reachedBound ? await countLiveEntries(entriesRef, docs, permission, entries.length) : entries.length
337
512
  };
338
513
  }
514
+ /**
515
+ * One page of a listing that starts PAST the cached read (AGL-3213).
516
+ *
517
+ * 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) {
528
+ try {
529
+ const collectionDoc = await findContentCollection(options.hostId, options.collectionSlug);
530
+ if (!collectionDoc) return null;
531
+ 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()));
534
+ const permission = due.length ? await scheduledPublishingPermission(options.hostId) : 'allowed';
535
+ if (permission !== 'allowed') {
536
+ for (const entryDoc of due){
537
+ flipDueEntry(entryDoc.ref, entryDoc.data(), permission);
538
+ }
539
+ }
540
+ const entries = toLiveEntries(snapshot.docs, permission);
541
+ // The byline reads `authorName`, which a record-backed author only has
542
+ // once resolved — the cached source does this for the entries it holds,
543
+ // and a windowed page holds entries it never saw (AGL-2486).
544
+ await attachEntryAuthors(options.hostId, entries);
545
+ return entries;
546
+ } catch (error) {
547
+ // Fail open to the cached head rather than to a 500: the caller keeps
548
+ // whatever it already had, which is a page the reader has seen before
549
+ // rather than an error they cannot get past.
550
+ console.error(error);
551
+ return null;
552
+ }
553
+ }
339
554
  /**
340
555
  * Published entries + category taxonomy for a collection resolved by slug —
341
556
  * the data source of the Collection entries block on arbitrary screens
@@ -382,10 +597,11 @@ async function readPublishedCollectionSource(options) {
382
597
  collection: null,
383
598
  entries: [],
384
599
  categories: [],
385
- reachedBound: false
600
+ reachedBound: false,
601
+ totalLive: 0
386
602
  };
387
603
  }
388
- const { entries, reachedBound, pendingSchedule } = await listLiveEntries(collectionDoc.ref.collection('entries'), options.hostId);
604
+ const { entries, reachedBound, pendingSchedule, totalLive } = await listLiveEntries(collectionDoc.ref.collection('entries'), options.hostId);
389
605
  // The compose-time source feeds the Collection entries block, whose byline
390
606
  // reads `authorName` — so a record-backed author has to be resolved here
391
607
  // too, or the block prints nothing for the entries a list page shows
@@ -396,7 +612,8 @@ async function readPublishedCollectionSource(options) {
396
612
  collection: mapCollectionDoc(collectionDoc, options.collectionSlug),
397
613
  entries,
398
614
  categories: mapCollectionCategories(collectionDoc.get('categories')),
399
- reachedBound
615
+ reachedBound,
616
+ totalLive
400
617
  }, pendingSchedule ? {
401
618
  pendingSchedule: true
402
619
  } : {});
@@ -406,7 +623,8 @@ async function readPublishedCollectionSource(options) {
406
623
  collection: null,
407
624
  entries: [],
408
625
  categories: [],
409
- reachedBound: false
626
+ reachedBound: false,
627
+ totalLive: 0
410
628
  };
411
629
  }
412
630
  }
@@ -425,7 +643,7 @@ async function readPublishedCollectionSource(options) {
425
643
  * category route hands its already-narrowed entries to compose and the
426
644
  * entries block's "measure the raw set, not the filtered one" rule then has
427
645
  * nothing raw left to measure (AGL-1516).
428
- */ function applyCategoryAndPagination(data, options) {
646
+ */ function applyCategoryAndPagination(data, options, listing) {
429
647
  var _options_categorySlug;
430
648
  const { page = 1, perPage } = options;
431
649
  const routedCategory = ((_options_categorySlug = options.categorySlug) != null ? _options_categorySlug : '').trim();
@@ -454,13 +672,32 @@ async function readPublishedCollectionSource(options) {
454
672
  });
455
673
  }
456
674
  if (perPage && perPage > 0) {
457
- const totalEntries = data.entries.length;
458
- data.pagination = {
675
+ /*
676
+ * The total is the COLLECTION's, not the read's (AGL-3213).
677
+ *
678
+ * `entries.length` was the count, and on a collection inside the bound it
679
+ * still is — the two are the same number there. Past the bound it is the
680
+ * size of the window, which is how `/changelog` advertised "Page 10 of
681
+ * 10" while holding 166 entries and linking to 100 of them.
682
+ *
683
+ * A ROUTED CATEGORY keeps counting its own entries, because that is the
684
+ * only honest number available here: the narrowing happens in memory over
685
+ * the cached head, so both the count and the listing describe the same
686
+ * bounded set. Paging a category past the bound needs its own ordered
687
+ * query and a `(categoryId, status, publishedAt DESC)` index; until then
688
+ * a category of a very large collection is capped, and says so by
689
+ * 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);
693
+ data.pagination = _extends({
459
694
  page,
460
695
  perPage,
461
696
  totalEntries,
462
697
  totalPages: collectionTotalPages(totalEntries, perPage)
463
- };
698
+ }, Number.isFinite(windowStart) && windowStart > 0 ? {
699
+ windowStart
700
+ } : {});
464
701
  }
465
702
  }
466
703
  /**
@@ -523,7 +760,39 @@ async function readPublishedCollectionSource(options) {
523
760
  data.collection = source.collection;
524
761
  data.entries = source.entries;
525
762
  data.entriesReachedBound = source.reachedBound;
526
- applyCategoryAndPagination(data, options);
763
+ /*
764
+ * A page that starts past the cached read is served by its own window
765
+ * (AGL-3213). Everything before that point comes out of the shared
766
+ * source, so the pages a reader actually visits stay free.
767
+ *
768
+ * Only the unfiltered listing: a category route narrows in memory over
769
+ * the same head, and a window read of the whole collection would hand
770
+ * it ten entries of which any number may belong to another category.
771
+ */ const { page = 1, perPage } = options;
772
+ 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
+ }
790
+ }
791
+ }
792
+ applyCategoryAndPagination(data, options, {
793
+ totalLive: source.totalLive,
794
+ windowStart
795
+ });
527
796
  return data;
528
797
  }
529
798
  const collectionDoc = await findContentCollection(hostId, collectionSlug);