Skip to content

Add clientWritable and paginate - #15

Merged
walhalabi-code merged 1 commit into
mainfrom
feat/client-writable-and-paginate
Sep 1, 2026
Merged

walhalabi-code merged 1 commit into
mainfrom
feat/client-writable-and-paginate

Conversation

@walhalabi-code

Copy link
Copy Markdown
Owner

Two features, both closing failures that run perfectly while being wrong.

clientWritable: false on @ParseField. fromParams set every declared field the request carried - mass assignment. An endpoint accepting {"title"} also accepted {"title", "status": "published", "views": 9999}, and the row saved with values the caller was never meant to choose, silently. Marked on the model, the rule holds wherever fromParams is used instead of depending on every endpoint overwriting afterwards.

The first implementation was fake, and the tests caught it: filtering inside the field loop came after Parse.Object.fromJSON had already assigned every key. The filter now runs on the source before the object is built.

Discarding is silent to the CLIENT on purpose - refusing the request would tell an attacker which fields are protected, and would break a merely stale app. The developer is the one silence serves badly, so outside production a warning names the field, once per class and field.

paginate(query, params, options). Every list endpoint hand-writes the same seven lines, and the obvious version returns results.length as the count - the size of the page the client already has, which nothing can paginate with. It shipped in this library's own starter. paginate reads limit and skip as the strings a GET sends, caps the limit, uses withCount for the true total, and computes hasMore.

It also appends a createdAt tiebreaker to the sort. "Always sort" is not sufficient advice: sorting by a low-cardinality column leaves ties, and tied rows page exactly as badly as unsorted ones - repeated on two pages or dropped between them, with nothing reported. Appended, never overriding, so the endpoint still owns the order; tiebreak: false opts out for the caller whose indexes make the extra key expensive.

The starter uses both: views is now server-owned, and listNotes shrinks to three lines of intent. RULES.md gains rule 7 and rewrites rule 6 from "overwrite after the call" to the declaration; the endpoint and model skills, CLAUDE.md, from-params.html and queries.html all follow, with search index entries.

356 tests pass, 21 of them new.

Two features, both closing failures that run perfectly while being wrong.

clientWritable: false on @ParseField. fromParams set every declared field the
request carried - mass assignment. An endpoint accepting {"title"} also
accepted {"title", "status": "published", "views": 9999}, and the row saved
with values the caller was never meant to choose, silently. Marked on the
model, the rule holds wherever fromParams is used instead of depending on
every endpoint overwriting afterwards.

The first implementation was fake, and the tests caught it: filtering inside
the field loop came after Parse.Object.fromJSON had already assigned every
key. The filter now runs on the source before the object is built.

Discarding is silent to the CLIENT on purpose - refusing the request would
tell an attacker which fields are protected, and would break a merely stale
app. The developer is the one silence serves badly, so outside production a
warning names the field, once per class and field.

paginate(query, params, options). Every list endpoint hand-writes the same
seven lines, and the obvious version returns results.length as the count -
the size of the page the client already has, which nothing can paginate with.
It shipped in this library's own starter. paginate reads limit and skip as
the strings a GET sends, caps the limit, uses withCount for the true total,
and computes hasMore.

It also appends a createdAt tiebreaker to the sort. "Always sort" is not
sufficient advice: sorting by a low-cardinality column leaves ties, and tied
rows page exactly as badly as unsorted ones - repeated on two pages or
dropped between them, with nothing reported. Appended, never overriding, so
the endpoint still owns the order; tiebreak: false opts out for the caller
whose indexes make the extra key expensive.

The starter uses both: views is now server-owned, and listNotes shrinks to
three lines of intent. RULES.md gains rule 7 and rewrites rule 6 from
"overwrite after the call" to the declaration; the endpoint and model skills,
CLAUDE.md, from-params.html and queries.html all follow, with search index
entries.

356 tests pass, 21 of them new.
@walhalabi-code
walhalabi-code merged commit 16cf2d1 into main Sep 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant