Blueprint · Marketing

Waitlist

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

Published by Croft · version 1 · waitlistsignupsearly accesslead capturepublic formmarketing

What this needs

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

Record streams

  • signups (write)

About this blueprint

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.

How it works

waitlist
Build row
Build row
if entity === "signups"
if entity === "signups"
records.append("signups", […])
Append Records
done
Source — 1 file, show

index.ts

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 };
  },
);

Want this running in your own workspace?

Installing recompiles this exact version in your workspace, lands it paused, and walks you through granting whatever it needs above. No account yet? Installing creates one.