diff --git a/CHANGELOG.md b/CHANGELOG.md index 558ae202..b878bff7 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-core/inbound` now publishes a version-hashed, provider-owned Nango/Hookdeck capability catalog, deterministic cross-runtime `logicalEventKey`, and exact golden vectors; incomplete Nango sync-page identities are rejected, and GitLab Hookdeck prefers `x-gitlab-event-uuid` before falling back to the Hookdeck delivery id. - `@relayfile/gmail/identity` now declares `gmail` + `/gmail` as canonical, publishes `google-mail` provider/path migration aliases, and keeps compatibility digests readable across both roots while canonical resyncs drain legacy mounts. - `@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. diff --git a/package-lock.json b/package-lock.json index a28d28bc..4e8983e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -963,18 +963,6 @@ "sisteransi": "^1.0.5" } }, - "node_modules/@clack/prompts/node_modules/is-unicode-supported": { - "version": "1.3.0", - "extraneous": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@electric-sql/pglite": { "version": "0.4.5", "dev": true, @@ -1184,6 +1172,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 +4086,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4270,7 +4262,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4440,7 +4432,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4456,7 +4448,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4472,7 +4464,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4648,7 +4640,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4818,7 +4810,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4837,7 +4829,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5019,7 +5011,7 @@ }, "packages/core": { "name": "@relayfile/adapter-core", - "version": "0.4.0", + "version": "0.5.12", "license": "Apache-2.0", "dependencies": { "@scalar/postman-to-openapi": "^0.6.0", @@ -5049,7 +5041,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5068,7 +5060,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5283,7 +5275,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5299,7 +5291,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5318,7 +5310,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5337,7 +5329,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5350,10 +5342,10 @@ }, "packages/github": { "name": "@relayfile/adapter-github", - "version": "0.4.0", + "version": "0.5.4", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", @@ -5370,10 +5362,10 @@ }, "packages/gitlab": { "name": "@relayfile/adapter-gitlab", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5508,10 +5500,10 @@ }, "packages/gmail": { "name": "@relayfile/gmail", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5527,7 +5519,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5704,7 +5696,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5720,7 +5712,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5932,10 +5924,10 @@ }, "packages/hubspot": { "name": "@relayfile/adapter-hubspot", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6105,7 +6097,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6275,7 +6267,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6442,10 +6434,10 @@ }, "packages/linear": { "name": "@relayfile/adapter-linear", - "version": "0.4.0", + "version": "0.4.9", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6584,7 +6576,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6754,7 +6746,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6930,7 +6922,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -6946,10 +6938,10 @@ }, "packages/notion": { "name": "@relayfile/adapter-notion", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7087,7 +7079,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7103,7 +7095,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7273,7 +7265,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@electric-sql/pglite": "^0.4.5", @@ -7285,12 +7277,32 @@ "node": ">=18" } }, + "packages/posthog": { + "name": "@relayfile/adapter-posthog", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@relayfile/adapter-core": "^0.5.12" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "yaml": "^2.8.1" + }, + "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.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7502,10 +7514,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.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7720,7 +7732,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7733,12 +7745,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.12", + "@relayfile/adapter-linear": "^0.4.9", + "@relayfile/adapter-reddit": "^0.2.7" } }, "packages/s3": { @@ -7746,7 +7758,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7762,7 +7774,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7932,7 +7944,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8108,7 +8120,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8278,7 +8290,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@types/node": "^24.6.0", @@ -8294,7 +8306,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8461,10 +8473,10 @@ }, "packages/slack": { "name": "@relayfile/adapter-slack", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8603,7 +8615,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8773,7 +8785,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8921,10 +8933,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.12" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", @@ -8965,7 +8977,7 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -9109,7 +9121,7 @@ "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.4.0" + "@relayfile/adapter-core": "^0.5.12" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", 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 2bfc792a..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" 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/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.ts b/packages/core/src/writeback-paths/catalog.generated.ts index 63b3fe8f..5a1398d5 100644 --- a/packages/core/src/writeback-paths/catalog.generated.ts +++ b/packages/core/src/writeback-paths/catalog.generated.ts @@ -1032,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 new file mode 100644 index 00000000..81b7d554 --- /dev/null +++ b/packages/posthog/package.json @@ -0,0 +1,85 @@ +{ + "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", + "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" + }, + "dependencies": { + "@relayfile/adapter-core": "^0.5.12" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "yaml": "^2.8.1" + }, + "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..9a7f3839 --- /dev/null +++ b/packages/posthog/posthog.mapping.yaml @@ -0,0 +1,50 @@ +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/. + 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/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/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/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.test.ts b/packages/posthog/src/emit-auxiliary-files.test.ts new file mode 100644 index 00000000..74aa15cd --- /dev/null +++ b/packages/posthog/src/emit-auxiliary-files.test.ts @@ -0,0 +1,202 @@ +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.equal(Object.hasOwn(rows[0]!, "state"), false); + assert.equal(Object.hasOwn(rows[0]!, "status"), 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 new file mode 100644 index 00000000..78261a63 --- /dev/null +++ b/packages/posthog/src/emit-auxiliary-files.ts @@ -0,0 +1,724 @@ +import { + EMIT_AUXILIARY_JSON_CONTENT_TYPE, + type AuxiliaryEmitterClient, + type EmitAuxiliaryFilesResult, +} from "@relayfile/adapter-core"; + +import { + composeProjectScopedId, + computePostHogPath, + posthogAggregateCollection, + posthogAggregateIndexPath, + posthogDashboardByNameAliasPath, + posthogExperimentByNameAliasPath, + posthogFeatureFlagByKeyAliasPath, + posthogGlobalByIdAliasPath, + posthogInsightByShortIdAliasPath, + posthogProjectByIdAliasPath, + posthogProjectByNameAliasPath, + 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 | number; + project_id?: string | number; + projectId?: string | number; + alert_id?: string | number; + 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; + +type IndexSnapshot = { + rows: IndexRow[]; + available: boolean; +}; + +type JsonReadResult = + | { available: true; value: unknown } + | { available: false; value: null }; + +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 existing = await readIndex(client, workspaceId, indexPath, aggregate); + const rows = new Map(existing.rows.map((row) => [row.id, row])); + + await runWithConcurrencyLimit(records, EMIT_CONCURRENCY, async (record) => { + const projectId = readProjectId(record, "project"); + if (!projectId) { + return; + } + + 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, + ); + } + return; + } + + const previousRow = rows.get(projectId); + rows.set(projectId, { + id: projectId, + title: currentName, + updated: readUpdated(record, previousRow?.updated), + canonicalPath, + organization_id: readIdentifier(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, + ); + } + }); + + if (existing.available) { + 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 aggregateExisting = await readIndex( + client, + workspaceId, + aggregateIndexPath, + aggregate, + ); + const aggregateRows = new Map( + aggregateExisting.rows.map((row) => [row.id, row]), + ); + + const grouped = groupByProject(records, objectType); + for (const [projectId, projectRecords] of grouped.entries()) { + const projectIndexPath = posthogProjectLocalIndexPath(objectType, projectId); + const projectExisting = await readIndex( + client, + workspaceId, + projectIndexPath, + aggregate, + ); + const projectRows = new Map( + projectExisting.rows.map((row) => [row.id, row]), + ); + + await runWithConcurrencyLimit( + projectRecords, + EMIT_CONCURRENCY, + async (record) => { + const objectId = readObjectId(record, objectType); + if (!objectId) { + return; + } + + const globalAliasPath = posthogGlobalByIdAliasPath( + objectType, + projectId, + objectId, + ); + const localAliasPath = posthogProjectLocalByIdAliasPath( + objectType, + projectId, + objectId, + ); + const previousRecord = await readAliasRecord( + client, + workspaceId, + globalAliasPath, + aggregate, + ); + const title = posthogRecordDisplayName( + objectType, + record, + objectId, + ); + const canonicalPath = computePostHogPath(objectType, objectId, { + projectId, + displayName: title, + }); + const previousCanonicalPath = previousRecord + ? computePostHogPath(objectType, objectId, { + projectId, + displayName: posthogRecordDisplayName( + objectType, + previousRecord, + objectId, + ), + }) + : null; + 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); + } + 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, previousAggregateRow?.updated), + 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, previousProjectRow?.updated), + 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, + ); + } + 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, + aggregateIndexPath, + aggregateRows, + aggregate, + ); + } +} + +function groupByProject( + records: readonly PostHogRecord[], + objectType: ProjectScopedObjectType, +): Map { + const grouped = new Map(); + for (const record of records) { + const projectId = readProjectId(record, objectType); + 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 readIdentifier(record.project_id) ?? readIdentifier(record.id); + } + return readIdentifier(record.project_id) ?? readIdentifier(record.projectId); +} + +function readObjectId( + record: PostHogRecord, + objectType: ProjectScopedObjectType, +): string | undefined { + if (objectType === "alert-event") { + return readIdentifier(record.id) ?? readIdentifier(record.alert_id); + } + return readIdentifier(record.id); +} + +function readProjectTitle(record: PostHogRecord, projectId: string): string { + return ( + readString(record.name) ?? + readString(record.project_name) ?? + readString(record.slug) ?? + projectId + ); +} + +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)] : []; + } + 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, + previousUpdated: string | undefined, +): 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) ?? + previousUpdated ?? + "" + ); +} + +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 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( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + aggregate: EmitAuxiliaryFilesResult, +): Promise { + const result = await safeReadJson(client, workspaceId, path, aggregate); + if (!result.available || !isRecord(result.value)) { + return null; + } + const payload = isRecord(result.value.payload) + ? result.value.payload + : result.value; + return payload as PostHogRecord; +} + +async function safeReadJson( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + aggregate: EmitAuxiliaryFilesResult, +): Promise { + if (!client.readFile) { + return { available: false, value: 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 { available: true, value: null }; + } + return { available: true, value: JSON.parse(content) as unknown }; + } catch (error) { + const message = String(error); + if ( + message.includes("not found") || + message.includes("ENOENT") || + message.includes("404") + ) { + return { available: true, value: null }; + } + aggregate.errors.push({ path, error: message }); + return { available: false, value: 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 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/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.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 new file mode 100644 index 00000000..04fb6ed7 --- /dev/null +++ b/packages/posthog/src/layout-prompt.ts @@ -0,0 +1,46 @@ +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 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/{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(): { + 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.test.ts b/packages/posthog/src/path-mapper.test.ts new file mode 100644 index 00000000..4e6db1e3 --- /dev/null +++ b/packages/posthog/src/path-mapper.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computePostHogPath, + parsePostHogPath, + posthogDashboardByNameAliasPath, + posthogExperimentByNameAliasPath, + posthogFeatureFlagByKeyAliasPath, + posthogGlobalByIdAliasPath, + posthogInsightByShortIdAliasPath, + posthogProjectByIdAliasPath, + posthogProjectLocalByIdAliasPath, + 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("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"), + /requires projectId/u, + ); +}); diff --git a/packages/posthog/src/path-mapper.ts b/packages/posthog/src/path-mapper.ts new file mode 100644 index 00000000..e2b7cb66 --- /dev/null +++ b/packages/posthog/src/path-mapper.ts @@ -0,0 +1,416 @@ +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 { + return posthogNamedAliasPath( + `${POSTHOG_PATH_ROOT}/projects`, + name, + projectId, + ); +} + +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 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, +): 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; displayName?: string } = {}, +): string { + if (objectType === "project") { + return posthogProjectPath(objectId); + } + + if (!context.projectId) { + throw new Error(`PostHog ${objectType} path requires projectId`); + } + 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, + objectId: extractPostHogIdFromCanonicalSegment(scopedMatch[3]), + projectId: decodeURIComponent(scopedMatch[1]), + }; +} + +export function composeProjectScopedId( + projectId: string, + objectId: string, +): string { + return `${assertNonEmpty(projectId, "project id")}__${assertNonEmpty( + objectId, + "object id", + )}`; +} + +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 { + 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; + } +} + +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 new file mode 100644 index 00000000..97fd2f05 --- /dev/null +++ b/packages/posthog/src/resources.ts @@ -0,0 +1,20 @@ +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 = [] as const satisfies readonly AdapterResourceConfig[]; + +export function findResourceByPath( + path: string, +): AdapterResourceConfig | undefined { + const normalizedPath = path.endsWith(".json") + ? path + : path.replace(/\/$/u, ""); + return readOnlyResources.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.test.ts b/packages/posthog/src/webhook-normalizer.test.ts new file mode 100644 index 00000000..478531ca --- /dev/null +++ b/packages/posthog/src/webhook-normalizer.test.ts @@ -0,0 +1,74 @@ +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: "resolved", + event_type: "posthog.alert.triggered", + }); + + assert.ok(normalized); + assert.equal(normalized.eventType, "posthog.alert.triggered"); + 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", () => { + 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 new file mode 100644 index 00000000..663c0d74 --- /dev/null +++ b/packages/posthog/src/webhook-normalizer.ts @@ -0,0 +1,126 @@ +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 = + 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); + if (!projectId || !objectId) { + return null; + } + + const state = normalizeState( + 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) ?? + new Date().toISOString(); + const title = + readString(payload.title) ?? + readString(payload.name) ?? + `PostHog alert ${objectId}`; + const severity = readString(payload.severity) ?? undefined; + + const record: Record = { + ...payload, + id: objectId, + project_id: projectId, + source: "posthog", + kind: "advisory", + title, + occurred_at: timestamp, + event_type: eventType, + ...(state ? { state } : {}), + ...(severity ? { severity } : {}), + }; + + 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, + displayName: title, + }), + ...(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; +} + +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/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" +} diff --git a/turbo.json b/turbo.json index 6233c12c..b490df2e 100644 --- a/turbo.json +++ b/turbo.json @@ -35,6 +35,13 @@ ], "outputs": [] }, + "@relayfile/adapter-core#test": { + "dependsOn": [ + "build", + "@relayfile/adapter-core#catalog:check" + ], + "outputs": [] + }, "typecheck": { "dependsOn": [ "^build" @@ -67,6 +74,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",