mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-28 02:41:36 +08:00
refactor(sim): consolidate record guards + pure utils into @sim/utils (#5061)
* feat(utils): add record guards and pure helpers to @sim/utils Add isRecordLike (loose, non-prototype-checked record guard) and sortObjectKeysDeep; relocate isPlainRecord (strict) and normalizeEmail into @sim/utils so they are reusable across apps and packages. Unit tests cover the loose-vs-strict distinction, deep key sorting, and email normalization. * refactor(sim): consolidate record guards and normalize helpers onto @sim/utils Replace ~55 re-implemented loose record guards with the canonical @sim/utils isRecordLike (and one strict site with isPlainRecord), and dedupe three normalize clusters: sortObjectKeysDeep (sanitization + copilot builders), normalizeToken (salesforce + servicenow triggers), and normalizeEmail. Array-allowing guards and domain-specific normalizers are intentionally left untouched. Pure refactor — identical predicates and transforms, no behavior change.
This commit is contained in:
@@ -12,7 +12,13 @@ export {
|
||||
} from './formatting.js'
|
||||
export { noop, sleep } from './helpers.js'
|
||||
export { generateId, generateShortId, isValidUuid } from './id.js'
|
||||
export { filterUndefined, omit } from './object.js'
|
||||
export {
|
||||
filterUndefined,
|
||||
isPlainRecord,
|
||||
isRecordLike,
|
||||
omit,
|
||||
sortObjectKeysDeep,
|
||||
} from './object.js'
|
||||
export {
|
||||
generateRandomBytes,
|
||||
generateRandomHex,
|
||||
@@ -24,4 +30,4 @@ export {
|
||||
} from './random.js'
|
||||
export type { BackoffOptions } from './retry.js'
|
||||
export { backoffWithJitter, parseRetryAfter } from './retry.js'
|
||||
export { truncate } from './string.js'
|
||||
export { normalizeEmail, truncate } from './string.js'
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isPlainRecord, isRecordLike, sortObjectKeysDeep } from './object.js'
|
||||
|
||||
class Sample {
|
||||
value = 1
|
||||
}
|
||||
|
||||
describe('isRecordLike', () => {
|
||||
it('returns true for plain objects, Date, and class instances', () => {
|
||||
expect(isRecordLike({})).toBe(true)
|
||||
expect(isRecordLike(new Date())).toBe(true)
|
||||
expect(isRecordLike(new Sample())).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for arrays, null, and primitives', () => {
|
||||
expect(isRecordLike([])).toBe(false)
|
||||
expect(isRecordLike(null)).toBe(false)
|
||||
expect(isRecordLike('not-a-record')).toBe(false)
|
||||
expect(isRecordLike(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPlainRecord', () => {
|
||||
it('returns true for plain objects', () => {
|
||||
expect(isPlainRecord({})).toBe(true)
|
||||
expect(isPlainRecord(Object.create(null))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for Date, class instances, arrays, and null', () => {
|
||||
expect(isPlainRecord(new Date())).toBe(false)
|
||||
expect(isPlainRecord(new Sample())).toBe(false)
|
||||
expect(isPlainRecord([])).toBe(false)
|
||||
expect(isPlainRecord(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortObjectKeysDeep', () => {
|
||||
it('sorts keys deeply and recurses into array elements', () => {
|
||||
const input = {
|
||||
b: 1,
|
||||
a: { d: 4, c: 3 },
|
||||
list: [{ z: 26, y: 25 }],
|
||||
}
|
||||
const sorted = sortObjectKeysDeep(input)
|
||||
expect(JSON.stringify(sorted)).toBe(
|
||||
JSON.stringify({ a: { c: 3, d: 4 }, b: 1, list: [{ y: 25, z: 26 }] })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns primitives and null unchanged', () => {
|
||||
expect(sortObjectKeysDeep(null)).toBe(null)
|
||||
expect(sortObjectKeysDeep(42)).toBe(42)
|
||||
expect(sortObjectKeysDeep('x')).toBe('x')
|
||||
})
|
||||
|
||||
it('preserves array order while sorting element keys', () => {
|
||||
const sorted = sortObjectKeysDeep([
|
||||
{ b: 1, a: 2 },
|
||||
{ d: 3, c: 4 },
|
||||
])
|
||||
expect(JSON.stringify(sorted)).toBe(
|
||||
JSON.stringify([
|
||||
{ a: 2, b: 1 },
|
||||
{ c: 4, d: 3 },
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -22,3 +22,51 @@ export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Om
|
||||
export function filterUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only for object-map values, excluding arrays and null.
|
||||
*/
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for any non-null, non-array object — the LOOSE record check.
|
||||
* Unlike {@link isPlainRecord}, this does NOT inspect the prototype, so it also
|
||||
* accepts class instances, Date, Map, etc. Use this when you only need to know a
|
||||
* value is an indexable object (e.g. before spreading or Object.entries), and
|
||||
* reach for isPlainRecord when you must exclude exotic objects.
|
||||
*/
|
||||
export function isRecordLike(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sorts the keys of every plain object reachable from {@link value},
|
||||
* preserving array order while recursing into array elements. Primitives and
|
||||
* `null` are returned unchanged. Produces a structurally equivalent value with
|
||||
* deterministic key ordering, suitable for stable comparison or serialization.
|
||||
*
|
||||
* @remarks Only string keys are sorted and retained; symbol keys are dropped,
|
||||
* matching the stable-serialization callers this serves.
|
||||
*/
|
||||
export function sortObjectKeysDeep(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortObjectKeysDeep)
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>
|
||||
return Object.keys(obj)
|
||||
.sort()
|
||||
.reduce((result: Record<string, unknown>, key: string) => {
|
||||
result[key] = sortObjectKeysDeep(obj[key])
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isVersionedType, stripVersionSuffix, truncate } from './string.js'
|
||||
import { isVersionedType, normalizeEmail, stripVersionSuffix, truncate } from './string.js'
|
||||
|
||||
describe('truncate', () => {
|
||||
it('appends the suffix when the string exceeds the slice length', () => {
|
||||
@@ -55,3 +55,13 @@ describe('isVersionedType', () => {
|
||||
expect(isVersionedType('x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeEmail', () => {
|
||||
it('trims surrounding whitespace and lowercases', () => {
|
||||
expect(normalizeEmail(' USER@Example.COM ')).toBe('user@example.com')
|
||||
})
|
||||
|
||||
it('leaves an already-normalized email unchanged', () => {
|
||||
expect(normalizeEmail('user@example.com')).toBe('user@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,3 +37,11 @@ export function stripVersionSuffix(value: string): string {
|
||||
export function isVersionedType(value: string): boolean {
|
||||
return /_v\d+$/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an email address for comparison and storage by trimming
|
||||
* surrounding whitespace and lowercasing.
|
||||
*/
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user