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: 2 additions & 3 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@
"WebSearch",
"WebFetch(domain:daddywarbucks.github.io)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(curl -sS https://raw.githubusercontent.com/DaddyWarbucks/feathers-fletching/master/src/hooks/rateLimit.ts)",
"Bash(curl -sS https://raw.githubusercontent.com/DaddyWarbucks/feathers-fletching/master/tests/hooks/rateLimit.test.ts)",
"Bash(pnpm run test:unit)",
"Bash(pnpm run test:unit *)",
"Bash(pnpm run build)",
"Bash(gh release list *)",
"Bash(gh release view *)",
"Bash(npm run *)",
"Bash(pnpm typecheck *)",
"Bash(pnpm vitest *)"
"Bash(pnpm vitest *)",
"Bash(pnpm lint *)"
]
}
}
33 changes: 33 additions & 0 deletions src/common/clone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { clone } from './clone.js'

describe('clone', () => {
it('deep-clones plain objects', () => {
const input = { a: 1, nested: { b: 2 } }
const out = clone(input)
expect(out).toEqual(input)
expect(out).not.toBe(input)
expect(out.nested).not.toBe(input.nested)
})

it('preserves Date instances (not a JSON string)', () => {
const date = new Date('2023-10-01T12:00:00Z')
const out = clone({ date })
expect(out.date).toBeInstanceOf(Date)
expect(out.date.getTime()).toBe(date.getTime())
expect(out.date).not.toBe(date)
})

it('preserves undefined values', () => {
const out = clone({ a: undefined, b: 1 })
expect('a' in out).toBe(true)
expect(out.a).toBeUndefined()
})

it('handles arrays', () => {
const input = [{ a: 1 }, { b: 2 }]
const out = clone(input)
expect(out).toEqual(input)
expect(out[0]).not.toBe(input[0])
})
})
16 changes: 9 additions & 7 deletions src/common/clone.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { copy } from 'fast-copy'

/**
* Deep-clones an object using `JSON.parse(JSON.stringify(...))`.
* Simple and fast for JSON-serializable data, but does not handle
* `Date` objects, `undefined` values, functions, or circular references.
* Deep-clones a value using `fast-copy`.
*
* Unlike a `JSON.parse(JSON.stringify(...))` round-trip, this correctly handles
* `Date`, `Map`, `Set`, `RegExp`, typed arrays, `undefined` values and circular
* references — all of which FeathersJS payloads routinely contain.
*
* @example
* ```ts
* const copy = clone({ name: 'Alice', nested: { value: 1 } })
* const copyOf = clone({ name: 'Alice', createdAt: new Date(), nested: { value: 1 } })
* ```
*/
export function clone(obj: any) {
return JSON.parse(JSON.stringify(obj))
}
export const clone = copy
63 changes: 63 additions & 0 deletions src/hooks/cache/cache-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { stableStringify } from './cache-utils.js'

describe('stableStringify', () => {
it('is order-independent for top-level keys', () => {
expect(stableStringify({ a: 1, b: 2 })).toBe(
stableStringify({ b: 2, a: 1 }),
)
})

it('is order-independent for query keys', () => {
const a = stableStringify({ query: { name: 'John', age: 30 } })
const b = stableStringify({ query: { age: 30, name: 'John' } })
expect(a).toBe(b)
})

it('normalizes $or array order', () => {
const a = stableStringify({
query: { $or: [{ name: 'John' }, { name: 'Jane' }] },
})
const b = stableStringify({
query: { $or: [{ name: 'Jane' }, { name: 'John' }] },
})
expect(a).toBe(b)
})

it('normalizes $in array order', () => {
const a = stableStringify({ query: { name: { $in: ['John', 'Jane'] } } })
const b = stableStringify({ query: { name: { $in: ['Jane', 'John'] } } })
expect(a).toBe(b)
})

it('distinguishes different queries', () => {
expect(stableStringify({ query: { name: 'John' } })).not.toBe(
stableStringify({ query: { name: 'Jane' } }),
)
})

it('handles params without a query', () => {
expect(() =>
stableStringify({ provider: 'rest', authenticated: true }),
).not.toThrow()
expect(stableStringify({ provider: 'rest' })).toBe(
stableStringify({ provider: 'rest' }),
)
})

it('throws on non-JSON (function) param values', () => {
expect(() => stableStringify({ fn: () => 1 })).toThrow(
'Cannot stringify non JSON value',
)
})

it('produces a deterministic string for nested objects', () => {
const a = stableStringify({
query: { user: { id: 1, role: 'admin' }, age: { $gt: 18 } },
})
const b = stableStringify({
query: { age: { $gt: 18 }, user: { role: 'admin', id: 1 } },
})
expect(a).toBe(b)
})
})
23 changes: 5 additions & 18 deletions src/hooks/cache/cache-utils.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,17 @@
import isObject from 'lodash/isObject.js'
import { sortQueryProperties } from '../../utils/sort-query-properties/sort-query-properties.util.js'

export { sortQueryProperties }

export const stableStringify = (obj: Record<string, any>) => {
if (obj.query) {
obj = { ...obj, query: sortQueryProperties(obj.query) }
}
// Canonicalize the whole params object once (recursive key-sort + operator-array
// sort). The JSON.stringify pass then only needs to reject non-JSON values
// instead of re-sorting and re-allocating every node.
const normalized = sortQueryProperties(obj as any)

return JSON.stringify(obj, (key, value) => {
return JSON.stringify(normalized, (_key, value) => {
if (typeof value === 'function') {
throw new Error('Cannot stringify non JSON value')
}

if (isObject(value)) {
return Object.keys(value)
.sort()
.reduce(
(result, key) => {
result[key] = (value as any)[key]
return result
},
{} as Record<string, any>,
)
}

return value
})
}
173 changes: 173 additions & 0 deletions src/hooks/cache/cache.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,3 +974,176 @@ describe('cache hook with custom serialize', () => {
).toExtend<AroundHookFunction<App, MemoryService<Item>>>()
})
})

/**
* Promise-based cache (Redis-like). Every method returns a Promise, so the hook
* MUST `await` the underlying `map.get`/`map.set`. `keys()` stays synchronous to
* match the `Cache` contract used by the invalidation loop.
*/
class AsyncCache {
private map = new Map<string, any>()
async get(key: string) {
return this.map.get(key)
}
async set(key: string, value: any) {
this.map.set(key, value)
return value
}
async delete(key: string) {
return this.map.delete(key)
}
async clear() {
this.map.clear()
}
keys() {
return this.map.keys()
}
}

describe('cache hook with an async (Promise-based) cache', () => {
// Regression for: get() must await map.get. Without the await, an async cache
// returns a (truthy) pending Promise, so a cold cache reports a "hit" instead of
// a "miss" (the value only survives by accident via fast-copy's thenable chaining).
it('reports a real miss/hit on a cold async cache', async () => {
const logger = vi.fn()
const { usersService, before } = setup({
map: new AsyncCache(),
transformParams: (params) => params,
logger,
})

await usersService.create({ id: 1, name: 'John' })
logger.mockClear()

let item = await usersService.get(1)
expect(item).toEqual({ id: 1, name: 'John' })
expect(before.get).toHaveBeenCalledTimes(1)
// cold cache -> first event MUST be a miss, not a hit
expect(logger.mock.calls[0][0]).toMatchObject({
type: 'miss',
method: 'get',
})

logger.mockClear()
item = await usersService.get(1)
expect(item).toEqual({ id: 1, name: 'John' })
expect(before.get).toHaveBeenCalledTimes(1)
expect(logger.mock.calls[0][0]).toMatchObject({
type: 'hit',
method: 'get',
})
})

it('invalidates an async cache on patch', async () => {
const { usersService, before } = setup({
map: new AsyncCache(),
transformParams: (params) => params,
})

await usersService.create({ id: 1, name: 'John' })
await usersService.find()
expect(before.find).toHaveBeenCalledTimes(1)

await usersService.patch(1, { name: 'John Doe' })
const items = await usersService.find()
expect(items.data).toEqual([{ id: 1, name: 'John Doe' }])
expect(before.find).toHaveBeenCalledTimes(2)
})
})

describe('cache hook clone option', () => {
it('clone: false skips cloning and serves the cached reference', async () => {
const map = new Map()
const { usersService } = setup({
map,
transformParams: (params) => params,
clone: false,
})

await usersService.create({ id: 1, name: 'John' })
const a = await usersService.get(1)
const b = await usersService.get(1)
// with cloning disabled, repeated hits return the same cached object
expect(a).toBe(b)
})

it('accepts a custom clone function', async () => {
const cloneFn = vi.fn((v: any) => structuredClone(v))
const { usersService } = setup({
map: new Map(),
transformParams: (params) => params,
clone: cloneFn as any,
})

await usersService.create({ id: 1, name: 'John' })
await usersService.get(1)
await usersService.get(1)
expect(cloneFn).toHaveBeenCalled()
})
})

describe('cache hook as an around hook', () => {
const setupAround = (options: CacheOptions) => {
const app = feathers<{ users: MemoryService }>()
app.use(
'users',
new MemoryService({
id: 'id',
paginate: { default: 10, max: 50 },
multi: true,
}),
)
const usersService = app.service('users')
const cacheHook = cache(options)
usersService.hooks({ around: { all: [cacheHook] } })
return { usersService }
}

it('serves get/find from cache and invalidates on mutation', async () => {
const logger = vi.fn()
const { usersService } = setupAround({
map: new Map(),
transformParams: (params) => params,
logger,
})

await usersService.create({ id: 1, name: 'John' })
logger.mockClear()

// miss -> set
let item = await usersService.get(1)
expect(item).toEqual({ id: 1, name: 'John' })
expect(logger.mock.calls.map((c) => c[0].type)).toEqual(['miss', 'set'])

logger.mockClear()
// hit (still re-set after)
item = await usersService.get(1)
expect(item).toEqual({ id: 1, name: 'John' })
expect(logger.mock.calls[0][0]).toMatchObject({
type: 'hit',
method: 'get',
})

logger.mockClear()
// mutation invalidates the get cache
await usersService.patch(1, { name: 'John Doe' })
expect(logger.mock.calls.some((c) => c[0].type === 'invalidate')).toBe(true)

item = await usersService.get(1)
expect(item).toEqual({ id: 1, name: 'John Doe' })
})

it('calls the service method exactly once on a miss', async () => {
const { usersService } = setupAround({
map: new Map(),
transformParams: (params) => params,
})

await usersService.create({ id: 1, name: 'John' })
const getSpy = vi.spyOn(usersService, 'get')

await usersService.get(1)
await usersService.get(1) // served from cache, but get() facade still invoked once each
expect(getSpy).toHaveBeenCalledTimes(2)
})
})
Loading
Loading