Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/reconcile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { KeyfoldMergeError } from "./errors.js";
import { isPlainObject } from "./objects.js";
import { enumerableOwnKeys, isPlainObject, UNSAFE_KEYS } from "./objects.js";
import type { PolicyNode } from "./paths.js";
import { DELETE_TOKEN } from "./sentinels.js";

Expand Down Expand Up @@ -143,8 +143,12 @@ function isTombstone(item: Record<string, unknown>, identityField: string, path:
);
}

for (const key of Object.keys(item)) {
// Purity is judged by the same key set the fold would merge: symbol keys
// are patch data, while unsafe keys are ignored everywhere and so cannot
// taint a tombstone.
for (const key of enumerableOwnKeys(item)) {
if (key === identityField || key === "$delete") continue;
if (typeof key === "string" && UNSAFE_KEYS.has(key)) continue;
throw new KeyfoldMergeError(
`tombstone at '${path}' must contain only '${identityField}' and '$delete'`,
);
Expand Down
11 changes: 11 additions & 0 deletions test/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ describe("ambiguity and failure safety", () => {
],
["mixed tombstone", [{ id: "a", $delete: true, quantity: 2 }]],
["mixed tombstone with undefined field", [{ id: "a", $delete: true, quantity: undefined }]],
["mixed tombstone with symbol-keyed field", [{ id: "a", $delete: true, [Symbol("patch")]: 2 }]],
["non-true tombstone", [{ id: "a", $delete: false }]],
])("throws for %s without mutating base", (_case, items) => {
const base = state();
Expand Down Expand Up @@ -271,6 +272,16 @@ describe("ambiguity and failure safety", () => {
expect(mergeUnknown(base, delta)).toEqual({ items: [{ id: "a", value: 1 }] });
});

test("ignores unsafe keys on a tombstone like on any other delta object", () => {
const base = state();
const wireTombstone: unknown = JSON.parse('{"id":"a","$delete":true,"__proto__":{"x":1}}');
const next = merge(base, {
items: [wireTombstone, { id: "b", $delete: true, constructor: { x: 1 } }],
} as unknown as Delta<State>);

expect(next.items).toEqual([]);
});

test("rejects the reserved tombstone field in base data", () => {
const base = state();
(base.items[0] as unknown as Record<string, unknown>).$delete = false;
Expand Down