server
tcmts.examples.polls.server · #ccfaemto · ts file
// tcm: namespace polls
// The server side. Nothing says "server": these functions use the server library (tables, sessions; tcm's, mounted
// at polls.lib.server) and Node, so tcm places them on the server, and the browser code that calls them gets stubs
// that call them by hash.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { enqueueAfter, every, HttpError, rateLimit, request, session, setSession, table, withRequest } from "#codebase/polls/lib/server";
import { chart, isOpen, tally, verdict, type Poll, type Tally, type Vote } from "#codebase/polls";
/**
* Every {@link Poll}, by id. Rows are checked against the `Poll` type when written, and dates come back as dates.
* Browse them in the editor's Data view, or with `tcm data polls`.
*/
export const polls = table<Poll>("polls");
/**
* Every {@link Vote}, one row per poll and voter: the id is `<poll>:<voter>`, so voting again in the same poll
* replaces your vote instead of adding one. Indexed by poll, which is how {@link results} reads them.
*/
export const votes = table<Vote>("votes", { index: ["poll"] });
/**
* Who's voting: a random id kept in this browser's session (made on its first vote). A session cookie holds only
* a session id; the voter id stays on the server.
*/
function voterId(): string {
const id = session().voter;
if (typeof id === "string") return id;
const fresh = randomUUID();
setSession({ voter: fresh });
return fresh;
}
/**
* A poll that still takes votes.
*
* @throws a 404 if there's no such poll, a 409 if it has closed (see {@link isOpen})
*/
async function openPoll(id: string): Promise<Poll> {
const poll = await polls.get(id);
if (!poll) throw new HttpError(404, "no such poll");
if (!isOpen(poll, new Date())) throw new HttpError(409, "this poll has closed");
return poll;
}
/**
* A poll's current results: its {@link votes}, counted by {@link tally}.
*
* @throws a 404 if there's no such poll
*/
export async function results(id: string): Promise<Tally> {
const poll = await polls.get(id);
if (!poll) throw new HttpError(404, "no such poll");
return tally(poll, (await votes.find({ poll: id })).map((r) => r.value));
}
/**
* Start a poll. When it closes, a background job ({@link announce}) announces the result: the job is queued now,
* pinned to this code's hash, so a deploy in between doesn't change what it does.
*
* Called from the browser, so it's an endpoint: its arguments are checked against these types before it runs,
* and it's rate limited per client.
*
* @param question what the poll asks
* @param options at least two answers
* @param minutes how long it takes votes
* @returns the new poll's id
* @throws a 400 for a poll without a question or with fewer than two options; a 429 beyond five a minute
*
* ```ts
* withRequest({ ip: "127.0.0.1" }, () => createPoll("Lunch?", ["pizza", "salad"], 5)).then((r) => typeof r.value)
* ```
*/
export async function createPoll(question: string, options: string[], minutes: number): Promise<string> {
rateLimit(`create:${request().ip}`, { max: 5, per: 60 });
if (!question.trim() || options.length < 2) throw new HttpError(400, "a poll needs a question and at least two options");
const id = await polls.insert({ question, options, closesAt: new Date(Date.now() + minutes * 60_000) });
enqueueAfter(minutes * 60, announce, id);
return id;
}
/**
* Vote, or change your vote, in an open poll. Your browser's session says who you are ({@link voterId}).
*
* @param id the poll
* @param option an index into the poll's options
* @returns the poll's results, with your vote counted
* @throws a 400 for an option the poll doesn't have; a 404 or 409 if the poll is missing or closed
*
* Two voters, in memory (two requests without cookies are two sessions):
*
* ```ts
* const lunch = await withRequest({}, () => createPoll("Lunch?", ["pizza", "salad"], 5));
* const first = await withRequest({}, () => vote(lunch.value, 1));
* const second = await withRequest({}, () => vote(lunch.value, 0));
* chart(second.value)
* ```
*/
export async function vote(id: string, option: number): Promise<Tally> {
const poll = await openPoll(id);
if (!Number.isInteger(option) || option < 0 || option >= poll.options.length) throw new HttpError(400, "no such option");
await votes.put(`${id}:${voterId()}`, { poll: id, voter: voterId(), option });
return results(id);
}
/** The 20 polls closing last (newest first), each with whether it still takes votes. The page lists these. */
export async function listPolls(): Promise<{ id: string; question: string; options: string[]; open: boolean }[]> {
const now = new Date();
return (await polls.find({}, { orderBy: "closesAt", desc: true, limit: 20 })).map((r) => ({ id: r.id, question: r.value.question, options: r.value.options, open: isOpen(r.value, now) }));
}
/**
* Live results: a fresh {@link Tally} every second while the poll is open, then the final one. It's an async
* generator, so tcm streams it: the browser iterates it with `for await`, and leaving the loop (or closing the
* page) stops it here too.
*/
export async function* watchResults(id: string): AsyncGenerator<Tally> {
const signal = request().signal;
for (let i = 0; i < 600 && !signal.aborted; i++) {
yield await results(id);
const poll = await polls.get(id);
if (!poll || !isOpen(poll, new Date())) return;
await new Promise((r) => setTimeout(r, 1000));
}
}
/**
* Runs as a background job when a poll closes (queued by {@link createPoll}) and logs its {@link verdict}. A job
* records the hash of the code that queued it, so a deploy between queueing and running can't change it.
*/
export async function announce(id: string): Promise<void> {
const poll = await polls.get(id);
if (poll) console.log(`poll closed: ${verdict(poll, await results(id))}`);
}
/** Forget polls that closed more than a week ago, and their {@link votes}. Run nightly by {@link nightly}. */
export async function forgetOldPolls(): Promise<void> {
const cutoff = Date.now() - 7 * 24 * 3600_000;
for (const p of await polls.find()) {
if (p.value.closesAt.getTime() > cutoff) continue;
for (const v of await votes.find({ poll: p.id })) await votes.delete(v.id);
await polls.delete(p.id);
}
}
/**
* A recurring job: {@link forgetOldPolls} at 03:00 UTC every night. `tcm start` and `tcm dev` find it in the app's
* namespace; each night runs once, however many servers there are.
*/
export const nightly = every("0 3 * * *", forgetOldPolls);
/**
* Voting again from the same browser changes your vote instead of adding one. Server code is testable in one
* process: tables live in memory, and `withRequest` stands in for a browser (passing its cookies back keeps the
* session).
*/
export const testVotingAgainChangesYourVote = async () => {
const { value: id } = await withRequest({ ip: "1.2.3.4" }, () => createPoll("Lunch?", ["pizza", "salad"], 5));
const first = await withRequest({}, () => vote(id, 0));
const cookies = Object.fromEntries(first.setCookies.map((c) => c.split(";")[0]!.split("=") as [string, string]));
await withRequest({ cookies }, () => vote(id, 1)); // same browser: replaces its vote
await withRequest({}, () => vote(id, 1)); // another browser
assert.deepEqual((await results(id)).map((r) => [r.option, r.votes]), [["salad", 2], ["pizza", 0]]);
};
used by