diff --git a/README.md b/README.md index f9c4cb2b..c2dd5277 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,13 @@ Only a scalar can name a file. If a path resolves to an object or array the sample falls back to `untitled-`, which is a signal that the identifier is wrong for that payload rather than something to work around. -The resolver is covered by unit tests in `topic.test.ts`. Run them with +The resolved topic becomes the file name through `safeStem`, the same rule +the registry uses for its doc samples: each run of characters outside +`[A-Za-z0-9._-]` becomes one `.`, with leading and trailing dots dropped +(`soft bounce` → `soft.bounce.json`). A capture therefore overwrites the +doc sample for its event instead of landing beside it. + +The resolver and `safeStem` are covered by unit tests in `topic.test.ts`. Run them with `yarn test`. `provenance` is optional and records, per version, how that version's diff --git a/providers/relay-messenger/latest/chat.created.json b/providers/relay-messenger/2026-08-30/chat.created.json similarity index 100% rename from providers/relay-messenger/latest/chat.created.json rename to providers/relay-messenger/2026-08-30/chat.created.json diff --git a/providers/relay-messenger/latest/chat.typing_indicator.started.json b/providers/relay-messenger/2026-08-30/chat.typing_indicator.started.json similarity index 100% rename from providers/relay-messenger/latest/chat.typing_indicator.started.json rename to providers/relay-messenger/2026-08-30/chat.typing_indicator.started.json diff --git a/providers/relay-messenger/latest/chat.typing_indicator.stopped.json b/providers/relay-messenger/2026-08-30/chat.typing_indicator.stopped.json similarity index 100% rename from providers/relay-messenger/latest/chat.typing_indicator.stopped.json rename to providers/relay-messenger/2026-08-30/chat.typing_indicator.stopped.json diff --git a/providers/relay-messenger/latest/message.delivered.json b/providers/relay-messenger/2026-08-30/message.delivered.json similarity index 100% rename from providers/relay-messenger/latest/message.delivered.json rename to providers/relay-messenger/2026-08-30/message.delivered.json diff --git a/providers/relay-messenger/latest/message.failed.json b/providers/relay-messenger/2026-08-30/message.failed.json similarity index 100% rename from providers/relay-messenger/latest/message.failed.json rename to providers/relay-messenger/2026-08-30/message.failed.json diff --git a/providers/relay-messenger/latest/message.read.json b/providers/relay-messenger/2026-08-30/message.read.json similarity index 100% rename from providers/relay-messenger/latest/message.read.json rename to providers/relay-messenger/2026-08-30/message.read.json diff --git a/providers/relay-messenger/latest/message.received.json b/providers/relay-messenger/2026-08-30/message.received.json similarity index 100% rename from providers/relay-messenger/latest/message.received.json rename to providers/relay-messenger/2026-08-30/message.received.json diff --git a/providers/relay-messenger/latest/message.sent.json b/providers/relay-messenger/2026-08-30/message.sent.json similarity index 100% rename from providers/relay-messenger/latest/message.sent.json rename to providers/relay-messenger/2026-08-30/message.sent.json diff --git a/providers/relay-messenger/index.json b/providers/relay-messenger/index.json index ae0fac7a..70c6dbe0 100644 --- a/providers/relay-messenger/index.json +++ b/providers/relay-messenger/index.json @@ -1,11 +1,11 @@ { "label": "Relay", "configs": { - "latest_version": "latest", + "latest_version": "2026-08-30", "topic_identifier": "event_type" }, "provenance": { - "latest": { + "2026-08-30": { "sourced_via": "docs", "sourced_on": "2026-10-26" } diff --git a/requestReceiver.ts b/requestReceiver.ts index 59f045ee..ee03701c 100644 --- a/requestReceiver.ts +++ b/requestReceiver.ts @@ -2,7 +2,7 @@ import express from "express"; import * as fs from "fs"; import * as path from "path"; import crypto from "crypto"; -import { resolveTopic } from "./topic"; +import { resolveTopic, safeStem } from "./topic"; const app = express(); const port = process.env.PORT || 9001; @@ -85,7 +85,7 @@ const outputToFile = (output: any, provider: string, version: string) => { "providers", provider, version, - `${topic.replace(/[/:\\]/g, ".")}.json` + `${safeStem(topic)}.json` ), JSON.stringify(output, null, 2) ); diff --git a/topic.test.ts b/topic.test.ts index ab1148e9..40e2b823 100644 --- a/topic.test.ts +++ b/topic.test.ts @@ -1,7 +1,9 @@ import { strict as assert } from "node:assert"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; import { describe, it } from "node:test"; -import { resolveTopic, scalarOrUndefined } from "./topic"; +import { resolveTopic, safeStem, scalarOrUndefined } from "./topic"; describe("resolveTopic", () => { it("resolves a plain key", () => { @@ -83,3 +85,40 @@ describe("scalarOrUndefined", () => { assert.equal(scalarOrUndefined(true), undefined); }); }); + +// Each case is a topic whose doc sample is already in providers/ under the registry's +// stem, so a capture of the same event has to be written to that same file. +describe("safeStem", () => { + const cases: [string, string, string][] = [ + ["pubnub/latest", "pubnub.com/schemas/events/channel.created?v=1.0.0", + "pubnub.com.schemas.events.channel.created.v.1.0.0"], + ["mailtrap/latest", "soft bounce", "soft.bounce"], + ["descope/latest", "Change Password", "Change.Password"], + ["document360/latest", "Extension token deleted", "Extension.token.deleted"], + ["flexport/latest", "/shipment_leg#departed", "shipment_leg.departed"], + ["castle/v1", "$list_item.created", "list_item.created"], + ]; + + for (const [dir, topic, stem] of cases) { + it(`names ${JSON.stringify(topic)} as the registry does (${dir}/${stem}.json)`, () => { + assert.equal(safeStem(topic), stem); + assert.ok(existsSync(join(__dirname, "providers", dir, `${stem}.json`))); + }); + } + + it("collapses a run of unsafe characters into one dot", () => { + assert.equal(safeStem("a :/\\ b"), "a.b"); + }); + + it("keeps dots, dashes and underscores", () => { + assert.equal(safeStem("charge.succeeded_v-2"), "charge.succeeded_v-2"); + }); + + it("strips leading and trailing dots", () => { + assert.equal(safeStem("..a.."), "a"); + }); + + it("never returns an empty stem", () => { + assert.equal(safeStem("///"), "_unnamed"); + }); +}); diff --git a/topic.ts b/topic.ts index 7b1c0b95..3b3379c0 100644 --- a/topic.ts +++ b/topic.ts @@ -50,3 +50,12 @@ export const scalarOrUndefined = (value: any): string | undefined => typeof value === "string" || typeof value === "number" ? String(value) : undefined; + +// The file stem for a topic, the same rule hookdeck/webhook-registry's generator +// uses for every sample it exports (safe_stem in generator/harvest_doc_examples.py): +// each run of characters outside [A-Za-z0-9._-] becomes one ".", and leading or +// trailing dots are dropped. A capture has to land on the doc sample's file name, +// otherwise it is written as a duplicate beside it ("soft bounce" -> soft.bounce, +// not "soft bounce"; a PubNub "...channel.created?v=1.0.0" -> "...channel.created.v.1.0.0"). +export const safeStem = (topic: string): string => + topic.replace(/[^A-Za-z0-9._-]+/g, ".").replace(/^\.+|\.+$/g, "") || "_unnamed";