From d48fb035b421da0b702da5b73f97aead2bf1e352 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 23 Jun 2025 18:17:23 +0200 Subject: [PATCH] Remove type from Api serialisation of ClientMap Move the logic that converts the EntityClass of a map to a string and then back into the class to the payload plugin in order to avoid a circular dependency where the ClientMap needs to know the entity classes and the entity classes needs to know the ClientMap. The only place that doesn't know the type of the entities stored in the client map is the payload reviver, so it makes sense to keep this logic contained to the payload plugin. --- plugins/payload-client-map.ts | 13 ++++- stores/users.ts | 10 +--- utils/client-map.nuxt.test.ts | 49 +++++++++---------- utils/client-map.ts | 89 +++++++++++++---------------------- 4 files changed, 70 insertions(+), 91 deletions(-) diff --git a/plugins/payload-client-map.ts b/plugins/payload-client-map.ts index c5a223f..432cf86 100644 --- a/plugins/payload-client-map.ts +++ b/plugins/payload-client-map.ts @@ -1,4 +1,10 @@ import { Info } from "~/shared/utils/luxon"; +import { ClientEntityNew } from "~/utils/client-user"; + +const typeMap: Record> = { + "user": ClientUser, +}; +const classMap = new Map(Object.entries(typeMap).map(([k, v]) => [v, k])); export default definePayloadPlugin(() => { definePayloadReducer( @@ -7,8 +13,10 @@ export default definePayloadPlugin(() => { if (!(data instanceof ClientMap)) { return; } + const type = classMap.get(data.EntityClass)!; const accountStore = useAccountStore(); return { + type, timezone: accountStore.activeTimezone, locale: accountStore.activeLocale, api: data.toApi(false), @@ -17,9 +25,10 @@ export default definePayloadPlugin(() => { ); definePayloadReviver( "ClientMap", - ({ timezone, locale, api }) => { + ({ type, timezone, locale, api }) => { + const EntityClass = typeMap[type]; const zone = Info.normalizeZone(timezone); - return ClientMap.fromApi(api, { zone, locale }) + return ClientMap.fromApi(EntityClass, api, { zone, locale }) }, ); }); diff --git a/stores/users.ts b/stores/users.ts index 8d50df4..11c17c3 100644 --- a/stores/users.ts +++ b/stores/users.ts @@ -33,10 +33,7 @@ export const useUsersStore = defineStore("users", () => { const promise = (async () => { try { const apiUsers = await requestFetch("/api/users", { signal: controller.signal }); - state.users.value.apiUpdate({ - type: "user", - entities: apiUsers, - }, { zone, locale }); + state.users.value.apiUpdate(apiUsers, { zone, locale }); state.pendingSync.value = undefined; state.fetched.value = true; return state.users; @@ -70,10 +67,7 @@ export const useUsersStore = defineStore("users", () => { console.log("appyling", event.data) const zone = Info.normalizeZone(accountStore.activeTimezone); const locale = accountStore.activeLocale; - state.users.value.apiUpdate({ - type: "user", - entities: [event.data.data], - }, { zone, locale }); + state.users.value.apiUpdate([event.data.data], { zone, locale }); }); return { diff --git a/utils/client-map.nuxt.test.ts b/utils/client-map.nuxt.test.ts index 5ec141e..426b895 100644 --- a/utils/client-map.nuxt.test.ts +++ b/utils/client-map.nuxt.test.ts @@ -27,34 +27,31 @@ function fixtureClientMap() { ); } -function fixtureApiMap(): ApiMap { - return { - type: "user", - entities: [ - { - id: 1, - updatedAt: nowIso, - name: "A", - type: "regular", - }, - { - id: 2, - updatedAt: nowIso, - name: "B", - type: "regular", - }, - { - id: 5, - updatedAt: nowIso, - deleted: true, - }, - ], - }; +function fixtureApiMap(): ApiUser[] { + return [ + { + id: 1, + updatedAt: nowIso, + name: "A", + type: "regular", + }, + { + id: 2, + updatedAt: nowIso, + name: "B", + type: "regular", + }, + { + id: 5, + updatedAt: nowIso, + deleted: true, + }, + ]; } describe("class ClientMap", () => { test("load from api", () => { - const map = ClientMap.fromApi(fixtureApiMap(), { zone, locale }) + const map = ClientMap.fromApi(ClientUser, fixtureApiMap(), { zone, locale }) expect(map).toStrictEqual(fixtureClientMap()); }); @@ -202,7 +199,7 @@ describe("class ClientMap", () => { } else { throw new Error(`Unknown action pattern ${action}`) } - map.apiUpdate({ type: "user", entities: [update] }, { zone, locale }); + map.apiUpdate([update], { zone, locale }); // Check const expectedUser = userFromPattern(expectedPattern, useSameTimestamp); const expectedTomb = tombFromPattern(expectedPattern, useSameTimestamp); @@ -243,7 +240,7 @@ describe("class ClientMap", () => { } else { throw new Error(`Unknown action pattern ${action}`) } - map.apiUpdate({ type: "user", entities: [update] }, { zone, locale }); + map.apiUpdate([update], { zone, locale }); // Check const expectedUser = userFromPattern(startPattern, useSameTimestamp); const expectedTomb = tombFromPattern(startPattern, useSameTimestamp); diff --git a/utils/client-map.ts b/utils/client-map.ts index 32f1054..72f0a58 100644 --- a/utils/client-map.ts +++ b/utils/client-map.ts @@ -2,23 +2,11 @@ import { type Entity, type EntityLiving, type Id, type Living } from "~/shared/t import { DateTime, Zone } from "~/shared/utils/luxon"; import { ClientEntityNew } from "~/utils/client-user"; -export interface ApiMap { - type: string, - entities: T[] -} - -interface EntityClass { - name: string, - type: string, +export interface EntityClass { fromApi(api: EntityLiving, opts: { zone: Zone, locale: string }): T, } - export class ClientMap { - static typeMap: Record> = { - user: ClientUser, - }; - constructor( public EntityClass: EntityClass, public map: Map, @@ -60,27 +48,24 @@ export class ClientMap { } } - static fromApi( - api: ApiMap, + static fromApi( + EntityClass: EntityClass, + entities: T[], opts: { zone: Zone, locale: string }, ) { - const EntityClass = this.typeMap[api.type]; - const entities = api.entities.filter(entity => !entity.deleted) as Living[]; - const tombstones = api.entities.filter(entity => entity.deleted === true); + const living = entities.filter(entity => !entity.deleted) as Living[]; + const tombstones = entities.filter(entity => entity.deleted === true); return new this( EntityClass, - idMap(entities.map(apiEntity => EntityClass.fromApi(apiEntity, opts))), + idMap(living.map(apiEntity => EntityClass.fromApi(apiEntity, opts))), new Map(tombstones.map(tombstone => [tombstone.id, Date.parse(tombstone.updatedAt)])), ); } - apiUpdate(api: ApiMap, opts: { zone: Zone, locale: string }) { - if (api.type !== this.EntityClass.type) { - throw new Error(`ClientMap: Map of ${this.EntityClass.name} received update for ${api.type}.`); - } - const entities = api.entities.filter(entity => !entity.deleted); - const tombstones = api.entities.filter(entity => entity.deleted === true); - for (const entity of entities) { + apiUpdate(entities: Entity[], opts: { zone: Zone, locale: string }) { + const living = entities.filter(entity => !entity.deleted); + const tombstones = entities.filter(entity => entity.deleted === true); + for (const entity of living) { const tombstoneMs = this.tombstones.get(entity.id); const updatedMs = Date.parse(entity.updatedAt); if (tombstoneMs !== undefined) { @@ -120,37 +105,31 @@ export class ClientMap { } } - toApi(diff: boolean): ApiMap { + toApi(diff: boolean): Entity[] { if (!diff) { - return { - type: this.EntityClass.type, - entities: [ - ...[...this.map.values()].map(entity => entity.toApi()), - ...[...this.tombstones].map(([id, updatedMs]) => ({ - id, - updatedAt: new Date(updatedMs).toISOString(), - deleted: true as const, - })) - ], - }; + return [ + ...[...this.map.values()].map(entity => entity.toApi()), + ...[...this.tombstones].map(([id, updatedMs]) => ({ + id, + updatedAt: new Date(updatedMs).toISOString(), + deleted: true as const, + })), + ]; } - return { - type: this.EntityClass.type, - entities: [ - ...[...this.map.values()] - .filter(entity => entity.isModified() && !entity.deleted) - .map(entity => entity.toApi()) - , - ...[...this.map.values()] - .filter(entity => entity.deleted) - .map(entity => ({ - id: entity.id, - updatedAt: toIso(entity.updatedAt), - deleted: true as const, - })) - , - ], - }; + return [ + ...[...this.map.values()] + .filter(entity => entity.isModified() && !entity.deleted) + .map(entity => entity.toApi()) + , + ...[...this.map.values()] + .filter(entity => entity.deleted) + .map(entity => ({ + id: entity.id, + updatedAt: toIso(entity.updatedAt), + deleted: true as const, + })) + , + ]; } }