Skip to content

Commit 1ac8d30

Browse files
dawsontothclaude
andcommitted
feat(operations): replace full_record and __unset__ with a put operation
Scope reduction, per kriszyp's review on #2347: one operation instead of a flag plus a per-record directive. { "operation": "put", "database": "dev", "table": "dog", "records": [{ "id": 1, "dog_name": "Penny" }] } The stored record becomes exactly that, so an attribute the request omits is removed. Creates a missing record and replaces an existing one — the same `Table.put` REST's `PUT /Table/id` performs, so the same audit type, replication shape, and retained `__createdtime__`. `update`/`upsert` keep merging, which is the v4-compatible behaviour existing clients depend on and the reason this is a separate operation rather than semantics changed in place. This covers what studio#1643 actually needs: its row editor holds the whole record already. Removed with the flag and the directive: - `full_record` as a client-facing field on update/upsert - `__unset__`, its reserved key, its validation, the attribute-collector divergence in both `insertUpdateValidate.js` and `insert.ts`, and the `getRecordAttributes` name expansion - `takeUnsetAttributes` and its latch Three problems retire with them, all raised in review and none now reachable: `__unset__` degrading a merge to a whole-record put under contention (kept as a feature request, see below), the reserved key reinterpreting a pre-existing `__unset__` attribute with no migration, and the directive's interaction with dynamic attribute expansion. The internal marker is an ARGUMENT, not a field: `putRecords` calls `upsertRecords(obj, true)`. Reading it off the request — which the first draft of this commit did — let a client send `full_record: true` with an `update` and get a replace, which makes the operation name stop describing the write and bypasses the attribute-scoped denial, since that is keyed on the operation. A test pins it; restoring the request read fails that test and nothing else. Kept from the previous rounds, unchanged in substance: - The attribute-scoped denial, now keyed on `op === write.put.name`. A replace removes what the request omits, and `checkAttributePerms` only sees what it supplies. - The target-database resolution fix and its fail-closed backstop, including the numeric `table: 0` guard. - The bulk-load opt-out keyed on the operation rather than a caller-supplied `action`. The `__unset__` half of that fix goes away with the directive; the pre-existing half — a direct request skipping every attribute check by attaching `action` — stays fixed and stays tested. Wired as a first-class operation: `OPERATIONS_ENUM.PUT`, the dispatch map, the replication catchup switch, `requiredPermissions` with `[insert, update]` (what `upsert` and REST `PUT` both require), the grantable `data` operation group, and the `new_attributes`/`txn_time` result cleanup. Returns `put_hashes`; a put never skips. Tests: `integrationTests/database/put-operation.test.ts`, renamed from full-record-write — 18 probes, all passing. Every guard mutation-checked: making put ignore the marker fails 5, reading the marker off the request fails 1, dropping the attribute-scoped denial fails 1. 143 tests across the security, terminology, auto-fields and patch-merge suites pass, plus 575 in northwind, which exercises the CSV and bulk-load paths. `__unset__` is not abandoned — #2350 is being reworked from "this is broken" into "add it properly", with concurrency-safe removal at the patch layer rather than a read-then-replace in the bridge. Refs: HarperFast/studio#1643 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 341be57 commit 1ac8d30

14 files changed

Lines changed: 669 additions & 1224 deletions

File tree

dataLayer/harperBridge/ResourceBridge.ts

Lines changed: 30 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
VALUE_SEARCH_COMPARATORS,
99
VALUE_SEARCH_COMPARATORS_REVERSE_LOOKUP,
1010
READ_AUDIT_LOG_SEARCH_TYPES_ENUM,
11-
UNSET_ATTRIBUTES as OPERATIONS_UNSET_KEY,
1211
} from '../../utility/hdbTerms.ts';
1312
import * as signalling from '../../utility/signalling.ts';
1413
import { SchemaEventMsg } from '../../server/threads/itc.js';
@@ -24,7 +23,7 @@ import type {
2423
Operator,
2524
} from '../../resources/ResourceInterface.ts';
2625
import { collapseData } from '../../resources/tracked.ts';
27-
import logger, { errorToString } from '../../utility/logging/harper_logger.ts';
26+
import { errorToString } from '../../utility/logging/harper_logger.ts';
2827
import { RocksDatabase } from '@harperfast/rocksdb-js';
2928
import { BridgeMethods } from './BridgeMethods.ts';
3029
import lmdbGetBackup from './lmdbBridge/lmdbMethods/lmdbGetBackup.js';
@@ -36,14 +35,6 @@ const { HDB_ERROR_MSGS } = hdbErrors;
3635
const DEFAULT_DATABASE = 'data';
3736
const DELETE_CHUNK = 10000;
3837
const DELETE_PAUSE_MS = 10;
39-
/** Shared result for the overwhelmingly common case of a record carrying no `__unset__`, so the
40-
* default write path allocates nothing per record. Frozen because it is handed out repeatedly. */
41-
const NO_UNSET_ATTRIBUTES = Object.freeze([]) as unknown as string[];
42-
/** Tables that have already logged a malformed-directive warning. Latched per table, not per
43-
* process: `warnedNullSourcePut` (resources/Table.ts, declared inside `makeTable`) is one warn per
44-
* table per worker, and a single module-level flag would let a bad batch on one table silence the
45-
* warning for every other table until restart. Weak so it never keeps a dropped table alive. */
46-
const warnedMalformedUnset = new WeakSet<object>();
4738

4839
export type SearchByConditionsRequest = Query &
4940
Context & {
@@ -208,27 +199,35 @@ export class ResourceBridge extends BridgeMethods {
208199
return this.upsertRecords(updateObj);
209200
}
210201

211-
async upsertRecords(upsertObj) {
202+
/**
203+
* Create-or-replace: the stored record becomes exactly the submitted one, so an attribute the
204+
* request omits is REMOVED rather than kept. `update`/`upsert` merge (`Table.patch`), which is the
205+
* v4-compatible behaviour every existing client depends on and is why this is a separate operation
206+
* rather than a flag on those. Equivalent to REST `PUT /Table/id` — same `Table.put`, so the same
207+
* audit type, replication shape, and retained `__createdtime__`.
208+
*/
209+
async putRecords(putObj) {
210+
return this.upsertRecords(putObj, true);
211+
}
212+
213+
async upsertRecords(upsertObj, fullRecord = false) {
212214
const { attributes } = insertUpdateValidate(upsertObj);
213215

214216
let new_attributes;
215-
// `full_record: true` makes this a full replace instead of a merge: an attribute absent from
216-
// the submitted record is REMOVED rather than left at its stored value. Without it, a write
217-
// over an existing record lands on `Table.patch`, so there is no way to drop an attribute
218-
// through the operations API — omitting it keeps the stored value and `null` stores a null
219-
// (HarperFast/studio#1643).
217+
// `fullRecord` arrives as an ARGUMENT, never as a field on the request. Reading it off
218+
// `upsertObj` would let a client send `full_record: true` with an `update` and get a replace —
219+
// which makes the operation name stop describing the write, and bypasses the attribute-scoped
220+
// `put` denial in `verifyPerms`, since that is keyed on the operation.
220221
//
221-
// The flag is orthogonal to each operation's create rule, which is what makes one flag cover
222-
// both useful shapes: `update` still requires an existing record (a missing one is skipped),
223-
// while `upsert` still creates one — so `upsert` + `full_record` is exactly REST's
224-
// `PUT /Table/id`, and `update` + `full_record` is that same replace, refused if the record
225-
// isn't there. It has no effect on `insert`, which never writes over an existing record.
222+
// Without it a write over an existing record merges (`Table.patch`), so an attribute the caller
223+
// omitted keeps its stored value and `null` stores a null. That merge is the v4-compatible
224+
// behaviour `update`/`upsert` must keep; HarperFast/studio#1643 is the removal case it cannot
225+
// serve, and `put` is the operation that can.
226226
//
227-
// Compared with the delete-then-insert this replaces, a full replace is one operation rather
228-
// than two: the record is never absent between them, subscribers see a single write instead of
229-
// a delete followed by an insert, and `__createdtime__` survives (`Table._writeUpdate` retains
230-
// the stored created time on a full update and only stamps a new one for a new entry).
231-
const fullRecord = upsertObj.full_record === true;
227+
// A put is also one operation where a client emulating removal needs two: the record is never
228+
// absent between them, subscribers see a single write rather than a delete followed by an
229+
// insert, and `__createdtime__` survives (`Table._writeUpdate` retains the stored created time
230+
// on a full update and only stamps a new one for a new entry).
232231
const Table = getDatabases()[upsertObj.schema][upsertObj.table];
233232
const context: Context = {
234233
user: upsertObj.hdb_user,
@@ -290,33 +289,12 @@ export class ResourceBridge extends BridgeMethods {
290289
// write one branch over: `insertUpdateValidate` requires a hash attribute only for
291290
// `update`, so such a record takes the `id == undefined → Table.create` path and stored an
292291
// auto-keyed record with the named attributes stripped, returning 200.
293-
const unset = takeUnsetAttributes(record, Table, !existingRecord);
294-
// `__unset__` removes named attributes while everything else still merges, which a patch
295-
// cannot express — so it resolves the merge here, against the record this transaction
296-
// already loaded, and writes the result as a full replace. Doing it server-side inside the
297-
// write transaction is the point: a client emulating this with read-then-replace races
298-
// every other writer, and `full_record` would make the caller resend attributes it never
299-
// meant to touch. Under `full_record` the submitted record is already the whole intended
300-
// state, so there is nothing to merge and the names are simply dropped from it.
301-
//
302-
// SINGLE-WRITER ONLY, and the surrounding wording should not be read as more than that.
303-
// Resolving the merge here still writes a `put`, and a full put is last-writer-wins over
304-
// the whole record: out-of-order reconciliation folds field-wise only for patches
305-
// (`resources/Table.ts:2902-2914`) and the commit-retry path re-reads the stored record
306-
// only when `!fullUpdate` (`resources/Table.ts:2530`). So a concurrent write to an
307-
// attribute this request never named can be lost — the opposite of what "everything else
308-
// merges" implies. Tracked in HarperFast/harper#2350; fixing it properly means resolving
309-
// removals at the patch layer instead.
310-
const toWrite = unset.length && existingRecord && !fullRecord ? { ...existingRecord, ...record } : record;
311-
for (const attribute of unset) {
312-
delete toWrite[attribute];
313-
}
314292
await (id == undefined
315-
? Table.create(toWrite, context)
316-
: existingRecord && !fullRecord && !unset.length
317-
? Table.patch(toWrite, context)
318-
: Table.put(toWrite, context));
319-
keys.push(toWrite[Table.primaryKey]);
293+
? Table.create(record, context)
294+
: existingRecord && !fullRecord
295+
? Table.patch(record, context)
296+
: Table.put(record, context));
297+
keys.push(record[Table.primaryKey]);
320298
}
321299
return {
322300
txn_time: (transaction as any).timestamp,
@@ -823,60 +801,3 @@ async function* groupRecordsInHistory(table, start?, end?, limit?) {
823801
}
824802
if (enqueued) yield enqueued;
825803
}
826-
827-
/**
828-
* Read and remove a record's `__unset__` list, returning the attribute names to drop.
829-
*
830-
* The key is removed because it is a directive, not data — left in place it would be stored as an
831-
* attribute. The refusals below live here rather than in the validator because only this layer knows
832-
* the table: which attribute is the primary key, and what the managed timestamps are called. Each is
833-
* refused rather than skipped, because `_writeUpdate` re-asserts all three on a full update, so
834-
* accepting them would return 200 and change nothing.
835-
*/
836-
function takeUnsetAttributes(record: Record<string, unknown>, Table: any, hasNoExistingRecord: boolean): string[] {
837-
// Own property, not `in`: an inherited `__unset__` (a record built on a prototype carrying one, or
838-
// after prototype pollution) would enter this path with no directive of its own, and `delete`
839-
// cannot remove an inherited value — so the caller would go on to delete the attributes it names.
840-
// Still a presence check rather than a value check, so an explicitly-undefined own key comes off.
841-
if (!Object.prototype.hasOwnProperty.call(record, OPERATIONS_UNSET_KEY)) return NO_UNSET_ATTRIBUTES;
842-
const unset = record[OPERATIONS_UNSET_KEY];
843-
// Removed before the shape is judged, and on every exit below.
844-
delete record[OPERATIONS_UNSET_KEY];
845-
if (hasNoExistingRecord) {
846-
throw new ClientError(
847-
`'${OPERATIONS_UNSET_KEY}' needs an existing record to remove attributes from; nothing is stored under this primary key`
848-
);
849-
}
850-
// Ignored with a warning rather than thrown: this is the write and replication apply path, where
851-
// throwing aborts the commit and can wedge a subscription, and skipping the directive degrades to
852-
// a plain merge, which removes nothing. `resources/tracked.ts` makes the same choice for an
853-
// unrecognized CRDT operation. Checked at all because the shape is validated one layer up, in a
854-
// module this one does not own: a non-iterable would throw in the caller's `for…of` and take the
855-
// write down, and a bare string would iterate per character, deleting single-letter attributes.
856-
if (!Array.isArray(unset) || unset.some((name) => typeof name !== 'string' || name.length === 0)) {
857-
// Latched per table, following `warnedNullSourcePut` in resources/Table.ts: one line per record
858-
// would bury the log under a single bad batch.
859-
if (!warnedMalformedUnset.has(Table)) {
860-
warnedMalformedUnset.add(Table);
861-
logger.warn(
862-
`Ignoring a malformed '${OPERATIONS_UNSET_KEY}' on a record in ${Table.databaseName}.${Table.tableName}; expected an array of attribute names`
863-
);
864-
}
865-
return NO_UNSET_ATTRIBUTES;
866-
}
867-
const names = unset as string[];
868-
const primaryKey = Table.primaryKey;
869-
if (primaryKey && names.includes(primaryKey)) {
870-
throw new ClientError(`'${primaryKey}' is the primary key of this table and cannot be unset`);
871-
}
872-
// `createdTimeProperty`/`updatedTimeProperty` resolve `assignCreatedTime` OR the legacy
873-
// `__createdtime__` spelling (resources/Table.ts), so this covers a schema-declared
874-
// `createdAt: Float @createdTime` as well as the legacy names — which a static list in the
875-
// validator, with no schema in scope, could not.
876-
for (const managed of [Table.createdTimeProperty, Table.updatedTimeProperty]) {
877-
if (managed?.name && names.includes(managed.name)) {
878-
throw new ClientError(`'${managed.name}' is maintained by Harper and cannot be unset`);
879-
}
880-
}
881-
return names;
882-
}

dataLayer/harperBridge/bridgeUtility/insertUpdateValidate.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ const hdbUtils = require('../../../utility/common_utils.ts');
44
const log = require('../../../utility/logging/harper_logger.ts');
55
const { getDatabases } = require('../../../resources/databases.ts');
66
const { ClientError } = require('../../../utility/errors/hdbError.ts');
7-
const { UNSET_ATTRIBUTES } = require('../../../utility/hdbTerms.ts');
87

98
module.exports = insertUpdateValidate;
109

@@ -74,15 +73,6 @@ function insertUpdateValidate(writeObject) {
7473
dups.add(hdbUtils.autoCast(record[hash_attribute]));
7574

7675
for (let attr in record) {
77-
// `__unset__` names attributes to remove; it is not one itself. Collecting it would make an
78-
// open (non-schemaDefined) table register `__unset__` as a real attribute, since
79-
// upsertRecords feeds this list straight to Table.addAttributes.
80-
//
81-
// Deliberately does NOT add the names it removes, unlike the async twin's list in
82-
// dataLayer/insert.ts: that one is the bulk-load attribute-permission input, where a removal
83-
// must be authorized, while this one creates table attributes — registering an attribute in
84-
// order to delete it would be backwards. Keep both comments in step.
85-
if (attr === UNSET_ATTRIBUTES) continue;
8676
attributes[attr] = 1;
8777
}
8878
});

dataLayer/insert.ts

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ import * as globalSchema from '../utility/globalSchema.ts';
1515
import log from '../utility/logging/harper_logger.ts';
1616
import { handleHDBError } from '../utility/errors/hdbError.ts';
1717
import { HTTP_STATUS_CODES } from '../utility/errors/commonErrors.ts';
18-
import * as terms from '../utility/hdbTerms.ts';
1918

2019
const pGlobalSchema = util.promisify(globalSchema.getTableSchema);
2120

2221
const UPDATE_ACTION = 'updated';
2322
const INSERT_ACTION = 'inserted';
2423
const UPSERT_ACTION = 'upserted';
24+
const PUT_ACTION = 'put';
2525

2626
//IMPORTANT - This validation function is the async version of the code in harperBridge/bridgeUtility/insertUpdateValidate.js
2727
// make sure any changes below are also made there. This is to resolve a circular dependency.
@@ -90,32 +90,6 @@ export async function validation(writeObject: any) {
9090
dups.add(hdbUtils.autoCast(record[hash_attribute]));
9191

9292
for (let attr in record) {
93-
// `__unset__` names attributes to remove and is not one itself, so the key never counts as
94-
// an attribute — but the names it removes DO. This list is the bulk-load path's
95-
// attribute-permission input: `bulkLoad.validateChunk` hands it to
96-
// `verifyBulkLoadAttributePerms`, so a name missing from it is a removal nobody authorized.
97-
// The direct operations path gets the same treatment from `getRecordAttributes`
98-
// (utility/operation_authorization.ts); without it here, `import_from_s3` with
99-
// `action: "update"` could unset an attribute the role has no permission to write while the
100-
// equivalent `update` returns 403.
101-
//
102-
// Contributed for every action, not just update/upsert: requiring the permission is the safe
103-
// direction, and `__unset__` is refused outright on an insert anyway (see
104-
// ResourceBridge.takeUnsetAttributes).
105-
//
106-
// The sync twin in harperBridge/bridgeUtility/insertUpdateValidate.js feeds
107-
// `Table.addAttributes` instead, so it must skip the key WITHOUT adding the removed names —
108-
// registering an attribute in order to delete it would be backwards. The two lists are
109-
// deliberately not identical; keep both comments in step.
110-
if (attr === terms.UNSET_ATTRIBUTES) {
111-
const unset = record[attr];
112-
if (Array.isArray(unset)) {
113-
for (const name of unset) {
114-
if (typeof name === 'string' && name.length > 0) attributes[name] = 1;
115-
}
116-
}
117-
continue;
118-
}
11993
attributes[attr] = 1;
12094
}
12195
});
@@ -245,6 +219,42 @@ async function upsertData(upsertObject: any) {
245219
);
246220
}
247221

222+
/**
223+
* Create-or-replace the records in the putObject parameter: the stored record becomes exactly the
224+
* submitted one, so an attribute the request omits is removed. `update`/`upsert` merge instead, and
225+
* must keep doing so for v4 compatibility — hence a distinct operation rather than a flag on those.
226+
* Equivalent to REST `PUT /Table/id`.
227+
* @param putObject - Represents the data that will be written
228+
*/
229+
async function putData(putObject: any) {
230+
if (putObject.operation !== 'put') {
231+
throw handleHDBError(new Error(), 'invalid operation, must be put', HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR);
232+
}
233+
234+
let validator = insertValidator(putObject);
235+
if (validator) {
236+
throw handleHDBError(new Error(), validator.message, HTTP_STATUS_CODES.BAD_REQUEST);
237+
}
238+
239+
hdbUtils.transformReq(putObject);
240+
241+
let invalidSchemaTableMsg = hdbUtils.checkSchemaTableExist(putObject.schema, putObject.table);
242+
if (invalidSchemaTableMsg) {
243+
throw handleHDBError(new Error(), invalidSchemaTableMsg, HTTP_STATUS_CODES.BAD_REQUEST);
244+
}
245+
246+
let bridgePutResult = await harperBridge.putRecords(putObject);
247+
248+
return returnObject(
249+
PUT_ACTION,
250+
bridgePutResult.written_hashes,
251+
putObject,
252+
[],
253+
bridgePutResult.new_attributes,
254+
bridgePutResult.txn_time
255+
);
256+
}
257+
248258
/**
249259
* Constructs return object for insert, update, and upsert.
250260
* @param action
@@ -281,6 +291,12 @@ function returnObject(
281291
return return_object;
282292
}
283293

294+
if (action === PUT_ACTION) {
295+
// `put` never skips: every submitted record is written, created or replaced.
296+
return_object.put_hashes = written_hashes;
297+
return return_object;
298+
}
299+
284300
return_object.update_hashes = written_hashes;
285301
return_object.skipped_hashes = skipped;
286302
return return_object;
@@ -290,4 +306,4 @@ export function flush(object: any) {
290306
hdbUtils.transformReq(object);
291307
return harperBridge.flush(object.schema, object.table);
292308
}
293-
export { insertData as insert, updateData as update, upsertData as upsert };
309+
export { insertData as insert, updateData as update, upsertData as upsert, putData as put };

0 commit comments

Comments
 (0)