@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.
- package/package.json +3 -3
- package/src/lib/collection-fallback-nodes.d.ts +6 -1
- package/src/lib/collection-fallback-nodes.js +23 -4
- package/src/lib/collection-fallback-nodes.js.map +1 -1
- package/src/lib/compose-collection-page.js +14 -6
- 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 +84 -4
- package/src/lib/get-collection-content.js +404 -38
- package/src/lib/get-collection-content.js.map +1 -1
|
@@ -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,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
|
-
})
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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);
|