diff --git a/plugins/payload-client-map.ts b/plugins/payload-client-map.ts index c28ac86..3a3efcb 100644 --- a/plugins/payload-client-map.ts +++ b/plugins/payload-client-map.ts @@ -1,7 +1,8 @@ +import type { ApiEntity } from "~/shared/types/api"; import { Info } from "~/shared/utils/luxon"; import { ClientEntity } from "~/utils/client-entity"; -const typeMap: Record> = { +const typeMap: Record>> = { "user": ClientUser, "schedule-location": ClientScheduleLocation, "schedule-event": ClientScheduleEvent, diff --git a/server/api/admin/user.patch.ts b/server/api/admin/user.patch.ts index 717358a..ffe0b71 100644 --- a/server/api/admin/user.patch.ts +++ b/server/api/admin/user.patch.ts @@ -1,9 +1,9 @@ import { readUsers, type ServerUser, writeUsers } from "~/server/database"; -import { type ApiUser, apiUserPatchSchema } from "~/shared/types/api"; +import { type ApiTombstone, type ApiUser, apiUserPatchSchema } from "~/shared/types/api"; import { z } from "zod/v4-mini"; import { broadcastEvent } from "~/server/streams"; -function serverUserToApi(user: ServerUser): ApiUser { +function serverUserToApi(user: ServerUser): ApiUser | ApiTombstone { if (user.deleted) { return { id: user.id, diff --git a/server/api/users/index.get.ts b/server/api/users/index.get.ts index feb63b4..e328516 100644 --- a/server/api/users/index.get.ts +++ b/server/api/users/index.get.ts @@ -1,7 +1,7 @@ import { readUsers, type ServerUser } from "~/server/database" -import type { ApiUser } from "~/shared/types/api"; +import type { ApiTombstone, ApiUser } from "~/shared/types/api"; -function serverUserToApi(user: ServerUser): ApiUser { +function serverUserToApi(user: ServerUser): ApiUser | ApiTombstone { if (user.deleted) { return { id: user.id, diff --git a/server/utils/schedule.ts b/server/utils/schedule.ts index 1c60d70..f7496a6 100644 --- a/server/utils/schedule.ts +++ b/server/utils/schedule.ts @@ -1,6 +1,6 @@ import { readSchedule, type ServerUser, writeSchedule } from '~/server/database'; import { broadcastEvent } from '~/server/streams'; -import type { ApiSchedule } from '~/shared/types/api'; +import type { ApiSchedule, ApiTombstone } from '~/shared/types/api'; export async function updateScheduleInterestedCounts(users: ServerUser[]) { const eventCounts = new Map(); @@ -69,7 +69,7 @@ export function canSeeCrew(userType: string | undefined) { } /** Filters out crew visible only parts of schedule */ -export function filterSchedule(schedule: ApiSchedule): ApiSchedule { +export function filterSchedule(schedule: ApiSchedule | ApiTombstone): ApiSchedule | ApiTombstone { if (schedule.deleted) { return schedule; } diff --git a/shared/types/api.ts b/shared/types/api.ts index d78bc84..49b54a3 100644 --- a/shared/types/api.ts +++ b/shared/types/api.ts @@ -1,5 +1,27 @@ import { z } from "zod/v4-mini"; -import { defineEntity, idSchema, type Id } from "~/shared/types/common"; +import { idSchema, type Id } from "~/shared/types/common"; + +export const apiEntitySchema = z.object({ + id: idSchema, + updatedAt: z.string(), + deleted: z.optional(z.literal(false)), +}); +export type ApiEntity = z.infer; + +export function defineApiEntity(fields: T) { + return z.extend(apiEntitySchema, fields); +} + +export function tombstonable(entitySchema: T) { + return z.discriminatedUnion("deleted", [entitySchema, apiTombstoneSchema]); +} + +export const apiTombstoneSchema = z.object({ + id: idSchema, + updatedAt: z.string(), + deleted: z.literal(true), +}); +export type ApiTombstone = z.infer; export const apiUserTypeSchema = z.union([ z.literal("anonymous"), @@ -47,7 +69,7 @@ export interface ApiSession { push: boolean, } -export const apiScheduleLocationSchema = defineEntity({ +export const apiScheduleLocationSchema = defineApiEntity({ name: z.string(), description: z.optional(z.string()), }); @@ -63,7 +85,7 @@ export const apiScheduleEventSlotSchema = z.object({ }); export type ApiScheduleEventSlot = z.infer; -export const apiScheduleEventSchema = defineEntity({ +export const apiScheduleEventSchema = defineApiEntity({ name: z.string(), crew: z.optional(z.boolean()), host: z.optional(z.string()), @@ -74,7 +96,7 @@ export const apiScheduleEventSchema = defineEntity({ }); export type ApiScheduleEvent = z.infer; -export const apiScheduleRoleSchema = defineEntity({ +export const apiScheduleRoleSchema = defineApiEntity({ name: z.string(), description: z.optional(z.string()), }); @@ -88,7 +110,7 @@ export const apiScheduleShiftSlotSchema = z.object({ }); export type ApiScheduleShiftSlot = z.infer; -export const apiScheduleShiftSchema = defineEntity({ +export const apiScheduleShiftSchema = defineApiEntity({ roleId: idSchema, name: z.string(), description: z.optional(z.string()), @@ -96,16 +118,16 @@ export const apiScheduleShiftSchema = defineEntity({ }); export type ApiScheduleShift = z.infer; -export const apiScheduleSchema = defineEntity({ +export const apiScheduleSchema = defineApiEntity({ id: z.literal(111), - locations: z.optional(z.array(apiScheduleLocationSchema)), - events: z.optional(z.array(apiScheduleEventSchema)), - roles: z.optional(z.array(apiScheduleRoleSchema)), - shifts: z.optional(z.array(apiScheduleShiftSchema)), + locations: z.optional(z.array(tombstonable(apiScheduleLocationSchema))), + events: z.optional(z.array(tombstonable(apiScheduleEventSchema))), + roles: z.optional(z.array(tombstonable(apiScheduleRoleSchema))), + shifts: z.optional(z.array(tombstonable(apiScheduleShiftSchema))), }); export type ApiSchedule = z.infer; -export const apiUserSchema = defineEntity({ +export const apiUserSchema = defineApiEntity({ type: apiUserTypeSchema, name: z.optional(z.string()), }); @@ -126,13 +148,13 @@ export interface ApiAccountUpdate { export interface ApiScheduleUpdate { type: "schedule-update", updatedFrom?: string, - data: ApiSchedule, + data: ApiSchedule | ApiTombstone, } export interface ApiUserUpdate { type: "user-update", updatedFrom?: string, - data: ApiUser, + data: ApiUser | ApiTombstone, } export type ApiEvent = diff --git a/shared/types/common.ts b/shared/types/common.ts index 07af6e8..bf04781 100644 --- a/shared/types/common.ts +++ b/shared/types/common.ts @@ -2,27 +2,3 @@ import { z } from "zod/v4-mini"; export const idSchema = z.number(); export type Id = z.infer; - -export const entityLivingSchema = z.object({ - id: idSchema, - updatedAt: z.string(), - deleted: z.optional(z.literal(false)), -}); -export type EntityLiving = z.infer; - -export const entityToombstoneSchema = z.object({ - id: idSchema, - updatedAt: z.string(), - deleted: z.literal(true), -}); -export type EntityToombstone = z.infer; - -export const entitySchema = z.discriminatedUnion("deleted", [entityLivingSchema, entityToombstoneSchema]); -export type Entity = z.infer; - -export type Living = Extract; -export type Tombstone = Extract; - -export function defineEntity(fields: T) { - return z.discriminatedUnion("deleted", [z.extend(entityLivingSchema, fields), entityToombstoneSchema]); -} diff --git a/shared/utils/update.ts b/shared/utils/update.ts index 9a8a040..d2fd431 100644 --- a/shared/utils/update.ts +++ b/shared/utils/update.ts @@ -1,6 +1,6 @@ -import type { Entity } from "~/shared/types/common"; +import type { ApiEntity, ApiTombstone } from "~/shared/types/api"; -export function applyUpdatesToArray(updates: T[], entities: T[]) { +export function applyUpdatesToArray(updates: T[], entities: T[]) { const idMap = new Map(entities.map((e, i) => [e.id, i])); for (const update of updates) { const index = idMap.get(update.id); diff --git a/utils/client-entity.ts b/utils/client-entity.ts index 5182256..369a0a7 100644 --- a/utils/client-entity.ts +++ b/utils/client-entity.ts @@ -1,7 +1,8 @@ -import type { Entity, EntityLiving, Id } from "~/shared/types/common"; +import type { ApiEntity, ApiTombstone } from "~/shared/types/api"; +import type { Id } from "~/shared/types/common"; import { DateTime, Zone } from "~/shared/utils/luxon"; -export abstract class ClientEntity { +export abstract class ClientEntity { /** Millisecond offset used to indicate this is a new entitity. */ @@ -73,10 +74,10 @@ export abstract class ClientEntity { /** Apply an update delivered from the API to this entity. */ - abstract apiUpdate(api: EntityLiving, opts: { zone: Zone, locale: string }): void + abstract apiUpdate(api: Api, opts: { zone: Zone, locale: string }): void /** Serialise this entity to the API format. Not allowed if {@link deleted} is true. */ - abstract toApi(): Entity + abstract toApi(): Api | ApiTombstone } diff --git a/utils/client-map.nuxt.test.ts b/utils/client-map.nuxt.test.ts index 895bf0e..b24aaa5 100644 --- a/utils/client-map.nuxt.test.ts +++ b/utils/client-map.nuxt.test.ts @@ -3,7 +3,7 @@ import { ClientEntity } from "~/utils/client-entity"; import { 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"; +import type { ApiTombstone, ApiUser } from "~/shared/types/api"; const locale = "en-GB"; const now = DateTime.now().setLocale(locale); @@ -28,7 +28,7 @@ function fixtureClientMap() { ); } -function fixtureApiMap(): ApiUser[] { +function fixtureApiMap(): (ApiUser | ApiTombstone)[] { return [ { id: 1, @@ -183,7 +183,7 @@ describe("class ClientMap", () => { if (tomb) map.tombstones.set(1, tomb); // Update - let update: ApiUser; + let update: ApiUser | ApiTombstone; if (action === "A") { update = { id: 1, @@ -224,7 +224,7 @@ describe("class ClientMap", () => { if (tomb) map.tombstones.set(1, tomb); // Update - let update: ApiUser; + let update: ApiUser | ApiTombstone; if (action === "A") { update = { id: 1, diff --git a/utils/client-map.ts b/utils/client-map.ts index 24c3c97..bf995eb 100644 --- a/utils/client-map.ts +++ b/utils/client-map.ts @@ -1,15 +1,19 @@ -import { type Entity, type EntityLiving, type Id, type Living } from "~/shared/types/common"; +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: EntityLiving, opts: { zone: Zone, locale: string }): T, +export interface EntityClass> { + fromApi(api: Api, opts: { zone: Zone, locale: string }): Ent, } -export class ClientMap { +export class ClientMap< + Ent extends ClientEntity, + Api extends ApiEntity = Ent extends ClientEntity ? Api : never +> { constructor( - public EntityClass: EntityClass, - public map: Map, + public EntityClass: EntityClass, + public map: Map, public tombstones: Map, ) { } @@ -34,7 +38,7 @@ export class ClientMap { return [...this.map.values()].some(entity => entity.isModified()); } - add(entity: T) { + add(entity: Ent) { if (this.map.has(entity.id)) { throw new Error("ClientMap.add: Entity already exists"); } @@ -62,12 +66,12 @@ export class ClientMap { } } - static fromApi( - EntityClass: EntityClass, - entities: T[], + static fromApi>( + EntityClass: EntityClass, + entities: (Api | ApiTombstone)[], opts: { zone: Zone, locale: string }, ) { - const living = entities.filter(entity => !entity.deleted) as Living[]; + const living = entities.filter(entity => !entity.deleted); const tombstones = entities.filter(entity => entity.deleted === true); return new this( EntityClass, @@ -76,7 +80,7 @@ export class ClientMap { ); } - apiUpdate(entities: Entity[], opts: { zone: Zone, locale: string }) { + 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) { @@ -119,7 +123,7 @@ export class ClientMap { } } - toApi(diff: boolean): Entity[] { + toApi(diff: boolean): (Api | ApiTombstone)[] { if (!diff) { return [ ...[...this.map.values()].map(entity => entity.toApi()), diff --git a/utils/client-schedule.nuxt.test.ts b/utils/client-schedule.nuxt.test.ts index 71839b0..06d0511 100644 --- a/utils/client-schedule.nuxt.test.ts +++ b/utils/client-schedule.nuxt.test.ts @@ -1,8 +1,7 @@ import { ClientEntity } from "~/utils/client-entity"; import { ClientSchedule, ClientScheduleEventSlot, ClientScheduleLocation, ClientScheduleShiftSlot, toIso } from "~/utils/client-schedule"; import { describe, expect, test } from "vitest"; -import type { ApiSchedule } from "~/shared/types/api"; -import type { Id, Living } from "~/shared/types/common"; +import type { ApiEntity, ApiSchedule } from "~/shared/types/api"; import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon"; const locale = "en-GB"; @@ -70,7 +69,7 @@ function fixtureClientSchedule(multiSlot = false) { return schedule; } -function fixtureApiSchedule(): Living { +function fixtureApiSchedule(): ApiSchedule { return { id: 111, updatedAt: nowIso, @@ -164,7 +163,7 @@ describe("class ClientSchedule", () => { expect(schedule.toApi(false)).toEqual(fixtureApiSchedule()) }); - const entityTests: [string, (schedule: ClientSchedule) => ClientEntity][] = [ + const entityTests: [string, (schedule: ClientSchedule) => ClientEntity][] = [ [ "location", () => ClientScheduleLocation.create(3, "New location", "", { zone, locale }) diff --git a/utils/client-schedule.ts b/utils/client-schedule.ts index dff3eaa..d0d78d6 100644 --- a/utils/client-schedule.ts +++ b/utils/client-schedule.ts @@ -1,23 +1,25 @@ import { DateTime, FixedOffsetZone, Zone } from "~/shared/utils/luxon"; import type { + ApiEntity, ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleLocation, ApiScheduleRole, ApiScheduleShift, - ApiScheduleShiftSlot + ApiScheduleShiftSlot, + ApiTombstone } from "~/shared/types/api"; -import type { Entity, Id, Living, Tombstone } from "~/shared/types/common"; -import { mapEquals, setEquals } from "~/shared/utils/functions"; +import type { Id } from "~/shared/types/common"; +import { setEquals } from "~/shared/utils/functions"; import { ClientEntity } from "~/utils/client-entity"; -function filterAlive(entities?: T[]) { - return (entities ?? []).filter((entity) => !entity.deleted) as Living[]; +function filterEntity(entities?: (T | ApiTombstone)[]) { + return (entities ?? []).filter((entity) => !entity.deleted) as T[]; } -function filterTombstone(entities?: T[]) { - return (entities ?? []).filter((entity) => entity.deleted) as Tombstone[]; +function filterTombstone(entities?: (T | ApiTombstone)[]) { + return (entities ?? []).filter((entity) => entity.deleted) as ApiTombstone[]; } export function toIso(timestamp: DateTime) { @@ -37,7 +39,7 @@ function mapWithout(map: Map, key: K) { } -export class ClientScheduleLocation extends ClientEntity { +export class ClientScheduleLocation extends ClientEntity { serverName: string; serverDescription: string; @@ -86,7 +88,7 @@ export class ClientScheduleLocation extends ClientEntity { ); } - static fromApi(api: Living, opts: { zone: Zone, locale: string }) { + static fromApi(api: ApiScheduleLocation, opts: { zone: Zone, locale: string }) { return new this( api.id, DateTime.fromISO(api.updatedAt, opts), @@ -96,7 +98,7 @@ export class ClientScheduleLocation extends ClientEntity { ); } - override apiUpdate(api: Living, opts: { zone: Zone, locale: string }) { + override apiUpdate(api: ApiScheduleLocation, opts: { zone: Zone, locale: string }) { const wasModified = this.isModified(); this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts); this.serverDeleted = false; @@ -107,7 +109,7 @@ export class ClientScheduleLocation extends ClientEntity { } } - toApi(): ApiScheduleLocation { + toApi(): ApiScheduleLocation | ApiTombstone { if (this.deleted) { return { id: this.id, @@ -124,7 +126,7 @@ export class ClientScheduleLocation extends ClientEntity { } } -export class ClientScheduleEvent extends ClientEntity { +export class ClientScheduleEvent extends ClientEntity { schedule!: ClientSchedule; serverName: string; serverCrew: boolean; @@ -237,7 +239,7 @@ export class ClientScheduleEvent extends ClientEntity { } static fromApi( - api: Living, + api: ApiScheduleEvent, opts: { zone: Zone, locale: string }, ) { return new this( @@ -255,7 +257,7 @@ export class ClientScheduleEvent extends ClientEntity { } override apiUpdate( - api: Living, + api: ApiScheduleEvent, opts: { zone: Zone, locale: string }, ) { const wasModified = this.isModified(); @@ -273,7 +275,7 @@ export class ClientScheduleEvent extends ClientEntity { } } - toApi(): ApiScheduleEvent { + toApi(): ApiScheduleEvent | ApiTombstone { if (this.deleted) { return { id: this.id, @@ -437,7 +439,7 @@ export class ClientScheduleEventSlot { } } -export class ClientScheduleRole extends ClientEntity { +export class ClientScheduleRole extends ClientEntity { serverName: string; serverDescription: string; @@ -486,7 +488,7 @@ export class ClientScheduleRole extends ClientEntity { ); } - static fromApi(api: Living, opts: { zone: Zone, locale: string }) { + static fromApi(api: ApiScheduleRole, opts: { zone: Zone, locale: string }) { return new this( api.id, DateTime.fromISO(api.updatedAt, opts), @@ -496,7 +498,7 @@ export class ClientScheduleRole extends ClientEntity { ); } - override apiUpdate(api: Living, opts: { zone: Zone, locale: string }) { + override apiUpdate(api: ApiScheduleRole, opts: { zone: Zone, locale: string }) { const wasModified = this.isModified(); this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts); this.serverDeleted = false; @@ -507,7 +509,7 @@ export class ClientScheduleRole extends ClientEntity { } } - toApi(): ApiScheduleRole { + toApi(): ApiScheduleRole | ApiTombstone { if (this.deleted) { return { id: this.id, @@ -524,7 +526,7 @@ export class ClientScheduleRole extends ClientEntity { } } -export class ClientScheduleShift extends ClientEntity { +export class ClientScheduleShift extends ClientEntity { schedule!: ClientSchedule; serverRoleId: Id; serverName: string; @@ -616,7 +618,7 @@ export class ClientScheduleShift extends ClientEntity { } static fromApi( - api: Living, + api: ApiScheduleShift, opts: { zone: Zone, locale: string }, ) { return new this( @@ -631,7 +633,7 @@ export class ClientScheduleShift extends ClientEntity { } override apiUpdate( - api: Living, + api: ApiScheduleShift, opts: { zone: Zone, locale: string }, ) { const wasModified = this.isModified(); @@ -646,7 +648,7 @@ export class ClientScheduleShift extends ClientEntity { } } - toApi(): ApiScheduleShift { + toApi(): ApiScheduleShift | ApiTombstone { if (this.deleted) { return { id: this.id, @@ -787,7 +789,7 @@ export class ClientScheduleShiftSlot { } } -export class ClientSchedule extends ClientEntity { +export class ClientSchedule extends ClientEntity { nextClientId = -1; constructor( @@ -854,7 +856,7 @@ export class ClientSchedule extends ClientEntity { } } - static fromApi(api: Living, opts: { zone: Zone, locale: string }) { + static fromApi(api: ApiSchedule, opts: { zone: Zone, locale: string }) { const eventSlots = idMap((api.events ?? []) .filter(event => !event.deleted) .flatMap(event => event.slots.map(slot => ClientScheduleEventSlot.fromApi(slot, event.id, opts))) @@ -890,7 +892,7 @@ export class ClientSchedule extends ClientEntity { return schedule; } - toApi(diff = false): ApiSchedule { + toApi(diff = false): ApiSchedule | ApiTombstone { if (this.deleted) { return { id: this.id, @@ -908,7 +910,7 @@ export class ClientSchedule extends ClientEntity { } } - override apiUpdate(update: Living, opts: { zone: Zone, locale: string }) { + override apiUpdate(update: ApiSchedule, opts: { zone: Zone, locale: string }) { if (update.deleted) throw new Error("ClientSchedule.apiUpdate: Unexpected deletion"); if (update.id !== this.id) diff --git a/utils/client-user.ts b/utils/client-user.ts index f5b004f..28f323b 100644 --- a/utils/client-user.ts +++ b/utils/client-user.ts @@ -1,9 +1,9 @@ import type { ApiUser, ApiUserType } from "~/shared/types/api"; -import type { Entity, EntityLiving, Id, Living } from "~/shared/types/common"; +import type { Id } from "~/shared/types/common"; import { DateTime, Zone } from "~/shared/utils/luxon"; import { ClientEntity } from "~/utils/client-entity"; -export class ClientUser extends ClientEntity { +export class ClientUser extends ClientEntity { serverName: string | undefined; serverType: ApiUserType @@ -52,7 +52,7 @@ export class ClientUser extends ClientEntity { ) } - static fromApi(api: Living, opts: { zone: Zone, locale: string }) { + static fromApi(api: ApiUser, opts: { zone: Zone, locale: string }) { return new this( api.id, DateTime.fromISO(api.updatedAt, opts), @@ -62,7 +62,7 @@ export class ClientUser extends ClientEntity { ); } - override apiUpdate(api: Living, opts: { zone: Zone, locale: string }) { + override apiUpdate(api: ApiUser, opts: { zone: Zone, locale: string }) { const wasModified = this.isModified(); this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts); this.serverDeleted = false;