@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.
- package/package.json +3 -3
- package/src/lib/compose-collection-page.js +6 -2
- package/src/lib/compose-collection-page.js.map +1 -1
- package/src/lib/compose-screen-nodes.d.ts +9 -0
- package/src/lib/compose-screen-nodes.js +2 -0
- package/src/lib/compose-screen-nodes.js.map +1 -1
- package/src/lib/get-author-content.js +63 -2
- package/src/lib/get-author-content.js.map +1 -1
- package/src/lib/get-collection-content.d.ts +18 -0
- package/src/lib/get-collection-content.js +312 -43
- package/src/lib/get-collection-content.js.map +1 -1
|
@@ -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
|
-
*
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
])//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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);
|