Implement mutable mapping for client entities
Create a simple Map like class for storing and keeping track of client entities that are synced from the server and have local editable state. This will form the basis for storing entities on the client and should replace the immutable concept used be the ClientSchedule class.
This commit is contained in:
parent
5edea4dd72
commit
6336ccdb96
3 changed files with 467 additions and 0 deletions
286
utils/client-map.nuxt.test.ts
Normal file
286
utils/client-map.nuxt.test.ts
Normal file
|
@ -0,0 +1,286 @@
|
|||
import { ClientMap, type ApiMap } from "~/utils/client-map";
|
||||
import { ClientEntityNew, ClientUser } from "~/utils/client-user";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon";
|
||||
import type { ApiUser } from "~/shared/types/api";
|
||||
|
||||
const locale = "en-GB";
|
||||
const now = DateTime.now().setLocale(locale);
|
||||
const earlier = now.minus({ minutes: 2 });
|
||||
const later = now.plus({ minutes: 2 });
|
||||
const zone = now.zone;
|
||||
const earlierIso = earlier.setZone(FixedOffsetZone.utcInstance).toISO();
|
||||
const nowIso = now.setZone(FixedOffsetZone.utcInstance).toISO();
|
||||
const laterIso = later.setZone(FixedOffsetZone.utcInstance).toISO();
|
||||
|
||||
function fixtureClientMap() {
|
||||
const userA = new ClientUser(1, now, false, "A", "regular");
|
||||
const userB = new ClientUser(2, now, false, "B", "regular");
|
||||
|
||||
return new ClientMap(
|
||||
ClientUser,
|
||||
new Map([
|
||||
[userA.id, userA],
|
||||
[userB.id, userB],
|
||||
]),
|
||||
new Map([[5, now.toMillis()]]),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("class ClientMap", () => {
|
||||
test("load from api", () => {
|
||||
const map = ClientMap.fromApi(fixtureApiMap(), { zone, locale })
|
||||
expect(map).toStrictEqual(fixtureClientMap());
|
||||
});
|
||||
|
||||
test("save to api", () => {
|
||||
const map = fixtureClientMap();
|
||||
expect(map.toApi(false)).toEqual(fixtureApiMap())
|
||||
});
|
||||
|
||||
// State: - = not deleted, x = deleted
|
||||
// Timestamp: n = new, - = now, l = later,
|
||||
|
||||
|
||||
function userFromPattern(pattern: string, laterIsNow: boolean) {
|
||||
const [
|
||||
serverState,
|
||||
serverTs,
|
||||
serverName,
|
||||
clientState,
|
||||
clientTs,
|
||||
clientName,
|
||||
] = pattern;
|
||||
if (serverName === "-" && clientName === "-")
|
||||
return undefined;
|
||||
|
||||
const timestamps: Record<string, DateTime> = {
|
||||
"-": now,
|
||||
"l": laterIsNow ? now : later,
|
||||
"n": DateTime.fromMillis(ClientEntityNew.newEntityMillis),
|
||||
};
|
||||
const user = new ClientUser(1, now, false, "", "regular");
|
||||
user.serverUpdatedAt = timestamps[serverTs];
|
||||
user.serverDeleted = serverState === "x";
|
||||
user.serverName = serverName.toUpperCase();
|
||||
user.updatedAt = timestamps[clientTs];
|
||||
user.deleted = clientState === "x";
|
||||
user.name = clientName.toUpperCase();
|
||||
return user;
|
||||
}
|
||||
|
||||
const updatePatterns = [
|
||||
// User pattern:
|
||||
// --a--a
|
||||
// |||||\- Client name: a, b, or - for no entity
|
||||
// ||||\- Client timestamp: n for new, - for now, and l for later
|
||||
// |||\- Client state: - not deleted, x marked as deleted or if no entity a tombstone
|
||||
// ||\- Server name: a, b, or - for no entity
|
||||
// |\- Server timestamp: n for new, - for now, and l for later
|
||||
// \- Server state: - not deleted, x marked as deleted or if no entity a tombstone
|
||||
|
||||
// inital-user action expected-user
|
||||
// Update with entity
|
||||
"--a--a A -la-la",
|
||||
"--b--a A -la-la",
|
||||
"x-a--a A -la-la",
|
||||
"x-b--a A -la-la",
|
||||
"-na-na A -la-la",
|
||||
"-nb-na A -la-la",
|
||||
"--ax-a A -lax-a",
|
||||
"--bx-a A -lax-a",
|
||||
"x-ax-a A -lax-a",
|
||||
"x-bx-a A -lax-a",
|
||||
"-naxna A -laxna",
|
||||
"-nbxna A -laxna",
|
||||
|
||||
"--a--b A -la--b",
|
||||
"--b--b A -la-la",
|
||||
"x-a--b A -la--b",
|
||||
"x-b--b A -la--b",
|
||||
"-na-nb A -la-nb",
|
||||
"-nb-nb A -la-nb",
|
||||
"--ax-b A -lax-b",
|
||||
"--bx-b A -lax-b",
|
||||
"x-ax-b A -lax-b",
|
||||
"x-bx-b A -lax-b",
|
||||
"-naxnb A -laxnb",
|
||||
"-nbxnb A -laxnb",
|
||||
|
||||
"x----- A -la-la",
|
||||
"------ A -la-la",
|
||||
|
||||
// Update with Tombstone
|
||||
"--a--a T xl----",
|
||||
"--b--a T xlb--a",
|
||||
"x-a--a T xla--a",
|
||||
"x-b--a T xlb--a",
|
||||
"-na-na T xla-na",
|
||||
"-nb-na T xlb-na",
|
||||
"--ax-a T xl----",
|
||||
"--bx-a T xl----",
|
||||
"x-ax-a T xl----",
|
||||
"x-bx-a T xl----",
|
||||
"-naxna T xl----",
|
||||
"-nbxna T xl----",
|
||||
|
||||
"--a--b T xla--b",
|
||||
"--b--b T xl----",
|
||||
"x-a--b T xla--b",
|
||||
"x-b--b T xlb--b",
|
||||
"-na-nb T xla-nb",
|
||||
"-nb-nb T xlb-nb",
|
||||
"--ax-b T xl----",
|
||||
"--bx-b T xl----",
|
||||
"x-ax-b T xl----",
|
||||
"x-bx-b T xl----",
|
||||
"-naxnb T xl----",
|
||||
"-nbxnb T xl----",
|
||||
|
||||
"x----- T xl----",
|
||||
"------ T xl----",
|
||||
];
|
||||
function tombFromPattern(pattern: string, laterIsNow: boolean) {
|
||||
if (pattern[0] === "x") {
|
||||
if (pattern[1] === "-")
|
||||
return now.toMillis();
|
||||
if (pattern[1] === "l")
|
||||
return (laterIsNow ? now : later).toMillis();
|
||||
}
|
||||
}
|
||||
for (const pattern of updatePatterns) {
|
||||
test(`apply diff pattern ${pattern}`, () => {
|
||||
for (const useSameTimestamp of [false, true]) {
|
||||
const map = new ClientMap(ClientUser, new Map(), new Map());
|
||||
const [startPattern, action, expectedPattern] = pattern.split(" ");
|
||||
const user = userFromPattern(startPattern, useSameTimestamp);
|
||||
const tomb = tombFromPattern(startPattern, useSameTimestamp);
|
||||
if (user)
|
||||
map.add(user);
|
||||
if (tomb)
|
||||
map.tombstones.set(1, tomb);
|
||||
// Update
|
||||
let update: ApiUser;
|
||||
if (action === "A") {
|
||||
update = {
|
||||
id: 1,
|
||||
updatedAt: useSameTimestamp ? nowIso : laterIso,
|
||||
name: "A",
|
||||
type: "regular",
|
||||
}
|
||||
} else if (action === "T") {
|
||||
update = {
|
||||
id: 1,
|
||||
updatedAt: useSameTimestamp ? nowIso : laterIso,
|
||||
deleted: true,
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown action pattern ${action}`)
|
||||
}
|
||||
map.apiUpdate({ type: "user", entities: [update] }, { zone, locale });
|
||||
// Check
|
||||
const expectedUser = userFromPattern(expectedPattern, useSameTimestamp);
|
||||
const expectedTomb = tombFromPattern(expectedPattern, useSameTimestamp);
|
||||
expect(map.get(1)).toEqual(expectedUser);
|
||||
expect(map.tombstones.get(1)).toEqual(expectedTomb);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const pattern of updatePatterns) {
|
||||
if (pattern[1] === "n" || pattern[2] === "-")
|
||||
continue; // Skip tests involving new or missing entities
|
||||
test(`ignore diff pattern ${pattern.slice(0, 8)}`, () => {
|
||||
for (const useSameTimestamp of [false, true]) {
|
||||
const map = new ClientMap(ClientUser, new Map(), new Map());
|
||||
const [startPattern, action] = pattern.split(" ");
|
||||
const user = userFromPattern(startPattern, useSameTimestamp);
|
||||
const tomb = tombFromPattern(startPattern, useSameTimestamp);
|
||||
if (user)
|
||||
map.add(user);
|
||||
if (tomb)
|
||||
map.tombstones.set(1, tomb);
|
||||
// Update
|
||||
let update: ApiUser;
|
||||
if (action === "A") {
|
||||
update = {
|
||||
id: 1,
|
||||
updatedAt: earlierIso,
|
||||
name: "A",
|
||||
type: "regular",
|
||||
}
|
||||
} else if (action === "T") {
|
||||
update = {
|
||||
id: 1,
|
||||
updatedAt: earlierIso,
|
||||
deleted: true,
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown action pattern ${action}`)
|
||||
}
|
||||
map.apiUpdate({ type: "user", entities: [update] }, { zone, locale });
|
||||
// Check
|
||||
const expectedUser = userFromPattern(startPattern, useSameTimestamp);
|
||||
const expectedTomb = tombFromPattern(startPattern, useSameTimestamp);
|
||||
expect(map.get(1)).toEqual(expectedUser);
|
||||
expect(map.tombstones.get(1)).toEqual(expectedTomb);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("create", () => {
|
||||
const map = fixtureClientMap();
|
||||
const entity = ClientUser.create(3, "New user", "regular", { zone, locale });
|
||||
expect(map.isModified()).toBe(false);
|
||||
// Create
|
||||
map.add(entity);
|
||||
// Check
|
||||
expect(map.isModified()).toBe(true);
|
||||
expect(map.get(entity.id)!.isModified()).toBe(true);
|
||||
});
|
||||
test("edit", () => {
|
||||
const map = fixtureClientMap();
|
||||
expect(map.isModified()).toBe(false);
|
||||
expect(map.get(1)!.isModified()).toBe(false);
|
||||
// Edit
|
||||
map.get(1)!.name = "Modified User";
|
||||
// Check
|
||||
expect(map.isModified()).toBe(true);
|
||||
expect(map.get(1)!.isModified()).toBe(true);
|
||||
});
|
||||
test("delete", () => {
|
||||
const map = fixtureClientMap();
|
||||
expect(map.isModified()).toBe(false);
|
||||
expect(map.get(1)!.isModified()).toBe(false);
|
||||
// Delete
|
||||
map.get(1)!.deleted = true;
|
||||
// Check
|
||||
expect(map.isModified()).toBe(true);
|
||||
expect(map.get(1)!.isModified()).toBe(true);
|
||||
});
|
||||
});
|
156
utils/client-map.ts
Normal file
156
utils/client-map.ts
Normal file
|
@ -0,0 +1,156 @@
|
|||
import { type Entity, type EntityLiving, type Id, type Living } from "~/shared/types/common";
|
||||
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,
|
||||
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>,
|
||||
public tombstones: Map<Id, number>,
|
||||
) {
|
||||
}
|
||||
|
||||
get(id: Id) {
|
||||
return this.map.get(id);
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.map.values();
|
||||
}
|
||||
|
||||
isModified() {
|
||||
return [...this.map.values()].some(entity => entity.isModified());
|
||||
}
|
||||
|
||||
add(entity: T) {
|
||||
if (this.map.has(entity.id)) {
|
||||
throw new Error("ClientMap.add: Entity already exists");
|
||||
}
|
||||
this.map.set(entity.id, entity);
|
||||
}
|
||||
|
||||
discard(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<T extends Entity>(
|
||||
api: ApiMap<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);
|
||||
return new this(
|
||||
EntityClass,
|
||||
idMap(entities.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) {
|
||||
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): ApiMap<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 {
|
||||
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,
|
||||
}))
|
||||
,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue