Problem
decodeCursor() in packages/sqlite-shared/src/cursor.ts accepts two cursor shapes: the 3-part field|value|id form, and a 2-part value|id form that it coerces to createdAt.
const parts = decoded.split('|');
const [field, value, id] =
parts.length === 3
? (parts as [string, string, string])
: (['createdAt', ...parts] as [string, string, string]);
encodeCursor() is the only producer of cursors in this library, and it always emits three parts:
export const encodeCursor = (field: SortField, value: number, id: string): string =>
btoa(`${field}|${value}|${id}`);
So the 2-part branch exists only to accept cursors minted by an older version of the codec. With no install base, there are no such cursors, and the branch is unreachable by construction.
Why it's worth removing rather than leaving alone
It isn't inert — it actively weakens cursor validation. A corrupt or truncated cursor that happens to decode to two |-separated parts is silently accepted with a fabricated sort field instead of being rejected:
decodeCursor(btoa('12345|rec01'))
// → { field: 'createdAt', value: 12345, id: 'rec01' } accepted
That contradicts the documented contract — the function's own doc comment says it "throws StackQueryError for any malformed, corrupt, or unrecognized cursor", and docs/spec/data-model.md § Sorting and pagination says a cursor that can't be decoded is a structurally malformed request that must throw StackQueryError → 400. A 2-part cursor is exactly that, and it currently doesn't throw.
Proposed change
Require exactly three parts; anything else throws StackQueryError:
const parts = decoded.split('|');
if (parts.length !== 3) {
throw new StackQueryError(`Invalid cursor: malformed "${cursor}"`);
}
const [field, value, id] = parts as [string, string, string];
The subsequent field === undefined || value === undefined || id === undefined guard becomes dead once the length check is in place and can go with it.
Also needs updating
packages/sqlite-shared/tests/cursor.test.ts has a test pinning the old behavior — accepts the legacy 2-part (value|id) format as createdAt. It should be replaced with one asserting a 2-part cursor throws StackQueryError.
Scope note
Both SQLite-backed adapters share this codec via @haverstack/sqlite-shared, so the change lands in one place and applies to both. Cursors are opaque and single-session by contract, so tightening the decoder has no data-migration implications.
Problem
decodeCursor()inpackages/sqlite-shared/src/cursor.tsaccepts two cursor shapes: the 3-partfield|value|idform, and a 2-partvalue|idform that it coerces tocreatedAt.encodeCursor()is the only producer of cursors in this library, and it always emits three parts:So the 2-part branch exists only to accept cursors minted by an older version of the codec. With no install base, there are no such cursors, and the branch is unreachable by construction.
Why it's worth removing rather than leaving alone
It isn't inert — it actively weakens cursor validation. A corrupt or truncated cursor that happens to decode to two
|-separated parts is silently accepted with a fabricated sort field instead of being rejected:That contradicts the documented contract — the function's own doc comment says it "throws StackQueryError for any malformed, corrupt, or unrecognized cursor", and
docs/spec/data-model.md§ Sorting and pagination says a cursor that can't be decoded is a structurally malformed request that must throwStackQueryError→ 400. A 2-part cursor is exactly that, and it currently doesn't throw.Proposed change
Require exactly three parts; anything else throws
StackQueryError:The subsequent
field === undefined || value === undefined || id === undefinedguard becomes dead once the length check is in place and can go with it.Also needs updating
packages/sqlite-shared/tests/cursor.test.tshas a test pinning the old behavior —accepts the legacy 2-part (value|id) format as createdAt. It should be replaced with one asserting a 2-part cursor throwsStackQueryError.Scope note
Both SQLite-backed adapters share this codec via
@haverstack/sqlite-shared, so the change lands in one place and applies to both. Cursors are opaque and single-session by contract, so tightening the decoder has no data-migration implications.