From f7f21df811e28df6ac344b08aa9ff60c92e550f3 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sat, 25 Jul 2026 23:06:03 +0200 Subject: [PATCH 1/6] feat(posthog): add relayfile adapter --- package-lock.json | 133 ++-- .../core/src/triggers/catalog.generated.json | 4 + .../core/src/triggers/catalog.generated.ts | 4 + packages/posthog/package.json | 83 +++ packages/posthog/posthog.mapping.yaml | 31 + packages/posthog/src/digest.ts | 110 ++++ packages/posthog/src/emit-auxiliary-files.ts | 621 ++++++++++++++++++ packages/posthog/src/index.ts | 8 + packages/posthog/src/layout-prompt.ts | 38 ++ packages/posthog/src/path-mapper.ts | 207 ++++++ packages/posthog/src/resources.ts | 95 +++ packages/posthog/src/sync-bucketing.ts | 20 + packages/posthog/src/types.ts | 21 + packages/posthog/src/webhook-normalizer.ts | 105 +++ packages/posthog/tsconfig.json | 26 + 15 files changed, 1451 insertions(+), 55 deletions(-) create mode 100644 packages/posthog/package.json create mode 100644 packages/posthog/posthog.mapping.yaml create mode 100644 packages/posthog/src/digest.ts create mode 100644 packages/posthog/src/emit-auxiliary-files.ts create mode 100644 packages/posthog/src/index.ts create mode 100644 packages/posthog/src/layout-prompt.ts create mode 100644 packages/posthog/src/path-mapper.ts create mode 100644 packages/posthog/src/resources.ts create mode 100644 packages/posthog/src/sync-bucketing.ts create mode 100644 packages/posthog/src/types.ts create mode 100644 packages/posthog/src/webhook-normalizer.ts create mode 100644 packages/posthog/tsconfig.json diff --git a/package-lock.json b/package-lock.json index a28d28bc..503667de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1184,6 +1184,10 @@ "resolved": "packages/pipedrive", "link": true }, + "node_modules/@relayfile/adapter-posthog": { + "resolved": "packages/posthog", + "link": true + }, "node_modules/@relayfile/adapter-recall": { "resolved": "packages/recall", "link": true @@ -4094,7 +4098,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4270,7 +4274,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4440,7 +4444,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4456,7 +4460,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4472,7 +4476,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4648,7 +4652,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4818,7 +4822,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4837,7 +4841,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5019,7 +5023,7 @@ }, "packages/core": { "name": "@relayfile/adapter-core", - "version": "0.4.0", + "version": "0.5.11", "license": "Apache-2.0", "dependencies": { "@scalar/postman-to-openapi": "^0.6.0", @@ -5049,7 +5053,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5068,7 +5072,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5283,7 +5287,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5299,7 +5303,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5318,7 +5322,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5337,7 +5341,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5350,10 +5354,10 @@ }, "packages/github": { "name": "@relayfile/adapter-github", - "version": "0.4.0", + "version": "0.5.3", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", @@ -5373,7 +5377,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5511,7 +5515,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5527,7 +5531,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5704,7 +5708,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5720,7 +5724,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5935,7 +5939,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6105,7 +6109,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6275,7 +6279,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6442,10 +6446,10 @@ }, "packages/linear": { "name": "@relayfile/adapter-linear", - "version": "0.4.0", + "version": "0.4.8", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6584,7 +6588,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6754,7 +6758,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6930,7 +6934,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -6949,7 +6953,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7087,7 +7091,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7103,7 +7107,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7273,7 +7277,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@electric-sql/pglite": "^0.4.5", @@ -7285,12 +7289,31 @@ "node": ">=18" } }, + "packages/posthog": { + "name": "@relayfile/adapter-posthog", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@relayfile/adapter-core": "^0.5.11" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + } + }, "packages/recall": { "name": "@relayfile/adapter-recall", "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7502,10 +7525,10 @@ }, "packages/reddit": { "name": "@relayfile/adapter-reddit", - "version": "0.2.0", + "version": "0.2.7", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7720,7 +7743,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7733,12 +7756,12 @@ }, "packages/relay-helpers": { "name": "@relayfile/relay-helpers", - "version": "0.4.0", + "version": "0.4.9", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0", - "@relayfile/adapter-linear": "^0.4.0", - "@relayfile/adapter-reddit": "^0.2.0" + "@relayfile/adapter-core": "^0.5.11", + "@relayfile/adapter-linear": "^0.4.8", + "@relayfile/adapter-reddit": "^0.2.7" } }, "packages/s3": { @@ -7746,7 +7769,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7762,7 +7785,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7932,7 +7955,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8108,7 +8131,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8278,7 +8301,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@types/node": "^24.6.0", @@ -8294,7 +8317,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8464,7 +8487,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8603,7 +8626,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8773,7 +8796,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8921,10 +8944,10 @@ }, "packages/telegram": { "name": "@relayfile/adapter-telegram", - "version": "0.1.0", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", @@ -8965,7 +8988,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -9109,7 +9132,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.11" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", diff --git a/packages/core/src/triggers/catalog.generated.json b/packages/core/src/triggers/catalog.generated.json index a26c02c5..cfefbba3 100644 --- a/packages/core/src/triggers/catalog.generated.json +++ b/packages/core/src/triggers/catalog.generated.json @@ -380,6 +380,10 @@ "file.deleted", "file.updated" ], + "posthog": [ + "posthog.alert.resolved", + "posthog.alert.triggered" + ], "recall": [ "file.created", "recording.created", diff --git a/packages/core/src/triggers/catalog.generated.ts b/packages/core/src/triggers/catalog.generated.ts index 6a7dfba0..ea09fa31 100644 --- a/packages/core/src/triggers/catalog.generated.ts +++ b/packages/core/src/triggers/catalog.generated.ts @@ -388,6 +388,10 @@ export const KNOWN_TRIGGER_CATALOG = { "file.deleted", "file.updated" ], + "posthog": [ + "posthog.alert.resolved", + "posthog.alert.triggered" + ], "recall": [ "file.created", "recording.created", diff --git a/packages/posthog/package.json b/packages/posthog/package.json new file mode 100644 index 00000000..bcd155c1 --- /dev/null +++ b/packages/posthog/package.json @@ -0,0 +1,83 @@ +{ + "name": "@relayfile/adapter-posthog", + "version": "0.1.0", + "description": "PostHog adapter package for Relayfile", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./digest": { + "types": "./dist/digest.d.ts", + "import": "./dist/digest.js", + "default": "./dist/digest.js" + }, + "./emit-auxiliary-files": { + "types": "./dist/emit-auxiliary-files.d.ts", + "import": "./dist/emit-auxiliary-files.js", + "default": "./dist/emit-auxiliary-files.js" + }, + "./layout-prompt": { + "types": "./dist/layout-prompt.d.ts", + "import": "./dist/layout-prompt.js", + "default": "./dist/layout-prompt.js" + }, + "./path-mapper": { + "types": "./dist/path-mapper.d.ts", + "import": "./dist/path-mapper.js", + "default": "./dist/path-mapper.js" + }, + "./resources": { + "types": "./dist/resources.d.ts", + "import": "./dist/resources.js", + "default": "./dist/resources.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js", + "default": "./dist/types.js" + }, + "./webhook": { + "types": "./dist/webhook-normalizer.d.ts", + "import": "./dist/webhook-normalizer.js", + "default": "./dist/webhook-normalizer.js" + } + }, + "files": [ + "dist", + "discovery" + ], + "scripts": { + "build": "tsc", + "test": "node --import tsx --test 'src/**/*.test.ts'", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@relayfile/adapter-core": "^0.5.11" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/relayfile-adapters", + "directory": "packages/posthog" + } +} diff --git a/packages/posthog/posthog.mapping.yaml b/packages/posthog/posthog.mapping.yaml new file mode 100644 index 00000000..cff87764 --- /dev/null +++ b/packages/posthog/posthog.mapping.yaml @@ -0,0 +1,31 @@ +adapter: + name: posthog + version: "1.0.0" + baseUrl: https://us.posthog.com +webhooks: + # Normalized forward-webhook events currently supported by the cloud ingress. + # These are alert lifecycle payloads materialized under /posthog/projects//alert-events/. + posthog.alert.triggered: + path: /posthog/projects/{{project_id}}/alert-events/{{id}}.json + extract: + - id + - project_id + - title + - severity + - state + - event_type + - occurred_at + - timestamp + - url + posthog.alert.resolved: + path: /posthog/projects/{{project_id}}/alert-events/{{id}}.json + extract: + - id + - project_id + - title + - severity + - state + - event_type + - occurred_at + - timestamp + - url diff --git a/packages/posthog/src/digest.ts b/packages/posthog/src/digest.ts new file mode 100644 index 00000000..218d7d62 --- /dev/null +++ b/packages/posthog/src/digest.ts @@ -0,0 +1,110 @@ +import { + createDigestHandler, + type DigestBullet, + type DigestChangeEvent, + type DigestContext, + type DigestHandler, + type DigestSection, + type DigestWindow, +} from "@relayfile/adapter-core/digest"; + +export type { + DigestBullet, + DigestChangeEvent, + DigestContext, + DigestHandler, + DigestSection, + DigestWindow, +}; + +export const digest: DigestHandler = createDigestHandler({ + provider: "posthog", + identify: posthogIdentifier, + alias: { + mode: "any", + segments: ["by-id", "by-name", "by-short-id", "by-key"], + }, + actionRules: [ + { verbs: "create|created|add|added|write|written", pastTense: "was created" }, + { verbs: "update|updated|change|changed|sync|synced", pastTense: "was updated" }, + { verbs: "delete|deleted|remove|removed", pastTense: "was deleted" }, + { verbs: "archive|archived", pastTense: "was archived" }, + { verbs: "complete|completed|finish|finished", pastTense: "was completed" }, + { verbs: "close|closed|resolve|resolved", pastTense: "was resolved" }, + { verbs: "fire|fired|trigger|triggered|alert", pastTense: "fired" }, + { verbs: "pause|paused", pastTense: "was paused" }, + { verbs: "launch|launched", pastTense: "was launched" }, + ], + classify: (event, canonicalPath) => terminalStateVerb(event, canonicalPath), +}); + +function posthogIdentifier(path: string, event: DigestChangeEvent): string { + const content = readRecord(event.content); + const payload = readRecord(content?.payload) ?? content; + const basename = + path.split("/").filter(Boolean).at(-1)?.replace(/\.[^.]+$/u, "") ?? path; + const projectId = path.split("/").filter(Boolean)[2] ?? readString(payload?.project_id); + const title = + readString(payload?.title) ?? + readString(payload?.name) ?? + readString(payload?.key) ?? + readString(payload?.short_id); + + if (path.includes("/feature-flags/")) return `feature flag ${title ?? basename}`; + if (path.includes("/dashboards/")) return `dashboard ${title ?? basename}`; + if (path.includes("/insights/")) return `insight ${title ?? basename}`; + if (path.includes("/annotations/")) return `annotation ${title ?? basename}`; + if (path.includes("/experiments/")) return `experiment ${title ?? basename}`; + if (path.includes("/surveys/")) return `survey ${title ?? basename}`; + if (path.includes("/alert-events/")) return `alert ${title ?? basename}`; + if (path.includes("/projects/")) return `project ${title ?? projectId ?? basename}`; + return title ?? basename; +} + +function terminalStateVerb( + event: DigestChangeEvent, + canonicalPath: string, +): string | null { + const content = readRecord(event.content); + const payload = readRecord(content?.payload) ?? content; + if (!payload) { + return null; + } + + const state = readLowerString(payload.state); + const status = readLowerString(payload.status); + const archived = payload.archived === true || payload.deleted === true; + const active = payload.active === true ? true : payload.active === false ? false : null; + + if (canonicalPath.includes("/alert-events/")) { + if (state === "resolved" || status === "resolved") return "was resolved"; + if (state === "triggered" || status === "triggered" || status === "firing") return "fired"; + } + + if (archived) return "was archived"; + if (status === "completed" || state === "completed") return "was completed"; + if (status === "closed" || state === "closed") return "was resolved"; + if (status === "paused" || state === "paused") return "was paused"; + if (active === false && canonicalPath.includes("/feature-flags/")) return "was archived"; + + return null; +} + +function readRecord( + value: unknown, +): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function readLowerString(value: unknown): string | undefined { + const raw = readString(value); + return raw ? raw.toLowerCase() : undefined; +} diff --git a/packages/posthog/src/emit-auxiliary-files.ts b/packages/posthog/src/emit-auxiliary-files.ts new file mode 100644 index 00000000..fcaed4c0 --- /dev/null +++ b/packages/posthog/src/emit-auxiliary-files.ts @@ -0,0 +1,621 @@ +import { + EMIT_AUXILIARY_JSON_CONTENT_TYPE, + type AuxiliaryEmitterClient, + type EmitAuxiliaryFilesResult, +} from "@relayfile/adapter-core"; + +import { + composeProjectScopedId, + computePostHogPath, + posthogAggregateCollection, + posthogAggregateIndexPath, + posthogFeatureFlagByKeyAliasPath, + posthogGlobalByIdAliasPath, + posthogInsightByShortIdAliasPath, + posthogProjectByIdAliasPath, + posthogProjectByNameAliasPath, + posthogProjectLocalByIdAliasPath, + posthogProjectLocalIndexPath, + posthogProjectsIndexPath, + posthogRootIndexPath, +} from "./path-mapper.js"; +import { type PostHogPathObjectType } from "./types.js"; + +const JSON_CONTENT_TYPE = EMIT_AUXILIARY_JSON_CONTENT_TYPE; + +type PostHogRecord = Record & { + id?: string; + project_id?: string; + project_name?: string; + name?: string; + title?: string; + short_id?: string; + key?: string; + state?: string; + status?: string; + active?: boolean; + archived?: boolean; + deleted?: boolean; + occurred_at?: string; + updated_at?: string; + updatedAt?: string; + created_at?: string; + createdAt?: string; + last_modified_at?: string; + last_called_at?: string; + start_date?: string; + timestamp?: string; + _deleted?: true; +}; + +type IndexRow = { + id: string; + title: string; + updated: string; + canonicalPath: string; +} & Record; + +type ProjectScopedObjectType = Exclude; + +export interface EmitPostHogAuxiliaryFilesInput { + workspaceId: string; + projects?: readonly PostHogRecord[]; + insights?: readonly PostHogRecord[]; + dashboards?: readonly PostHogRecord[]; + featureFlags?: readonly PostHogRecord[]; + annotations?: readonly PostHogRecord[]; + experiments?: readonly PostHogRecord[]; + surveys?: readonly PostHogRecord[]; + alertEvents?: readonly PostHogRecord[]; + connectionId?: string; +} + +export async function emitPostHogAuxiliaryFiles( + client: AuxiliaryEmitterClient, + input: EmitPostHogAuxiliaryFilesInput, +): Promise { + const aggregate: EmitAuxiliaryFilesResult = { written: 0, deleted: 0, errors: [] }; + + await safeWrite( + client, + input.workspaceId, + posthogRootIndexPath(), + `${JSON.stringify( + [ + { id: "projects", title: "Projects", canonicalPath: posthogProjectsIndexPath() }, + { id: "insights", title: "Insights", canonicalPath: posthogAggregateIndexPath("insights") }, + { id: "dashboards", title: "Dashboards", canonicalPath: posthogAggregateIndexPath("dashboards") }, + { id: "feature-flags", title: "Feature Flags", canonicalPath: posthogAggregateIndexPath("feature-flags") }, + { id: "annotations", title: "Annotations", canonicalPath: posthogAggregateIndexPath("annotations") }, + { id: "experiments", title: "Experiments", canonicalPath: posthogAggregateIndexPath("experiments") }, + { id: "surveys", title: "Surveys", canonicalPath: posthogAggregateIndexPath("surveys") }, + { id: "alert-events", title: "Alert Events", canonicalPath: posthogAggregateIndexPath("alert-events") }, + ], + null, + 2, + )}\n`, + aggregate, + ); + + await emitProjects(client, input.workspaceId, input.projects ?? [], aggregate, input.connectionId); + await emitProjectScopedCollection(client, input.workspaceId, "insight", input.insights ?? [], aggregate, input.connectionId); + await emitProjectScopedCollection(client, input.workspaceId, "dashboard", input.dashboards ?? [], aggregate, input.connectionId); + await emitProjectScopedCollection(client, input.workspaceId, "feature-flag", input.featureFlags ?? [], aggregate, input.connectionId); + await emitProjectScopedCollection(client, input.workspaceId, "annotation", input.annotations ?? [], aggregate, input.connectionId); + await emitProjectScopedCollection(client, input.workspaceId, "experiment", input.experiments ?? [], aggregate, input.connectionId); + await emitProjectScopedCollection(client, input.workspaceId, "survey", input.surveys ?? [], aggregate, input.connectionId); + await emitProjectScopedCollection(client, input.workspaceId, "alert-event", input.alertEvents ?? [], aggregate, input.connectionId); + + return aggregate; +} + +async function emitProjects( + client: AuxiliaryEmitterClient, + workspaceId: string, + records: readonly PostHogRecord[], + aggregate: EmitAuxiliaryFilesResult, + connectionId: string | undefined, +): Promise { + const indexPath = posthogProjectsIndexPath(); + const existingRows = await readIndex(client, workspaceId, indexPath, aggregate); + const rows = new Map(existingRows.map((row) => [row.id, row])); + + for (const record of records) { + const projectId = readProjectId(record, "project"); + if (!projectId) { + continue; + } + + const byIdPath = posthogProjectByIdAliasPath(projectId); + const previousRecord = await readAliasRecord(client, workspaceId, byIdPath, aggregate); + const previousName = readString(previousRecord?.name) ?? readString(previousRecord?.project_name); + const currentName = readProjectTitle(record, projectId); + const canonicalPath = computePostHogPath("project", projectId); + const byNamePath = posthogProjectByNameAliasPath(currentName, projectId); + + if (record._deleted === true) { + rows.delete(projectId); + await safeDelete(client, workspaceId, byIdPath, aggregate); + if (previousName) { + await safeDelete( + client, + workspaceId, + posthogProjectByNameAliasPath(previousName, projectId), + aggregate, + ); + } + continue; + } + + rows.set(projectId, { + id: projectId, + title: currentName, + updated: readUpdated(record), + canonicalPath, + organization_id: readString(record.organization_id), + archived: record.archived === true, + }); + + const aliasPayload = buildAliasPayload({ + provider: "posthog", + objectType: "project", + objectId: projectId, + canonicalPath, + payload: record, + ...(connectionId ? { connectionId } : {}), + }); + + await safeWrite( + client, + workspaceId, + byIdPath, + `${JSON.stringify(aliasPayload, null, 2)}\n`, + aggregate, + ); + await safeWrite( + client, + workspaceId, + byNamePath, + `${JSON.stringify(aliasPayload, null, 2)}\n`, + aggregate, + ); + + if (previousName && previousName !== currentName) { + await safeDelete( + client, + workspaceId, + posthogProjectByNameAliasPath(previousName, projectId), + aggregate, + ); + } + } + + await writeSortedIndex(client, workspaceId, indexPath, rows, aggregate); +} + +async function emitProjectScopedCollection( + client: AuxiliaryEmitterClient, + workspaceId: string, + objectType: ProjectScopedObjectType, + records: readonly PostHogRecord[], + aggregate: EmitAuxiliaryFilesResult, + connectionId: string | undefined, +): Promise { + const aggregateIndexPath = posthogAggregateIndexPath( + posthogAggregateCollection(objectType), + ); + const aggregateExistingRows = await readIndex( + client, + workspaceId, + aggregateIndexPath, + aggregate, + ); + const aggregateRows = new Map(aggregateExistingRows.map((row) => [row.id, row])); + + const grouped = groupByProject(records); + for (const [projectId, projectRecords] of grouped.entries()) { + const projectIndexPath = posthogProjectLocalIndexPath(objectType, projectId); + const projectExistingRows = await readIndex( + client, + workspaceId, + projectIndexPath, + aggregate, + ); + const projectRows = new Map(projectExistingRows.map((row) => [row.id, row])); + + for (const record of projectRecords) { + const objectId = readObjectId(record, objectType); + if (!objectId) { + continue; + } + + const globalAliasPath = posthogGlobalByIdAliasPath( + objectType, + projectId, + objectId, + ); + const localAliasPath = posthogProjectLocalByIdAliasPath( + objectType, + projectId, + objectId, + ); + const previousRecord = await readAliasRecord( + client, + workspaceId, + globalAliasPath, + aggregate, + ); + const canonicalPath = computePostHogPath(objectType, objectId, { + projectId, + }); + const title = readObjectTitle(record, objectType, objectId); + const aggregateId = composeProjectScopedId(projectId, objectId); + const aliasPayload = buildAliasPayload({ + provider: "posthog", + objectType, + objectId, + canonicalPath, + payload: record, + ...(connectionId ? { connectionId } : {}), + projectId, + }); + + if (record._deleted === true) { + aggregateRows.delete(aggregateId); + projectRows.delete(objectId); + await safeDelete(client, workspaceId, globalAliasPath, aggregate); + await safeDelete(client, workspaceId, localAliasPath, aggregate); + for (const extraAlias of extraAliasPaths( + objectType, + projectId, + previousRecord ?? record, + )) { + await safeDelete(client, workspaceId, extraAlias, aggregate); + } + continue; + } + + aggregateRows.set(aggregateId, { + id: aggregateId, + title, + updated: readUpdated(record), + canonicalPath, + project_id: projectId, + project_name: readString(record.project_name), + state: readString(record.state), + status: readString(record.status), + archived: record.archived === true, + }); + projectRows.set(objectId, { + id: objectId, + title, + updated: readUpdated(record), + canonicalPath, + state: readString(record.state), + status: readString(record.status), + archived: record.archived === true, + }); + + await safeWrite( + client, + workspaceId, + globalAliasPath, + `${JSON.stringify(aliasPayload, null, 2)}\n`, + aggregate, + ); + await safeWrite( + client, + workspaceId, + localAliasPath, + `${JSON.stringify(aliasPayload, null, 2)}\n`, + aggregate, + ); + + const previousExtraAliases = new Set( + extraAliasPaths(objectType, projectId, previousRecord), + ); + const currentExtraAliases = new Set( + extraAliasPaths(objectType, projectId, record), + ); + for (const staleAlias of previousExtraAliases) { + if (!currentExtraAliases.has(staleAlias)) { + await safeDelete(client, workspaceId, staleAlias, aggregate); + } + } + for (const aliasPath of currentExtraAliases) { + await safeWrite( + client, + workspaceId, + aliasPath, + `${JSON.stringify(aliasPayload, null, 2)}\n`, + aggregate, + ); + } + } + + await writeSortedIndex( + client, + workspaceId, + projectIndexPath, + projectRows, + aggregate, + ); + } + + await writeSortedIndex( + client, + workspaceId, + aggregateIndexPath, + aggregateRows, + aggregate, + ); +} + +function groupByProject( + records: readonly PostHogRecord[], +): Map { + const grouped = new Map(); + for (const record of records) { + const projectId = readProjectId(record, "insight"); + if (!projectId) { + continue; + } + if (!grouped.has(projectId)) { + grouped.set(projectId, []); + } + grouped.get(projectId)?.push(record); + } + return grouped; +} + +function readProjectId( + record: PostHogRecord, + objectType: PostHogPathObjectType, +): string | undefined { + if (objectType === "project") { + return readString(record.project_id) ?? readString(record.id); + } + return readString(record.project_id) ?? readString(record.projectId); +} + +function readObjectId( + record: PostHogRecord, + objectType: ProjectScopedObjectType, +): string | undefined { + if (objectType === "alert-event") { + return readString(record.id) ?? readString(record.alert_id); + } + return readString(record.id); +} + +function readProjectTitle(record: PostHogRecord, projectId: string): string { + return ( + readString(record.name) ?? + readString(record.project_name) ?? + readString(record.slug) ?? + projectId + ); +} + +function readObjectTitle( + record: PostHogRecord, + objectType: ProjectScopedObjectType, + objectId: string, +): string { + switch (objectType) { + case "insight": + return ( + readString(record.name) ?? + readString(record.derived_name) ?? + readString(record.short_id) ?? + objectId + ); + case "dashboard": + return readString(record.name) ?? readString(record.description) ?? objectId; + case "feature-flag": + return readString(record.name) ?? readString(record.key) ?? objectId; + case "annotation": + return readString(record.content) ?? readString(record.scope) ?? objectId; + case "experiment": + return readString(record.name) ?? readString(record.feature_flag_key) ?? objectId; + case "survey": + return readString(record.name) ?? readString(record.type) ?? objectId; + case "alert-event": + return readString(record.title) ?? readString(record.event_type) ?? objectId; + } +} + +function extraAliasPaths( + objectType: ProjectScopedObjectType, + projectId: string, + record: PostHogRecord | null | undefined, +): string[] { + if (!record) { + return []; + } + switch (objectType) { + case "insight": { + const shortId = readString(record.short_id); + return shortId ? [posthogInsightByShortIdAliasPath(projectId, shortId)] : []; + } + case "feature-flag": { + const key = readString(record.key); + return key ? [posthogFeatureFlagByKeyAliasPath(projectId, key)] : []; + } + default: + return []; + } +} + +function readUpdated(record: PostHogRecord): string { + return ( + readString(record.updated_at) ?? + readString(record.updatedAt) ?? + readString(record.modified_at) ?? + readString(record.last_modified_at) ?? + readString(record.last_called_at) ?? + readString(record.occurred_at) ?? + readString(record.timestamp) ?? + readString(record.start_date) ?? + readString(record.created_at) ?? + readString(record.createdAt) ?? + new Date().toISOString() + ); +} + +function buildAliasPayload(input: { + provider: "posthog"; + objectType: PostHogPathObjectType; + objectId: string; + canonicalPath: string; + payload: PostHogRecord; + connectionId?: string; + projectId?: string; +}): Record { + return { + provider: input.provider, + objectType: input.objectType, + objectId: input.objectId, + canonicalPath: input.canonicalPath, + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.connectionId ? { connectionId: input.connectionId } : {}), + payload: input.payload, + }; +} + +async function writeSortedIndex( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + rows: Map, + aggregate: EmitAuxiliaryFilesResult, +): Promise { + await safeWrite( + client, + workspaceId, + path, + `${JSON.stringify( + Array.from(rows.values()).sort((left, right) => + String(right.updated).localeCompare(String(left.updated)), + ), + null, + 2, + )}\n`, + aggregate, + ); +} + +async function readIndex( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + aggregate: EmitAuxiliaryFilesResult, +): Promise { + const value = await safeReadJson(client, workspaceId, path, aggregate); + return Array.isArray(value) ? (value as IndexRow[]) : []; +} + +async function readAliasRecord( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + aggregate: EmitAuxiliaryFilesResult, +): Promise { + const value = await safeReadJson(client, workspaceId, path, aggregate); + if (!isRecord(value)) { + return null; + } + const payload = isRecord(value.payload) ? value.payload : value; + return payload as PostHogRecord; +} + +async function safeReadJson( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + aggregate: EmitAuxiliaryFilesResult, +): Promise { + if (!client.readFile) { + return null; + } + try { + const value = await client.readFile({ workspaceId, path }); + const content = + typeof value === "string" + ? value + : value && typeof value.content === "string" + ? value.content + : null; + if (!content) { + return null; + } + return JSON.parse(content) as unknown; + } catch (error) { + const message = String(error); + if ( + message.includes("not found") || + message.includes("ENOENT") || + message.includes("404") + ) { + return null; + } + aggregate.errors.push({ path, error: message }); + return null; + } +} + +async function safeWrite( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + content: string, + aggregate: EmitAuxiliaryFilesResult, +): Promise { + try { + await client.writeFile({ + workspaceId, + path, + content, + contentType: JSON_CONTENT_TYPE, + }); + aggregate.written += 1; + } catch (error) { + aggregate.errors.push({ path, error: String(error) }); + } +} + +async function safeDelete( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + aggregate: EmitAuxiliaryFilesResult, +): Promise { + if (!client.deleteFile) { + aggregate.errors.push({ path, error: "deleteFile not supported by client" }); + return; + } + try { + await client.deleteFile({ workspaceId, path }); + aggregate.deleted += 1; + } catch (error) { + const message = String(error); + if ( + message.includes("not found") || + message.includes("ENOENT") || + message.includes("404") + ) { + return; + } + aggregate.errors.push({ path, error: message }); + } +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/posthog/src/index.ts b/packages/posthog/src/index.ts new file mode 100644 index 00000000..5547a4ca --- /dev/null +++ b/packages/posthog/src/index.ts @@ -0,0 +1,8 @@ +export * from "./digest.js"; +export * from "./emit-auxiliary-files.js"; +export * from "./layout-prompt.js"; +export * from "./path-mapper.js"; +export * from "./resources.js"; +export * from "./sync-bucketing.js"; +export * from "./types.js"; +export * from "./webhook-normalizer.js"; diff --git a/packages/posthog/src/layout-prompt.ts b/packages/posthog/src/layout-prompt.ts new file mode 100644 index 00000000..d1df34d8 --- /dev/null +++ b/packages/posthog/src/layout-prompt.ts @@ -0,0 +1,38 @@ +import { POSTHOG_PATH_ROOT } from "./types.js"; + +export const POSTHOG_LAYOUT_PROMPT = `# PostHog Mount Layout + +Always inspect \`_index.json\` files before constructing a record path. PostHog admin records are project-scoped, so canonical files live under the owning project even when aggregate indexes exist at the provider root. + +\`/posthog/LAYOUT.md\` is this guide. +\`/posthog/projects/\` holds project metadata records. +\`/posthog/insights/_index.json\`, \`/posthog/dashboards/_index.json\`, \`/posthog/feature-flags/_index.json\`, \`/posthog/annotations/_index.json\`, \`/posthog/experiments/_index.json\`, and \`/posthog/surveys/_index.json\` are aggregate indexes spanning all synced projects. +\`/posthog/projects//insights/\`, \`dashboards/\`, \`feature-flags/\`, \`annotations/\`, \`experiments/\`, and \`surveys/\` hold canonical JSON records keyed by PostHog ids. +\`/posthog/alert-events/_index.json\` and \`/posthog/projects//alert-events/\` hold normalized alert webhook events when configured. + +Discovery contracts: +- \`/posthog/projects/{projectId}.json\` -> \`discovery/posthog/projects/.schema.json\` +- \`/posthog/projects/{projectId}/insights/{insightId}.json\` -> \`discovery/posthog/insights/.schema.json\` +- \`/posthog/projects/{projectId}/dashboards/{dashboardId}.json\` -> \`discovery/posthog/dashboards/.schema.json\` +- \`/posthog/projects/{projectId}/feature-flags/{featureFlagId}.json\` -> \`discovery/posthog/feature-flags/.schema.json\` +- \`/posthog/projects/{projectId}/annotations/{annotationId}.json\` -> \`discovery/posthog/annotations/.schema.json\` +- \`/posthog/projects/{projectId}/experiments/{experimentId}.json\` -> \`discovery/posthog/experiments/.schema.json\` +- \`/posthog/projects/{projectId}/surveys/{surveyId}.json\` -> \`discovery/posthog/surveys/.schema.json\` +- \`/posthog/projects/{projectId}/alert-events/{eventId}.json\` -> \`discovery/posthog/alert-events/.schema.json\` + +Every collection exposes an \`_index.json\` and a stable \`by-id/\` alias tree. Project records also expose \`/posthog/projects/by-name/\`. Insights expose \`by-short-id/\` aliases within their project, and feature flags expose \`by-key/\` aliases within their project. +`; + +export function layoutPromptFile(): { + path: string; + content: string; + contentType: string; +} { + return { + path: `${POSTHOG_PATH_ROOT}/LAYOUT.md`, + contentType: "text/markdown; charset=utf-8", + content: POSTHOG_LAYOUT_PROMPT.endsWith("\n") + ? POSTHOG_LAYOUT_PROMPT + : `${POSTHOG_LAYOUT_PROMPT}\n`, + }; +} diff --git a/packages/posthog/src/path-mapper.ts b/packages/posthog/src/path-mapper.ts new file mode 100644 index 00000000..3f772f96 --- /dev/null +++ b/packages/posthog/src/path-mapper.ts @@ -0,0 +1,207 @@ +import { + aliasCollisionSuffix, + slugifyAlias, +} from "@relayfile/adapter-core"; + +import { + POSTHOG_PATH_ROOT, + type PostHogAggregateCollection, + type PostHogPathObjectType, +} from "./types.js"; + +function assertNonEmpty(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`PostHog ${label} must be a non-empty string`); + } + return trimmed; +} + +export function encodePostHogPathSegment(value: string): string { + return encodeURIComponent(assertNonEmpty(value, "path segment")); +} + +export function posthogRootIndexPath(): string { + return `${POSTHOG_PATH_ROOT}/_index.json`; +} + +export function posthogProjectsIndexPath(): string { + return `${POSTHOG_PATH_ROOT}/projects/_index.json`; +} + +export function posthogAggregateIndexPath( + collection: PostHogAggregateCollection, +): string { + return `${POSTHOG_PATH_ROOT}/${collection}/_index.json`; +} + +export function posthogProjectPath(projectId: string): string { + return `${POSTHOG_PATH_ROOT}/projects/${encodePostHogPathSegment(projectId)}.json`; +} + +export function posthogProjectByIdAliasPath(projectId: string): string { + return `${POSTHOG_PATH_ROOT}/projects/by-id/${encodePostHogPathSegment(projectId)}.json`; +} + +export function posthogProjectByNameAliasPath( + name: string, + projectId: string, +): string { + const slug = slugifyAlias(name); + const suffix = aliasCollisionSuffix(projectId); + return `${POSTHOG_PATH_ROOT}/projects/by-name/${encodePostHogPathSegment(`${slug}-${suffix}__${projectId}`)}.json`; +} + +export function posthogProjectLocalIndexPath( + objectType: Exclude, + projectId: string, +): string { + return `${posthogProjectCollectionPrefix( + objectType, + projectId, + )}/_index.json`; +} + +export function posthogGlobalByIdAliasPath( + objectType: Exclude, + projectId: string, + objectId: string, +): string { + return `${POSTHOG_PATH_ROOT}/${posthogAggregateCollection( + objectType, + )}/by-id/${encodePostHogPathSegment(composeProjectScopedId(projectId, objectId))}.json`; +} + +export function posthogProjectLocalByIdAliasPath( + objectType: Exclude, + projectId: string, + objectId: string, +): string { + return `${posthogProjectCollectionPrefix( + objectType, + projectId, + )}/by-id/${encodePostHogPathSegment(objectId)}.json`; +} + +export function posthogInsightByShortIdAliasPath( + projectId: string, + shortId: string, +): string { + return `${posthogProjectCollectionPrefix( + "insight", + projectId, + )}/by-short-id/${encodePostHogPathSegment(shortId)}.json`; +} + +export function posthogFeatureFlagByKeyAliasPath( + projectId: string, + key: string, +): string { + return `${posthogProjectCollectionPrefix( + "feature-flag", + projectId, + )}/by-key/${encodePostHogPathSegment(key)}.json`; +} + +export function posthogProjectCollectionPrefix( + objectType: Exclude, + projectId: string, +): string { + const project = encodePostHogPathSegment(projectId); + return `${POSTHOG_PATH_ROOT}/projects/${project}/${posthogAggregateCollection( + objectType, + )}`; +} + +export function posthogAggregateCollection( + objectType: PostHogPathObjectType, +): PostHogAggregateCollection { + switch (objectType) { + case "project": + return "projects"; + case "insight": + return "insights"; + case "dashboard": + return "dashboards"; + case "feature-flag": + return "feature-flags"; + case "annotation": + return "annotations"; + case "experiment": + return "experiments"; + case "survey": + return "surveys"; + case "alert-event": + return "alert-events"; + } +} + +export function computePostHogPath( + objectType: PostHogPathObjectType, + objectId: string, + context: { projectId?: string } = {}, +): string { + const id = encodePostHogPathSegment(objectId); + if (objectType === "project") { + return posthogProjectPath(objectId); + } + + const projectId = context.projectId + ? encodePostHogPathSegment(context.projectId) + : null; + if (!projectId) { + throw new Error(`PostHog ${objectType} path requires projectId`); + } + return `${POSTHOG_PATH_ROOT}/projects/${projectId}/${posthogAggregateCollection( + objectType, + )}/${id}.json`; +} + +export function composeProjectScopedId( + projectId: string, + objectId: string, +): string { + return `${assertNonEmpty(projectId, "project id")}__${assertNonEmpty( + objectId, + "object id", + )}`; +} + +export function normalizeNangoPostHogModel( + model: string, +): PostHogPathObjectType | null { + const normalized = model.trim().toLowerCase().replace(/[_\s]+/gu, "-"); + switch (normalized) { + case "posthogproject": + case "project": + return "project"; + case "posthoginsight": + case "insight": + return "insight"; + case "posthogdashboard": + case "dashboard": + return "dashboard"; + case "posthogfeatureflag": + case "feature-flag": + case "feature-flags": + return "feature-flag"; + case "posthogannotation": + case "annotation": + case "annotations": + return "annotation"; + case "posthogexperiment": + case "experiment": + case "experiments": + return "experiment"; + case "posthogsurvey": + case "survey": + case "surveys": + return "survey"; + case "posthogalertevent": + case "alert-event": + case "alert-events": + return "alert-event"; + default: + return null; + } +} diff --git a/packages/posthog/src/resources.ts b/packages/posthog/src/resources.ts new file mode 100644 index 00000000..220753d1 --- /dev/null +++ b/packages/posthog/src/resources.ts @@ -0,0 +1,95 @@ +export interface AdapterResourceConfig { + readonly name: string; + readonly path: string; + readonly pathPattern: RegExp; + readonly idPattern: RegExp; + readonly schema: string; + readonly createExample: string; + readonly sampleIndexPath?: string; +} + +export const resources = [ + { + name: "projects", + path: "/posthog/projects/{projectId}.json", + pathPattern: /^\/posthog\/projects\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/projects/.schema.json", + createExample: "discovery/posthog/projects/.create.example.json", + sampleIndexPath: "/posthog/projects/_index.json", + }, + { + name: "insights", + path: "/posthog/projects/{projectId}/insights/{insightId}.json", + pathPattern: /^\/posthog\/projects\/[^/]+\/insights\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/insights/.schema.json", + createExample: "discovery/posthog/insights/.create.example.json", + sampleIndexPath: "/posthog/insights", + }, + { + name: "dashboards", + path: "/posthog/projects/{projectId}/dashboards/{dashboardId}.json", + pathPattern: /^\/posthog\/projects\/[^/]+\/dashboards\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/dashboards/.schema.json", + createExample: "discovery/posthog/dashboards/.create.example.json", + sampleIndexPath: "/posthog/dashboards", + }, + { + name: "feature-flags", + path: "/posthog/projects/{projectId}/feature-flags/{featureFlagId}.json", + pathPattern: /^\/posthog\/projects\/[^/]+\/feature-flags\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/feature-flags/.schema.json", + createExample: "discovery/posthog/feature-flags/.create.example.json", + sampleIndexPath: "/posthog/feature-flags", + }, + { + name: "annotations", + path: "/posthog/projects/{projectId}/annotations/{annotationId}.json", + pathPattern: /^\/posthog\/projects\/[^/]+\/annotations\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/annotations/.schema.json", + createExample: "discovery/posthog/annotations/.create.example.json", + sampleIndexPath: "/posthog/annotations", + }, + { + name: "experiments", + path: "/posthog/projects/{projectId}/experiments/{experimentId}.json", + pathPattern: /^\/posthog\/projects\/[^/]+\/experiments\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/experiments/.schema.json", + createExample: "discovery/posthog/experiments/.create.example.json", + sampleIndexPath: "/posthog/experiments", + }, + { + name: "surveys", + path: "/posthog/projects/{projectId}/surveys/{surveyId}.json", + pathPattern: /^\/posthog\/projects\/[^/]+\/surveys\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/surveys/.schema.json", + createExample: "discovery/posthog/surveys/.create.example.json", + sampleIndexPath: "/posthog/surveys", + }, + { + name: "alert-events", + path: "/posthog/projects/{projectId}/alert-events/{eventId}.json", + pathPattern: /^\/posthog\/projects\/[^/]+\/alert-events\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/alert-events/.schema.json", + createExample: "discovery/posthog/alert-events/.create.example.json", + sampleIndexPath: "/posthog/alert-events", + }, +] as const satisfies readonly AdapterResourceConfig[]; + +export function findResourceByPath( + path: string, +): AdapterResourceConfig | undefined { + const normalizedPath = path.endsWith(".json") + ? path + : path.replace(/\/$/u, ""); + return resources.find((resource) => + resource.pathPattern.test(normalizedPath), + ); +} diff --git a/packages/posthog/src/sync-bucketing.ts b/packages/posthog/src/sync-bucketing.ts new file mode 100644 index 00000000..9f3749de --- /dev/null +++ b/packages/posthog/src/sync-bucketing.ts @@ -0,0 +1,20 @@ +import { + modelBucket, + safeNormalize, +} from "@relayfile/adapter-core/sync-bucketing"; + +import { normalizeNangoPostHogModel } from "./path-mapper.js"; + +export const syncRecordBucketing = modelBucket({ + normalizeModel: safeNormalize(normalizeNangoPostHogModel), + buckets: { + project: "projects", + insight: "insights", + dashboard: "dashboards", + "feature-flag": "featureFlags", + annotation: "annotations", + experiment: "experiments", + survey: "surveys", + "alert-event": "alertEvents", + }, +}); diff --git a/packages/posthog/src/types.ts b/packages/posthog/src/types.ts new file mode 100644 index 00000000..4b741b96 --- /dev/null +++ b/packages/posthog/src/types.ts @@ -0,0 +1,21 @@ +export const POSTHOG_PATH_ROOT = "/posthog"; + +export type PostHogPathObjectType = + | "project" + | "insight" + | "dashboard" + | "feature-flag" + | "annotation" + | "experiment" + | "survey" + | "alert-event"; + +export type PostHogAggregateCollection = + | "projects" + | "insights" + | "dashboards" + | "feature-flags" + | "annotations" + | "experiments" + | "surveys" + | "alert-events"; diff --git a/packages/posthog/src/webhook-normalizer.ts b/packages/posthog/src/webhook-normalizer.ts new file mode 100644 index 00000000..8fdfc475 --- /dev/null +++ b/packages/posthog/src/webhook-normalizer.ts @@ -0,0 +1,105 @@ +import { computePostHogPath } from "./path-mapper.js"; + +type FileEventType = "file.created" | "file.updated"; + +export interface NormalizedPostHogWebhook { + provider: "posthog"; + eventType: string; + objectType: "alert-event"; + objectId: string; + projectId: string; + payload: Record; + record: Record; + fileEventType: FileEventType; + shouldDelete: false; + path: string; + state?: "triggered" | "resolved"; + severity?: string; + timestamp: string; +} + +export function normalizePostHogWebhook( + payload: Record, +): NormalizedPostHogWebhook | null { + const projectId = readString(payload.project_id) ?? readString(payload.projectId); + const objectId = readString(payload.id) ?? readString(payload.alert_id); + const eventType = + readString(payload.event_type) ?? + readString(payload.eventType) ?? + readString(payload.type) ?? + "posthog.alert.triggered"; + if (!projectId || !objectId) { + return null; + } + + const state = normalizeState( + readString(payload.state) ?? readString(payload.status) ?? eventType, + ); + const timestamp = + readString(payload.occurred_at) ?? + readString(payload.timestamp) ?? + new Date().toISOString(); + const title = + readString(payload.title) ?? + readString(payload.name) ?? + `PostHog alert ${objectId}`; + const severity = readString(payload.severity) ?? undefined; + + const record: Record = { + id: objectId, + project_id: projectId, + source: "posthog", + kind: "advisory", + title, + occurred_at: timestamp, + event_type: eventType, + ...(state ? { state } : {}), + ...(severity ? { severity } : {}), + ...payload, + }; + + return { + provider: "posthog", + eventType, + objectType: "alert-event", + objectId, + projectId, + payload, + record, + fileEventType: state === "resolved" ? "file.updated" : "file.created", + shouldDelete: false, + path: computePostHogPath("alert-event", objectId, { projectId }), + ...(state ? { state } : {}), + ...(severity ? { severity } : {}), + timestamp, + }; +} + +function normalizeState( + value: string | undefined, +): "triggered" | "resolved" | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if ( + normalized.includes("resolved") || + normalized.includes("closed") + ) { + return "resolved"; + } + if ( + normalized.includes("trigger") || + normalized.includes("fire") || + normalized.includes("open") + ) { + return "triggered"; + } + return undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} diff --git a/packages/posthog/tsconfig.json b/packages/posthog/tsconfig.json new file mode 100644 index 00000000..7226026d --- /dev/null +++ b/packages/posthog/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "moduleDetection": "force", + "strict": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "types": ["node"], + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"], + "extends": "../../tsconfig.base.json" +} From df80cc26ac50680d8886c9e8e24d958162f1120b Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 26 Jul 2026 00:14:50 +0200 Subject: [PATCH 2/6] fix(ci): include posthog adapter in catalog task graph --- turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/turbo.json b/turbo.json index 6233c12c..37f8d7d0 100644 --- a/turbo.json +++ b/turbo.json @@ -67,6 +67,7 @@ "@relayfile/adapter-mixpanel#build", "@relayfile/adapter-neon#build", "@relayfile/adapter-notion#build", + "@relayfile/adapter-posthog#build", "@relayfile/adapter-pipedrive#build", "@relayfile/adapter-recall#build", "@relayfile/adapter-reddit#build", From 46ddba3d6e362a7973e430f76f6a80d7355e9f53 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 26 Jul 2026 00:23:07 +0200 Subject: [PATCH 3/6] fix(ci): refresh posthog generated catalogs --- ...rs-without-known-scope-keys.generated.json | 6 ++ .../core/src/scope-keys/catalog.generated.ts | 6 ++ .../writeback-paths/catalog.generated.json | 73 +++++++++++++++++++ .../src/writeback-paths/catalog.generated.ts | 73 +++++++++++++++++++ 4 files changed, 158 insertions(+) diff --git a/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json b/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json index 993d0e9d..47d2d796 100644 --- a/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json +++ b/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json @@ -155,6 +155,12 @@ "provider": "postgres", "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" }, + { + "packageName": "@relayfile/adapter-posthog", + "packagePath": "packages/posthog", + "provider": "posthog", + "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" + }, { "packageName": "@relayfile/adapter-recall", "packagePath": "packages/recall", diff --git a/packages/core/src/scope-keys/catalog.generated.ts b/packages/core/src/scope-keys/catalog.generated.ts index 2bfc792a..78fe3ac0 100644 --- a/packages/core/src/scope-keys/catalog.generated.ts +++ b/packages/core/src/scope-keys/catalog.generated.ts @@ -198,6 +198,12 @@ export const ADAPTERS_WITHOUT_KNOWN_SCOPE_KEYS = [ "provider": "postgres", "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" }, + { + "packageName": "@relayfile/adapter-posthog", + "packagePath": "packages/posthog", + "provider": "posthog", + "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" + }, { "packageName": "@relayfile/adapter-recall", "packagePath": "packages/recall", diff --git a/packages/core/src/writeback-paths/catalog.generated.json b/packages/core/src/writeback-paths/catalog.generated.json index a29f4e66..054994ae 100644 --- a/packages/core/src/writeback-paths/catalog.generated.json +++ b/packages/core/src/writeback-paths/catalog.generated.json @@ -740,6 +740,79 @@ } ] }, + "posthog": { + "alert-events": [ + { + "path": "/posthog/projects/{projectId}/alert-events/{eventId}.json", + "params": [ + "projectId", + "eventId" + ] + } + ], + "annotations": [ + { + "path": "/posthog/projects/{projectId}/annotations/{annotationId}.json", + "params": [ + "projectId", + "annotationId" + ] + } + ], + "dashboards": [ + { + "path": "/posthog/projects/{projectId}/dashboards/{dashboardId}.json", + "params": [ + "projectId", + "dashboardId" + ] + } + ], + "experiments": [ + { + "path": "/posthog/projects/{projectId}/experiments/{experimentId}.json", + "params": [ + "projectId", + "experimentId" + ] + } + ], + "feature-flags": [ + { + "path": "/posthog/projects/{projectId}/feature-flags/{featureFlagId}.json", + "params": [ + "projectId", + "featureFlagId" + ] + } + ], + "insights": [ + { + "path": "/posthog/projects/{projectId}/insights/{insightId}.json", + "params": [ + "projectId", + "insightId" + ] + } + ], + "projects": [ + { + "path": "/posthog/projects/{projectId}.json", + "params": [ + "projectId" + ] + } + ], + "surveys": [ + { + "path": "/posthog/projects/{projectId}/surveys/{surveyId}.json", + "params": [ + "projectId", + "surveyId" + ] + } + ] + }, "recall": { "recordings": [ { diff --git a/packages/core/src/writeback-paths/catalog.generated.ts b/packages/core/src/writeback-paths/catalog.generated.ts index 63b3fe8f..d2057b3f 100644 --- a/packages/core/src/writeback-paths/catalog.generated.ts +++ b/packages/core/src/writeback-paths/catalog.generated.ts @@ -749,6 +749,79 @@ export const WRITEBACK_PATH_CATALOG = { } ] }, + "posthog": { + "alert-events": [ + { + "path": "/posthog/projects/{projectId}/alert-events/{eventId}.json", + "params": [ + "projectId", + "eventId" + ] + } + ], + "annotations": [ + { + "path": "/posthog/projects/{projectId}/annotations/{annotationId}.json", + "params": [ + "projectId", + "annotationId" + ] + } + ], + "dashboards": [ + { + "path": "/posthog/projects/{projectId}/dashboards/{dashboardId}.json", + "params": [ + "projectId", + "dashboardId" + ] + } + ], + "experiments": [ + { + "path": "/posthog/projects/{projectId}/experiments/{experimentId}.json", + "params": [ + "projectId", + "experimentId" + ] + } + ], + "feature-flags": [ + { + "path": "/posthog/projects/{projectId}/feature-flags/{featureFlagId}.json", + "params": [ + "projectId", + "featureFlagId" + ] + } + ], + "insights": [ + { + "path": "/posthog/projects/{projectId}/insights/{insightId}.json", + "params": [ + "projectId", + "insightId" + ] + } + ], + "projects": [ + { + "path": "/posthog/projects/{projectId}.json", + "params": [ + "projectId" + ] + } + ], + "surveys": [ + { + "path": "/posthog/projects/{projectId}/surveys/{surveyId}.json", + "params": [ + "projectId", + "surveyId" + ] + } + ] + }, "recall": { "recordings": [ { From 76b1d3c39542ca12a3f5d3814b1ea3b7df1f039a Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 26 Jul 2026 00:29:07 +0200 Subject: [PATCH 4/6] fix(ci): regenerate relay helper clients --- packages/relay-helpers/src/generated/clients.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/relay-helpers/src/generated/clients.ts b/packages/relay-helpers/src/generated/clients.ts index 439d98f1..1dfffb13 100644 --- a/packages/relay-helpers/src/generated/clients.ts +++ b/packages/relay-helpers/src/generated/clients.ts @@ -80,6 +80,9 @@ export const pipedriveClient = (opts?: RelayClientOptions): ProviderClient<'pipe export const postgresClient = (opts?: RelayClientOptions): ProviderClient<'postgres'> => providerClient('postgres', opts); +export const posthogClient = (opts?: RelayClientOptions): ProviderClient<'posthog'> => + providerClient('posthog', opts); + export const recallClient = (opts?: RelayClientOptions): ProviderClient<'recall'> => providerClient('recall', opts); From e2eb72d31c219a427e0bc42fe3f959ef90574a29 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 26 Jul 2026 11:53:08 +0200 Subject: [PATCH 5/6] fix(posthog): address adapter review feedback --- CHANGELOG.md | 1 + package-lock.json | 3 +- ...rs-without-known-scope-keys.generated.json | 6 - .../src/scope-keys/catalog.generated.json | 3 + .../core/src/scope-keys/catalog.generated.ts | 9 +- ...ers-without-writeback-paths.generated.json | 6 + .../writeback-paths/catalog.generated.json | 73 ----- .../src/writeback-paths/catalog.generated.ts | 79 +----- packages/posthog/package.json | 6 +- packages/posthog/posthog.mapping.yaml | 19 ++ .../scripts/generate-resource-paths.mjs | 45 +++ .../posthog/src/emit-auxiliary-files.test.ts | 200 ++++++++++++++ packages/posthog/src/emit-auxiliary-files.ts | 261 ++++++++++++------ packages/posthog/src/layout-prompt.test.ts | 17 ++ packages/posthog/src/layout-prompt.ts | 28 +- packages/posthog/src/path-mapper.test.ts | 86 ++++++ packages/posthog/src/path-mapper.ts | 231 +++++++++++++++- packages/posthog/src/read-only-resources.ts | 93 +++++++ .../posthog/src/resource-paths.generated.ts | 11 + packages/posthog/src/resources.test.ts | 36 +++ packages/posthog/src/resources.ts | 93 +------ .../posthog/src/webhook-normalizer.test.ts | 71 +++++ packages/posthog/src/webhook-normalizer.ts | 37 ++- .../relay-helpers/src/generated/clients.ts | 3 - 24 files changed, 1061 insertions(+), 356 deletions(-) create mode 100644 packages/posthog/scripts/generate-resource-paths.mjs create mode 100644 packages/posthog/src/emit-auxiliary-files.test.ts create mode 100644 packages/posthog/src/layout-prompt.test.ts create mode 100644 packages/posthog/src/path-mapper.test.ts create mode 100644 packages/posthog/src/read-only-resources.ts create mode 100644 packages/posthog/src/resource-paths.generated.ts create mode 100644 packages/posthog/src/resources.test.ts create mode 100644 packages/posthog/src/webhook-normalizer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb25b9f..5fa0b812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it. ### Added +- `@relayfile/adapter-posthog` now provides read-only project-scoped materialization with canonical slug-and-ID paths, stable indexes and aliases, alert webhook normalization, digest visibility, and generated trigger and scope-key catalogs. - `@relayfile/adapter-github` can now backfill bounded repository commit history into a discoverable, newest-first `commits/_index.json` with canonical commit metadata paths, while push webhooks keep that index current. - `@relayfile/relay-helpers` now exposes a process-scoped final-write authorizer that can deny or redirect every generic and bespoke helper write after explicit transport selection, preventing authored transports from bypassing immutable local preview policy. - `@relayfile/relay-helpers` now exports an injectable `RelayTransport` and side-effect-free `PreviewTransport` with deterministic simulated receipts, seeded reads, process-scoped binding for existing no-argument clients, and cross-write thread-reference recording. diff --git a/package-lock.json b/package-lock.json index 503667de..1c3c0def 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7299,7 +7299,8 @@ "devDependencies": { "@types/node": "^24.6.0", "tsx": "^4.20.6", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.8.1" }, "engines": { "node": ">=18" diff --git a/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json b/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json index 47d2d796..993d0e9d 100644 --- a/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json +++ b/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json @@ -155,12 +155,6 @@ "provider": "postgres", "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" }, - { - "packageName": "@relayfile/adapter-posthog", - "packagePath": "packages/posthog", - "provider": "posthog", - "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" - }, { "packageName": "@relayfile/adapter-recall", "packagePath": "packages/recall", diff --git a/packages/core/src/scope-keys/catalog.generated.json b/packages/core/src/scope-keys/catalog.generated.json index f3a2b979..7c8ce62c 100644 --- a/packages/core/src/scope-keys/catalog.generated.json +++ b/packages/core/src/scope-keys/catalog.generated.json @@ -19,6 +19,9 @@ "organizationId", "projectId" ], + "posthog": [ + "project_id" + ], "salesforce": [ "connectionId", "providerConfigKey" diff --git a/packages/core/src/scope-keys/catalog.generated.ts b/packages/core/src/scope-keys/catalog.generated.ts index 78fe3ac0..94ed7255 100644 --- a/packages/core/src/scope-keys/catalog.generated.ts +++ b/packages/core/src/scope-keys/catalog.generated.ts @@ -27,6 +27,9 @@ export const KNOWN_SCOPE_KEY_CATALOG = { "organizationId", "projectId" ], + "posthog": [ + "project_id" + ], "salesforce": [ "connectionId", "providerConfigKey" @@ -198,12 +201,6 @@ export const ADAPTERS_WITHOUT_KNOWN_SCOPE_KEYS = [ "provider": "postgres", "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" }, - { - "packageName": "@relayfile/adapter-posthog", - "packagePath": "packages/posthog", - "provider": "posthog", - "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" - }, { "packageName": "@relayfile/adapter-recall", "packagePath": "packages/recall", diff --git a/packages/core/src/writeback-paths/adapters-without-writeback-paths.generated.json b/packages/core/src/writeback-paths/adapters-without-writeback-paths.generated.json index b0a2ba94..8e4b8ceb 100644 --- a/packages/core/src/writeback-paths/adapters-without-writeback-paths.generated.json +++ b/packages/core/src/writeback-paths/adapters-without-writeback-paths.generated.json @@ -29,6 +29,12 @@ "provider": "neon", "reason": "No resources.ts with writeback path templates" }, + { + "packageName": "@relayfile/adapter-posthog", + "packagePath": "packages/posthog", + "provider": "posthog", + "reason": "No resources.ts with writeback path templates" + }, { "packageName": "@relayfile/adapter-segment", "packagePath": "packages/segment", diff --git a/packages/core/src/writeback-paths/catalog.generated.json b/packages/core/src/writeback-paths/catalog.generated.json index 054994ae..a29f4e66 100644 --- a/packages/core/src/writeback-paths/catalog.generated.json +++ b/packages/core/src/writeback-paths/catalog.generated.json @@ -740,79 +740,6 @@ } ] }, - "posthog": { - "alert-events": [ - { - "path": "/posthog/projects/{projectId}/alert-events/{eventId}.json", - "params": [ - "projectId", - "eventId" - ] - } - ], - "annotations": [ - { - "path": "/posthog/projects/{projectId}/annotations/{annotationId}.json", - "params": [ - "projectId", - "annotationId" - ] - } - ], - "dashboards": [ - { - "path": "/posthog/projects/{projectId}/dashboards/{dashboardId}.json", - "params": [ - "projectId", - "dashboardId" - ] - } - ], - "experiments": [ - { - "path": "/posthog/projects/{projectId}/experiments/{experimentId}.json", - "params": [ - "projectId", - "experimentId" - ] - } - ], - "feature-flags": [ - { - "path": "/posthog/projects/{projectId}/feature-flags/{featureFlagId}.json", - "params": [ - "projectId", - "featureFlagId" - ] - } - ], - "insights": [ - { - "path": "/posthog/projects/{projectId}/insights/{insightId}.json", - "params": [ - "projectId", - "insightId" - ] - } - ], - "projects": [ - { - "path": "/posthog/projects/{projectId}.json", - "params": [ - "projectId" - ] - } - ], - "surveys": [ - { - "path": "/posthog/projects/{projectId}/surveys/{surveyId}.json", - "params": [ - "projectId", - "surveyId" - ] - } - ] - }, "recall": { "recordings": [ { diff --git a/packages/core/src/writeback-paths/catalog.generated.ts b/packages/core/src/writeback-paths/catalog.generated.ts index d2057b3f..5a1398d5 100644 --- a/packages/core/src/writeback-paths/catalog.generated.ts +++ b/packages/core/src/writeback-paths/catalog.generated.ts @@ -749,79 +749,6 @@ export const WRITEBACK_PATH_CATALOG = { } ] }, - "posthog": { - "alert-events": [ - { - "path": "/posthog/projects/{projectId}/alert-events/{eventId}.json", - "params": [ - "projectId", - "eventId" - ] - } - ], - "annotations": [ - { - "path": "/posthog/projects/{projectId}/annotations/{annotationId}.json", - "params": [ - "projectId", - "annotationId" - ] - } - ], - "dashboards": [ - { - "path": "/posthog/projects/{projectId}/dashboards/{dashboardId}.json", - "params": [ - "projectId", - "dashboardId" - ] - } - ], - "experiments": [ - { - "path": "/posthog/projects/{projectId}/experiments/{experimentId}.json", - "params": [ - "projectId", - "experimentId" - ] - } - ], - "feature-flags": [ - { - "path": "/posthog/projects/{projectId}/feature-flags/{featureFlagId}.json", - "params": [ - "projectId", - "featureFlagId" - ] - } - ], - "insights": [ - { - "path": "/posthog/projects/{projectId}/insights/{insightId}.json", - "params": [ - "projectId", - "insightId" - ] - } - ], - "projects": [ - { - "path": "/posthog/projects/{projectId}.json", - "params": [ - "projectId" - ] - } - ], - "surveys": [ - { - "path": "/posthog/projects/{projectId}/surveys/{surveyId}.json", - "params": [ - "projectId", - "surveyId" - ] - } - ] - }, "recall": { "recordings": [ { @@ -1105,6 +1032,12 @@ export const ADAPTERS_WITHOUT_WRITEBACK_PATHS = [ "provider": "neon", "reason": "No resources.ts with writeback path templates" }, + { + "packageName": "@relayfile/adapter-posthog", + "packagePath": "packages/posthog", + "provider": "posthog", + "reason": "No resources.ts with writeback path templates" + }, { "packageName": "@relayfile/adapter-segment", "packagePath": "packages/segment", diff --git a/packages/posthog/package.json b/packages/posthog/package.json index bcd155c1..8f21f8de 100644 --- a/packages/posthog/package.json +++ b/packages/posthog/package.json @@ -53,7 +53,8 @@ ], "scripts": { "build": "tsc", - "test": "node --import tsx --test 'src/**/*.test.ts'", + "generate:resources": "node scripts/generate-resource-paths.mjs", + "test": "node scripts/generate-resource-paths.mjs --check && node --import tsx --test 'src/**/*.test.ts'", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build" }, @@ -66,7 +67,8 @@ "devDependencies": { "@types/node": "^24.6.0", "tsx": "^4.20.6", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.8.1" }, "engines": { "node": ">=18" diff --git a/packages/posthog/posthog.mapping.yaml b/packages/posthog/posthog.mapping.yaml index cff87764..9a7f3839 100644 --- a/packages/posthog/posthog.mapping.yaml +++ b/packages/posthog/posthog.mapping.yaml @@ -2,6 +2,25 @@ adapter: name: posthog version: "1.0.0" baseUrl: https://us.posthog.com +scopeKeys: + - project_id +resources: + projects: + path: /posthog/projects/{projectId}.json + insights: + path: /posthog/projects/{projectId}/insights/{slug}__{insightId}.json + dashboards: + path: /posthog/projects/{projectId}/dashboards/{slug}__{dashboardId}.json + feature-flags: + path: /posthog/projects/{projectId}/feature-flags/{slug}__{featureFlagId}.json + annotations: + path: /posthog/projects/{projectId}/annotations/{slug}__{annotationId}.json + experiments: + path: /posthog/projects/{projectId}/experiments/{slug}__{experimentId}.json + surveys: + path: /posthog/projects/{projectId}/surveys/{slug}__{surveyId}.json + alert-events: + path: /posthog/projects/{projectId}/alert-events/{slug}__{eventId}.json webhooks: # Normalized forward-webhook events currently supported by the cloud ingress. # These are alert lifecycle payloads materialized under /posthog/projects//alert-events/. diff --git a/packages/posthog/scripts/generate-resource-paths.mjs b/packages/posthog/scripts/generate-resource-paths.mjs new file mode 100644 index 00000000..bd1b1b2f --- /dev/null +++ b/packages/posthog/scripts/generate-resource-paths.mjs @@ -0,0 +1,45 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import YAML from "yaml"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const mappingPath = fileURLToPath( + new URL("../posthog.mapping.yaml", import.meta.url), +); +const outputPath = fileURLToPath( + new URL("../src/resource-paths.generated.ts", import.meta.url), +); + +const mapping = YAML.parse(await readFile(mappingPath, "utf8")); +const resources = mapping?.resources; +if (!resources || typeof resources !== "object" || Array.isArray(resources)) { + throw new Error("posthog.mapping.yaml must define a resources object"); +} + +const paths = Object.fromEntries( + Object.entries(resources) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, config]) => { + const path = config?.path; + if (typeof path !== "string" || path.length === 0) { + throw new Error(`PostHog mapping resource ${name} must define path`); + } + return [name, path]; + }), +); + +const content = `// Generated by scripts/generate-resource-paths.mjs. Do not edit. +export const POSTHOG_RESOURCE_PATHS = ${JSON.stringify(paths, null, 2)} as const; +`; + +if (process.argv.includes("--check")) { + const current = await readFile(outputPath, "utf8").catch(() => ""); + if (current !== content) { + throw new Error( + `PostHog resource paths are stale; run node ${packageRoot}/scripts/generate-resource-paths.mjs`, + ); + } +} else { + await writeFile(outputPath, content); +} diff --git a/packages/posthog/src/emit-auxiliary-files.test.ts b/packages/posthog/src/emit-auxiliary-files.test.ts new file mode 100644 index 00000000..b83d2a78 --- /dev/null +++ b/packages/posthog/src/emit-auxiliary-files.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { AuxiliaryEmitterClient } from "@relayfile/adapter-core"; + +import { emitPostHogAuxiliaryFiles } from "./emit-auxiliary-files.js"; +import { posthogDashboardByNameAliasPath } from "./path-mapper.js"; + +function makeClient(seed: Record = {}): { + client: AuxiliaryEmitterClient; + files: Map; + deleted: string[]; +} { + const files = new Map( + Object.entries(seed).map(([path, value]) => [ + path, + `${JSON.stringify(value, null, 2)}\n`, + ]), + ); + const deleted: string[] = []; + return { + files, + deleted, + client: { + async readFile({ path }) { + const content = files.get(path); + return content ? { content } : null; + }, + async writeFile({ path, content }) { + files.set(path, content); + }, + async deleteFile({ path }) { + files.delete(path); + deleted.push(path); + }, + }, + }; +} + +function readJson( + files: Map, + path: string, +): Record[] { + const content = files.get(path); + assert.ok(content, `${path} missing`); + return JSON.parse(content) as Record[]; +} + +test("numeric ids materialize stable indexes and named aliases", async () => { + const { client, files } = makeClient(); + const result = await emitPostHogAuxiliaryFiles(client, { + workspaceId: "workspace-1", + dashboards: [ + { + id: 73, + project_id: 17, + name: "Checkout funnel", + updated_at: "2026-07-25T08:10:00.000Z", + }, + ], + }); + + assert.deepEqual(result.errors, []); + const rows = readJson(files, "/posthog/projects/17/dashboards/_index.json"); + assert.deepEqual(rows[0], { + id: "73", + title: "Checkout funnel", + updated: "2026-07-25T08:10:00.000Z", + canonicalPath: + "/posthog/projects/17/dashboards/checkout-funnel__73.json", + archived: false, + }); + assert.ok( + files.has( + posthogDashboardByNameAliasPath("17", "Checkout funnel", "73"), + ), + ); +}); + +test("incremental indexes retain prior rows and prior timestamps", async () => { + const aggregatePath = "/posthog/dashboards/_index.json"; + const projectPath = "/posthog/projects/17/dashboards/_index.json"; + const previousUpdated = "2026-07-24T08:10:00.000Z"; + const { client, files } = makeClient({ + [aggregatePath]: [ + { + id: "17__72", + title: "Existing dashboard", + updated: "2026-07-23T08:10:00.000Z", + canonicalPath: + "/posthog/projects/17/dashboards/existing-dashboard__72.json", + }, + { + id: "17__73", + title: "Checkout funnel", + updated: previousUpdated, + canonicalPath: + "/posthog/projects/17/dashboards/checkout-funnel__73.json", + }, + ], + [projectPath]: [ + { + id: "73", + title: "Checkout funnel", + updated: previousUpdated, + canonicalPath: + "/posthog/projects/17/dashboards/checkout-funnel__73.json", + }, + ], + }); + + await emitPostHogAuxiliaryFiles(client, { + workspaceId: "workspace-1", + dashboards: [{ id: 73, project_id: 17, name: "Checkout funnel" }], + }); + + const aggregateRows = readJson(files, aggregatePath); + assert.equal(aggregateRows.length, 2); + assert.equal( + aggregateRows.find((row) => row.id === "17__73")?.updated, + previousUpdated, + ); + assert.equal(readJson(files, projectPath)[0]?.updated, previousUpdated); +}); + +test("unavailable prior state never overwrites incremental indexes", async () => { + const writes: string[] = []; + const client: AuxiliaryEmitterClient = { + async writeFile({ path }) { + writes.push(path); + }, + async deleteFile() {}, + }; + + await emitPostHogAuxiliaryFiles(client, { + workspaceId: "workspace-1", + dashboards: [{ id: 73, project_id: 17, name: "Checkout funnel" }], + }); + + assert.ok(writes.includes("/posthog/_index.json")); + assert.equal( + writes.some( + (path) => path !== "/posthog/_index.json" && path.endsWith("/_index.json"), + ), + false, + ); +}); + +test("record I/O uses bounded concurrency", async () => { + let activeReads = 0; + let maxActiveReads = 0; + const client: AuxiliaryEmitterClient = { + async readFile() { + activeReads += 1; + maxActiveReads = Math.max(maxActiveReads, activeReads); + await new Promise((resolve) => setImmediate(resolve)); + activeReads -= 1; + return null; + }, + async writeFile() {}, + async deleteFile() {}, + }; + + await emitPostHogAuxiliaryFiles(client, { + workspaceId: "workspace-1", + dashboards: Array.from({ length: 24 }, (_, index) => ({ + id: index + 1, + project_id: 17, + name: `Dashboard ${index + 1}`, + })), + }); + + assert.ok(maxActiveReads > 1); + assert.ok(maxActiveReads <= 8); +}); + +test("read failures are reported and do not produce replacement indexes", async () => { + const writes: string[] = []; + const client: AuxiliaryEmitterClient = { + async readFile() { + throw new Error("transport unavailable"); + }, + async writeFile({ path }) { + writes.push(path); + }, + async deleteFile() {}, + }; + + const result = await emitPostHogAuxiliaryFiles(client, { + workspaceId: "workspace-1", + dashboards: [{ id: 73, project_id: 17, name: "Checkout funnel" }], + }); + + assert.ok(result.errors.length > 0); + assert.equal(writes.includes("/posthog/dashboards/_index.json"), false); + assert.equal( + writes.includes("/posthog/projects/17/dashboards/_index.json"), + false, + ); +}); diff --git a/packages/posthog/src/emit-auxiliary-files.ts b/packages/posthog/src/emit-auxiliary-files.ts index fcaed4c0..78261a63 100644 --- a/packages/posthog/src/emit-auxiliary-files.ts +++ b/packages/posthog/src/emit-auxiliary-files.ts @@ -9,6 +9,8 @@ import { computePostHogPath, posthogAggregateCollection, posthogAggregateIndexPath, + posthogDashboardByNameAliasPath, + posthogExperimentByNameAliasPath, posthogFeatureFlagByKeyAliasPath, posthogGlobalByIdAliasPath, posthogInsightByShortIdAliasPath, @@ -17,15 +19,20 @@ import { posthogProjectLocalByIdAliasPath, posthogProjectLocalIndexPath, posthogProjectsIndexPath, + posthogRecordDisplayName, posthogRootIndexPath, + posthogSurveyByNameAliasPath, } from "./path-mapper.js"; import { type PostHogPathObjectType } from "./types.js"; const JSON_CONTENT_TYPE = EMIT_AUXILIARY_JSON_CONTENT_TYPE; +const EMIT_CONCURRENCY = 8; type PostHogRecord = Record & { - id?: string; - project_id?: string; + id?: string | number; + project_id?: string | number; + projectId?: string | number; + alert_id?: string | number; project_name?: string; name?: string; title?: string; @@ -57,6 +64,15 @@ type IndexRow = { type ProjectScopedObjectType = Exclude; +type IndexSnapshot = { + rows: IndexRow[]; + available: boolean; +}; + +type JsonReadResult = + | { available: true; value: unknown } + | { available: false; value: null }; + export interface EmitPostHogAuxiliaryFilesInput { workspaceId: string; projects?: readonly PostHogRecord[]; @@ -117,13 +133,13 @@ async function emitProjects( connectionId: string | undefined, ): Promise { const indexPath = posthogProjectsIndexPath(); - const existingRows = await readIndex(client, workspaceId, indexPath, aggregate); - const rows = new Map(existingRows.map((row) => [row.id, row])); + const existing = await readIndex(client, workspaceId, indexPath, aggregate); + const rows = new Map(existing.rows.map((row) => [row.id, row])); - for (const record of records) { + await runWithConcurrencyLimit(records, EMIT_CONCURRENCY, async (record) => { const projectId = readProjectId(record, "project"); if (!projectId) { - continue; + return; } const byIdPath = posthogProjectByIdAliasPath(projectId); @@ -144,15 +160,16 @@ async function emitProjects( aggregate, ); } - continue; + return; } + const previousRow = rows.get(projectId); rows.set(projectId, { id: projectId, title: currentName, - updated: readUpdated(record), + updated: readUpdated(record, previousRow?.updated), canonicalPath, - organization_id: readString(record.organization_id), + organization_id: readIdentifier(record.organization_id), archived: record.archived === true, }); @@ -188,9 +205,11 @@ async function emitProjects( aggregate, ); } - } + }); - await writeSortedIndex(client, workspaceId, indexPath, rows, aggregate); + if (existing.available) { + await writeSortedIndex(client, workspaceId, indexPath, rows, aggregate); + } } async function emitProjectScopedCollection( @@ -204,29 +223,36 @@ async function emitProjectScopedCollection( const aggregateIndexPath = posthogAggregateIndexPath( posthogAggregateCollection(objectType), ); - const aggregateExistingRows = await readIndex( + const aggregateExisting = await readIndex( client, workspaceId, aggregateIndexPath, aggregate, ); - const aggregateRows = new Map(aggregateExistingRows.map((row) => [row.id, row])); + const aggregateRows = new Map( + aggregateExisting.rows.map((row) => [row.id, row]), + ); - const grouped = groupByProject(records); + const grouped = groupByProject(records, objectType); for (const [projectId, projectRecords] of grouped.entries()) { const projectIndexPath = posthogProjectLocalIndexPath(objectType, projectId); - const projectExistingRows = await readIndex( + const projectExisting = await readIndex( client, workspaceId, projectIndexPath, aggregate, ); - const projectRows = new Map(projectExistingRows.map((row) => [row.id, row])); + const projectRows = new Map( + projectExisting.rows.map((row) => [row.id, row]), + ); - for (const record of projectRecords) { + await runWithConcurrencyLimit( + projectRecords, + EMIT_CONCURRENCY, + async (record) => { const objectId = readObjectId(record, objectType); if (!objectId) { - continue; + return; } const globalAliasPath = posthogGlobalByIdAliasPath( @@ -245,10 +271,25 @@ async function emitProjectScopedCollection( globalAliasPath, aggregate, ); + const title = posthogRecordDisplayName( + objectType, + record, + objectId, + ); const canonicalPath = computePostHogPath(objectType, objectId, { projectId, + displayName: title, }); - const title = readObjectTitle(record, objectType, objectId); + const previousCanonicalPath = previousRecord + ? computePostHogPath(objectType, objectId, { + projectId, + displayName: posthogRecordDisplayName( + objectType, + previousRecord, + objectId, + ), + }) + : null; const aggregateId = composeProjectScopedId(projectId, objectId); const aliasPayload = buildAliasPayload({ provider: "posthog", @@ -272,13 +313,23 @@ async function emitProjectScopedCollection( )) { await safeDelete(client, workspaceId, extraAlias, aggregate); } - continue; + if (previousCanonicalPath) { + await safeDelete( + client, + workspaceId, + previousCanonicalPath, + aggregate, + ); + } + return; } + const previousAggregateRow = aggregateRows.get(aggregateId); + const previousProjectRow = projectRows.get(objectId); aggregateRows.set(aggregateId, { id: aggregateId, title, - updated: readUpdated(record), + updated: readUpdated(record, previousAggregateRow?.updated), canonicalPath, project_id: projectId, project_name: readString(record.project_name), @@ -289,7 +340,7 @@ async function emitProjectScopedCollection( projectRows.set(objectId, { id: objectId, title, - updated: readUpdated(record), + updated: readUpdated(record, previousProjectRow?.updated), canonicalPath, state: readString(record.state), status: readString(record.status), @@ -331,32 +382,49 @@ async function emitProjectScopedCollection( aggregate, ); } + if ( + previousCanonicalPath && + previousCanonicalPath !== canonicalPath + ) { + await safeDelete( + client, + workspaceId, + previousCanonicalPath, + aggregate, + ); + } + }, + ); + + if (projectExisting.available) { + await writeSortedIndex( + client, + workspaceId, + projectIndexPath, + projectRows, + aggregate, + ); } + } + if (aggregateExisting.available) { await writeSortedIndex( client, workspaceId, - projectIndexPath, - projectRows, + aggregateIndexPath, + aggregateRows, aggregate, ); } - - await writeSortedIndex( - client, - workspaceId, - aggregateIndexPath, - aggregateRows, - aggregate, - ); } function groupByProject( records: readonly PostHogRecord[], + objectType: ProjectScopedObjectType, ): Map { const grouped = new Map(); for (const record of records) { - const projectId = readProjectId(record, "insight"); + const projectId = readProjectId(record, objectType); if (!projectId) { continue; } @@ -373,9 +441,9 @@ function readProjectId( objectType: PostHogPathObjectType, ): string | undefined { if (objectType === "project") { - return readString(record.project_id) ?? readString(record.id); + return readIdentifier(record.project_id) ?? readIdentifier(record.id); } - return readString(record.project_id) ?? readString(record.projectId); + return readIdentifier(record.project_id) ?? readIdentifier(record.projectId); } function readObjectId( @@ -383,9 +451,9 @@ function readObjectId( objectType: ProjectScopedObjectType, ): string | undefined { if (objectType === "alert-event") { - return readString(record.id) ?? readString(record.alert_id); + return readIdentifier(record.id) ?? readIdentifier(record.alert_id); } - return readString(record.id); + return readIdentifier(record.id); } function readProjectTitle(record: PostHogRecord, projectId: string): string { @@ -397,34 +465,6 @@ function readProjectTitle(record: PostHogRecord, projectId: string): string { ); } -function readObjectTitle( - record: PostHogRecord, - objectType: ProjectScopedObjectType, - objectId: string, -): string { - switch (objectType) { - case "insight": - return ( - readString(record.name) ?? - readString(record.derived_name) ?? - readString(record.short_id) ?? - objectId - ); - case "dashboard": - return readString(record.name) ?? readString(record.description) ?? objectId; - case "feature-flag": - return readString(record.name) ?? readString(record.key) ?? objectId; - case "annotation": - return readString(record.content) ?? readString(record.scope) ?? objectId; - case "experiment": - return readString(record.name) ?? readString(record.feature_flag_key) ?? objectId; - case "survey": - return readString(record.name) ?? readString(record.type) ?? objectId; - case "alert-event": - return readString(record.title) ?? readString(record.event_type) ?? objectId; - } -} - function extraAliasPaths( objectType: ProjectScopedObjectType, projectId: string, @@ -442,12 +482,36 @@ function extraAliasPaths( const key = readString(record.key); return key ? [posthogFeatureFlagByKeyAliasPath(projectId, key)] : []; } + case "dashboard": { + const name = readString(record.name); + const id = readObjectId(record, objectType); + return name && id + ? [posthogDashboardByNameAliasPath(projectId, name, id)] + : []; + } + case "experiment": { + const name = readString(record.name); + const id = readObjectId(record, objectType); + return name && id + ? [posthogExperimentByNameAliasPath(projectId, name, id)] + : []; + } + case "survey": { + const name = readString(record.name); + const id = readObjectId(record, objectType); + return name && id + ? [posthogSurveyByNameAliasPath(projectId, name, id)] + : []; + } default: return []; } } -function readUpdated(record: PostHogRecord): string { +function readUpdated( + record: PostHogRecord, + previousUpdated: string | undefined, +): string { return ( readString(record.updated_at) ?? readString(record.updatedAt) ?? @@ -459,7 +523,8 @@ function readUpdated(record: PostHogRecord): string { readString(record.start_date) ?? readString(record.created_at) ?? readString(record.createdAt) ?? - new Date().toISOString() + previousUpdated ?? + "" ); } @@ -510,9 +575,15 @@ async function readIndex( workspaceId: string, path: string, aggregate: EmitAuxiliaryFilesResult, -): Promise { - const value = await safeReadJson(client, workspaceId, path, aggregate); - return Array.isArray(value) ? (value as IndexRow[]) : []; +): Promise { + const result = await safeReadJson(client, workspaceId, path, aggregate); + return { + available: result.available, + rows: + result.available && Array.isArray(result.value) + ? (result.value as IndexRow[]) + : [], + }; } async function readAliasRecord( @@ -521,11 +592,13 @@ async function readAliasRecord( path: string, aggregate: EmitAuxiliaryFilesResult, ): Promise { - const value = await safeReadJson(client, workspaceId, path, aggregate); - if (!isRecord(value)) { + const result = await safeReadJson(client, workspaceId, path, aggregate); + if (!result.available || !isRecord(result.value)) { return null; } - const payload = isRecord(value.payload) ? value.payload : value; + const payload = isRecord(result.value.payload) + ? result.value.payload + : result.value; return payload as PostHogRecord; } @@ -534,9 +607,9 @@ async function safeReadJson( workspaceId: string, path: string, aggregate: EmitAuxiliaryFilesResult, -): Promise { +): Promise { if (!client.readFile) { - return null; + return { available: false, value: null }; } try { const value = await client.readFile({ workspaceId, path }); @@ -547,9 +620,9 @@ async function safeReadJson( ? value.content : null; if (!content) { - return null; + return { available: true, value: null }; } - return JSON.parse(content) as unknown; + return { available: true, value: JSON.parse(content) as unknown }; } catch (error) { const message = String(error); if ( @@ -557,10 +630,10 @@ async function safeReadJson( message.includes("ENOENT") || message.includes("404") ) { - return null; + return { available: true, value: null }; } aggregate.errors.push({ path, error: message }); - return null; + return { available: false, value: null }; } } @@ -616,6 +689,36 @@ function readString(value: unknown): string | undefined { : undefined; } +function readIdentifier(value: unknown): string | undefined { + const text = readString(value); + if (text) { + return text; + } + return typeof value === "number" && Number.isFinite(value) + ? String(value) + : undefined; +} + +async function runWithConcurrencyLimit( + values: readonly T[], + limit: number, + worker: (value: T) => Promise, +): Promise { + let nextIndex = 0; + const workerCount = Math.min(Math.max(limit, 1), values.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < values.length) { + const value = values[nextIndex]; + nextIndex += 1; + if (value !== undefined) { + await worker(value); + } + } + }), + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/packages/posthog/src/layout-prompt.test.ts b/packages/posthog/src/layout-prompt.test.ts new file mode 100644 index 00000000..af1487a9 --- /dev/null +++ b/packages/posthog/src/layout-prompt.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { layoutPromptFile } from "./layout-prompt.js"; + +test("PostHog layout documents canonical paths, aliases, and terminal states", () => { + const layout = layoutPromptFile(); + assert.equal(layout.path, "/posthog/LAYOUT.md"); + assert.equal(layout.contentType, "text/markdown; charset=utf-8"); + assert.ok(layout.content.length >= 1_000); + assert.match(layout.content, /__\.json/u); + assert.match(layout.content, /by-name/u); + assert.match(layout.content, /by-short-id/u); + assert.match(layout.content, /by-key/u); + assert.match(layout.content, /terminal state is not a deletion/u); + assert.match(layout.content, /jq /u); +}); diff --git a/packages/posthog/src/layout-prompt.ts b/packages/posthog/src/layout-prompt.ts index d1df34d8..04fb6ed7 100644 --- a/packages/posthog/src/layout-prompt.ts +++ b/packages/posthog/src/layout-prompt.ts @@ -7,20 +7,28 @@ Always inspect \`_index.json\` files before constructing a record path. PostHog \`/posthog/LAYOUT.md\` is this guide. \`/posthog/projects/\` holds project metadata records. \`/posthog/insights/_index.json\`, \`/posthog/dashboards/_index.json\`, \`/posthog/feature-flags/_index.json\`, \`/posthog/annotations/_index.json\`, \`/posthog/experiments/_index.json\`, and \`/posthog/surveys/_index.json\` are aggregate indexes spanning all synced projects. -\`/posthog/projects//insights/\`, \`dashboards/\`, \`feature-flags/\`, \`annotations/\`, \`experiments/\`, and \`surveys/\` hold canonical JSON records keyed by PostHog ids. +\`/posthog/projects//insights/\`, \`dashboards/\`, \`feature-flags/\`, \`annotations/\`, \`experiments/\`, and \`surveys/\` hold canonical flat records named \`__.json\`. The stable PostHog id after the double underscore remains authoritative when a title changes. \`/posthog/alert-events/_index.json\` and \`/posthog/projects//alert-events/\` hold normalized alert webhook events when configured. Discovery contracts: - \`/posthog/projects/{projectId}.json\` -> \`discovery/posthog/projects/.schema.json\` -- \`/posthog/projects/{projectId}/insights/{insightId}.json\` -> \`discovery/posthog/insights/.schema.json\` -- \`/posthog/projects/{projectId}/dashboards/{dashboardId}.json\` -> \`discovery/posthog/dashboards/.schema.json\` -- \`/posthog/projects/{projectId}/feature-flags/{featureFlagId}.json\` -> \`discovery/posthog/feature-flags/.schema.json\` -- \`/posthog/projects/{projectId}/annotations/{annotationId}.json\` -> \`discovery/posthog/annotations/.schema.json\` -- \`/posthog/projects/{projectId}/experiments/{experimentId}.json\` -> \`discovery/posthog/experiments/.schema.json\` -- \`/posthog/projects/{projectId}/surveys/{surveyId}.json\` -> \`discovery/posthog/surveys/.schema.json\` -- \`/posthog/projects/{projectId}/alert-events/{eventId}.json\` -> \`discovery/posthog/alert-events/.schema.json\` - -Every collection exposes an \`_index.json\` and a stable \`by-id/\` alias tree. Project records also expose \`/posthog/projects/by-name/\`. Insights expose \`by-short-id/\` aliases within their project, and feature flags expose \`by-key/\` aliases within their project. +- \`/posthog/projects/{projectId}/insights/{slug}__{insightId}.json\` -> \`discovery/posthog/insights/.schema.json\` +- \`/posthog/projects/{projectId}/dashboards/{slug}__{dashboardId}.json\` -> \`discovery/posthog/dashboards/.schema.json\` +- \`/posthog/projects/{projectId}/feature-flags/{slug}__{featureFlagId}.json\` -> \`discovery/posthog/feature-flags/.schema.json\` +- \`/posthog/projects/{projectId}/annotations/{slug}__{annotationId}.json\` -> \`discovery/posthog/annotations/.schema.json\` +- \`/posthog/projects/{projectId}/experiments/{slug}__{experimentId}.json\` -> \`discovery/posthog/experiments/.schema.json\` +- \`/posthog/projects/{projectId}/surveys/{slug}__{surveyId}.json\` -> \`discovery/posthog/surveys/.schema.json\` +- \`/posthog/projects/{projectId}/alert-events/{slug}__{eventId}.json\` -> \`discovery/posthog/alert-events/.schema.json\` + +Every collection exposes an \`_index.json\` and a stable \`by-id/\` alias tree. Alias files are pointer envelopes with \`canonicalPath\`; follow that field to read the canonical record. Project records expose \`/posthog/projects/by-name/\`. Insights expose \`by-short-id/\`, feature flags expose \`by-key/\`, and dashboards, experiments, and surveys expose deterministic \`by-name/\` aliases within their project. + +Useful reads: +- \`jq 'map({id, title, updated, canonicalPath})' /posthog/insights/_index.json\` +- \`jq 'map(select(.status == "running"))' /posthog/experiments/_index.json\` +- \`jq -r '.canonicalPath' /posthog/projects//dashboards/by-name/__.json\` +- \`ls /posthog/projects//feature-flags/by-key/\` + +Lifecycle states such as resolved alerts, archived flags, completed experiments, and closed surveys remain readable canonical records. A terminal state is not a deletion; only an explicit upstream deletion removes a canonical file and its aliases. `; export function layoutPromptFile(): { diff --git a/packages/posthog/src/path-mapper.test.ts b/packages/posthog/src/path-mapper.test.ts new file mode 100644 index 00000000..85f82510 --- /dev/null +++ b/packages/posthog/src/path-mapper.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computePostHogPath, + parsePostHogPath, + posthogDashboardByNameAliasPath, + posthogExperimentByNameAliasPath, + posthogProjectByNameAliasPath, + posthogSurveyByNameAliasPath, +} from "./path-mapper.js"; +import type { PostHogPathObjectType } from "./types.js"; + +const scopedCases = [ + ["insight", "Checkout conversion", "141"], + ["dashboard", "Checkout funnel", "73"], + ["feature-flag", "Checkout V2", "9"], + ["annotation", "Release shipped", "55"], + ["experiment", "Checkout CTA color", "22"], + ["survey", "Checkout feedback", "survey-1"], + ["alert-event", "Checkout errors", "alert-1"], +] as const satisfies readonly [ + Exclude, + string, + string, +][]; + +test("canonical PostHog paths compose and parse with slug-and-id filenames", () => { + for (const [objectType, displayName, objectId] of scopedCases) { + const path = computePostHogPath(objectType, objectId, { + projectId: "17", + displayName, + }); + assert.match(path, /\/[a-z0-9-]+__[^/]+\.json$/u); + assert.deepEqual(parsePostHogPath(path), { + objectType, + objectId, + projectId: "17", + }); + } + + const projectPath = computePostHogPath("project", "17"); + assert.equal(projectPath, "/posthog/projects/17.json"); + assert.deepEqual(parsePostHogPath(projectPath), { + objectType: "project", + objectId: "17", + }); +}); + +test("parser retains compatibility with legacy bare-id paths", () => { + assert.deepEqual( + parsePostHogPath("/posthog/projects/17/dashboards/73.json"), + { + objectType: "dashboard", + objectId: "73", + projectId: "17", + }, + ); +}); + +test("named aliases use deterministic collision suffixes", () => { + const helpers = [ + (id: string) => posthogProjectByNameAliasPath("Revenue", id), + (id: string) => + posthogDashboardByNameAliasPath("17", "Revenue", id), + (id: string) => + posthogExperimentByNameAliasPath("17", "Revenue", id), + (id: string) => posthogSurveyByNameAliasPath("17", "Revenue", id), + ]; + + for (const helper of helpers) { + const first = helper("1"); + const repeated = helper("1"); + const collision = helper("2"); + assert.equal(first, repeated); + assert.notEqual(first, collision); + assert.match(first, /\/by-name\/revenue-[a-f0-9]{8}__1\.json$/u); + } +}); + +test("project-scoped paths require a project id", () => { + assert.throws( + () => computePostHogPath("dashboard", "73"), + /requires projectId/u, + ); +}); diff --git a/packages/posthog/src/path-mapper.ts b/packages/posthog/src/path-mapper.ts index 3f772f96..e2b7cb66 100644 --- a/packages/posthog/src/path-mapper.ts +++ b/packages/posthog/src/path-mapper.ts @@ -47,9 +47,11 @@ export function posthogProjectByNameAliasPath( name: string, projectId: string, ): string { - const slug = slugifyAlias(name); - const suffix = aliasCollisionSuffix(projectId); - return `${POSTHOG_PATH_ROOT}/projects/by-name/${encodePostHogPathSegment(`${slug}-${suffix}__${projectId}`)}.json`; + return posthogNamedAliasPath( + `${POSTHOG_PATH_ROOT}/projects`, + name, + projectId, + ); } export function posthogProjectLocalIndexPath( @@ -103,6 +105,45 @@ export function posthogFeatureFlagByKeyAliasPath( )}/by-key/${encodePostHogPathSegment(key)}.json`; } +export function posthogDashboardByNameAliasPath( + projectId: string, + name: string, + dashboardId: string, +): string { + return posthogProjectScopedByNameAliasPath( + "dashboard", + projectId, + name, + dashboardId, + ); +} + +export function posthogExperimentByNameAliasPath( + projectId: string, + name: string, + experimentId: string, +): string { + return posthogProjectScopedByNameAliasPath( + "experiment", + projectId, + name, + experimentId, + ); +} + +export function posthogSurveyByNameAliasPath( + projectId: string, + name: string, + surveyId: string, +): string { + return posthogProjectScopedByNameAliasPath( + "survey", + projectId, + name, + surveyId, + ); +} + export function posthogProjectCollectionPrefix( objectType: Exclude, projectId: string, @@ -139,22 +180,67 @@ export function posthogAggregateCollection( export function computePostHogPath( objectType: PostHogPathObjectType, objectId: string, - context: { projectId?: string } = {}, + context: { projectId?: string; displayName?: string } = {}, ): string { - const id = encodePostHogPathSegment(objectId); if (objectType === "project") { return posthogProjectPath(objectId); } - const projectId = context.projectId - ? encodePostHogPathSegment(context.projectId) - : null; - if (!projectId) { + if (!context.projectId) { throw new Error(`PostHog ${objectType} path requires projectId`); } - return `${POSTHOG_PATH_ROOT}/projects/${projectId}/${posthogAggregateCollection( + return posthogProjectScopedPath( + objectType, + context.projectId, + objectId, + context.displayName, + ); +} + +export function posthogProjectScopedPath( + objectType: Exclude, + projectId: string, + objectId: string, + displayName?: string, +): string { + return `${posthogProjectCollectionPrefix( + objectType, + projectId, + )}/${posthogCanonicalFlatRecordSegment(objectId, displayName)}.json`; +} + +export interface ParsedPostHogPath { + objectType: PostHogPathObjectType; + objectId: string; + projectId?: string; +} + +export function parsePostHogPath(path: string): ParsedPostHogPath | null { + const projectMatch = /^\/posthog\/projects\/([^/]+)\.json$/u.exec(path); + if (projectMatch?.[1]) { + return { + objectType: "project", + objectId: decodeURIComponent(projectMatch[1]), + }; + } + + const scopedMatch = + /^\/posthog\/projects\/([^/]+)\/(insights|dashboards|feature-flags|annotations|experiments|surveys|alert-events)\/([^/]+)\.json$/u.exec( + path, + ); + if (!scopedMatch?.[1] || !scopedMatch[2] || !scopedMatch[3]) { + return null; + } + + const objectType = posthogObjectTypeFromCollection(scopedMatch[2]); + if (!objectType) { + return null; + } + return { objectType, - )}/${id}.json`; + objectId: extractPostHogIdFromCanonicalSegment(scopedMatch[3]), + projectId: decodeURIComponent(scopedMatch[1]), + }; } export function composeProjectScopedId( @@ -167,6 +253,58 @@ export function composeProjectScopedId( )}`; } +export function posthogRecordDisplayName( + objectType: Exclude, + record: Record, + objectId: string, +): string { + switch (objectType) { + case "insight": + return ( + readDisplayString(record.name) ?? + readDisplayString(record.derived_name) ?? + readDisplayString(record.short_id) ?? + objectId + ); + case "dashboard": + return ( + readDisplayString(record.name) ?? + readDisplayString(record.description) ?? + objectId + ); + case "feature-flag": + return ( + readDisplayString(record.name) ?? + readDisplayString(record.key) ?? + objectId + ); + case "annotation": + return ( + readDisplayString(record.content) ?? + readDisplayString(record.scope) ?? + objectId + ); + case "experiment": + return ( + readDisplayString(record.name) ?? + readDisplayString(record.feature_flag_key) ?? + objectId + ); + case "survey": + return ( + readDisplayString(record.name) ?? + readDisplayString(record.type) ?? + objectId + ); + case "alert-event": + return ( + readDisplayString(record.title) ?? + readDisplayString(record.event_type) ?? + objectId + ); + } +} + export function normalizeNangoPostHogModel( model: string, ): PostHogPathObjectType | null { @@ -205,3 +343,74 @@ export function normalizeNangoPostHogModel( return null; } } + +function posthogProjectScopedByNameAliasPath( + objectType: "dashboard" | "experiment" | "survey", + projectId: string, + name: string, + objectId: string, +): string { + return posthogNamedAliasPath( + posthogProjectCollectionPrefix(objectType, projectId), + name, + objectId, + ); +} + +function posthogNamedAliasPath( + collectionPrefix: string, + name: string, + objectId: string, +): string { + const slug = slugifyAlias(name); + const suffix = aliasCollisionSuffix(objectId); + return `${collectionPrefix}/by-name/${encodePostHogPathSegment( + `${slug}-${suffix}__${objectId}`, + )}.json`; +} + +function posthogCanonicalFlatRecordSegment( + objectId: string, + displayName: string | undefined, +): string { + const id = assertNonEmpty(objectId, "object id"); + const name = displayName?.trim(); + return encodePostHogPathSegment( + name ? `${slugifyAlias(name)}__${id}` : id, + ); +} + +function extractPostHogIdFromCanonicalSegment(segment: string): string { + const decoded = decodeURIComponent(segment); + const separatorIndex = decoded.lastIndexOf("__"); + return separatorIndex >= 0 ? decoded.slice(separatorIndex + 2) : decoded; +} + +function posthogObjectTypeFromCollection( + collection: string, +): Exclude | null { + switch (collection) { + case "insights": + return "insight"; + case "dashboards": + return "dashboard"; + case "feature-flags": + return "feature-flag"; + case "annotations": + return "annotation"; + case "experiments": + return "experiment"; + case "surveys": + return "survey"; + case "alert-events": + return "alert-event"; + default: + return null; + } +} + +function readDisplayString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} diff --git a/packages/posthog/src/read-only-resources.ts b/packages/posthog/src/read-only-resources.ts new file mode 100644 index 00000000..d42f1cc4 --- /dev/null +++ b/packages/posthog/src/read-only-resources.ts @@ -0,0 +1,93 @@ +import { POSTHOG_RESOURCE_PATHS } from "./resource-paths.generated.js"; + +export interface AdapterResourceConfig { + readonly name: string; + readonly path: string; + readonly pathPattern: RegExp; + readonly idPattern: RegExp; + readonly schema: string; + readonly createExample: string; + readonly sampleIndexPath?: string; +} + +export const readOnlyResources = [ + { + name: "projects", + path: POSTHOG_RESOURCE_PATHS.projects, + pathPattern: /^\/posthog\/projects\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/projects/.schema.json", + createExample: "discovery/posthog/projects/.create.example.json", + sampleIndexPath: "/posthog/projects/_index.json", + }, + { + name: "insights", + path: POSTHOG_RESOURCE_PATHS.insights, + pathPattern: + /^\/posthog\/projects\/[^/]+\/insights\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/insights/.schema.json", + createExample: "discovery/posthog/insights/.create.example.json", + sampleIndexPath: "/posthog/insights/_index.json", + }, + { + name: "dashboards", + path: POSTHOG_RESOURCE_PATHS.dashboards, + pathPattern: + /^\/posthog\/projects\/[^/]+\/dashboards\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/dashboards/.schema.json", + createExample: "discovery/posthog/dashboards/.create.example.json", + sampleIndexPath: "/posthog/dashboards/_index.json", + }, + { + name: "feature-flags", + path: POSTHOG_RESOURCE_PATHS["feature-flags"], + pathPattern: + /^\/posthog\/projects\/[^/]+\/feature-flags\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/feature-flags/.schema.json", + createExample: "discovery/posthog/feature-flags/.create.example.json", + sampleIndexPath: "/posthog/feature-flags/_index.json", + }, + { + name: "annotations", + path: POSTHOG_RESOURCE_PATHS.annotations, + pathPattern: + /^\/posthog\/projects\/[^/]+\/annotations\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/annotations/.schema.json", + createExample: "discovery/posthog/annotations/.create.example.json", + sampleIndexPath: "/posthog/annotations/_index.json", + }, + { + name: "experiments", + path: POSTHOG_RESOURCE_PATHS.experiments, + pathPattern: + /^\/posthog\/projects\/[^/]+\/experiments\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/experiments/.schema.json", + createExample: "discovery/posthog/experiments/.create.example.json", + sampleIndexPath: "/posthog/experiments/_index.json", + }, + { + name: "surveys", + path: POSTHOG_RESOURCE_PATHS.surveys, + pathPattern: + /^\/posthog\/projects\/[^/]+\/surveys\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/surveys/.schema.json", + createExample: "discovery/posthog/surveys/.create.example.json", + sampleIndexPath: "/posthog/surveys/_index.json", + }, + { + name: "alert-events", + path: POSTHOG_RESOURCE_PATHS["alert-events"], + pathPattern: + /^\/posthog\/projects\/[^/]+\/alert-events\/[^/]+\.json$/u, + idPattern: /^[^/]+$/u, + schema: "discovery/posthog/alert-events/.schema.json", + createExample: "discovery/posthog/alert-events/.create.example.json", + sampleIndexPath: "/posthog/alert-events/_index.json", + }, +] as const satisfies readonly AdapterResourceConfig[]; diff --git a/packages/posthog/src/resource-paths.generated.ts b/packages/posthog/src/resource-paths.generated.ts new file mode 100644 index 00000000..ee14042c --- /dev/null +++ b/packages/posthog/src/resource-paths.generated.ts @@ -0,0 +1,11 @@ +// Generated by scripts/generate-resource-paths.mjs. Do not edit. +export const POSTHOG_RESOURCE_PATHS = { + "alert-events": "/posthog/projects/{projectId}/alert-events/{slug}__{eventId}.json", + "annotations": "/posthog/projects/{projectId}/annotations/{slug}__{annotationId}.json", + "dashboards": "/posthog/projects/{projectId}/dashboards/{slug}__{dashboardId}.json", + "experiments": "/posthog/projects/{projectId}/experiments/{slug}__{experimentId}.json", + "feature-flags": "/posthog/projects/{projectId}/feature-flags/{slug}__{featureFlagId}.json", + "insights": "/posthog/projects/{projectId}/insights/{slug}__{insightId}.json", + "projects": "/posthog/projects/{projectId}.json", + "surveys": "/posthog/projects/{projectId}/surveys/{slug}__{surveyId}.json" +} as const; diff --git a/packages/posthog/src/resources.test.ts b/packages/posthog/src/resources.test.ts new file mode 100644 index 00000000..0d528148 --- /dev/null +++ b/packages/posthog/src/resources.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { POSTHOG_RESOURCE_PATHS } from "./resource-paths.generated.js"; +import { + findResourceByPath, + readOnlyResources, + resources, +} from "./resources.js"; + +test("PostHog remains read-only in the writeback catalog", () => { + assert.deepEqual(resources, []); + assert.equal(readOnlyResources.length, 8); +}); + +test("read-only discovery resources derive names and paths from the mapping", () => { + assert.deepEqual( + Object.fromEntries( + readOnlyResources.map((resource) => [resource.name, resource.path]), + ), + POSTHOG_RESOURCE_PATHS, + ); + for (const resource of readOnlyResources) { + assert.match(resource.sampleIndexPath ?? "", /\/_index\.json$/u); + } +}); + +test("resource lookup accepts slug-and-id canonical paths", () => { + assert.equal( + findResourceByPath( + "/posthog/projects/17/dashboards/checkout-funnel__73.json", + )?.name, + "dashboards", + ); + assert.equal(findResourceByPath("/posthog/projects/17/by-id/73.json"), undefined); +}); diff --git a/packages/posthog/src/resources.ts b/packages/posthog/src/resources.ts index 220753d1..97fd2f05 100644 --- a/packages/posthog/src/resources.ts +++ b/packages/posthog/src/resources.ts @@ -1,87 +1,12 @@ -export interface AdapterResourceConfig { - readonly name: string; - readonly path: string; - readonly pathPattern: RegExp; - readonly idPattern: RegExp; - readonly schema: string; - readonly createExample: string; - readonly sampleIndexPath?: string; -} +import { + readOnlyResources, + type AdapterResourceConfig, +} from "./read-only-resources.js"; + +export type { AdapterResourceConfig } from "./read-only-resources.js"; +export { readOnlyResources } from "./read-only-resources.js"; -export const resources = [ - { - name: "projects", - path: "/posthog/projects/{projectId}.json", - pathPattern: /^\/posthog\/projects\/[^/]+\.json$/u, - idPattern: /^[^/]+$/u, - schema: "discovery/posthog/projects/.schema.json", - createExample: "discovery/posthog/projects/.create.example.json", - sampleIndexPath: "/posthog/projects/_index.json", - }, - { - name: "insights", - path: "/posthog/projects/{projectId}/insights/{insightId}.json", - pathPattern: /^\/posthog\/projects\/[^/]+\/insights\/[^/]+\.json$/u, - idPattern: /^[^/]+$/u, - schema: "discovery/posthog/insights/.schema.json", - createExample: "discovery/posthog/insights/.create.example.json", - sampleIndexPath: "/posthog/insights", - }, - { - name: "dashboards", - path: "/posthog/projects/{projectId}/dashboards/{dashboardId}.json", - pathPattern: /^\/posthog\/projects\/[^/]+\/dashboards\/[^/]+\.json$/u, - idPattern: /^[^/]+$/u, - schema: "discovery/posthog/dashboards/.schema.json", - createExample: "discovery/posthog/dashboards/.create.example.json", - sampleIndexPath: "/posthog/dashboards", - }, - { - name: "feature-flags", - path: "/posthog/projects/{projectId}/feature-flags/{featureFlagId}.json", - pathPattern: /^\/posthog\/projects\/[^/]+\/feature-flags\/[^/]+\.json$/u, - idPattern: /^[^/]+$/u, - schema: "discovery/posthog/feature-flags/.schema.json", - createExample: "discovery/posthog/feature-flags/.create.example.json", - sampleIndexPath: "/posthog/feature-flags", - }, - { - name: "annotations", - path: "/posthog/projects/{projectId}/annotations/{annotationId}.json", - pathPattern: /^\/posthog\/projects\/[^/]+\/annotations\/[^/]+\.json$/u, - idPattern: /^[^/]+$/u, - schema: "discovery/posthog/annotations/.schema.json", - createExample: "discovery/posthog/annotations/.create.example.json", - sampleIndexPath: "/posthog/annotations", - }, - { - name: "experiments", - path: "/posthog/projects/{projectId}/experiments/{experimentId}.json", - pathPattern: /^\/posthog\/projects\/[^/]+\/experiments\/[^/]+\.json$/u, - idPattern: /^[^/]+$/u, - schema: "discovery/posthog/experiments/.schema.json", - createExample: "discovery/posthog/experiments/.create.example.json", - sampleIndexPath: "/posthog/experiments", - }, - { - name: "surveys", - path: "/posthog/projects/{projectId}/surveys/{surveyId}.json", - pathPattern: /^\/posthog\/projects\/[^/]+\/surveys\/[^/]+\.json$/u, - idPattern: /^[^/]+$/u, - schema: "discovery/posthog/surveys/.schema.json", - createExample: "discovery/posthog/surveys/.create.example.json", - sampleIndexPath: "/posthog/surveys", - }, - { - name: "alert-events", - path: "/posthog/projects/{projectId}/alert-events/{eventId}.json", - pathPattern: /^\/posthog\/projects\/[^/]+\/alert-events\/[^/]+\.json$/u, - idPattern: /^[^/]+$/u, - schema: "discovery/posthog/alert-events/.schema.json", - createExample: "discovery/posthog/alert-events/.create.example.json", - sampleIndexPath: "/posthog/alert-events", - }, -] as const satisfies readonly AdapterResourceConfig[]; +export const resources = [] as const satisfies readonly AdapterResourceConfig[]; export function findResourceByPath( path: string, @@ -89,7 +14,7 @@ export function findResourceByPath( const normalizedPath = path.endsWith(".json") ? path : path.replace(/\/$/u, ""); - return resources.find((resource) => + return readOnlyResources.find((resource) => resource.pathPattern.test(normalizedPath), ); } diff --git a/packages/posthog/src/webhook-normalizer.test.ts b/packages/posthog/src/webhook-normalizer.test.ts new file mode 100644 index 00000000..657f34e1 --- /dev/null +++ b/packages/posthog/src/webhook-normalizer.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizePostHogWebhook } from "./webhook-normalizer.js"; + +test("normalizes numeric PostHog ids and derives resolved trigger semantics", () => { + const normalized = normalizePostHogWebhook({ + id: 42, + project_id: 17, + title: "Checkout errors above threshold", + state: "resolved", + source: "untrusted-source", + occurred_at: "2026-07-25T08:33:00.000Z", + }); + + assert.ok(normalized); + assert.equal(normalized.objectId, "42"); + assert.equal(normalized.projectId, "17"); + assert.equal(normalized.eventType, "posthog.alert.resolved"); + assert.equal(normalized.fileEventType, "file.updated"); + assert.equal(normalized.shouldDelete, false); + assert.equal( + normalized.path, + "/posthog/projects/17/alert-events/checkout-errors-above-threshold__42.json", + ); + assert.deepEqual( + { + id: normalized.record.id, + project_id: normalized.record.project_id, + source: normalized.record.source, + state: normalized.record.state, + event_type: normalized.record.event_type, + }, + { + id: "42", + project_id: "17", + source: "posthog", + state: "resolved", + event_type: "posthog.alert.resolved", + }, + ); +}); + +test("explicit upstream event type remains authoritative", () => { + const normalized = normalizePostHogWebhook({ + alert_id: "alert-1", + projectId: "project-1", + status: "open", + event_type: "posthog.alert.triggered", + }); + + assert.ok(normalized); + assert.equal(normalized.eventType, "posthog.alert.triggered"); + assert.equal(normalized.state, "triggered"); + assert.equal(normalized.fileEventType, "file.created"); +}); + +test("rejects missing and non-finite identifiers", () => { + assert.equal( + normalizePostHogWebhook({ id: "alert-1", state: "triggered" }), + null, + ); + assert.equal( + normalizePostHogWebhook({ + id: Number.NaN, + project_id: 17, + state: "triggered", + }), + null, + ); +}); diff --git a/packages/posthog/src/webhook-normalizer.ts b/packages/posthog/src/webhook-normalizer.ts index 8fdfc475..663c0d74 100644 --- a/packages/posthog/src/webhook-normalizer.ts +++ b/packages/posthog/src/webhook-normalizer.ts @@ -21,20 +21,28 @@ export interface NormalizedPostHogWebhook { export function normalizePostHogWebhook( payload: Record, ): NormalizedPostHogWebhook | null { - const projectId = readString(payload.project_id) ?? readString(payload.projectId); - const objectId = readString(payload.id) ?? readString(payload.alert_id); - const eventType = + const projectId = + readIdentifier(payload.project_id) ?? readIdentifier(payload.projectId); + const objectId = + readIdentifier(payload.id) ?? readIdentifier(payload.alert_id); + const explicitEventType = readString(payload.event_type) ?? readString(payload.eventType) ?? - readString(payload.type) ?? - "posthog.alert.triggered"; + readString(payload.type); if (!projectId || !objectId) { return null; } const state = normalizeState( - readString(payload.state) ?? readString(payload.status) ?? eventType, + readString(payload.state) ?? + readString(payload.status) ?? + explicitEventType, ); + const eventType = + explicitEventType ?? + (state === "resolved" + ? "posthog.alert.resolved" + : "posthog.alert.triggered"); const timestamp = readString(payload.occurred_at) ?? readString(payload.timestamp) ?? @@ -46,6 +54,7 @@ export function normalizePostHogWebhook( const severity = readString(payload.severity) ?? undefined; const record: Record = { + ...payload, id: objectId, project_id: projectId, source: "posthog", @@ -55,7 +64,6 @@ export function normalizePostHogWebhook( event_type: eventType, ...(state ? { state } : {}), ...(severity ? { severity } : {}), - ...payload, }; return { @@ -68,7 +76,10 @@ export function normalizePostHogWebhook( record, fileEventType: state === "resolved" ? "file.updated" : "file.created", shouldDelete: false, - path: computePostHogPath("alert-event", objectId, { projectId }), + path: computePostHogPath("alert-event", objectId, { + projectId, + displayName: title, + }), ...(state ? { state } : {}), ...(severity ? { severity } : {}), timestamp, @@ -103,3 +114,13 @@ function readString(value: unknown): string | undefined { ? value.trim() : undefined; } + +function readIdentifier(value: unknown): string | undefined { + const text = readString(value); + if (text) { + return text; + } + return typeof value === "number" && Number.isFinite(value) + ? String(value) + : undefined; +} diff --git a/packages/relay-helpers/src/generated/clients.ts b/packages/relay-helpers/src/generated/clients.ts index 1dfffb13..439d98f1 100644 --- a/packages/relay-helpers/src/generated/clients.ts +++ b/packages/relay-helpers/src/generated/clients.ts @@ -80,9 +80,6 @@ export const pipedriveClient = (opts?: RelayClientOptions): ProviderClient<'pipe export const postgresClient = (opts?: RelayClientOptions): ProviderClient<'postgres'> => providerClient('postgres', opts); -export const posthogClient = (opts?: RelayClientOptions): ProviderClient<'posthog'> => - providerClient('posthog', opts); - export const recallClient = (opts?: RelayClientOptions): ProviderClient<'recall'> => providerClient('recall', opts); From 04468ae6c2a15b0f8e8902d88fafba1ccf783144 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 26 Jul 2026 12:07:11 +0200 Subject: [PATCH 6/6] test(posthog): clarify lifecycle and alias contracts --- packages/posthog/src/digest.test.ts | 42 +++++++++++++++++++ .../posthog/src/emit-auxiliary-files.test.ts | 2 + packages/posthog/src/path-mapper.test.ts | 23 ++++++++++ .../posthog/src/webhook-normalizer.test.ts | 9 ++-- 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 packages/posthog/src/digest.test.ts diff --git a/packages/posthog/src/digest.test.ts b/packages/posthog/src/digest.test.ts new file mode 100644 index 00000000..d374e067 --- /dev/null +++ b/packages/posthog/src/digest.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { digest, type DigestContext } from "./digest.js"; + +test("resolved alert updates remain readable and use terminal digest wording", async () => { + const ctx: DigestContext = { + provider: "posthog", + window: { + from: "2026-07-25T00:00:00.000Z", + to: "2026-07-26T00:00:00.000Z", + }, + async changeEvents() { + return [ + { + id: "evt-1", + timestamp: "2026-07-25T08:33:00.000Z", + action: "file.updated", + canonicalPath: + "/posthog/projects/17/alert-events/checkout-errors__42.json", + content: { + id: "42", + title: "Checkout errors", + state: "resolved", + event_type: "posthog.alert.resolved", + }, + }, + ]; + }, + }; + + assert.deepEqual(await digest(ctx), { + provider: "posthog", + bullets: [ + { + text: "alert Checkout errors was resolved", + canonicalPath: + "posthog/projects/17/alert-events/checkout-errors__42.json", + }, + ], + }); +}); diff --git a/packages/posthog/src/emit-auxiliary-files.test.ts b/packages/posthog/src/emit-auxiliary-files.test.ts index b83d2a78..74aa15cd 100644 --- a/packages/posthog/src/emit-auxiliary-files.test.ts +++ b/packages/posthog/src/emit-auxiliary-files.test.ts @@ -70,6 +70,8 @@ test("numeric ids materialize stable indexes and named aliases", async () => { "/posthog/projects/17/dashboards/checkout-funnel__73.json", archived: false, }); + assert.equal(Object.hasOwn(rows[0]!, "state"), false); + assert.equal(Object.hasOwn(rows[0]!, "status"), false); assert.ok( files.has( posthogDashboardByNameAliasPath("17", "Checkout funnel", "73"), diff --git a/packages/posthog/src/path-mapper.test.ts b/packages/posthog/src/path-mapper.test.ts index 85f82510..4e6db1e3 100644 --- a/packages/posthog/src/path-mapper.test.ts +++ b/packages/posthog/src/path-mapper.test.ts @@ -6,6 +6,11 @@ import { parsePostHogPath, posthogDashboardByNameAliasPath, posthogExperimentByNameAliasPath, + posthogFeatureFlagByKeyAliasPath, + posthogGlobalByIdAliasPath, + posthogInsightByShortIdAliasPath, + posthogProjectByIdAliasPath, + posthogProjectLocalByIdAliasPath, posthogProjectByNameAliasPath, posthogSurveyByNameAliasPath, } from "./path-mapper.js"; @@ -78,6 +83,24 @@ test("named aliases use deterministic collision suffixes", () => { } }); +test("provider-unique aliases preserve opaque identifiers without collision suffixes", () => { + const helpers = [ + (value: string) => posthogProjectByIdAliasPath(value), + (value: string) => + posthogGlobalByIdAliasPath("dashboard", "17", value), + (value: string) => + posthogProjectLocalByIdAliasPath("dashboard", "17", value), + (value: string) => posthogInsightByShortIdAliasPath("17", value), + (value: string) => posthogFeatureFlagByKeyAliasPath("17", value), + ]; + + for (const helper of helpers) { + assert.equal(helper("opaque/value"), helper("opaque/value")); + assert.notEqual(helper("opaque/value"), helper("other/value")); + assert.match(helper("opaque/value"), /opaque%2Fvalue\.json$/u); + } +}); + test("project-scoped paths require a project id", () => { assert.throws( () => computePostHogPath("dashboard", "73"), diff --git a/packages/posthog/src/webhook-normalizer.test.ts b/packages/posthog/src/webhook-normalizer.test.ts index 657f34e1..478531ca 100644 --- a/packages/posthog/src/webhook-normalizer.test.ts +++ b/packages/posthog/src/webhook-normalizer.test.ts @@ -45,14 +45,17 @@ test("explicit upstream event type remains authoritative", () => { const normalized = normalizePostHogWebhook({ alert_id: "alert-1", projectId: "project-1", - status: "open", + status: "resolved", event_type: "posthog.alert.triggered", }); assert.ok(normalized); assert.equal(normalized.eventType, "posthog.alert.triggered"); - assert.equal(normalized.state, "triggered"); - assert.equal(normalized.fileEventType, "file.created"); + assert.equal(normalized.state, "resolved"); + assert.equal(normalized.record.state, "resolved"); + assert.equal(normalized.record.event_type, "posthog.alert.triggered"); + assert.equal(normalized.fileEventType, "file.updated"); + assert.equal(normalized.shouldDelete, false); }); test("rejects missing and non-finite identifiers", () => {