owltide/utils/client-map.ts

157 lines
4 KiB
TypeScript
Raw Normal View History

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