2025-06-30 18:58:24 +02:00
|
|
|
/*
|
|
|
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
*/
|
2025-06-24 15:19:11 +02:00
|
|
|
import type { ApiEntity, ApiTombstone } from "~/shared/types/api";
|
|
|
|
import type { Id } from "~/shared/types/common";
|
2025-06-23 00:03:37 +02:00
|
|
|
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-24 15:19:11 +02:00
|
|
|
export interface EntityClass<Api extends ApiEntity, Ent extends ClientEntity<Api>> {
|
|
|
|
fromApi(api: Api, opts: { zone: Zone, locale: string }): Ent,
|
2025-06-23 00:03:37 +02:00
|
|
|
}
|
|
|
|
|
2025-06-24 15:19:11 +02:00
|
|
|
export class ClientMap<
|
|
|
|
Ent extends ClientEntity<Api>,
|
|
|
|
Api extends ApiEntity = Ent extends ClientEntity<infer Api> ? Api : never
|
|
|
|
> {
|
2025-06-23 00:03:37 +02:00
|
|
|
constructor(
|
2025-06-24 15:19:11 +02:00
|
|
|
public EntityClass: EntityClass<Api, Ent>,
|
|
|
|
public map: Map<Id, Ent>,
|
2025-06-23 00:03:37 +02:00
|
|
|
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());
|
|
|
|
}
|
|
|
|
|
2025-06-24 15:19:11 +02:00
|
|
|
add(entity: Ent) {
|
2025-06-23 00:03:37 +02:00
|
|
|
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-24 15:19:11 +02:00
|
|
|
static fromApi<Api extends ApiEntity, Ent extends ClientEntity<Api>>(
|
|
|
|
EntityClass: EntityClass<Api, Ent>,
|
|
|
|
entities: (Api | ApiTombstone)[],
|
2025-06-23 00:03:37 +02:00
|
|
|
opts: { zone: Zone, locale: string },
|
|
|
|
) {
|
2025-06-24 15:19:11 +02:00
|
|
|
const living = entities.filter(entity => !entity.deleted);
|
2025-06-23 18:17:23 +02:00
|
|
|
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-24 15:19:11 +02:00
|
|
|
apiUpdate(entities: (Api | ApiTombstone)[], opts: { zone: Zone, locale: string }) {
|
2025-06-23 18:17:23 +02:00
|
|
|
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-24 15:19:11 +02:00
|
|
|
toApi(diff: boolean): (Api | ApiTombstone)[] {
|
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
|
|
|
}
|
|
|
|
}
|