Skip to content

Commit 14f9dca

Browse files
dawsontothclaude
andcommitted
fix(operations): refuse __unset__ whenever there is no record to remove from
Delta review of the previous three commits found the insert refusal guarding the wrong condition. It keyed on `requires_no_existing` — the insert flag — so the same silent partial write was still reachable one branch over: `insertUpdateValidate` requires a primary key only for `update`, so `{operation:'upsert', records:[{name:'Penny', color:'black', __unset__:['color']}]}` took the `id == undefined → Table.create` path and stored an auto-keyed record with `color` stripped, returning 200. An `upsert` naming a primary key that isn't stored had the same shape. Now keyed on `!existingRecord`, which is the actual invariant: no stored record means nothing to remove from, so the directive can only be a misunderstanding. That covers insert, upsert-without-a-key, and upsert-of-an-absent-key together. `update` never reaches it — a missing record is already skipped upstream. This is the fix-one-of-N-surfaces mistake: the condition was written from the operation that exhibited the bug rather than from the property being protected. Also from the review: - Managed timestamps are refused off `Table.createdTimeProperty`/ `updatedTimeProperty` instead of the validator's static legacy list, so a schema-declared `createdAt: Float @createdTime` is caught too. Those properties resolve `assignCreatedTime` OR the legacy `__createdtime__` spelling (resources/Table.ts:504-505), so one check covers both and there is no second list to drift. The validator's list is removed rather than kept alongside it. - The malformed-directive warning is latched and names the table, following `warnedNullSourcePut` in resources/Table.ts — it sits on the write and replication apply path, where one line per record buries the log under a single bad batch. - Comment density trimmed. Both reviews flagged it; the removed lines narrated review history or addressed the reviewer rather than the next reader. Kept: why the malformed case warns instead of throwing, why the refusals live in this layer, and the falsy-vs-nullish reasoning. Tests: 33 in the suite, all passing, and the new guards are mutation-checked — reverting to the insert flag fails the two new upsert probes, and removing the managed-timestamp loop fails both timestamp probes. 118 more across security, terminology and auto-fields still pass. One test correction worth noting: the first version of the upsert probe asserted "no record with this name and null breed/color", which matched eight records other probes had legitimately stripped attributes from. It now uses a name unique to the probe. Still open from the review, unfixed: `__unset__` remains a whole-record put under contention (#2350), the directive refusals happen after dynamic attribute expansion so a rejected write can still have registered new attributes on an open table, and the bulk-load authorization fix has no test because the JSON bulk path needs S3/URL input. The CSV entry point takes a different attribute list (bulkLoad.ts:82, papaparse header fields) which this change does not touch, and CSV cannot express the array anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a3bab5c commit 14f9dca

3 files changed

Lines changed: 135 additions & 44 deletions

File tree

dataLayer/harperBridge/ResourceBridge.ts

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ const DELETE_PAUSE_MS = 10;
3939
/** Shared result for the overwhelmingly common case of a record carrying no `__unset__`, so the
4040
* default write path allocates nothing per record. Frozen because it is handed out repeatedly. */
4141
const NO_UNSET_ATTRIBUTES = Object.freeze([]) as unknown as string[];
42+
/** Latch for the malformed-directive warning; see takeUnsetAttributes. */
43+
let warnedMalformedUnset = false;
4244

4345
export type SearchByConditionsRequest = Query &
4446
Context & {
@@ -280,14 +282,28 @@ export class ResourceBridge extends BridgeMethods {
280282
}
281283
}
282284
}
283-
const unset = takeUnsetAttributes(record, Table.primaryKey, upsertObj.requires_no_existing);
285+
// Keyed on whether there IS a record to remove from, not on which operation asked. Guarding
286+
// the insert flag alone left `upsert` with no primary key reaching the same silent partial
287+
// write one branch over: `insertUpdateValidate` requires a hash attribute only for
288+
// `update`, so such a record takes the `id == undefined → Table.create` path and stored an
289+
// auto-keyed record with the named attributes stripped, returning 200.
290+
const unset = takeUnsetAttributes(record, Table, !existingRecord);
284291
// `__unset__` removes named attributes while everything else still merges, which a patch
285292
// cannot express — so it resolves the merge here, against the record this transaction
286293
// already loaded, and writes the result as a full replace. Doing it server-side inside the
287294
// write transaction is the point: a client emulating this with read-then-replace races
288295
// every other writer, and `full_record` would make the caller resend attributes it never
289296
// meant to touch. Under `full_record` the submitted record is already the whole intended
290297
// state, so there is nothing to merge and the names are simply dropped from it.
298+
//
299+
// SINGLE-WRITER ONLY, and the surrounding wording should not be read as more than that.
300+
// Resolving the merge here still writes a `put`, and a full put is last-writer-wins over
301+
// the whole record: out-of-order reconciliation folds field-wise only for patches
302+
// (`resources/Table.ts:2902-2914`) and the commit-retry path re-reads the stored record
303+
// only when `!fullUpdate` (`resources/Table.ts:2530`). So a concurrent write to an
304+
// attribute this request never named can be lost — the opposite of what "everything else
305+
// merges" implies. Tracked in HarperFast/harper#2350; fixing it properly means resolving
306+
// removals at the patch layer instead.
291307
const toWrite = unset.length && existingRecord && !fullRecord ? { ...existingRecord, ...record } : record;
292308
for (const attribute of unset) {
293309
delete toWrite[attribute];
@@ -808,53 +824,53 @@ async function* groupRecordsInHistory(table, start?, end?, limit?) {
808824
/**
809825
* Read and remove a record's `__unset__` list, returning the attribute names to drop.
810826
*
811-
* Removed from the record because it is a directive, not data: leaving it in place would store it as
812-
* an attribute. Shape is already validated (`validation/insertValidator.ts`); the primary key is
813-
* refused here instead, since only this layer knows which attribute that is. Unsetting it would
814-
* otherwise be silently ignored — `Table._writeUpdate` re-asserts the primary key on a full update
815-
* and a directive that quietly does nothing is worse than one that says no.
827+
* The key is removed because it is a directive, not data — left in place it would be stored as an
828+
* attribute. The refusals below live here rather than in the validator because only this layer knows
829+
* the table: which attribute is the primary key, and what the managed timestamps are called. Each is
830+
* refused rather than skipped, because `_writeUpdate` re-asserts all three on a full update, so
831+
* accepting them would return 200 and change nothing.
816832
*/
817-
function takeUnsetAttributes(
818-
record: Record<string, unknown>,
819-
primaryKey: string,
820-
isCreateOnly: boolean | undefined
821-
): string[] {
833+
function takeUnsetAttributes(record: Record<string, unknown>, Table: any, hasNoExistingRecord: boolean): string[] {
822834
if (!(OPERATIONS_UNSET_KEY in record)) return NO_UNSET_ATTRIBUTES;
823835
const unset = record[OPERATIONS_UNSET_KEY];
824-
// Removed before the shape is judged, and on every exit below: the key is a directive, so
825-
// leaving it on a record that is about to be written stores it as data. `in` rather than an
826-
// `undefined` check for the same reason — an explicitly-undefined key still has to come off.
836+
// Before the shape is judged, and on every exit: `in` rather than a value check, so an
837+
// explicitly-undefined key still comes off the record.
827838
delete record[OPERATIONS_UNSET_KEY];
828-
// `insert` has no existing record to remove anything from, so the directive can only mean the
829-
// caller misunderstood it. Refused rather than applied: applying it deleted attributes the same
830-
// request had just supplied, which is a silent partial write, and rather than ignored, because a
831-
// directive that quietly does nothing is worse than one that says no — the same reason the primary
832-
// key below is refused instead of skipped. Authorization contributes nothing for `insert` on the
833-
// strength of this (`utility/operation_authorization.ts` getRecordAttributes).
834-
if (isCreateOnly) {
839+
if (hasNoExistingRecord) {
835840
throw new ClientError(
836-
`'${OPERATIONS_UNSET_KEY}' is not valid on an insert, which has no existing record to remove attributes from`
841+
`'${OPERATIONS_UNSET_KEY}' needs an existing record to remove attributes from; nothing is stored under this primary key`
837842
);
838843
}
839-
// Self-safeguarding rather than trusting the caller. `validation/insertValidator.ts` rejects a
840-
// malformed value, and every current route here goes through it (`dataLayer/insert.ts`
841-
// create/update/upsertRecords, including the replication catchup path) — but the bridge's own
842-
// `insertUpdateValidate` does not check this key, so that safety lives in a different layer, and
843-
// this is a module boundary an internal caller could reach directly. A non-iterable reaching the
844-
// `for…of` in `upsertRecords` would throw and take the write down; a bare string would iterate
845-
// per character and delete single-letter attributes.
846-
//
847844
// Ignored with a warning rather than thrown: this is the write and replication apply path, where
848-
// throwing aborts the commit and can wedge a subscription. Skipping the directive degrades to a
849-
// plain merge, which removes nothing — the safe direction. Same choice `resources/tracked.ts`
850-
// makes for an unrecognized CRDT operation, and the reason it makes it.
845+
// throwing aborts the commit and can wedge a subscription, and skipping the directive degrades to
846+
// a plain merge, which removes nothing. `resources/tracked.ts` makes the same choice for an
847+
// unrecognized CRDT operation. Checked at all because the shape is validated one layer up, in a
848+
// module this one does not own: a non-iterable would throw in the caller's `for…of` and take the
849+
// write down, and a bare string would iterate per character, deleting single-letter attributes.
851850
if (!Array.isArray(unset) || unset.some((name) => typeof name !== 'string' || name.length === 0)) {
852-
logger.warn(`Ignoring a malformed '${OPERATIONS_UNSET_KEY}' on a record; expected an array of attribute names`);
851+
// Latched, following `warnedNullSourcePut` in resources/Table.ts: one line per record would
852+
// bury the log under a single bad batch.
853+
if (!warnedMalformedUnset) {
854+
warnedMalformedUnset = true;
855+
logger.warn(
856+
`Ignoring a malformed '${OPERATIONS_UNSET_KEY}' on a record in ${Table.databaseName}.${Table.tableName}; expected an array of attribute names`
857+
);
858+
}
853859
return NO_UNSET_ATTRIBUTES;
854860
}
855861
const names = unset as string[];
862+
const primaryKey = Table.primaryKey;
856863
if (primaryKey && names.includes(primaryKey)) {
857864
throw new ClientError(`'${primaryKey}' is the primary key of this table and cannot be unset`);
858865
}
866+
// `createdTimeProperty`/`updatedTimeProperty` resolve `assignCreatedTime` OR the legacy
867+
// `__createdtime__` spelling (resources/Table.ts), so this covers a schema-declared
868+
// `createdAt: Float @createdTime` as well as the legacy names — which a static list in the
869+
// validator, with no schema in scope, could not.
870+
for (const managed of [Table.createdTimeProperty, Table.updatedTimeProperty]) {
871+
if (managed?.name && names.includes(managed.name)) {
872+
throw new ClientError(`'${managed.name}' is maintained by Harper and cannot be unset`);
873+
}
874+
}
859875
return names;
860876
}

integrationTests/database/full-record-write.test.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -530,15 +530,54 @@ suite('full_record: full replace over the operations API', { skip: skipSuite },
530530
strictEqual((await read('Dog', 'us-6')).name, 'Penny', 'nothing should have changed');
531531
});
532532

533-
test('__unset__ refuses a system timestamp', async () => {
533+
// Both spellings, because they are refused by one check rather than two. `Table.createdTimeProperty`
534+
// resolves `assignCreatedTime` OR the legacy `__createdtime__` name (`resources/Table.ts:504-505`),
535+
// so a schema-declared `createdAt: Float @createdTime` — which this fixture has, and which a static
536+
// name list in the validator could never catch — is refused by the same code as the legacy name.
537+
// Without this the write path silently re-asserts the value: a 200 that changes nothing.
538+
test('__unset__ refuses a schema-declared managed timestamp', async () => {
534539
await insert('Dog', [{ id: 'us-7', name: 'Penny' }]);
540+
const before = await read('Dog', 'us-7');
541+
542+
// This fixture declares `createdAt: Float @createdTime` / `updatedAt: Float @updatedTime`, so
543+
// these are the table's managed timestamps and a static name list in the validator could never
544+
// have caught them. Without the refusal the write path silently re-asserts the value.
545+
for (const name of ['createdAt', 'updatedAt']) {
546+
const r = await ops({ operation: 'update', table: 'Dog', records: [{ id: 'us-7', __unset__: [name] }] });
547+
ok(r.status >= 400, `unsetting '${name}' should be refused; got ${r.status} ${JSON.stringify(r.body)}`);
548+
}
535549

536-
const r = await ops({
550+
const after = await read('Dog', 'us-7');
551+
strictEqual(after.createdAt, before.createdAt, 'no refused request may have altered the record');
552+
strictEqual(after.name, 'Penny');
553+
554+
// ...and on THIS table `__createdtime__` is not a managed attribute at all, just a name the
555+
// record doesn't have — so it is an ordinary no-op, not a refusal. The legacy spelling is only
556+
// managed where it is the table's actual timestamp attribute (next test).
557+
const legacy = await ops({
537558
operation: 'update',
538559
table: 'Dog',
539560
records: [{ id: 'us-7', __unset__: ['__createdtime__'] }],
540561
});
541-
ok(r.status >= 400, `unsetting a system timestamp should be refused; got ${r.status} ${JSON.stringify(r.body)}`);
562+
strictEqual(legacy.status, 200, `an unknown attribute name is a no-op; got ${JSON.stringify(legacy.body)}`);
563+
});
564+
565+
// The other half of "one check resolves both spellings": on an open table created through
566+
// `create_table`, `__createdtime__` IS the table's createdTimeProperty — `Table.ts:504` assigns it
567+
// by name when no `assignCreatedTime` flag exists — so the same code refuses it there.
568+
test('__unset__ refuses the legacy timestamp names on an open table, where they are the managed ones', async () => {
569+
strictEqual(
570+
(await ops({ operation: 'create_table', database: 'data', table: 'OpenStamp', primary_key: 'id' })).status,
571+
200
572+
);
573+
await insert('OpenStamp', [{ id: 'os-1', name: 'Penny' }]);
574+
575+
for (const name of ['__createdtime__', '__updatedtime__']) {
576+
const r = await ops({ operation: 'update', table: 'OpenStamp', records: [{ id: 'os-1', __unset__: [name] }] });
577+
ok(r.status >= 400, `unsetting '${name}' should be refused; got ${r.status} ${JSON.stringify(r.body)}`);
578+
}
579+
580+
strictEqual((await read('OpenStamp', 'os-1')).name, 'Penny', 'no refused request may have altered the record');
542581
});
543582

544583
test('a malformed __unset__ is rejected', async () => {
@@ -639,6 +678,42 @@ suite('full_record: full replace over the operations API', { skip: skipSuite },
639678
ok(stored == null, `nothing should have been written; got ${JSON.stringify(stored)}`);
640679
});
641680

681+
// The refusal keys on whether there is a record to remove from, not on which operation asked.
682+
// Guarding the insert flag alone left this case reaching the same silent partial write one branch
683+
// over: `insertUpdateValidate` requires a primary key only for `update`, so an `upsert` without one
684+
// takes the auto-keyed `Table.create` path and stored the record with the named attributes stripped.
685+
test('__unset__ is refused on an upsert that has no primary key to look up', async () => {
686+
// A name no other probe in this suite uses, so the search below can only match the record this
687+
// request would have auto-keyed. Querying on "name plus some null columns" matched records other
688+
// tests had legitimately stripped attributes from.
689+
const r = await ops({
690+
operation: 'upsert',
691+
table: 'Dog',
692+
records: [{ name: 'UpsertNoPrimaryKey', color: 'black', __unset__: ['color'] }],
693+
});
694+
ok(r.status >= 400, `upsert with no primary key should be refused; got ${r.status} ${JSON.stringify(r.body)}`);
695+
696+
// Nothing auto-keyed should have been stored — a 200 here was the bug.
697+
const all = await ops({
698+
operation: 'sql',
699+
sql: `SELECT id FROM data.Dog WHERE name = 'UpsertNoPrimaryKey'`,
700+
});
701+
strictEqual(all.status, 200, `sql should 200; got ${JSON.stringify(all.body)}`);
702+
strictEqual((all.body ?? []).length, 0, `no auto-keyed record may exist; got ${JSON.stringify(all.body)}`);
703+
});
704+
705+
// ...and the same for an `upsert` naming a primary key that isn't stored: there is still nothing to
706+
// remove from, so it is a misunderstanding rather than a create.
707+
test('__unset__ is refused on an upsert whose primary key is not stored', async () => {
708+
const r = await ops({
709+
operation: 'upsert',
710+
table: 'Dog',
711+
records: [{ id: 'ups-absent', name: 'Penny', color: 'black', __unset__: ['color'] }],
712+
});
713+
ok(r.status >= 400, `upsert of an absent record should be refused; got ${r.status} ${JSON.stringify(r.body)}`);
714+
ok((await read('Dog', 'ups-absent')) == null, 'nothing should have been created');
715+
});
716+
642717
// `hdbTable` is `Joi.alternatives(Joi.string(), Joi.number())`, so a table named `0` validates. A
643718
// truthy `table` test dropped it from `schemaTableMap`, leaving `hasPermissions` nothing to
644719
// iterate — the same vacuous-truth bypass the database resolution fixed, one guard over.

validation/insertValidator.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { hdbTable, hdbDatabase } from './common_validators.ts';
22
import * as validator from './validationWrapper.ts';
3-
import { TIME_STAMP_NAMES, UNSET_ATTRIBUTES } from '../utility/hdbTerms.ts';
3+
import { UNSET_ATTRIBUTES } from '../utility/hdbTerms.ts';
44
import Joi from 'joi';
55
const INVALID_ATTRIBUTE_NAMES = {
66
undefined: 'undefined',
@@ -53,12 +53,12 @@ function unsetAttributesError(unset: unknown): string | undefined {
5353
if (typeof name !== 'string' || name.length === 0) {
5454
return `'${UNSET_ATTRIBUTES}' must contain only non-empty attribute names`;
5555
}
56-
// The server owns these; `checkAttributePerms` already refuses to let a role write them, and
57-
// a full-record write retains __createdtime__ rather than dropping it, so removing them here
58-
// would be the one way to lose them.
59-
if ((TIME_STAMP_NAMES as readonly string[]).includes(name)) {
60-
return `'${name}' is maintained by Harper and cannot be unset`;
61-
}
56+
// Managed timestamps are NOT refused here. This validator has no table schema, so a static
57+
// list would catch only the legacy `__createdtime__`/`__updatedtime__` and miss a
58+
// schema-declared `createdAt: Float @createdTime` — which the write path then silently
59+
// re-asserts, a 200 that changes nothing. `ResourceBridge.takeUnsetAttributes` refuses them
60+
// instead, off `Table.createdTimeProperty`/`updatedTimeProperty`, which resolve both spellings.
61+
// One check in the layer that can do it completely, rather than two that disagree.
6262
if (name === UNSET_ATTRIBUTES) {
6363
return `'${UNSET_ATTRIBUTES}' is not an attribute and cannot be unset`;
6464
}

0 commit comments

Comments
 (0)