diff --git a/docs/writeback-spec-coverage.md b/docs/writeback-spec-coverage.md index a9255097..5047b449 100644 --- a/docs/writeback-spec-coverage.md +++ b/docs/writeback-spec-coverage.md @@ -24,7 +24,7 @@ Contract-backed means the endpoint uses `contractEndpoint(...)`, loads its reque | intercom | None | 0 | 3 | Inline JS schemas. | | jira | None | 0 | 4 | Inline JS schemas. | | linear | None | 0 | 2 | Inline JS schemas; provider source is GraphQL, not OpenAPI. | -| notion | None | 0 | 1 | Inline JS schemas. | +| notion | None | 0 | 9 | Inline JS schemas cover database page creates, page property updates, content replacement, and comments. | | onedrive | None | 0 | 2 | Inline JS schemas. | | pipedrive | None | 0 | 4 | Inline JS schemas. | | postgres | None | 0 | 2 | Inline JS schemas; database/table shape is runtime-native rather than provider OpenAPI. | diff --git a/packages/core/src/runtime/file-native-router.ts b/packages/core/src/runtime/file-native-router.ts index 78111e8e..99a1aa6c 100644 --- a/packages/core/src/runtime/file-native-router.ts +++ b/packages/core/src/runtime/file-native-router.ts @@ -152,34 +152,37 @@ export function classifyWrite( ): FileNativeWritebackRoute | null { const event = opts.fsEvent ?? "write"; const normalizedPath = normalizeWritebackPath(path); - const resource = findMatchingResource(normalizedPath, resources); - if (!resource) { - return null; - } + for (const resource of matchingResources(normalizedPath, resources)) { + const id = readWritebackId(normalizedPath, resource); + if (!id || isReservedWritebackFilename(id)) { + continue; + } - const id = readWritebackId(normalizedPath); - if (!id || isReservedWritebackFilename(id)) { - return null; - } + const canonical = testResourceId(resource.idPattern, id); + if (event === "delete") { + if (!canonical) { + continue; + } + return { + kind: "delete", + resource, + id, + canonical, + }; + } - const canonical = testResourceId(resource.idPattern, id); - if (event === "delete") { - return canonical - ? { - kind: "delete", - resource, - id, - canonical, - } - : null; - } + if (!canonical && resourceUsesExactFile(resource)) { + continue; + } - return { - kind: canonical ? "patch" : "create", - resource, - id, - canonical, - }; + return { + kind: canonical ? "patch" : "create", + resource, + id, + canonical, + }; + } + return null; } export function validatePayload( @@ -265,7 +268,7 @@ export async function executeFileNativeWriteback( request = await options.resolveDeleteRequest(options.path); } else { const content = options.content ?? ""; - const payload = parseWritebackJsonObject(content); + const payload = parseWritebackPayload(content, route.resource); const schema = await loadWritebackSchema(route.resource, options); const validation = validatePayload(payload, schema, route.kind); if (!validation.ok) { @@ -386,6 +389,27 @@ function validateFieldValue( } } +function parseWritebackPayload( + content: string, + resource: AdapterResourceConfig +): Record { + if (resource.path.endsWith(".md")) { + const parsed = safeParseWritebackJsonObject(content); + return parsed ?? { markdown: content }; + } + return parseWritebackJsonObject(content); +} + +function safeParseWritebackJsonObject(content: string): Record | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return undefined; + } + return isRecord(parsed) ? parsed : undefined; +} + function parseWritebackJsonObject(content: string): Record { let parsed: unknown; try { @@ -591,9 +615,16 @@ function findMatchingResource( path: string, resources: readonly AdapterResourceConfig[] ): AdapterResourceConfig | undefined { + return matchingResources(path, resources)[0]; +} + +function matchingResources( + path: string, + resources: readonly AdapterResourceConfig[] +): AdapterResourceConfig[] { return [...resources] .sort((left, right) => right.path.length - left.path.length) - .find((resource) => { + .filter((resource) => { resource.pathPattern.lastIndex = 0; const matched = resource.pathPattern.test(path); resource.pathPattern.lastIndex = 0; @@ -606,7 +637,16 @@ function normalizeWritebackPath(path: string): string { return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; } -function readWritebackId(path: string): string | undefined { +function readWritebackId( + path: string, + resource?: AdapterResourceConfig +): string | undefined { + if (resource && resourceUsesExactFile(resource)) { + const id = readExactFileResourceId(path, resource); + if (id) { + return id; + } + } const segment = path.split("/").filter(Boolean).at(-1); if (!segment || !segment.endsWith(".json")) { return undefined; @@ -618,6 +658,38 @@ function readWritebackId(path: string): string | undefined { return decodeURIComponent(stem); } +function resourceUsesExactFile(resource: AdapterResourceConfig): boolean { + return /\.(?:json|md)$/u.test(resource.path); +} + +function readExactFileResourceId( + path: string, + resource: AdapterResourceConfig +): string | undefined { + const pathSegments = path.split("/").filter(Boolean); + const resourceSegments = resource.path.split("/").filter(Boolean); + if (pathSegments.length !== resourceSegments.length) { + return undefined; + } + + for (let index = resourceSegments.length - 1; index >= 0; index -= 1) { + const resourceSegment = resourceSegments[index]; + const pathSegment = pathSegments[index]; + const placeholder = /\{[^}]+\}/u.exec(resourceSegment); + if (!placeholder || !pathSegment) { + continue; + } + if (resourceSegment.endsWith(".json") && pathSegment.endsWith(".json")) { + return decodeURIComponent(pathSegment.slice(0, -5)); + } + if (resourceSegment.endsWith(".md") && pathSegment.endsWith(".md")) { + return decodeURIComponent(pathSegment.slice(0, -3)); + } + return decodeURIComponent(pathSegment); + } + return undefined; +} + function isReservedWritebackFilename(stem: string): boolean { return ( stem === ".schema" || diff --git a/packages/github/src/__tests__/emit-auxiliary-files.test.ts b/packages/github/src/__tests__/emit-auxiliary-files.test.ts index 73cac9c6..b3ae3398 100644 --- a/packages/github/src/__tests__/emit-auxiliary-files.test.ts +++ b/packages/github/src/__tests__/emit-auxiliary-files.test.ts @@ -351,10 +351,43 @@ describe('emitGitHubAuxiliaryFiles', () => { assert.ok(writtenPaths.includes(githubByEditedAliasPath('acme', 'widgets', 'issues', '2026-05-12', 7))); assert.ok(writtenPaths.includes(indexPath)); + const canonicalBytes = client.files.get(githubIssuePath('acme', 'widgets', 7, 'Bug report')); + assert.equal(client.files.get(githubByIdAliasPath('acme', 'widgets', 'issues', 7)), canonicalBytes); + assert.equal( + client.files.get(githubNumberedByTitleAliasPath('acme', 'widgets', 'issues', 'Bug report', 7)), + canonicalBytes, + ); + assert.equal(client.files.get(githubByStateAliasPath('acme', 'widgets', 'issues', 'open', 7)), canonicalBytes); + assert.equal(client.files.get(githubByEditedAliasPath('acme', 'widgets', 'issues', '2026-05-12', 7)), canonicalBytes); + // No writes leaked into the pulls index for this repo. assert.ok(!writtenPaths.includes(githubRepoPullsIndexPath('acme', 'widgets'))); }); + it('uses the newest lifecycle timestamp for PR by-edited aliases', async () => { + const client = createClient(); + + await emitGitHubAuxiliaryFiles(client, { + workspaceId: 'ws-1', + pullRequests: [ + { + owner: 'acme', + repo: 'widgets', + number: 42, + title: 'Follow-up after merge', + state: 'closed', + merged_at: '2026-05-12T00:00:00Z', + closed_at: '2026-05-12T00:00:00Z', + updated_at: '2026-05-13T00:00:00Z', + }, + ], + }); + + const writtenPaths = client.writes.map((w) => w.path); + assert.ok(writtenPaths.includes(githubByEditedAliasPath('acme', 'widgets', 'pulls', '2026-05-13', 42))); + assert.ok(!writtenPaths.includes(githubByEditedAliasPath('acme', 'widgets', 'pulls', '2026-05-12', 42))); + }); + it('writes distinct by-title aliases for duplicate issue titles and keeps cleanup scoped by number', async () => { const client = createClient(); await emitGitHubAuxiliaryFiles(client, { diff --git a/packages/github/src/emit-auxiliary-files.ts b/packages/github/src/emit-auxiliary-files.ts index 8cf13027..969cb9ae 100644 --- a/packages/github/src/emit-auxiliary-files.ts +++ b/packages/github/src/emit-auxiliary-files.ts @@ -1090,12 +1090,18 @@ function readUpdatedAt(record: Record): string { } function readLifecycleEditedAt(record: Record): string | undefined { - return ( - readNonEmptyString(record.merged_at) ?? - readNonEmptyString(record.closed_at) ?? - readNonEmptyString(record.updated_at) ?? - readNonEmptyString(record.updatedAt) - ); + const candidates = [ + readNonEmptyString(record.merged_at), + readNonEmptyString(record.closed_at), + readNonEmptyString(record.updated_at), + readNonEmptyString(record.updatedAt), + ] + .filter((value): value is string => Boolean(value)) + .map((value) => ({ value, timestamp: Date.parse(value) })) + .filter((entry) => Number.isFinite(entry.timestamp)) + .sort((left, right) => right.timestamp - left.timestamp); + + return candidates[0]?.value; } function editedDateSegment(value: string | undefined): string | undefined { diff --git a/packages/gitlab/discovery/gitlab/.adapter.md b/packages/gitlab/discovery/gitlab/.adapter.md index 916a3f80..a2712101 100644 --- a/packages/gitlab/discovery/gitlab/.adapter.md +++ b/packages/gitlab/discovery/gitlab/.adapter.md @@ -39,11 +39,12 @@ Resource: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slu Schema: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.schema.json` Create example: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.create.example.json` Required fields: `body`. -Optional fields: `created_at`. +Optional fields: `position`, `created_at`. Fields: - `body` (required, string) - Markdown note body. +- `position` (optional, object) - Optional GitLab position object for diff discussions. - `created_at` (optional, string, date-time) - Optional timestamp for imports when supported by GitLab. ### Create GitLab issue note @@ -52,12 +53,11 @@ Resource: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments//metadata.json` - Database metadata. @@ -13,6 +13,14 @@ Resources: | Resource | Schema | Create example | ID pattern | What it does | |---|---|---|---|---| | `/notion/databases/{databaseId}/pages/.json` | `/notion/databases/{databaseId}/pages/.schema.json` | `/notion/databases/{databaseId}/pages/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates a page inside a Notion database. | +| `/notion/databases/{databaseId}/pages/{pageId}.json` | `/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Updates properties, archive state, icon, or cover for a page inside a Notion database. | +| `/notion/databases/{databaseId}/pages/{pageId}/properties.json` | `/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/properties.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Updates properties, archive state, icon, or cover through a database page properties sidecar. | +| `/notion/databases/{databaseId}/pages/{pageId}/content.md` | `/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/content.md/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Replaces the rendered markdown body for a page inside a Notion database. | +| `/notion/databases/{databaseId}/pages/{pageId}/comments.json` | `/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json` | `/notion/databases/{databaseId}/pages/{pageId}/comments.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates a Notion comment on a page inside a Notion database from comments.json. | +| `/notion/pages/{pageId}.json` | `/notion/pages/{pageId}.json/.schema.json` | `/notion/pages/{pageId}.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Updates properties, archive state, icon, or cover for a standalone page. | +| `/notion/pages/{pageId}/properties.json` | `/notion/pages/{pageId}/properties.json/.schema.json` | `/notion/pages/{pageId}/properties.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Updates properties, archive state, icon, or cover through a standalone page properties sidecar. | +| `/notion/pages/{pageId}/content.md` | `/notion/pages/{pageId}/content.md/.schema.json` | `/notion/pages/{pageId}/content.md/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Replaces the rendered markdown body for a standalone page. | +| `/notion/pages/{pageId}/comments.json` | `/notion/pages/{pageId}/comments.json/.schema.json` | `/notion/pages/{pageId}/comments.json/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates a Notion comment on a standalone page from comments.json. | ## Operations @@ -26,6 +34,14 @@ Resources: ## ID Patterns - `/notion/databases/{databaseId}/pages/.json`: `^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$`. Filenames that do not match this pattern are treated as create drafts. +- `/notion/databases/{databaseId}/pages/{pageId}.json`: exact file path. +- `/notion/databases/{databaseId}/pages/{pageId}/properties.json`: exact file path. +- `/notion/databases/{databaseId}/pages/{pageId}/content.md`: exact file path. +- `/notion/databases/{databaseId}/pages/{pageId}/comments.json`: exact file path. +- `/notion/pages/{pageId}.json`: exact file path. +- `/notion/pages/{pageId}/properties.json`: exact file path. +- `/notion/pages/{pageId}/content.md`: exact file path. +- `/notion/pages/{pageId}/comments.json`: exact file path. ## Write field contracts @@ -43,5 +59,123 @@ Fields: - `children` (optional, array) - Optional child blocks for the new page. - `markdown` (optional, string) - Optional markdown body. When present the adapter uses the Notion markdown API version. +### Update Notion database page properties + +Resource: `/notion/databases/{databaseId}/pages/{pageId}.json` +Schema: `/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json` +Create example: `/notion/databases/{databaseId}/pages/{pageId}.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `properties`, `archived`, `icon`, `cover`. +Validation: provide at least one of `properties`, `archived`, `icon`, `cover`. + +Fields: + +- `properties` (optional, object) - Serialized Notion property map. Each property value should match the adapter property serializer shape. +- `archived` (optional, boolean) - Whether to archive or restore the page. +- `icon` (optional, object) - Notion page icon object. +- `cover` (optional, object) - Notion page cover object. + +### Update Notion database page properties file + +Resource: `/notion/databases/{databaseId}/pages/{pageId}/properties.json` +Schema: `/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json` +Create example: `/notion/databases/{databaseId}/pages/{pageId}/properties.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `properties`, `archived`, `icon`, `cover`. +Validation: provide at least one of `properties`, `archived`, `icon`, `cover`. + +Fields: + +- `properties` (optional, object) - Serialized Notion property map. Each property value should match the adapter property serializer shape. +- `archived` (optional, boolean) - Whether to archive or restore the page. +- `icon` (optional, object) - Notion page icon object. +- `cover` (optional, object) - Notion page cover object. + +### Replace Notion database page markdown + +Resource: `/notion/databases/{databaseId}/pages/{pageId}/content.md` +Schema: `/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json` +Create example: `/notion/databases/{databaseId}/pages/{pageId}/content.md/.create.example.json` +Required fields: none at the top level. +Optional fields: `markdown`. + +Fields: + +- `markdown` (optional, string) - Plain markdown body written to content.md. + +### Create Notion database page comment + +Resource: `/notion/databases/{databaseId}/pages/{pageId}/comments.json` +Schema: `/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json` +Create example: `/notion/databases/{databaseId}/pages/{pageId}/comments.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `text`, `discussionId`, `richText`. +Validation: provide at least one of `text`, `richText`. + +Fields: + +- `text` (optional, string) - Plain text comment body. A raw string body is also accepted by the resolver. +- `discussionId` (optional, string) - Optional Notion discussion id to append to. +- `richText` (optional, array) - Optional rich_text array. + +### Update Notion standalone page properties + +Resource: `/notion/pages/{pageId}.json` +Schema: `/notion/pages/{pageId}.json/.schema.json` +Create example: `/notion/pages/{pageId}.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `properties`, `archived`, `icon`, `cover`. +Validation: provide at least one of `properties`, `archived`, `icon`, `cover`. + +Fields: + +- `properties` (optional, object) - Serialized Notion property map. Each property value should match the adapter property serializer shape. +- `archived` (optional, boolean) - Whether to archive or restore the page. +- `icon` (optional, object) - Notion page icon object. +- `cover` (optional, object) - Notion page cover object. + +### Update Notion standalone page properties file + +Resource: `/notion/pages/{pageId}/properties.json` +Schema: `/notion/pages/{pageId}/properties.json/.schema.json` +Create example: `/notion/pages/{pageId}/properties.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `properties`, `archived`, `icon`, `cover`. +Validation: provide at least one of `properties`, `archived`, `icon`, `cover`. + +Fields: + +- `properties` (optional, object) - Serialized Notion property map. Each property value should match the adapter property serializer shape. +- `archived` (optional, boolean) - Whether to archive or restore the page. +- `icon` (optional, object) - Notion page icon object. +- `cover` (optional, object) - Notion page cover object. + +### Replace Notion standalone page markdown + +Resource: `/notion/pages/{pageId}/content.md` +Schema: `/notion/pages/{pageId}/content.md/.schema.json` +Create example: `/notion/pages/{pageId}/content.md/.create.example.json` +Required fields: none at the top level. +Optional fields: `markdown`. + +Fields: + +- `markdown` (optional, string) - Plain markdown body written to content.md. + +### Create Notion standalone page comment + +Resource: `/notion/pages/{pageId}/comments.json` +Schema: `/notion/pages/{pageId}/comments.json/.schema.json` +Create example: `/notion/pages/{pageId}/comments.json/.create.example.json` +Required fields: none at the top level. +Optional fields: `text`, `discussionId`, `richText`. +Validation: provide at least one of `text`, `richText`. + +Fields: + +- `text` (optional, string) - Plain text comment body. A raw string body is also accepted by the resolver. +- `discussionId` (optional, string) - Optional Notion discussion id to append to. +- `richText` (optional, array) - Optional rich_text array. + ## Create Examples Read the resource `.schema.json` first, then use the sibling `.create.example.json` as a minimal create document. The example intentionally omits read-only fields. diff --git a/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}.json/.create.example.json b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}.json/.create.example.json new file mode 100644 index 00000000..f9947257 --- /dev/null +++ b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}.json/.create.example.json @@ -0,0 +1,8 @@ +{ + "properties": { + "Status": { + "type": "select", + "value": "In progress" + } + } +} diff --git a/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json new file mode 100644 index 00000000..03df78c8 --- /dev/null +++ b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Update Notion database page properties", + "type": "object", + "required": [], + "anyOf": [ + { + "required": [ + "properties" + ] + }, + { + "required": [ + "archived" + ] + }, + { + "required": [ + "icon" + ] + }, + { + "required": [ + "cover" + ] + } + ], + "properties": { + "properties": { + "type": "object", + "description": "Serialized Notion property map. Each property value should match the adapter property serializer shape.", + "additionalProperties": true + }, + "archived": { + "type": "boolean", + "description": "Whether to archive or restore the page." + }, + "icon": { + "type": "object", + "description": "Notion page icon object.", + "additionalProperties": true + }, + "cover": { + "type": "object", + "description": "Notion page cover object.", + "additionalProperties": true + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.create.example.json b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.create.example.json new file mode 100644 index 00000000..9277c0d1 --- /dev/null +++ b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.create.example.json @@ -0,0 +1,3 @@ +{ + "text": "Replace example comment body." +} diff --git a/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json new file mode 100644 index 00000000..0cc72ea8 --- /dev/null +++ b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Notion database page comment", + "type": "object", + "required": [], + "anyOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "richText" + ] + } + ], + "properties": { + "text": { + "type": "string", + "description": "Plain text comment body. A raw string body is also accepted by the resolver.", + "minLength": 1, + "pattern": ".*\\S.*" + }, + "discussionId": { + "type": "string", + "description": "Optional Notion discussion id to append to." + }, + "richText": { + "type": "array", + "description": "Optional rich_text array.", + "items": { + "type": "object", + "description": "Notion rich_text object.", + "additionalProperties": true + }, + "minItems": 1 + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.create.example.json b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.create.example.json new file mode 100644 index 00000000..bc598292 --- /dev/null +++ b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.create.example.json @@ -0,0 +1,3 @@ +{ + "markdown": "# Replace page content" +} diff --git a/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json new file mode 100644 index 00000000..bfc5e051 --- /dev/null +++ b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Replace Notion database page markdown", + "type": "object", + "required": [], + "properties": { + "markdown": { + "type": "string", + "description": "Plain markdown body written to content.md." + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.create.example.json b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.create.example.json new file mode 100644 index 00000000..f9947257 --- /dev/null +++ b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.create.example.json @@ -0,0 +1,8 @@ +{ + "properties": { + "Status": { + "type": "select", + "value": "In progress" + } + } +} diff --git a/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json new file mode 100644 index 00000000..637696b3 --- /dev/null +++ b/packages/notion/discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Update Notion database page properties file", + "type": "object", + "required": [], + "anyOf": [ + { + "required": [ + "properties" + ] + }, + { + "required": [ + "archived" + ] + }, + { + "required": [ + "icon" + ] + }, + { + "required": [ + "cover" + ] + } + ], + "properties": { + "properties": { + "type": "object", + "description": "Serialized Notion property map. Each property value should match the adapter property serializer shape.", + "additionalProperties": true + }, + "archived": { + "type": "boolean", + "description": "Whether to archive or restore the page." + }, + "icon": { + "type": "object", + "description": "Notion page icon object.", + "additionalProperties": true + }, + "cover": { + "type": "object", + "description": "Notion page cover object.", + "additionalProperties": true + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/notion/discovery/notion/pages/{pageId}.json/.create.example.json b/packages/notion/discovery/notion/pages/{pageId}.json/.create.example.json new file mode 100644 index 00000000..f9947257 --- /dev/null +++ b/packages/notion/discovery/notion/pages/{pageId}.json/.create.example.json @@ -0,0 +1,8 @@ +{ + "properties": { + "Status": { + "type": "select", + "value": "In progress" + } + } +} diff --git a/packages/notion/discovery/notion/pages/{pageId}.json/.schema.json b/packages/notion/discovery/notion/pages/{pageId}.json/.schema.json new file mode 100644 index 00000000..bd8dbf1a --- /dev/null +++ b/packages/notion/discovery/notion/pages/{pageId}.json/.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Update Notion standalone page properties", + "type": "object", + "required": [], + "anyOf": [ + { + "required": [ + "properties" + ] + }, + { + "required": [ + "archived" + ] + }, + { + "required": [ + "icon" + ] + }, + { + "required": [ + "cover" + ] + } + ], + "properties": { + "properties": { + "type": "object", + "description": "Serialized Notion property map. Each property value should match the adapter property serializer shape.", + "additionalProperties": true + }, + "archived": { + "type": "boolean", + "description": "Whether to archive or restore the page." + }, + "icon": { + "type": "object", + "description": "Notion page icon object.", + "additionalProperties": true + }, + "cover": { + "type": "object", + "description": "Notion page cover object.", + "additionalProperties": true + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/notion/discovery/notion/pages/{pageId}/comments.json/.create.example.json b/packages/notion/discovery/notion/pages/{pageId}/comments.json/.create.example.json new file mode 100644 index 00000000..9277c0d1 --- /dev/null +++ b/packages/notion/discovery/notion/pages/{pageId}/comments.json/.create.example.json @@ -0,0 +1,3 @@ +{ + "text": "Replace example comment body." +} diff --git a/packages/notion/discovery/notion/pages/{pageId}/comments.json/.schema.json b/packages/notion/discovery/notion/pages/{pageId}/comments.json/.schema.json new file mode 100644 index 00000000..09dfdb02 --- /dev/null +++ b/packages/notion/discovery/notion/pages/{pageId}/comments.json/.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Notion standalone page comment", + "type": "object", + "required": [], + "anyOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "richText" + ] + } + ], + "properties": { + "text": { + "type": "string", + "description": "Plain text comment body. A raw string body is also accepted by the resolver.", + "minLength": 1, + "pattern": ".*\\S.*" + }, + "discussionId": { + "type": "string", + "description": "Optional Notion discussion id to append to." + }, + "richText": { + "type": "array", + "description": "Optional rich_text array.", + "items": { + "type": "object", + "description": "Notion rich_text object.", + "additionalProperties": true + }, + "minItems": 1 + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/notion/discovery/notion/pages/{pageId}/content.md/.create.example.json b/packages/notion/discovery/notion/pages/{pageId}/content.md/.create.example.json new file mode 100644 index 00000000..bc598292 --- /dev/null +++ b/packages/notion/discovery/notion/pages/{pageId}/content.md/.create.example.json @@ -0,0 +1,3 @@ +{ + "markdown": "# Replace page content" +} diff --git a/packages/notion/discovery/notion/pages/{pageId}/content.md/.schema.json b/packages/notion/discovery/notion/pages/{pageId}/content.md/.schema.json new file mode 100644 index 00000000..35f05046 --- /dev/null +++ b/packages/notion/discovery/notion/pages/{pageId}/content.md/.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Replace Notion standalone page markdown", + "type": "object", + "required": [], + "properties": { + "markdown": { + "type": "string", + "description": "Plain markdown body written to content.md." + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/notion/discovery/notion/pages/{pageId}/properties.json/.create.example.json b/packages/notion/discovery/notion/pages/{pageId}/properties.json/.create.example.json new file mode 100644 index 00000000..f9947257 --- /dev/null +++ b/packages/notion/discovery/notion/pages/{pageId}/properties.json/.create.example.json @@ -0,0 +1,8 @@ +{ + "properties": { + "Status": { + "type": "select", + "value": "In progress" + } + } +} diff --git a/packages/notion/discovery/notion/pages/{pageId}/properties.json/.schema.json b/packages/notion/discovery/notion/pages/{pageId}/properties.json/.schema.json new file mode 100644 index 00000000..f21c2a8e --- /dev/null +++ b/packages/notion/discovery/notion/pages/{pageId}/properties.json/.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Update Notion standalone page properties file", + "type": "object", + "required": [], + "anyOf": [ + { + "required": [ + "properties" + ] + }, + { + "required": [ + "archived" + ] + }, + { + "required": [ + "icon" + ] + }, + { + "required": [ + "cover" + ] + } + ], + "properties": { + "properties": { + "type": "object", + "description": "Serialized Notion property map. Each property value should match the adapter property serializer shape.", + "additionalProperties": true + }, + "archived": { + "type": "boolean", + "description": "Whether to archive or restore the page." + }, + "icon": { + "type": "object", + "description": "Notion page icon object.", + "additionalProperties": true + }, + "cover": { + "type": "object", + "description": "Notion page cover object.", + "additionalProperties": true + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/notion/src/__tests__/emit-auxiliary-files.test.ts b/packages/notion/src/__tests__/emit-auxiliary-files.test.ts index d3fc0e8d..34856bf1 100644 --- a/packages/notion/src/__tests__/emit-auxiliary-files.test.ts +++ b/packages/notion/src/__tests__/emit-auxiliary-files.test.ts @@ -127,7 +127,7 @@ describe('emitNotionAuxiliaryFiles', () => { ); }); - it('writes canonical + by-id + by-title + by-database for a database-rooted page', async () => { + it('writes canonical + by-id + by-title + by-database + by-edited for a database-rooted page', async () => { const client = createClient(); const page = { id: PAGE_A, @@ -165,6 +165,15 @@ describe('emitNotionAuxiliaryFiles', () => { assert.equal(rows[0]!.title, 'Release Plan'); assert.equal(rows[0]!.parent_type, 'database'); assert.equal(rows[0]!.parent_id, DATABASE_A); + + const canonicalBytes = client.files.get(notionDatabasePagePath(DATABASE_A, PAGE_A)); + assert.equal(client.files.get(notionByIdAliasPath(PAGES_SCOPE, PAGE_A)), canonicalBytes); + assert.equal(client.files.get(notionByEditedAliasPath(PAGES_SCOPE, '2026-05-12', PAGE_A)), canonicalBytes); + assert.equal(client.files.get(notionByTitleAliasPath(PAGES_SCOPE, 'Release Plan', PAGE_A)), canonicalBytes); + assert.equal( + client.files.get(notionPageByDatabaseAliasPath(DATABASE_A, PAGE_A, 'Tasks', 'Release Plan')), + canonicalBytes, + ); }); it('writes by-parent alias for a page whose parent is another page', async () => { diff --git a/packages/notion/src/__tests__/layout-prompt.test.ts b/packages/notion/src/__tests__/layout-prompt.test.ts index 3ce438cc..df5db778 100644 --- a/packages/notion/src/__tests__/layout-prompt.test.ts +++ b/packages/notion/src/__tests__/layout-prompt.test.ts @@ -15,7 +15,10 @@ describe('notion layout prompt', () => { assert.match(file.content, /_index\.json/u); assert.match(file.content, /by-edited\/YYYY-MM-DD/u); assert.match(file.content, /discovery\/notion\/databases\/\{databaseId\}\/pages\/\.schema\.json/u); - assert.match(file.content, /discovery\/notion\/databases\/\{databaseId\}\/pages\/\.create\.example\.json/u); + assert.match(file.content, /discovery\/notion\/databases\/\{databaseId\}\/pages\/\{pageId\}\/content\.md\/\.schema\.json/u); + assert.match(file.content, /discovery\/notion\/pages\/\{pageId\}\/comments\.json\/\.schema\.json/u); + assert.match(file.content, /Every schema has a sibling `\.create\.example\.json` file\./u); assert.match(file.content, /ls \/notion\/pages\/by-edited\/2026-05-12/u); + assert.ok(file.content.trim().length > 0); }); }); diff --git a/packages/notion/src/__tests__/writeback-id-extraction.test.ts b/packages/notion/src/__tests__/writeback-id-extraction.test.ts index 6c4d884b..53605d8a 100644 --- a/packages/notion/src/__tests__/writeback-id-extraction.test.ts +++ b/packages/notion/src/__tests__/writeback-id-extraction.test.ts @@ -56,13 +56,14 @@ describe('notion writeback id extraction', () => { ); }); - it('passes synthetic ids (used in fixture tests) straight through', () => { - // `page-1` is not UUID-shaped; the API will validate. This preserves - // backwards-compatibility with the existing writeback test suite. - const req = resolveWritebackRequest( - '/notion/pages/page-1/content.md', - '# Body', + it('rejects synthetic ids on exact-file sidecars before dispatch', () => { + assert.throws( + () => resolveWritebackRequest('/notion/pages/page-1/content.md', '# Body'), + /No Notion writeback rule matched/, + ); + assert.throws( + () => resolveWritebackRequest('/notion/pages/page-1/comments.json', '"Body"'), + /No Notion writeback rule matched/, ); - assert.strictEqual(req.endpoint, '/v1/pages/page-1/markdown'); }); }); diff --git a/packages/notion/src/__tests__/writeback.test.ts b/packages/notion/src/__tests__/writeback.test.ts index f9c4fd5f..59509a06 100644 --- a/packages/notion/src/__tests__/writeback.test.ts +++ b/packages/notion/src/__tests__/writeback.test.ts @@ -1,7 +1,14 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { classifyWrite, executeFileNativeWriteback } from '@relayfile/adapter-core'; +import type { FileNativeWritebackRequest } from '@relayfile/adapter-core'; +import { resources } from '../resources.js'; import { ReadOnlyFieldError, resolveDeleteRequest, resolveWritebackRequest } from '../writeback.js'; +const PAGE_ONE = '00000000-0000-0000-0000-000000000001'; +const PAGE_TWO = '00000000-0000-0000-0000-000000000002'; +const PAGE_THREE = '00000000-0000-0000-0000-000000000003'; + describe('writeback rule matching', () => { it('maps page JSON to PATCH /v1/pages/{id}', () => { const request = resolveWritebackRequest( @@ -142,16 +149,115 @@ describe('writeback rule matching', () => { ); }); + it('allows page patches that only update archive state, icon, or cover', () => { + const archived = resolveWritebackRequest( + `/notion/pages/${PAGE_ONE}.json`, + JSON.stringify({ archived: true }), + ); + const icon = resolveWritebackRequest( + `/notion/databases/db-1/pages/${PAGE_TWO}/properties.json`, + JSON.stringify({ icon: { type: 'emoji', emoji: ':check:' } }), + ); + const cover = resolveWritebackRequest( + `/notion/pages/${PAGE_THREE}/properties.json`, + JSON.stringify({ cover: { type: 'external', external: { url: 'https://example.com/cover.png' } } }), + ); + + assert.deepStrictEqual(archived.body, { + properties: undefined, + archived: true, + icon: undefined, + cover: undefined, + }); + assert.deepStrictEqual(icon.body, { + properties: undefined, + archived: undefined, + icon: { type: 'emoji', emoji: ':check:' }, + cover: undefined, + }); + assert.deepStrictEqual(cover.body, { + properties: undefined, + archived: undefined, + icon: undefined, + cover: { type: 'external', external: { url: 'https://example.com/cover.png' } }, + }); + assert.throws( + () => resolveWritebackRequest(`/notion/pages/${PAGE_ONE}.json`, '{}'), + /must include properties, archived, icon, or cover/, + ); + }); + it('maps markdown and comments writeback paths', () => { - const markdown = resolveWritebackRequest('/notion/pages/page-1/content.md', '# Updated'); - const comment = resolveWritebackRequest('/notion/pages/page-1/comments.json', '"Looks good"'); + const markdown = resolveWritebackRequest(`/notion/pages/${PAGE_ONE}/content.md`, '# Updated'); + const markdownObject = resolveWritebackRequest( + `/notion/databases/db-1/pages/${PAGE_TWO}/content.md`, + '{"markdown":"# Updated from schema-shaped content"}', + ); + const comment = resolveWritebackRequest(`/notion/pages/${PAGE_ONE}/comments.json`, '"Looks good"'); + const markdownObjectBody = markdownObject.body as { + replace_content: { new_str: string }; + }; - assert.strictEqual(markdown.endpoint, '/v1/pages/page-1/markdown'); + assert.strictEqual(markdown.endpoint, `/v1/pages/${PAGE_ONE}/markdown`); + assert.deepStrictEqual(markdownObjectBody.replace_content.new_str, '# Updated from schema-shaped content'); assert.strictEqual(comment.endpoint, '/v1/comments'); assert.deepStrictEqual(comment.body, { - parent: { page_id: 'page-1' }, + parent: { page_id: PAGE_ONE }, rich_text: [{ type: 'text', text: { content: 'Looks good', link: null } }], }); + assert.throws( + () => resolveWritebackRequest(`/notion/pages/${PAGE_ONE}/comments.json`, '{}'), + /expects text or richText/, + ); + assert.throws( + () => resolveWritebackRequest(`/notion/pages/${PAGE_ONE}/comments.json`, '""'), + /expects a non-empty comment body/, + ); + }); + + it('rejects draft-like exact-file markdown and comments sidecars', () => { + for (const path of [ + '/notion/pages/draft-page/content.md', + '/notion/pages/draft-page/comments.json', + '/notion/databases/db-1/pages/draft-page/content.md', + '/notion/databases/db-1/pages/draft-page/comments.json', + ]) { + assert.equal(classifyWrite(path, resources), null, path); + assert.throws( + () => resolveWritebackRequest(path, path.endsWith('.md') ? '# Draft' : '"Draft"'), + /No Notion writeback rule matched/, + path, + ); + } + }); + + it('executes plain markdown writes through the generic file-native router', async () => { + const result = await executeFileNativeWriteback({ + path: `/notion/pages/${PAGE_THREE}/content.md`, + content: '# Updated from plain markdown', + resources, + loadSchema(resource) { + assert.equal(resource.schema, 'discovery/notion/pages/{pageId}/content.md/.schema.json'); + return { + type: 'object', + properties: { + markdown: { type: 'string' }, + }, + additionalProperties: false, + }; + }, + resolveWritebackRequest(path, content) { + return resolveWritebackRequest(path, content) as unknown as FileNativeWritebackRequest; + }, + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.route.kind, 'patch'); + assert.equal(result.request?.endpoint, `/v1/pages/${PAGE_THREE}/markdown`); + const body = result.request?.body as { replace_content: { new_str: string } }; + assert.equal(body.replace_content.new_str, '# Updated from plain markdown'); + } }); it('maps database page draft templates to page creation', () => { @@ -191,4 +297,26 @@ describe('writeback rule matching', () => { /No Notion delete writeback rule matched/, ); }); + + it('classifies Notion exact-file resources with the generic router', () => { + const create = classifyWrite('/notion/databases/db-1/pages/draft-page.json', resources); + assert.equal(create?.kind, 'create'); + assert.equal(create?.resource.schema, 'discovery/notion/databases/{databaseId}/pages/.schema.json'); + assert.equal(create?.id, 'draft-page'); + + const page = classifyWrite('/notion/databases/db-1/pages/00000000000000000000000000000001.json', resources); + assert.equal(page?.kind, 'patch'); + assert.equal(page?.resource.schema, 'discovery/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json'); + assert.equal(page?.id, '00000000000000000000000000000001'); + + const properties = classifyWrite('/notion/pages/00000000000000000000000000000002/properties.json', resources); + assert.equal(properties?.kind, 'patch'); + assert.equal(properties?.resource.schema, 'discovery/notion/pages/{pageId}/properties.json/.schema.json'); + assert.equal(properties?.id, '00000000000000000000000000000002'); + + const content = classifyWrite('/notion/pages/00000000000000000000000000000002/content.md', resources); + assert.equal(content?.kind, 'patch'); + assert.equal(content?.resource.schema, 'discovery/notion/pages/{pageId}/content.md/.schema.json'); + assert.equal(content?.id, '00000000000000000000000000000002'); + }); }); diff --git a/packages/notion/src/layout-prompt.ts b/packages/notion/src/layout-prompt.ts index 5c3ec557..78149f0e 100644 --- a/packages/notion/src/layout-prompt.ts +++ b/packages/notion/src/layout-prompt.ts @@ -149,9 +149,20 @@ property update payload to \`properties.json\` beside a page directory, such as \`/notion/pages/__/properties.json\` or \`/notion/databases//pages/__/properties.json\`. -Writable database page resources advertise sibling discovery files at -\`discovery/notion/databases/{databaseId}/pages/.schema.json\` and -\`discovery/notion/databases/{databaseId}/pages/.create.example.json\`. +Writable database page resources advertise sibling discovery files under: +- \`discovery/notion/databases/{databaseId}/pages/.schema.json\` +- \`discovery/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json\` +- \`discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json\` +- \`discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json\` +- \`discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json\` + +Standalone page resources advertise matching discovery files under: +- \`discovery/notion/pages/{pageId}.json/.schema.json\` +- \`discovery/notion/pages/{pageId}/properties.json/.schema.json\` +- \`discovery/notion/pages/{pageId}/content.md/.schema.json\` +- \`discovery/notion/pages/{pageId}/comments.json/.schema.json\` + +Every schema has a sibling \`.create.example.json\` file. ## Common commands diff --git a/packages/notion/src/layout.test.ts b/packages/notion/src/layout.test.ts index 7eee191a..370d69af 100644 --- a/packages/notion/src/layout.test.ts +++ b/packages/notion/src/layout.test.ts @@ -11,6 +11,26 @@ test('layoutManifest exposes Notion resources with canonical aliases and writeba assert.equal(manifest.provider, 'notion'); assert.deepEqual(manifest.aliasSegments, ['by-database', 'by-edited', 'by-id', 'by-name', 'by-parent', 'by-title']); assert.ok(manifest.resources.length > 0); + assert.deepEqual( + manifest.resources.find((resource) => resource.path === 'notion/pages')?.writebackResources, + [ + { path: 'notion/pages/*', schemaId: 'notion/page' }, + { path: 'notion/pages/*/properties', schemaId: 'notion/page-properties' }, + { path: 'notion/pages/*/content', schemaId: 'notion/page-content' }, + { path: 'notion/pages/*/comments', schemaId: 'notion/comment' }, + ], + ); + assert.deepEqual( + manifest.resources.find((resource) => resource.path === 'notion/databases')?.writebackResources, + [ + { path: 'notion/databases', schemaId: 'notion/database' }, + { path: 'notion/databases/*/pages', schemaId: 'notion/page' }, + { path: 'notion/databases/*/pages/*', schemaId: 'notion/page' }, + { path: 'notion/databases/*/pages/*/properties', schemaId: 'notion/page-properties' }, + { path: 'notion/databases/*/pages/*/content', schemaId: 'notion/page-content' }, + { path: 'notion/databases/*/pages/*/comments', schemaId: 'notion/comment' }, + ], + ); for (const resource of manifest.resources) { assert.ok(resource.path.startsWith('notion/')); diff --git a/packages/notion/src/layout.ts b/packages/notion/src/layout.ts index 4c877c73..5b532f06 100644 --- a/packages/notion/src/layout.ts +++ b/packages/notion/src/layout.ts @@ -19,8 +19,10 @@ export const layoutManifest: CoreLayoutManifestProvider = () => ({ materialization: 'eager', aliasSegments: ['by-id', 'by-title', 'by-database', 'by-parent', 'by-edited'], writebackResources: [ - { path: 'notion/pages', schemaId: 'notion/page' }, - { path: 'notion/pages/comments', schemaId: 'notion/comment' }, + { path: 'notion/pages/*', schemaId: 'notion/page' }, + { path: 'notion/pages/*/properties', schemaId: 'notion/page-properties' }, + { path: 'notion/pages/*/content', schemaId: 'notion/page-content' }, + { path: 'notion/pages/*/comments', schemaId: 'notion/comment' }, ], }, { @@ -30,6 +32,11 @@ export const layoutManifest: CoreLayoutManifestProvider = () => ({ aliasSegments: ['by-id', 'by-title'], writebackResources: [ { path: 'notion/databases', schemaId: 'notion/database' }, + { path: 'notion/databases/*/pages', schemaId: 'notion/page' }, + { path: 'notion/databases/*/pages/*', schemaId: 'notion/page' }, + { path: 'notion/databases/*/pages/*/properties', schemaId: 'notion/page-properties' }, + { path: 'notion/databases/*/pages/*/content', schemaId: 'notion/page-content' }, + { path: 'notion/databases/*/pages/*/comments', schemaId: 'notion/comment' }, ], }, { diff --git a/packages/notion/src/resources.ts b/packages/notion/src/resources.ts index b7a635e7..66ef0f40 100644 --- a/packages/notion/src/resources.ts +++ b/packages/notion/src/resources.ts @@ -16,6 +16,70 @@ export const resources = [ schema: "discovery/notion/databases/{databaseId}/pages/.schema.json", createExample: "discovery/notion/databases/{databaseId}/pages/.create.example.json", }, + { + name: "pages", + path: "/notion/databases/{databaseId}/pages/{pageId}.json", + pathPattern: /^\/notion\/databases\/[^\/]+\/pages\/[^\/]+\.json$/, + idPattern: /^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i, + schema: "discovery/notion/databases/{databaseId}/pages/{pageId}.json/.schema.json", + createExample: "discovery/notion/databases/{databaseId}/pages/{pageId}.json/.create.example.json", + }, + { + name: "properties", + path: "/notion/databases/{databaseId}/pages/{pageId}/properties.json", + pathPattern: /^\/notion\/databases\/[^\/]+\/pages\/[^\/]+\/properties\.json$/, + idPattern: /^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i, + schema: "discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.schema.json", + createExample: "discovery/notion/databases/{databaseId}/pages/{pageId}/properties.json/.create.example.json", + }, + { + name: "content", + path: "/notion/databases/{databaseId}/pages/{pageId}/content.md", + pathPattern: /^\/notion\/databases\/[^\/]+\/pages\/[^\/]+\/content\.md$/, + idPattern: /^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i, + schema: "discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.schema.json", + createExample: "discovery/notion/databases/{databaseId}/pages/{pageId}/content.md/.create.example.json", + }, + { + name: "comments", + path: "/notion/databases/{databaseId}/pages/{pageId}/comments.json", + pathPattern: /^\/notion\/databases\/[^\/]+\/pages\/[^\/]+\/comments\.json$/, + idPattern: /^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i, + schema: "discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.schema.json", + createExample: "discovery/notion/databases/{databaseId}/pages/{pageId}/comments.json/.create.example.json", + }, + { + name: "pages", + path: "/notion/pages/{pageId}.json", + pathPattern: /^\/notion\/pages\/[^\/]+\.json$/, + idPattern: /^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i, + schema: "discovery/notion/pages/{pageId}.json/.schema.json", + createExample: "discovery/notion/pages/{pageId}.json/.create.example.json", + }, + { + name: "properties", + path: "/notion/pages/{pageId}/properties.json", + pathPattern: /^\/notion\/pages\/[^\/]+\/properties\.json$/, + idPattern: /^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i, + schema: "discovery/notion/pages/{pageId}/properties.json/.schema.json", + createExample: "discovery/notion/pages/{pageId}/properties.json/.create.example.json", + }, + { + name: "content", + path: "/notion/pages/{pageId}/content.md", + pathPattern: /^\/notion\/pages\/[^\/]+\/content\.md$/, + idPattern: /^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i, + schema: "discovery/notion/pages/{pageId}/content.md/.schema.json", + createExample: "discovery/notion/pages/{pageId}/content.md/.create.example.json", + }, + { + name: "comments", + path: "/notion/pages/{pageId}/comments.json", + pathPattern: /^\/notion\/pages\/[^\/]+\/comments\.json$/, + idPattern: /^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i, + schema: "discovery/notion/pages/{pageId}/comments.json/.schema.json", + createExample: "discovery/notion/pages/{pageId}/comments.json/.create.example.json", + }, ] as const satisfies readonly AdapterResourceConfig[]; export function findResourceByPath(path: string): AdapterResourceConfig | undefined { diff --git a/packages/notion/src/writeback.ts b/packages/notion/src/writeback.ts index c3704683..899caadb 100644 --- a/packages/notion/src/writeback.ts +++ b/packages/notion/src/writeback.ts @@ -99,7 +99,7 @@ export function resolveWritebackRequest(path: string, content: string): NotionWr // Standalone pages: see resolveDeleteRequest for why classifyWrite returns // null for these paths and why the canonical-id gate substitutes for it. const standalonePageMatch = path.match(/^\/notion\/pages\/([^/]+)\.json$/); - if (route === null && standalonePageMatch && isCanonicalStandalonePageSegment(standalonePageMatch[1])) { + if ((route === null || route?.kind === 'patch' || route?.kind === 'create') && standalonePageMatch && isCanonicalStandalonePageSegment(standalonePageMatch[1])) { return buildPagePropertiesWriteback(extractNotionId(standalonePageMatch[1]), content); } @@ -114,22 +114,22 @@ export function resolveWritebackRequest(path: string, content: string): NotionWr } const databaseContentMatch = path.match(/^\/notion\/databases\/([^/]+)\/pages\/([^/]+)\/content\.md$/); - if (databaseContentMatch) { + if (databaseContentMatch && isCanonicalStandalonePageSegment(databaseContentMatch[2])) { return buildMarkdownWriteback(extractNotionId(databaseContentMatch[2]), content); } const standaloneContentMatch = path.match(/^\/notion\/pages\/([^/]+)\/content\.md$/); - if (standaloneContentMatch) { + if (standaloneContentMatch && isCanonicalStandalonePageSegment(standaloneContentMatch[1])) { return buildMarkdownWriteback(extractNotionId(standaloneContentMatch[1]), content); } const databaseCommentsMatch = path.match(/^\/notion\/databases\/([^/]+)\/pages\/([^/]+)\/comments\.json$/); - if (databaseCommentsMatch) { + if (databaseCommentsMatch && isCanonicalStandalonePageSegment(databaseCommentsMatch[2])) { return buildCommentWriteback(extractNotionId(databaseCommentsMatch[2]), content); } const standaloneCommentsMatch = path.match(/^\/notion\/pages\/([^/]+)\/comments\.json$/); - if (standaloneCommentsMatch) { + if (standaloneCommentsMatch && isCanonicalStandalonePageSegment(standaloneCommentsMatch[1])) { return buildCommentWriteback(extractNotionId(standaloneCommentsMatch[1]), content); } @@ -143,12 +143,11 @@ export function resolveDeleteRequest(path: string): NotionWritebackRequest { return buildArchivePageWriteback(extractNotionId(databasePageMatch[2])); } - // Standalone pages aren't declared in resources.ts (they share the parent - // `/notion/pages/...` namespace with markdown/comments resources), so - // `classifyWrite` returns null here. The canonical-id gate below replaces - // the route check used for database pages. + // Standalone pages may be classified when discovery resources are present. + // The canonical-id gate keeps aliases and create-like filenames out of the + // archive route. const standalonePageMatch = path.match(/^\/notion\/pages\/([^/]+)\.json$/); - if (route === null && standalonePageMatch?.[1] && isCanonicalStandalonePageSegment(standalonePageMatch[1])) { + if ((route === null || route?.kind === 'delete') && standalonePageMatch?.[1] && isCanonicalStandalonePageSegment(standalonePageMatch[1])) { return buildArchivePageWriteback(extractNotionId(standalonePageMatch[1])); } @@ -157,22 +156,27 @@ export function resolveDeleteRequest(path: string): NotionWritebackRequest { /** * Build a `PATCH /v1/pages/{id}` request to update a page's properties, - * archived flag, icon, or cover. The payload must include a `properties` - * object; everything else is optional. + * archived flag, icon, or cover. */ function buildPagePropertiesWriteback(pageId: string, content: string): NotionWritebackRequest { const payload = parseJson(content); rejectReadOnlyFields(payload); - const properties = extractSerializedProperties(payload); + const properties = extractOptionalSerializedProperties(payload); + const archived = readBoolean(payload, 'archived'); + const icon = readObject(payload, 'icon'); + const cover = readObject(payload, 'cover'); + if (properties === undefined && archived === undefined && icon === undefined && cover === undefined) { + throw new Error('Writeback payload must include properties, archived, icon, or cover'); + } return { action: 'update_page_properties', method: 'PATCH', endpoint: `/v1/pages/${encodeURIComponent(pageId)}`, body: { properties, - archived: readBoolean(payload, 'archived'), - icon: readObject(payload, 'icon'), - cover: readObject(payload, 'cover'), + archived, + icon, + cover, }, }; } @@ -183,6 +187,8 @@ function buildPagePropertiesWriteback(pageId: string, content: string): NotionWr * are not supported by this entrypoint. */ function buildMarkdownWriteback(pageId: string, markdown: string): NotionWritebackRequest { + const parsed = safeParseJson(markdown); + const body = isRecord(parsed) && typeof parsed.markdown === 'string' ? parsed.markdown : markdown; return { action: 'update_page_markdown', method: 'PATCH', @@ -191,7 +197,7 @@ function buildMarkdownWriteback(pageId: string, markdown: string): NotionWriteba body: { type: 'replace_content', replace_content: { - new_str: markdown, + new_str: body, allow_deleting_content: true, }, }, @@ -211,6 +217,9 @@ function buildMarkdownWriteback(pageId: string, markdown: string): NotionWriteba function buildCommentWriteback(pageId: string, content: string): NotionWritebackRequest { const parsed = safeParseJson(content); if (typeof parsed === 'string') { + if (!parsed.trim()) { + throw new Error('comments.json writeback expects a non-empty comment body'); + } return { action: 'create_comment', method: 'POST', @@ -231,6 +240,11 @@ function buildCommentWriteback(pageId: string, content: string): NotionWriteback if (!isRecord(comment)) { throw new Error('comments.json writeback expects a JSON object, JSON array, or plain string'); } + const richText = Array.isArray(comment.richText) && comment.richText.length > 0 ? comment.richText : undefined; + const text = typeof comment.text === 'string' && comment.text.trim() ? comment.text : undefined; + if (!richText && !text) { + throw new Error('comments.json writeback expects text or richText'); + } return { action: 'create_comment', @@ -240,14 +254,13 @@ function buildCommentWriteback(pageId: string, content: string): NotionWriteback parent: { page_id: pageId }, discussion_id: typeof comment.discussionId === 'string' ? comment.discussionId : undefined, rich_text: - Array.isArray(comment.richText) && comment.richText.length > 0 - ? comment.richText - : [ - { - type: 'text', - text: { content: typeof comment.text === 'string' ? comment.text : JSON.stringify(comment), link: null }, - }, - ], + richText ?? + [ + { + type: 'text', + text: { content: text, link: null }, + }, + ], }, }; } @@ -312,6 +325,13 @@ function extractSerializedProperties(payload: Record): Record): Record | undefined { + if (!Object.hasOwn(payload, 'properties')) { + return undefined; + } + return extractSerializedProperties(payload); +} + /** Parse `content` as a JSON object, throwing if it isn't an object. */ function parseJson(content: string): Record { const parsed = safeParseJson(content); diff --git a/scripts/digest-layout-contracts.mjs b/scripts/digest-layout-contracts.mjs index a51f733a..6bcd0d53 100644 --- a/scripts/digest-layout-contracts.mjs +++ b/scripts/digest-layout-contracts.mjs @@ -76,6 +76,44 @@ const categoryResourceContracts = [ }, ]; +const activitySummaryFallbackContracts = [ + { + provider: 'github', + resources: ['github/repos/*/*/issues', 'github/repos/*/*/pulls'], + layoutPrompt: 'src/layout-prompt.ts', + emissionTest: 'src/__tests__/emit-auxiliary-files.test.ts', + emissionNeedles: ['githubByEditedAliasPath', 'client.files.get(byEdited)', 'canonicalBytes'], + }, + { + provider: 'linear', + resources: ['linear/issues'], + layoutPrompt: 'src/layout-prompt.ts', + emissionTest: 'src/__tests__/emit-auxiliary-files.test.ts', + emissionNeedles: ['linearIssueByEditedPath', 'canonicalBytes', 'bytes mismatch'], + }, + { + provider: 'notion', + resources: ['notion/pages'], + layoutPrompt: 'src/layout-prompt.ts', + emissionTest: 'src/__tests__/emit-auxiliary-files.test.ts', + emissionNeedles: ['notionByEditedAliasPath', 'canonicalBytes', '2026-05-12'], + }, + { + provider: 'jira', + resources: ['jira/issues'], + layoutPrompt: 'src/layout-prompt.ts', + emissionTest: 'src/__tests__/emit-auxiliary-files.test.ts', + emissionNeedles: ['jiraIssueByEditedPath', 'canonicalBytes', 'bytes mismatch'], + }, + { + provider: 'confluence', + resources: ['confluence/pages'], + layoutPrompt: 'src/layout-prompt.ts', + emissionTest: 'src/__tests__/emit-auxiliary-files.test.ts', + emissionNeedles: ['confluencePageByEditedPath', 'canonicalBytes', 'bytes mismatch'], + }, +]; + const requiredDocs = [ { file: 'AGENTS.md', @@ -168,6 +206,7 @@ function main() { const failures = []; verifyNoProviderDigestHandlerContract(failures); +verifyActivitySummaryFallbackContracts(failures); for (const contract of categoryResourceContracts) { const layoutPath = join(packagesDir, contract.provider, 'src', 'layout.ts'); @@ -236,6 +275,7 @@ if (failures.length > 0) { console.log('Verified adapter metadata/layout contracts do not require provider digest handlers.'); console.log(`Verified ${categoryResourceContracts.length} category resource contracts.`); +console.log(`Verified ${activitySummaryFallbackContracts.length} activity-summary fallback contracts.`); console.log(`Verified ${executableRegressionContracts.length} executable regression contracts.`); } @@ -422,3 +462,43 @@ function verifyNoProviderDigestHandlerContract(failures) { } } } + +function verifyActivitySummaryFallbackContracts(failures) { + for (const contract of activitySummaryFallbackContracts) { + const layoutPath = join(packagesDir, contract.provider, 'src', 'layout.ts'); + if (!existsSync(layoutPath)) { + failures.push(`${contract.provider}: activity-summary fallback contract requires src/layout.ts`); + continue; + } + + const layoutSource = readFileSync(layoutPath, 'utf8'); + for (const resource of contract.resources) { + const aliases = aliasesForResource(layoutSource, resource); + if (!aliases) { + failures.push(`${contract.provider}: activity-summary fallback layout missing resource ${resource}`); + } else if (!aliases.includes('by-edited')) { + failures.push(`${contract.provider}: ${resource} missing by-edited for activity-summary fallback reads`); + } + } + + const promptPath = join(packagesDir, contract.provider, contract.layoutPrompt); + if (!existsSync(promptPath)) { + failures.push(`${contract.provider}: activity-summary fallback contract requires ${contract.layoutPrompt}`); + } else { + const promptSource = readFileSync(promptPath, 'utf8'); + if (!promptSource.includes('by-edited/YYYY-MM-DD')) { + failures.push(`${contract.provider}: ${contract.layoutPrompt} must document by-edited/YYYY-MM-DD`); + } + } + + const testPath = join(packagesDir, contract.provider, contract.emissionTest); + if (!existsSync(testPath)) { + failures.push(`${contract.provider}: activity-summary fallback contract requires ${contract.emissionTest}`); + } else { + const testSource = readFileSync(testPath, 'utf8'); + if (!activeRegressionContractSatisfied(testSource, contract.emissionNeedles)) { + failures.push(`${contract.provider}: ${contract.emissionTest} must assert by-edited alias emission resolves to the canonical record`); + } + } + } +} diff --git a/scripts/generate-writeback-discovery.mjs b/scripts/generate-writeback-discovery.mjs index c1ea3bd5..893ca69e 100644 --- a/scripts/generate-writeback-discovery.mjs +++ b/scripts/generate-writeback-discovery.mjs @@ -49,7 +49,7 @@ function renderAdapterReadme(adapter) { '', '| Resource | Schema | Create example | ID pattern | What it does |', '|---|---|---|---|---|', - ...resources.map((resource) => `| \`${resource.resourcePath}/.json\` | \`${resource.schemaPath}\` | \`${resource.examplePath}\` | \`${escapeMarkdownTableCell(resource.idPatternSource)}\` | ${resource.description} |`), + ...resources.map((resource) => `| \`${resourceWritePath(resource)}\` | \`${resource.schemaPath}\` | \`${resource.examplePath}\` | \`${escapeMarkdownTableCell(resource.idPatternSource)}\` | ${resource.description} |`), '', '## Operations', '', @@ -62,7 +62,7 @@ function renderAdapterReadme(adapter) { '| Delete | `rm .json` for canonical ids. |', '', '## ID Patterns', - ...resources.map((resource) => `- \`${resource.resourcePath}/.json\`: \`${resource.idPatternSource}\`. Filenames that do not match this pattern are treated as create drafts.`), + ...resources.map((resource) => renderIdPattern(resource)), '', '## Write field contracts', '', @@ -83,7 +83,7 @@ function renderEndpointContract(endpoint) { const lines = [ `### ${endpoint.schema.title}`, '', - `Resource: \`${resource.resourcePath}/.json\``, + `Resource: \`${resourceWritePath(resource)}\``, `Schema: \`${resource.schemaPath}\``, `Create example: \`${resource.examplePath}\``, `Required fields: ${required.size > 0 ? [...required].map((fieldName) => `\`${fieldName}\``).join(', ') : 'none at the top level'}.`, @@ -99,6 +99,20 @@ function renderEndpointContract(endpoint) { return lines; } +function resourceWritePath(resource) { + return /\.(?:json|md)$/u.test(resource.resourcePath) + ? resource.resourcePath + : `${resource.resourcePath}/.json`; +} + +function renderIdPattern(resource) { + const writePath = resourceWritePath(resource); + if (writePath === resource.resourcePath) { + return `- \`${writePath}\`: exact file path.`; + } + return `- \`${writePath}\`: \`${resource.idPatternSource}\`. Filenames that do not match this pattern are treated as create drafts.`; +} + function renderValidationNotes(schema) { const notes = []; const anyOfFields = describeRequiredBranches(schema.anyOf); diff --git a/scripts/verify-writeback-discovery.mjs b/scripts/verify-writeback-discovery.mjs index 9b19acb7..a2a9fa7d 100644 --- a/scripts/verify-writeback-discovery.mjs +++ b/scripts/verify-writeback-discovery.mjs @@ -33,9 +33,11 @@ for (const adapter of adapters) { const schemaPath = `${resourcePath}/.schema.json`; const examplePath = `${resourcePath}/.create.example.json`; - const legacySchemaFile = join(root, 'packages', adapter.slug, 'discovery', endpoint.path.replace(/new\.json$/, 'new.schema.json').slice(1)); - if (await fileExists(legacySchemaFile)) { - failures.push(`${adapter.slug}: legacy new.schema.json must be renamed to .schema.json: ${legacySchemaFile}`); + if (endpoint.path.endsWith('/new.json')) { + const legacySchemaFile = join(root, 'packages', adapter.slug, 'discovery', endpoint.path.replace(/new\.json$/, 'new.schema.json').slice(1)); + if (await fileExists(legacySchemaFile)) { + failures.push(`${adapter.slug}: legacy new.schema.json must be renamed to .schema.json: ${legacySchemaFile}`); + } } const schemaFile = join(root, 'packages', adapter.slug, 'discovery', schemaPath.slice(1)); @@ -58,6 +60,9 @@ for (const adapter of adapters) { if (!adapterMd.includes(`\`${schemaPath}\``) || !adapterMd.includes('## Operations') || !adapterMd.includes('## ID Patterns')) { failures.push(`${adapter.slug}: .adapter.md must list ${schemaPath} plus Operations and ID Patterns sections`); } + if (/\.(?:json|md)$/.test(resourcePath) && adapterMd.includes(`\`${resourcePath}/.json\``)) { + failures.push(`${adapter.slug}: .adapter.md must document exact-file resource ${resourcePath}, not ${resourcePath}/.json`); + } } } diff --git a/scripts/writeback-discovery-data.mjs b/scripts/writeback-discovery-data.mjs index f38e2270..f20f8e7c 100644 --- a/scripts/writeback-discovery-data.mjs +++ b/scripts/writeback-discovery-data.mjs @@ -151,16 +151,18 @@ export const adapters = [ slug: 'gitlab', title: 'GitLab adapter', overview: - 'The GitLab adapter exposes projects, merge requests, discussions, issues, commits, pipelines, and jobs under `/gitlab`, with writeback routes for merge request discussions and issue notes.', + 'The GitLab adapter exposes projects, merge requests, discussions, issues, commits, pipelines, jobs, deployments, and tags under `/gitlab`, with writeback routes for merge request discussions and issue notes.', readPaths: [ ['/gitlab/projects///merge_requests/__/meta.json', 'Merge request metadata.'], ['/gitlab/projects///merge_requests/__/discussions/.json', 'Merge request discussions.'], ['/gitlab/projects///issues/__/meta.json', 'Issue metadata.'], ['/gitlab/projects///pipelines/__/jobs/.json', 'Pipeline job records.'], + ['/gitlab/projects///deployments//meta.json', 'Deployment records.'], + ['/gitlab/projects///tags//meta.json', 'Tag records.'], ], endpoints: [ - endpoint('/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/new.json', 'Create GitLab merge request discussion', 'Creates a discussion on a merge request.', ['body'], gitlabNoteProps(), { body: 'Replace example discussion body.' }), - endpoint('/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/new.json', 'Create GitLab issue note', 'Creates a note on an issue.', ['body'], gitlabNoteProps(), { body: 'Replace example note body.' }), + endpoint('/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/new.json', 'Create GitLab merge request discussion', 'Creates a discussion on a merge request.', ['body'], gitlabDiscussionProps(), { body: 'Replace example discussion body.' }), + endpoint('/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/new.json', 'Create GitLab issue note', 'Creates a note on an issue.', ['body'], gitlabIssueNoteProps(), { body: 'Replace example note body.' }), ], }, { @@ -297,7 +299,7 @@ export const adapters = [ slug: 'notion', title: 'Notion adapter', overview: - 'The Notion adapter exposes databases, pages, page markdown, blocks, and comments under `/notion`, with writeback routes for creating database pages and updating page content.', + 'The Notion adapter exposes databases, pages, page markdown, blocks, and comments under `/notion`, with writeback routes for creating database pages, updating page properties/content, archiving pages, and creating page comments.', readPaths: [ ['/notion/databases//metadata.json', 'Database metadata.'], ['/notion/databases//pages/.json', 'Database page records.'], @@ -310,6 +312,30 @@ export const adapters = [ children: arr(obj('Notion block object.'), 'Optional child blocks for the new page.'), markdown: str('Optional markdown body. When present the adapter uses the Notion markdown API version.'), }, { properties: { Name: { type: 'title', value: 'Replace example page title' } } }), + endpoint('/notion/databases/{databaseId}/pages/{pageId}.json', 'Update Notion database page properties', 'Updates properties, archive state, icon, or cover for a page inside a Notion database.', [], notionPagePatchProps(), { + properties: { Status: { type: 'select', value: 'In progress' } }, + }, notionPagePatchRequirement()), + endpoint('/notion/databases/{databaseId}/pages/{pageId}/properties.json', 'Update Notion database page properties file', 'Updates properties, archive state, icon, or cover through a database page properties sidecar.', [], notionPagePatchProps(), { + properties: { Status: { type: 'select', value: 'In progress' } }, + }, notionPagePatchRequirement()), + endpoint('/notion/databases/{databaseId}/pages/{pageId}/content.md', 'Replace Notion database page markdown', 'Replaces the rendered markdown body for a page inside a Notion database.', [], { + markdown: str('Plain markdown body written to content.md.'), + }, { markdown: '# Replace page content' }), + endpoint('/notion/databases/{databaseId}/pages/{pageId}/comments.json', 'Create Notion database page comment', 'Creates a Notion comment on a page inside a Notion database from comments.json.', [], notionCommentProps(), { + text: 'Replace example comment body.', + }, notionCommentRequirement()), + endpoint('/notion/pages/{pageId}.json', 'Update Notion standalone page properties', 'Updates properties, archive state, icon, or cover for a standalone page.', [], notionPagePatchProps(), { + properties: { Status: { type: 'select', value: 'In progress' } }, + }, notionPagePatchRequirement()), + endpoint('/notion/pages/{pageId}/properties.json', 'Update Notion standalone page properties file', 'Updates properties, archive state, icon, or cover through a standalone page properties sidecar.', [], notionPagePatchProps(), { + properties: { Status: { type: 'select', value: 'In progress' } }, + }, notionPagePatchRequirement()), + endpoint('/notion/pages/{pageId}/content.md', 'Replace Notion standalone page markdown', 'Replaces the rendered markdown body for a standalone page.', [], { + markdown: str('Plain markdown body written to content.md.'), + }, { markdown: '# Replace page content' }), + endpoint('/notion/pages/{pageId}/comments.json', 'Create Notion standalone page comment', 'Creates a Notion comment on a standalone page from comments.json.', [], notionCommentProps(), { + text: 'Replace example comment body.', + }, notionCommentRequirement()), ], }, { @@ -751,14 +777,21 @@ function confluencePageProps(options = {}) { }; } -function gitlabNoteProps() { +function gitlabDiscussionProps() { return { - body: str('Markdown note body.'), + body: str('Markdown note body.', undefined, { minLength: 1, pattern: '.*\\S.*' }), position: obj('Optional GitLab position object for diff discussions.'), created_at: str('Optional timestamp for imports when supported by GitLab.', 'date-time'), }; } +function gitlabIssueNoteProps() { + return { + body: str('Markdown note body.', undefined, { minLength: 1, pattern: '.*\\S.*' }), + created_at: str('Optional timestamp for imports when supported by GitLab.', 'date-time'), + }; +} + function slackMessageProps() { return { text: str('Message text. Required unless blocks or attachments are supplied.'), @@ -940,6 +973,43 @@ function salesforceAccountProps() { }; } +function notionPagePatchProps() { + return { + properties: obj('Serialized Notion property map. Each property value should match the adapter property serializer shape.'), + archived: bool('Whether to archive or restore the page.'), + icon: obj('Notion page icon object.'), + cover: obj('Notion page cover object.'), + }; +} + +function notionPagePatchRequirement() { + return { + anyOf: [ + { required: ['properties'] }, + { required: ['archived'] }, + { required: ['icon'] }, + { required: ['cover'] }, + ], + }; +} + +function notionCommentProps() { + return { + text: str('Plain text comment body. A raw string body is also accepted by the resolver.', undefined, { minLength: 1, pattern: '.*\\S.*' }), + discussionId: str('Optional Notion discussion id to append to.'), + richText: { ...arr(obj('Notion rich_text object.'), 'Optional rich_text array.'), minItems: 1 }, + }; +} + +function notionCommentRequirement() { + return { + anyOf: [ + { required: ['text'] }, + { required: ['richText'] }, + ], + }; +} + function salesforceContactProps() { return { LastName: str('Contact last name.'), diff --git a/scripts/writeback-discovery-normalizer.mjs b/scripts/writeback-discovery-normalizer.mjs index f7a40061..d9283bdc 100644 --- a/scripts/writeback-discovery-normalizer.mjs +++ b/scripts/writeback-discovery-normalizer.mjs @@ -210,7 +210,12 @@ function resourceNameFor(adapterSlug, resourcePath) { if (adapterSlug === 'slack' && resourcePath.includes('/users/') && resourcePath.endsWith('/messages')) { return 'direct-messages'; } - return resourcePath.split('/').filter(Boolean).at(-1) ?? adapterSlug; + const last = resourcePath.split('/').filter(Boolean).at(-1); + if (!last) return adapterSlug; + if (/^\{[^}]+\}\.json$/u.test(last)) { + return resourcePath.split('/').filter(Boolean).at(-2) ?? adapterSlug; + } + return last.replace(/\.(?:json|md)$/u, ''); } function pathPatternSourceFor(adapterSlug, resourcePath) { @@ -231,8 +236,14 @@ function pathPatternSourceFor(adapterSlug, resourcePath) { if (/^\{[^}]+\}$/.test(segment)) { return '[^/]+'; } + if (segment.includes('{')) { + return escapeRegex(segment).replace(/\\\{[^}]+\\\}/g, '[^/]+'); + } return escapeRegex(segment); }); + if (/\.(?:json|md)$/u.test(resourcePath)) { + return `^/${resourceSegments.join('/')}$`; + } return `^/${resourceSegments.join('/')}(?:/[^/]+(?:\\.json)?)?$`; }