From bae3887d086989c8d9cde154fac626d5be5a0c82 Mon Sep 17 00:00:00 2001 From: Dan Date: Tue, 15 Sep 2026 15:00:46 +0300 Subject: [PATCH] fix: tolerate native activity ranking in pagination --- README.md | 10 ++++++---- package.json | 2 +- src/resources/activity/index.ts | 11 +++++------ test/activity.test.ts | 26 +++++++++++++++++++++++--- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 02083a6..3e42989 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,13 @@ minus one millisecond**, formatted as a UTC ISO timestamp. For example, `2026-09-05T21:53:10.062Z`. When `more=false`, `nextAfter` is `null`, including on an empty final page. These rules follow observed native Substack pagination. -Items must be ordered by descending `updated_at` (ties allowed). With an `after` -cursor, no item may be newer than the cursor, and a nonempty page's last item -must be strictly older than it. Invalid input throws `SubstackConfigurationError` +Items retain Substack's native ranking, which need not be descending by +`updated_at`. Individual grouped items may be newer than an `after` cursor. +For a nonempty page, the final item's `updated_at` minus 1 ms must be strictly +earlier than the supplied cursor. Do not sort items or use the minimum timestamp +to derive a cursor. Invalid input throws `SubstackConfigurationError` before requesting activity. Malformed responses, missing or invalid timestamps, -unordered items, non-advancing pages, and empty pages with `more=true` throw +non-advancing pages, and empty pages with `more=true` throw `SubstackApiError`; they are never interpreted as exhaustion. Grouped activity can be created in April and updated in September. This method diff --git a/package.json b/package.json index fba19ca..3902268 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unofficial-substack-sdk", - "version": "0.3.13", + "version": "0.3.14", "description": "Unofficial, portable TypeScript SDK for Substack's web API", "type": "module", "license": "MIT", diff --git a/src/resources/activity/index.ts b/src/resources/activity/index.ts index befb6a3..dc48f60 100644 --- a/src/resources/activity/index.ts +++ b/src/resources/activity/index.ts @@ -50,17 +50,16 @@ export async function getActivityPage( } const items = page.activityItems as unknown[] if (page.more && items.length === 0) invalid('an empty page cannot have more=true.') - let previous = Infinity + let lastUpdatedAt = Infinity for (const item of items) { const time = timestamp(item && typeof item === 'object' && !Array.isArray(item) ? (item as Record).updated_at : undefined) if (time === null) return invalid('each item must have a valid updated_at timestamp.') - if (time > previous) invalid('updated_at values must be in descending order (ties allowed).') - if (afterTime !== null && time > afterTime) invalid('items must not be newer than the supplied after cursor.') - previous = time + // Preserve native ranking: grouped notifications need not be ordered by updated_at. + lastUpdatedAt = time } - const nextTime = previous - 1 - if (items.length && afterTime !== null && previous >= afterTime) { + const nextTime = lastUpdatedAt - 1 + if (items.length && afterTime !== null && nextTime >= afterTime) { invalid('the page must advance beyond the supplied after cursor.') } const nextAfter = page.more ? new Date(nextTime).toISOString() : null diff --git a/test/activity.test.ts b/test/activity.test.ts index 8510402..3093905 100644 --- a/test/activity.test.ts +++ b/test/activity.test.ts @@ -65,8 +65,7 @@ describe('historical activity pages', () => { { activityItems: [{ created_at: oldest }], more: true }, { activityItems: [item('bad')], more: false }, { activityItems: [item('2026-02-30T00:00:00.000Z')], more: true }, - { activityItems: [item('0000-01-01T00:00:00.000Z')], more: true }, - { activityItems: [item(), item(recent)], more: false } + { activityItems: [item('0000-01-01T00:00:00.000Z')], more: true } ] for (const [index, response] of malformed.entries()) { test(`rejects malformed response ${index} rather than reporting exhaustion`, async () => { @@ -77,7 +76,7 @@ describe('historical activity pages', () => { } for (const more of [true, false]) { - for (const times of [[recent], [oldest], [recent, '2026-09-04T00:00:00.000Z']]) { + for (const times of [[recent], ['2026-09-05T21:53:10.059Z']]) { test(`rejects non-advancing or out-of-bound pages: ${times.join(',')}, more=${more}`, async () => { const { client } = fixture({ activityItems: times.map(time => item(time)), more }) await expect(client.getActivityPage({ after: oldest })).rejects.toBeInstanceOf(SubstackApiError) @@ -85,6 +84,27 @@ describe('historical activity pages', () => { } } + test('preserves unordered native ranking and derives the cursor from the final item, not the minimum', async () => { + const response = { activityItems: [item('2026-09-04T00:00:00.000Z'), item(recent), item()], more: true, users: { '7': { name: 'Ada' } } } + const { client, requests } = fixture(response) + expect(await client.getActivityPage()).toEqual({ ...response, nextAfter: next }) + expect(requests).toHaveLength(1) + }) + + for (const more of [true, false]) { + test(`allows grouped items newer than after when the final cursor advances, more=${more}`, async () => { + const response = { activityItems: [item(recent), item('2026-09-04T00:00:00.000Z')], more } + const { client, requests } = fixture(response) + expect(await client.getActivityPage({ after: oldest })).toEqual({ ...response, nextAfter: more ? '2026-09-03T23:59:59.999Z' : null }) + expect(requests).toHaveLength(1) + }) + } + + test('accepts a final timestamp equal to after when subtracting one millisecond advances the cursor', async () => { + const { client } = fixture({ activityItems: [item()], more: true }) + expect((await client.getActivityPage({ after: oldest })).nextAfter).toBe(next) + }) + for (const after of ['', 'bad', '2026-02-30T00:00:00Z', '2026-09-05', '2026-09-05T21:53:10', '2026-09-05T24:00:00Z', '2026-09-05T21:53:10.0581Z', '2026-09-05T21:53:10+24:00', null, 123]) { test(`rejects invalid input cursor ${after} before requesting activity`, async () => {