157 lines
4 KiB
TypeScript
157 lines
4 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 ApiMap<T extends Entity> {
|
||
|
type: string,
|
||
|
entities: T[]
|
||
|
}
|
||
|
|
||
|
interface EntityClass<T extends ClientEntityNew> {
|
||
|
name: string,
|
||
|
type: string,
|
||
|
fromApi(api: EntityLiving, opts: { zone: Zone, locale: string }): T,
|
||
|
}
|
||
|
|
||
|
|
||
|
export class ClientMap<T extends ClientEntityNew> {
|
||
|
static typeMap: Record<string, EntityClass<ClientEntityNew>> = {
|
||
|
user: ClientUser,
|
||
|
};
|
||
|
|
||
|
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>(
|
||
|
api: ApiMap<T>,
|
||
|
opts: { zone: Zone, locale: string },
|
||
|
) {
|
||
|
const EntityClass = this.typeMap[api.type];
|
||
|
const entities = api.entities.filter(entity => !entity.deleted) as Living<T>[];
|
||
|
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<Entity>, 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<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 {
|
||
|
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,
|
||
|
}))
|
||
|
,
|
||
|
],
|
||
|
};
|
||
|
}
|
||
|
}
|