Skip to content

Remove the 2-part cursor fallback in decodeCursor() #152

Description

@cuibonobo

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions