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, + })) + , + ], + }; + } +}