Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@relayfile/adapter-core": "0.5.25",
"@relayfile/adapter-core": "0.5.26",
"@relayfile/relay-helpers": "0.4.11",
"@relayflows/surface": "2.0.16",
"@types/js-yaml": "^4.0.9",
Expand Down
77 changes: 75 additions & 2 deletions packages/sdk/tests/generate-triggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function mapping(root: string, path: string, value: unknown): void {
}

it('reproduces all checked-in modules from the pinned adapter mappings', () => {
expect(generate('--check')).toContain('Checked 2 provider trigger modules');
expect(generate('--check')).toMatch(/Checked \d{2,} provider trigger modules/);
});

it('discovers new adapters, preserves exact event names, and prefers adapter-local mappings', () => {
Expand All @@ -34,7 +34,7 @@ it('discovers new adapters, preserves exact event names, and prefers adapter-loc
mapping(root, 'new-provider', { provider: 'new-provider', webhooks: { 'file.created': {}, 'file.deleted': {} } });
mapping(root, 'no-events', { provider: 'no-events', webhooks: {} });
generate('--adapters-dir', root, '--out-dir', out);
expect(readdirSync(out).sort()).toEqual(['github.ts', 'index.ts', 'new-provider.ts']);
expect(readdirSync(out).sort()).toEqual(['PROVIDERS.md', 'github.ts', 'index.ts', 'new-provider.ts']);
expect(readFileSync(join(out, 'github.ts'), 'utf8')).toContain('pull_request(action?: string)');
expect(readFileSync(join(out, 'github.ts'), 'utf8')).not.toContain('stale');
const provider = readFileSync(join(out, 'new-provider.ts'), 'utf8');
Expand All @@ -60,3 +60,76 @@ it('fails closed on malformed mappings and colliding method names before writing
expect(readdirSync(root)).toEqual(['packages']);
}
});

function catalog(root: string, value: unknown): void {
const target = join(root, 'packages', 'core', 'src', 'triggers');
mkdirSync(target, { recursive: true });
writeFileSync(join(target, 'catalog.generated.json'), JSON.stringify(value));
}

it('unions catalog events into every provider with the plain signature and never overrides a mapping-declared one', () => {
const root = temporary();
const out = join(root, 'generated');
mapping(root, 'github', { adapter: { name: 'github' }, webhooks: { pull_request: { extract: ['action'] } } });
catalog(root, {
github: ['pull_request', 'pull_request.opened'], // union: mapping keeps its signature, the rest is added plain
linear: ['issue.created', 'issue.updated'],
'google-drive': ['file.created'],
empty: [],
});
generate('--adapters-dir', root, '--out-dir', out);
expect(readdirSync(out).sort()).toEqual(['PROVIDERS.md', 'github.ts', 'google-drive.ts', 'index.ts', 'linear.ts']);
const github = readFileSync(join(out, 'github.ts'), 'utf8');
expect(github).toContain('pull_request(action?: string)');
expect(github).toContain('pull_request_opened(filter?: WebhookFilter)');
const linear = readFileSync(join(out, 'linear.ts'), 'utf8');
expect(linear).toContain('export const linear');
expect(linear).toContain('issue_created(filter?: WebhookFilter)');
expect(linear).toContain('providerTrigger("linear", "issue.created", filter)');
expect(readFileSync(join(out, 'google-drive.ts'), 'utf8')).toContain('export const google_drive');
const index = readFileSync(join(out, 'index.ts'), 'utf8');
expect(index).toContain('"linear": Object.freeze(["issue.created","issue.updated"] as const)');
expect(index).toContain('"github": Object.freeze(["pull_request","pull_request.opened"] as const)');
const providers = readFileSync(join(out, 'PROVIDERS.md'), 'utf8');
expect(providers).toContain('| `github` | `github` | 2 | mapping |');
expect(providers).toContain('| `linear` | `linear` | 2 | catalog |');
generate('--adapters-dir', root, '--out-dir', out, '--check');
writeFileSync(join(out, 'PROVIDERS.md'), 'stale');
expect(() => generate('--adapters-dir', root, '--out-dir', out, '--check')).toThrow(/drifted/);
});

it('refuses a provider whose namespace would shadow a surface export', () => {
const root = temporary();
catalog(root, { webhook: ['fired'] });
expect(() => generate('--adapters-dir', root, '--out-dir', join(root, 'out'))).toThrow(/collision/);
const other = temporary();
catalog(other, { schedule: ['due'] });
expect(() => generate('--adapters-dir', other, '--out-dir', join(other, 'out'))).toThrow(/collision/);
});

it('lets a mapping-declared event own a colliding identifier and keeps the other in the registry', () => {
const root = temporary();
const out = join(root, 'generated');
mapping(root, 'chat', { provider: 'chat', webhooks: { reaction_added: {} } });
catalog(root, { chat: ['reaction.added', 'reaction_added', 'reaction.removed'] });
generate('--adapters-dir', root, '--out-dir', out);
const chat = readFileSync(join(out, 'chat.ts'), 'utf8');
expect(chat).toContain('providerTrigger("chat", "reaction_added", filter)');
expect(chat).not.toContain('"reaction.added"');
expect(chat).toContain('providerTrigger("chat", "reaction.removed", filter)');
expect(readFileSync(join(out, 'index.ts'), 'utf8')).toContain('"chat": Object.freeze(["reaction.added","reaction.removed","reaction_added"] as const)');
expect(readFileSync(join(out, 'PROVIDERS.md'), 'utf8')).toContain('| `chat` | `chat` | 3 | mapping | `reaction.added` |');
});

it('gives action-qualified events a plain filter even when the mapping extracts action', () => {
const root = temporary();
const out = join(root, 'generated');
mapping(root, 'github', { adapter: { name: 'github' }, webhooks: {
pull_request: { extract: ['action'] }, 'pull_request.edited': { extract: ['action', 'number'] },
} });
generate('--adapters-dir', root, '--out-dir', out);
const github = readFileSync(join(out, 'github.ts'), 'utf8');
expect(github).toContain('pull_request(action?: string)');
expect(github).toContain('pull_request_edited(filter?: WebhookFilter)');
expect(github).not.toContain('pull_request_edited(action');
});
32 changes: 31 additions & 1 deletion packages/sdk/tests/provider-trigger-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { github, slack, webhook } from '@relayflows/surface';
import { github, gitlab, linear, notion, slack, webhook } from '@relayflows/surface';
import { preflightProviderTriggers, providerDeclaration } from '../src/provider-trigger-contract.js';
import { providerInboxEvent } from '../src/trigger-executor.js';
import { preflightWebhookTriggers } from '../src/preflight.js';
Expand Down Expand Up @@ -85,6 +85,36 @@ describe('provider trigger contract', () => {
expect(preflightProviderTriggers([slack.mention('C123')])).toEqual([]);
});

it('accepts one subscription from each of five providers and refuses a bogus event on any of them', async () => {
expect(preflightProviderTriggers([
github.pull_request('opened'), slack.mention('C123'),
linear[Object.keys(linear)[0] as keyof typeof linear]!(),
notion[Object.keys(notion)[0] as keyof typeof notion]!(),
gitlab[Object.keys(gitlab)[0] as keyof typeof gitlab]!(),
])).toEqual([]);
for (const provider of ['linear', 'notion', 'gitlab', 'cloudflare', 'ramp']) {
const [refusal] = preflightProviderTriggers([webhook(provider, { provider, type: 'no.such.event' })]);
expect(refusal, provider).toMatchObject({ severity: 'refusal' });
expect(refusal!.message).toContain('does not publish');
}
const dir = await temporary();
await symlink(resolve('node_modules'), join(dir, 'node_modules'), 'dir');
await writeFile(join(dir, 'package.json'), '{"type":"module"}');
await writeFile(join(dir, 'flows.json'), '{"executors":["github","slack","linear","notion","gitlab"]}');
const path = join(dir, 'five.flow.ts');
await writeFile(path, "import { flow, github, slack, linear, notion, gitlab } from '@relayflows/surface';\n"
+ "const first = (ns: Record<string, (f?: unknown) => unknown>) => Object.values(ns)[0]!() as never;\n"
+ "export default flow('five')\n"
+ " .on(github.pull_request('opened'), async f => f.done('success'))\n"
+ " .on(slack.mention('C123'), async f => f.done('success'))\n"
+ " .on(first(linear), async f => f.done('success'))\n"
+ " .on(first(notion), async f => f.done('success'))\n"
+ " .on(first(gitlab), async f => f.done('success'));\n");
const reports: string[] = [];
const io = { stdout: (line: string) => reports.push(line), stderr: (line: string) => reports.push(line) };
expect(await runCli(['check', '--json', path], io), reports.join('\n')).toBe(0);
});

it('fails `flows check` before deployment and passes once the event is real', async () => {
const dir = await temporary();
await symlink(resolve('node_modules'), join(dir, 'node_modules'), 'dir');
Expand Down
56 changes: 56 additions & 0 deletions packages/surface/src/triggers/PROVIDERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!-- GENERATED by scripts/generate-triggers.mjs — do not edit. -->
# Provider trigger namespaces

47 providers, 570 events. `mapping` rows come from the adapter's `webhooks:` block (payload-aware signatures); `catalog` rows come from `KNOWN_TRIGGER_CATALOG` (`supportedEvents()`) only. Every provider also includes its catalog events, so the count is what ingress delivers.

| Provider | Namespace | Events | Source | Registry-only events |
|---|---|---:|---|---|
| `airtable` | `airtable` | 9 | catalog | |
| `asana` | `asana` | 17 | catalog | |
| `azure-blob` | `azure_blob` | 3 | mapping | |
| `box` | `box` | 3 | mapping | |
| `calendly` | `calendly` | 12 | catalog | |
| `clickup` | `clickup` | 12 | catalog | |
| `cloudflare` | `cloudflare` | 8 | mapping | |
| `confluence` | `confluence` | 6 | catalog | |
| `daytona` | `daytona` | 7 | mapping | |
| `docker-hub` | `docker_hub` | 1 | catalog | |
| `dropbox` | `dropbox` | 1 | mapping | |
| `fathom` | `fathom` | 1 | mapping | |
| `gcp` | `gcp` | 9 | mapping | |
| `gcs` | `gcs` | 3 | mapping | |
| `github` | `github` | 26 | mapping | |
| `gitlab` | `gitlab` | 53 | mapping | |
| `gmail` | `gmail` | 3 | mapping | |
| `google-calendar` | `google_calendar` | 3 | catalog | |
| `google-drive` | `google_drive` | 3 | mapping | |
| `granola` | `granola` | 4 | mapping | |
| `hubspot` | `hubspot` | 20 | catalog | |
| `intercom` | `intercom` | 18 | catalog | |
| `jira` | `jira` | 12 | catalog | |
| `linear` | `linear` | 33 | catalog | |
| `mailgun` | `mailgun` | 15 | catalog | |
| `mixpanel` | `mixpanel` | 12 | catalog | |
| `neon` | `neon` | 5 | mapping | |
| `notion` | `notion` | 13 | mapping | |
| `onedrive` | `onedrive` | 3 | mapping | |
| `pipedrive` | `pipedrive` | 14 | catalog | |
| `postgres` | `postgres` | 3 | mapping | |
| `posthog` | `posthog` | 2 | mapping | |
| `ramp` | `ramp` | 47 | mapping | |
| `recall` | `recall` | 3 | mapping | |
| `redis` | `redis` | 3 | mapping | |
| `s3` | `s3` | 3 | mapping | |
| `salesforce` | `salesforce` | 22 | catalog | |
| `segment` | `segment` | 12 | catalog | |
| `sendgrid` | `sendgrid` | 17 | catalog | |
| `sharepoint` | `sharepoint` | 3 | mapping | |
| `shopify` | `shopify` | 24 | catalog | |
| `shortcut` | `shortcut` | 6 | mapping | |
| `slack` | `slack` | 21 | mapping | `reaction.added` |
| `stripe` | `stripe` | 21 | catalog | |
| `teams` | `teams` | 20 | catalog | |
| `telegram` | `telegram` | 25 | catalog | |
| `zendesk` | `zendesk` | 9 | catalog | |

Registry-only events share an identifier with another event of the same provider; subscribe with `webhook(provider, { provider, type })`.
39 changes: 33 additions & 6 deletions packages/surface/src/triggers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,25 @@ export default flow('triage')
.on(github.pull_request('opened'), async (f, event) => { f.done('success'); });
```

Every provider a relayfile adapter can deliver has a namespace — 47 today,
570 events; see [PROVIDERS.md](PROVIDERS.md) (generated) for the full table.
Hyphenated providers become identifiers: `azure-blob` → `azure_blob`,
`google-drive` → `google_drive`; the inbox name and event type keep the
upstream spelling, so `azure_blob.file_created()` lowers to
`{ provider: 'azure-blob', type: 'file.created' }`. Namespaces come from two
sources: an adapter's `webhooks:` mapping block, which carries payload shape
(`github.pull_request(action?)` takes an action because the mapping extracts
one), and the trigger catalog (`@relayfile/adapter-core/triggers`, every adapter's
`supportedEvents()`), which is the full set an adapter delivers and yields the
plain `(filter?)` signature. The two are unioned per provider: a mapping
covers payload shape for some events, never the whole set (gitlab maps 8 of
53). Where two upstream names share an identifier (`reaction.added` and
`reaction_added`), the mapping-declared one owns the method and the other
stays subscribable via `webhook(provider, { provider, type })`; PROVIDERS.md
lists them.

Provider namespaces also export from `@relayflows/surface/triggers` and
`@relayflows/surface/triggers/slack` (or `/github`). Declarations are immutable
`@relayflows/surface/triggers/slack` (or `/github`, `/notion`, …). Declarations are immutable
webhook sources: their executor/inbox name is the provider, and their filter
matches the provider, event type, and requested payload fields. Register those
provider names in `flows.json`'s `executors` array for preflight.
Expand All @@ -32,7 +49,14 @@ filter names that same provider, and the filter pins an event type.
`slack.reaction(emoji)` subscribes to `reaction_added`. Arguments match provider
values exactly: use the channel ID and reaction name from the incoming event.
`github.pull_request(action)` filters `payload.action`; omitting the action
accepts every pull request event. Upstream mappings do not declare action enums,
accepts every pull request event. GitHub (and some other providers) appear in
two spellings because two ingresses deliver them: the aggregate event with the
action in the payload (`pull_request` + `payload.action`) is what a raw GitHub
delivery and this package's `POST /providers/github` receiver carry, while the
action-qualified name (`pull_request.opened`) is what relayfile's Cloud
ingress normalizes to and what the adapter catalog lists. Subscribe in the
spelling of the ingress that will deliver to you; action-qualified methods
take only a payload filter, never a second action. Upstream mappings do not declare action enums,
so the action parameter is a string. `github.check_run(action)` (a CI check
finished: `completed`, with `check_run.conclusion` in the payload) and
`github.issue_comment(action)` (a comment on an issue or pull-request
Expand Down Expand Up @@ -122,8 +146,10 @@ Schedules need no `flows.json` executor entry: the tick source ships with
the CLI.

Generation requires the SDK's development dependencies. The default input is
the mapping YAML shipped in the pinned `@relayfile/adapter-core` dependency
(currently Slack and GitHub). A checkout supplies additional providers:
the pinned `@relayfile/adapter-core` dependency: its `mappings/` fallbacks,
its `mappings/adapters/` bundle (every adapter's own mapping, adapter-core
≥ 0.5.26), and its trigger catalog. A checkout supplies the same three from
source:

```sh
node scripts/generate-triggers.mjs
Expand All @@ -132,6 +158,7 @@ node scripts/generate-triggers.mjs --adapters-dir /path/to/relayfile-adapters
```

The generator reads `packages/core/mappings/*.mapping.yaml`, then each adapter's
`packages/<adapter>/*.mapping.yaml`; adapter-local mappings take precedence.
Only adapters with a nonempty `webhooks:` section produce modules. It never
`packages/<adapter>/*.mapping.yaml` (adapter-local mappings take precedence),
then `packages/core/src/triggers/catalog.generated.json` for providers with no
`webhooks:` block. Only providers with at least one event produce modules. It never
imports or executes adapter code. Rebuild the surface and SDK after regeneration.
34 changes: 34 additions & 0 deletions packages/surface/src/triggers/airtable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// GENERATED by scripts/generate-triggers.mjs — do not edit.

import { providerTrigger } from "../provider-trigger.js";
import type { WebhookFilter } from "../triggers.js";

export const airtable = Object.freeze({
base_create(filter?: WebhookFilter) {
return providerTrigger("airtable", "base.create", filter);
},
base_delete(filter?: WebhookFilter) {
return providerTrigger("airtable", "base.delete", filter);
},
base_update(filter?: WebhookFilter) {
return providerTrigger("airtable", "base.update", filter);
},
record_create(filter?: WebhookFilter) {
return providerTrigger("airtable", "record.create", filter);
},
record_delete(filter?: WebhookFilter) {
return providerTrigger("airtable", "record.delete", filter);
},
record_update(filter?: WebhookFilter) {
return providerTrigger("airtable", "record.update", filter);
},
table_create(filter?: WebhookFilter) {
return providerTrigger("airtable", "table.create", filter);
},
table_delete(filter?: WebhookFilter) {
return providerTrigger("airtable", "table.delete", filter);
},
table_update(filter?: WebhookFilter) {
return providerTrigger("airtable", "table.update", filter);
},
});
Loading
Loading