Skip to content

Commit e9786f6

Browse files
kriszypclaude
andcommitted
fix: copy-on-mutate frozen records in the save and source-resolve write paths
With typed structures disabled (storage.randomAccessFields off), decoded records are classic-encoded and frozen by freezeData. Several write paths stamped created/updated times and the primary key onto the record in place, which throws ("Cannot assign to read only property") when the record is frozen — e.g. when a record decoded during transaction-log replay is re-saved. This surfaced broadly in integration tests under the new default-off. Records are intentionally immutable (5.2 record caching relies on it), so the fix is copy-on-mutate: shallow-copy a frozen record before stamping it — in the table save validate callback and the source/caching resolve write. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 65fad22 commit e9786f6

1 file changed

Lines changed: 23 additions & 0 deletions

File tree

resources/Table.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,19 @@ const CACHEABLE_STATUS_CODES = new Set([200, 203, 204, 206, 300, 301, 308, 404,
108108
envMngr.initSync();
109109
const LMDB_PREFETCH_WRITES = envMngr.get(CONFIG_PARAMS.STORAGE_PREFETCHWRITES);
110110
const LOCK_TIMEOUT = 10000;
111+
// A frozen record we may need to copy-on-mutate before stamping it (records are immutable — decoded
112+
// records are frozen and 5.2 record caching relies on it). Only plain/record objects qualify: never
113+
// a Buffer/typed-array (spreading would corrupt the binary into a {0:.., 1:..} object) or a primitive
114+
// (which reports as frozen and would spread into character/index keys).
115+
function isFrozenRecordObject(value: any): boolean {
116+
return (
117+
value !== null &&
118+
typeof value === 'object' &&
119+
!ArrayBuffer.isView(value) &&
120+
!(value instanceof ArrayBuffer) &&
121+
Object.isFrozen(value)
122+
);
123+
}
111124
export const INVALIDATED = 1;
112125
export const EVICTED = 8; // note that 2 is reserved for timestamps
113126
const TEST_WRITE_KEY_BUFFER = Buffer.allocUnsafeSlow(8192);
@@ -1666,6 +1679,12 @@ export function makeTable(options) {
16661679
if (fullUpdate || (recordUpdate && hasChanges(this.#changes === recordUpdate ? this : recordUpdate))) {
16671680
if (!(context as any)?.source) {
16681681
transaction.checkOverloaded();
1682+
// Records are intentionally immutable: decoded records are frozen (and 5.2 record
1683+
// caching relies on it), so mutating in place would corrupt cached/shared state.
1684+
// validate() coerces values and we stamp created/updated times + the primary key
1685+
// below, so copy-on-mutate when recordUpdate is frozen (e.g. a record decoded during
1686+
// log replay) instead of writing through the frozen object.
1687+
if (isFrozenRecordObject(recordUpdate)) recordUpdate = { ...recordUpdate };
16691688
this.validate(recordUpdate, !fullUpdate);
16701689
if (updatedTimeProperty) {
16711690
recordUpdate[updatedTimeProperty.name] =
@@ -4382,6 +4401,10 @@ export function makeTable(options) {
43824401
}
43834402
}
43844403
if (typeof updatedRecord.toJSON === 'function') updatedRecord = updatedRecord.toJSON();
4404+
// updatedRecord may still be a frozen record (e.g. a reused existingRecord); copy-on-mutate
4405+
// before stamping the primary key and created/updated times below (records are immutable —
4406+
// 5.2 record caching relies on it — so we must not write through the frozen object).
4407+
if (isFrozenRecordObject(updatedRecord)) updatedRecord = { ...updatedRecord };
43854408
if (primaryKey && updatedRecord[primaryKey] !== id) updatedRecord[primaryKey] = id;
43864409
}
43874410
resolved = true;

0 commit comments

Comments
 (0)