feat(operations): remove attributes over the operations API (full_record, __unset__) - #2346
feat(operations): remove attributes over the operations API (full_record, __unset__)#2346dawsontoth wants to merge 3 commits into
full_record, __unset__)#2346Conversation
`update` and `upsert` both merge: for a record that already exists they land on `Table.patch` (ResourceBridge.upsertRecords), so an attribute left out of the payload keeps its stored value and `null` stores a null. There was no way to REMOVE an attribute through the operations API — a full replace existed only over REST (`PUT /Table/id` -> `resource.put`), which a client talking to the operations API can't reach. Clients worked around it by deleting the record and inserting it again; that leaves the record absent between two writes, resets `__createdtime__`, and shows subscribers a delete followed by an insert. `full_record: true` selects `Table.put` instead. It is orthogonal to each operation's create rule, so one flag covers both useful shapes: update + full_record -> full replace, still skips a record that isn't there upsert + full_record -> full replace, still creates one (= REST PUT) It has no effect on `insert`, which never writes over an existing record. The merge default is untouched: that is the v4-compatible behaviour existing clients depend on, and 5bd3639 / b302a16 made it deliberate. `.strict()` on the validator key, not the whole object: `validateBySchema` allows unknown keys and discards Joi's coerced value, so without it a string `"false"` would validate and reach the bridge still a string, letting truthiness decide a destructive question. A role with `attribute_permissions` on the table is refused. Attribute permissions are checked against the attributes a request SUPPLIES, which is enough for a merge but not for a replace, where an omitted attribute is removed — such a role could otherwise erase an attribute it has no `update` permission for by leaving it out. REST closes the same gap in `Table.allowUpdate` by restoring those attributes from the stored record, which needs the stored record; authorization runs before anything is read, so it denies instead. Covered by integrationTests/database/full-record-write.test.ts: the removal, the merge default, an explicit null staying a stored null, `__createdtime__` surviving, both create rules, parity with REST PUT, a relationship foreign key still resolving, the attribute-permission denial, and the non-boolean rejection. Refs: HarperFast/studio#1643 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a full_record option for update and upsert operations in the operations API, enabling a full replace instead of a merge. This allows omitted attributes to be removed while preserving the original creation timestamp and maintaining relationship integrity. The changes also include strict boolean validation for the new flag, authorization checks to prevent attribute-scoped roles from bypassing permissions via full replaces, and comprehensive integration tests. We have no feedback to provide on these changes.
|
Found one blocker (inline, still unaddressed): the |
`full_record` can drop an attribute, but only by replacing the whole record — so
a caller who wants to remove one field has to resend every other field, and any
field it doesn't resend is silently dropped too. That makes it the wrong tool for
"delete this one property": it forces a read-modify-write on the client, which
races every other writer.
`__unset__` is the narrower one. It names attributes to remove and merges
everything else as usual:
{ "operation": "update", "database": "dev", "table": "dog",
"records": [{ "id": 1, "__unset__": ["age"] }] }
`age` is removed; every attribute the request didn't mention keeps its value.
An array of names rather than a map with ignored values: the values would carry
no meaning, and a shape that accepts anything invites callers to read
significance into it.
Resolved server-side in `upsertRecords`, against the record the write transaction
has already loaded, and written as a full replace. That is the point of doing it
here rather than in the client: the merge and the write are one transaction.
Reserved like `__createdtime__`/`__updatedtime__` (`terms.UNSET_ATTRIBUTES`), and
never stored. Both copies of the attribute-collection loop skip it —
`insertUpdateValidate.js` and its documented async twin in `insert.ts` — because
`upsertRecords` feeds that list to `Table.addAttributes`, so on an open
(non-schemaDefined) table an unrecognised `__unset__` would be registered as a
real table attribute.
Refused: the primary key (in the bridge, which is the only layer that knows which
attribute that is — `_writeUpdate` re-asserts it on a full update, so unsetting it
would otherwise be silently ignored), the system timestamps, and any malformed
value.
Attribute permissions are checked precisely, unlike the blanket `full_record`
denial: `getRecordAttributes` contributes the names `__unset__` REMOVES instead of
the key itself, so removing an attribute needs the same `update` permission that
writing it would. A role may unset what it can write and nothing else.
Covered by 11 further probes in
integrationTests/database/full-record-write.test.ts, including the open-table
attribute-registration guard and the permission check.
One behaviour found while testing and pinned rather than changed: on a legacy open
table the operations-API attribute projection reports every REGISTERED attribute,
filling in `null` for one the record does not have — so a removed attribute still
reads back as `key: null` through `get_attributes`, identically to a record that
never had it. `SELECT *` reflects the stored record. This is a read-path artifact,
not storage, and it applies equally to `full_record`; clients removing attributes
from open tables need to know it.
Refs: HarperFast/studio#1643
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
full_record for a full-replace writefull_record, __unset__)
…ey apply to
`verifyPerms` runs for every operation, and both new directives pass validation on
requests where they mean nothing — `full_record` is an accepted key on the shared
insert/update/upsert validator, and unknown keys pass validation everywhere. So
neither check may test the directive alone:
- The `full_record` denial fired for ANY operation whose body carried the flag.
An attribute-scoped role doing an `insert`, a `search_by_*`, or anything else
with a stray `full_record: true` got a spurious 403 — plausible for a generic
client wrapper that sets the flag on every write. Those operations never reach
`Table.patch`, so there is nothing there for the check to protect. Now gated on
`update`/`upsert`, which is what the flag actually changes, and what the PR
already claimed ("no effect on insert").
- `getRecordAttributes` contributed the names `__unset__` removes for every
operation. On an `insert` the directive removes nothing (`upsertRecords` strips
it, and an insert never writes over an existing record), so demanding `insert`
permission on attributes the request isn't touching denied that too.
Caught in review by claude[bot] on #2346 for the first; the second is the same
defect one file over, found by looking for it.
Both directions covered: the existing probes still assert the denials fire on
`update`, and three new ones assert an `insert` carrying either directive and a
search carrying a stray flag are NOT denied for an attribute-scoped role.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Superseded by #2347, which carries these two commits plus the attribute-permission scoping fix and the target-database authorization fix. #2347 is based on Branch |
What
Adds
full_record: truetoupdateandupsert, selecting a full replace instead of thedefault merge.
{ "operation": "update", "database": "dev", "table": "dog", "full_record": true, "records": [{ "id": 1, "dog_name": "Penny" }] }The stored record becomes exactly that — any attribute not in the payload is removed.
Why
There is currently no way to remove an attribute from a record through the operations API.
updateandupsertboth land onTable.patchfor a record that already exists(
ResourceBridge.upsertRecords), which merges: an omitted attribute keeps its stored value, andnullstores a null. A full replace exists only over REST (PUT /Table/id→resource.put),which a client speaking the operations API can't reach — and can't reach at all through a proxied
operations endpoint.
Reported downstream as HarperFast/studio#1643, where Studio's row editor silently dropped a
property deletion. The workaround available to clients today is to
deletethe record andinsertit again, which:from the far side, and replicas all observe the gap;
__createdtime__(a realputretains it —Table._writeUpdatekeeps the storedcreated time on a full update and only stamps a new one for a new entry);
A single full-replace write has none of those properties.
Design
The flag is orthogonal to each operation's create rule, so one flag covers both useful shapes
without a new verb:
updateupsertupdate+full_recordupsert+full_recordPUT /Table/idNo effect on
insert, which never writes over an existing record.The merge default is deliberately untouched.
5da23c3eforiginally usedTable.putandhand-copied missing properties from the existing record to emulate a merge;
5bd363946("Update/upsert records should use patch") replaced that with a real
Table.patch, andb302a16e7added theputfallback for a missing record. So merge semantics is load-bearing v4compatibility, and changing
upsertin place would be breaking. Hence a flag rather than newsemantics — even though the newer resource/type layer already reads
upsertas PUT(
defineTable.ts:UPSERT (PUT): full replace, anddefineResource.tstreatsupsertandpatchas distinct projections).Two details worth a look
.strict()on the validator key, not the whole object.validateBySchemapasses unknown keysthrough and discards Joi's converted value, so without
.strict()a string"false"wouldvalidate and then reach the bridge still a string — letting truthiness decide a destructive
question. Strict on the one key rejects it instead; making the whole object strict would tighten
the long-standing contract of
database/schema/table/records. Same rationale asanalyticsValidator's.strict().A role with
attribute_permissionson the table is refused. Attribute permissions are checkedagainst the attributes a request supplies (
checkAttributePerms), which is sufficient for amerge but not for a replace, where an omitted attribute is removed — so an attribute-scoped role
could otherwise erase an attribute it has no
updatepermission for simply by leaving it out.REST closes the same gap in
Table.allowUpdate("if this is a full put operation that removesmissing properties, we don't want to remove properties that the user doesn't have permission to
remove") by restoring those attributes from the stored record, which needs the stored record;
authorization runs before anything is read, so it denies rather than silently narrowing the write.
Roles that scope no attribute — every role without deliberate
attribute_permissionson the table— are unaffected. Happy to swap this for a restore-based approach if you'd rather it mirror REST.
__unset__— removing an attribute without resending the recordfull_recordcan drop an attribute, but only by replacing the whole record, so a caller who wantsto remove one field has to resend every other field — and anything it fails to resend is dropped
too.
__unset__is the narrower tool:{ "operation": "update", "database": "dev", "table": "dog", "records": [{ "id": 1, "__unset__": ["age"] }] }ageis removed; every attribute the request didn't mention keeps its value. An array of namesrather than a map with ignored values — the values would carry no meaning, and a shape that accepts
anything invites callers to read significance into it.
Resolved server-side in
upsertRecords, against the record the write transaction has alreadyloaded, and written as a full replace. Doing it here rather than in the client is the point: the
merge and the write are one transaction, where a client emulating it with read-then-replace races
every other writer.
Reserved like
__createdtime__/__updatedtime__(terms.UNSET_ATTRIBUTES) and never stored. Bothcopies of the attribute-collection loop skip it —
insertUpdateValidate.jsand its documented asynctwin in
insert.ts— becauseupsertRecordsfeeds that list toTable.addAttributes, so on anopen (non-
schemaDefined) table an unrecognised__unset__would be registered as a real tableattribute. Refused: the primary key (in the bridge, the only layer that knows which attribute that
is —
_writeUpdatere-asserts it on a full update, so unsetting it would otherwise be silentlyignored), the system timestamps, and any malformed value.
Attribute permissions are checked precisely here, which is the part worth reviewing:
getRecordAttributescontributes the names__unset__removes instead of the key itself, soremoving an attribute needs the same
updatepermission that writing it would. A role may unsetwhat it can write and nothing else. That's strictly better than the blanket
full_recorddenialabove, because the removals are named — if you'd prefer,
full_recordcould later be narrowed thesame way by resolving its removals against the stored record.
One behaviour found while testing, pinned rather than changed
On a legacy open (non-
schemaDefined) table, the operations-API attribute projection reportsevery registered attribute, filling in
nullfor one the record doesn't have. So after removingan attribute from an open table, a
get_attributesread still showskey: null— identically to arecord that never had it.
SELECT *reflects the stored record and omits it.It's a read-path artifact, not storage: verified by inserting a record that never had the attribute
and getting the same
null. It applies equally tofull_record, and it predates both. Called outbecause it makes a successful removal look like a failed one, which is exactly the symptom
studio#1643 was originally reported as — a client removing attributes from open tables needs to know
the read lies. There's a test pinning it so it can't drift silently.
Testing
integrationTests/database/full-record-write.test.ts— 21 probes, all passing on rocksdb.For
full_record:nullstaying a stored null (omission is the only thing that removes)__createdtime__surviving and__updatedtime__re-stampingupdateskips a missing record,upsertcreates one)PUT— same stored attribute set through both doorsfull_recordrejected rather than coercedFor
__unset__: the removal with everything else merged, several at once,__createdtime__preserved, a relationship table surviving the server-side merge with its foreign key intact, an
unset of an attribute the record doesn't have being a no-op, the attribute-permission check
(denied for an attribute the role can't update, allowed for one it can), the primary-key and
system-timestamp refusals, malformed values rejected,
__unset__not becoming an attribute of anopen table, and the open-table read-path projection above.
Every guard was mutation-checked — reverting it has to turn a test red. Making the bridge ignore
full_recordfails 5; dropping the server-side merge fails 4; not expanding__unset__for thepermission check fails 1; letting
__unset__register as an attribute fails 1. That last one iswhy the open-table probe creates its own table: the first version asserted against a
schema-defined table and passed with the guard removed, i.e. it tested nothing.
Also ran, green, for regressions around authorization and write semantics:
integrationTests/security/{user-role-management,sql-unqualified-table-authz,query-row-allowread-checkpermission}and
integrationTests/database/{auto-fields,bulk-conditional-mutation,patch-disjoint-field-merge}— 59 tests, re-run after
__unset__landed.Not covered / not done:
full_recordwrite replicates as aput, the same as RESTPUTalready does, but I have not proven it — no multi-node probe in this suite.HARPER_STORAGE_ENGINE=lmdb). The suite is written to run under it.mine to do. Nothing in this change has unit coverage; it's all integration.
reference/operations-api/operations.mdin the documentation repo needsboth
full_recordand__unset__on theupdateandupsertentries, plus a note about theopen-table read projection. Happy to open that PR once the shape here is agreed.
__unset__oninsertis accepted and ignored (there is no existing record to remove from).Rejecting it instead would be easy if you'd rather it be an error.
Draft until the naming (
full_record,__unset__) and the two attribute-permission decisions geta maintainer opinion — all public API surface, all cheap to change now and awkward later.
🤖 Generated with Claude Code