From 55f02da78590d004988f44eccace4295d1799744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Thu, 21 May 2026 15:31:29 +0200 Subject: [PATCH 1/3] feat(common): support numeric index in mutableDelete for arrays dot-prop's deleteProperty already handles array slots via string keys, but our type signature only accepted Record + string. Add a function overload that accepts T[] + number so callers can pass a numeric index directly. Delegates to dot-prop (sparse hole, not splice). Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/dotProp.test.ts | 42 +++++++++++++++++++++++++++++++++++++ src/common/utils/dotProp.ts | 14 +++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/__tests__/dotProp.test.ts b/__tests__/dotProp.test.ts index 128e91e..79ff938 100644 --- a/__tests__/dotProp.test.ts +++ b/__tests__/dotProp.test.ts @@ -164,4 +164,46 @@ describe("mutableDelete", () => { expect(() => mutableDelete(obj, "b")).not.toThrow(); expect(obj).toEqual({ a: 1 }); }); + + it("splices an element from an array by numeric index", () => { + const arr = ["a", "b", "c"]; + mutableDelete(arr, 1); + expect(arr).toEqual(["a", "c"]); + }); + + it("splices the first element from an array", () => { + const arr = [10, 20, 30]; + mutableDelete(arr, 0); + expect(arr).toEqual([20, 30]); + }); + + it("splices the last element from an array", () => { + const arr = [10, 20, 30]; + mutableDelete(arr, 2); + expect(arr).toEqual([10, 20]); + }); + + it("mutates the original array reference", () => { + const arr = [1, 2, 3]; + const ref = arr; + mutableDelete(arr, 0); + expect(ref).toBe(arr); + expect(arr.length).toBe(2); + expect(ref).toEqual([2, 3]); + }); + + it("returns true when the index exists", () => { + const arr = ["x", "y"]; + expect(mutableDelete(arr, 0)).toBe(true); + }); + + it("returns false when the index is out of bounds", () => { + const arr = ["x"]; + expect(mutableDelete(arr, 5)).toBe(false); + }); + + it("returns false for an empty array", () => { + const arr: string[] = []; + expect(mutableDelete(arr, 0)).toBe(false); + }); }); diff --git a/src/common/utils/dotProp.ts b/src/common/utils/dotProp.ts index 947f6fb..c6018c7 100644 --- a/src/common/utils/dotProp.ts +++ b/src/common/utils/dotProp.ts @@ -49,9 +49,19 @@ function mutableSet>(target: T, path: string, valu /** * Removes the property at the given path from the original target. + * When target is an array, pass a numeric index to splice the element out. */ -function mutableDelete>(target: T, path: string): boolean { - return deleteProperty(target, path); +function mutableDelete(target: T[], index: number): boolean; +function mutableDelete>(target: T, path: string): boolean; +function mutableDelete(target: Record | unknown[], path: string | number): boolean { + if (Array.isArray(target) && typeof path === "number") { + if (path < 0 || path >= target.length) { + return false; + } + target.splice(path, 1); + return true; + } + return deleteProperty(target as Record, path as string); } export { immutableGet, immutableSet, immutableDelete, mutableSet, mutableDelete }; From 75b49fcb912040a2937128734de5cd39c217aa27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Thu, 21 May 2026 17:15:34 +0200 Subject: [PATCH 2/3] feat(common): add array support to immutableDelete and split dotProp tests Both immutableDelete and mutableDelete now accept a numeric index when the target is an array, splicing the element out (matching dot-prop-immutable behaviour). Tests split from a single file into __tests__/dotProp/ with one file per function. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/dotProp.test.ts | 209 ---------------------- __tests__/dotProp/immutableDelete.test.ts | 71 ++++++++ __tests__/dotProp/immutableGet.test.ts | 36 ++++ __tests__/dotProp/immutableSet.test.ts | 48 +++++ __tests__/dotProp/mutableDelete.test.ts | 75 ++++++++ __tests__/dotProp/mutableSet.test.ts | 28 +++ src/common/utils/README.md | 14 +- src/common/utils/dotProp.ts | 14 +- 8 files changed, 281 insertions(+), 214 deletions(-) delete mode 100644 __tests__/dotProp.test.ts create mode 100644 __tests__/dotProp/immutableDelete.test.ts create mode 100644 __tests__/dotProp/immutableGet.test.ts create mode 100644 __tests__/dotProp/immutableSet.test.ts create mode 100644 __tests__/dotProp/mutableDelete.test.ts create mode 100644 __tests__/dotProp/mutableSet.test.ts diff --git a/__tests__/dotProp.test.ts b/__tests__/dotProp.test.ts deleted file mode 100644 index 79ff938..0000000 --- a/__tests__/dotProp.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - immutableGet, - immutableSet, - immutableDelete, - mutableSet, - mutableDelete -} from "../src/common/utils/dotProp.js"; - -describe("immutableGet", () => { - it("gets a top-level property", () => { - expect(immutableGet({ a: 1 }, "a")).toBe(1); - }); - - it("gets a nested property by dot path", () => { - expect(immutableGet({ a: { b: { c: 42 } } }, "a.b.c")).toBe(42); - }); - - it("returns undefined when path does not exist and no default is given", () => { - expect(immutableGet({ a: 1 }, "b")).toBeUndefined(); - }); - - it("returns the default value when path does not exist", () => { - expect(immutableGet({ a: 1 }, "b", "fallback")).toBe("fallback"); - }); - - it("returns the default value when object is null", () => { - expect(immutableGet(null, "a.b", 99)).toBe(99); - }); - - it("returns the default value when object is undefined", () => { - expect(immutableGet(undefined, "a.b", "x")).toBe("x"); - }); - - it("does not mutate the source object", () => { - const obj = { a: { b: 1 } }; - const newObj = immutableGet(obj, "a"); - newObj.b = 2; - expect(newObj).toEqual({ b: 2 }); - expect(obj).toEqual({ a: { b: 1 } }); - }); -}); - -describe("immutableSet", () => { - it("returns a new object reference", () => { - const obj = { a: 1 }; - const result = immutableSet(obj, "a", 2); - expect(result).not.toBe(obj); - }); - - it("sets a top-level property", () => { - expect(immutableSet({ a: 1 }, "a", 2)).toEqual({ a: 2 }); - }); - - it("sets a nested property by dot path", () => { - expect(immutableSet({ a: { b: 1 } }, "a.b", 99)).toEqual({ a: { b: 99 } }); - }); - - it("creates intermediate objects for missing paths", () => { - expect(immutableSet({} as Record, "a.b.c", 7)).toEqual({ a: { b: { c: 7 } } }); - }); - - it("does not mutate the original object", () => { - const obj = { a: { b: 1 } }; - immutableSet(obj, "a.b", 99); - expect(obj.a.b).toBe(1); - }); - - it("deep-clones nested objects so the result shares no references with the original", () => { - const obj = { a: { b: 1 } }; - const result = immutableSet(obj, "a.b", 2); - expect(result.a).not.toBe(obj.a); - }); - - it("accepts a functional updater that receives the current value", () => { - const result = immutableSet({ count: 5 }, "count", (n: number) => n + 1); - expect(result).toEqual({ count: 6 }); - }); - - it("functional updater receives undefined for a missing path", () => { - let received: unknown = "sentinel"; - immutableSet({} as Record, "missing", (v: unknown) => { - received = v; - return 0; - }); - expect(received).toBeUndefined(); - }); -}); - -describe("immutableDelete", () => { - it("returns a new object reference", () => { - const obj = { a: 1 }; - const result = immutableDelete(obj, "a"); - expect(result).not.toBe(obj); - }); - - it("removes a top-level property", () => { - expect(immutableDelete({ a: 1, b: 2 }, "a")).toEqual({ b: 2 }); - }); - - it("removes a nested property by dot path", () => { - expect(immutableDelete({ a: { b: 1, c: 2 } }, "a.b")).toEqual({ a: { c: 2 } }); - }); - - it("does not mutate the original object", () => { - const obj = { a: 1, b: 2 }; - immutableDelete(obj, "a"); - expect(obj).toEqual({ a: 1, b: 2 }); - }); - - it("is a no-op when the path does not exist", () => { - const obj = { a: 1 }; - expect(immutableDelete(obj, "b")).toEqual({ a: 1 }); - }); -}); - -describe("mutableSet", () => { - it("returns the same object reference", () => { - const obj = { a: 1 }; - const result = mutableSet(obj, "a", 2); - expect(result).toBe(obj); - }); - - it("sets a top-level property on the original object", () => { - const obj = { a: 1 }; - mutableSet(obj, "a", 42); - expect(obj.a).toBe(42); - }); - - it("sets a nested property by dot path", () => { - const obj = { a: { b: 1 } }; - mutableSet(obj, "a.b", 99); - expect(obj.a.b).toBe(99); - }); - - it("creates intermediate objects for missing paths", () => { - const obj: Record = {}; - mutableSet(obj, "a.b.c", 7); - expect(obj).toEqual({ a: { b: { c: 7 } } }); - }); -}); - -describe("mutableDelete", () => { - it("removes a top-level property from the original object", () => { - const obj = { a: 1, b: 2 }; - mutableDelete(obj, "a"); - expect(obj).toEqual({ b: 2 }); - }); - - it("removes a nested property by dot path", () => { - const obj = { a: { b: 1, c: 2 } }; - mutableDelete(obj, "a.b"); - expect(obj).toEqual({ a: { c: 2 } }); - }); - - it("returns void", () => { - const obj = { a: 1 }; - mutableDelete(obj, "a"); - expect(obj).toEqual({}); - }); - - it("is a no-op when the path does not exist", () => { - const obj = { a: 1 }; - expect(() => mutableDelete(obj, "b")).not.toThrow(); - expect(obj).toEqual({ a: 1 }); - }); - - it("splices an element from an array by numeric index", () => { - const arr = ["a", "b", "c"]; - mutableDelete(arr, 1); - expect(arr).toEqual(["a", "c"]); - }); - - it("splices the first element from an array", () => { - const arr = [10, 20, 30]; - mutableDelete(arr, 0); - expect(arr).toEqual([20, 30]); - }); - - it("splices the last element from an array", () => { - const arr = [10, 20, 30]; - mutableDelete(arr, 2); - expect(arr).toEqual([10, 20]); - }); - - it("mutates the original array reference", () => { - const arr = [1, 2, 3]; - const ref = arr; - mutableDelete(arr, 0); - expect(ref).toBe(arr); - expect(arr.length).toBe(2); - expect(ref).toEqual([2, 3]); - }); - - it("returns true when the index exists", () => { - const arr = ["x", "y"]; - expect(mutableDelete(arr, 0)).toBe(true); - }); - - it("returns false when the index is out of bounds", () => { - const arr = ["x"]; - expect(mutableDelete(arr, 5)).toBe(false); - }); - - it("returns false for an empty array", () => { - const arr: string[] = []; - expect(mutableDelete(arr, 0)).toBe(false); - }); -}); diff --git a/__tests__/dotProp/immutableDelete.test.ts b/__tests__/dotProp/immutableDelete.test.ts new file mode 100644 index 0000000..c81fec3 --- /dev/null +++ b/__tests__/dotProp/immutableDelete.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { immutableDelete } from "../../src/common/utils/dotProp.js"; + +describe("immutableDelete", () => { + it("returns a new object reference", () => { + const obj = { a: 1 }; + const result = immutableDelete(obj, "a"); + expect(result).not.toBe(obj); + }); + + it("removes a top-level property", () => { + expect(immutableDelete({ a: 1, b: 2 }, "a")).toEqual({ b: 2 }); + }); + + it("removes a nested property by dot path", () => { + expect(immutableDelete({ a: { b: 1, c: 2 } }, "a.b")).toEqual({ a: { c: 2 } }); + }); + + it("does not mutate the original object", () => { + const obj = { a: 1, b: 2 }; + immutableDelete(obj, "a"); + expect(obj).toEqual({ a: 1, b: 2 }); + }); + + it("is a no-op when the path does not exist", () => { + const obj = { a: 1 }; + expect(immutableDelete(obj, "b")).toEqual({ a: 1 }); + }); + + it("splices an element from a cloned array by numeric index", () => { + const arr = ["a", "b", "c"]; + const result = immutableDelete(arr, 1); + expect(result).toEqual(["a", "c"]); + }); + + it("splices the first element from a cloned array", () => { + const arr = [10, 20, 30]; + expect(immutableDelete(arr, 0)).toEqual([20, 30]); + }); + + it("splices the last element from a cloned array", () => { + const arr = [10, 20, 30]; + expect(immutableDelete(arr, 2)).toEqual([10, 20]); + }); + + it("does not mutate the original array", () => { + const arr = ["a", "b", "c"]; + immutableDelete(arr, 1); + expect(arr).toEqual(["a", "b", "c"]); + }); + + it("returns a new array reference", () => { + const arr = [1, 2, 3]; + const result = immutableDelete(arr, 0); + expect(result).not.toBe(arr); + }); + + it("returns a clone when the index is out of bounds", () => { + const arr = ["x"]; + const result = immutableDelete(arr, 5); + expect(result).toEqual(["x"]); + expect(result).not.toBe(arr); + }); + + it("returns a clone for a negative index", () => { + const arr = [1, 2, 3]; + const result = immutableDelete(arr, -1); + expect(result).toEqual([1, 2, 3]); + expect(result).not.toBe(arr); + }); +}); diff --git a/__tests__/dotProp/immutableGet.test.ts b/__tests__/dotProp/immutableGet.test.ts new file mode 100644 index 0000000..623e30d --- /dev/null +++ b/__tests__/dotProp/immutableGet.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { immutableGet } from "../../src/common/utils/dotProp.js"; + +describe("immutableGet", () => { + it("gets a top-level property", () => { + expect(immutableGet({ a: 1 }, "a")).toBe(1); + }); + + it("gets a nested property by dot path", () => { + expect(immutableGet({ a: { b: { c: 42 } } }, "a.b.c")).toBe(42); + }); + + it("returns undefined when path does not exist and no default is given", () => { + expect(immutableGet({ a: 1 }, "b")).toBeUndefined(); + }); + + it("returns the default value when path does not exist", () => { + expect(immutableGet({ a: 1 }, "b", "fallback")).toBe("fallback"); + }); + + it("returns the default value when object is null", () => { + expect(immutableGet(null, "a.b", 99)).toBe(99); + }); + + it("returns the default value when object is undefined", () => { + expect(immutableGet(undefined, "a.b", "x")).toBe("x"); + }); + + it("does not mutate the source object", () => { + const obj = { a: { b: 1 } }; + const newObj = immutableGet(obj, "a"); + newObj.b = 2; + expect(newObj).toEqual({ b: 2 }); + expect(obj).toEqual({ a: { b: 1 } }); + }); +}); diff --git a/__tests__/dotProp/immutableSet.test.ts b/__tests__/dotProp/immutableSet.test.ts new file mode 100644 index 0000000..71653e5 --- /dev/null +++ b/__tests__/dotProp/immutableSet.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { immutableSet } from "../../src/common/utils/dotProp.js"; + +describe("immutableSet", () => { + it("returns a new object reference", () => { + const obj = { a: 1 }; + const result = immutableSet(obj, "a", 2); + expect(result).not.toBe(obj); + }); + + it("sets a top-level property", () => { + expect(immutableSet({ a: 1 }, "a", 2)).toEqual({ a: 2 }); + }); + + it("sets a nested property by dot path", () => { + expect(immutableSet({ a: { b: 1 } }, "a.b", 99)).toEqual({ a: { b: 99 } }); + }); + + it("creates intermediate objects for missing paths", () => { + expect(immutableSet({} as Record, "a.b.c", 7)).toEqual({ a: { b: { c: 7 } } }); + }); + + it("does not mutate the original object", () => { + const obj = { a: { b: 1 } }; + immutableSet(obj, "a.b", 99); + expect(obj.a.b).toBe(1); + }); + + it("deep-clones nested objects so the result shares no references with the original", () => { + const obj = { a: { b: 1 } }; + const result = immutableSet(obj, "a.b", 2); + expect(result.a).not.toBe(obj.a); + }); + + it("accepts a functional updater that receives the current value", () => { + const result = immutableSet({ count: 5 }, "count", (n: number) => n + 1); + expect(result).toEqual({ count: 6 }); + }); + + it("functional updater receives undefined for a missing path", () => { + let received: unknown = "sentinel"; + immutableSet({} as Record, "missing", (v: unknown) => { + received = v; + return 0; + }); + expect(received).toBeUndefined(); + }); +}); diff --git a/__tests__/dotProp/mutableDelete.test.ts b/__tests__/dotProp/mutableDelete.test.ts new file mode 100644 index 0000000..5a71f38 --- /dev/null +++ b/__tests__/dotProp/mutableDelete.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { mutableDelete } from "../../src/common/utils/dotProp.js"; + +describe("mutableDelete", () => { + it("removes a top-level property from the original object", () => { + const obj = { a: 1, b: 2 }; + mutableDelete(obj, "a"); + expect(obj).toEqual({ b: 2 }); + }); + + it("removes a nested property by dot path", () => { + const obj = { a: { b: 1, c: 2 } }; + mutableDelete(obj, "a.b"); + expect(obj).toEqual({ a: { c: 2 } }); + }); + + it("returns true when the property existed", () => { + const obj = { a: 1 }; + expect(mutableDelete(obj, "a")).toBe(true); + expect(obj).toEqual({}); + }); + + it("is a no-op when the path does not exist", () => { + const obj = { a: 1 }; + expect(() => mutableDelete(obj, "b")).not.toThrow(); + expect(obj).toEqual({ a: 1 }); + }); + + it("splices an element from an array by numeric index", () => { + const arr = ["a", "b", "c"]; + mutableDelete(arr, 1); + expect(arr).toEqual(["a", "c"]); + }); + + it("splices the first element from an array", () => { + const arr = [10, 20, 30]; + mutableDelete(arr, 0); + expect(arr).toEqual([20, 30]); + }); + + it("splices the last element from an array", () => { + const arr = [10, 20, 30]; + mutableDelete(arr, 2); + expect(arr).toEqual([10, 20]); + }); + + it("mutates the original array reference", () => { + const arr = [1, 2, 3]; + const ref = arr; + mutableDelete(arr, 0); + expect(ref).toBe(arr); + expect(arr.length).toBe(2); + expect(ref).toEqual([2, 3]); + }); + + it("returns true when the index exists", () => { + const arr = ["x", "y"]; + expect(mutableDelete(arr, 0)).toBe(true); + }); + + it("returns false when the index is out of bounds", () => { + const arr = ["x"]; + expect(mutableDelete(arr, 5)).toBe(false); + }); + + it("returns false for a negative index", () => { + const arr = [1, 2, 3]; + expect(mutableDelete(arr, -1)).toBe(false); + }); + + it("returns false for an empty array", () => { + const arr: string[] = []; + expect(mutableDelete(arr, 0)).toBe(false); + }); +}); diff --git a/__tests__/dotProp/mutableSet.test.ts b/__tests__/dotProp/mutableSet.test.ts new file mode 100644 index 0000000..1065232 --- /dev/null +++ b/__tests__/dotProp/mutableSet.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { mutableSet } from "../../src/common/utils/dotProp.js"; + +describe("mutableSet", () => { + it("returns the same object reference", () => { + const obj = { a: 1 }; + const result = mutableSet(obj, "a", 2); + expect(result).toBe(obj); + }); + + it("sets a top-level property on the original object", () => { + const obj = { a: 1 }; + mutableSet(obj, "a", 42); + expect(obj.a).toBe(42); + }); + + it("sets a nested property by dot path", () => { + const obj = { a: { b: 1 } }; + mutableSet(obj, "a.b", 99); + expect(obj.a.b).toBe(99); + }); + + it("creates intermediate objects for missing paths", () => { + const obj: Record = {}; + mutableSet(obj, "a.b.c", 7); + expect(obj).toEqual({ a: { b: { c: 7 } } }); + }); +}); diff --git a/src/common/utils/README.md b/src/common/utils/README.md index 7df7585..58f35ec 100644 --- a/src/common/utils/README.md +++ b/src/common/utils/README.md @@ -32,9 +32,10 @@ Returns a deep clone of `object` with `value` set at `path`. Pass a function as ```ts function immutableDelete>(object: T, path: string): T; +function immutableDelete(target: T[], index: number): T[]; ``` -Returns a deep clone of `object` with the property at `path` removed. +Returns a deep clone with the property at `path` removed. When called on an array with a numeric index, splices the element out of the clone (the original array is unchanged). ```ts function mutableSet>(object: T, path: string, value: unknown): T; @@ -43,10 +44,11 @@ function mutableSet>(object: T, path: string, valu Sets `value` at `path` on `object` in place. Returns `object`. ```ts -function mutableDelete>(object: T, path: string): void; +function mutableDelete>(object: T, path: string): boolean; +function mutableDelete(target: T[], index: number): boolean; ``` -Removes the property at `path` from `object` in place. +Removes the property at `path` from `object` in place. When called on an array with a numeric index, splices the element out. Returns `true` if the property/element existed, `false` otherwise. ### Usage @@ -74,6 +76,12 @@ const withoutHost = immutableDelete(config, "server.host"); mutableSet(config, "server.port", 5000); // mutates config mutableDelete(config, "server.host"); // mutates config + +// Array support — both delete functions accept a numeric index +const items = ["a", "b", "c"]; + +const without = immutableDelete(items, 1); // ["a", "c"] — items unchanged +mutableDelete(items, 0); // items is now ["b", "c"] ``` --- diff --git a/src/common/utils/dotProp.ts b/src/common/utils/dotProp.ts index c6018c7..0a5b7d8 100644 --- a/src/common/utils/dotProp.ts +++ b/src/common/utils/dotProp.ts @@ -33,10 +33,20 @@ function immutableSet>( /** * Returns a deep clone with the property at the given path removed. + * When target is an array, pass a numeric index to splice the element out of the clone. */ -function immutableDelete>(target: T, path: string): T { +function immutableDelete(target: T[], index: number): T[]; +function immutableDelete>(target: T, path: string): T; +function immutableDelete(target: Record | unknown[], path: string | number): unknown { const clone = structuredClone(target); - deleteProperty(clone, path); + if (Array.isArray(clone) && typeof path === "number") { + if (path < 0 || path >= clone.length) { + return clone; + } + clone.splice(path, 1); + return clone; + } + deleteProperty(clone as Record, path as string); return clone; } From ec33e01328986df1cf3bfbf03473c9babc1c46dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Thu, 21 May 2026 17:16:26 +0200 Subject: [PATCH 3/3] chore: bump npmMinimalAgeGate to 48h Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/busy-paws-worry.md | 5 +++++ .yarnrc.yml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/busy-paws-worry.md diff --git a/.changeset/busy-paws-worry.md b/.changeset/busy-paws-worry.md new file mode 100644 index 0000000..2a9413b --- /dev/null +++ b/.changeset/busy-paws-worry.md @@ -0,0 +1,5 @@ +--- +"@webiny/stdlib": patch +--- + +refactor: add array index support to immutableDelete and mutableDelete diff --git a/.yarnrc.yml b/.yarnrc.yml index 3333fdd..9fedb46 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,4 +2,4 @@ nodeLinker: node-modules yarnPath: .yarn/releases/yarn-4.14.1.cjs approvedGitRepositories: [] enableScripts: false -npmMinimalAgeGate: 24h +npmMinimalAgeGate: 48h