diff --git a/apps/README.md b/apps/README.md index 7c85b36c..63e3821b 100644 --- a/apps/README.md +++ b/apps/README.md @@ -15,6 +15,7 @@ independent project that can be copied, built, and deployed on its own. | ----------------------------------------------- | ------------------------------------------------------------------------- | | [Default app](templates/apps-default/) | A recurring workflow with a durable step and an interactive custom block. | | [Perfect Meetings](templates/perfect-meetings/) | Calendar connection, deterministic triggers and agentic research | +| [Statsig Rollout](templates/statsig-rollout/) | Database-triggered workflow that calls an external API with retries | ## Quick start diff --git a/apps/templates/statsig-rollout/.agents/INSTRUCTIONS.md b/apps/templates/statsig-rollout/.agents/INSTRUCTIONS.md new file mode 100644 index 00000000..1d34c8b9 --- /dev/null +++ b/apps/templates/statsig-rollout/.agents/INSTRUCTIONS.md @@ -0,0 +1,200 @@ +# Apps template guidance + +Apps are a private alpha. Check the installed `@notionhq/apps` exports and +declarations before using a capability; SDK support does not establish that a +provider is enabled on the server. + +## Design the App before implementation + +First establish what the complete App should do: its outcome, source data, +triggers, external services, and every Notion resource it needs. Recommend a +workflow for most automations; use a sync when the goal is to mirror an external +collection into a Notion database. An App may contain both. + +Before proposing the design, read only enough of this guidance and the +top-level descriptions of the relevant capability skills (workflow, sync, +connections, and Notion as Code) to describe concrete options for open +decisions. Do not read full skill instructions, generated declarations +(`*.generated.d.ts`), full provider API surfaces, or other implementation +details until the user agrees on a direction. Verify those details against the +selected option during implementation. + +Present the proposed design concisely and get the user's agreement before +implementing. Include `APP.md` as the App's home page and primary user +interface. It should briefly explain the App, embed its most important views, +and link to supporting Notion resources. The proposal should +include: + +- `APP.md`, the primary views it will embed, and the resources it will link to. +- Other Notion resources the App will create, including databases, pages, and + custom agents; state each resource's purpose and which capabilities use it. +- Every sync, including its external source, destination database, and + synchronization behavior. +- Every workflow, including its trigger, main actions, resources it reads or + changes, and external connections. +- Open decisions and required access. + +Adapt the format to the App. For example: + +### Example App design + +**Outcome:** Bring support tickets into Notion and escalate urgent tickets to +the support team. + +**Notion resources** + +| Kind | Name | Purpose | Used by | +| ------------ | ----------------- | --------------------------------------------------- | -------------------------------- | +| `APP.md` | Support App | Explain the App and embed the ticket triage view | Team members | +| Database | Support tickets | Store synchronized tickets and triage status | Ticket sync, escalation workflow | +| Page | Support dashboard | Give the team an operational home and database view | Team members | +| Custom agent | Ticket triage | Classify urgency and summarize a ticket | Escalation workflow | + +**Syncs and workflows** + +| Kind | Name | Source or trigger | Behavior | Dependencies | +| -------- | ---------------------- | --------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------- | +| Sync | Ticket sync | Support-system tickets | Upsert by stable external ID | Support-system connection, Support tickets database | +| Workflow | Escalate urgent ticket | Support tickets page created or updated | Run triage, update status, and notify support channel | Ticket triage agent, Support tickets database, messaging connection | + +**Open questions:** Which support system and messaging channel should the App use? + +Ask the user to confirm or revise the design. Do not begin implementation +until they agree. If implementation reveals a material resource or capability +not covered by the agreed design, update the proposal and confirm the change +before adding it. + +## App home page (APP.md) + +The app root includes `APP.md`. This file is the App's home page and, as much +as the App needs one, its user interface. Design it as the place people come +to use the App. Update it when the App's behavior or resources change. +The Apps SDK provisions the page in the +installation workspace when the file exists. Its page title is the deployed +App's name, not `APP.md`; do not put a second copy of the title in its body. + +Use the installed SDK's `skills/notion-as-code/markdown.md` reference for +Notion Markdown in `APP.md`, page content, and custom-agent instructions. +Use resource IDs in mentions and database tags. An optional YAML frontmatter +`icon` sets the app page icon; the build removes frontmatter from the body. + +Use this basic structure, adapting the sections to the App: + +1. **Brief description:** Explain what the App does and who it helps in a few + sentences. +2. **Primary views:** Embed the most important databases or specific views, + including custom views, in the order people need them. For a CRM, this + might be Contacts and a pipeline view; for a meeting App, a next-meeting + card and upcoming meetings. Give each view a clear heading and only the + explanation needed to use it. +3. **Common actions:** Explain what to open, add, or change and what happens + next. Include views or controls for those actions when available, along + with useful status indicators and automatic update behavior. +4. **Supporting resources:** Link to other pages and databases people may + need without embedding every resource on the home page. + +Embed linked database views with declared data source or view resource IDs, +including custom view resource IDs. For example, after declaring the view: + +```text +Contacts +``` + +Use a Notion as Code resource reference, such as +`Resource name`, to link to +another page declared by the App. Keep the resource ID in sync with the +corresponding declaration. Use only declared resources in actual embeds and +links; keep illustrative placeholders in code fences. Do not create a separate +home page. Keep developer setup, deployment commands, and implementation +details in the README. + +## SDK imports + +Import the creation helpers you use directly from the package root: + +```ts +import { database, sync } from "@notionhq/apps" +``` + +The root exports `page`, `database`, `teamspace`, `customAgent`, `view`, +`sync`, `workflow`, and `customBlock`. Workflow helpers `access`, `input`, +and `events`, plus workflow types such as `WorkflowContext`, are also available +from the root. +`sync` takes a Notion as Code data source handle. There is no standalone +`dataSource`; declare data sources inside `database`. + +Keep other imports on their existing subpaths: `Builder` from +`@notionhq/apps/builder`, value helpers and types from their own modules, +`connections` from `@notionhq/apps/workflow`, and `events` from +`@notionhq/apps/events` (or use the workflow's typed trigger callback). +Provider input and result types come from `@notionhq/apps/connections/`. +Browser runtime and React APIs also keep their subpaths. + +Check that the installed SDK supports these exports before building. + +## Capability layout + +After the user agrees on the design, compare it with the complete scaffold. +Remove template workflows, syncs, custom blocks, Notion resource declarations, +sample assets, and supporting code that the App does not need. Do not leave +example or placeholder capabilities in discovered capability directories: the +build can include and deploy them. Preserve shared configuration and +infrastructure required by the selected capabilities. + +Each direct TypeScript file in these directories default-exports a declaration. +Its filename supplies the capability key. Put capability-specific helpers in +`src/workflows/lib/` (or the matching capability directory's `lib/`) and shared +helpers in `src/lib/`. Discovery only reads direct children, so these helpers +are not treated as capabilities. + +The App template's `.agents/skills/` files point to the full SDK skills +installed under `./node_modules/@notionhq/apps/skills/`. For every area you +implement, follow the template pointer and read the corresponding installed +SDK skill listed in this table. If the installed SDK skill is missing, install +the App's dependencies; if it remains missing, update `@notionhq/apps` to a +release that includes it. Do not continue without the full SDK guidance. + +| Area | Where used | Template pointer | Full SDK skill | +| -------------- | ------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------ | +| Workflow | `src/workflows/` | `.agents/skills/workflow/SKILL.md` | `node_modules/@notionhq/apps/skills/workflow/SKILL.md` | +| Connections | Workflow provider clients and triggers | `.agents/skills/connections/SKILL.md` | `node_modules/@notionhq/apps/skills/connections/SKILL.md` | +| Database sync | `src/syncs/` | `.agents/skills/sync/SKILL.md` | `node_modules/@notionhq/apps/skills/sync/SKILL.md` | +| Custom blocks | `src/customBlocks/` | `.agents/skills/custom-blocks/SKILL.md` | `node_modules/@notionhq/apps/skills/custom-blocks/SKILL.md` | +| Notion as Code | Resources declared in modules imported by workflows or syncs | `.agents/skills/notion-as-code/SKILL.md` | `node_modules/@notionhq/apps/skills/notion-as-code/SKILL.md` | + +Notion as Code declarations are not a separate discovered capability +directory. Syncs can use their declared data source handles. + +Prefer Notion as Code over other methods for equivalent supported resource +setup. Use another method when the user explicitly requests it, an existing +resource must be attached, or the required operation is not supported by Apps +Notion as Code. Runtime operations on changing data still use the appropriate API. + +Apps do not expose Worker-style tool or webhook registration, Worker fetch +interceptors, or Worker managed-database configuration. Do not translate those +APIs by changing package names. Sync contexts have `notion`, but no workflow +`step` or `connections` client. + +## Runtime and credentials + +Use Node.js 26 or newer and install from the app root. Use `context.notion` +for Notion API access. Deployed apps receive Notion credentials automatically; +local execution needs `NOTION_API_TOKEN` in `.env` before making API requests. +Never commit tokens or generated `dist/` and `.notion/` state. + +## Verification and deployment + +Run `npm run check` and `npm run build` from the app root after changes. +Add offline tests for pagination, transforms, and retry-sensitive behavior. +Building does not verify server availability, published connection setup, or +browser interactions. State separately which live checks actually ran. + +For a requested deployment, enable `ntn experiments enable apps`, authenticate, +and use `ntn apps deploy --json` with any required arguments. On the first +deployment, pass `--name `; omit `--name` for updates. Use the JSON fields +`worker_url`, `setup_url`, and `is_update` in the handoff. For a first +deployment (`is_update` is false), show the `setup_url` and tell the user to +open it to finish setup. For an update (`is_update` is true), show both URLs +with clear labels: `worker_url` opens the deployed App and `setup_url` revisits +onboarding. Use the URLs returned by the CLI; do not construct or guess them. +Read the installed CLI help for other commands. diff --git a/apps/templates/statsig-rollout/.agents/skills/connections/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/connections/SKILL.md new file mode 100644 index 00000000..e90c6217 --- /dev/null +++ b/apps/templates/statsig-rollout/.agents/skills/connections/SKILL.md @@ -0,0 +1,16 @@ +--- +name: connections +description: Configure and use typed provider connections and connection-bound triggers in Notion App workflows. +user-invocable: false +--- + +# App connections + +Before creating, modifying, or troubleshooting connection code, read +`node_modules/@notionhq/apps/skills/connections/SKILL.md`. +Resolve this path relative to the App project root. +Follow the instructions in that file. + +If the file is missing, install the project dependencies. +If it remains missing, update `@notionhq/apps` to a release that includes the skill. +Do not continue with connection changes until you can read the SDK skill. diff --git a/apps/templates/statsig-rollout/.agents/skills/custom-blocks/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/custom-blocks/SKILL.md new file mode 100644 index 00000000..4d8ff3f0 --- /dev/null +++ b/apps/templates/statsig-rollout/.agents/skills/custom-blocks/SKILL.md @@ -0,0 +1,16 @@ +--- +name: custom-blocks +description: Build interactive browser custom blocks declared by a Notion App. +user-invocable: false +--- + +# App custom blocks + +Before creating, modifying, or troubleshooting custom block code, read +`node_modules/@notionhq/apps/skills/custom-blocks/SKILL.md`. +Resolve this path relative to the App project root. +Follow the instructions in that file. + +If the file is missing, install the project dependencies. +If it remains missing, update `@notionhq/apps` to a release that includes the skill. +Do not continue with custom block changes until you can read the SDK skill. diff --git a/apps/templates/statsig-rollout/.agents/skills/notion-as-code/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/notion-as-code/SKILL.md new file mode 100644 index 00000000..a113829a --- /dev/null +++ b/apps/templates/statsig-rollout/.agents/skills/notion-as-code/SKILL.md @@ -0,0 +1,16 @@ +--- +name: notion-as-code +description: Declare Notion pages, databases, teamspaces, and custom agents in an App, including data sources used by syncs. +user-invocable: false +--- + +# Apps Notion as Code + +Before creating, modifying, or troubleshooting Notion as Code code, read +`node_modules/@notionhq/apps/skills/notion-as-code/SKILL.md`. +Resolve this path relative to the App project root. +Follow the instructions in that file. + +If the file is missing, install the project dependencies. +If it remains missing, update `@notionhq/apps` to a release that includes the skill. +Do not continue with Notion as Code changes until you can read the SDK skill. diff --git a/apps/templates/statsig-rollout/.agents/skills/sync/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/sync/SKILL.md new file mode 100644 index 00000000..6aab482f --- /dev/null +++ b/apps/templates/statsig-rollout/.agents/skills/sync/SKILL.md @@ -0,0 +1,16 @@ +--- +name: sync +description: Build, debug, or review Notion App database syncs with stable keys and resumable pagination. +user-invocable: false +--- + +# App database syncs + +Before creating, modifying, or troubleshooting sync code, read +`node_modules/@notionhq/apps/skills/sync/SKILL.md`. +Resolve this path relative to the App project root. +Follow the instructions in that file. + +If the file is missing, install the project dependencies. +If it remains missing, update `@notionhq/apps` to a release that includes the skill. +Do not continue with sync changes until you can read the SDK skill. diff --git a/apps/templates/statsig-rollout/.agents/skills/workflow/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/workflow/SKILL.md new file mode 100644 index 00000000..e68efd8f --- /dev/null +++ b/apps/templates/statsig-rollout/.agents/skills/workflow/SKILL.md @@ -0,0 +1,17 @@ +--- +name: workflow +description: Build or review Notion App workflows for typed triggers, durable replay-safe steps, and idempotent effects. +user-invocable: true +disable-model-invocation: true +--- + +# App workflows + +Before creating, modifying, or troubleshooting workflow code, read +`node_modules/@notionhq/apps/skills/workflow/SKILL.md`. +Resolve this path relative to the App project root. +Follow the instructions in that file. + +If the file is missing, install the project dependencies. +If it remains missing, update `@notionhq/apps` to a release that includes the skill. +Do not continue with workflow changes until you can read the SDK skill. diff --git a/apps/templates/statsig-rollout/.claude/skills b/apps/templates/statsig-rollout/.claude/skills new file mode 120000 index 00000000..2b7a412b --- /dev/null +++ b/apps/templates/statsig-rollout/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/apps/templates/statsig-rollout/.claudeignore b/apps/templates/statsig-rollout/.claudeignore new file mode 100644 index 00000000..665da455 --- /dev/null +++ b/apps/templates/statsig-rollout/.claudeignore @@ -0,0 +1,2 @@ +.env +.env.* diff --git a/apps/templates/statsig-rollout/.codexignore b/apps/templates/statsig-rollout/.codexignore new file mode 100644 index 00000000..665da455 --- /dev/null +++ b/apps/templates/statsig-rollout/.codexignore @@ -0,0 +1,2 @@ +.env +.env.* diff --git a/apps/templates/statsig-rollout/.env.example b/apps/templates/statsig-rollout/.env.example new file mode 100644 index 00000000..a4441f7f --- /dev/null +++ b/apps/templates/statsig-rollout/.env.example @@ -0,0 +1,2 @@ +STATSIG_CONSOLE_API_KEY=console-xxxxxxxx +STATSIG_SEGMENT_ID=my_rollout_segment diff --git a/apps/templates/statsig-rollout/.gitignore b/apps/templates/statsig-rollout/.gitignore new file mode 100644 index 00000000..add172fe --- /dev/null +++ b/apps/templates/statsig-rollout/.gitignore @@ -0,0 +1,12 @@ +.notion/ +app.json +workers.json +dist/ +node_modules/ +package-lock.json +.env +.env.* +!.env.example + +# Local editor state; each installation creates its own. +.claude/settings.local.json diff --git a/apps/templates/statsig-rollout/AGENTS.md b/apps/templates/statsig-rollout/AGENTS.md new file mode 120000 index 00000000..b4012b3d --- /dev/null +++ b/apps/templates/statsig-rollout/AGENTS.md @@ -0,0 +1 @@ +.agents/INSTRUCTIONS.md \ No newline at end of file diff --git a/apps/templates/statsig-rollout/APP.md b/apps/templates/statsig-rollout/APP.md new file mode 100644 index 00000000..e10626e7 --- /dev/null +++ b/apps/templates/statsig-rollout/APP.md @@ -0,0 +1,10 @@ +--- +icon: + type: notion_icon + description: flag + color: green +--- + +Add a space, user, or other UUID below to add it to your Statsig ID List segment. **Status** shows whether it was added. + +Rollout diff --git a/apps/templates/statsig-rollout/CLAUDE.md b/apps/templates/statsig-rollout/CLAUDE.md new file mode 120000 index 00000000..b4012b3d --- /dev/null +++ b/apps/templates/statsig-rollout/CLAUDE.md @@ -0,0 +1 @@ +.agents/INSTRUCTIONS.md \ No newline at end of file diff --git a/apps/templates/statsig-rollout/README.md b/apps/templates/statsig-rollout/README.md new file mode 100644 index 00000000..b9bcfc1c --- /dev/null +++ b/apps/templates/statsig-rollout/README.md @@ -0,0 +1,103 @@ +# Statsig Rollout + +A Notion App that adds IDs to a Statsig +[ID List segment](https://docs.statsig.com/segments) from a Notion database. +Use it to roll a feature gate out to specific spaces, users, or other UUID-keyed +entities without giving everyone who manages the rollout access to Statsig. + +> [!WARNING] +> +> Notion Apps and the Apps SDK are early alpha features and can introduce +> breaking changes. + +## How it works + +Add a row to **Rollout** with the ID as its title. The `addToSegment` workflow +then: + +1. Normalizes the ID (trims it, lowercases it, and converts the 32-character + compact form to dashed UUID form). +2. Sets **Status** to `invalid` with an explanation in **Error** if it isn't a + UUID. +3. Otherwise sets `adding`, calls + `PATCH https://statsigapi.net/console/v1/segments//id_list`, + and sets `success` or `fail` (with the Statsig error). + +Statsig requests are retried up to 3 times for network, 409, 429, and 5xx +errors. Adding an ID that is already in the segment succeeds. The App only adds +IDs; deleting a row does not remove its ID from the segment. + +**Re-runs:** only rows at `pending` are processed. To retry an `invalid` or +`fail` row, fix the ID if needed and set Status back to `pending`. The workflow +never writes `pending` itself, so the trigger events caused by its own status +writes are skipped. + +## Prerequisites + +- Node.js 26 or newer and the `ntn` CLI. +- A Statsig **Console API** key with write access. A server secret key does not + work with the Console API. +- An existing Statsig segment of type **ID List**. Its ID is shown in the + segment's URL in the Statsig console. + +## Setup + +```shell +cd apps/templates/statsig-rollout +npm install +npm run check +npm test +npm run build + +ntn experiments enable apps +ntn login +ntn apps deploy --name "Statsig Rollout" # omit --name on later deploys +ntn workers env set STATSIG_CONSOLE_API_KEY= +ntn workers env set STATSIG_SEGMENT_ID= +``` + +After deploying, open the workflow in Notion and save it to activate the page +triggers. The App's home page embeds the **Rollout** table. + +For local execution, copy `.env.example` to `.env` and fill in the values. +Never commit `.env`. + +## Manual debugging + +The workflow also has a manual trigger that takes an ID, validates it, and adds +it to the segment without reading or writing the database. If this succeeds but +a database row fails, the problem is in the trigger or row handling rather than +the validation or Statsig call: + +```shell +ntn apps workflows trigger addToSegment --data '{"id":""}' +ntn apps workflows runs --help # inspect the run's result +``` + +An invalid ID or a Statsig error fails the run with the error message. + +## Code map + +```text +APP.md Home page: embedded Rollout table +src/workflows/addToSegment.ts Triggers, status flow, and retries +src/workflows/lib/notion.ts Rollout database and table view +src/workflows/lib/uuid.ts UUID normalization (tested) +src/workflows/lib/statsig.ts Statsig Console API client +``` + +## Extension points + +- **Non-UUID IDs:** Statsig ID lists accept any string. To roll out by email or + another key, replace `normalizeUuid` in `addToSegment.ts` with your own + validation. +- **Several segments:** add a select property to the database and map each + option to a segment ID instead of reading `STATSIG_SEGMENT_ID`. +- **Removal:** Statsig's `DELETE .../id_list` endpoint takes the same body. Add + a `remove` status option and call it from the workflow. + +## Verification + +`npm test` covers UUID normalization and page ID parsing offline. To check the +live flow, add a known UUID to **Rollout** and confirm that the row reaches +`success` and the ID appears in the segment in the Statsig console. diff --git a/apps/templates/statsig-rollout/package.json b/apps/templates/statsig-rollout/package.json new file mode 100644 index 00000000..8df5fdf6 --- /dev/null +++ b/apps/templates/statsig-rollout/package.json @@ -0,0 +1,26 @@ +{ + "name": "@notion-cookbook/statsig-rollout", + "version": "0.0.0", + "private": true, + "description": "Adds IDs from a Notion database to a Statsig ID List segment", + "notion": { + "name": "Statsig Rollout", + "description": "Adds IDs entered in a Notion database to a Statsig ID List segment and reports the result on each row." + }, + "type": "module", + "scripts": { + "build": "notion-apps build", + "check": "tsc --noEmit", + "test": "node --experimental-strip-types --test src/workflows/lib/*.test.ts" + }, + "engines": { + "node": ">=26.0.0" + }, + "dependencies": { + "@notionhq/apps": "latest" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "typescript": "^5.9.3" + } +} diff --git a/apps/templates/statsig-rollout/pnpm-workspace.yaml b/apps/templates/statsig-rollout/pnpm-workspace.yaml new file mode 100644 index 00000000..3720bf8c --- /dev/null +++ b/apps/templates/statsig-rollout/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true +minimumReleaseAgeExclude: + - "@notionhq/apps" diff --git a/apps/templates/statsig-rollout/sketches/statsig-rollout.sketch.json b/apps/templates/statsig-rollout/sketches/statsig-rollout.sketch.json new file mode 100644 index 00000000..99aec8b8 --- /dev/null +++ b/apps/templates/statsig-rollout/sketches/statsig-rollout.sketch.json @@ -0,0 +1,224 @@ +{ + "version": 0, + "name": "Statsig Rollout", + "resources": [ + { + "id": "rollout", + "label": "Rollout", + "properties": { + "ID": "title", + "Status": "status (pending, adding, success, invalid, fail)", + "Error": "text" + } + } + ], + "connections": [ + { + "id": "statsig", + "label": "Statsig", + "notes": "Console API key with write access (STATSIG_CONSOLE_API_KEY) and an ID List segment (STATSIG_SEGMENT_ID)." + } + ], + "state": { + "pageId": "1f2e…", + "rawId": " 0B9C4D2E8F1A4B3C9D7E6F5A4B3C2D1E ", + "status": "pending", + "id": "0b9c4d2e-8f1a-4b3c-9d7e-6f5a4b3c2d1e" + }, + "nodes": [ + { + "id": "readRow", + "label": "Read the row's ID and Status", + "uses": ["rollout"], + "reads": ["pageId"], + "writes": ["rawId", "status"] + }, + { + "id": "gate", + "label": "Continue only if the ID is set and Status is pending", + "notes": "The workflow never writes pending, so the update events from its own Status writes stop here. Set a row back to pending to retry it.", + "reads": ["rawId", "status"], + "aux": { + "label": "No ID yet, or not pending", + "color": "amber", + "shape": { + "status": "success" + } + } + }, + { + "id": "normalize", + "label": "Normalize the ID to a lowercase, dashed UUID", + "notes": "Trims, lowercases, and converts the 32-character compact form.", + "reads": ["rawId"], + "writes": ["id"], + "aux": { + "label": "Not a UUID", + "color": "amber", + "shape": { + "rawId": "space-123" + } + } + }, + { + "id": "markInvalid", + "label": "Set Status to invalid and explain in Error", + "uses": ["rollout"], + "reads": ["pageId", "rawId"], + "out": null + }, + { + "id": "markAdding", + "label": "Set Status to adding", + "uses": ["rollout"], + "reads": ["pageId"], + "out": null + }, + { + "id": "addToSegment", + "label": "Add the ID to the Statsig segment", + "notes": "PATCH /console/v1/segments/{segment}/id_list. Up to 3 attempts with 1s and 4s backoff for network errors, 409, 429, and 5xx. Adding an ID already in the segment succeeds.", + "uses": ["statsig"], + "reads": ["id"], + "out": null, + "error": { + "shape": { + "status": 403, + "body": "…" + } + } + }, + { + "id": "markSuccess", + "label": "Set Status to success and clear Error", + "uses": ["rollout"], + "reads": ["pageId"], + "out": null + }, + { + "id": "markFail", + "label": "Set Status to fail and put the Statsig error in Error", + "uses": ["rollout"], + "reads": ["pageId"], + "out": null + }, + { + "id": "manualValidate", + "label": "Normalize the ID, failing the run if it isn't a UUID", + "notes": "Manual debugging path. It doesn't read or write Rollout.", + "reads": ["rawId"], + "writes": ["id"], + "out": null + }, + { + "id": "manualAdd", + "label": "Add the ID to the Statsig segment", + "notes": "Same Statsig call and retries as addToSegment.", + "uses": ["statsig"], + "reads": ["id"], + "out": null, + "error": { + "shape": { + "status": 403, + "body": "…" + } + } + }, + { + "id": "manualFail", + "label": "Fail the run with the Statsig error", + "out": null + } + ], + "edges": [ + { + "from": null, + "to": "readRow", + "label": "When a row is added to Rollout", + "uses": ["rollout"], + "shape": { + "pageId": "1f2e…" + }, + "writes": ["pageId"] + }, + { + "from": null, + "to": "readRow", + "label": "When a Rollout row's ID or Status changes", + "uses": ["rollout"], + "shape": { + "pageId": "1f2e…" + }, + "writes": ["pageId"] + }, + { + "from": "readRow", + "to": "gate" + }, + { + "from": "gate", + "to": "normalize" + }, + { + "from": "gate.aux", + "to": null + }, + { + "from": "normalize", + "to": "markAdding" + }, + { + "from": "normalize.aux", + "to": "markInvalid" + }, + { + "from": "markInvalid", + "to": null + }, + { + "from": "markAdding", + "to": "addToSegment" + }, + { + "from": "addToSegment", + "to": "markSuccess" + }, + { + "from": "addToSegment.error", + "to": "markFail" + }, + { + "from": "markSuccess", + "to": null + }, + { + "from": "markFail", + "to": null + }, + { + "from": null, + "to": "manualValidate", + "label": "Run manually with an ID (debugging)", + "shape": { + "id": "0b9c4d2e8f1a4b3c9d7e6f5a4b3c2d1e" + }, + "writes": ["rawId"] + }, + { + "from": "manualValidate", + "to": "manualAdd" + }, + { + "from": "manualAdd", + "to": null + }, + { + "from": "manualAdd.error", + "to": "manualFail" + }, + { + "from": "manualFail", + "to": null + } + ] +} diff --git a/apps/templates/statsig-rollout/src/workflows/addToSegment.ts b/apps/templates/statsig-rollout/src/workflows/addToSegment.ts new file mode 100644 index 00000000..fa4a9b33 --- /dev/null +++ b/apps/templates/statsig-rollout/src/workflows/addToSegment.ts @@ -0,0 +1,184 @@ +import { access, events, workflow, type WorkflowContext } from "@notionhq/apps" +import { j } from "@notionhq/apps/schema-builder" +import { FatalError } from "@notionhq/apps/workflow" + +import { rollout, STATUS, type Status } from "./lib/notion.js" +import { addIdsToSegment, StatsigError } from "./lib/statsig.js" +import { normalizeUuid, pageIdFromUrl } from "./lib/uuid.js" + +const API_KEY_ENV = "STATSIG_CONSOLE_API_KEY" +const SEGMENT_ENV = "STATSIG_SEGMENT_ID" +const MAX_ATTEMPTS = 3 + +export default workflow({ + name: "Add to segment", + description: + "Validates an ID added to Rollout and adds it to a Statsig ID List segment.", + triggers: [ + // For debugging: runs the validation and Statsig call without touching + // the database, to separate trigger problems from logic problems. + events.manual({ + inputSchema: j.object({ + id: j.string().describe("ID to add to the segment"), + }), + }), + events.notionPageCreated({ dataSource: rollout.dataSource }), + events.notionPageUpdated({ + dataSource: rollout.dataSource, + properties: [ + rollout.dataSource.properties.ID, + rollout.dataSource.properties.Status, + ], + }), + ], + access: { spaces: access.edit(rollout.dataSource) }, + handler: async (event, context) => { + if (event.type === "workflow.manual") { + const id = normalizeUuid(event.input.id) + if (!id) { + throw new FatalError(`"${event.input.id}" is not a UUID`) + } + const result = await context.step( + "Add to Statsig segment", + { timeoutMs: 60_000, retry: { maxAttempts: 1 } }, + ({ signal }) => addWithRetry(id, signal) + ) + if (!result.ok) { + throw new FatalError(result.error) + } + console.log(`Added ${id} to ${process.env[SEGMENT_ENV]}`) + return + } + + const pageId = pageIdFromEvent(event) + if (!pageId) { + throw new FatalError( + "Could not determine the page ID from the trigger event" + ) + } + + const row = await context.step("Read row", async () => { + const page = await context.notion.pages.retrieve({ page_id: pageId }) + const properties = "properties" in page ? page.properties : {} + return { + id: readTitle(properties.ID), + status: readStatus(properties.Status), + } + }) + + const rawId = row.id.trim() + // Only pending rows run: new rows, or rows set back to pending to retry. + // The workflow never writes pending, so the update events fired by its own + // status writes are skipped. Rows with no ID yet stay pending until the ID + // is typed in. + if (!rawId || (row.status !== null && row.status !== STATUS.pending)) { + return + } + + const id = normalizeUuid(rawId) + if (!id) { + await context.step("Mark invalid", () => + setStatus( + context.notion, + pageId, + STATUS.invalid, + `"${rawId}" is not a UUID` + ) + ) + return + } + + await context.step("Mark adding", () => + setStatus(context.notion, pageId, STATUS.adding, "") + ) + + const result = await context.step( + "Add to Statsig segment", + { timeoutMs: 60_000, retry: { maxAttempts: 1 } }, + ({ signal }) => addWithRetry(id, signal) + ) + + await context.step("Record result", () => + result.ok + ? setStatus(context.notion, pageId, STATUS.success, "") + : setStatus(context.notion, pageId, STATUS.fail, result.error) + ) + }, +}) + +type AddResult = { ok: true } | { ok: false; error: string } + +async function addWithRetry( + id: string, + signal: AbortSignal +): Promise { + const apiKey = process.env[API_KEY_ENV] + const segmentId = process.env[SEGMENT_ENV] + if (!apiKey || !segmentId) { + return { + ok: false, + error: `${!apiKey ? API_KEY_ENV : SEGMENT_ENV} is not set`, + } + } + let lastError = "" + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + await addIdsToSegment(apiKey, segmentId, [id], signal) + return { ok: true } + } catch (error) { + signal.throwIfAborted() + lastError = error instanceof Error ? error.message : String(error) + const retryable = !(error instanceof StatsigError) || error.retryable + if (!retryable || attempt === MAX_ATTEMPTS) { + break + } + await new Promise((resolve) => + setTimeout(resolve, 1_000 * 4 ** (attempt - 1)) + ) + } + } + return { ok: false, error: lastError } +} + +async function setStatus( + notion: WorkflowContext["notion"], + pageId: string, + status: Status, + error: string +): Promise { + await notion.pages.update({ + page_id: pageId, + properties: { + Status: { status: { name: status } }, + Error: { + rich_text: error + ? [{ type: "text", text: { content: error.slice(0, 2000) } }] + : [], + }, + }, + }) +} + +function pageIdFromEvent(event: { + url: string | null + [key: string]: unknown +}): string | null { + const page = event.page as { id?: unknown } | undefined + if (typeof page?.id === "string") { + return page.id + } + return pageIdFromUrl(event.url) +} + +function readTitle(property: unknown): string { + const title = + (property as { title?: { plain_text?: string }[] } | undefined)?.title ?? [] + return title.map((part) => part.plain_text ?? "").join("") +} + +function readStatus(property: unknown): string | null { + return ( + (property as { status?: { name?: string } | null } | undefined)?.status + ?.name ?? null + ) +} diff --git a/apps/templates/statsig-rollout/src/workflows/lib/notion.ts b/apps/templates/statsig-rollout/src/workflows/lib/notion.ts new file mode 100644 index 00000000..b67d6b9f --- /dev/null +++ b/apps/templates/statsig-rollout/src/workflows/lib/notion.ts @@ -0,0 +1,45 @@ +import { database } from "@notionhq/apps" + +export const STATUS = { + pending: "pending", + adding: "adding", + invalid: "invalid", + success: "success", + fail: "fail", +} as const + +export type Status = (typeof STATUS)[keyof typeof STATUS] + +export const rollout = database("rollout-db", { + dataSourceResourceId: "rollout-source", + name: "Rollout", + schema: { + ID: { resourceId: "rollout-id", type: "title" }, + Status: { + resourceId: "rollout-status", + type: "status", + options: { + todo: [{ name: STATUS.pending, color: "gray", default: true }], + inProgress: [{ name: STATUS.adding, color: "blue" }], + complete: [ + { name: STATUS.success, color: "green" }, + { name: STATUS.invalid, color: "orange" }, + { name: STATUS.fail, color: "red" }, + ], + }, + }, + Error: { resourceId: "rollout-error", type: "text" }, + }, +}) + +export const rolloutTable = rollout.addView({ + resourceId: "rollout-table", + name: "All IDs", + type: "table", + dataSourceResourceId: "rollout-source", + properties: [ + { property: "rollout-id", visible: true }, + { property: "rollout-status", visible: true }, + { property: "rollout-error", visible: true }, + ], +}) diff --git a/apps/templates/statsig-rollout/src/workflows/lib/statsig.ts b/apps/templates/statsig-rollout/src/workflows/lib/statsig.ts new file mode 100644 index 00000000..507a5040 --- /dev/null +++ b/apps/templates/statsig-rollout/src/workflows/lib/statsig.ts @@ -0,0 +1,41 @@ +const CONSOLE_API = "https://statsigapi.net/console/v1" + +/** Adds IDs to a Statsig ID List segment. Adding an existing ID is a no-op. */ +export async function addIdsToSegment( + apiKey: string, + segmentId: string, + ids: string[], + signal?: AbortSignal +): Promise { + const response = await fetch( + `${CONSOLE_API}/segments/${encodeURIComponent(segmentId)}/id_list`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "STATSIG-API-KEY": apiKey, + }, + body: JSON.stringify({ ids }), + signal, + } + ) + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new StatsigError(response.status, body.slice(0, 500)) + } +} + +export class StatsigError extends Error { + readonly status: number + + constructor(status: number, body: string) { + super(`Statsig responded ${status}${body ? `: ${body}` : ""}`) + this.name = "StatsigError" + this.status = status + } + + /** 4xx errors other than conflicts and rate limiting will not succeed on retry. */ + get retryable(): boolean { + return this.status === 409 || this.status === 429 || this.status >= 500 + } +} diff --git a/apps/templates/statsig-rollout/src/workflows/lib/uuid.test.ts b/apps/templates/statsig-rollout/src/workflows/lib/uuid.test.ts new file mode 100644 index 00000000..044e4a4b --- /dev/null +++ b/apps/templates/statsig-rollout/src/workflows/lib/uuid.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { normalizeUuid, pageIdFromUrl } from "./uuid.ts" + +test("normalizeUuid accepts dashed UUIDs", () => { + assert.equal( + normalizeUuid(" F04E1A2B-3C4D-4E5F-8A9B-0C1D2E3F4A5B "), + "f04e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b" + ) +}) + +test("normalizeUuid converts compact UUIDs", () => { + assert.equal( + normalizeUuid("f04e1a2b3c4d4e5f8a9b0c1d2e3f4a5b"), + "f04e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b" + ) +}) + +test("normalizeUuid rejects non-UUIDs", () => { + for (const value of [ + "", + "abc", + "f04e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5", + "g04e1a2b3c4d4e5f8a9b0c1d2e3f4a5b", + "f04e1a2b3c4d-4e5f-8a9b-0c1d2e3f4a5b", + ]) { + assert.equal(normalizeUuid(value), null, value) + } +}) + +test("pageIdFromUrl reads the trailing page ID", () => { + assert.equal( + pageIdFromUrl( + "https://www.notion.so/Some-row-f04e1a2b3c4d4e5f8a9b0c1d2e3f4a5b?pvs=4" + ), + "f04e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b" + ) + assert.equal(pageIdFromUrl(null), null) + assert.equal(pageIdFromUrl("https://www.notion.so/no-id"), null) +}) diff --git a/apps/templates/statsig-rollout/src/workflows/lib/uuid.ts b/apps/templates/statsig-rollout/src/workflows/lib/uuid.ts new file mode 100644 index 00000000..40c164f5 --- /dev/null +++ b/apps/templates/statsig-rollout/src/workflows/lib/uuid.ts @@ -0,0 +1,33 @@ +const DASHED_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const COMPACT_UUID = /^[0-9a-f]{32}$/ +const PAGE_ID_IN_URL = + /([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[?#]|$)/i + +/** + * Normalizes an ID to lowercase dashed UUID form. Accepts the dashed form and + * the 32-character compact form used in Notion URLs. Returns null when the + * input is not a UUID. + */ +export function normalizeUuid(raw: string): string | null { + const value = raw.trim().toLowerCase() + if (DASHED_UUID.test(value)) { + return value + } + if (COMPACT_UUID.test(value)) { + return [ + value.slice(0, 8), + value.slice(8, 12), + value.slice(12, 16), + value.slice(16, 20), + value.slice(20), + ].join("-") + } + return null +} + +/** Extracts the Notion page ID from a page URL such as https://www.notion.so/Title-. */ +export function pageIdFromUrl(url: string | null | undefined): string | null { + const match = url ? PAGE_ID_IN_URL.exec(url) : null + return match ? normalizeUuid(match[1]!) : null +} diff --git a/apps/templates/statsig-rollout/tsconfig.json b/apps/templates/statsig-rollout/tsconfig.json new file mode 100644 index 00000000..cd746131 --- /dev/null +++ b/apps/templates/statsig-rollout/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024"], + "types": ["node"], + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} diff --git a/catalog.json b/catalog.json index 3c258515..01c7a9ec 100644 --- a/catalog.json +++ b/catalog.json @@ -356,6 +356,25 @@ "deploy": "ntn apps deploy --name \"Perfect Meetings\"" } }, + { + "id": "statsig-rollout", + "title": "Statsig rollout App", + "summary": "Add IDs entered in a Notion database to a Statsig ID List segment, with per-row status.", + "path": "apps/templates/statsig-rollout", + "kind": "app-default", + "status": "ready", + "language": "typescript", + "runtime": "node", + "integrations": ["notion-apps", "notion-workflow", "statsig"], + "entrypoints": ["APP.md"], + "commands": { + "install": "npm install", + "check": "npm run check", + "test": "npm test", + "build": "npm run build", + "deploy": "ntn apps deploy --name \"Statsig Rollout\"" + } + }, { "id": "duckdb-query", "title": "DuckDB query agent tool", diff --git a/package.json b/package.json index 67fe4111..c8108123 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,10 @@ "prepare": "husky" }, "lint-staged": { - "!(*AGENTS|*CLAUDE).{md,json,yml,yaml}": [ + "*.{json,yml,yaml}": [ + "prettier --write" + ], + "!(*AGENTS|*CLAUDE).md": [ "prettier --write", "markdownlint --config .markdownlint.jsonc --fix" ],