From dfe17ee226f595151c3ef2022c5595b671cd940f Mon Sep 17 00:00:00 2001 From: senelway Date: Thu, 25 Dec 2025 14:44:15 +0000 Subject: [PATCH 1/2] [beta.4] remove primaryKey --- README.md | 24 +-- example/request/app/api/bundle/route.ts | 23 ++- example/request/lib/store.ts | 1 - lib/reducer/index.ts | 1 - lib/reducer/storage.ts | 41 ++---- lib/reducer/types.ts | 1 - package.json | 2 +- tests/lib/index.test.ts | 1 - tests/lib/reducer/helpers.test.ts | 14 +- tests/lib/reducer/index.test.ts | 54 ++----- tests/lib/reducer/request.test.ts | 1 - tests/lib/reducer/storage.test.ts | 187 ++++++------------------ 12 files changed, 93 insertions(+), 257 deletions(-) diff --git a/README.md b/README.md index 130b2ae..07811f6 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,6 @@ export const cdeebeeSlice = factory( modules: ['history', 'listener', 'cancelation', 'storage'], fileKey: 'file', bodyKey: 'value', - primaryKey: 'id', listStrategy: { forumList: 'merge', threadList: 'replace', @@ -166,7 +165,6 @@ interface CdeebeeSettings { modules: CdeebeeModule[]; // Active modules: 'history' | 'listener' | 'storage' | 'cancelation' fileKey: string; // Key name for file uploads in FormData bodyKey: string; // Key name for request body in FormData - primaryKey: string; // Primary key field name in API responses (default: 'primaryKey') listStrategy?: CdeebeeListStrategy; // Merge strategy per list: 'merge' | 'replace' mergeWithData?: unknown; // Data to merge with every request body mergeWithHeaders?: Record; // Headers to merge with every request @@ -207,27 +205,7 @@ listStrategy: { ## API Response Format -cdeebee expects API responses in a specific format for automatic normalization: - -```typescript -{ - forumList: { - primaryKey: 'id', // The field name specified in settings.primaryKey - data: [ - { id: 1, title: 'Forum 1' }, - { id: 2, title: 'Forum 2' }, - ] - }, - threadList: { - primaryKey: 'id', - data: [ - { id: 101, title: 'Thread 1', forumID: 1 }, - ] - } -} -``` - -The library automatically normalizes this into: +cdeebee expects API responses in a normalized format where data is already organized as objects with keys representing entity IDs: ```typescript { diff --git a/example/request/app/api/bundle/route.ts b/example/request/app/api/bundle/route.ts index d0134ad..3f65945 100644 --- a/example/request/app/api/bundle/route.ts +++ b/example/request/app/api/bundle/route.ts @@ -11,19 +11,16 @@ export async function POST(request: NextRequest) { return NextResponse.json( { bundleList: { - 'data': [ - { - 'bundleID': 961, - 'timestamp': new Date().toISOString(), - 'bundle': 'test123', - }, - { - 'bundleID': 1549103, - 'timestamp': new Date().toISOString(), - 'bundle': 'test4', - }, - ], - 'primaryKey': 'bundleID', + '961': { + 'bundleID': 961, + 'timestamp': new Date().toISOString(), + 'bundle': 'test123', + }, + '1549103': { + 'bundleID': 1549103, + 'timestamp': new Date().toISOString(), + 'bundle': 'test4', + }, }, } ); diff --git a/example/request/lib/store.ts b/example/request/lib/store.ts index bdd682e..a2c85c3 100644 --- a/example/request/lib/store.ts +++ b/example/request/lib/store.ts @@ -14,7 +14,6 @@ export const cdeebeeSlice = factory( modules: ['history', 'listener', 'cancelation', 'storage'], fileKey: 'file', bodyKey: 'value', - primaryKey: 'primaryKey', listStrategy: { bundleList: 'merge', campaignList: 'replace', diff --git a/lib/reducer/index.ts b/lib/reducer/index.ts index 9b5b934..1b4e1a2 100644 --- a/lib/reducer/index.ts +++ b/lib/reducer/index.ts @@ -11,7 +11,6 @@ const initialState: CdeebeeState = { modules: ['history', 'listener', 'storage', 'cancelation'], fileKey: 'file', bodyKey: 'value', - primaryKey: 'primaryKey', listStrategy: {}, mergeWithData: {}, mergeWithHeaders: {}, diff --git a/lib/reducer/storage.ts b/lib/reducer/storage.ts index 6fecc46..e6fa2e9 100644 --- a/lib/reducer/storage.ts +++ b/lib/reducer/storage.ts @@ -1,10 +1,7 @@ import { type CdeebeeListStrategy, type CdeebeeState } from './types'; -import { hasDataProperty, hasProperty, isRecord, mergeDeepRight, omit } from './helpers'; +import { isRecord, mergeDeepRight, omit } from './helpers'; -type ResponseValue = Record & { - data?: unknown[]; - [key: string]: unknown; -}; +type ResponseValue = Record; type IResponse = Record; @@ -16,7 +13,6 @@ export function defaultNormalize( strategyList: CdeebeeListStrategy ): Record { const keyList = Object.keys(response); - const primaryKey = cdeebee.settings.primaryKey; const currentStorage = isRecord(cdeebee.storage) ? (cdeebee.storage as Record) : {}; // Start with existing storage to preserve keys not in response @@ -31,43 +27,28 @@ export function defaultNormalize( continue; } - if (hasDataProperty(responseValue) && hasProperty(responseValue, primaryKey)) { - const primaryKeyValue = responseValue[primaryKey]; - - if (typeof primaryKeyValue !== 'string') { - console.warn(`Cdeebee: Primary key "${primaryKey}" is not a string for API "${key}". Skipping normalization.`); - result[key] = responseValue; - continue; - } - - // Pre-allocate storage data object - const newStorageData: StorageData = {}; - const dataArray = responseValue.data; - const dataLength = dataArray.length; - - for (let i = 0; i < dataLength; i++) { - const element = dataArray[i]; - if (isRecord(element) && element[primaryKeyValue]) { - const elementKey = element[primaryKeyValue] as string; - newStorageData[elementKey] = element; - } - } + // Check if responseValue is already normalized (object with keys mapping to objects) + const isNormalized = isRecord(responseValue) && + Object.keys(responseValue).length > 0 && + Object.values(responseValue).every(val => isRecord(val) && !Array.isArray(val)); + if (isNormalized) { const strategy = strategyList[key as keyof T] ?? 'merge'; const existingValue = key in currentStorage ? (currentStorage[key] as StorageData) : {}; if (strategy === 'replace') { // Replace: completely replace the value - result[key] = newStorageData as ResponseValue; + result[key] = responseValue as ResponseValue; } else if (strategy === 'merge') { // Merge: merge with existing value - result[key] = mergeDeepRight(existingValue, newStorageData) as ResponseValue; + result[key] = mergeDeepRight(existingValue, responseValue as StorageData) as ResponseValue; } else { // Unknown strategy: warn and fall back to merge console.warn(`Cdeebee: Unknown strategy "${strategy}" for key "${key}". Skipping normalization.`); - result[key] = mergeDeepRight(existingValue, newStorageData) as ResponseValue; + result[key] = mergeDeepRight(existingValue, responseValue as StorageData) as ResponseValue; } } else { + // Not a normalized object, store as-is result[key] = responseValue; } } diff --git a/lib/reducer/types.ts b/lib/reducer/types.ts index 978f5da..7e9f275 100644 --- a/lib/reducer/types.ts +++ b/lib/reducer/types.ts @@ -7,7 +7,6 @@ export interface CdeebeeSettings { modules: CdeebeeModule[]; fileKey: string; bodyKey: string; - primaryKey: string; mergeWithData: unknown; mergeWithHeaders: unknown; listStrategy?: CdeebeeListStrategy; diff --git a/package.json b/package.json index 1434519..ef9f036 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@recats/cdeebee", - "version": "3.0.0-beta.3", + "version": "3.0.0-beta.4", "description": "React Redux data-logic library", "repository": "git@github.com:recats/cdeebee.git", "author": "recats", diff --git a/tests/lib/index.test.ts b/tests/lib/index.test.ts index 5b6c35e..4d33d61 100644 --- a/tests/lib/index.test.ts +++ b/tests/lib/index.test.ts @@ -16,7 +16,6 @@ describe('lib/index exports', () => { modules: [], fileKey: 'file', bodyKey: 'body', - primaryKey: 'id', mergeWithData: {}, }, storage: {}, diff --git a/tests/lib/reducer/helpers.test.ts b/tests/lib/reducer/helpers.test.ts index 02b0b82..3bca593 100644 --- a/tests/lib/reducer/helpers.test.ts +++ b/tests/lib/reducer/helpers.test.ts @@ -8,8 +8,8 @@ describe('checkModule', () => { modules: ['history', 'listener'] as CdeebeeModule[], fileKey: 'file', bodyKey: 'body', - primaryKey: 'id', mergeWithData: {}, + mergeWithHeaders: {}, listStrategy: {}, }; @@ -21,11 +21,11 @@ describe('checkModule', () => { it('should not execute result callback when module is not included in settings', () => { const settings: CdeebeeSettings = { - modules: ['history', 'listener'] as CdeebeeModule[], + modules: ['history', 'listener'], fileKey: 'file', bodyKey: 'body', - primaryKey: 'id', mergeWithData: {}, + mergeWithHeaders: {}, listStrategy: {}, }; @@ -37,11 +37,11 @@ describe('checkModule', () => { it('should work with all module types', () => { const settings: CdeebeeSettings = { - modules: ['history', 'listener', 'storage', 'cancelation'] as CdeebeeModule[], + modules: ['history', 'listener', 'storage', 'cancelation'], fileKey: 'file', bodyKey: 'body', - primaryKey: 'id', mergeWithData: {}, + mergeWithHeaders: {}, listStrategy: {}, }; @@ -56,11 +56,11 @@ describe('checkModule', () => { it('should handle empty modules array', () => { const settings: CdeebeeSettings = { - modules: [] as CdeebeeModule[], + modules: [], fileKey: 'file', bodyKey: 'body', - primaryKey: 'id', mergeWithData: {}, + mergeWithHeaders: {}, listStrategy: {}, }; diff --git a/tests/lib/reducer/index.test.ts b/tests/lib/reducer/index.test.ts index 2b076ac..b6fe8ba 100644 --- a/tests/lib/reducer/index.test.ts +++ b/tests/lib/reducer/index.test.ts @@ -33,8 +33,8 @@ describe('factory', () => { modules: ['history', 'listener', 'storage', 'cancelation'], fileKey: 'file', bodyKey: 'value', - primaryKey: 'id', mergeWithData: {}, + mergeWithHeaders: {}, listStrategy: {}, }; }); @@ -49,8 +49,8 @@ describe('factory', () => { modules: ['history'], fileKey: 'customFile', bodyKey: 'customBody', - primaryKey: 'customId', mergeWithData: { custom: 'data' }, + mergeWithHeaders: {}, listStrategy: { list: 'merge' }, }; @@ -60,7 +60,6 @@ describe('factory', () => { const state = store.getState().cdeebee as CdeebeeState>; expect(state.settings.fileKey).toBe('customFile'); expect(state.settings.bodyKey).toBe('customBody'); - expect(state.settings.primaryKey).toBe('customId'); }); it('should have correct initial state structure', () => { @@ -318,11 +317,8 @@ describe('factory', () => { const mockResponse = { userList: { - data: [ - { id: '1', name: 'John' }, - { id: '2', name: 'Jane' }, - ], - id: 'id', + '1': { id: '1', name: 'John' }, + '2': { id: '2', name: 'Jane' }, }, }; @@ -358,11 +354,8 @@ describe('factory', () => { const mockResponse = { userList: { - data: [ - { id: '1', name: 'John' }, - { id: '2', name: 'Jane' }, - ], - id: 'id', + '1': { id: '1', name: 'John' }, + '2': { id: '2', name: 'Jane' }, }, }; @@ -400,11 +393,8 @@ describe('factory', () => { // First request - initial data const firstResponse = { userList: { - data: [ - { id: '1', name: 'John' }, - { id: '3', name: 'Bob' }, - ], - id: 'id', + '1': { id: '1', name: 'John' }, + '3': { id: '3', name: 'Bob' }, }, }; @@ -420,11 +410,8 @@ describe('factory', () => { // Second request - merge new data const secondResponse = { userList: { - data: [ - { id: '1', name: 'John Updated' }, - { id: '2', name: 'Jane' }, - ], - id: 'id', + '1': { id: '1', name: 'John Updated' }, + '2': { id: '2', name: 'Jane' }, }, }; @@ -463,10 +450,7 @@ describe('factory', () => { const mockResponse = { userList: { - data: [ - { id: '1', name: 'John' }, - ], - id: 'id', + '1': { id: '1', name: 'John' }, }, }; @@ -544,8 +528,7 @@ describe('factory', () => { const mockResponse = { userList: { - data: [{ id: '1', name: 'John' }], - id: 'id', + '1': { id: '1', name: 'John' }, }, }; @@ -576,8 +559,7 @@ describe('factory', () => { // First request for userList const userListResponse = { userList: { - data: [{ id: '1', name: 'John' }], - id: 'id', + '1': { id: '1', name: 'John' }, }, }; @@ -589,8 +571,7 @@ describe('factory', () => { const postListResponse = { postList: { - data: [{ id: '1', title: 'Post 1' }], - id: 'id', + '1': { id: '1', title: 'Post 1' }, }, }; @@ -670,11 +651,8 @@ describe('factory', () => { const mockResponse = { userList: { - data: [ - { id: '1', name: 'John' }, - { id: '2', name: 'Jane' }, - ], - id: 'id', + '1': { id: '1', name: 'John' }, + '2': { id: '2', name: 'Jane' }, }, }; diff --git a/tests/lib/reducer/request.test.ts b/tests/lib/reducer/request.test.ts index 1ee78a0..6428b54 100644 --- a/tests/lib/reducer/request.test.ts +++ b/tests/lib/reducer/request.test.ts @@ -20,7 +20,6 @@ describe('request', () => { modules: ['history', 'listener', 'cancelation'], fileKey: 'file', bodyKey: 'value', - primaryKey: 'id', mergeWithData: { defaultKey: 'defaultValue' }, listStrategy: {}, }; diff --git a/tests/lib/reducer/storage.test.ts b/tests/lib/reducer/storage.test.ts index 091f405..031a653 100644 --- a/tests/lib/reducer/storage.test.ts +++ b/tests/lib/reducer/storage.test.ts @@ -2,10 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { defaultNormalize } from '../../../lib/reducer/storage'; import { type CdeebeeState, type CdeebeeListStrategy } from '../../../lib/reducer/types'; -type IResponse = Record & { - data?: unknown[]; - [key: string]: unknown; -}>; +type IResponse = Record>; describe('defaultNormalize', () => { let mockCdeebee: CdeebeeState; @@ -19,8 +16,8 @@ describe('defaultNormalize', () => { modules: ['storage'], fileKey: 'file', bodyKey: 'body', - primaryKey: 'id', mergeWithData: {}, + mergeWithHeaders: {}, listStrategy: {}, }, storage: {}, @@ -37,14 +34,11 @@ describe('defaultNormalize', () => { }); describe('replace strategy', () => { - it('should normalize response with replace strategy', () => { + it('should use already normalized response with replace strategy', () => { const response = { userList: { - data: [ - { id: '1', name: 'John' }, - { id: '2', name: 'Jane' }, - ], - id: 'id', + '1': { id: '1', name: 'John' }, + '2': { id: '2', name: 'Jane' }, }, }; @@ -70,11 +64,8 @@ describe('defaultNormalize', () => { const response = { userList: { - data: [ - { id: '1', name: 'New John' }, - { id: '2', name: 'Jane' }, - ], - id: 'id', + '1': { id: '1', name: 'New John' }, + '2': { id: '2', name: 'Jane' }, }, }; @@ -103,11 +94,8 @@ describe('defaultNormalize', () => { const response = { userList: { - data: [ - { id: '1', name: 'John Updated', age: 31 }, - { id: '2', name: 'Jane' }, - ], - id: 'id', + '1': { id: '1', name: 'John Updated', age: 31 }, + '2': { id: '2', name: 'Jane' }, }, }; @@ -130,11 +118,8 @@ describe('defaultNormalize', () => { it('should create new storage when no existing state', () => { const response = { userList: { - data: [ - { id: '1', name: 'John' }, - { id: '2', name: 'Jane' }, - ], - id: 'id', + '1': { id: '1', name: 'John' }, + '2': { id: '2', name: 'Jane' }, }, }; @@ -154,16 +139,20 @@ describe('defaultNormalize', () => { describe('unknown strategy', () => { it('should fall back to merge strategy for unknown strategy', () => { const response = { - userList: { data: [ { id: '1', name: 'John' }, ], id: 'id', }, + userList: { + '1': { id: '1', name: 'John' }, + }, }; const strategyList: CdeebeeListStrategy = { - userList: 'unknown', + userList: 'unknown' as any, }; const result = defaultNormalize(mockCdeebee, response, strategyList); - expect(result.userList).toEqual({ '1': { id: '1', name: 'John' }, }); + expect(result.userList).toEqual({ + '1': { id: '1', name: 'John' }, + }); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Unknown strategy') ); @@ -171,31 +160,10 @@ describe('defaultNormalize', () => { }); describe('edge cases', () => { - it('should skip normalization when primaryKey is not a string', () => { - const response = { - userList: { - data: [ { id: '1', name: 'John' }, ], - id: 123, // not a string - }, - }; - - const strategyList: CdeebeeListStrategy = { - userList: 'replace', - }; - - const result = defaultNormalize(mockCdeebee, response, strategyList); - - expect(result.userList).toEqual(response.userList); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Primary key') - ); - }); - it('should remove keys with null values', () => { const response = { userList: { - data: [{ id: '1', name: 'John' }], - id: 'id', + '1': { id: '1', name: 'John' }, }, invalid: null, } as unknown as IResponse; @@ -213,8 +181,7 @@ describe('defaultNormalize', () => { it('should remove keys with undefined values', () => { const response = { userList: { - data: [{ id: '1', name: 'John' }], - id: 'id', + '1': { id: '1', name: 'John' }, }, invalid: undefined, } as unknown as IResponse; @@ -232,8 +199,7 @@ describe('defaultNormalize', () => { it('should remove keys with string values', () => { const response = { userList: { - data: [{ id: '1', name: 'John' }], - id: 'id', + '1': { id: '1', name: 'John' }, }, message: 'some string', } as unknown as IResponse; @@ -248,49 +214,14 @@ describe('defaultNormalize', () => { expect(result).not.toHaveProperty('message'); }); - it('should skip normalization when response value has no data property', () => { - const response = { - userList: { - id: 'id', - // no data property - }, - }; - - const strategyList: CdeebeeListStrategy = { - userList: 'replace', - }; - - const result = defaultNormalize(mockCdeebee, response, strategyList); - - expect(result.userList).toEqual(response.userList); - }); - - it('should skip normalization when response value has no primaryKey property', () => { + it('should handle non-normalized objects as-is', () => { const response = { userList: { - data: [{ id: '1', name: 'John' }], - // no id property matching primaryKey + '1': { id: '1', name: 'John' }, }, - }; - - const strategyList: CdeebeeListStrategy = { - userList: 'replace', - }; - - const result = defaultNormalize(mockCdeebee, response, strategyList); - - expect(result.userList).toEqual(response.userList); - }); - - it('should skip elements without primaryKey value', () => { - const response = { - userList: { - data: [ - { id: '1', name: 'John' }, - { name: 'Jane' }, // no id - { id: '3', name: 'Bob' }, - ], - id: 'id', + config: { + theme: 'dark', + language: 'en', }, }; @@ -302,17 +233,16 @@ describe('defaultNormalize', () => { expect(result.userList).toEqual({ '1': { id: '1', name: 'John' }, - '3': { id: '3', name: 'Bob' }, }); - expect(result.userList).not.toHaveProperty('undefined'); + expect(result.config).toEqual({ + theme: 'dark', + language: 'en', + }); }); - it('should handle empty data array', () => { + it('should handle empty normalized object', () => { const response = { - userList: { - data: [], - id: 'id', - }, + userList: {}, }; const strategyList: CdeebeeListStrategy = { @@ -338,12 +268,10 @@ describe('defaultNormalize', () => { const response = { userList: { - data: [{ id: '1', name: 'New John' }], - id: 'id', + '1': { id: '1', name: 'New John' }, }, postList: { - data: [{ id: '2', title: 'New Post' }], - id: 'id', + '2': { id: '2', title: 'New Post' }, }, }; @@ -363,7 +291,9 @@ describe('defaultNormalize', () => { it('should handle keys without strategy (undefined strategy)', () => { const response = { - userList: { data: [{ id: '1', name: 'John' }], id: 'id', }, + userList: { + '1': { id: '1', name: 'John' }, + }, }; const strategyList: CdeebeeListStrategy = {}; @@ -377,17 +307,11 @@ describe('defaultNormalize', () => { }); }); - describe('custom primaryKey', () => { - it('should use custom primaryKey from settings', () => { - mockCdeebee.settings.primaryKey = 'uuid'; - + describe('complex data structures', () => { + it('should handle nested objects in normalized data', () => { const response = { userList: { - data: [ - { uuid: 'user-1', name: 'John' }, - { uuid: 'user-2', name: 'Jane' }, - ], - uuid: 'uuid', + '1': { id: '1', name: 'John', profile: { age: 30, city: 'NYC' } }, }, }; @@ -398,37 +322,21 @@ describe('defaultNormalize', () => { const result = defaultNormalize(mockCdeebee, response, strategyList); expect(result.userList).toEqual({ - 'user-1': { uuid: 'user-1', name: 'John' }, - 'user-2': { uuid: 'user-2', name: 'Jane' }, + '1': { id: '1', name: 'John', profile: { age: 30, city: 'NYC' } }, }); }); - }); - - describe('complex data structures', () => { - it('should handle nested objects in data elements', () => { - const response = { - userList: { - data: [ { id: '1', name: 'John', profile: { age: 30, city: 'NYC', }, }, ], - id: 'id', - }, - }; - - const strategyList: CdeebeeListStrategy = { - userList: 'replace', - }; - - const result = defaultNormalize(mockCdeebee, response, strategyList); - - expect(result.userList).toEqual({ '1': { id: '1', name: 'John', profile: { age: 30, city: 'NYC', }, } }); - }); it('should handle deep merge correctly', () => { mockCdeebee.storage = { - userList: { '1': { id: '1', name: 'John', profile: { age: 30, city: 'NYC', }, } }, + userList: { + '1': { id: '1', name: 'John', profile: { age: 30, city: 'NYC' } }, + }, }; const response = { - userList: { data: [ { id: '1', name: 'John Updated', profile: { age: 31, }, }, ], id: 'id' }, + userList: { + '1': { id: '1', name: 'John Updated', profile: { age: 31 } }, + }, }; const strategyList: CdeebeeListStrategy = { @@ -446,4 +354,3 @@ describe('defaultNormalize', () => { }); }); }); - From ea0387c1e1e41bac5ebc62ce32299c7abc154374 Mon Sep 17 00:00:00 2001 From: senelway Date: Thu, 25 Dec 2025 14:46:16 +0000 Subject: [PATCH 2/2] wip --- lib/reducer/storage.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/reducer/storage.ts b/lib/reducer/storage.ts index e6fa2e9..91ce1b8 100644 --- a/lib/reducer/storage.ts +++ b/lib/reducer/storage.ts @@ -27,10 +27,7 @@ export function defaultNormalize( continue; } - // Check if responseValue is already normalized (object with keys mapping to objects) - const isNormalized = isRecord(responseValue) && - Object.keys(responseValue).length > 0 && - Object.values(responseValue).every(val => isRecord(val) && !Array.isArray(val)); + const isNormalized = isRecord(responseValue) && Object.keys(responseValue).length > 0; if (isNormalized) { const strategy = strategyList[key as keyof T] ?? 'merge';