owltide/utils/client-map.ts

158 lines
4 KiB
TypeScript
Raw Normal View History

/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ApiEntity, ApiTombstone } from "~/shared/types/api";
import type { Id } from "~/shared/types/common";
import { DateTime, Zone } from "~/shared/utils/luxon";
import { ClientEntity } from "~/utils/client-entity";
export interface EntityClass<Api extends ApiEntity, Ent extends ClientEntity<Api>> {
fromApi(api: Api, opts: { zone: Zone, locale: string }): Ent,
}
export class ClientMap<
Ent extends ClientEntity<Api>,
Api extends ApiEntity = Ent extends ClientEntity<infer Api> ? Api : never
> {
constructor(
public EntityClass: EntityClass<Api, Ent>,
public map: Map<Id, Ent>,
public tombstones: Map<Id, number>,
) {
}
get(id: Id) {
return this.map.get(id);
}
keys() {
return this.map.keys();
}
values() {
return this.map.values();
}
get size() {
return this.map.size;
}
isModified() {
return [...this.map.values()].some(entity => entity.isModified());
}
add(entity: Ent) {
if (this.map.has(entity.id)) {
throw new Error("ClientMap.add: Entity already exists");
}
this.map.set(entity.id, entity);
}
discard() {
for (const id of this.keys()) {
this.discardId(id);
}
}
discardId(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<Api extends ApiEntity, Ent extends ClientEntity<Api>>(
EntityClass: EntityClass<Api, Ent>,
entities: (Api | ApiTombstone)[],
opts: { zone: Zone, locale: string },
) {
const living = entities.filter(entity => !entity.deleted);
const tombstones = entities.filter(entity => entity.deleted === true);
return new this(
EntityClass,
idMap(living.map(apiEntity => EntityClass.fromApi(apiEntity, opts))),
new Map(tombstones.map(tombstone => [tombstone.id, Date.parse(tombstone.updatedAt)])),
);
}
apiUpdate(entities: (Api | ApiTombstone)[], 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) {
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): (Api | ApiTombstone)[] {
if (!diff) {
return [
...[...this.map.values()].map(entity => entity.toApi()),
...[...this.tombstones].map(([id, updatedMs]) => ({
id,
updatedAt: new Date(updatedMs).toISOString(),
deleted: true as const,
})),
];
}
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,
}))
,
];
}
}