From 6336ccdb96c173bd5053bb1dea8f88e951988418 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 23 Jun 2025 00:03:37 +0200 Subject: [PATCH] Implement mutable mapping for client entities Create a simple Map like class for storing and keeping track of client entities that are synced from the server and have local editable state. This will form the basis for storing entities on the client and should replace the immutable concept used be the ClientSchedule class. --- plugins/payload-client-map.ts | 25 +++ utils/client-map.nuxt.test.ts | 286 ++++++++++++++++++++++++++++++++++ utils/client-map.ts | 156 +++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 plugins/payload-client-map.ts create mode 100644 utils/client-map.nuxt.test.ts create mode 100644 utils/client-map.ts diff --git a/plugins/payload-client-map.ts b/plugins/payload-client-map.ts new file mode 100644 index 0000000..c5a223f --- /dev/null +++ b/plugins/payload-client-map.ts @@ -0,0 +1,25 @@ +import { Info } from "~/shared/utils/luxon"; + +export default definePayloadPlugin(() => { + definePayloadReducer( + "ClientMap", + data => { + if (!(data instanceof ClientMap)) { + return; + } + const accountStore = useAccountStore(); + return { + timezone: accountStore.activeTimezone, + locale: accountStore.activeLocale, + api: data.toApi(false), + }; + }, + ); + definePayloadReviver( + "ClientMap", + ({ timezone, locale, api }) => { + const zone = Info.normalizeZone(timezone); + return ClientMap.fromApi(api, { zone, locale }) + }, + ); +}); diff --git a/utils/client-map.nuxt.test.ts b/utils/client-map.nuxt.test.ts new file mode 100644 index 0000000..5ec141e --- /dev/null +++ b/utils/client-map.nuxt.test.ts @@ -0,0 +1,286 @@ +import { ClientMap, type ApiMap } from "~/utils/client-map"; +import { ClientEntityNew, ClientUser } from "~/utils/client-user"; +import { describe, expect, test } from "vitest"; +import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon"; +import type { ApiUser } from "~/shared/types/api"; + +const locale = "en-GB"; +const now = DateTime.now().setLocale(locale); +const earlier = now.minus({ minutes: 2 }); +const later = now.plus({ minutes: 2 }); +const zone = now.zone; +const earlierIso = earlier.setZone(FixedOffsetZone.utcInstance).toISO(); +const nowIso = now.setZone(FixedOffsetZone.utcInstance).toISO(); +const laterIso = later.setZone(FixedOffsetZone.utcInstance).toISO(); + +function fixtureClientMap() { + const userA = new ClientUser(1, now, false, "A", "regular"); + const userB = new ClientUser(2, now, false, "B", "regular"); + + return new ClientMap( + ClientUser, + new Map([ + [userA.id, userA], + [userB.id, userB], + ]), + new Map([[5, now.toMillis()]]), + ); +} + +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, + }, + ], + }; +} + +describe("class ClientMap", () => { + test("load from api", () => { + const map = ClientMap.fromApi(fixtureApiMap(), { zone, locale }) + expect(map).toStrictEqual(fixtureClientMap()); + }); + + test("save to api", () => { + const map = fixtureClientMap(); + expect(map.toApi(false)).toEqual(fixtureApiMap()) + }); + + // State: - = not deleted, x = deleted + // Timestamp: n = new, - = now, l = later, + + + function userFromPattern(pattern: string, laterIsNow: boolean) { + const [ + serverState, + serverTs, + serverName, + clientState, + clientTs, + clientName, + ] = pattern; + if (serverName === "-" && clientName === "-") + return undefined; + + const timestamps: Record = { + "-": now, + "l": laterIsNow ? now : later, + "n": DateTime.fromMillis(ClientEntityNew.newEntityMillis), + }; + const user = new ClientUser(1, now, false, "", "regular"); + user.serverUpdatedAt = timestamps[serverTs]; + user.serverDeleted = serverState === "x"; + user.serverName = serverName.toUpperCase(); + user.updatedAt = timestamps[clientTs]; + user.deleted = clientState === "x"; + user.name = clientName.toUpperCase(); + return user; + } + + const updatePatterns = [ + // User pattern: + // --a--a + // |||||\- Client name: a, b, or - for no entity + // ||||\- Client timestamp: n for new, - for now, and l for later + // |||\- Client state: - not deleted, x marked as deleted or if no entity a tombstone + // ||\- Server name: a, b, or - for no entity + // |\- Server timestamp: n for new, - for now, and l for later + // \- Server state: - not deleted, x marked as deleted or if no entity a tombstone + + // inital-user action expected-user + // Update with entity + "--a--a A -la-la", + "--b--a A -la-la", + "x-a--a A -la-la", + "x-b--a A -la-la", + "-na-na A -la-la", + "-nb-na A -la-la", + "--ax-a A -lax-a", + "--bx-a A -lax-a", + "x-ax-a A -lax-a", + "x-bx-a A -lax-a", + "-naxna A -laxna", + "-nbxna A -laxna", + + "--a--b A -la--b", + "--b--b A -la-la", + "x-a--b A -la--b", + "x-b--b A -la--b", + "-na-nb A -la-nb", + "-nb-nb A -la-nb", + "--ax-b A -lax-b", + "--bx-b A -lax-b", + "x-ax-b A -lax-b", + "x-bx-b A -lax-b", + "-naxnb A -laxnb", + "-nbxnb A -laxnb", + + "x----- A -la-la", + "------ A -la-la", + + // Update with Tombstone + "--a--a T xl----", + "--b--a T xlb--a", + "x-a--a T xla--a", + "x-b--a T xlb--a", + "-na-na T xla-na", + "-nb-na T xlb-na", + "--ax-a T xl----", + "--bx-a T xl----", + "x-ax-a T xl----", + "x-bx-a T xl----", + "-naxna T xl----", + "-nbxna T xl----", + + "--a--b T xla--b", + "--b--b T xl----", + "x-a--b T xla--b", + "x-b--b T xlb--b", + "-na-nb T xla-nb", + "-nb-nb T xlb-nb", + "--ax-b T xl----", + "--bx-b T xl----", + "x-ax-b T xl----", + "x-bx-b T xl----", + "-naxnb T xl----", + "-nbxnb T xl----", + + "x----- T xl----", + "------ T xl----", + ]; + function tombFromPattern(pattern: string, laterIsNow: boolean) { + if (pattern[0] === "x") { + if (pattern[1] === "-") + return now.toMillis(); + if (pattern[1] === "l") + return (laterIsNow ? now : later).toMillis(); + } + } + for (const pattern of updatePatterns) { + test(`apply diff pattern ${pattern}`, () => { + for (const useSameTimestamp of [false, true]) { + const map = new ClientMap(ClientUser, new Map(), new Map()); + const [startPattern, action, expectedPattern] = pattern.split(" "); + const user = userFromPattern(startPattern, useSameTimestamp); + const tomb = tombFromPattern(startPattern, useSameTimestamp); + if (user) + map.add(user); + if (tomb) + map.tombstones.set(1, tomb); + // Update + let update: ApiUser; + if (action === "A") { + update = { + id: 1, + updatedAt: useSameTimestamp ? nowIso : laterIso, + name: "A", + type: "regular", + } + } else if (action === "T") { + update = { + id: 1, + updatedAt: useSameTimestamp ? nowIso : laterIso, + deleted: true, + } + } else { + throw new Error(`Unknown action pattern ${action}`) + } + map.apiUpdate({ type: "user", entities: [update] }, { zone, locale }); + // Check + const expectedUser = userFromPattern(expectedPattern, useSameTimestamp); + const expectedTomb = tombFromPattern(expectedPattern, useSameTimestamp); + expect(map.get(1)).toEqual(expectedUser); + expect(map.tombstones.get(1)).toEqual(expectedTomb); + } + }); + } + + for (const pattern of updatePatterns) { + if (pattern[1] === "n" || pattern[2] === "-") + continue; // Skip tests involving new or missing entities + test(`ignore diff pattern ${pattern.slice(0, 8)}`, () => { + for (const useSameTimestamp of [false, true]) { + const map = new ClientMap(ClientUser, new Map(), new Map()); + const [startPattern, action] = pattern.split(" "); + const user = userFromPattern(startPattern, useSameTimestamp); + const tomb = tombFromPattern(startPattern, useSameTimestamp); + if (user) + map.add(user); + if (tomb) + map.tombstones.set(1, tomb); + // Update + let update: ApiUser; + if (action === "A") { + update = { + id: 1, + updatedAt: earlierIso, + name: "A", + type: "regular", + } + } else if (action === "T") { + update = { + id: 1, + updatedAt: earlierIso, + deleted: true, + } + } else { + throw new Error(`Unknown action pattern ${action}`) + } + map.apiUpdate({ type: "user", entities: [update] }, { zone, locale }); + // Check + const expectedUser = userFromPattern(startPattern, useSameTimestamp); + const expectedTomb = tombFromPattern(startPattern, useSameTimestamp); + expect(map.get(1)).toEqual(expectedUser); + expect(map.tombstones.get(1)).toEqual(expectedTomb); + } + }); + } + + test("create", () => { + const map = fixtureClientMap(); + const entity = ClientUser.create(3, "New user", "regular", { zone, locale }); + expect(map.isModified()).toBe(false); + // Create + map.add(entity); + // Check + expect(map.isModified()).toBe(true); + expect(map.get(entity.id)!.isModified()).toBe(true); + }); + test("edit", () => { + const map = fixtureClientMap(); + expect(map.isModified()).toBe(false); + expect(map.get(1)!.isModified()).toBe(false); + // Edit + map.get(1)!.name = "Modified User"; + // Check + expect(map.isModified()).toBe(true); + expect(map.get(1)!.isModified()).toBe(true); + }); + test("delete", () => { + const map = fixtureClientMap(); + expect(map.isModified()).toBe(false); + expect(map.get(1)!.isModified()).toBe(false); + // Delete + map.get(1)!.deleted = true; + // Check + expect(map.isModified()).toBe(true); + expect(map.get(1)!.isModified()).toBe(true); + }); +}); diff --git a/utils/client-map.ts b/utils/client-map.ts new file mode 100644 index 0000000..32f1054 --- /dev/null +++ b/utils/client-map.ts @@ -0,0 +1,156 @@ +import { type Entity, type EntityLiving, type Id, type Living } from "~/shared/types/common"; +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, + fromApi(api: EntityLiving, opts: { zone: Zone, locale: string }): T, +} + + +export class ClientMap { + static typeMap: Record> = { + user: ClientUser, + }; + + constructor( + public EntityClass: EntityClass, + public map: Map, + public tombstones: Map, + ) { + } + + get(id: Id) { + return this.map.get(id); + } + + values() { + return this.map.values(); + } + + isModified() { + return [...this.map.values()].some(entity => entity.isModified()); + } + + add(entity: T) { + if (this.map.has(entity.id)) { + throw new Error("ClientMap.add: Entity already exists"); + } + this.map.set(entity.id, entity); + } + + discard(id: Id) { + const entity = this.map.get(id); + if (!entity) { + throw new Error("ClientMap.discard: entity does not exist."); + } + if ( + this.tombstones.has(id) + || entity.isNew() + ) { + this.map.delete(id); + } else { + entity.discard(); + } + } + + static fromApi( + api: ApiMap, + 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); + return new this( + EntityClass, + idMap(entities.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) { + const tombstoneMs = this.tombstones.get(entity.id); + const updatedMs = Date.parse(entity.updatedAt); + if (tombstoneMs !== undefined) { + if (tombstoneMs > updatedMs) { + continue; + } + this.tombstones.delete(entity.id); + } + + const stored = this.map.get(entity.id); + if (stored) { + if (stored.updatedAt.toMillis() > updatedMs) { + continue; + } + stored.apiUpdate(entity, opts); + } else { + this.map.set(entity.id, this.EntityClass.fromApi(entity, opts)) + } + } + for (const tombstone of tombstones) { + const updatedMs = Date.parse(tombstone.updatedAt); + const tombstoneMs = this.tombstones.get(tombstone.id); + const stored = this.map.get(tombstone.id); + if ( + stored && stored.serverUpdatedAt.toMillis() > updatedMs + || tombstoneMs && tombstoneMs > updatedMs + ) { + continue; + } + this.tombstones.set(tombstone.id, updatedMs); + if (stored && stored.isModified() && !stored.deleted) { + stored.serverUpdatedAt = DateTime.fromISO(tombstone.updatedAt, opts); + stored.serverDeleted = true; + } else if (stored) { + this.map.delete(tombstone.id); + } + } + } + + toApi(diff: boolean): ApiMap { + 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 { + 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, + })) + , + ], + }; + } +}