I firmly believe in free software. The application I'm making here have capabilities that I've not seen in any system. It presents itself as an opportunity to collaborate on a tool that serves the people rather than corporations. Whose incentives are to help people rather, not make the most money. And whose terms ensure that these freedoms and incentives cannot be taken back or subverted. I license this software under the AGPL.
157 lines
4 KiB
TypeScript
157 lines
4 KiB
TypeScript
/*
|
|
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,
|
|
}))
|
|
,
|
|
];
|
|
}
|
|
}
|