2025-06-23 00:03:37 +02:00
|
|
|
import { type Entity, type EntityLiving, type Id, type Living } from "~/shared/types/common";
|
|
|
|
import { DateTime, Zone } from "~/shared/utils/luxon";
|
2025-06-23 22:46:39 +02:00
|
|
|
import { ClientEntity } from "~/utils/client-entity";
|
2025-06-23 00:03:37 +02:00
|
|
|
|
2025-06-23 22:46:39 +02:00
|
|
|
export interface EntityClass<T extends ClientEntity> {
|
2025-06-23 00:03:37 +02:00
|
|
|
fromApi(api: EntityLiving, opts: { zone: Zone, locale: string }): T,
|
|
|
|
}
|
|
|
|
|
2025-06-23 22:46:39 +02:00
|
|
|
export class ClientMap<T extends ClientEntity> {
|
2025-06-23 00:03:37 +02:00
|
|
|
constructor(
|
|
|
|
public EntityClass: EntityClass<T>,
|
|
|
|
public map: Map<Id, T>,
|
|
|
|
public tombstones: Map<Id, number>,
|
|
|
|
) {
|
|
|
|
}
|
|
|
|
|
|
|
|
get(id: Id) {
|
|
|
|
return this.map.get(id);
|
|
|
|
}
|
|
|
|
|
2025-06-23 22:46:39 +02:00
|
|
|
keys() {
|
|
|
|
return this.map.keys();
|
|
|
|
}
|
|
|
|
|
2025-06-23 00:03:37 +02:00
|
|
|
values() {
|
|
|
|
return this.map.values();
|
|
|
|
}
|
|
|
|
|
2025-06-23 22:46:39 +02:00
|
|
|
get size() {
|
|
|
|
return this.map.size;
|
|
|
|
}
|
|
|
|
|
2025-06-23 00:03:37 +02:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2025-06-23 22:46:39 +02:00
|
|
|
discard() {
|
|
|
|
for (const id of this.keys()) {
|
|
|
|
this.discardId(id);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
discardId(id: Id) {
|
2025-06-23 00:03:37 +02:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-23 22:46:39 +02:00
|
|
|
static fromApi<T extends Entity, U extends ClientEntity>(
|
2025-06-23 18:17:23 +02:00
|
|
|
EntityClass: EntityClass<U>,
|
|
|
|
entities: T[],
|
2025-06-23 00:03:37 +02:00
|
|
|
opts: { zone: Zone, locale: string },
|
|
|
|
) {
|
2025-06-23 18:17:23 +02:00
|
|
|
const living = entities.filter(entity => !entity.deleted) as Living<T>[];
|
|
|
|
const tombstones = entities.filter(entity => entity.deleted === true);
|
2025-06-23 00:03:37 +02:00
|
|
|
return new this(
|
|
|
|
EntityClass,
|
2025-06-23 18:17:23 +02:00
|
|
|
idMap(living.map(apiEntity => EntityClass.fromApi(apiEntity, opts))),
|
2025-06-23 00:03:37 +02:00
|
|
|
new Map(tombstones.map(tombstone => [tombstone.id, Date.parse(tombstone.updatedAt)])),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-06-23 18:17:23 +02:00
|
|
|
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) {
|
2025-06-23 00:03:37 +02:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-23 18:17:23 +02:00
|
|
|
toApi(diff: boolean): Entity[] {
|
2025-06-23 00:03:37 +02:00
|
|
|
if (!diff) {
|
2025-06-23 18:17:23 +02:00
|
|
|
return [
|
|
|
|
...[...this.map.values()].map(entity => entity.toApi()),
|
|
|
|
...[...this.tombstones].map(([id, updatedMs]) => ({
|
|
|
|
id,
|
|
|
|
updatedAt: new Date(updatedMs).toISOString(),
|
|
|
|
deleted: true as const,
|
|
|
|
})),
|
|
|
|
];
|
2025-06-23 00:03:37 +02:00
|
|
|
}
|
|
|
|
|
2025-06-23 18:17:23 +02:00
|
|
|
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,
|
|
|
|
}))
|
|
|
|
,
|
|
|
|
];
|
2025-06-23 00:03:37 +02:00
|
|
|
}
|
|
|
|
}
|