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.
This commit is contained in:
Hornwitser 2025-06-23 00:03:37 +02:00
parent 5edea4dd72
commit 6336ccdb96
3 changed files with 467 additions and 0 deletions

156
utils/client-map.ts Normal file
View file

@ -0,0 +1,156 @@
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,
}))
,
],
};
}
}