Remove type from Api serialisation of ClientMap
All checks were successful
/ build (push) Successful in 1m34s
/ deploy (push) Successful in 16s

Move the logic that converts the EntityClass of a map to a string and
then back into the class to the payload plugin in order to avoid a
circular dependency where the ClientMap needs to know the entity classes
and the entity classes needs to know the ClientMap.

The only place that doesn't know the type of the entities stored in the
client map is the payload reviver, so it makes sense to keep this logic
contained to the payload plugin.
This commit is contained in:
Hornwitser 2025-06-23 18:17:23 +02:00
parent 930d93a95f
commit d48fb035b4
4 changed files with 70 additions and 91 deletions

View file

@ -27,34 +27,31 @@ function fixtureClientMap() {
);
}
function fixtureApiMap(): ApiMap<ApiUser> {
return {
type: "user",
entities: [
{
id: 1,
updatedAt: nowIso,
name: "A",
type: "regular",
},
{
id: 2,
updatedAt: nowIso,
name: "B",
type: "regular",
},
{
id: 5,
updatedAt: nowIso,
deleted: true,
},
],
};
function fixtureApiMap(): ApiUser[] {
return [
{
id: 1,
updatedAt: nowIso,
name: "A",
type: "regular",
},
{
id: 2,
updatedAt: nowIso,
name: "B",
type: "regular",
},
{
id: 5,
updatedAt: nowIso,
deleted: true,
},
];
}
describe("class ClientMap", () => {
test("load from api", () => {
const map = ClientMap.fromApi(fixtureApiMap(), { zone, locale })
const map = ClientMap.fromApi(ClientUser, fixtureApiMap(), { zone, locale })
expect(map).toStrictEqual(fixtureClientMap());
});
@ -202,7 +199,7 @@ describe("class ClientMap", () => {
} else {
throw new Error(`Unknown action pattern ${action}`)
}
map.apiUpdate({ type: "user", entities: [update] }, { zone, locale });
map.apiUpdate([update], { zone, locale });
// Check
const expectedUser = userFromPattern(expectedPattern, useSameTimestamp);
const expectedTomb = tombFromPattern(expectedPattern, useSameTimestamp);
@ -243,7 +240,7 @@ describe("class ClientMap", () => {
} else {
throw new Error(`Unknown action pattern ${action}`)
}
map.apiUpdate({ type: "user", entities: [update] }, { zone, locale });
map.apiUpdate([update], { zone, locale });
// Check
const expectedUser = userFromPattern(startPattern, useSameTimestamp);
const expectedTomb = tombFromPattern(startPattern, useSameTimestamp);

View file

@ -2,23 +2,11 @@ import { type Entity, type EntityLiving, type Id, type Living } from "~/shared/t
import { DateTime, Zone } from "~/shared/utils/luxon";
import { ClientEntityNew } from "~/utils/client-user";
export interface ApiMap<T extends Entity> {
type: string,
entities: T[]
}
interface EntityClass<T extends ClientEntityNew> {
name: string,
type: string,
export interface EntityClass<T extends ClientEntityNew> {
fromApi(api: EntityLiving, opts: { zone: Zone, locale: string }): T,
}
export class ClientMap<T extends ClientEntityNew> {
static typeMap: Record<string, EntityClass<ClientEntityNew>> = {
user: ClientUser,
};
constructor(
public EntityClass: EntityClass<T>,
public map: Map<Id, T>,
@ -60,27 +48,24 @@ export class ClientMap<T extends ClientEntityNew> {
}
}
static fromApi<T extends Entity>(
api: ApiMap<T>,
static fromApi<T extends Entity, U extends ClientEntityNew>(
EntityClass: EntityClass<U>,
entities: T[],
opts: { zone: Zone, locale: string },
) {
const EntityClass = this.typeMap[api.type];
const entities = api.entities.filter(entity => !entity.deleted) as Living<T>[];
const tombstones = api.entities.filter(entity => entity.deleted === true);
const living = entities.filter(entity => !entity.deleted) as Living<T>[];
const tombstones = entities.filter(entity => entity.deleted === true);
return new this(
EntityClass,
idMap(entities.map(apiEntity => EntityClass.fromApi(apiEntity, opts))),
idMap(living.map(apiEntity => EntityClass.fromApi(apiEntity, opts))),
new Map(tombstones.map(tombstone => [tombstone.id, Date.parse(tombstone.updatedAt)])),
);
}
apiUpdate(api: ApiMap<Entity>, opts: { zone: Zone, locale: string }) {
if (api.type !== this.EntityClass.type) {
throw new Error(`ClientMap: Map of ${this.EntityClass.name} received update for ${api.type}.`);
}
const entities = api.entities.filter(entity => !entity.deleted);
const tombstones = api.entities.filter(entity => entity.deleted === true);
for (const entity of entities) {
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) {
const tombstoneMs = this.tombstones.get(entity.id);
const updatedMs = Date.parse(entity.updatedAt);
if (tombstoneMs !== undefined) {
@ -120,37 +105,31 @@ export class ClientMap<T extends ClientEntityNew> {
}
}
toApi(diff: boolean): ApiMap<Entity> {
toApi(diff: boolean): Entity[] {
if (!diff) {
return {
type: this.EntityClass.type,
entities: [
...[...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()].map(entity => entity.toApi()),
...[...this.tombstones].map(([id, updatedMs]) => ({
id,
updatedAt: new Date(updatedMs).toISOString(),
deleted: true as const,
})),
];
}
return {
type: this.EntityClass.type,
entities: [
...[...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,
}))
,
],
};
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,
}))
,
];
}
}