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