# Waitlist

Published by Croft · Category: Marketing

A product waitlist with a public join form (name, email, company) and a private list with new, invited, converted, and counts.

Waitlist is the whole of a private-beta sign-up flow in two pages. The public one is a join form — full name, email, company, an optional role, and an optional "how did you hear about us" — that ends in a proper success state rather than a page reload. The private one is the list behind it: every signup with the company and source they came in through, counts for new, invited and converted, a share of the list that has converted, a strip of the last eight weeks of joins, search across name, email and company, and one-click moves from new to invited to converted. Signups can also be added, edited, or removed by hand for the ones that arrive by email or in a hallway.

Under the hood there is a single callable workflow and one append-only record stream, `signups`. Every write is a full snapshot of the row keyed by a stable id, so an edit is just a newer append and a removal is a tombstone rather than an erasure — the history of who joined when, and when they were invited, stays intact. Both pages read current state with a newest-row-per-id window query, so there is no reconciliation step and nothing to migrate. The public page deliberately declares no record streams at all: it is write-only, because a page anyone can open should not be able to read back everybody else's email address.

It is built for a small team running an early-access list without wiring up a form builder, a spreadsheet, and an email tool to each other. Point the public link at a landing page, a launch post, or a footer, and work the private list as the invites go out. The two statuses past "new" are the ones that matter — who has been sent an invite, and who actually turned up — and the counts across the top answer the only question anyone asks about a waitlist, which is whether it is converting.

## Requirements

Requires no connections. Runs on demand, when called directly.

### Record streams

- signups (write)

## Source

#### `index.ts`

```typescript
import { callable, workflow, z } from "@croft/sdk";

/**
 * Waitlist — the write engine behind the waitlist app.
 *
 * Every entity lives in an append-only record stream as an event log: each
 * append is a full snapshot of the row, and the newest row per `id` wins.
 * Removals append a tombstone (`_op: "gone"`) rather than erasing history.
 * The artifact pages read current state with a `row_number()` window query.
 *
 * `_op` is "live"/"gone" and never the word "delete", because `records.query`
 * rejects COPY EXPORT INSTALL LOAD ATTACH CREATE INSERT UPDATE DELETE PRAGMA
 * as whole words anywhere in the SQL text — string literals included.
 *
 * The appends are written out one stream at a time, rather than through a
 * computed stream name, so the analyzer can name every stream this workflow
 * touches and an install plan can list them.
 */

const Input = z.object({
  entity: z
    .enum(["signups"])
    .describe("Which record stream to write"),
  action: z
    .enum(["save", "remove"])
    .describe("save writes a full snapshot of the row; remove appends a tombstone"),
  id: z
    .string()
    .describe("Stable id for the row. Reuse it to update; omit on create to mint one.")
    .optional(),
  data: z
    .record(z.string(), z.any())
    .describe(
      "Flat field map. signups{name,email,company,role,status,source,notes,joined_on}. status is one of new, invited, converted.",
    )
    .optional(),
});

export default workflow(
  "waitlist",
  {
    trigger: callable({
      description:
        "Join the waitlist, or update or remove a signup: name, email, company, and a status of new, invited, or converted.",
      input: Input,
    }),
    connections: [],
    description:
      "Write engine for the waitlist app: a public join form appends a signup, and the private list moves it through new, invited, and converted.",
  },
  async (ctx) => {
    const { entity, action, id, data } = ctx.input;

    const row = await ctx.step("Build row", () => {
      const rowId = id ?? `${entity.slice(0, 3)}_${Math.random().toString(36).slice(2, 10)}`;
      return {
        ...(action === "remove" ? {} : (data ?? {})),
        id: rowId,
        _op: action === "remove" ? "gone" : "live",
        _ts: new Date().toISOString(),
      };
    });

    if (entity === "signups") {
      await ctx.records.append("signups", [row]);
    }

    return { id: row.id, entity, action, ts: row._ts };
  },
);

```

## Install

Install this blueprint: https://croft.now/blueprints/croft-now/waitlist/install

---
View online: https://croft.now/blueprints/croft-now/waitlist
