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
5 changes: 5 additions & 0 deletions .changeset/swift-swans-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/fmdapi": patch
---

Update removeFMTableNames function for proper type-safety
40 changes: 19 additions & 21 deletions packages/fmdapi/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
import type { S, L, U } from "ts-toolbelt";
import type * as z3 from "zod/v3";
import type * as z4 from "zod/v4/core";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TransformedFields<T extends Record<string, any>> = U.Merge<
{
[Field in keyof T]: {
[Key in Field extends string
? L.Last<S.Split<Field, "::">>
: Field]: T[Field];
};
}[keyof T]
>;
type StripFMTableName<K extends PropertyKey> = K extends `${string}::${infer R}`
? R
: K;
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Type/runtime mismatch: StripFMTableName uses “last ::” while runtime uses “first ::”.

Currently, the type-level mapper strips after the last '::' (due to ${string}::${infer R} greediness), while the runtime uses key.split("::")[1] (after the first). For keys containing multiple '::', the returned object’s keys won’t match its static type.

Pick ONE of the following to align behavior:

Option A — make runtime match the type (strip after the last '::'):

-    const mappedKey = (
-      typeof key === "string" && key.includes("::") ? key.split("::")[1] : key
-    ) as keyof TransformedFields<T>;
+    const idx = key.lastIndexOf("::");
+    const mappedKey = (
+      typeof key === "string" && idx !== -1 ? key.slice(idx + 2) : key
+    ) as keyof TransformedFields<T>;

Option B — make the type match the runtime (strip after the first '::'):

-type StripFMTableName<K extends PropertyKey> = K extends `${string}::${infer R}`
+type StripFMTableName<K extends PropertyKey> = K extends `${infer _}::${infer R}`
   ? R
   : K;

If you have domain certainty that keys never contain more than one '::', add a test to lock that invariant; otherwise, I recommend Option A (safer in the presence of extra qualifiers).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type StripFMTableName<K extends PropertyKey> = K extends `${string}::${infer R}`
? R
: K;
type StripFMTableName<K extends PropertyKey> = K extends `${infer _}::${infer R}`
? R
: K;


// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function removeFMTableNames<T extends Record<string, any>>(
type TransformedFields<T extends object> = {
[K in keyof T as K extends string ? StripFMTableName<K> : K]: T[K];
};

export function removeFMTableNames<T extends object>(
obj: T,
): TransformedFields<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObj: any = {};
const newObj = {} as TransformedFields<T>;
for (const key in obj) {
if (key.includes("::")) {
const newKey = key.split("::")[1];
newObj[newKey as keyof TransformedFields<T>] = obj[key];
} else {
newObj[key] = obj[key];
}
const originalKey = key as keyof T;
const value = obj[originalKey];
const mappedKey = (
typeof key === "string" && key.includes("::") ? key.split("::")[1] : key
) as keyof TransformedFields<T>;

// Use a temporary index signature cast to assign without any
(newObj as unknown as Record<PropertyKey, unknown>)[
mappedKey as unknown as PropertyKey
] = value as unknown;
}
Comment on lines 16 to 27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid copying inherited enumerable properties; restrict to own keys.

for..in iterates inherited enumerable props. That can silently pull in prototype properties. Add a hasOwnProperty guard (minimal change), or iterate Object.keys/Reflect.ownKeys instead.

Minimal, surgical fix:

-  for (const key in obj) {
+  for (const key in obj) {
+    if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
     const originalKey = key as keyof T;
     const value = obj[originalKey];

If you want to also preserve symbol keys (currently dropped), consider Reflect.ownKeys:

-  for (const key in obj) {
-    const originalKey = key as keyof T;
-    const value = obj[originalKey];
+  for (const k of Reflect.ownKeys(obj)) {
+    const key = k as keyof T;
+    const value = (obj as Record<PropertyKey, unknown>)[k];

…and keep the mappedKey logic the same (only transform when typeof k === "string").

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const key in obj) {
if (key.includes("::")) {
const newKey = key.split("::")[1];
newObj[newKey as keyof TransformedFields<T>] = obj[key];
} else {
newObj[key] = obj[key];
}
const originalKey = key as keyof T;
const value = obj[originalKey];
const mappedKey = (
typeof key === "string" && key.includes("::") ? key.split("::")[1] : key
) as keyof TransformedFields<T>;
// Use a temporary index signature cast to assign without any
(newObj as unknown as Record<PropertyKey, unknown>)[
mappedKey as unknown as PropertyKey
] = value as unknown;
}
for (const key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
const originalKey = key as keyof T;
const value = obj[originalKey];
const mappedKey = (
typeof key === "string" && key.includes("::") ? key.split("::")[1] : key
) as keyof TransformedFields<T>;
// Use a temporary index signature cast to assign without any
(newObj as unknown as Record<PropertyKey, unknown>)[
mappedKey as unknown as PropertyKey
] = value as unknown;
}
🤖 Prompt for AI Agents
In packages/fmdapi/src/utils.ts around lines 16 to 27, the for..in loop
currently iterates inherited enumerable properties; change it to only process
the object's own keys by either adding a hasOwnProperty guard inside the loop
(if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;) or replace the
loop with Object.keys(obj).forEach(...) to iterate own string keys; if you need
to preserve symbol keys instead use Reflect.ownKeys(obj).forEach and keep the
existing mappedKey transformation logic unchanged.

return newObj;
}
Expand Down
50 changes: 50 additions & 0 deletions packages/fmdapi/tests/removeFMTableNames.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { removeFMTableNames } from "../src";

describe("removeFMTableNames", () => {
it("strips FM table prefixes from keys and preserves values", () => {
const input = {
"Customer::name": "Alice",
count: 2,
"X::flag": true,
} as const;

const out = removeFMTableNames(input);

expect(out).toEqual({ name: "Alice", count: 2, flag: true });
expect("Customer::name" in (out as Record<string, unknown>)).toBe(false);
expect("name" in out).toBe(true);
expect(out.name).toBe("Alice");
expect(out.count).toBe(2);
expect(out.flag).toBe(true);
});

it("produces a type with stripped keys", () => {
type Input = {
"Customer::first_name": string;
last_name: string;
"Portal::recordId": number;
};

const input: Input = {
"Customer::first_name": "Bob",
last_name: "Builder",
"Portal::recordId": 42,
};
const out = removeFMTableNames(input);

// Type-level assertions via assignment
const first: string = out.first_name;
const last: string = out.last_name;
const recId: number = out.recordId;
expect(first).toBe("Bob");
expect(last).toBe("Builder");
expect(recId).toBe(42);

// Old keys should not exist on the resulting type
// @ts-expect-error - old FM-prefixed key should not exist on result type
out["Customer::first_name"];
// @ts-expect-error - old FM-prefixed key should not exist on result type
out["Portal::recordId"];
});
Comment on lines +22 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

ts-expect-error in runtime tests isn’t enforced by Vitest; wire up the dedicated type-check instead.

Vitest (via esbuild) doesn’t type-check by default, so the @ts-expect-error assertions here won’t fail builds. You already added a dedicated type-check config—great. Ensure CI runs it.

Run locally/CI:

Optionally, move the @ts-expect-error assertions to packages/fmdapi/tests/type/removeFMTableNames.typecheck.ts to keep runtime tests focused.


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Type-check only:
npx -y tsc -p packages/fmdapi/tests/tsconfig.typecheck.json --noEmit

Length of output: 556


Type-check setup is broken – CI will fail until it’s fixed

The dedicated type-check command (npx tsc -p packages/fmdapi/tests/tsconfig.typecheck.json --noEmit) currently errors out:

  • TS2688: Cannot find type definition file for 'node'
  • TS6053: File packages/fmdapi/tests/type/removeFMTableNames.typecheck.ts not found

You need to:

  • Add the missing Node type defs (npm install -D @types/node) so tsc can find the node library.
  • Either create the expected type-check file (packages/fmdapi/tests/type/removeFMTableNames.typecheck.ts)—moving just the @ts-expect-error lines from the runtime test into it—or update tsconfig.typecheck.json’s files list to point at your existing .test.ts file.

Once the above is addressed, ensure your CI invokes:

npx tsc -p packages/fmdapi/tests/tsconfig.typecheck.json --noEmit

so that any stray or missing @ts-expect-error assertions will fail the build rather than slip through Vitest’s runtime tests.

🤖 Prompt for AI Agents
packages/fmdapi/tests/removeFMTableNames.test.ts lines 22-49: Type-check CI is
failing because Node type defs are missing and the type-check harness expects a
separate file; install dev dependency @types/node (npm i -D @types/node) so tsc
finds the node lib, then either create
packages/fmdapi/tests/type/removeFMTableNames.typecheck.ts containing the two
@ts-expect-error lines moved out of the runtime test OR update
packages/fmdapi/tests/tsconfig.typecheck.json’s "files" array to point at the
existing .test.ts file, and finally ensure CI invokes npx tsc -p
packages/fmdapi/tests/tsconfig.typecheck.json --noEmit so type-level
@ts-expect-error assertions fail the build if incorrect.

});
10 changes: 10 additions & 0 deletions packages/fmdapi/tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["vitest", "node"],
"isolatedModules": true
},
"include": ["./**/*.ts"],
"exclude": ["../dist", "../schema"]
}
14 changes: 14 additions & 0 deletions packages/fmdapi/tests/tsconfig.typecheck.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"types": ["node"]
},
"files": ["./type/removeFMTableNames.typecheck.ts"]
Comment on lines +2 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Ensure your toolchain TS version supports moduleResolution: "Bundler".

Bundler resolution requires a relatively recent TypeScript. Please confirm your CI/runner uses a compatible TS version; otherwise, this config will be ignored or fail.

You can verify with:


🏁 Script executed:

#!/bin/bash
# Expect TS >= 5.x supporting "Bundler" moduleResolution.
npx -y tsc -v
# Also prove the typecheck config compiles:
npx -y tsc -p packages/fmdapi/tests/tsconfig.typecheck.json --noEmit

Length of output: 677


Fix TypeScript type‐check configuration errors

Verification shows your CI is using TypeScript 5.9.2, so moduleResolution: "Bundler" is supported. However, the type-check step is failing:

• TS2688: Cannot find type definition file for ‘node’.
• TS6053: File ‘packages/fmdapi/tests/type/removeFMTableNames.typecheck.ts’ not found.

To resolve:

  • Ensure Node.js type definitions are installed for this project. For example, in packages/fmdapi, run:
    npm install --save-dev @types/node
    or add "@types/node": "…" under devDependencies.
  • Correct the path in packages/fmdapi/tests/tsconfig.typecheck.json so it matches where the .typecheck.ts file actually lives. For example, if the file is in tests/types/, update:
      "compilerOptions": {
        "types": ["node"],
        …
      },
      "files": [
    -   "./type/removeFMTableNames.typecheck.ts"
    +   "./types/removeFMTableNames.typecheck.ts"
      ]
    Otherwise, adjust to the correct subdirectory or move the file.

After making these fixes, re-run:

npx tsc -v
npx tsc -p packages/fmdapi/tests/tsconfig.typecheck.json --noEmit
🤖 Prompt for AI Agents
packages/fmdapi/tests/tsconfig.typecheck.json lines 2-13: The typecheck config
is failing because Node.js types are missing (TS2688) and the referenced
typecheck file path is incorrect (TS6053); install or add "@types/node" to
devDependencies in packages/fmdapi (e.g., npm install --save-dev @types/node or
add the exact version to package.json) and update the "files" entry to the real
location of removeFMTableNames.typecheck.ts (or move the file to match the path)
so tsc can find it, then re-run the typecheck command.

}