diff --git a/package-lock.json b/package-lock.json index 09f5fcf..cecf21b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2748,6 +2748,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@script-development/fs-helpers": { + "resolved": "packages/helpers", + "link": true + }, "node_modules/@script-development/fs-http": { "resolved": "packages/http", "link": true @@ -6954,6 +6958,12 @@ "dev": true, "license": "MIT" }, + "node_modules/string-ts": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.3.1.tgz", + "integrity": "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==", + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8008,6 +8018,14 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/helpers": { + "name": "@script-development/fs-helpers", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "string-ts": "^2.3.1" + } + }, "packages/http": { "name": "@script-development/fs-http", "version": "0.1.0", diff --git a/packages/helpers/package.json b/packages/helpers/package.json new file mode 100644 index 0000000..1ef2d06 --- /dev/null +++ b/packages/helpers/package.json @@ -0,0 +1,44 @@ +{ + "name": "@script-development/fs-helpers", + "version": "0.1.0", + "description": "Tree-shakeable shared utility helpers: deep copy, type guards, and case conversion", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/script-development/fs-packages.git", + "directory": "packages/helpers" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "lint:pkg": "publint && attw --pack", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "string-ts": "^2.3.1" + } +} diff --git a/packages/helpers/src/case-conversion.ts b/packages/helpers/src/case-conversion.ts new file mode 100644 index 0000000..b49c8f6 --- /dev/null +++ b/packages/helpers/src/case-conversion.ts @@ -0,0 +1,14 @@ +import type { DeepSnakeKeys } from "string-ts"; + +import { deepCamelKeys, deepSnakeKeys } from "string-ts"; + +export { deepCamelKeys, deepSnakeKeys }; + +/** + * Convert a snake_case API response to the camelCase generic T. + * + * Runtime transformation via `deepCamelKeys` aligns keys with T's shape; + * the cast is safe because the transformation is exhaustive. + */ +export const toCamelCaseTyped = (data: T | DeepSnakeKeys): T => + deepCamelKeys(data) as unknown as T; diff --git a/packages/helpers/src/deep-copy.ts b/packages/helpers/src/deep-copy.ts new file mode 100644 index 0000000..a2d97a1 --- /dev/null +++ b/packages/helpers/src/deep-copy.ts @@ -0,0 +1,36 @@ +type WritablePrimitive = undefined | null | boolean | string | number | Date; + +export type Writable = T extends WritablePrimitive + ? T + : T extends readonly [...infer U] + ? { -readonly [K in keyof U]: Writable } + : T extends ReadonlyArray + ? Array> + : T extends object + ? { -readonly [K in keyof T]: Writable } + : T; + +/** + * Deep copy for plain objects, arrays, and Date instances. + * + * Uses manual recursion over structuredClone for performance (~10x faster + * depending on object size and depth). + * + * Handles: primitives, plain objects, arrays, Date, null. + * Does NOT handle: Map, Set, RegExp, functions, circular references. + */ +export const deepCopy = (toCopy: T): Writable => { + if (typeof toCopy !== "object" || toCopy === null) return toCopy as Writable; + + if (toCopy instanceof Date) return new Date(toCopy.getTime()) as Writable; + + if (Array.isArray(toCopy)) return toCopy.map((value: unknown) => deepCopy(value)) as Writable; + + const copiedObject: Record = {}; + + for (const key of Object.keys(toCopy)) { + copiedObject[key] = deepCopy((toCopy as Record)[key]); + } + + return copiedObject as Writable; +}; diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts new file mode 100644 index 0000000..cf2d84d --- /dev/null +++ b/packages/helpers/src/index.ts @@ -0,0 +1,6 @@ +export { deepCopy } from "./deep-copy"; +export type { Writable } from "./deep-copy"; + +export { isExisting } from "./type-guards"; + +export { toCamelCaseTyped, deepCamelKeys, deepSnakeKeys } from "./case-conversion"; diff --git a/packages/helpers/src/type-guards.ts b/packages/helpers/src/type-guards.ts new file mode 100644 index 0000000..e5174eb --- /dev/null +++ b/packages/helpers/src/type-guards.ts @@ -0,0 +1,6 @@ +/** + * Type guard that checks if an object has an `id` property, + * distinguishing existing resources from new ones. + */ +export const isExisting = (obj: T | Omit): obj is T => + "id" in obj; diff --git a/packages/helpers/tests/case-conversion.spec.ts b/packages/helpers/tests/case-conversion.spec.ts new file mode 100644 index 0000000..d96014b --- /dev/null +++ b/packages/helpers/tests/case-conversion.spec.ts @@ -0,0 +1,40 @@ +import { toCamelCaseTyped, deepCamelKeys, deepSnakeKeys } from "../src"; +import { describe, expect, it } from "vitest"; + +describe("toCamelCaseTyped", () => { + it("should convert snake_case keys to camelCase", () => { + const input = { first_name: "John", last_name: "Doe" }; + + const result = toCamelCaseTyped<{ firstName: string; lastName: string }>(input); + + expect(result).toEqual({ firstName: "John", lastName: "Doe" }); + }); + + it("should handle nested objects", () => { + const input = { user_data: { first_name: "John" } }; + + const result = toCamelCaseTyped<{ userData: { firstName: string } }>(input); + + expect(result).toEqual({ userData: { firstName: "John" } }); + }); + + it("should handle already camelCase data", () => { + const input = { firstName: "John" }; + + const result = toCamelCaseTyped<{ firstName: string }>(input); + + expect(result).toEqual({ firstName: "John" }); + }); +}); + +describe("re-exports", () => { + it("should re-export deepCamelKeys from string-ts", () => { + expect(typeof deepCamelKeys).toBe("function"); + expect(deepCamelKeys({ snake_case: 1 })).toEqual({ snakeCase: 1 }); + }); + + it("should re-export deepSnakeKeys from string-ts", () => { + expect(typeof deepSnakeKeys).toBe("function"); + expect(deepSnakeKeys({ camelCase: 1 })).toEqual({ camel_case: 1 }); + }); +}); diff --git a/packages/helpers/tests/deep-copy.spec.ts b/packages/helpers/tests/deep-copy.spec.ts new file mode 100644 index 0000000..f8052b5 --- /dev/null +++ b/packages/helpers/tests/deep-copy.spec.ts @@ -0,0 +1,73 @@ +import { deepCopy } from "../src"; +import { describe, expect, it } from "vitest"; + +describe("deepCopy", () => { + it("should return primitives as-is", () => { + expect(deepCopy(42)).toBe(42); + expect(deepCopy("hello")).toBe("hello"); + expect(deepCopy(true)).toBe(true); + expect(deepCopy(null)).toBeNull(); + expect(deepCopy(undefined)).toBeUndefined(); + }); + + it("should deep copy plain objects", () => { + const original = { a: 1, b: { c: 2 } }; + + const copy = deepCopy(original); + + expect(copy).toEqual(original); + expect(copy).not.toBe(original); + expect(copy.b).not.toBe(original.b); + }); + + it("should deep copy arrays", () => { + const original = [1, [2, 3], { a: 4 }]; + + const copy = deepCopy(original); + + expect(copy).toEqual(original); + expect(copy).not.toBe(original); + expect(copy[1]).not.toBe(original[1]); + expect(copy[2]).not.toBe(original[2]); + }); + + it("should deep copy Date instances", () => { + const original = new Date("2026-01-01"); + + const copy = deepCopy(original); + + expect(copy).toEqual(original); + expect(copy).not.toBe(original); + expect(copy.getTime()).toBe(original.getTime()); + }); + + it("should deep copy nested objects with arrays", () => { + const original = { items: [{ id: 1, name: "test" }], count: 1 }; + + const copy = deepCopy(original); + + expect(copy).toEqual(original); + expect(copy.items).not.toBe(original.items); + expect(copy.items[0]).not.toBe(original.items[0]); + }); + + it("should handle empty objects", () => { + expect(deepCopy({})).toEqual({}); + }); + + it("should handle empty arrays", () => { + expect(deepCopy([])).toEqual([]); + }); + + it("should produce a mutable copy from readonly input", () => { + const original = { a: 1, b: { c: 2 } } as const; + + const copy = deepCopy(original); + copy.a = 99; + copy.b.c = 99; + + expect(copy.a).toBe(99); + expect(copy.b.c).toBe(99); + expect(original.a).toBe(1); + }); +}); diff --git a/packages/helpers/tests/type-guards.spec.ts b/packages/helpers/tests/type-guards.spec.ts new file mode 100644 index 0000000..d75c6f9 --- /dev/null +++ b/packages/helpers/tests/type-guards.spec.ts @@ -0,0 +1,21 @@ +import { isExisting } from "../src"; +import { describe, expect, it } from "vitest"; + +describe("isExisting", () => { + it("should return true for objects with an id", () => { + expect(isExisting({ id: 1, name: "test" })).toBe(true); + }); + + it("should return false for objects without an id", () => { + expect(isExisting({ name: "test" })).toBe(false); + }); + + it("should narrow the type correctly", () => { + const item: { id: number; name: string } | { name: string } = { id: 1, name: "test" }; + + if (isExisting(item)) { + // TypeScript should narrow to the type with id + expect(item.id).toBe(1); + } + }); +}); diff --git a/packages/helpers/tsconfig.json b/packages/helpers/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/helpers/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/helpers/tsdown.config.ts b/packages/helpers/tsdown.config.ts new file mode 100644 index 0000000..aa2e8b8 --- /dev/null +++ b/packages/helpers/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/helpers/vitest.config.ts b/packages/helpers/vitest.config.ts new file mode 100644 index 0000000..cdb6d94 --- /dev/null +++ b/packages/helpers/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "helpers", + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + }, + }, +});