Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 5 additions & 6 deletions src/resources/activity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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
Expand Down
26 changes: 23 additions & 3 deletions test/activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -77,14 +76,35 @@ 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)
})
}
}

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 () => {
Expand Down