/* SPDX-FileCopyrightText: © 2025 Hornwitser 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> { fromApi(api: Api, opts: { zone: Zone, locale: string }): Ent, } export class ClientMap< Ent extends ClientEntity, Api extends ApiEntity = Ent extends ClientEntity ? Api : never > { constructor( public EntityClass: EntityClass, public map: Map, public tombstones: Map, ) { } 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>( EntityClass: EntityClass, 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, })) , ]; } }