owltide/utils/client-map.ts
Hornwitser 6336ccdb96 Implement mutable mapping for client entities
Create a simple Map like class for storing and keeping track of client
entities that are synced from the server and have local editable state.
This will form the basis for storing entities on the client and should
replace the immutable concept used be the ClientSchedule class.
2025-06-23 00:28:58 +02:00

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