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.
135 lines
3.5 KiB
TypeScript
135 lines
3.5 KiB
TypeScript
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 EntityClass<T extends ClientEntityNew> {
|
|
fromApi(api: EntityLiving, opts: { zone: Zone, locale: string }): T,
|
|
}
|
|
|
|
export class ClientMap<T extends ClientEntityNew> {
|
|
constructor(
|
|
public EntityClass: EntityClass<T>,
|
|
public map: Map<Id, T>,
|
|
public tombstones: Map<Id, number>,
|
|
) {
|
|
}
|
|
|
|
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<T extends Entity, U extends ClientEntityNew>(
|
|
EntityClass: EntityClass<U>,
|
|
entities: T[],
|
|
opts: { zone: Zone, locale: string },
|
|
) {
|
|
const living = entities.filter(entity => !entity.deleted) as Living<T>[];
|
|
const tombstones = entities.filter(entity => entity.deleted === true);
|
|
return new this(
|
|
EntityClass,
|
|
idMap(living.map(apiEntity => EntityClass.fromApi(apiEntity, opts))),
|
|
new Map(tombstones.map(tombstone => [tombstone.id, Date.parse(tombstone.updatedAt)])),
|
|
);
|
|
}
|
|
|
|
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) {
|
|
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): Entity[] {
|
|
if (!diff) {
|
|
return [
|
|
...[...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()]
|
|
.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,
|
|
}))
|
|
,
|
|
];
|
|
}
|
|
}
|