fix: keep tierlist saves consistent
Build and Deploy Next Preview / build-deploy (push) Successful in 2m52s
Build and Deploy Next Preview / build-deploy (push) Successful in 2m52s
This commit is contained in:
+97
-43
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { fetch, randomUUIDv7 } from "bun";
|
||||
import { and, eq, inArray, isNotNull, lt, not, or, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, isNotNull, lt, not, sql } from "drizzle-orm";
|
||||
import type { PgDatabase } from "drizzle-orm/pg-core";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
tierlistVersions,
|
||||
} from "./db/schema";
|
||||
import { sse, tlSse } from "./db/sse-endpoints";
|
||||
import type { TierlistWriteResult } from "./tierlist-sync";
|
||||
import {
|
||||
finishTierlistWrite,
|
||||
lockTierlistList,
|
||||
normalizeTierlistPlacements,
|
||||
resolveTierlistStateTarget,
|
||||
validateTierlistStateInput,
|
||||
} from "./tierlist-write";
|
||||
import { b2s } from "./utils";
|
||||
|
||||
export async function getCharacters(chars: string[]) {
|
||||
@@ -262,60 +270,106 @@ export async function revalidateCard(sub: string) {
|
||||
|
||||
export async function tlState(
|
||||
data: Partial<typeof tierlistStates.$inferInsert>,
|
||||
) {
|
||||
): Promise<TierlistWriteResult<(typeof tierlistStates.$inferSelect)[]>> {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(
|
||||
or(
|
||||
eq(tierlistStates.uuid, `${data.uuid}`),
|
||||
and(
|
||||
eq(tierlistStates.ref, `${data.ref}`),
|
||||
eq(tierlistStates.list, `${data.list}`),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (existing)
|
||||
await db
|
||||
.update(tierlistStates)
|
||||
.set(data)
|
||||
.where(eq(tierlistStates.uuid, existing.uuid));
|
||||
else
|
||||
await db
|
||||
.insert(tierlistStates)
|
||||
.values(data as typeof tierlistStates.$inferInsert);
|
||||
const list = data.list || existing?.list;
|
||||
const states = await db
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(eq(tierlistStates.list, list));
|
||||
|
||||
revalidatePath(`/api/tl/${list}/states`);
|
||||
const { list, ref } = validateTierlistStateInput(data);
|
||||
|
||||
await actionLog(`Updated a state in tierlist ${list}`, data);
|
||||
tlSse(list).pub("update_states", states);
|
||||
const states = await db.transaction(async (tx) => {
|
||||
await lockTierlistList(tx, list);
|
||||
const [byUuid] = data.uuid
|
||||
? await tx
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(eq(tierlistStates.uuid, data.uuid))
|
||||
.limit(1)
|
||||
: [];
|
||||
// If legacy duplicate refs exist, update the lexicographically first UUID
|
||||
// deterministically. No duplicate rows are deleted by this write.
|
||||
const [byRef] = await tx
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(and(eq(tierlistStates.ref, ref), eq(tierlistStates.list, list)))
|
||||
.orderBy(asc(tierlistStates.uuid))
|
||||
.limit(1);
|
||||
const existing = resolveTierlistStateTarget(byUuid, byRef, list);
|
||||
|
||||
if (existing) {
|
||||
const { uuid: _uuid, ...updates } = data;
|
||||
await tx
|
||||
.update(tierlistStates)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(tierlistStates.uuid, existing.uuid),
|
||||
eq(tierlistStates.list, list),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await tx
|
||||
.insert(tierlistStates)
|
||||
.values(data as typeof tierlistStates.$inferInsert);
|
||||
}
|
||||
|
||||
return tx
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(eq(tierlistStates.list, list))
|
||||
.orderBy(asc(tierlistStates.uuid));
|
||||
});
|
||||
|
||||
return finishTierlistWrite({
|
||||
list,
|
||||
operation: "state",
|
||||
data: states,
|
||||
audit: () => actionLog(`Updated a state in tierlist ${list}`, data),
|
||||
publish: () => tlSse(list).pub("update_states", states),
|
||||
revalidate: () => revalidatePath(`/api/tl/${list}/states`),
|
||||
});
|
||||
}
|
||||
|
||||
export async function tlPlacements(
|
||||
list: string,
|
||||
placements: Record<string, string[]>,
|
||||
) {
|
||||
): Promise<TierlistWriteResult<Record<string, string[]>>> {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
const { untiered: _, ...placementObj } = placements;
|
||||
if (typeof list !== "string" || !list.trim())
|
||||
throw new Error("A tierlist version is required.");
|
||||
if (
|
||||
!placements ||
|
||||
typeof placements !== "object" ||
|
||||
Array.isArray(placements) ||
|
||||
Object.entries(placements).some(
|
||||
([tier, refs]) =>
|
||||
!tier.trim() ||
|
||||
!Array.isArray(refs) ||
|
||||
refs.some((ref) => typeof ref !== "string"),
|
||||
)
|
||||
)
|
||||
throw new Error("Invalid tierlist placements.");
|
||||
|
||||
await db
|
||||
.update(tierlistVersions)
|
||||
.set({
|
||||
placements: placementObj,
|
||||
})
|
||||
.where(eq(tierlistVersions.id, list));
|
||||
// Clone before removing the client-only untiered bucket; callers retain their
|
||||
// original object and the legacy SSE event still receives its raw payload.
|
||||
const placementObj = normalizeTierlistPlacements(placements);
|
||||
const [version] = await db.transaction(async (tx) => {
|
||||
await lockTierlistList(tx, list);
|
||||
return tx
|
||||
.update(tierlistVersions)
|
||||
.set({ placements: placementObj })
|
||||
.where(eq(tierlistVersions.id, list))
|
||||
.returning({ placements: tierlistVersions.placements });
|
||||
});
|
||||
if (!version) throw new Error("Tierlist version not found.");
|
||||
|
||||
revalidatePath(`/api/tl/${list}`);
|
||||
|
||||
await actionLog(`Updated a placement in tierlist ${list}`);
|
||||
tlSse(list).pub("update_placements", placements);
|
||||
return finishTierlistWrite({
|
||||
list,
|
||||
operation: "placements",
|
||||
data: version.placements,
|
||||
audit: () => actionLog(`Updated a placement in tierlist ${list}`),
|
||||
publish: () => tlSse(list).pub("update_placements", placements),
|
||||
revalidate: () => revalidatePath(`/api/tl/${list}`),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cdnDelete(ids: string[], force = false) {
|
||||
|
||||
+6
-1
@@ -136,10 +136,15 @@ export class EventSourceEndpoint<T extends EventSourceEventMap> {
|
||||
throw new Error(
|
||||
"EventSourceEndpoint.pub(...) can only be called on the server.",
|
||||
);
|
||||
this.manager.publish(this.eventMap[event].parse(data), {
|
||||
const publication = this.manager.publish(this.eventMap[event].parse(data), {
|
||||
topic: this.endpoint,
|
||||
event: String(event),
|
||||
});
|
||||
// Existing publishers intentionally fire-and-forget. Mark the rejection as
|
||||
// observed for those callers while still returning the original promise to
|
||||
// callers that need to await delivery.
|
||||
void publication.catch(() => {});
|
||||
return publication;
|
||||
}
|
||||
|
||||
subMany(
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
reconcileCanonicalStates,
|
||||
reconcileScopedStateWrite,
|
||||
TierlistCanonicalFetch,
|
||||
} from "./tierlist-canonical-fetch";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("TierlistCanonicalFetch", () => {
|
||||
it("fetches the switched-to version while ignoring the older response", async () => {
|
||||
const coordinator = new TierlistCanonicalFetch<string>();
|
||||
const old = deferred<string>();
|
||||
const newVersion = deferred<string>();
|
||||
const applied: string[] = [];
|
||||
coordinator.setVersion("A");
|
||||
coordinator.fetch(
|
||||
"A",
|
||||
() => old.promise,
|
||||
(value) => applied.push(value),
|
||||
() => {},
|
||||
);
|
||||
coordinator.setVersion("B");
|
||||
coordinator.fetch(
|
||||
"B",
|
||||
() => newVersion.promise,
|
||||
(value) => applied.push(value),
|
||||
() => {},
|
||||
);
|
||||
old.resolve("A");
|
||||
await flush();
|
||||
expect(applied).toEqual([]);
|
||||
newVersion.resolve("B");
|
||||
await flush();
|
||||
expect(applied).toEqual(["B"]);
|
||||
});
|
||||
|
||||
it("refetches after an SSE invalidation during the initial fetch", async () => {
|
||||
const coordinator = new TierlistCanonicalFetch<string>();
|
||||
const first = deferred<string>();
|
||||
const applied: string[] = [];
|
||||
let fetchCount = 0;
|
||||
coordinator.setVersion("A");
|
||||
const load = () =>
|
||||
++fetchCount === 1 ? first.promise : Promise.resolve("latest");
|
||||
const fetch = () =>
|
||||
coordinator.fetch(
|
||||
"A",
|
||||
load,
|
||||
(value) => applied.push(value),
|
||||
() => {},
|
||||
);
|
||||
fetch();
|
||||
fetch();
|
||||
first.resolve("stale");
|
||||
await flush();
|
||||
expect(fetchCount).toBe(2);
|
||||
expect(applied).toEqual(["latest"]);
|
||||
});
|
||||
|
||||
it("does not start a replacement after disposal", async () => {
|
||||
const coordinator = new TierlistCanonicalFetch<string>();
|
||||
const first = deferred<string>();
|
||||
let fetchCount = 0;
|
||||
coordinator.setVersion("A");
|
||||
const load = () =>
|
||||
++fetchCount === 1 ? first.promise : Promise.resolve("next");
|
||||
coordinator.fetch(
|
||||
"A",
|
||||
load,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
coordinator.fetch(
|
||||
"A",
|
||||
load,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
coordinator.dispose();
|
||||
first.resolve("stale");
|
||||
await flush();
|
||||
expect(fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves a local edit made during a fetch while reconciling other refs", () => {
|
||||
const merged = reconcileCanonicalStates(
|
||||
[
|
||||
{ ref: "one", value: "stale server value" },
|
||||
{ ref: "two", value: "fresh server value" },
|
||||
],
|
||||
[
|
||||
{ ref: "one", value: "local edit" },
|
||||
{ ref: "two", value: "old local value" },
|
||||
],
|
||||
new Set(["one"]),
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ ref: "two", value: "fresh server value" },
|
||||
{ ref: "one", value: "local edit" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes out-of-order server write responses to their own ref", () => {
|
||||
let states = [
|
||||
{ ref: "one", value: "newer one" },
|
||||
{ ref: "two", value: "newer two" },
|
||||
];
|
||||
states = reconcileScopedStateWrite(states, "two", [
|
||||
{ ref: "one", value: "stale one" },
|
||||
{ ref: "two", value: "saved two" },
|
||||
]);
|
||||
states = reconcileScopedStateWrite(states, "one", [
|
||||
{ ref: "one", value: "saved one" },
|
||||
{ ref: "two", value: "stale two" },
|
||||
]);
|
||||
expect(states).toEqual([
|
||||
{ ref: "two", value: "saved two" },
|
||||
{ ref: "one", value: "saved one" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Keeps canonical reads current across invalidations and version switches. */
|
||||
export function reconcileCanonicalStates<T extends { ref: string }>(
|
||||
canonical: T[],
|
||||
current: T[],
|
||||
protectedRefs: Set<string>,
|
||||
): T[] {
|
||||
return [
|
||||
...canonical.filter((entry) => !protectedRefs.has(entry.ref)),
|
||||
...current.filter((entry) => protectedRefs.has(entry.ref)),
|
||||
];
|
||||
}
|
||||
|
||||
export function reconcileScopedStateWrite<T extends { ref: string }>(
|
||||
current: T[],
|
||||
ref: string,
|
||||
saved: T[],
|
||||
): T[] {
|
||||
return [
|
||||
...current.filter((entry) => entry.ref !== ref),
|
||||
...saved.filter((entry) => entry.ref === ref),
|
||||
];
|
||||
}
|
||||
|
||||
export class TierlistCanonicalFetch<T> {
|
||||
private activeVersion: string | undefined;
|
||||
private disposed = false;
|
||||
private readonly requests = new Map<
|
||||
string,
|
||||
{ generation: number; invalidated: boolean; inFlight: boolean }
|
||||
>();
|
||||
|
||||
activate(version: string) {
|
||||
this.disposed = false;
|
||||
this.activeVersion = version;
|
||||
}
|
||||
|
||||
setVersion(version: string) {
|
||||
if (this.activeVersion === version) return;
|
||||
this.activeVersion = version;
|
||||
const request = this.requests.get(version);
|
||||
if (request?.inFlight) request.invalidated = true;
|
||||
}
|
||||
|
||||
invalidate(version: string) {
|
||||
const request = this.requests.get(version);
|
||||
if (request?.inFlight) request.invalidated = true;
|
||||
}
|
||||
|
||||
fetch(
|
||||
version: string,
|
||||
load: () => Promise<T>,
|
||||
apply: (data: T) => void,
|
||||
onError: (error: unknown) => void,
|
||||
onSettled: () => void = () => {},
|
||||
) {
|
||||
if (this.disposed || this.activeVersion !== version) return;
|
||||
let request = this.requests.get(version);
|
||||
if (request?.inFlight) {
|
||||
request.invalidated = true;
|
||||
return;
|
||||
}
|
||||
request ??= { generation: 0, invalidated: false, inFlight: false };
|
||||
request.inFlight = true;
|
||||
request.invalidated = false;
|
||||
const generation = ++request.generation;
|
||||
this.requests.set(version, request);
|
||||
|
||||
void load()
|
||||
.then(
|
||||
(data) => {
|
||||
if (
|
||||
!this.disposed &&
|
||||
this.activeVersion === version &&
|
||||
request?.generation === generation &&
|
||||
!request.invalidated
|
||||
)
|
||||
apply(data);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!this.disposed && this.activeVersion === version) onError(error);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (request?.generation !== generation) return;
|
||||
request.inFlight = false;
|
||||
if (
|
||||
this.disposed ||
|
||||
this.activeVersion !== version ||
|
||||
!request.invalidated
|
||||
) {
|
||||
if (this.activeVersion === version) onSettled();
|
||||
return;
|
||||
}
|
||||
request.invalidated = false;
|
||||
this.fetch(version, load, apply, onError, onSettled);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
this.requests.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { TierlistSaveQueue } from "./tierlist-save-queue";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("TierlistSaveQueue", () => {
|
||||
it("serializes writes and persists the latest coalesced value", async () => {
|
||||
const writes: string[] = [];
|
||||
const first = deferred<string>();
|
||||
const second = deferred<string>();
|
||||
const queue = new TierlistSaveQueue<string, string>(
|
||||
(value) => {
|
||||
writes.push(value);
|
||||
return writes.length === 1 ? first.promise : second.promise;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
const a = queue.enqueue("a");
|
||||
const b = queue.enqueue("b");
|
||||
const c = queue.enqueue("c");
|
||||
await flushMicrotasks();
|
||||
expect(writes).toEqual(["a"]);
|
||||
first.resolve("saved a");
|
||||
await flushMicrotasks();
|
||||
expect(writes).toEqual(["a", "c"]);
|
||||
second.resolve("saved c");
|
||||
await expect(Promise.all([a, b, c])).resolves.toEqual([
|
||||
"saved a",
|
||||
"saved c",
|
||||
"saved c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains failed intent for explicit retry", async () => {
|
||||
const writes: string[] = [];
|
||||
const retry = deferred<string>();
|
||||
const queue = new TierlistSaveQueue<string, string>(
|
||||
(value) => {
|
||||
writes.push(value);
|
||||
if (writes.length === 1) return Promise.reject(new Error("offline"));
|
||||
return retry.promise;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
await expect(queue.enqueue("latest")).rejects.toThrow("offline");
|
||||
const retried = queue.retry();
|
||||
await flushMicrotasks();
|
||||
expect(writes).toEqual(["latest", "latest"]);
|
||||
retry.resolve("saved");
|
||||
await expect(retried).resolves.toBe("saved");
|
||||
});
|
||||
|
||||
it("continues with a newer queued value after an older write fails", async () => {
|
||||
const writes: string[] = [];
|
||||
const latest = deferred<string>();
|
||||
const queue = new TierlistSaveQueue<string, string>(
|
||||
(value) => {
|
||||
writes.push(value);
|
||||
return writes.length === 1
|
||||
? Promise.reject(new Error("offline"))
|
||||
: latest.promise;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
const oldIntent = queue.enqueue("old");
|
||||
const latestIntent = queue.enqueue("latest");
|
||||
await flushMicrotasks();
|
||||
await expect(oldIntent).rejects.toThrow("offline");
|
||||
await flushMicrotasks();
|
||||
expect(writes).toEqual(["old", "latest"]);
|
||||
latest.resolve("saved latest");
|
||||
await expect(latestIntent).resolves.toBe("saved latest");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
export type SaveQueueResult<T, R> = {
|
||||
generation: number;
|
||||
value: T;
|
||||
result: R;
|
||||
};
|
||||
|
||||
type Waiter<R> = {
|
||||
generation: number;
|
||||
resolve: (result: R) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/** Serializes writes while coalescing edits made during an in-flight write. */
|
||||
export class TierlistSaveQueue<T, R> {
|
||||
private generation = 0;
|
||||
private desired: { generation: number; value: T } | undefined;
|
||||
private writing = false;
|
||||
private disposed = false;
|
||||
private waiters: Waiter<R>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly write: (value: T) => Promise<R>,
|
||||
private readonly onSuccess: (saved: SaveQueueResult<T, R>) => void,
|
||||
private readonly onPendingChange: (pending: boolean) => void = () => {},
|
||||
private readonly onFailure: (error: unknown, value: T) => void = () => {},
|
||||
) {}
|
||||
|
||||
get isPending() {
|
||||
return this.writing;
|
||||
}
|
||||
|
||||
enqueue(value: T): Promise<R> {
|
||||
if (this.disposed)
|
||||
return Promise.reject(new Error("Save queue is disposed"));
|
||||
const generation = ++this.generation;
|
||||
this.desired = { generation, value };
|
||||
this.onPendingChange(true);
|
||||
const promise = new Promise<R>((resolve, reject) => {
|
||||
this.waiters.push({ generation, resolve, reject });
|
||||
});
|
||||
this.flush();
|
||||
return promise;
|
||||
}
|
||||
|
||||
retry(): Promise<R> {
|
||||
if (!this.desired)
|
||||
return Promise.reject(new Error("No failed save to retry"));
|
||||
return this.enqueue(this.desired.value);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.desired = undefined;
|
||||
for (const waiter of this.waiters)
|
||||
waiter.reject(new Error("Save queue disposed"));
|
||||
this.waiters = [];
|
||||
this.onPendingChange(false);
|
||||
}
|
||||
|
||||
private flush() {
|
||||
if (this.writing || !this.desired) return;
|
||||
const attempted = this.desired;
|
||||
this.writing = true;
|
||||
this.onPendingChange(true);
|
||||
void Promise.resolve()
|
||||
.then(() => this.write(attempted.value))
|
||||
.then(
|
||||
(result) => {
|
||||
if (this.disposed) return;
|
||||
this.onSuccess({ ...attempted, result });
|
||||
this.waiters = this.waiters.filter((waiter) => {
|
||||
if (waiter.generation > attempted.generation) return true;
|
||||
waiter.resolve(result);
|
||||
return false;
|
||||
});
|
||||
if (this.desired?.generation === attempted.generation)
|
||||
this.desired = undefined;
|
||||
this.writing = false;
|
||||
this.onPendingChange(false);
|
||||
this.flush();
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (this.disposed) return;
|
||||
this.onFailure(error, attempted.value);
|
||||
this.waiters = this.waiters.filter((waiter) => {
|
||||
if (waiter.generation > attempted.generation) return true;
|
||||
waiter.reject(error);
|
||||
return false;
|
||||
});
|
||||
this.writing = false;
|
||||
this.onPendingChange(
|
||||
!!this.desired && this.desired.generation > attempted.generation,
|
||||
);
|
||||
// Continue newer work; otherwise keep failed intent for explicit retry.
|
||||
if (this.desired && this.desired.generation > attempted.generation)
|
||||
this.flush();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { tierlistStates } from "./db/schema";
|
||||
|
||||
export type TierlistState = typeof tierlistStates.$inferSelect;
|
||||
|
||||
export type TierlistSyncData = {
|
||||
states: TierlistState[];
|
||||
placements: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type TierlistWriteResult<T> = {
|
||||
persisted: true;
|
||||
data: T;
|
||||
publication: "sent" | "failed";
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import type { tierlistStates } from "./db/schema";
|
||||
import {
|
||||
finishTierlistWrite,
|
||||
lockTierlistList,
|
||||
normalizeTierlistPlacements,
|
||||
resolveTierlistStateTarget,
|
||||
validateTierlistStateInput,
|
||||
} from "./tierlist-write";
|
||||
|
||||
type StateRow = typeof tierlistStates.$inferSelect;
|
||||
|
||||
const stateRow = (uuid: string, list: string, ref = "char-1") =>
|
||||
({ uuid, list, ref }) as StateRow;
|
||||
|
||||
describe("tierlist write consistency helpers", () => {
|
||||
test("validates list, ref, and optional UUID before writing", () => {
|
||||
expect(
|
||||
validateTierlistStateInput({
|
||||
list: "v1",
|
||||
ref: "char-1",
|
||||
uuid: undefined,
|
||||
}),
|
||||
).toEqual({ list: "v1", ref: "char-1" });
|
||||
expect(() =>
|
||||
validateTierlistStateInput({ list: "", ref: "char-1" }),
|
||||
).toThrow();
|
||||
expect(() => validateTierlistStateInput({ list: "v1", ref: "" })).toThrow();
|
||||
expect(() =>
|
||||
validateTierlistStateInput({ list: "v1", ref: "char-1", uuid: "bad" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("rejects cross-list UUIDs and canonicalizes duplicate refs", () => {
|
||||
const uuidMatch = stateRow("uuid-1", "v1");
|
||||
const canonicalRefMatch = stateRow("uuid-0", "v1");
|
||||
expect(() =>
|
||||
resolveTierlistStateTarget(stateRow("uuid-2", "v2"), uuidMatch, "v1"),
|
||||
).toThrow("belongs to another version");
|
||||
expect(resolveTierlistStateTarget(uuidMatch, undefined, "v1")).toBe(
|
||||
uuidMatch,
|
||||
);
|
||||
expect(resolveTierlistStateTarget(uuidMatch, canonicalRefMatch, "v1")).toBe(
|
||||
canonicalRefMatch,
|
||||
);
|
||||
expect(resolveTierlistStateTarget(undefined, uuidMatch, "v1")).toBe(
|
||||
uuidMatch,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses one transaction-scoped advisory lock per list before mutations", async () => {
|
||||
const order: string[] = [];
|
||||
const queries: unknown[] = [];
|
||||
await lockTierlistList(
|
||||
{
|
||||
execute: async (query) => {
|
||||
order.push("lock");
|
||||
queries.push(query);
|
||||
},
|
||||
},
|
||||
"v1",
|
||||
);
|
||||
order.push("write");
|
||||
const compiled = new PgDialect().sqlToQuery(queries[0] as never);
|
||||
expect(compiled.sql).toContain("pg_advisory_xact_lock");
|
||||
expect(compiled.sql).toContain("hashtextextended");
|
||||
expect(compiled.params).toContain("v1");
|
||||
expect(order).toEqual(["lock", "write"]);
|
||||
});
|
||||
|
||||
test("normalizes placements without mutating the caller's untiered data", () => {
|
||||
const placements = { S: ["a"], untiered: ["b"] };
|
||||
expect(normalizeTierlistPlacements(placements)).toEqual({ S: ["a"] });
|
||||
expect(placements).toEqual({ S: ["a"], untiered: ["b"] });
|
||||
});
|
||||
|
||||
test("returns persisted success when publication fails after commit", async () => {
|
||||
const errorLog = spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const result = await finishTierlistWrite({
|
||||
list: "v1",
|
||||
operation: "state",
|
||||
data: [{ uuid: "state-1" }],
|
||||
audit: async () => {},
|
||||
revalidate: () => {},
|
||||
publish: async () => {
|
||||
throw new Error("redis unavailable");
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
persisted: true,
|
||||
data: [{ uuid: "state-1" }],
|
||||
publication: "failed",
|
||||
});
|
||||
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports sent when post-commit publication succeeds", async () => {
|
||||
const result = await finishTierlistWrite({
|
||||
list: "v1",
|
||||
operation: "placements",
|
||||
data: { S: ["char-1"] },
|
||||
audit: async () => {},
|
||||
revalidate: () => {},
|
||||
publish: async () => 1,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
persisted: true,
|
||||
data: { S: ["char-1"] },
|
||||
publication: "sent",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not turn a precommit transaction failure into persisted success", async () => {
|
||||
const failure = new Error("transaction failed");
|
||||
await expect(
|
||||
lockTierlistList({ execute: async () => Promise.reject(failure) }, "v1"),
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { tierlistStates } from "./db/schema";
|
||||
import type { TierlistWriteResult } from "./tierlist-sync";
|
||||
|
||||
type StateInput = Partial<typeof tierlistStates.$inferInsert>;
|
||||
type StateRow = typeof tierlistStates.$inferSelect;
|
||||
|
||||
export function validateTierlistStateInput(data: StateInput) {
|
||||
const { list, ref, uuid } = data;
|
||||
if (typeof list !== "string" || !list.trim())
|
||||
throw new Error("A tierlist version is required.");
|
||||
if (typeof ref !== "string" || !ref.trim())
|
||||
throw new Error("A tierlist state reference is required.");
|
||||
if (
|
||||
uuid !== undefined &&
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
uuid,
|
||||
)
|
||||
)
|
||||
throw new Error("A valid tierlist state UUID is required.");
|
||||
return { list, ref };
|
||||
}
|
||||
|
||||
export function resolveTierlistStateTarget(
|
||||
byUuid: StateRow | undefined,
|
||||
byRef: StateRow | undefined,
|
||||
list: string,
|
||||
) {
|
||||
if (byUuid && byUuid.list !== list)
|
||||
throw new Error("Tierlist state UUID belongs to another version.");
|
||||
if (byUuid && byRef && byUuid.ref === byRef.ref) return byRef;
|
||||
return byUuid ?? byRef;
|
||||
}
|
||||
|
||||
type TransactionLike = {
|
||||
execute(query: ReturnType<typeof sql>): Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function lockTierlistList(tx: TransactionLike, list: string) {
|
||||
await tx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(hashtextextended(${list}, 0))`,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeTierlistPlacements(
|
||||
placements: Record<string, string[]>,
|
||||
) {
|
||||
const { untiered: _untiered, ...persistedPlacements } =
|
||||
structuredClone(placements);
|
||||
return persistedPlacements;
|
||||
}
|
||||
|
||||
export async function finishTierlistWrite<T>({
|
||||
list,
|
||||
operation,
|
||||
data,
|
||||
audit,
|
||||
publish,
|
||||
revalidate,
|
||||
}: {
|
||||
list: string;
|
||||
operation: "state" | "placements";
|
||||
data: T;
|
||||
audit: () => Promise<unknown>;
|
||||
publish: () => Promise<unknown>;
|
||||
revalidate: () => void;
|
||||
}): Promise<TierlistWriteResult<T>> {
|
||||
try {
|
||||
revalidate();
|
||||
} catch (error) {
|
||||
logTierlistPostCommitFailure(list, operation, "revalidation", error);
|
||||
}
|
||||
try {
|
||||
await audit();
|
||||
} catch (error) {
|
||||
logTierlistPostCommitFailure(list, operation, "audit", error);
|
||||
}
|
||||
|
||||
let publication: TierlistWriteResult<T>["publication"] = "sent";
|
||||
try {
|
||||
await publish();
|
||||
} catch (error) {
|
||||
publication = "failed";
|
||||
logTierlistPostCommitFailure(list, operation, "publication", error);
|
||||
}
|
||||
return { persisted: true, data, publication };
|
||||
}
|
||||
|
||||
function logTierlistPostCommitFailure(
|
||||
list: string,
|
||||
operation: string,
|
||||
step: string,
|
||||
error: unknown,
|
||||
) {
|
||||
console.error(
|
||||
`Tierlist ${operation} post-commit ${step} failed for ${list} (${error instanceof Error ? error.name : "unknown error"}).`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { shouldSnapshot, type TierlistResolvedConfig } from "./tierlist";
|
||||
|
||||
function config({
|
||||
deprecates = "01/01/2000",
|
||||
chars = [{ id: "char-1" }, { id: "char-2" }],
|
||||
placements = { S: ["char-1#2026-01", "char-2"] },
|
||||
}: {
|
||||
deprecates?: string;
|
||||
chars?: { id: string }[];
|
||||
placements?: Record<string, string[]>;
|
||||
} = {}) {
|
||||
return {
|
||||
version: { deprecates, placements },
|
||||
chars,
|
||||
} as unknown as TierlistResolvedConfig;
|
||||
}
|
||||
|
||||
describe("shouldSnapshot", () => {
|
||||
test("does not snapshot a fully tiered version before its deprecation date", () => {
|
||||
expect(shouldSnapshot(config({ deprecates: "31/12/2999" }))).toBe(false);
|
||||
});
|
||||
|
||||
test("does not snapshot an old version with unfinished placements", () => {
|
||||
expect(shouldSnapshot(config({ placements: { S: ["char-1"] } }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshots an old version only when every character is tiered", () => {
|
||||
expect(shouldSnapshot(config())).toBe(true);
|
||||
});
|
||||
|
||||
test("does not consider an empty character list fully tiered", () => {
|
||||
expect(shouldSnapshot(config({ chars: [] }))).toBe(false);
|
||||
});
|
||||
|
||||
test("does not snapshot when the deprecation date is invalid", () => {
|
||||
expect(
|
||||
shouldSnapshot(
|
||||
config({
|
||||
deprecates: "not-a-date",
|
||||
placements: { S: ["char-1", "char-2"] },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not snapshot when the deprecation date is missing", () => {
|
||||
expect(
|
||||
shouldSnapshot(
|
||||
config({ deprecates: "", placements: { S: ["char-1", "char-2"] } }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -102,7 +102,7 @@ async function buildLiveTierlistConfig(
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSnapshot(config: TierlistResolvedConfig) {
|
||||
export function shouldSnapshot(config: TierlistResolvedConfig) {
|
||||
const deprecatedAt = parseDate(config.version.deprecates);
|
||||
const olderThanMonth =
|
||||
Number.isFinite(deprecatedAt) &&
|
||||
@@ -117,7 +117,7 @@ function shouldSnapshot(config: TierlistResolvedConfig) {
|
||||
config.chars.length > 0 &&
|
||||
config.chars.every((char) => placed.has(char.id));
|
||||
|
||||
return olderThanMonth || allTiered;
|
||||
return olderThanMonth && allTiered;
|
||||
}
|
||||
|
||||
function parseDate(value: string) {
|
||||
|
||||
Reference in New Issue
Block a user